From 5baf3ad63843fe363ed2b85e4399a3cf3fa4848e Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:28:41 -0700 Subject: [PATCH 01/17] perf: skip ensureWasmTrees when native engine provides complete data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before calling ensureWasmTrees, check whether native engine already supplies CFG and dataflow data for all files. When it does, skip the WASM pre-parse entirely — avoiding a full WASM parse of every file on native builds where the data is already available. Impact: 1 functions changed, 14 affected --- src/builder.js | 44 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/src/builder.js b/src/builder.js index c5019b43..28637dc3 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1317,16 +1317,44 @@ export async function buildGraph(rootDir, opts = {}) { _t.complexityMs = performance.now() - _t.complexity0; // Pre-parse files missing WASM trees (native builds) so CFG + dataflow - // share a single parse pass instead of each creating parsers independently + // share a single parse pass instead of each creating parsers independently. + // Skip entirely when native engine already provides CFG + dataflow data. if (opts.cfg !== false || opts.dataflow !== false) { - _t.wasmPre0 = performance.now(); - try { - const { ensureWasmTrees } = await import('./parser.js'); - await ensureWasmTrees(astComplexitySymbols, rootDir); - } catch (err) { - debug(`WASM pre-parse failed: ${err.message}`); + const needsCfg = opts.cfg !== false; + const needsDataflow = opts.dataflow !== false; + + let needsWasmTrees = false; + for (const [, symbols] of astComplexitySymbols) { + if (symbols._tree) continue; // already has a tree + // CFG: need tree if any function/method def lacks native CFG + if (needsCfg) { + const fnDefs = symbols.definitions.filter( + (d) => (d.kind === 'function' || d.kind === 'method') && d.line, + ); + if (fnDefs.length > 0 && !fnDefs.every((d) => d.cfg?.blocks?.length)) { + needsWasmTrees = true; + break; + } + } + // Dataflow: need tree if file lacks native dataflow + if (needsDataflow && !symbols.dataflow) { + needsWasmTrees = true; + break; + } + } + + if (needsWasmTrees) { + _t.wasmPre0 = performance.now(); + try { + const { ensureWasmTrees } = await import('./parser.js'); + await ensureWasmTrees(astComplexitySymbols, rootDir); + } catch (err) { + debug(`WASM pre-parse failed: ${err.message}`); + } + _t.wasmPreMs = performance.now() - _t.wasmPre0; + } else { + _t.wasmPreMs = 0; } - _t.wasmPreMs = performance.now() - _t.wasmPre0; } // CFG analysis (skip with --no-cfg) From d411ab6f3c57a587e36fa7eccf611ef953259548 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:09:58 -0700 Subject: [PATCH 02/17] =?UTF-8?q?fix:=20address=20PR=20#344=20review=20com?= =?UTF-8?q?ments=20=E2=80=94=20TODO=20for=20constant=20exclusion,=20comple?= =?UTF-8?q?te=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ tests/integration/build-parity.test.js | 2 ++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4573de31..3770c713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,16 @@ All notable changes to this project will be documented in this file. See [commit ## [3.0.3](https://github.com/optave/codegraph/compare/v3.0.2...v3.0.3) (2026-03-04) +> **Note:** 3.0.2 was an internal/unpublished version used during development. + ### Performance * **ast:** use single transaction for AST node insertion — astMs drops from ~3600ms to ~350ms (native) and ~547ms (WASM), reducing overall native build from 24.9 to 8.5 ms/file ([#333](https://github.com/optave/codegraph/pull/333)) +* **builder:** skip `ensureWasmTrees` when native engine provides complete CFG + dataflow data ([#344](https://github.com/optave/codegraph/pull/344)) + +### Bug Fixes + +* **native:** fix function-scoped `const` declarations being incorrectly extracted as top-level constants ([#344](https://github.com/optave/codegraph/pull/344)) ## [3.0.2](https://github.com/optave/codegraph/compare/v3.0.1...v3.0.2) (2026-03-04) diff --git a/tests/integration/build-parity.test.js b/tests/integration/build-parity.test.js index 566d2019..d2f2e8a8 100644 --- a/tests/integration/build-parity.test.js +++ b/tests/integration/build-parity.test.js @@ -35,6 +35,8 @@ function readGraph(dbPath) { // Exclude constant nodes — the native engine has a known scope bug where it // extracts local `const` variables inside functions as top-level constants, // while WASM correctly limits constant extraction to program-level declarations. + // TODO: Remove kind != 'constant' exclusion once native binary >= 3.0.4 ships + // Fix: crates/codegraph-core/src/extractors/javascript.rs (find_parent_of_types guard) const nodes = db .prepare( "SELECT name, kind, file, line FROM nodes WHERE kind != 'constant' ORDER BY name, kind, file, line", From 4317e0df98fb83dc9aa840aeb1420f3034fdf6eb Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:24:41 -0700 Subject: [PATCH 03/17] fix: add null guard on symbols.definitions in pre-parse check Impact: 1 functions changed, 0 affected --- src/builder.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/builder.js b/src/builder.js index 28637dc3..c921c78f 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1328,7 +1328,7 @@ export async function buildGraph(rootDir, opts = {}) { if (symbols._tree) continue; // already has a tree // CFG: need tree if any function/method def lacks native CFG if (needsCfg) { - const fnDefs = symbols.definitions.filter( + const fnDefs = (symbols.definitions || []).filter( (d) => (d.kind === 'function' || d.kind === 'method') && d.line, ); if (fnDefs.length > 0 && !fnDefs.every((d) => d.cfg?.blocks?.length)) { From c2bb5dedf66fbdb95aa6470876dfcc534672c197 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:00:38 -0700 Subject: [PATCH 04/17] fix: treat empty cfg.blocks array as valid native CFG d.cfg?.blocks?.length evaluates to 0 (falsy) when the native engine returns cfg: { blocks: [] } for trivial functions, spuriously triggering needsWasmTrees. Use Array.isArray(d.cfg?.blocks) instead, and preserve d.cfg === null for nodes that intentionally have no CFG (e.g. interface members). Impact: 1 functions changed, 1 affected --- src/builder.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/builder.js b/src/builder.js index c921c78f..c7078771 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1331,7 +1331,7 @@ export async function buildGraph(rootDir, opts = {}) { const fnDefs = (symbols.definitions || []).filter( (d) => (d.kind === 'function' || d.kind === 'method') && d.line, ); - if (fnDefs.length > 0 && !fnDefs.every((d) => d.cfg?.blocks?.length)) { + if (fnDefs.length > 0 && !fnDefs.every((d) => d.cfg === null || Array.isArray(d.cfg?.blocks))) { needsWasmTrees = true; break; } From 7e6b48912b84d62ec755fd8fc1354d57d6fdd6f1 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:00:51 -0700 Subject: [PATCH 05/17] style: format cfg check per biome rules Impact: 1 functions changed, 0 affected --- src/builder.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/builder.js b/src/builder.js index c7078771..5eb48d7c 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1331,7 +1331,10 @@ export async function buildGraph(rootDir, opts = {}) { const fnDefs = (symbols.definitions || []).filter( (d) => (d.kind === 'function' || d.kind === 'method') && d.line, ); - if (fnDefs.length > 0 && !fnDefs.every((d) => d.cfg === null || Array.isArray(d.cfg?.blocks))) { + if ( + fnDefs.length > 0 && + !fnDefs.every((d) => d.cfg === null || Array.isArray(d.cfg?.blocks)) + ) { needsWasmTrees = true; break; } From f0a5522c52168e0a6dcdefaefb433ac19bbdbfdf Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:40:46 -0700 Subject: [PATCH 06/17] feat: add `sequence` command for Mermaid sequence diagrams Generate inter-module call sequence diagrams from the graph DB. Participants are files (not functions), keeping diagrams readable. BFS forward from entry point, with optional --dataflow flag to annotate parameter names and return arrows. New: src/sequence.js, tests/integration/sequence.test.js Modified: cli.js, mcp.js, index.js, CLAUDE.md, mcp.test.js Impact: 8 functions changed, 6 affected --- CLAUDE.md | 2 + src/cli.js | 33 +++ src/index.js | 3 +- src/mcp.js | 54 ++++ src/sequence.js | 423 +++++++++++++++++++++++++++++ tests/integration/sequence.test.js | 192 +++++++++++++ tests/unit/mcp.test.js | 1 + 7 files changed, 707 insertions(+), 1 deletion(-) create mode 100644 src/sequence.js create mode 100644 tests/integration/sequence.test.js diff --git a/CLAUDE.md b/CLAUDE.md index dd1739ad..fb22fc2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,7 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The | `boundaries.js` | Architecture boundary rules with onion architecture preset | | `owners.js` | CODEOWNERS integration for ownership queries | | `snapshot.js` | SQLite DB backup and restore | +| `sequence.js` | Mermaid sequence diagram generation from call graph edges | | `paginate.js` | Pagination helpers for bounded query results | | `logger.js` | Structured logging (`warn`, `debug`, `info`, `error`) | @@ -132,6 +133,7 @@ node src/cli.js deps src/.js # File-level imports and importers node src/cli.js diff-impact main # Impact of current branch vs main node src/cli.js complexity -T # Per-function complexity metrics node src/cli.js communities -T # Community detection & drift analysis +node src/cli.js sequence -T # Mermaid sequence diagram from call edges node src/cli.js check -T # Rule engine pass/fail check node src/cli.js audit -T # Combined structural summary + impact + health report node src/cli.js triage -T # Ranked audit priority queue diff --git a/src/cli.js b/src/cli.js index c799ef1c..41c94a6f 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1137,6 +1137,39 @@ program }); }); +program + .command('sequence ') + .description('Generate a Mermaid sequence diagram from call graph edges (participants = files)') + .option('--depth ', 'Max forward traversal depth', '10') + .option('--dataflow', 'Annotate with parameter names and return arrows from dataflow table') + .option('-d, --db ', 'Path to graph.db') + .option('-f, --file ', 'Scope to a specific file (partial match)') + .option('-k, --kind ', 'Filter by symbol kind') + .option('-T, --no-tests', 'Exclude test/spec files from results') + .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') + .option('-j, --json', 'Output as JSON') + .option('--limit ', 'Max results to return') + .option('--offset ', 'Skip N results (default: 0)') + .option('--ndjson', 'Newline-delimited JSON output') + .action(async (name, opts) => { + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); + process.exit(1); + } + const { sequence } = await import('./sequence.js'); + sequence(name, opts.db, { + depth: parseInt(opts.depth, 10), + file: opts.file, + kind: opts.kind, + noTests: resolveNoTests(opts), + json: opts.json, + dataflow: opts.dataflow, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + ndjson: opts.ndjson, + }); + }); + program .command('dataflow ') .description('Show data flow for a function: parameters, return consumers, mutations') diff --git a/src/index.js b/src/index.js index 0cf65498..9f51e6cf 100644 --- a/src/index.js +++ b/src/index.js @@ -121,7 +121,6 @@ export { isNativeAvailable } from './native.js'; export { matchOwners, owners, ownersData, ownersForFiles, parseCodeowners } from './owners.js'; // Pagination utilities export { MCP_DEFAULTS, MCP_MAX_LIMIT, paginate, paginateResult, printNdjson } from './paginate.js'; - // Unified parser API export { getActiveEngine, isWasmAvailable, parseFileAuto, parseFilesAuto } from './parser.js'; // Query functions (data-returning) @@ -170,6 +169,8 @@ export { saveRegistry, unregisterRepo, } from './registry.js'; +// Sequence diagram generation +export { sequence, sequenceData, sequenceToMermaid } from './sequence.js'; // Snapshot management export { snapshotDelete, diff --git a/src/mcp.js b/src/mcp.js index a63ff873..1b51d75d 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -418,6 +418,43 @@ const BASE_TOOLS = [ }, }, }, + { + name: 'sequence', + description: + 'Generate a Mermaid sequence diagram from call graph edges. Participants are files, messages are function calls between them.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Entry point or function name to trace from (partial match)', + }, + depth: { type: 'number', description: 'Max forward traversal depth', default: 10 }, + format: { + type: 'string', + enum: ['mermaid', 'json'], + description: 'Output format (default: mermaid)', + }, + dataflow: { + type: 'boolean', + description: 'Annotate with parameter names and return arrows', + default: false, + }, + file: { + type: 'string', + description: 'Scope search to functions in this file (partial match)', + }, + kind: { + type: 'string', + enum: EVERY_SYMBOL_KIND, + description: 'Filter to a specific symbol kind', + }, + no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, + }, + required: ['name'], + }, + }, { name: 'complexity', description: @@ -1165,6 +1202,23 @@ export async function startMCPServer(customDbPath, options = {}) { } break; } + case 'sequence': { + const { sequenceData, sequenceToMermaid } = await import('./sequence.js'); + const seqResult = sequenceData(args.name, dbPath, { + depth: args.depth, + file: args.file, + kind: args.kind, + dataflow: args.dataflow, + noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.execution_flow, MCP_MAX_LIMIT), + offset: args.offset ?? 0, + }); + result = + args.format === 'json' + ? seqResult + : { text: sequenceToMermaid(seqResult), ...seqResult }; + break; + } case 'complexity': { const { complexityData } = await import('./complexity.js'); result = complexityData(dbPath, { diff --git a/src/sequence.js b/src/sequence.js new file mode 100644 index 00000000..7678096e --- /dev/null +++ b/src/sequence.js @@ -0,0 +1,423 @@ +/** + * Sequence diagram generation — Mermaid sequenceDiagram from call graph edges. + * + * Participants are files (not individual functions). Calls within the same file + * become self-messages. This keeps diagrams readable and matches typical + * sequence-diagram conventions. + */ + +import { openReadonlyOrFail } from './db.js'; +import { paginateResult, printNdjson } from './paginate.js'; +import { isTestFile, kindIcon } from './queries.js'; +import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; + +// ─── findBestMatch (copied from flow.js — same pattern) ───────────── + +function findBestMatch(db, name, opts = {}) { + const kinds = opts.kind + ? [opts.kind] + : [ + 'function', + 'method', + 'class', + 'interface', + 'type', + 'struct', + 'enum', + 'trait', + 'record', + 'module', + ]; + const placeholders = kinds.map(() => '?').join(', '); + const params = [`%${name}%`, ...kinds]; + + let fileCondition = ''; + if (opts.file) { + fileCondition = ' AND n.file LIKE ?'; + params.push(`%${opts.file}%`); + } + + const rows = db + .prepare( + `SELECT n.*, COALESCE(fi.cnt, 0) AS fan_in + FROM nodes n + LEFT JOIN ( + SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id + ) fi ON fi.target_id = n.id + WHERE n.name LIKE ? AND n.kind IN (${placeholders})${fileCondition}`, + ) + .all(...params); + + const noTests = opts.noTests || false; + const nodes = noTests ? rows.filter((n) => !isTestFile(n.file)) : rows; + + if (nodes.length === 0) return null; + + const lowerQuery = name.toLowerCase(); + for (const node of nodes) { + const lowerName = node.name.toLowerCase(); + const bareName = lowerName.includes('.') ? lowerName.split('.').pop() : lowerName; + + let matchScore; + if (lowerName === lowerQuery || bareName === lowerQuery) { + matchScore = 100; + } else if (lowerName.startsWith(lowerQuery) || bareName.startsWith(lowerQuery)) { + matchScore = 60; + } else if (lowerName.includes(`.${lowerQuery}`) || lowerName.includes(`${lowerQuery}.`)) { + matchScore = 40; + } else { + matchScore = 10; + } + + const fanInBonus = Math.min(Math.log2(node.fan_in + 1) * 5, 25); + node._relevance = matchScore + fanInBonus; + } + + nodes.sort((a, b) => b._relevance - a._relevance); + return nodes[0]; +} + +// ─── Alias generation ──────────────────────────────────────────────── + +/** + * Build short participant aliases from file paths with collision handling. + * e.g. "src/builder.js" → "builder", but if two files share basename, + * progressively add parent dirs: "src/builder" vs "lib/builder". + */ +function buildAliases(files) { + const aliases = new Map(); + const basenames = new Map(); + + // Group by basename + for (const file of files) { + const base = file + .split('/') + .pop() + .replace(/\.[^.]+$/, ''); + if (!basenames.has(base)) basenames.set(base, []); + basenames.get(base).push(file); + } + + for (const [base, paths] of basenames) { + if (paths.length === 1) { + aliases.set(paths[0], base); + } else { + // Collision — progressively add parent dirs until aliases are unique + for (let depth = 2; depth <= 10; depth++) { + const trial = new Map(); + let allUnique = true; + const seen = new Set(); + + for (const p of paths) { + const parts = p.replace(/\.[^.]+$/, '').split('/'); + const alias = parts + .slice(-depth) + .join('/') + .replace(/[^a-zA-Z0-9_/-]/g, '_'); + trial.set(p, alias); + if (seen.has(alias)) allUnique = false; + seen.add(alias); + } + + if (allUnique || depth === 10) { + for (const [p, alias] of trial) { + aliases.set(p, alias); + } + break; + } + } + } + } + + return aliases; +} + +// ─── Core data function ────────────────────────────────────────────── + +/** + * Build sequence diagram data by BFS-forward from an entry point. + * + * @param {string} name - Symbol name to trace from + * @param {string} [dbPath] + * @param {object} [opts] + * @param {number} [opts.depth=10] + * @param {boolean} [opts.noTests] + * @param {string} [opts.file] + * @param {string} [opts.kind] + * @param {boolean} [opts.dataflow] + * @param {number} [opts.limit] + * @param {number} [opts.offset] + * @returns {{ entry, participants, messages, depth, totalMessages, truncated }} + */ +export function sequenceData(name, dbPath, opts = {}) { + const db = openReadonlyOrFail(dbPath); + const maxDepth = opts.depth || 10; + const noTests = opts.noTests || false; + const withDataflow = opts.dataflow || false; + + // Phase 1: Direct LIKE match + let matchNode = findBestMatch(db, name, opts); + + // Phase 2: Prefix-stripped matching + if (!matchNode) { + for (const prefix of FRAMEWORK_ENTRY_PREFIXES) { + matchNode = findBestMatch(db, `${prefix}${name}`, opts); + if (matchNode) break; + } + } + + if (!matchNode) { + db.close(); + return { + entry: null, + participants: [], + messages: [], + depth: maxDepth, + totalMessages: 0, + truncated: false, + }; + } + + const entry = { + name: matchNode.name, + file: matchNode.file, + kind: matchNode.kind, + line: matchNode.line, + }; + + // BFS forward — track edges, not just nodes + const visited = new Set([matchNode.id]); + let frontier = [matchNode.id]; + const messages = []; + const fileSet = new Set([matchNode.file]); + const idToNode = new Map(); + idToNode.set(matchNode.id, matchNode); + let truncated = false; + + for (let d = 1; d <= maxDepth; d++) { + const nextFrontier = []; + + for (const fid of frontier) { + const callees = db + .prepare( + `SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line + FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'calls'`, + ) + .all(fid); + + const caller = idToNode.get(fid); + + for (const c of callees) { + if (noTests && isTestFile(c.file)) continue; + + // Always record the message (even for visited nodes — different caller path) + fileSet.add(c.file); + messages.push({ + from: caller.file, + to: c.file, + label: c.name, + type: 'call', + depth: d, + }); + + if (visited.has(c.id)) continue; + + visited.add(c.id); + nextFrontier.push(c.id); + idToNode.set(c.id, c); + } + } + + frontier = nextFrontier; + if (frontier.length === 0) break; + + if (d === maxDepth && frontier.length > 0) { + truncated = true; + } + } + + // Dataflow annotations: add return arrows + if (withDataflow && messages.length > 0) { + const hasTable = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dataflow'") + .get(); + + if (hasTable) { + // For each called function, check if it has return edges + const seenReturns = new Set(); + for (const msg of [...messages]) { + if (msg.type !== 'call') continue; + const targetNode = [...idToNode.values()].find( + (n) => n.name === msg.label && n.file === msg.to, + ); + if (!targetNode) continue; + + const returnKey = `${msg.to}->${msg.from}:${msg.label}`; + if (seenReturns.has(returnKey)) continue; + + const returns = db + .prepare( + `SELECT d.expression FROM dataflow d + WHERE d.source_id = ? AND d.kind = 'returns'`, + ) + .all(targetNode.id); + + if (returns.length > 0) { + seenReturns.add(returnKey); + const expr = returns[0].expression || 'result'; + messages.push({ + from: msg.to, + to: msg.from, + label: expr, + type: 'return', + depth: msg.depth, + }); + } + } + + // Annotate call messages with parameter names + for (const msg of messages) { + if (msg.type !== 'call') continue; + const targetNode = [...idToNode.values()].find( + (n) => n.name === msg.label && n.file === msg.to, + ); + if (!targetNode) continue; + + const params = db + .prepare( + `SELECT d.expression FROM dataflow d + WHERE d.target_id = ? AND d.kind = 'flows_to' + ORDER BY d.param_index`, + ) + .all(targetNode.id); + + if (params.length > 0) { + const paramNames = params + .map((p) => p.expression) + .filter(Boolean) + .slice(0, 3); + if (paramNames.length > 0) { + msg.label = `${msg.label}(${paramNames.join(', ')})`; + } + } + } + } + } + + // Sort messages by depth, then call before return + messages.sort((a, b) => { + if (a.depth !== b.depth) return a.depth - b.depth; + if (a.type === 'call' && b.type === 'return') return -1; + if (a.type === 'return' && b.type === 'call') return 1; + return 0; + }); + + // Build participant list from files + const aliases = buildAliases([...fileSet]); + const participants = [...fileSet].map((file) => ({ + id: aliases.get(file), + label: file.split('/').pop(), + file, + })); + + // Sort participants: entry file first, then alphabetically + participants.sort((a, b) => { + if (a.file === entry.file) return -1; + if (b.file === entry.file) return 1; + return a.file.localeCompare(b.file); + }); + + // Replace file paths with alias IDs in messages + for (const msg of messages) { + msg.from = aliases.get(msg.from); + msg.to = aliases.get(msg.to); + } + + db.close(); + + const base = { + entry, + participants, + messages, + depth: maxDepth, + totalMessages: messages.length, + truncated, + }; + return paginateResult(base, 'messages', { limit: opts.limit, offset: opts.offset }); +} + +// ─── Mermaid formatter ─────────────────────────────────────────────── + +/** + * Escape special Mermaid characters in labels. + */ +function escapeMermaid(str) { + return str.replace(/:/g, '#colon;').replace(/"/g, '#quot;'); +} + +/** + * Convert sequenceData result to Mermaid sequenceDiagram syntax. + * @param {{ participants, messages, truncated }} seqResult + * @returns {string} + */ +export function sequenceToMermaid(seqResult) { + const lines = ['sequenceDiagram']; + + for (const p of seqResult.participants) { + lines.push(` participant ${p.id} as ${escapeMermaid(p.label)}`); + } + + for (const msg of seqResult.messages) { + const arrow = msg.type === 'return' ? '-->>' : '->>'; + lines.push(` ${msg.from}${arrow}${msg.to}: ${escapeMermaid(msg.label)}`); + } + + if (seqResult.truncated) { + lines.push( + ` note right of ${seqResult.participants[0]?.id}: Truncated at depth ${seqResult.depth}`, + ); + } + + return lines.join('\n'); +} + +// ─── CLI formatter ─────────────────────────────────────────────────── + +/** + * CLI entry point — format sequence data as mermaid, JSON, or ndjson. + */ +export function sequence(name, dbPath, opts = {}) { + const data = sequenceData(name, dbPath, opts); + + if (opts.ndjson) { + printNdjson(data, 'messages'); + return; + } + + if (opts.json) { + console.log(JSON.stringify(data, null, 2)); + return; + } + + // Default: mermaid format + if (!data.entry) { + console.log(`No matching function found for "${name}".`); + return; + } + + const e = data.entry; + console.log(`\nSequence from: [${kindIcon(e.kind)}] ${e.name} ${e.file}:${e.line}`); + console.log(`Participants: ${data.participants.length} Messages: ${data.totalMessages}`); + if (data.truncated) { + console.log(` (truncated at depth ${data.depth})`); + } + console.log(); + + if (data.messages.length === 0) { + console.log(' (leaf node — no callees)'); + return; + } + + console.log(sequenceToMermaid(data)); +} diff --git a/tests/integration/sequence.test.js b/tests/integration/sequence.test.js new file mode 100644 index 00000000..3c44c133 --- /dev/null +++ b/tests/integration/sequence.test.js @@ -0,0 +1,192 @@ +/** + * Integration tests for sequence diagram generation. + * + * Uses a hand-crafted in-memory DB with known graph topology: + * + * buildGraph() → parseFiles() [src/builder.js → src/parser.js] + * → resolveImports() [src/builder.js → src/resolve.js] + * parseFiles() → extractSymbols() [src/parser.js → src/parser.js, same-file] + * extractSymbols() [leaf] + * resolveImports() [leaf] + * + * For alias collision test: + * helperA() in src/utils/helper.js + * helperB() in lib/utils/helper.js + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { initSchema } from '../../src/db.js'; +import { sequenceData, sequenceToMermaid } from '../../src/sequence.js'; + +// ─── Helpers ─────────────────────────────────────────────────────────── + +function insertNode(db, name, kind, file, line) { + return db + .prepare('INSERT INTO nodes (name, kind, file, line) VALUES (?, ?, ?, ?)') + .run(name, kind, file, line).lastInsertRowid; +} + +function insertEdge(db, sourceId, targetId, kind, confidence = 1.0) { + db.prepare( + 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, 0)', + ).run(sourceId, targetId, kind, confidence); +} + +// ─── Fixture DB ──────────────────────────────────────────────────────── + +let tmpDir, dbPath; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-sequence-')); + fs.mkdirSync(path.join(tmpDir, '.codegraph')); + dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // Core nodes + const buildGraph = insertNode(db, 'buildGraph', 'function', 'src/builder.js', 10); + const parseFiles = insertNode(db, 'parseFiles', 'function', 'src/parser.js', 5); + const extractSymbols = insertNode(db, 'extractSymbols', 'function', 'src/parser.js', 20); + const resolveImports = insertNode(db, 'resolveImports', 'function', 'src/resolve.js', 1); + + // Call edges + insertEdge(db, buildGraph, parseFiles, 'calls'); + insertEdge(db, buildGraph, resolveImports, 'calls'); + insertEdge(db, parseFiles, extractSymbols, 'calls'); + + // Alias collision nodes (two different helper.js files) + const helperA = insertNode(db, 'helperA', 'function', 'src/utils/helper.js', 1); + const helperB = insertNode(db, 'helperB', 'function', 'lib/utils/helper.js', 1); + insertEdge(db, buildGraph, helperA, 'calls'); + insertEdge(db, helperA, helperB, 'calls'); + + // Test file node (for noTests filtering) + const testFn = insertNode(db, 'testBuild', 'function', 'tests/builder.test.js', 1); + insertEdge(db, buildGraph, testFn, 'calls'); + + db.close(); +}); + +afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── sequenceData ────────────────────────────────────────────────────── + +describe('sequenceData', () => { + test('basic sequence — correct participants and messages in BFS order', () => { + const data = sequenceData('buildGraph', dbPath, { noTests: true }); + expect(data.entry).not.toBeNull(); + expect(data.entry.name).toBe('buildGraph'); + + // Should have 4 files as participants (builder, parser, resolve, src/utils/helper) + // (test file excluded by noTests, lib/utils/helper reachable via helperA) + expect(data.participants.length).toBe(5); + + // Messages should be in BFS depth order + expect(data.messages.length).toBeGreaterThanOrEqual(4); + const depths = data.messages.map((m) => m.depth); + for (let i = 1; i < depths.length; i++) { + expect(depths[i]).toBeGreaterThanOrEqual(depths[i - 1]); + } + }); + + test('self-call — same-file call appears as self-message', () => { + const data = sequenceData('parseFiles', dbPath, { noTests: true }); + expect(data.entry).not.toBeNull(); + + // parseFiles → extractSymbols are both in src/parser.js + const selfMessages = data.messages.filter((m) => m.from === m.to); + expect(selfMessages.length).toBe(1); + expect(selfMessages[0].label).toBe('extractSymbols'); + }); + + test('depth limiting — depth:1 truncates', () => { + const data = sequenceData('buildGraph', dbPath, { depth: 1, noTests: true }); + expect(data.truncated).toBe(true); + expect(data.depth).toBe(1); + + // At depth 1, only direct callees of buildGraph + const msgDepths = data.messages.map((m) => m.depth); + expect(Math.max(...msgDepths)).toBe(1); + }); + + test('unknown name — entry is null', () => { + const data = sequenceData('nonExistentFunction', dbPath); + expect(data.entry).toBeNull(); + expect(data.participants).toHaveLength(0); + expect(data.messages).toHaveLength(0); + }); + + test('leaf entry — entry exists, zero messages', () => { + const data = sequenceData('extractSymbols', dbPath); + expect(data.entry).not.toBeNull(); + expect(data.entry.name).toBe('extractSymbols'); + expect(data.messages).toHaveLength(0); + // Only the entry file as participant + expect(data.participants).toHaveLength(1); + }); + + test('participant alias collision — two helper.js files get distinct IDs', () => { + const data = sequenceData('buildGraph', dbPath, { noTests: true }); + const helperParticipants = data.participants.filter((p) => p.label === 'helper.js'); + expect(helperParticipants.length).toBe(2); + + // IDs should be distinct + const ids = helperParticipants.map((p) => p.id); + expect(ids[0]).not.toBe(ids[1]); + }); + + test('noTests filtering — test file nodes excluded', () => { + const withTests = sequenceData('buildGraph', dbPath, { noTests: false }); + const withoutTests = sequenceData('buildGraph', dbPath, { noTests: true }); + + // With tests should have more messages (includes testBuild) + expect(withTests.totalMessages).toBeGreaterThan(withoutTests.totalMessages); + + // testBuild should not appear when filtering + const testMsgs = withoutTests.messages.filter((m) => m.label === 'testBuild'); + expect(testMsgs).toHaveLength(0); + }); +}); + +// ─── sequenceToMermaid ────────────────────────────────────────────────── + +describe('sequenceToMermaid', () => { + test('starts with sequenceDiagram and has participant lines', () => { + const data = sequenceData('buildGraph', dbPath, { noTests: true }); + const mermaid = sequenceToMermaid(data); + + expect(mermaid).toMatch(/^sequenceDiagram/); + expect(mermaid).toContain('participant'); + }); + + test('has ->> arrows for calls', () => { + const data = sequenceData('buildGraph', dbPath, { noTests: true }); + const mermaid = sequenceToMermaid(data); + expect(mermaid).toContain('->>'); + }); + + test('truncation note when truncated', () => { + const data = sequenceData('buildGraph', dbPath, { depth: 1, noTests: true }); + const mermaid = sequenceToMermaid(data); + expect(mermaid).toContain('Truncated at depth'); + }); + + test('escapes colons in labels', () => { + const mockData = { + participants: [{ id: 'a', label: 'a.js' }], + messages: [{ from: 'a', to: 'a', label: 'route:GET /users', type: 'call' }], + truncated: false, + }; + const mermaid = sequenceToMermaid(mockData); + expect(mermaid).toContain('#colon;'); + expect(mermaid).not.toContain('route:'); + }); +}); diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index 4e5bc3cc..70889bfe 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -28,6 +28,7 @@ const ALL_TOOL_NAMES = [ 'co_changes', 'node_roles', 'execution_flow', + 'sequence', 'complexity', 'communities', 'code_owners', From 0201d314ddcd4870e4b2bfff8912128b982fb801 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Thu, 5 Mar 2026 02:31:38 -0700 Subject: [PATCH 07/17] perf: hoist db.prepare() calls and build O(1) lookup map in sequence Impact: 1 functions changed, 1 affected --- src/sequence.js | 53 +++++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/src/sequence.js b/src/sequence.js index 7678096e..616f3e50 100644 --- a/src/sequence.js +++ b/src/sequence.js @@ -194,17 +194,17 @@ export function sequenceData(name, dbPath, opts = {}) { idToNode.set(matchNode.id, matchNode); let truncated = false; + const getCallees = db.prepare( + `SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line + FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'calls'`, + ); + for (let d = 1; d <= maxDepth; d++) { const nextFrontier = []; for (const fid of frontier) { - const callees = db - .prepare( - `SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line - FROM edges e JOIN nodes n ON e.target_id = n.id - WHERE e.source_id = ? AND e.kind = 'calls'`, - ) - .all(fid); + const callees = getCallees.all(fid); const caller = idToNode.get(fid); @@ -244,24 +244,33 @@ export function sequenceData(name, dbPath, opts = {}) { .get(); if (hasTable) { + // Build name|file lookup for O(1) target node access + const nodeByNameFile = new Map(); + for (const n of idToNode.values()) { + nodeByNameFile.set(`${n.name}|${n.file}`, n); + } + + const getReturns = db.prepare( + `SELECT d.expression FROM dataflow d + WHERE d.source_id = ? AND d.kind = 'returns'`, + ); + const getFlowsTo = db.prepare( + `SELECT d.expression FROM dataflow d + WHERE d.target_id = ? AND d.kind = 'flows_to' + ORDER BY d.param_index`, + ); + // For each called function, check if it has return edges const seenReturns = new Set(); for (const msg of [...messages]) { if (msg.type !== 'call') continue; - const targetNode = [...idToNode.values()].find( - (n) => n.name === msg.label && n.file === msg.to, - ); + const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); if (!targetNode) continue; const returnKey = `${msg.to}->${msg.from}:${msg.label}`; if (seenReturns.has(returnKey)) continue; - const returns = db - .prepare( - `SELECT d.expression FROM dataflow d - WHERE d.source_id = ? AND d.kind = 'returns'`, - ) - .all(targetNode.id); + const returns = getReturns.all(targetNode.id); if (returns.length > 0) { seenReturns.add(returnKey); @@ -279,18 +288,10 @@ export function sequenceData(name, dbPath, opts = {}) { // Annotate call messages with parameter names for (const msg of messages) { if (msg.type !== 'call') continue; - const targetNode = [...idToNode.values()].find( - (n) => n.name === msg.label && n.file === msg.to, - ); + const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); if (!targetNode) continue; - const params = db - .prepare( - `SELECT d.expression FROM dataflow d - WHERE d.target_id = ? AND d.kind = 'flows_to' - ORDER BY d.param_index`, - ) - .all(targetNode.id); + const params = getFlowsTo.all(targetNode.id); if (params.length > 0) { const paramNames = params From 19a990a5a93966c52da74cbda6f9f8d936be77b1 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:39:04 -0700 Subject: [PATCH 08/17] =?UTF-8?q?fix:=20address=20sequence=20review=20comm?= =?UTF-8?q?ents=20=E2=80=94=20alias=20collision,=20truncation,=20escape,?= =?UTF-8?q?=20pagination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix buildAliases join('/') producing Mermaid-invalid participant IDs; use join('_') and strip '/' from allowed regex chars - Add Mermaid format validation to alias collision test - Fix truncated false-positive when nextFrontier has unvisited nodes at maxDepth boundary - Add angle bracket escaping to escapeMermaid (#gt;/#lt; entities) - Filter orphaned participants after pagination slices messages - Fix misleading test comment (4 files → 5 files) Impact: 1 functions changed, 2 affected --- src/sequence.js | 21 ++++++++++++++++----- tests/integration/sequence.test.js | 9 +++++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/sequence.js b/src/sequence.js index 616f3e50..56d17be2 100644 --- a/src/sequence.js +++ b/src/sequence.js @@ -112,8 +112,8 @@ function buildAliases(files) { const parts = p.replace(/\.[^.]+$/, '').split('/'); const alias = parts .slice(-depth) - .join('/') - .replace(/[^a-zA-Z0-9_/-]/g, '_'); + .join('_') + .replace(/[^a-zA-Z0-9_-]/g, '_'); trial.set(p, alias); if (seen.has(alias)) allUnique = false; seen.add(alias); @@ -233,7 +233,9 @@ export function sequenceData(name, dbPath, opts = {}) { if (frontier.length === 0) break; if (d === maxDepth && frontier.length > 0) { - truncated = true; + // Only mark truncated if at least one frontier node has further callees + const hasMoreCalls = frontier.some((fid) => getCallees.all(fid).length > 0); + if (hasMoreCalls) truncated = true; } } @@ -345,7 +347,12 @@ export function sequenceData(name, dbPath, opts = {}) { totalMessages: messages.length, truncated, }; - return paginateResult(base, 'messages', { limit: opts.limit, offset: opts.offset }); + const result = paginateResult(base, 'messages', { limit: opts.limit, offset: opts.offset }); + if (opts.limit !== undefined || opts.offset !== undefined) { + const activeFiles = new Set(result.messages.flatMap((m) => [m.from, m.to])); + result.participants = result.participants.filter((p) => activeFiles.has(p.id)); + } + return result; } // ─── Mermaid formatter ─────────────────────────────────────────────── @@ -354,7 +361,11 @@ export function sequenceData(name, dbPath, opts = {}) { * Escape special Mermaid characters in labels. */ function escapeMermaid(str) { - return str.replace(/:/g, '#colon;').replace(/"/g, '#quot;'); + return str + .replace(//g, '>') + .replace(/:/g, '#colon;') + .replace(/"/g, '#quot;'); } /** diff --git a/tests/integration/sequence.test.js b/tests/integration/sequence.test.js index 3c44c133..3de2b37a 100644 --- a/tests/integration/sequence.test.js +++ b/tests/integration/sequence.test.js @@ -85,8 +85,8 @@ describe('sequenceData', () => { expect(data.entry).not.toBeNull(); expect(data.entry.name).toBe('buildGraph'); - // Should have 4 files as participants (builder, parser, resolve, src/utils/helper) - // (test file excluded by noTests, lib/utils/helper reachable via helperA) + // Should have 5 files as participants (builder, parser, resolve, src/utils/helper, lib/utils/helper) + // (test file excluded by noTests) expect(data.participants.length).toBe(5); // Messages should be in BFS depth order @@ -141,6 +141,11 @@ describe('sequenceData', () => { // IDs should be distinct const ids = helperParticipants.map((p) => p.id); expect(ids[0]).not.toBe(ids[1]); + + // IDs must be valid Mermaid participant identifiers (no slashes, etc.) + for (const id of ids) { + expect(id).toMatch(/^[a-zA-Z0-9_-]+$/); + } }); test('noTests filtering — test file nodes excluded', () => { From 0db9017520e7ea14e4e7a8cd0d379b8eed17053c Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:15:52 -0700 Subject: [PATCH 09/17] ci: retrigger workflows --- src/sequence.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sequence.js b/src/sequence.js index 56d17be2..7e7d115c 100644 --- a/src/sequence.js +++ b/src/sequence.js @@ -1,5 +1,5 @@ /** - * Sequence diagram generation — Mermaid sequenceDiagram from call graph edges. + * Sequence diagram generation – Mermaid sequenceDiagram from call graph edges. * * Participants are files (not individual functions). Calls within the same file * become self-messages. This keeps diagrams readable and matches typical From 995c816f3a5149f151c91659008cfb642cdb9a47 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:24:24 -0700 Subject: [PATCH 10/17] =?UTF-8?q?fix:=20address=20round-3=20review=20?= =?UTF-8?q?=E2=80=94=20deduplicate=20findBestMatch,=20guard=20truncation,?= =?UTF-8?q?=20add=20dataflow=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export findMatchingNodes from queries.js; remove duplicated findBestMatch from flow.js and sequence.js, replacing with findMatchingNodes(...)[0] ?? null - Guard sequenceToMermaid truncation note on participants.length > 0 to prevent invalid "note right of undefined:" in Mermaid output - Add dataflow annotation test suite with dedicated fixture DB: covers return arrows, parameter annotations, disabled-dataflow path, and Mermaid dashed arrow output Impact: 4 functions changed, 27 affected --- src/flow.js | 73 ++------------------------ src/queries.js | 2 +- src/sequence.js | 76 ++------------------------- tests/integration/sequence.test.js | 82 ++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 142 deletions(-) diff --git a/src/flow.js b/src/flow.js index 23224bf5..61f39504 100644 --- a/src/flow.js +++ b/src/flow.js @@ -7,7 +7,7 @@ import { openReadonlyOrFail } from './db.js'; import { paginateResult, printNdjson } from './paginate.js'; -import { isTestFile, kindIcon } from './queries.js'; +import { findMatchingNodes, isTestFile, kindIcon } from './queries.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; /** @@ -95,12 +95,12 @@ export function flowData(name, dbPath, opts = {}) { const noTests = opts.noTests || false; // Phase 1: Direct LIKE match on full name - let matchNode = findBestMatch(db, name, opts); + let matchNode = findMatchingNodes(db, name, opts)[0] ?? null; // Phase 2: Prefix-stripped matching — try adding framework prefixes if (!matchNode) { for (const prefix of FRAMEWORK_ENTRY_PREFIXES) { - matchNode = findBestMatch(db, `${prefix}${name}`, opts); + matchNode = findMatchingNodes(db, `${prefix}${name}`, opts)[0] ?? null; if (matchNode) break; } } @@ -219,73 +219,6 @@ export function flowData(name, dbPath, opts = {}) { return paginateResult(base, 'steps', { limit: opts.limit, offset: opts.offset }); } -/** - * Find the best matching node using the same relevance scoring as queries.js findMatchingNodes. - */ -function findBestMatch(db, name, opts = {}) { - const kinds = opts.kind - ? [opts.kind] - : [ - 'function', - 'method', - 'class', - 'interface', - 'type', - 'struct', - 'enum', - 'trait', - 'record', - 'module', - ]; - const placeholders = kinds.map(() => '?').join(', '); - const params = [`%${name}%`, ...kinds]; - - let fileCondition = ''; - if (opts.file) { - fileCondition = ' AND n.file LIKE ?'; - params.push(`%${opts.file}%`); - } - - const rows = db - .prepare( - `SELECT n.*, COALESCE(fi.cnt, 0) AS fan_in - FROM nodes n - LEFT JOIN ( - SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id - ) fi ON fi.target_id = n.id - WHERE n.name LIKE ? AND n.kind IN (${placeholders})${fileCondition}`, - ) - .all(...params); - - const noTests = opts.noTests || false; - const nodes = noTests ? rows.filter((n) => !isTestFile(n.file)) : rows; - - if (nodes.length === 0) return null; - - const lowerQuery = name.toLowerCase(); - for (const node of nodes) { - const lowerName = node.name.toLowerCase(); - const bareName = lowerName.includes('.') ? lowerName.split('.').pop() : lowerName; - - let matchScore; - if (lowerName === lowerQuery || bareName === lowerQuery) { - matchScore = 100; - } else if (lowerName.startsWith(lowerQuery) || bareName.startsWith(lowerQuery)) { - matchScore = 60; - } else if (lowerName.includes(`.${lowerQuery}`) || lowerName.includes(`${lowerQuery}.`)) { - matchScore = 40; - } else { - matchScore = 10; - } - - const fanInBonus = Math.min(Math.log2(node.fan_in + 1) * 5, 25); - node._relevance = matchScore + fanInBonus; - } - - nodes.sort((a, b) => b._relevance - a._relevance); - return nodes[0]; -} - /** * CLI formatter — text or JSON output. */ diff --git a/src/queries.js b/src/queries.js index a35f4f46..27915091 100644 --- a/src/queries.js +++ b/src/queries.js @@ -163,7 +163,7 @@ function resolveMethodViaHierarchy(db, methodName) { * Find nodes matching a name query, ranked by relevance. * Scoring: exact=100, prefix=60, word-boundary=40, substring=10, plus fan-in tiebreaker. */ -function findMatchingNodes(db, name, opts = {}) { +export function findMatchingNodes(db, name, opts = {}) { const kinds = opts.kind ? [opts.kind] : FUNCTION_KINDS; const placeholders = kinds.map(() => '?').join(', '); const params = [`%${name}%`, ...kinds]; diff --git a/src/sequence.js b/src/sequence.js index 7e7d115c..cb9c2541 100644 --- a/src/sequence.js +++ b/src/sequence.js @@ -8,75 +8,9 @@ import { openReadonlyOrFail } from './db.js'; import { paginateResult, printNdjson } from './paginate.js'; -import { isTestFile, kindIcon } from './queries.js'; +import { findMatchingNodes, isTestFile, kindIcon } from './queries.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; -// ─── findBestMatch (copied from flow.js — same pattern) ───────────── - -function findBestMatch(db, name, opts = {}) { - const kinds = opts.kind - ? [opts.kind] - : [ - 'function', - 'method', - 'class', - 'interface', - 'type', - 'struct', - 'enum', - 'trait', - 'record', - 'module', - ]; - const placeholders = kinds.map(() => '?').join(', '); - const params = [`%${name}%`, ...kinds]; - - let fileCondition = ''; - if (opts.file) { - fileCondition = ' AND n.file LIKE ?'; - params.push(`%${opts.file}%`); - } - - const rows = db - .prepare( - `SELECT n.*, COALESCE(fi.cnt, 0) AS fan_in - FROM nodes n - LEFT JOIN ( - SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id - ) fi ON fi.target_id = n.id - WHERE n.name LIKE ? AND n.kind IN (${placeholders})${fileCondition}`, - ) - .all(...params); - - const noTests = opts.noTests || false; - const nodes = noTests ? rows.filter((n) => !isTestFile(n.file)) : rows; - - if (nodes.length === 0) return null; - - const lowerQuery = name.toLowerCase(); - for (const node of nodes) { - const lowerName = node.name.toLowerCase(); - const bareName = lowerName.includes('.') ? lowerName.split('.').pop() : lowerName; - - let matchScore; - if (lowerName === lowerQuery || bareName === lowerQuery) { - matchScore = 100; - } else if (lowerName.startsWith(lowerQuery) || bareName.startsWith(lowerQuery)) { - matchScore = 60; - } else if (lowerName.includes(`.${lowerQuery}`) || lowerName.includes(`${lowerQuery}.`)) { - matchScore = 40; - } else { - matchScore = 10; - } - - const fanInBonus = Math.min(Math.log2(node.fan_in + 1) * 5, 25); - node._relevance = matchScore + fanInBonus; - } - - nodes.sort((a, b) => b._relevance - a._relevance); - return nodes[0]; -} - // ─── Alias generation ──────────────────────────────────────────────── /** @@ -156,12 +90,12 @@ export function sequenceData(name, dbPath, opts = {}) { const withDataflow = opts.dataflow || false; // Phase 1: Direct LIKE match - let matchNode = findBestMatch(db, name, opts); + let matchNode = findMatchingNodes(db, name, opts)[0] ?? null; // Phase 2: Prefix-stripped matching if (!matchNode) { for (const prefix of FRAMEWORK_ENTRY_PREFIXES) { - matchNode = findBestMatch(db, `${prefix}${name}`, opts); + matchNode = findMatchingNodes(db, `${prefix}${name}`, opts)[0] ?? null; if (matchNode) break; } } @@ -385,9 +319,9 @@ export function sequenceToMermaid(seqResult) { lines.push(` ${msg.from}${arrow}${msg.to}: ${escapeMermaid(msg.label)}`); } - if (seqResult.truncated) { + if (seqResult.truncated && seqResult.participants.length > 0) { lines.push( - ` note right of ${seqResult.participants[0]?.id}: Truncated at depth ${seqResult.depth}`, + ` note right of ${seqResult.participants[0].id}: Truncated at depth ${seqResult.depth}`, ); } diff --git a/tests/integration/sequence.test.js b/tests/integration/sequence.test.js index 3de2b37a..6d3367e4 100644 --- a/tests/integration/sequence.test.js +++ b/tests/integration/sequence.test.js @@ -184,6 +184,18 @@ describe('sequenceToMermaid', () => { expect(mermaid).toContain('Truncated at depth'); }); + test('no truncation note when participants empty (offset past all messages)', () => { + const mockData = { + participants: [], + messages: [], + truncated: true, + depth: 5, + }; + const mermaid = sequenceToMermaid(mockData); + expect(mermaid).not.toContain('note right of'); + expect(mermaid).not.toContain('undefined'); + }); + test('escapes colons in labels', () => { const mockData = { participants: [{ id: 'a', label: 'a.js' }], @@ -195,3 +207,73 @@ describe('sequenceToMermaid', () => { expect(mermaid).not.toContain('route:'); }); }); + +// ─── Dataflow annotations ─────────────────────────────────────────────── + +describe('dataflow annotations', () => { + let dfTmpDir, dfDbPath; + + beforeAll(() => { + dfTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-seq-df-')); + fs.mkdirSync(path.join(dfTmpDir, '.codegraph')); + dfDbPath = path.join(dfTmpDir, '.codegraph', 'graph.db'); + + const db = new Database(dfDbPath); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // caller → callee (cross-file) + const caller = insertNode(db, 'handleRequest', 'function', 'src/handler.js', 1); + const callee = insertNode(db, 'fetchData', 'function', 'src/service.js', 10); + insertEdge(db, caller, callee, 'calls'); + + // dataflow: fetchData returns 'Promise' and receives param 'userId' + db.prepare( + 'INSERT INTO dataflow (source_id, target_id, kind, param_index, expression) VALUES (?, ?, ?, ?, ?)', + ).run(callee, caller, 'returns', null, 'Promise'); + db.prepare( + 'INSERT INTO dataflow (source_id, target_id, kind, param_index, expression) VALUES (?, ?, ?, ?, ?)', + ).run(caller, callee, 'flows_to', 0, 'userId'); + + db.close(); + }); + + afterAll(() => { + if (dfTmpDir) fs.rmSync(dfTmpDir, { recursive: true, force: true }); + }); + + test('return arrows appear with dataflow enabled', () => { + const data = sequenceData('handleRequest', dfDbPath, { noTests: true, dataflow: true }); + expect(data.entry).not.toBeNull(); + + const returnMsgs = data.messages.filter((m) => m.type === 'return'); + expect(returnMsgs.length).toBe(1); + expect(returnMsgs[0].label).toBe('Promise'); + // Return goes from callee file back to caller file + expect(returnMsgs[0].from).not.toBe(returnMsgs[0].to); + }); + + test('call labels annotated with parameter names', () => { + const data = sequenceData('handleRequest', dfDbPath, { noTests: true, dataflow: true }); + + const callMsgs = data.messages.filter((m) => m.type === 'call'); + expect(callMsgs.length).toBe(1); + expect(callMsgs[0].label).toBe('fetchData(userId)'); + }); + + test('without dataflow flag, no return arrows or param annotations', () => { + const data = sequenceData('handleRequest', dfDbPath, { noTests: true, dataflow: false }); + + const returnMsgs = data.messages.filter((m) => m.type === 'return'); + expect(returnMsgs).toHaveLength(0); + + const callMsgs = data.messages.filter((m) => m.type === 'call'); + expect(callMsgs[0].label).toBe('fetchData'); + }); + + test('mermaid output has dashed return arrow', () => { + const data = sequenceData('handleRequest', dfDbPath, { noTests: true, dataflow: true }); + const mermaid = sequenceToMermaid(data); + expect(mermaid).toContain('-->>'); + }); +}); From c97bdee2c426ebd2eb62f585b695d88be4647199 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:04:40 -0600 Subject: [PATCH 11/17] chore: add hook to block "generated with" in PR bodies --- .claude/hooks/guard-pr-body.sh | 13 +++++++++++++ .claude/settings.json | 5 +++++ 2 files changed, 18 insertions(+) create mode 100644 .claude/hooks/guard-pr-body.sh diff --git a/.claude/hooks/guard-pr-body.sh b/.claude/hooks/guard-pr-body.sh new file mode 100644 index 00000000..51d18cef --- /dev/null +++ b/.claude/hooks/guard-pr-body.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Block PR creation if the body contains "generated with" (case-insensitive) + +input="$CLAUDE_TOOL_INPUT" + +# Only check gh pr create commands +echo "$input" | grep -qi 'gh pr create' || exit 0 + +# Block if body contains "generated with" +if echo "$input" | grep -qi 'generated with'; then + echo "BLOCK: Remove any 'Generated with ...' line from the PR body." >&2 + exit 2 +fi diff --git a/.claude/settings.json b/.claude/settings.json index 4ffe2530..6c059786 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -14,6 +14,11 @@ "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-git.sh\"", "timeout": 10 }, + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-pr-body.sh\"", + "timeout": 10 + }, { "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/show-diff-impact.sh\"", From 95c5c66c71c6fca69e24b11a079cafbcc7028671 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:20:54 -0600 Subject: [PATCH 12/17] feat: add pre-commit hooks for cycle detection and dead export checks Add two new PreToolUse hooks that block git commits when issues are detected: - check-cycles.sh: runs `codegraph check --staged` and blocks if NEW circular dependencies are introduced (compares against baseline count) - check-dead-exports.sh: checks staged src/ files for newly added exports with zero consumers (diff-aware, ignores pre-existing dead exports) Also wire the --unused flag on the exports CLI command, adding totalUnused to all output formats. Impact: 4 functions changed, 5 affected --- .claude/hooks/check-cycles.sh | 115 ++++++++++++++++++++++++++++ .claude/hooks/check-dead-exports.sh | 103 +++++++++++++++++++++++++ .claude/settings.json | 10 +++ src/cli.js | 2 + src/queries.js | 21 +++-- 5 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 .claude/hooks/check-cycles.sh create mode 100644 .claude/hooks/check-dead-exports.sh diff --git a/.claude/hooks/check-cycles.sh b/.claude/hooks/check-cycles.sh new file mode 100644 index 00000000..6befb9e6 --- /dev/null +++ b/.claude/hooks/check-cycles.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# check-cycles.sh — PreToolUse hook for Bash (git commit) +# Blocks commits that introduce NEW circular dependencies (compares HEAD baseline). + +set -euo pipefail + +INPUT=$(cat) + +# Extract the command from tool_input JSON +COMMAND=$(echo "$INPUT" | node -e " + let d=''; + process.stdin.on('data',c=>d+=c); + process.stdin.on('end',()=>{ + const p=JSON.parse(d).tool_input?.command||''; + if(p)process.stdout.write(p); + }); +" 2>/dev/null) || true + +if [ -z "$COMMAND" ]; then + exit 0 +fi + +# Only trigger on git commit commands +if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit\b'; then + exit 0 +fi + +# Guard: codegraph DB must exist +WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}" +if [ ! -f "$WORK_ROOT/.codegraph/graph.db" ]; then + exit 0 +fi + +# Guard: must have staged changes +STAGED=$(git diff --cached --name-only 2>/dev/null) || true +if [ -z "$STAGED" ]; then + exit 0 +fi + +# Count current cycles involving staged files +RESULT=$(node "$WORK_ROOT/src/cli.js" check --staged --json -T 2>/dev/null) || true +if [ -z "$RESULT" ]; then + exit 0 +fi + +# Extract cycle count and details; compare against HEAD baseline +NEW_CYCLES=$(echo "$RESULT" | node -e " + let d=''; + process.stdin.on('data',c=>d+=c); + process.stdin.on('end',()=>{ + try { + const data=JSON.parse(d); + const cyclesPred=(data.predicates||[]).find(p=>p.name==='cycles'); + if(!cyclesPred || cyclesPred.passed) return; + const cycles=cyclesPred.cycles||[]; + // Compare with HEAD: run cycles on HEAD to get baseline count + // Since we can't run two builds, check if cycles involve NEW files + const newFiles=new Set((data.summary?.newFiles > 0) ? + [...(data._newFiles||[])] : []); + // If all cycles existed before (no new files involved), skip + // For now, report cycle count — the predicate already scopes to changed files + // so any cycle here involves a file the user touched + const summary=cycles.slice(0,5).map(c=>c.join(' -> ')).join('\\n '); + const extra=cycles.length>5?'\\n ... and '+(cycles.length-5)+' more':''; + process.stdout.write(summary+extra); + }catch{} + }); +" 2>/dev/null) || true + +# For diff-awareness: get baseline cycle count from HEAD +# The check --staged predicate uses the *current* graph DB which reflects the working tree. +# If cycles exist in the DB before our changes, they're pre-existing. +# We compare: cycles on HEAD (via diff against HEAD~1) vs cycles on staged. +BASELINE_COUNT=$(node -e " + const {findCycles}=require('./src/cycles.js'); + const {openReadonlyOrFail}=require('./src/db.js'); + try { + const db=openReadonlyOrFail(); + const cycles=findCycles(db,{fileLevel:true,noTests:true}); + process.stdout.write(String(cycles.length)); + db.close(); + }catch{process.stdout.write('0');} +" 2>/dev/null) || echo "0" + +CURRENT_COUNT=$(echo "$RESULT" | node -e " + let d=''; + process.stdin.on('data',c=>d+=c); + process.stdin.on('end',()=>{ + try { + const data=JSON.parse(d); + const cyclesPred=(data.predicates||[]).find(p=>p.name==='cycles'); + process.stdout.write(String((cyclesPred?.cycles||[]).length)); + }catch{process.stdout.write('0');} + }); +" 2>/dev/null) || echo "0" + +# Only block if cycle count increased (new cycles introduced) +if [ "$CURRENT_COUNT" -gt "$BASELINE_COUNT" ] 2>/dev/null; then + REASON="BLOCKED: New circular dependencies introduced (was $BASELINE_COUNT, now $CURRENT_COUNT): + $NEW_CYCLES +Fix the new cycles before committing." + + node -e " + console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: process.argv[1] + } + })); + " "$REASON" + exit 0 +fi + +exit 0 diff --git a/.claude/hooks/check-dead-exports.sh b/.claude/hooks/check-dead-exports.sh new file mode 100644 index 00000000..7332bf94 --- /dev/null +++ b/.claude/hooks/check-dead-exports.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# check-dead-exports.sh — PreToolUse hook for Bash (git commit) +# Blocks commits that introduce NEW unused exports in staged files. +# Compares against the pre-change state so pre-existing dead exports are ignored. + +set -euo pipefail + +INPUT=$(cat) + +# Extract the command from tool_input JSON +COMMAND=$(echo "$INPUT" | node -e " + let d=''; + process.stdin.on('data',c=>d+=c); + process.stdin.on('end',()=>{ + const p=JSON.parse(d).tool_input?.command||''; + if(p)process.stdout.write(p); + }); +" 2>/dev/null) || true + +if [ -z "$COMMAND" ]; then + exit 0 +fi + +# Only trigger on git commit commands +if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit\b'; then + exit 0 +fi + +# Guard: codegraph DB must exist +WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}" +if [ ! -f "$WORK_ROOT/.codegraph/graph.db" ]; then + exit 0 +fi + +# Guard: must have staged changes +STAGED=$(git diff --cached --name-only 2>/dev/null) || true +if [ -z "$STAGED" ]; then + exit 0 +fi + +# For each staged src/ file, compare current unused count against the +# count from the staged diff's added lines. Only flag truly new dead exports. +NEW_DEAD="" + +while IFS= read -r file; do + # Only check source files + case "$file" in + src/*.js|src/*.ts|src/*.tsx) ;; + *) continue ;; + esac + + # Get current unused exports for this file + RESULT=$(node "$WORK_ROOT/src/cli.js" exports "$file" --json -T 2>/dev/null) || true + if [ -z "$RESULT" ]; then + continue + fi + + # Get names of new functions added in the staged diff for this file + ADDED_NAMES=$(git diff --cached -U0 "$file" 2>/dev/null | grep -E '^\+.*(export\s+(function|const|class|async\s+function)|module\.exports)' | grep -oP '(?:function|const|class)\s+\K\w+' 2>/dev/null) || true + + # If no new exports were added in the diff, skip this file + if [ -z "$ADDED_NAMES" ]; then + continue + fi + + # Check if any of the newly added exports have zero consumers + NEWLY_DEAD=$(echo "$RESULT" | node -e " + const added=new Set(process.argv.slice(2)); + let d=''; + process.stdin.on('data',c=>d+=c); + process.stdin.on('end',()=>{ + try { + const data=JSON.parse(d); + const dead=(data.results||[]) + .filter(r=>r.consumerCount===0 && added.has(r.name)); + if(dead.length>0){ + process.stdout.write(dead.map(u=>u.name+' ('+data.file+':'+u.line+')').join(', ')); + } + }catch{} + }); + " -- $ADDED_NAMES 2>/dev/null) || true + + if [ -n "$NEWLY_DEAD" ]; then + NEW_DEAD="${NEW_DEAD:+$NEW_DEAD; }$NEWLY_DEAD" + fi +done <<< "$STAGED" + +if [ -n "$NEW_DEAD" ]; then + REASON="BLOCKED: Newly added exports with zero consumers detected: $NEW_DEAD. Either add consumers, remove the exports, or verify these are intentionally public API." + + node -e " + console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: process.argv[1] + } + })); + " "$REASON" + exit 0 +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 6c059786..68fba780 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -23,6 +23,16 @@ "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/show-diff-impact.sh\"", "timeout": 15 + }, + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-cycles.sh\"", + "timeout": 15 + }, + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-dead-exports.sh\"", + "timeout": 30 } ] }, diff --git a/src/cli.js b/src/cli.js index 41c94a6f..aa008e92 100644 --- a/src/cli.js +++ b/src/cli.js @@ -276,11 +276,13 @@ program .option('-j, --json', 'Output as JSON') .option('--limit ', 'Max results to return') .option('--offset ', 'Skip N results (default: 0)') + .option('--unused', 'Show only exports with zero consumers (dead exports)') .option('--ndjson', 'Newline-delimited JSON output') .action((file, opts) => { fileExports(file, opts.db, { noTests: resolveNoTests(opts), json: opts.json, + unused: opts.unused || false, limit: opts.limit ? parseInt(opts.limit, 10) : undefined, offset: opts.offset ? parseInt(opts.offset, 10) : undefined, ndjson: opts.ndjson, diff --git a/src/queries.js b/src/queries.js index 27915091..9f88a719 100644 --- a/src/queries.js +++ b/src/queries.js @@ -3134,7 +3134,7 @@ export function roles(customDbPath, opts = {}) { // ─── exportsData ───────────────────────────────────────────────────── -function exportsFileImpl(db, target, noTests, getFileLines) { +function exportsFileImpl(db, target, noTests, getFileLines, unused) { const fileNodes = db .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`) .all(`%${target}%`); @@ -3194,12 +3194,20 @@ function exportsFileImpl(db, target, noTests, getFileLines) { .all(fn.id) .map((r) => ({ file: r.file })); + const totalUnused = results.filter((r) => r.consumerCount === 0).length; + + let filteredResults = results; + if (unused) { + filteredResults = results.filter((r) => r.consumerCount === 0); + } + return { file: fn.file, - results, + results: filteredResults, reexports, totalExported: exported.length, totalInternal: internalCount, + totalUnused, }; }); } @@ -3229,12 +3237,13 @@ export function exportsData(file, customDbPath, opts = {}) { } } - const fileResults = exportsFileImpl(db, file, noTests, getFileLines); + const unused = opts.unused || false; + const fileResults = exportsFileImpl(db, file, noTests, getFileLines, unused); db.close(); if (fileResults.length === 0) { return paginateResult( - { file, results: [], reexports: [], totalExported: 0, totalInternal: 0 }, + { file, results: [], reexports: [], totalExported: 0, totalInternal: 0, totalUnused: 0 }, 'results', { limit: opts.limit, offset: opts.offset }, ); @@ -3248,6 +3257,7 @@ export function exportsData(file, customDbPath, opts = {}) { reexports: first.reexports, totalExported: first.totalExported, totalInternal: first.totalInternal, + totalUnused: first.totalUnused, }; return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); } @@ -3268,8 +3278,9 @@ export function fileExports(file, customDbPath, opts = {}) { return; } + const unusedNote = data.totalUnused > 0 ? `, ${data.totalUnused} unused` : ''; console.log( - `\n# ${data.file} — ${data.totalExported} exported, ${data.totalInternal} internal\n`, + `\n# ${data.file} — ${data.totalExported} exported, ${data.totalInternal} internal${unusedNote}\n`, ); for (const sym of data.results) { From f8ff77560dcc9011459beabcdd7532aedde5e974 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:23:31 -0600 Subject: [PATCH 13/17] fix: scope cycle and dead-export hooks to session-edited files Update both hooks to use the session edit log so pre-existing issues in untouched files don't block commits. Add hook descriptions to recommended-practices.md. --- .claude/hooks/check-cycles.sh | 71 ++++++++++------------------ .claude/hooks/check-dead-exports.sh | 50 ++++++++++---------- docs/guides/recommended-practices.md | 6 +++ 3 files changed, 56 insertions(+), 71 deletions(-) diff --git a/.claude/hooks/check-cycles.sh b/.claude/hooks/check-cycles.sh index 6befb9e6..3885c407 100644 --- a/.claude/hooks/check-cycles.sh +++ b/.claude/hooks/check-cycles.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # check-cycles.sh — PreToolUse hook for Bash (git commit) -# Blocks commits that introduce NEW circular dependencies (compares HEAD baseline). +# Blocks commits if circular dependencies involve files edited in this session. set -euo pipefail @@ -37,14 +37,23 @@ if [ -z "$STAGED" ]; then exit 0 fi -# Count current cycles involving staged files +# Load session edit log +LOG_FILE="$WORK_ROOT/.claude/session-edits.log" +if [ ! -f "$LOG_FILE" ] || [ ! -s "$LOG_FILE" ]; then + exit 0 +fi +EDITED_FILES=$(awk '{print $2}' "$LOG_FILE" | sort -u) + +# Run check with cycles predicate on staged changes RESULT=$(node "$WORK_ROOT/src/cli.js" check --staged --json -T 2>/dev/null) || true + if [ -z "$RESULT" ]; then exit 0 fi -# Extract cycle count and details; compare against HEAD baseline -NEW_CYCLES=$(echo "$RESULT" | node -e " +# Check if cycles predicate failed — but only block if a cycle involves +# a file that was edited in this session +CYCLES_FAILED=$(echo "$RESULT" | EDITED="$EDITED_FILES" node -e " let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{ @@ -52,53 +61,23 @@ NEW_CYCLES=$(echo "$RESULT" | node -e " const data=JSON.parse(d); const cyclesPred=(data.predicates||[]).find(p=>p.name==='cycles'); if(!cyclesPred || cyclesPred.passed) return; - const cycles=cyclesPred.cycles||[]; - // Compare with HEAD: run cycles on HEAD to get baseline count - // Since we can't run two builds, check if cycles involve NEW files - const newFiles=new Set((data.summary?.newFiles > 0) ? - [...(data._newFiles||[])] : []); - // If all cycles existed before (no new files involved), skip - // For now, report cycle count — the predicate already scopes to changed files - // so any cycle here involves a file the user touched - const summary=cycles.slice(0,5).map(c=>c.join(' -> ')).join('\\n '); - const extra=cycles.length>5?'\\n ... and '+(cycles.length-5)+' more':''; + const edited=new Set(process.env.EDITED.split('\\n').filter(Boolean)); + // Filter to cycles that involve at least one file we edited + const relevant=(cyclesPred.cycles||[]).filter( + cycle=>cycle.some(f=>edited.has(f)) + ); + if(relevant.length===0) return; + const summary=relevant.slice(0,5).map(c=>c.join(' -> ')).join('\\n '); + const extra=relevant.length>5?'\\n ... and '+(relevant.length-5)+' more':''; process.stdout.write(summary+extra); }catch{} }); " 2>/dev/null) || true -# For diff-awareness: get baseline cycle count from HEAD -# The check --staged predicate uses the *current* graph DB which reflects the working tree. -# If cycles exist in the DB before our changes, they're pre-existing. -# We compare: cycles on HEAD (via diff against HEAD~1) vs cycles on staged. -BASELINE_COUNT=$(node -e " - const {findCycles}=require('./src/cycles.js'); - const {openReadonlyOrFail}=require('./src/db.js'); - try { - const db=openReadonlyOrFail(); - const cycles=findCycles(db,{fileLevel:true,noTests:true}); - process.stdout.write(String(cycles.length)); - db.close(); - }catch{process.stdout.write('0');} -" 2>/dev/null) || echo "0" - -CURRENT_COUNT=$(echo "$RESULT" | node -e " - let d=''; - process.stdin.on('data',c=>d+=c); - process.stdin.on('end',()=>{ - try { - const data=JSON.parse(d); - const cyclesPred=(data.predicates||[]).find(p=>p.name==='cycles'); - process.stdout.write(String((cyclesPred?.cycles||[]).length)); - }catch{process.stdout.write('0');} - }); -" 2>/dev/null) || echo "0" - -# Only block if cycle count increased (new cycles introduced) -if [ "$CURRENT_COUNT" -gt "$BASELINE_COUNT" ] 2>/dev/null; then - REASON="BLOCKED: New circular dependencies introduced (was $BASELINE_COUNT, now $CURRENT_COUNT): - $NEW_CYCLES -Fix the new cycles before committing." +if [ -n "$CYCLES_FAILED" ]; then + REASON="BLOCKED: Circular dependencies detected involving files you edited: + $CYCLES_FAILED +Fix the cycles before committing." node -e " console.log(JSON.stringify({ diff --git a/.claude/hooks/check-dead-exports.sh b/.claude/hooks/check-dead-exports.sh index 7332bf94..4f8140b7 100644 --- a/.claude/hooks/check-dead-exports.sh +++ b/.claude/hooks/check-dead-exports.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # check-dead-exports.sh — PreToolUse hook for Bash (git commit) -# Blocks commits that introduce NEW unused exports in staged files. -# Compares against the pre-change state so pre-existing dead exports are ignored. +# Blocks commits if any src/ file edited in THIS SESSION has exports with zero consumers. +# Uses the session edit log to scope checks to files you actually touched. set -euo pipefail @@ -38,9 +38,15 @@ if [ -z "$STAGED" ]; then exit 0 fi -# For each staged src/ file, compare current unused count against the -# count from the staged diff's added lines. Only flag truly new dead exports. -NEW_DEAD="" +# Load session edit log to scope checks to files we actually edited +LOG_FILE="$WORK_ROOT/.claude/session-edits.log" +if [ ! -f "$LOG_FILE" ] || [ ! -s "$LOG_FILE" ]; then + exit 0 +fi +EDITED_FILES=$(awk '{print $2}' "$LOG_FILE" | sort -u) + +# Check each staged source file that was edited in this session +DEAD_EXPORTS="" while IFS= read -r file; do # Only check source files @@ -49,44 +55,38 @@ while IFS= read -r file; do *) continue ;; esac - # Get current unused exports for this file - RESULT=$(node "$WORK_ROOT/src/cli.js" exports "$file" --json -T 2>/dev/null) || true - if [ -z "$RESULT" ]; then + # Only check files edited in this session + if ! echo "$EDITED_FILES" | grep -qxF "$file"; then continue fi - # Get names of new functions added in the staged diff for this file - ADDED_NAMES=$(git diff --cached -U0 "$file" 2>/dev/null | grep -E '^\+.*(export\s+(function|const|class|async\s+function)|module\.exports)' | grep -oP '(?:function|const|class)\s+\K\w+' 2>/dev/null) || true - - # If no new exports were added in the diff, skip this file - if [ -z "$ADDED_NAMES" ]; then + RESULT=$(node "$WORK_ROOT/src/cli.js" exports "$file" --unused --json -T 2>/dev/null) || true + if [ -z "$RESULT" ]; then continue fi - # Check if any of the newly added exports have zero consumers - NEWLY_DEAD=$(echo "$RESULT" | node -e " - const added=new Set(process.argv.slice(2)); + # Extract unused export names + UNUSED=$(echo "$RESULT" | node -e " let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{ try { const data=JSON.parse(d); - const dead=(data.results||[]) - .filter(r=>r.consumerCount===0 && added.has(r.name)); - if(dead.length>0){ - process.stdout.write(dead.map(u=>u.name+' ('+data.file+':'+u.line+')').join(', ')); + const unused=(data.results||[]).filter(r=>r.consumerCount===0); + if(unused.length>0){ + process.stdout.write(unused.map(u=>u.name+' ('+data.file+':'+u.line+')').join(', ')); } }catch{} }); - " -- $ADDED_NAMES 2>/dev/null) || true + " 2>/dev/null) || true - if [ -n "$NEWLY_DEAD" ]; then - NEW_DEAD="${NEW_DEAD:+$NEW_DEAD; }$NEWLY_DEAD" + if [ -n "$UNUSED" ]; then + DEAD_EXPORTS="${DEAD_EXPORTS:+$DEAD_EXPORTS; }$UNUSED" fi done <<< "$STAGED" -if [ -n "$NEW_DEAD" ]; then - REASON="BLOCKED: Newly added exports with zero consumers detected: $NEW_DEAD. Either add consumers, remove the exports, or verify these are intentionally public API." +if [ -n "$DEAD_EXPORTS" ]; then + REASON="BLOCKED: Dead exports (zero consumers) detected in files you edited: $DEAD_EXPORTS. Either add consumers, remove the exports, or verify these are intentionally public API." node -e " console.log(JSON.stringify({ diff --git a/docs/guides/recommended-practices.md b/docs/guides/recommended-practices.md index f4bfdc6b..304d47f1 100644 --- a/docs/guides/recommended-practices.md +++ b/docs/guides/recommended-practices.md @@ -335,6 +335,10 @@ You can configure [Claude Code hooks](https://docs.anthropic.com/en/docs/claude- **Graph update hook** (PostToolUse on Edit/Write): keeps the graph incrementally updated after each file edit. Only changed files are re-parsed. +**Cycle check hook** (PreToolUse on Bash): when Claude runs `git commit`, the hook runs `codegraph check --staged --json -T` and checks if any circular dependencies involve files edited in this session. If found, blocks the commit with a `deny` decision listing the cycles. Uses the session edit log to scope checks — pre-existing cycles in untouched files don't trigger. + +**Dead export check hook** (PreToolUse on Bash): when Claude runs `git commit`, the hook runs `codegraph exports --unused --json -T` for each staged `src/` file that was edited in this session. If any export has zero consumers (dead code), blocks the commit. This catches accidentally introduced dead exports before they reach a PR. + **Git operation hook** (PostToolUse on Bash): detects `git rebase`, `git revert`, `git cherry-pick`, `git merge`, and `git pull` commands and automatically: (1) rebuilds the codegraph so dependency context stays fresh, (2) logs all files changed by the operation to `session-edits.log` so commit validation doesn't block rebase-modified files, and (3) clears stale entries from `codegraph-checked.log` so the edit reminder re-fires for affected files. Uses `ORIG_HEAD` (set by all these git operations) to detect which files changed. If the operation failed (e.g. merge conflicts), the diff safely returns nothing. > **Windows note:** If your hooks use bash scripts, normalize backslashes inside `node -e` rather than bash (`${VAR//\\//}` fails on Git Bash). See this repo's `.claude/hooks/enrich-context.sh` for the pattern. @@ -348,6 +352,8 @@ You can configure [Claude Code hooks](https://docs.anthropic.com/en/docs/claude- - `guard-git.sh` — blocks dangerous git commands + validates commits - `track-edits.sh` — logs edited files for commit validation - `track-moves.sh` — logs file moves/copies for commit validation +- `check-cycles.sh` — blocks commits if circular dependencies involve files you edited +- `check-dead-exports.sh` — blocks commits if files you edited contain exports with zero consumers #### Parallel session safety hooks From d5355505b491fa0ff09e6ae782b779986dc67d73 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:30:55 -0600 Subject: [PATCH 14/17] feat: add signature change warning hook with role-based risk levels Non-blocking PreToolUse hook that detects when staged changes modify function signatures. Enriches each violation with the symbol's role (core/utility/etc.) and transitive caller count, then injects a risk-rated warning (HIGH/MEDIUM/low) via additionalContext. --- .claude/hooks/warn-signature-changes.sh | 129 ++++++++++++++++++++++++ .claude/settings.json | 5 + docs/guides/recommended-practices.md | 3 + 3 files changed, 137 insertions(+) create mode 100644 .claude/hooks/warn-signature-changes.sh diff --git a/.claude/hooks/warn-signature-changes.sh b/.claude/hooks/warn-signature-changes.sh new file mode 100644 index 00000000..80d8cfda --- /dev/null +++ b/.claude/hooks/warn-signature-changes.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# warn-signature-changes.sh — PreToolUse hook for Bash (git commit) +# Warns when staged changes modify function signatures, highlighting risk +# level based on the symbol's role (core > utility > others). +# Informational only — never blocks. + +set -euo pipefail + +INPUT=$(cat) + +# Extract the command from tool_input JSON +COMMAND=$(echo "$INPUT" | node -e " + let d=''; + process.stdin.on('data',c=>d+=c); + process.stdin.on('end',()=>{ + const p=JSON.parse(d).tool_input?.command||''; + if(p)process.stdout.write(p); + }); +" 2>/dev/null) || true + +if [ -z "$COMMAND" ]; then + exit 0 +fi + +# Only trigger on git commit commands +if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit\b'; then + exit 0 +fi + +# Guard: codegraph DB must exist +WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}" +if [ ! -f "$WORK_ROOT/.codegraph/graph.db" ]; then + exit 0 +fi + +# Guard: must have staged changes +STAGED=$(git diff --cached --name-only 2>/dev/null) || true +if [ -z "$STAGED" ]; then + exit 0 +fi + +# Run check --staged to get signature violations, then enrich with role + caller count +WARNING=$(node -e " + const path = require('path'); + const { checkData } = require(path.join(process.argv[1], 'src/check.js')); + const { openReadonlyOrFail } = require(path.join(process.argv[1], 'src/db.js')); + + const result = checkData(undefined, { staged: true, noTests: true }); + if (!result || result.error) process.exit(0); + + const sigPred = (result.predicates || []).find(p => p.name === 'signatures'); + if (!sigPred || sigPred.passed || !sigPred.violations.length) process.exit(0); + + const db = openReadonlyOrFail(); + const lines = []; + + for (const v of sigPred.violations) { + // Get role from DB + const node = db.prepare( + 'SELECT role FROM nodes WHERE name = ? AND file = ? AND line = ?' + ).get(v.name, v.file, v.line); + const role = node?.role || 'unknown'; + + // Count transitive callers (BFS, depth 3) + const defNode = db.prepare( + 'SELECT id FROM nodes WHERE name = ? AND file = ? AND line = ?' + ).get(v.name, v.file, v.line); + + let callerCount = 0; + if (defNode) { + const visited = new Set([defNode.id]); + let frontier = [defNode.id]; + for (let d = 0; d < 3; d++) { + const next = []; + for (const fid of frontier) { + const callers = db.prepare( + 'SELECT DISTINCT n.id FROM edges e JOIN nodes n ON e.source_id = n.id WHERE e.target_id = ? AND e.kind = \\'calls\\'' + ).all(fid); + for (const c of callers) { + if (!visited.has(c.id)) { + visited.add(c.id); + next.push(c.id); + callerCount++; + } + } + } + frontier = next; + if (!frontier.length) break; + } + } + + const risk = role === 'core' ? 'HIGH' : role === 'utility' ? 'MEDIUM' : 'low'; + lines.push(risk + ': ' + v.name + ' (' + v.kind + ') [' + role + '] at ' + v.file + ':' + v.line + ' — ' + callerCount + ' transitive callers'); + } + + db.close(); + + if (lines.length > 0) { + process.stdout.write(lines.join('\\n')); + } +" "$WORK_ROOT" 2>/dev/null) || true + +if [ -z "$WARNING" ]; then + exit 0 +fi + +# Escape for JSON +ESCAPED=$(printf '%s' "$WARNING" | node -e " + let d=''; + process.stdin.on('data',c=>d+=c); + process.stdin.on('end',()=>process.stdout.write(JSON.stringify(d))); +" 2>/dev/null) || true + +if [ -z "$ESCAPED" ]; then + exit 0 +fi + +# Inject as additionalContext — informational, never blocks +node -e " + console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'allow', + additionalContext: '[codegraph] Signature changes detected in staged files:\\n' + JSON.parse(process.argv[1]) + } + })); +" "$ESCAPED" 2>/dev/null || true + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 68fba780..fe942475 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -33,6 +33,11 @@ "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-dead-exports.sh\"", "timeout": 30 + }, + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/warn-signature-changes.sh\"", + "timeout": 15 } ] }, diff --git a/docs/guides/recommended-practices.md b/docs/guides/recommended-practices.md index 304d47f1..030db0c8 100644 --- a/docs/guides/recommended-practices.md +++ b/docs/guides/recommended-practices.md @@ -339,6 +339,8 @@ You can configure [Claude Code hooks](https://docs.anthropic.com/en/docs/claude- **Dead export check hook** (PreToolUse on Bash): when Claude runs `git commit`, the hook runs `codegraph exports --unused --json -T` for each staged `src/` file that was edited in this session. If any export has zero consumers (dead code), blocks the commit. This catches accidentally introduced dead exports before they reach a PR. +**Signature change warning hook** (PreToolUse on Bash): when Claude runs `git commit`, the hook runs `codegraph check --staged` to detect modified function declaration lines, then enriches each violation with the symbol's role (`core`, `utility`, etc.) and transitive caller count from the graph. Injects a risk-rated summary via `additionalContext` — `HIGH` for core symbols, `MEDIUM` for utility, `low` for others. Non-blocking — the agent sees the warning and can decide whether the signature change is intentional. + **Git operation hook** (PostToolUse on Bash): detects `git rebase`, `git revert`, `git cherry-pick`, `git merge`, and `git pull` commands and automatically: (1) rebuilds the codegraph so dependency context stays fresh, (2) logs all files changed by the operation to `session-edits.log` so commit validation doesn't block rebase-modified files, and (3) clears stale entries from `codegraph-checked.log` so the edit reminder re-fires for affected files. Uses `ORIG_HEAD` (set by all these git operations) to detect which files changed. If the operation failed (e.g. merge conflicts), the diff safely returns nothing. > **Windows note:** If your hooks use bash scripts, normalize backslashes inside `node -e` rather than bash (`${VAR//\\//}` fails on Git Bash). See this repo's `.claude/hooks/enrich-context.sh` for the pattern. @@ -354,6 +356,7 @@ You can configure [Claude Code hooks](https://docs.anthropic.com/en/docs/claude- - `track-moves.sh` — logs file moves/copies for commit validation - `check-cycles.sh` — blocks commits if circular dependencies involve files you edited - `check-dead-exports.sh` — blocks commits if files you edited contain exports with zero consumers +- `warn-signature-changes.sh` — warns (non-blocking) when staged changes modify function signatures, with risk level based on symbol role and transitive caller count #### Parallel session safety hooks From 3bdc5ee2445e929f584c4b6d0365ff984a7c0500 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:33:01 -0600 Subject: [PATCH 15/17] fix: break circular dependency cycle and remove dead queryName export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract isTestFile into src/test-utils.js to break the owners.js → cycles.js → cochange.js → queries.js → boundaries.js cycle. Remove unused queryName display function (CLI uses fnDeps instead). Fix dead-export hook to count test-file consumers (removes false positives for test-only exports like clearCodeownersCache and re-exported queryNameData). Impact: 1 functions changed, 0 affected --- .claude/hooks/check-dead-exports.sh | 2 +- src/boundaries.js | 2 +- src/cochange.js | 2 +- src/cycles.js | 2 +- src/owners.js | 2 +- src/queries.js | 48 ++--------------------------- src/test-utils.js | 4 +++ tests/unit/queries-unit.test.js | 29 ----------------- 8 files changed, 12 insertions(+), 79 deletions(-) create mode 100644 src/test-utils.js diff --git a/.claude/hooks/check-dead-exports.sh b/.claude/hooks/check-dead-exports.sh index 4f8140b7..01ea8964 100644 --- a/.claude/hooks/check-dead-exports.sh +++ b/.claude/hooks/check-dead-exports.sh @@ -60,7 +60,7 @@ while IFS= read -r file; do continue fi - RESULT=$(node "$WORK_ROOT/src/cli.js" exports "$file" --unused --json -T 2>/dev/null) || true + RESULT=$(node "$WORK_ROOT/src/cli.js" exports "$file" --unused --json 2>/dev/null) || true if [ -z "$RESULT" ]; then continue fi diff --git a/src/boundaries.js b/src/boundaries.js index 78bd29e9..c813afa8 100644 --- a/src/boundaries.js +++ b/src/boundaries.js @@ -1,5 +1,5 @@ import { debug } from './logger.js'; -import { isTestFile } from './queries.js'; +import { isTestFile } from './test-utils.js'; // ─── Glob-to-Regex ─────────────────────────────────────────────────── diff --git a/src/cochange.js b/src/cochange.js index d1fb2ed3..94a32302 100644 --- a/src/cochange.js +++ b/src/cochange.js @@ -12,7 +12,7 @@ import { normalizePath } from './constants.js'; import { closeDb, findDbPath, initSchema, openDb, openReadonlyOrFail } from './db.js'; import { warn } from './logger.js'; import { paginateResult } from './paginate.js'; -import { isTestFile } from './queries.js'; +import { isTestFile } from './test-utils.js'; /** * Scan git history and return parsed commit data. diff --git a/src/cycles.js b/src/cycles.js index 675bce2f..29de27f2 100644 --- a/src/cycles.js +++ b/src/cycles.js @@ -1,5 +1,5 @@ import { loadNative } from './native.js'; -import { isTestFile } from './queries.js'; +import { isTestFile } from './test-utils.js'; /** * Detect circular dependencies in the codebase using Tarjan's SCC algorithm. diff --git a/src/owners.js b/src/owners.js index fb8681df..6e954450 100644 --- a/src/owners.js +++ b/src/owners.js @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { findDbPath, openReadonlyOrFail } from './db.js'; -import { isTestFile } from './queries.js'; +import { isTestFile } from './test-utils.js'; // ─── CODEOWNERS Parsing ────────────────────────────────────────────── diff --git a/src/queries.js b/src/queries.js index 9f88a719..7900c6ad 100644 --- a/src/queries.js +++ b/src/queries.js @@ -10,6 +10,9 @@ import { debug } from './logger.js'; import { ownersForFiles } from './owners.js'; import { paginateResult, printNdjson } from './paginate.js'; import { LANGUAGE_REGISTRY } from './parser.js'; +import { isTestFile } from './test-utils.js'; + +export { isTestFile }; /** * Resolve a file path relative to repoRoot, rejecting traversal outside the repo. @@ -21,11 +24,6 @@ function safePath(repoRoot, file) { return resolved; } -const TEST_PATTERN = /\.(test|spec)\.|__test__|__tests__|\.stories\./; -export function isTestFile(filePath) { - return TEST_PATTERN.test(filePath); -} - export const FALSE_POSITIVE_NAMES = new Set([ 'run', 'get', @@ -1766,46 +1764,6 @@ export async function stats(customDbPath, opts = {}) { console.log(); } -// ─── Human-readable output (original formatting) ─────────────────────── - -export function queryName(name, customDbPath, opts = {}) { - const data = queryNameData(name, customDbPath, { - noTests: opts.noTests, - limit: opts.limit, - offset: opts.offset, - }); - if (opts.ndjson) { - printNdjson(data, 'results'); - return; - } - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } - if (data.results.length === 0) { - console.log(`No results for "${name}"`); - return; - } - - console.log(`\nResults for "${name}":\n`); - for (const r of data.results) { - console.log(` ${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}`); - if (r.callees.length > 0) { - console.log(` -> calls/uses:`); - for (const c of r.callees.slice(0, 15)) - console.log(` -> ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`); - if (r.callees.length > 15) console.log(` ... and ${r.callees.length - 15} more`); - } - if (r.callers.length > 0) { - console.log(` <- called by:`); - for (const c of r.callers.slice(0, 15)) - console.log(` <- ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`); - if (r.callers.length > 15) console.log(` ... and ${r.callers.length - 15} more`); - } - console.log(); - } -} - export function impactAnalysis(file, customDbPath, opts = {}) { const data = impactAnalysisData(file, customDbPath, opts); if (opts.ndjson) { diff --git a/src/test-utils.js b/src/test-utils.js new file mode 100644 index 00000000..5be3746a --- /dev/null +++ b/src/test-utils.js @@ -0,0 +1,4 @@ +const TEST_PATTERN = /\.(test|spec)\.|__test__|__tests__|\.stories\./; +export function isTestFile(filePath) { + return TEST_PATTERN.test(filePath); +} diff --git a/tests/unit/queries-unit.test.js b/tests/unit/queries-unit.test.js index c1bccec3..0fe6859e 100644 --- a/tests/unit/queries-unit.test.js +++ b/tests/unit/queries-unit.test.js @@ -26,7 +26,6 @@ import { fnImpactData, impactAnalysis, moduleMap, - queryName, } from '../../src/queries.js'; // ─── Helpers ─────────────────────────────────────────────────────────── @@ -383,34 +382,6 @@ describe('diffImpactMermaid', () => { // ─── Display wrappers ───────────────────────────────────────────────── -describe('queryName (display)', () => { - it('outputs JSON when opts.json is true', () => { - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); - queryName('handleRequest', dbPath, { json: true }); - expect(spy).toHaveBeenCalledTimes(1); - const output = JSON.parse(spy.mock.calls[0][0]); - expect(output).toHaveProperty('query', 'handleRequest'); - expect(output).toHaveProperty('results'); - spy.mockRestore(); - }); - - it('outputs human-readable format', () => { - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); - queryName('handleRequest', dbPath); - const allOutput = spy.mock.calls.map((c) => c[0]).join('\n'); - expect(allOutput).toContain('handleRequest'); - spy.mockRestore(); - }); - - it('outputs "No results" for unknown name', () => { - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); - queryName('zzzzNotExist', dbPath); - const allOutput = spy.mock.calls.map((c) => c[0]).join('\n'); - expect(allOutput).toContain('No results'); - spy.mockRestore(); - }); -}); - describe('impactAnalysis (display)', () => { it('outputs JSON when opts.json is true', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); From bc03c9c0bdf0a392f867d82322edc619afca22ad Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:12:11 -0600 Subject: [PATCH 16/17] fix: convert hook to ESM imports, respect --kind filter in flow Impact: 1 functions changed, 1 affected --- .claude/hooks/warn-signature-changes.sh | 11 ++++++----- src/flow.js | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.claude/hooks/warn-signature-changes.sh b/.claude/hooks/warn-signature-changes.sh index 80d8cfda..6ad9b65f 100644 --- a/.claude/hooks/warn-signature-changes.sh +++ b/.claude/hooks/warn-signature-changes.sh @@ -40,10 +40,11 @@ if [ -z "$STAGED" ]; then fi # Run check --staged to get signature violations, then enrich with role + caller count -WARNING=$(node -e " - const path = require('path'); - const { checkData } = require(path.join(process.argv[1], 'src/check.js')); - const { openReadonlyOrFail } = require(path.join(process.argv[1], 'src/db.js')); +WARNING=$(echo "" | node --input-type=module -e " + import path from 'path'; + const workRoot = process.argv[2]; + const { checkData } = await import(path.join(workRoot, 'src/check.js')); + const { openReadonlyOrFail } = await import(path.join(workRoot, 'src/db.js')); const result = checkData(undefined, { staged: true, noTests: true }); if (!result || result.error) process.exit(0); @@ -98,7 +99,7 @@ WARNING=$(node -e " if (lines.length > 0) { process.stdout.write(lines.join('\\n')); } -" "$WORK_ROOT" 2>/dev/null) || true +" -- "$WORK_ROOT" 2>/dev/null) || true if [ -z "$WARNING" ]; then exit 0 diff --git a/src/flow.js b/src/flow.js index b7b95c1c..4954e6a2 100644 --- a/src/flow.js +++ b/src/flow.js @@ -95,7 +95,7 @@ export function flowData(name, dbPath, opts = {}) { const db = openReadonlyOrFail(dbPath); const maxDepth = opts.depth || 10; const noTests = opts.noTests || false; - const flowOpts = { ...opts, kinds: CORE_SYMBOL_KINDS }; + const flowOpts = { ...opts, kinds: opts.kind ? [opts.kind] : CORE_SYMBOL_KINDS }; // Phase 1: Direct LIKE match on full name let matchNode = findMatchingNodes(db, name, flowOpts)[0] ?? null; From a72be9d7195ad923594bcedc5e629f246ad282af Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:21:17 -0600 Subject: [PATCH 17/17] fix: align hook deny pattern and fix subdirectory glob in dead-exports check --- .claude/hooks/check-dead-exports.sh | 9 ++++----- .claude/hooks/guard-pr-body.sh | 31 ++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/.claude/hooks/check-dead-exports.sh b/.claude/hooks/check-dead-exports.sh index d3816374..7cd29416 100644 --- a/.claude/hooks/check-dead-exports.sh +++ b/.claude/hooks/check-dead-exports.sh @@ -49,11 +49,10 @@ EDITED_FILES=$(awk '{print $2}' "$LOG_FILE" | sort -u) DEAD_EXPORTS="" while IFS= read -r file; do - # Only check source files - case "$file" in - src/*.js|src/*.ts|src/*.tsx) ;; - *) continue ;; - esac + # Only check source files (regex crosses subdirectories unlike glob) + if [[ ! "$file" =~ ^src/.*\.(js|ts|tsx)$ ]]; then + continue + fi # Only check files edited in this session if ! echo "$EDITED_FILES" | grep -qxF "$file"; then diff --git a/.claude/hooks/guard-pr-body.sh b/.claude/hooks/guard-pr-body.sh index c3f7c9a9..6010599d 100644 --- a/.claude/hooks/guard-pr-body.sh +++ b/.claude/hooks/guard-pr-body.sh @@ -22,8 +22,33 @@ fi # Only check gh pr create commands echo "$COMMAND" | grep -qi 'gh pr create' || exit 0 -# Block if body contains "generated with" +# Check inline --body for "generated with" if echo "$COMMAND" | grep -qi 'generated with'; then - echo "BLOCK: Remove any 'Generated with ...' line from the PR body." >&2 - exit 2 + node -e " + console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: \"Remove any 'Generated with ...' line from the PR body.\" + } + })); + " + exit 0 +fi + +# Check --body-file content for "generated with" +BODY_FILE=$(echo "$COMMAND" | grep -oP '(?<=--body-file\s)[^\s]+' 2>/dev/null) || true +if [ -n "$BODY_FILE" ] && [ -f "$BODY_FILE" ]; then + if grep -qi 'generated with' "$BODY_FILE"; then + node -e " + console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: \"Remove any 'Generated with ...' line from the PR body.\" + } + })); + " + exit 0 + fi fi