feat(skill): add /understand-diff command for PR/diff analysis

Adds buildDiffContext and formatDiffAnalysis to map git diffs against
the knowledge graph, identifying changed nodes, affected components,
impacted layers, and risk assessment. Includes 11 tests and skill
definition for Claude Code integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-03-14 22:13:41 +08:00
Unverified
parent 5628c2ed82
commit e69acffd06
4 changed files with 336 additions and 0 deletions
@@ -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
@@ -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");
});
});
});
+198
View File
@@ -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<string>();
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<string>();
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");
}
+5
View File
@@ -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";