Skip to content

Commit d2d767f

Browse files
feat: add Mermaid output to diff-impact command
Add --format mermaid flag to diff-impact for generating ready-to-use Mermaid flowcharts showing changed functions, transitive callers, and blast radius in a single command. - Enhance diffImpactData BFS to return depth-structured levels, edges, and newFiles detection (backward-compatible additions) - Add diffImpactMermaid function that builds flowchart TB diagrams with color-coded subgraphs (green=new, orange=modified, purple=blast radius) - Add --format option to CLI diff-impact command (text/mermaid/json) - Add format property to MCP diff_impact tool schema - Export diffImpactMermaid from index.js - Add unit tests for levels/edges, new file detection, and Mermaid output - Update llm-integration.md with PR impact graph roadmap Impact: 6 functions changed, 5 affected
1 parent 3ac5138 commit d2d767f

6 files changed

Lines changed: 377 additions & 10 deletions

File tree

docs/llm-integration.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,44 @@ Code changes → codegraph build (+ LLM enrichment) → SQLite DB with semantic
5555
- Returns: what changed, what's affected, risk assessment, suggested review focus areas
5656
- MCP tool: `review_diff <ref>` — structured review the consuming AI can relay to the user
5757

58+
#### "Show me a visual impact graph for this PR"
59+
- **Foundation (implemented):** `codegraph diff-impact <base> --format mermaid -T` generates a Mermaid flowchart showing changed functions, transitive callers, and blast radius — color-coded by new/modified/blast-radius
60+
- **CI automation:** GitHub Action that runs on every PR:
61+
1. `codegraph build .` (incremental, fast on CI cache)
62+
2. `codegraph diff-impact $BASE_REF --format mermaid -T` to generate the graph
63+
3. Post as a PR comment — GitHub renders Mermaid natively in markdown
64+
4. Update on new pushes (edit the existing comment)
65+
- **LLM-enriched annotations:** Overlay the graph with semantic context:
66+
- For each changed function: one-line summary of WHAT changed (from diff hunks)
67+
- For each affected caller: WHY it's affected — what behavior might change downstream
68+
- Risk labels per node: `low` (cosmetic / internal), `medium` (behavior change), `high` (breaking / public API)
69+
- Node colors shift from green → yellow → red based on risk, replacing the static new/modified styling
70+
- **Diff-aware narrative:** LLM reads the diff + graph and generates a structured PR summary:
71+
- "What changed and why it matters" per function
72+
- Potential breaking changes and side effects (from `side_effects` metadata)
73+
- Overall PR risk score (aggregate of node risks weighted by centrality)
74+
- **Review focus:** Prioritize reviewer attention:
75+
- Rank affected files by risk × blast radius — "review this file first"
76+
- Highlight critical paths: the shortest path from a changed function to a high-fan-in entry point
77+
- Flag test coverage gaps for affected code (cross-reference with test file graph edges)
78+
- **Historical context overlay:**
79+
- Annotate nodes with churn data: "this function changed 12 times in the last 30 days"
80+
- Highlight fragile nodes: high churn + high fan-in = high breakage risk
81+
- Track blast radius trends over time: "this PR's blast radius is 2× larger than your average"
82+
- **Interactive rendering (stretch):**
83+
- Render as SVG with clickable nodes linking to file:line in the PR diff view
84+
- Collapse/expand depth levels to manage large graphs
85+
- Filter by risk level or file path
86+
87+
**Infrastructure needed:**
88+
| What | Where | Depends on |
89+
|------|-------|------------|
90+
| GitHub Action workflow | `.github/workflows/impact-graph.yml` | `diff-impact --format mermaid` (done) |
91+
| LLM diff summarizer | `llm.js` + `queries.js` | LLM provider abstraction, `summaries` table |
92+
| Risk scoring per node | `nodes` table column | LLM assessment + graph centrality metrics |
93+
| Churn tracking | `metadata` table | Git log integration at build time |
94+
| SVG renderer | New module or external tool | Mermaid CLI (`mmdc`) or D3-based renderer |
95+
5896
---
5997

