diff --git a/packages/skill/.claude/skills/understand-diff.md b/packages/skill/.claude/skills/understand-diff.md new file mode 100644 index 0000000..7d89ad9 --- /dev/null +++ b/packages/skill/.claude/skills/understand-diff.md @@ -0,0 +1,27 @@ +--- +name: understand-diff +description: Analyze current git diff or PR against the knowledge graph to identify changes, impact, and risks +--- + +# /understand-diff + +Analyze the current code changes against the knowledge graph at `.understand-anything/knowledge-graph.json`. + +## Instructions + +1. Read the knowledge graph file at `.understand-anything/knowledge-graph.json` in the current project root +2. If the file doesn't exist, tell the user to run `/understand` first +3. Get the current diff: + - If on a branch with uncommitted changes: `git diff --name-only` + - If on a feature branch: `git diff main...HEAD --name-only` (or the base branch) + - If the user specifies a PR number: get the diff from that PR +4. For each changed file, identify: + - Which nodes in the knowledge graph correspond to that file + - Which other nodes are connected (imports, calls, depends_on, etc.) + - Which architectural layers are affected +5. Provide a structured analysis: + - **Changed Components**: What was directly modified + - **Affected Components**: What might be impacted by the changes + - **Affected Layers**: Which architectural layers are touched + - **Risk Assessment**: Complexity, cross-layer impact, blast radius +6. Suggest what to review carefully and any potential issues diff --git a/packages/skill/src/__tests__/diff-analyzer.test.ts b/packages/skill/src/__tests__/diff-analyzer.test.ts new file mode 100644 index 0000000..0f13aa2 --- /dev/null +++ b/packages/skill/src/__tests__/diff-analyzer.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; +import { buildDiffContext, formatDiffAnalysis } from "../diff-analyzer.js"; +import type { KnowledgeGraph } from "@understand-anything/core"; + +const sampleGraph: KnowledgeGraph = { + version: "1.0.0", + project: { + name: "test-project", + languages: ["typescript"], + frameworks: ["express"], + description: "A test project", + analyzedAt: "2026-03-14T00:00:00Z", + gitCommitHash: "abc123", + }, + nodes: [ + { id: "file:src/index.ts", type: "file", name: "index.ts", filePath: "src/index.ts", summary: "Entry point", tags: ["entry"], complexity: "simple" }, + { id: "file:src/routes.ts", type: "file", name: "routes.ts", filePath: "src/routes.ts", summary: "Routes", tags: ["routes"], complexity: "moderate" }, + { id: "file:src/service.ts", type: "file", name: "service.ts", filePath: "src/service.ts", summary: "Service", tags: ["service"], complexity: "complex" }, + { id: "func:src/service.ts:process", type: "function", name: "process", filePath: "src/service.ts", lineRange: [10, 30], summary: "Process function", tags: ["core"], complexity: "complex" }, + { id: "file:src/db.ts", type: "file", name: "db.ts", filePath: "src/db.ts", summary: "Database", tags: ["db"], complexity: "simple" }, + ], + edges: [ + { source: "file:src/index.ts", target: "file:src/routes.ts", type: "imports", direction: "forward", weight: 0.9 }, + { source: "file:src/routes.ts", target: "file:src/service.ts", type: "calls", direction: "forward", weight: 0.8 }, + { source: "file:src/service.ts", target: "func:src/service.ts:process", type: "contains", direction: "forward", weight: 1.0 }, + { source: "file:src/service.ts", target: "file:src/db.ts", type: "reads_from", direction: "forward", weight: 0.7 }, + ], + layers: [ + { id: "layer:api", name: "API Layer", description: "HTTP routes", nodeIds: ["file:src/index.ts", "file:src/routes.ts"] }, + { id: "layer:service", name: "Service Layer", description: "Business logic", nodeIds: ["file:src/service.ts", "func:src/service.ts:process"] }, + { id: "layer:data", name: "Data Layer", description: "Database", nodeIds: ["file:src/db.ts"] }, + ], + tour: [], +}; + +describe("diff-analyzer", () => { + describe("buildDiffContext", () => { + it("identifies directly changed nodes", () => { + const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]); + expect(ctx.changedNodes.map((n) => n.id)).toContain("file:src/service.ts"); + }); + + it("identifies child nodes of changed files", () => { + const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]); + expect(ctx.changedNodes.map((n) => n.id)).toContain("func:src/service.ts:process"); + }); + + it("identifies affected nodes via edges (1-hop)", () => { + const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]); + expect(ctx.affectedNodes.map((n) => n.id)).toContain("file:src/routes.ts"); + expect(ctx.affectedNodes.map((n) => n.id)).toContain("file:src/db.ts"); + }); + + it("identifies affected layers", () => { + const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]); + expect(ctx.affectedLayers.map((l) => l.name)).toContain("Service Layer"); + }); + + it("identifies impacted edges", () => { + const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]); + expect(ctx.impactedEdges.length).toBeGreaterThan(0); + }); + + it("handles files not in the graph gracefully", () => { + const ctx = buildDiffContext(sampleGraph, ["src/unknown.ts"]); + expect(ctx.changedNodes).toHaveLength(0); + expect(ctx.unmappedFiles).toContain("src/unknown.ts"); + }); + + it("handles empty diff", () => { + const ctx = buildDiffContext(sampleGraph, []); + expect(ctx.changedNodes).toHaveLength(0); + expect(ctx.affectedNodes).toHaveLength(0); + }); + + it("de-duplicates affected nodes (not in changed set)", () => { + const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]); + const changedIds = new Set(ctx.changedNodes.map((n) => n.id)); + for (const affected of ctx.affectedNodes) { + expect(changedIds.has(affected.id)).toBe(false); + } + }); + }); + + describe("formatDiffAnalysis", () => { + it("produces structured markdown", () => { + const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]); + const analysis = formatDiffAnalysis(ctx); + expect(analysis).toContain("## Changed Components"); + expect(analysis).toContain("## Affected Components"); + expect(analysis).toContain("## Affected Layers"); + }); + + it("includes risk assessment section", () => { + const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]); + const analysis = formatDiffAnalysis(ctx); + expect(analysis).toContain("## Risk Assessment"); + }); + + it("lists unmapped files when present", () => { + const ctx = buildDiffContext(sampleGraph, ["src/unknown.ts"]); + const analysis = formatDiffAnalysis(ctx); + expect(analysis).toContain("src/unknown.ts"); + }); + }); +}); diff --git a/packages/skill/src/diff-analyzer.ts b/packages/skill/src/diff-analyzer.ts new file mode 100644 index 0000000..7920660 --- /dev/null +++ b/packages/skill/src/diff-analyzer.ts @@ -0,0 +1,198 @@ +import type { + KnowledgeGraph, + GraphNode, + GraphEdge, + Layer, +} from "@understand-anything/core"; + +export interface DiffContext { + projectName: string; + changedFiles: string[]; + changedNodes: GraphNode[]; + affectedNodes: GraphNode[]; + impactedEdges: GraphEdge[]; + affectedLayers: Layer[]; + unmappedFiles: string[]; +} + +/** + * Map a list of changed file paths to knowledge graph nodes and + * identify the ripple effect (affected nodes, layers, edges). + */ +export function buildDiffContext( + graph: KnowledgeGraph, + changedFiles: string[], +): DiffContext { + const { nodes, edges, layers } = graph; + + const changedNodeIds = new Set(); + const unmappedFiles: string[] = []; + + for (const file of changedFiles) { + let mapped = false; + for (const node of nodes) { + if (node.filePath === file) { + changedNodeIds.add(node.id); + mapped = true; + } + } + if (!mapped) { + unmappedFiles.push(file); + } + } + + // Also include "contains" children of changed file nodes + for (const edge of edges) { + if (edge.type === "contains" && changedNodeIds.has(edge.source)) { + changedNodeIds.add(edge.target); + } + } + + const changedNodes = nodes.filter((n) => changedNodeIds.has(n.id)); + + // Find affected nodes: 1-hop neighbors of changed nodes (excluding already changed) + const affectedNodeIds = new Set(); + const impactedEdges: GraphEdge[] = []; + + for (const edge of edges) { + const sourceChanged = changedNodeIds.has(edge.source); + const targetChanged = changedNodeIds.has(edge.target); + + if (sourceChanged || targetChanged) { + impactedEdges.push(edge); + if (sourceChanged && !changedNodeIds.has(edge.target)) { + affectedNodeIds.add(edge.target); + } + if (targetChanged && !changedNodeIds.has(edge.source)) { + affectedNodeIds.add(edge.source); + } + } + } + + const affectedNodes = nodes.filter((n) => affectedNodeIds.has(n.id)); + + const allImpactedIds = new Set([...changedNodeIds, ...affectedNodeIds]); + const affectedLayers = layers.filter((layer) => + layer.nodeIds.some((id) => allImpactedIds.has(id)), + ); + + return { + projectName: graph.project.name, + changedFiles, + changedNodes, + affectedNodes, + impactedEdges, + affectedLayers, + unmappedFiles, + }; +} + +/** + * Format the diff analysis as structured markdown for LLM or human consumption. + */ +export function formatDiffAnalysis(ctx: DiffContext): string { + const lines: string[] = []; + + lines.push(`# Diff Analysis: ${ctx.projectName}`); + lines.push(""); + + lines.push("## Changed Components"); + lines.push(""); + if (ctx.changedNodes.length === 0) { + lines.push("No mapped components found for changed files."); + } else { + for (const node of ctx.changedNodes) { + lines.push(`- **${node.name}** (${node.type}) — ${node.summary}`); + if (node.filePath) lines.push(` - File: \`${node.filePath}\``); + lines.push(` - Complexity: ${node.complexity}`); + } + } + lines.push(""); + + lines.push("## Affected Components"); + lines.push(""); + if (ctx.affectedNodes.length === 0) { + lines.push("No downstream impact detected."); + } else { + lines.push( + "These components are connected to changed code and may need attention:", + ); + lines.push(""); + for (const node of ctx.affectedNodes) { + lines.push(`- **${node.name}** (${node.type}) — ${node.summary}`); + } + } + lines.push(""); + + lines.push("## Affected Layers"); + lines.push(""); + if (ctx.affectedLayers.length === 0) { + lines.push("No layers affected."); + } else { + for (const layer of ctx.affectedLayers) { + lines.push(`- **${layer.name}**: ${layer.description}`); + } + } + lines.push(""); + + if (ctx.impactedEdges.length > 0) { + lines.push("## Impacted Relationships"); + lines.push(""); + for (const edge of ctx.impactedEdges) { + lines.push(`- ${edge.source} --[${edge.type}]--> ${edge.target}`); + } + lines.push(""); + } + + if (ctx.unmappedFiles.length > 0) { + lines.push("## Unmapped Files"); + lines.push(""); + lines.push("These changed files are not yet in the knowledge graph:"); + lines.push(""); + for (const f of ctx.unmappedFiles) { + lines.push(`- \`${f}\``); + } + lines.push(""); + } + + lines.push("## Risk Assessment"); + lines.push(""); + const complexChanges = ctx.changedNodes.filter( + (n) => n.complexity === "complex", + ); + const crossLayerCount = new Set(ctx.affectedLayers.map((l) => l.id)).size; + + if (complexChanges.length > 0) { + lines.push( + `- **High complexity**: ${complexChanges.length} complex component(s) changed: ${complexChanges.map((n) => n.name).join(", ")}`, + ); + } + if (crossLayerCount > 1) { + lines.push( + `- **Cross-layer impact**: Changes span ${crossLayerCount} architectural layers`, + ); + } + if (ctx.affectedNodes.length > 5) { + lines.push( + `- **Wide blast radius**: ${ctx.affectedNodes.length} components affected downstream`, + ); + } + if (ctx.unmappedFiles.length > 0) { + lines.push( + `- **New/unmapped files**: ${ctx.unmappedFiles.length} files not in the knowledge graph (may need re-analysis)`, + ); + } + if ( + complexChanges.length === 0 && + crossLayerCount <= 1 && + ctx.affectedNodes.length <= 5 && + ctx.unmappedFiles.length === 0 + ) { + lines.push( + "- **Low risk**: Changes are localized with limited downstream impact.", + ); + } + lines.push(""); + + return lines.join("\n"); +} diff --git a/packages/skill/src/index.ts b/packages/skill/src/index.ts index 96c3335..e953523 100644 --- a/packages/skill/src/index.ts +++ b/packages/skill/src/index.ts @@ -4,3 +4,8 @@ export { type ChatContext, } from "./context-builder.js"; export { buildChatPrompt } from "./understand-chat.js"; +export { + buildDiffContext, + formatDiffAnalysis, + type DiffContext, +} from "./diff-analyzer.js";