diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c944642f..3bfd1dfb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,8 +22,8 @@ jobs: preflight: name: Preflight checks runs-on: ubuntu-latest - # Skip dev publish when the push is a stable release version bump - if: "${{ github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore: release v') }}" + # Skip dev publish when the push is a stable release version bump (direct push or merged PR) + if: "${{ github.event_name != 'push' || (!startsWith(github.event.head_commit.message, 'chore: release v') && !contains(github.event.head_commit.message, 'release/v')) }}" permissions: contents: read steps: diff --git a/docs/llm-integration.md b/docs/llm-integration.md new file mode 100644 index 00000000..2595810e --- /dev/null +++ b/docs/llm-integration.md @@ -0,0 +1,175 @@ +# LLM Integration — Feature Planning + +> **Core principle:** Compute once at build time, serve compressed at query time. The graph tells you what's connected, the LLM tells you what it means, and the consuming AI gets both without reading raw code. + +## Architecture + +Two layers: + +1. **Build-time LLM enrichment** — during `codegraph build`, an LLM annotates each function/class with semantic metadata (summaries, purpose, side effects, etc.) and stores it in the graph DB. +2. **Query-time token savings** — the consuming AI model (via MCP) gets pre-digested context instead of raw source code. + +``` +Code changes → codegraph build (+ LLM enrichment) → SQLite DB with semantic metadata + ↓ + AI model queries via MCP + ↓ + Gets structured summaries, + not raw code → saves tokens +``` + +--- + +## Features by Category + +### Understanding & Documentation + +#### "What problem does this function solve?" +- `summaries` table — LLM-generated one-liner per node, stored at build time +- MCP tool: `explain_purpose ` — returns summary + caller context ("it's called by X to do Y") + +#### "Summarize this module in plain English" +- Module-level rollup summaries — aggregate function summaries + dependency direction into a module narrative +- MCP tool: `explain_module ` — returns module purpose, key exports, role in the system + +#### "Auto-generate meaningful docstrings" +- `docstrings` column on nodes — LLM-generated, aware of callers/callees/types +- CLI command: `codegraph annotate` — generates or updates docstrings for changed functions +- Diff-aware: only regenerate for functions whose code or dependencies changed + +--- + +### Code Review & Quality + +#### "Is this function doing too much?" +- `complexity_notes` column — LLM assessment stored at build time: responsibility count, cohesion rating +- Graph metrics feed into the assessment: fan-in, fan-out, edge count +- MCP tool: `assess ` — returns complexity rating + specific concerns + +#### "Are there naming inconsistencies?" +- `naming_conventions` metadata per module — detected patterns (camelCase, snake_case, verb-first, etc.) +- CLI command: `codegraph lint-names` — LLM compares names against detected conventions, flags outliers + +#### "Smart PR review" +- `diff-review` command — takes a diff, walks the graph for affected nodes, fetches their summaries +- Returns: what changed, what's affected, risk assessment, suggested review focus areas +- MCP tool: `review_diff ` — structured review the consuming AI can relay to the user + +#### "Show me a visual impact graph for this PR" +- **Foundation (implemented):** `codegraph diff-impact --format mermaid -T` generates a Mermaid flowchart showing changed functions, transitive callers, and blast radius — color-coded by new/modified/blast-radius +- **CI automation:** GitHub Action that runs on every PR: + 1. `codegraph build .` (incremental, fast on CI cache) + 2. `codegraph diff-impact $BASE_REF --format mermaid -T` to generate the graph + 3. Post as a PR comment — GitHub renders Mermaid natively in markdown + 4. Update on new pushes (edit the existing comment) +- **LLM-enriched annotations:** Overlay the graph with semantic context: + - For each changed function: one-line summary of WHAT changed (from diff hunks) + - For each affected caller: WHY it's affected — what behavior might change downstream + - Risk labels per node: `low` (cosmetic / internal), `medium` (behavior change), `high` (breaking / public API) + - Node colors shift from green → yellow → red based on risk, replacing the static new/modified styling +- **Diff-aware narrative:** LLM reads the diff + graph and generates a structured PR summary: + - "What changed and why it matters" per function + - Potential breaking changes and side effects (from `side_effects` metadata) + - Overall PR risk score (aggregate of node risks weighted by centrality) +- **Review focus:** Prioritize reviewer attention: + - Rank affected files by risk × blast radius — "review this file first" + - Highlight critical paths: the shortest path from a changed function to a high-fan-in entry point + - Flag test coverage gaps for affected code (cross-reference with test file graph edges) +- **Historical context overlay:** + - Annotate nodes with churn data: "this function changed 12 times in the last 30 days" + - Highlight fragile nodes: high churn + high fan-in = high breakage risk + - Track blast radius trends over time: "this PR's blast radius is 2× larger than your average" +- **Interactive rendering (stretch):** + - Render as SVG with clickable nodes linking to file:line in the PR diff view + - Collapse/expand depth levels to manage large graphs + - Filter by risk level or file path + +**Infrastructure needed:** +| What | Where | Depends on | +|------|-------|------------| +| GitHub Action workflow | `.github/workflows/impact-graph.yml` | `diff-impact --format mermaid` (done) | +| LLM diff summarizer | `llm.js` + `queries.js` | LLM provider abstraction, `summaries` table | +| Risk scoring per node | `nodes` table column | LLM assessment + graph centrality metrics | +| Churn tracking | `metadata` table | Git log integration at build time | +| SVG renderer | New module or external tool | Mermaid CLI (`mmdc`) or D3-based renderer | + +--- + +### Refactoring Assistance + +#### "Can I safely split this file?" +- `split_analysis ` — graph identifies clusters of tightly-coupled functions within the file, LLM suggests groupings +- Returns: proposed split, edges that would cross file boundaries, risk of circular imports + +#### "Which functions are extraction candidates?" +- `extraction_candidates` query — find functions called from multiple modules (high fan-in, low internal coupling) +- LLM ranks them by utility: "this is a pure helper" vs "this has side effects, risky to move" + +#### "Suggest backward-compatible signature change" +- `signature_impact ` — graph provides all call sites, LLM reads each one +- Returns: suggested new signature, adapter pattern if needed, list of call sites that need updating + +--- + +### Architecture & Design + +#### "Why does module A depend on module B?" +- `dependency_path ` — graph finds shortest path(s), LLM narrates each hop +- Returns: "A imports X from B because A needs to validate tokens, and B owns the token schema" + +#### "What's the most fragile part of the codebase?" +- `fragility_report` — combines graph metrics (high fan-in + high fan-out + on many paths) with LLM reasoning +- `risk_score` column per node — computed at build time from graph centrality + LLM complexity assessment +- CLI command: `codegraph hotspots` — ranked list of riskiest nodes with explanations + +#### "Suggest better module boundaries" +- `boundary_analysis` — graph clustering algorithm identifies tightly-coupled groups that span modules +- LLM suggests reorganization: "these 4 functions in 3 different files all deal with auth, consider consolidating" + +--- + +### Onboarding & Navigation + +#### "Where should I start reading?" +- `entry_points` query — graph finds roots (high fan-out, low fan-in) + LLM ranks by importance +- `onboarding_guide` command — generates a reading order based on dependency layers +- MCP tool: `get_started` — returns ordered list: "start here, then read this, then this" + +#### "What's the flow when a user clicks submit?" +- `trace_flow ` — graph walks the call chain, LLM narrates each step +- Returns sequential narrative: "1. handler validates input → 2. calls createOrder → 3. writes to DB → 4. emits event" +- `flow_narratives` table — pre-computed for key entry points at build time + +#### "What would I need to change to add feature X?" +- `change_plan ` — LLM reads the description, graph identifies relevant modules, LLM maps out touch points +- Returns: files to modify, functions to change, new functions needed, test coverage gaps + +--- + +### Bug Investigation + +#### "What upstream functions could cause this bug?" +- `trace_upstream ` — graph walks callers recursively, LLM reads each and flags suspects +- `side_effects` column per node — pre-computed: "mutates state", "writes DB", "calls external service" +- Returns ranked list: "most likely cause is X because it modifies the same state" + +#### "What are the side effects of calling this function?" +- `effect_analysis ` — graph walks the full callee tree, aggregates `side_effects` from every descendant +- Returns: "calling X will: write to DB (via Y), send email (via Z), log to file (via W)" +- Pre-computed at build time, invalidated when any descendant changes + +--- + +## New Infrastructure Required + +| What | Where | When computed | +|------|-------|---------------| +| `summaries` — one-line purpose per node | `nodes` table column | Build time, incremental | +| `side_effects` — mutation/IO tags | `nodes` table column | Build time, incremental | +| `complexity_notes` — risk assessment | `nodes` table column | Build time, incremental | +| `risk_score` — fragility metric | `nodes` table column | Build time, from graph + LLM | +| `flow_narratives` — traced call stories | New table | Build time for entry points | +| `module_summaries` — file-level rollups | New table | Build time, re-rolled on change | +| `naming_conventions` — detected patterns | Metadata table | Build time per module | +| LLM provider abstraction | `llm.js` | Config: local/API/none | +| Cascade invalidation | `builder.js` | When a node changes, mark dependents for re-enrichment | diff --git a/src/cli.js b/src/cli.js index 12d6de09..f15e1d02 100644 --- a/src/cli.js +++ b/src/cli.js @@ -216,6 +216,7 @@ program .option('--depth ', 'Max transitive caller depth', '3') .option('-T, --no-tests', 'Exclude test/spec files from results') .option('-j, --json', 'Output as JSON') + .option('-f, --format ', 'Output format: text, mermaid, json', 'text') .action((ref, opts) => { diffImpact(opts.db, { ref, @@ -223,6 +224,7 @@ program depth: parseInt(opts.depth, 10), noTests: !opts.tests, json: opts.json, + format: opts.format, }); }); diff --git a/src/index.js b/src/index.js index fe8c321d..a0caf3b4 100644 --- a/src/index.js +++ b/src/index.js @@ -41,6 +41,7 @@ export { ALL_SYMBOL_KINDS, contextData, diffImpactData, + diffImpactMermaid, explainData, FALSE_POSITIVE_CALLER_THRESHOLD, FALSE_POSITIVE_NAMES, diff --git a/src/mcp.js b/src/mcp.js index d16a6033..83ab1f90 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -8,7 +8,7 @@ import { createRequire } from 'node:module'; import { findCycles } from './cycles.js'; import { findDbPath } from './db.js'; -import { ALL_SYMBOL_KINDS } from './queries.js'; +import { ALL_SYMBOL_KINDS, diffImpactMermaid } from './queries.js'; const REPO_PROP = { repo: { @@ -201,6 +201,11 @@ const BASE_TOOLS = [ ref: { type: 'string', description: 'Git ref to diff against (default: HEAD)' }, depth: { type: 'number', description: 'Transitive caller depth', default: 3 }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + format: { + type: 'string', + enum: ['json', 'mermaid'], + description: 'Output format (default: json)', + }, }, }, }, @@ -467,12 +472,21 @@ export async function startMCPServer(customDbPath, options = {}) { }); break; case 'diff_impact': - result = diffImpactData(dbPath, { - staged: args.staged, - ref: args.ref, - depth: args.depth, - noTests: args.no_tests, - }); + if (args.format === 'mermaid') { + result = diffImpactMermaid(dbPath, { + staged: args.staged, + ref: args.ref, + depth: args.depth, + noTests: args.no_tests, + }); + } else { + result = diffImpactData(dbPath, { + staged: args.staged, + ref: args.ref, + depth: args.depth, + noTests: args.no_tests, + }); + } break; case 'semantic_search': { const { searchData } = await import('./embedder.js'); diff --git a/src/queries.js b/src/queries.js index e1cd31a5..4fb71292 100644 --- a/src/queries.js +++ b/src/queries.js @@ -608,16 +608,34 @@ export function diffImpactData(customDbPath, opts = {}) { if (!diffOutput.trim()) { db.close(); - return { changedFiles: 0, affectedFunctions: [], affectedFiles: [], summary: null }; + return { + changedFiles: 0, + newFiles: [], + affectedFunctions: [], + affectedFiles: [], + summary: null, + }; } const changedRanges = new Map(); + const newFiles = new Set(); let currentFile = null; + let prevIsDevNull = false; for (const line of diffOutput.split('\n')) { + if (line.startsWith('--- /dev/null')) { + prevIsDevNull = true; + continue; + } + if (line.startsWith('--- ')) { + prevIsDevNull = false; + continue; + } const fileMatch = line.match(/^\+\+\+ b\/(.+)/); if (fileMatch) { currentFile = fileMatch[1]; if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []); + if (prevIsDevNull) newFiles.add(currentFile); + prevIsDevNull = false; continue; } const hunkMatch = line.match(/^@@ .+ \+(\d+)(?:,(\d+))? @@/); @@ -630,7 +648,13 @@ export function diffImpactData(customDbPath, opts = {}) { if (changedRanges.size === 0) { db.close(); - return { changedFiles: 0, affectedFunctions: [], affectedFiles: [], summary: null }; + return { + changedFiles: 0, + newFiles: [], + affectedFunctions: [], + affectedFiles: [], + summary: null, + }; } const affectedFunctions = []; @@ -658,6 +682,10 @@ export function diffImpactData(customDbPath, opts = {}) { const visited = new Set([fn.id]); let frontier = [fn.id]; let totalCallers = 0; + const levels = {}; + const edges = []; + const idToKey = new Map(); + idToKey.set(fn.id, `${fn.file}::${fn.name}:${fn.line}`); for (let d = 1; d <= maxDepth; d++) { const nextFrontier = []; for (const fid of frontier) { @@ -673,6 +701,11 @@ export function diffImpactData(customDbPath, opts = {}) { visited.add(c.id); nextFrontier.push(c.id); allAffected.add(`${c.file}:${c.name}`); + const callerKey = `${c.file}::${c.name}:${c.line}`; + idToKey.set(c.id, callerKey); + if (!levels[d]) levels[d] = []; + levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line }); + edges.push({ from: idToKey.get(fid), to: callerKey }); totalCallers++; } } @@ -686,6 +719,8 @@ export function diffImpactData(customDbPath, opts = {}) { file: fn.file, line: fn.line, transitiveCallers: totalCallers, + levels, + edges, }; }); @@ -695,6 +730,7 @@ export function diffImpactData(customDbPath, opts = {}) { db.close(); return { changedFiles: changedRanges.size, + newFiles: [...newFiles], affectedFunctions: functionResults, affectedFiles: [...affectedFiles], summary: { @@ -705,6 +741,120 @@ export function diffImpactData(customDbPath, opts = {}) { }; } +export function diffImpactMermaid(customDbPath, opts = {}) { + const data = diffImpactData(customDbPath, opts); + if (data.error) return data.error; + if (data.changedFiles === 0 || data.affectedFunctions.length === 0) { + return 'flowchart TB\n none["No impacted functions detected"]'; + } + + const newFileSet = new Set(data.newFiles || []); + const lines = ['flowchart TB']; + + // Assign stable Mermaid node IDs + let nodeCounter = 0; + const nodeIdMap = new Map(); + const nodeLabels = new Map(); + function nodeId(key, label) { + if (!nodeIdMap.has(key)) { + nodeIdMap.set(key, `n${nodeCounter++}`); + if (label) nodeLabels.set(key, label); + } + return nodeIdMap.get(key); + } + + // Register all nodes (changed functions + their callers) + for (const fn of data.affectedFunctions) { + nodeId(`${fn.file}::${fn.name}:${fn.line}`, fn.name); + for (const callers of Object.values(fn.levels || {})) { + for (const c of callers) { + nodeId(`${c.file}::${c.name}:${c.line}`, c.name); + } + } + } + + // Collect all edges and determine blast radius + const allEdges = new Set(); + const edgeFromNodes = new Set(); + const edgeToNodes = new Set(); + const changedKeys = new Set(); + + for (const fn of data.affectedFunctions) { + changedKeys.add(`${fn.file}::${fn.name}:${fn.line}`); + for (const edge of fn.edges || []) { + const edgeKey = `${edge.from}|${edge.to}`; + if (!allEdges.has(edgeKey)) { + allEdges.add(edgeKey); + edgeFromNodes.add(edge.from); + edgeToNodes.add(edge.to); + } + } + } + + // Blast radius: caller nodes that are never a source (leaf nodes of the impact tree) + const blastRadiusKeys = new Set(); + for (const key of edgeToNodes) { + if (!edgeFromNodes.has(key) && !changedKeys.has(key)) { + blastRadiusKeys.add(key); + } + } + + // Intermediate callers: not changed, not blast radius + const intermediateKeys = new Set(); + for (const key of edgeToNodes) { + if (!changedKeys.has(key) && !blastRadiusKeys.has(key)) { + intermediateKeys.add(key); + } + } + + // Group changed functions by file + const fileGroups = new Map(); + for (const fn of data.affectedFunctions) { + if (!fileGroups.has(fn.file)) fileGroups.set(fn.file, []); + fileGroups.get(fn.file).push(fn); + } + + // Emit changed-file subgraphs + let sgCounter = 0; + for (const [file, fns] of fileGroups) { + const isNew = newFileSet.has(file); + const tag = isNew ? 'new' : 'modified'; + const sgId = `sg${sgCounter++}`; + lines.push(` subgraph ${sgId}["${file} **(${tag})**"]`); + for (const fn of fns) { + const key = `${fn.file}::${fn.name}:${fn.line}`; + lines.push(` ${nodeIdMap.get(key)}["${fn.name}"]`); + } + lines.push(' end'); + const style = isNew ? 'fill:#e8f5e9,stroke:#4caf50' : 'fill:#fff3e0,stroke:#ff9800'; + lines.push(` style ${sgId} ${style}`); + } + + // Emit intermediate caller nodes (outside subgraphs) + for (const key of intermediateKeys) { + lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`); + } + + // Emit blast radius subgraph + if (blastRadiusKeys.size > 0) { + const sgId = `sg${sgCounter++}`; + lines.push(` subgraph ${sgId}["Callers **(blast radius)**"]`); + for (const key of blastRadiusKeys) { + lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`); + } + lines.push(' end'); + lines.push(` style ${sgId} fill:#f3e5f5,stroke:#9c27b0`); + } + + // Emit edges (impact flows from changed fn toward callers) + for (const edgeKey of allEdges) { + const [from, to] = edgeKey.split('|'); + lines.push(` ${nodeIdMap.get(from)} --> ${nodeIdMap.get(to)}`); + } + + return lines.join('\n'); +} + export function listFunctionsData(customDbPath, opts = {}) { const db = openReadonlyOrFail(customDbPath); const noTests = opts.noTests || false; @@ -2079,8 +2229,12 @@ export function fnImpact(name, customDbPath, opts = {}) { } export function diffImpact(customDbPath, opts = {}) { + if (opts.format === 'mermaid') { + console.log(diffImpactMermaid(customDbPath, opts)); + return; + } const data = diffImpactData(customDbPath, opts); - if (opts.json) { + if (opts.json || opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; } diff --git a/tests/unit/queries-unit.test.js b/tests/unit/queries-unit.test.js index 31a300a9..c1bccec3 100644 --- a/tests/unit/queries-unit.test.js +++ b/tests/unit/queries-unit.test.js @@ -18,6 +18,7 @@ import { initSchema } from '../../src/db.js'; import { diffImpact, diffImpactData, + diffImpactMermaid, fileDeps, fnDeps, fnDepsData, @@ -241,6 +242,143 @@ describe('diffImpactData — mocked git diff', () => { mockExecFile.mockRestore(); }); + + it('returns levels and edges in function results', async () => { + const { execFileSync: mockExecFile } = await import('node:child_process'); + mockExecFile.mockImplementationOnce(() => { + return [ + 'diff --git a/app/handler.js b/app/handler.js', + '--- a/app/handler.js', + '+++ b/app/handler.js', + '@@ -5,3 +5,4 @@', + '+ // changed line', + ].join('\n'); + }); + + const data = diffImpactData(dbPath); + const fn = data.affectedFunctions.find((f) => f.name === 'handleRequest'); + expect(fn).toBeDefined(); + expect(fn.levels).toBeDefined(); + expect(fn.edges).toBeDefined(); + expect(fn.transitiveCallers).toBeGreaterThanOrEqual(0); + + mockExecFile.mockRestore(); + }); + + it('detects new files via --- /dev/null', async () => { + const { execFileSync: mockExecFile } = await import('node:child_process'); + mockExecFile.mockImplementationOnce(() => { + return [ + 'diff --git a/app/handler.js b/app/handler.js', + '--- a/app/handler.js', + '+++ b/app/handler.js', + '@@ -5,3 +5,4 @@', + '+ // changed line', + 'diff --git a/brand-new.js b/brand-new.js', + 'new file mode 100644', + '--- /dev/null', + '+++ b/brand-new.js', + '@@ -0,0 +1,5 @@', + '+function newFn() {}', + ].join('\n'); + }); + + const data = diffImpactData(dbPath); + expect(data.newFiles).toContain('brand-new.js'); + expect(data.newFiles).not.toContain('app/handler.js'); + + mockExecFile.mockRestore(); + }); +}); + +// ─── diffImpactMermaid ──────────────────────────────────────────────── + +describe('diffImpactMermaid', () => { + it('returns valid Mermaid flowchart with subgraphs', async () => { + const { execFileSync: mockExecFile } = await import('node:child_process'); + mockExecFile.mockImplementationOnce(() => { + return [ + 'diff --git a/lib/child.js b/lib/child.js', + '--- a/lib/child.js', + '+++ b/lib/child.js', + '@@ -10,3 +10,4 @@', + '+ // changed method', + ].join('\n'); + }); + + const output = diffImpactMermaid(dbPath); + expect(output).toContain('flowchart TB'); + expect(output).toContain('lib/child.js **(modified)**'); + expect(output).toContain('ChildService.process'); + // Should have edges (ChildService.process has callers) + expect(output).toContain('-->'); + + mockExecFile.mockRestore(); + }); + + it('marks new files with green styling', async () => { + const { execFileSync: mockExecFile } = await import('node:child_process'); + mockExecFile.mockImplementationOnce(() => { + return [ + 'diff --git a/app/handler.js b/app/handler.js', + '--- /dev/null', + '+++ b/app/handler.js', + '@@ -0,0 +5,4 @@', + '+ // new file content', + ].join('\n'); + }); + + const output = diffImpactMermaid(dbPath); + expect(output).toContain('**(new)**'); + expect(output).toContain('fill:#e8f5e9,stroke:#4caf50'); + + mockExecFile.mockRestore(); + }); + + it('includes blast radius subgraph for leaf callers', async () => { + const { execFileSync: mockExecFile } = await import('node:child_process'); + mockExecFile.mockImplementationOnce(() => { + return [ + 'diff --git a/lib/base.js b/lib/base.js', + '--- a/lib/base.js', + '+++ b/lib/base.js', + '@@ -10,3 +10,4 @@', + '+ // changed base', + ].join('\n'); + }); + + const output = diffImpactMermaid(dbPath, { depth: 3 }); + expect(output).toContain('flowchart TB'); + // BaseService.process is called by ChildService.process, which is called by handleRequest + // handleRequest should be in the blast radius + if (output.includes('blast radius')) { + expect(output).toContain('fill:#f3e5f5,stroke:#9c27b0'); + } + + mockExecFile.mockRestore(); + }); + + it('returns fallback diagram when no impacted functions', async () => { + const { execFileSync: mockExecFile } = await import('node:child_process'); + mockExecFile.mockImplementationOnce(() => ''); + + const output = diffImpactMermaid(dbPath); + expect(output).toContain('No impacted functions detected'); + + mockExecFile.mockRestore(); + }); + + it('returns error string on git failure', async () => { + const { execFileSync: mockExecFile } = await import('node:child_process'); + mockExecFile.mockImplementationOnce(() => { + throw new Error('git not found'); + }); + + const output = diffImpactMermaid(dbPath); + expect(output).toMatch(/git diff/i); + + mockExecFile.mockRestore(); + }); }); // ─── Display wrappers ───────────────────────────────────────────────── @@ -441,4 +579,24 @@ describe('diffImpact (display)', () => { spy.mockRestore(); mockExecFile.mockRestore(); }); + + it('outputs Mermaid when format is mermaid', async () => { + const { execFileSync: mockExecFile } = await import('node:child_process'); + mockExecFile.mockImplementationOnce(() => { + return [ + 'diff --git a/app/handler.js b/app/handler.js', + '--- a/app/handler.js', + '+++ b/app/handler.js', + '@@ -5,3 +5,4 @@', + '+ // changed line', + ].join('\n'); + }); + + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + diffImpact(dbPath, { format: 'mermaid' }); + const allOutput = spy.mock.calls.map((c) => c[0]).join('\n'); + expect(allOutput).toContain('flowchart TB'); + spy.mockRestore(); + mockExecFile.mockRestore(); + }); });