6098
### Refactoring Assistance

src/cli.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,13 +216,15 @@ program
216216
.option('--depth <n>', 'Max transitive caller depth', '3')
217217
.option('-T, --no-tests', 'Exclude test/spec files from results')
218218
.option('-j, --json', 'Output as JSON')
219+
.option('-f, --format <format>', 'Output format: text, mermaid, json', 'text')
219220
.action((ref, opts) => {
220221
diffImpact(opts.db, {
221222
ref,
222223
staged: opts.staged,
223224
depth: parseInt(opts.depth, 10),
224225
noTests: !opts.tests,
225226
json: opts.json,
227+
format: opts.format,
226228
});
227229
});
228230

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export {
4141
ALL_SYMBOL_KINDS,
4242
contextData,
4343
diffImpactData,
44+
diffImpactMermaid,
4445
explainData,
4546
FALSE_POSITIVE_CALLER_THRESHOLD,
4647
FALSE_POSITIVE_NAMES,

src/mcp.js

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import { createRequire } from 'node:module';
99
import { findCycles } from './cycles.js';
1010
import { findDbPath } from './db.js';
11-
import { ALL_SYMBOL_KINDS } from './queries.js';
11+
import { ALL_SYMBOL_KINDS, diffImpactMermaid } from './queries.js';
1212

1313
const REPO_PROP = {
1414
repo: {
@@ -201,6 +201,11 @@ const BASE_TOOLS = [
201201
ref: { type: 'string', description: 'Git ref to diff against (default: HEAD)' },
202202
depth: { type: 'number', description: 'Transitive caller depth', default: 3 },
203203
no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
204+
format: {
205+
type: 'string',
206+
enum: ['json', 'mermaid'],
207+
description: 'Output format (default: json)',
208+
},
204209
},
205210
},
206211
},
@@ -467,12 +472,21 @@ export async function startMCPServer(customDbPath, options = {}) {
467472
});
468473
break;
469474
case 'diff_impact':
470-
result = diffImpactData(dbPath, {
471-
staged: args.staged,
472-
ref: args.ref,
473-
depth: args.depth,
474-
noTests: args.no_tests,
475-
});
475+
if (args.format === 'mermaid') {
476+
result = diffImpactMermaid(dbPath, {
477+
staged: args.staged,
478+
ref: args.ref,
479+
depth: args.depth,
480+
noTests: args.no_tests,
481+
});
482+
} else {
483+
result = diffImpactData(dbPath, {
484+
staged: args.staged,
485+
ref: args.ref,
486+
depth: args.depth,
487+
noTests: args.no_tests,
488+
});
489+
}
476490
break;
477491
case 'semantic_search': {
478492
const { searchData } = await import('./embedder.js');

src/queries.js

Lines changed: 157 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -608,16 +608,34 @@ export function diffImpactData(customDbPath, opts = {}) {
608608

609609
if (!diffOutput.trim()) {
610610
db.close();
611-
return { changedFiles: 0, affectedFunctions: [], affectedFiles: [], summary: null };
611+
return {
612+
changedFiles: 0,
613+
newFiles: [],
614+
affectedFunctions: [],
615+
affectedFiles: [],
616+
summary: null,
617+
};
612618
}
613619

614620
const changedRanges = new Map();
621+
const newFiles = new Set();
615622
let currentFile = null;
623+
let prevIsDevNull = false;
616624
for (const line of diffOutput.split('\n')) {
625+
if (line.startsWith('--- /dev/null')) {
626+
prevIsDevNull = true;
627+
continue;
628+
}
629+
if (line.startsWith('--- ')) {
630+
prevIsDevNull = false;
631+
continue;
632+
}
617633
const fileMatch = line.match(/^\+\+\+ b\/(.+)/);
618634
if (fileMatch) {
619635
currentFile = fileMatch[1];
620636
if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []);
637+
if (prevIsDevNull) newFiles.add(currentFile);
638+
prevIsDevNull = false;
621639
continue;
622640
}
623641
const hunkMatch = line.match(/^@@ .+ \+(\d+)(?:,(\d+))? @@/);
@@ -630,7 +648,13 @@ export function diffImpactData(customDbPath, opts = {}) {
630648

631649
if (changedRanges.size === 0) {
632650
db.close();
633-
return { changedFiles: 0, affectedFunctions: [], affectedFiles: [], summary: null };
651+
return {
652+
changedFiles: 0,
653+
newFiles: [],
654+
affectedFunctions: [],
655+
affectedFiles: [],
656+
summary: null,
657+
};
634658
}
635659

636660
const affectedFunctions = [];
@@ -658,6 +682,10 @@ export function diffImpactData(customDbPath, opts = {}) {
658682
const visited = new Set([fn.id]);
659683
let frontier = [fn.id];
660684
let totalCallers = 0;
685+
const levels = {};
686+
const edges = [];
687+
const idToKey = new Map();
688+
idToKey.set(fn.id, `${fn.file}::${fn.name}:${fn.line}`);
661689
for (let d = 1; d <= maxDepth; d++) {
662690
const nextFrontier = [];
663691
for (const fid of frontier) {
@@ -673,6 +701,11 @@ export function diffImpactData(customDbPath, opts = {}) {
673701
visited.add(c.id);
674702
nextFrontier.push(c.id);
675703
allAffected.add(`${c.file}:${c.name}`);
704+
const callerKey = `${c.file}::${c.name}:${c.line}`;
705+
idToKey.set(c.id, callerKey);
706+
if (!levels[d]) levels[d] = [];
707+
levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line });
708+
edges.push({ from: idToKey.get(fid), to: callerKey });
676709
totalCallers++;
677710
}
678711
}
@@ -686,6 +719,8 @@ export function diffImpactData(customDbPath, opts = {}) {
686719
file: fn.file,
687720
line: fn.line,
688721
transitiveCallers: totalCallers,
722+
levels,
723+
edges,
689724
};
690725
});
691726

@@ -695,6 +730,7 @@ export function diffImpactData(customDbPath, opts = {}) {
695730
db.close();
696731
return {
697732
changedFiles: changedRanges.size,
733+
newFiles: [...newFiles],
698734
affectedFunctions: functionResults,
699735
affectedFiles: [...affectedFiles],
700736
summary: {
@@ -705,6 +741,120 @@ export function diffImpactData(customDbPath, opts = {}) {
705741
};
706742
}
707743

744+
export function diffImpactMermaid(customDbPath, opts = {}) {
745+
const data = diffImpactData(customDbPath, opts);
746+
if (data.error) return data.error;
747+
if (data.changedFiles === 0 || data.affectedFunctions.length === 0) {
748+
return 'flowchart TB\n none["No impacted functions detected"]';
749+
}
750+
751+
const newFileSet = new Set(data.newFiles || []);
752+
const lines = ['flowchart TB'];
753+
754+
// Assign stable Mermaid node IDs
755+
let nodeCounter = 0;
756+
const nodeIdMap = new Map();
757+
const nodeLabels = new Map();
758+
function nodeId(key, label) {
759+
if (!nodeIdMap.has(key)) {
760+
nodeIdMap.set(key, `n${nodeCounter++}`);
761+
if (label) nodeLabels.set(key, label);
762+
}
763+
return nodeIdMap.get(key);
764+
}
765+
766+
// Register all nodes (changed functions + their callers)
767+
for (const fn of data.affectedFunctions) {
768+
nodeId(`${fn.file}::${fn.name}:${fn.line}`, fn.name);
769+
for (const callers of Object.values(fn.levels || {})) {
770+
for (const c of callers) {
771+
nodeId(`${c.file}::${c.name}:${c.line}`, c.name);
772+
}
773+
}
774+
}
775+
776+
// Collect all edges and determine blast radius
777+
const allEdges = new Set();
778+
const edgeFromNodes = new Set();
779+
const edgeToNodes = new Set();
780+
const changedKeys = new Set();
781+
782+
for (const fn of data.affectedFunctions) {
783+
changedKeys.add(`${fn.file}::${fn.name}:${fn.line}`);
784+
for (const edge of fn.edges || []) {
785+
const edgeKey = `${edge.from}|${edge.to}`;
786+
if (!allEdges.has(edgeKey)) {
787+
allEdges.add(edgeKey);
788+
edgeFromNodes.add(edge.from);
789+
edgeToNodes.add(edge.to);
790+
}
791+
}
792+
}
793+
794+
// Blast radius: caller nodes that are never a source (leaf nodes of the impact tree)
795+
const blastRadiusKeys = new Set();
796+
for (const key of edgeToNodes) {
797+
if (!edgeFromNodes.has(key) && !changedKeys.has(key)) {
798+
blastRadiusKeys.add(key);
799+
}
800+
}
801+
802+
// Intermediate callers: not changed, not blast radius
803+
const intermediateKeys = new Set();
804+
for (const key of edgeToNodes) {
805+
if (!changedKeys.has(key) && !blastRadiusKeys.has(key)) {
806+
intermediateKeys.add(key);
807+
}
808+
}
809+
810+
// Group changed functions by file
811+
const fileGroups = new Map();
812+
for (const fn of data.affectedFunctions) {
813+
if (!fileGroups.has(fn.file)) fileGroups.set(fn.file, []);
814+
fileGroups.get(fn.file).push(fn);
815+
}
816+
817+
// Emit changed-file subgraphs
818+
let sgCounter = 0;
819+
for (const [file, fns] of fileGroups) {
820+
const isNew = newFileSet.has(file);
821+
const tag = isNew ? 'new' : 'modified';
822+
const sgId = `sg${sgCounter++}`;
823+
lines.push(` subgraph ${sgId}["${file} **(${tag})**"]`);
824+
for (const fn of fns) {
825+
const key = `${fn.file}::${fn.name}:${fn.line}`;
826+
lines.push(` ${nodeIdMap.get(key)}["${fn.name}"]`);
827+
}
828+
lines.push(' end');
829+
const style = isNew ? 'fill:#e8f5e9,stroke:#4caf50' : 'fill:#fff3e0,stroke:#ff9800';
830+
lines.push(` style ${sgId} ${style}`);
831+
}
832+
833+
// Emit intermediate caller nodes (outside subgraphs)
834+
for (const key of intermediateKeys) {
835+
lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`);
836+
}
837+
838+
// Emit blast radius subgraph
839+
if (blastRadiusKeys.size > 0) {
840+
const sgId = `sg${sgCounter++}`;
841+
lines.push(` subgraph ${sgId}["Callers **(blast radius)**"]`);
842+
for (const key of blastRadiusKeys) {
843+
lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`);
844+
}
845+
lines.push(' end');
846+
lines.push(` style ${sgId} fill:#f3e5f5,stroke:#9c27b0`);
847+
}
848+
849+
// Emit edges (impact flows from changed fn toward callers)
850+
for (const edgeKey of allEdges) {
851+
const [from, to] = edgeKey.split('|');
852+
lines.push(` ${nodeIdMap.get(from)} --> ${nodeIdMap.get(to)}`);
853+
}
854+
855+
return lines.join('\n');
856+
}
857+
708858
export function listFunctionsData(customDbPath, opts = {}) {
709859
const db = openReadonlyOrFail(customDbPath);
710860
const noTests = opts.noTests || false;
@@ -2079,8 +2229,12 @@ export function fnImpact(name, customDbPath, opts = {}) {
20792229
}
20802230

20812231
export function diffImpact(customDbPath, opts = {}) {
2232+
if (opts.format === 'mermaid') {
2233+
console.log(diffImpactMermaid(customDbPath, opts));
2234+
return;
2235+
}
20822236
const data = diffImpactData(customDbPath, opts);
2083-
if (opts.json) {
2237+
if (opts.json || opts.format === 'json') {
20842238
console.log(JSON.stringify(data, null, 2));
20852239
return;
20862240
}

0 commit comments

Comments
 (0)