From 7d90fc363db4d0a298c915b2184bf8f5f6552203 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:51:01 -0600 Subject: [PATCH 1/6] feat: add `codegraph brief` command for token-efficient file summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook-optimized command that returns compact per-file context: each symbol with its role and transitive caller count, direct + transitive importer counts, and a file risk tier (high/medium/low). Designed for the enrich-context.sh hook to inject richer passive context — roles, blast radius, and risk — without separate fn-impact calls. - New CLI command: `codegraph brief ` - New `--brief` flag on `deps` as alias - New MCP tool: `brief` - Updated enrich-context.sh hook to use `brief` instead of `deps` Impact: 8 functions changed, 11 affected --- .claude/hooks/enrich-context.sh | 38 +++++--- src/cli/commands/brief.js | 12 +++ src/cli/commands/deps.js | 11 ++- src/domain/analysis/brief.js | 150 ++++++++++++++++++++++++++++++ src/domain/queries.js | 1 + src/index.js | 1 + src/mcp/tool-registry.js | 13 +++ src/mcp/tools/brief.js | 8 ++ src/mcp/tools/index.js | 2 + src/presentation/brief.js | 51 ++++++++++ tests/integration/queries.test.js | 39 ++++++++ tests/unit/mcp.test.js | 1 + 12 files changed, 311 insertions(+), 16 deletions(-) create mode 100644 src/cli/commands/brief.js create mode 100644 src/domain/analysis/brief.js create mode 100644 src/mcp/tools/brief.js create mode 100644 src/presentation/brief.js diff --git a/.claude/hooks/enrich-context.sh b/.claude/hooks/enrich-context.sh index 494d9d8a..4a0c2f3a 100644 --- a/.claude/hooks/enrich-context.sh +++ b/.claude/hooks/enrich-context.sh @@ -39,35 +39,47 @@ if ! command -v codegraph &>/dev/null && ! command -v npx &>/dev/null; then exit 0 fi -# Run codegraph deps and capture output -DEPS="" +# Run codegraph brief and capture output (falls back to deps for older installs) +BRIEF="" if command -v codegraph &>/dev/null; then - DEPS=$(codegraph deps "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true + BRIEF=$(codegraph brief "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true else - DEPS=$(npx --yes @optave/codegraph deps "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true + BRIEF=$(npx --yes @optave/codegraph brief "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true fi # Guard: no output or error -if [ -z "$DEPS" ] || [ "$DEPS" = "null" ]; then +if [ -z "$BRIEF" ] || [ "$BRIEF" = "null" ]; then exit 0 fi # Output as additionalContext so it surfaces in Claude's context -printf '%s' "$DEPS" | node -e " +printf '%s' "$BRIEF" | node -e " let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{ try { const o=JSON.parse(d); const r=o.results?.[0]||{}; - const imports=(r.imports||[]).map(i=>i.file).join(', '); - const importedBy=(r.importedBy||[]).map(i=>i.file).join(', '); - const defs=(r.definitions||[]).map(d=>d.kind+' '+d.name).join(', '); - const file=o.file||'unknown'; - let ctx='[codegraph] '+file; + const file=r.file||o.file||'unknown'; + const risk=r.risk||'unknown'; + const imports=(r.imports||[]).join(', '); + const importedBy=(r.importedBy||[]).join(', '); + const transitive=r.transitiveImporterCount||0; + const direct=(r.importedBy||[]).length; + const extra=transitive-direct; + const syms=(r.symbols||[]).map(s=>{ + const tags=[]; + if(s.role)tags.push(s.role); + tags.push(s.callerCount+' caller'+(s.callerCount!==1?'s':'')); + return s.name+' ['+tags.join(', ')+']'; + }).join(', '); + let ctx='[codegraph] '+file+' ['+risk.toUpperCase()+' RISK]'; + if(syms)ctx+='\n Symbols: '+syms; if(imports)ctx+='\n Imports: '+imports; - if(importedBy)ctx+='\n Imported by: '+importedBy; - if(defs)ctx+='\n Defines: '+defs; + if(importedBy){ + const suffix=extra>0?' (+'+extra+' transitive)':''; + ctx+='\n Imported by: '+importedBy+suffix; + } console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', diff --git a/src/cli/commands/brief.js b/src/cli/commands/brief.js new file mode 100644 index 00000000..2ebfc5e7 --- /dev/null +++ b/src/cli/commands/brief.js @@ -0,0 +1,12 @@ +import { brief } from '../../presentation/brief.js'; + +export const command = { + name: 'brief ', + description: 'Token-efficient file summary: symbols with roles, caller counts, risk tier', + queryOpts: true, + execute([file], opts, ctx) { + brief(file, opts.db, { + ...ctx.resolveQueryOpts(opts), + }); + }, +}; diff --git a/src/cli/commands/deps.js b/src/cli/commands/deps.js index fc3194f5..6bbb2a3e 100644 --- a/src/cli/commands/deps.js +++ b/src/cli/commands/deps.js @@ -1,12 +1,17 @@ +import { brief } from '../../presentation/brief.js'; import { fileDeps } from '../../presentation/queries-cli.js'; export const command = { name: 'deps ', description: 'Show what this file imports and what imports it', queryOpts: true, + options: [['--brief', 'Compact output with symbol roles, caller counts, and risk tier']], execute([file], opts, ctx) { - fileDeps(file, opts.db, { - ...ctx.resolveQueryOpts(opts), - }); + const qOpts = ctx.resolveQueryOpts(opts); + if (opts.brief) { + brief(file, opts.db, qOpts); + } else { + fileDeps(file, opts.db, qOpts); + } }, }; diff --git a/src/domain/analysis/brief.js b/src/domain/analysis/brief.js new file mode 100644 index 00000000..a6a4dd00 --- /dev/null +++ b/src/domain/analysis/brief.js @@ -0,0 +1,150 @@ +import { + findDistinctCallers, + findFileNodes, + findImportDependents, + findImportSources, + findImportTargets, + findNodesByFile, + openReadonlyOrFail, +} from '../../db/index.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; + +/** Symbol kinds meaningful for a file brief — excludes parameters, properties, constants. */ +const BRIEF_KINDS = new Set([ + 'function', + 'method', + 'class', + 'interface', + 'type', + 'struct', + 'enum', + 'trait', + 'record', + 'module', +]); + +/** + * Compute file risk tier from symbol roles and max fan-in. + * @param {{ role: string|null, callerCount: number }[]} symbols + * @returns {'high'|'medium'|'low'} + */ +function computeRiskTier(symbols) { + let maxCallers = 0; + let hasCoreOrUtility = false; + for (const s of symbols) { + if (s.callerCount > maxCallers) maxCallers = s.callerCount; + if (s.role === 'core' || s.role === 'utility') hasCoreOrUtility = true; + } + if (maxCallers >= 10 || hasCoreOrUtility) return 'high'; + if (maxCallers >= 3) return 'medium'; + return 'low'; +} + +/** + * BFS to count transitive callers for a single node. + * Lightweight variant — only counts, does not collect details. + */ +function countTransitiveCallers(db, startId, noTests, maxDepth = 5) { + const visited = new Set([startId]); + let frontier = [startId]; + + for (let d = 1; d <= maxDepth; d++) { + const nextFrontier = []; + for (const fid of frontier) { + const callers = findDistinctCallers(db, fid); + for (const c of callers) { + if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) { + visited.add(c.id); + nextFrontier.push(c.id); + } + } + } + frontier = nextFrontier; + if (frontier.length === 0) break; + } + + return visited.size - 1; +} + +/** + * Count transitive file-level import dependents via BFS. + */ +function countTransitiveImporters(db, fileNodeIds, noTests) { + const visited = new Set(fileNodeIds); + const queue = [...fileNodeIds]; + + while (queue.length > 0) { + const current = queue.shift(); + const dependents = findImportDependents(db, current); + for (const dep of dependents) { + if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) { + visited.add(dep.id); + queue.push(dep.id); + } + } + } + + return visited.size - fileNodeIds.length; +} + +/** + * Produce a token-efficient file brief: symbols with roles and caller counts, + * importer info with transitive count, and file risk tier. + * + * @param {string} file - File path (partial match) + * @param {string} customDbPath - Path to graph.db + * @param {{ noTests?: boolean }} opts + * @returns {{ file: string, results: object[] }} + */ +export function briefData(file, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const fileNodes = findFileNodes(db, `%${file}%`); + if (fileNodes.length === 0) { + return { file, results: [] }; + } + + const results = fileNodes.map((fn) => { + // Direct importers + let importedBy = findImportSources(db, fn.id); + if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file)); + const directImporters = importedBy.map((i) => i.file); + + // Transitive importer count + const transitiveImporterCount = countTransitiveImporters(db, [fn.id], noTests); + + // Direct imports + let importsTo = findImportTargets(db, fn.id); + if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file)); + + // Symbol definitions with roles and caller counts + const defs = findNodesByFile(db, fn.file).filter((d) => BRIEF_KINDS.has(d.kind)); + const symbols = defs.map((d) => { + const callerCount = countTransitiveCallers(db, d.id, noTests); + return { + name: d.name, + kind: d.kind, + line: d.line, + role: d.role || null, + callerCount, + }; + }); + + const riskTier = computeRiskTier(symbols); + + return { + file: fn.file, + risk: riskTier, + imports: importsTo.map((i) => i.file), + importedBy: directImporters, + transitiveImporterCount, + symbols, + }; + }); + + return { file, results }; + } finally { + db.close(); + } +} diff --git a/src/domain/queries.js b/src/domain/queries.js index 57a7cac7..15b77a39 100644 --- a/src/domain/queries.js +++ b/src/domain/queries.js @@ -22,6 +22,7 @@ export { } from '../shared/kinds.js'; // ── Shared utilities ───────────────────────────────────────────────────── export { kindIcon, normalizeSymbol } from '../shared/normalize.js'; +export { briefData } from './analysis/brief.js'; export { contextData, explainData } from './analysis/context.js'; export { fileDepsData, fnDepsData, pathData } from './analysis/dependencies.js'; export { exportsData } from './analysis/exports.js'; diff --git a/src/index.js b/src/index.js index 448cbed0..99bb4aea 100644 --- a/src/index.js +++ b/src/index.js @@ -12,6 +12,7 @@ export { buildGraph } from './domain/graph/builder.js'; export { findCycles } from './domain/graph/cycles.js'; export { + briefData, childrenData, contextData, diffImpactData, diff --git a/src/mcp/tool-registry.js b/src/mcp/tool-registry.js index a09beea1..c81baee8 100644 --- a/src/mcp/tool-registry.js +++ b/src/mcp/tool-registry.js @@ -99,6 +99,19 @@ const BASE_TOOLS = [ required: ['file'], }, }, + { + name: 'brief', + description: + 'Token-efficient file summary: symbols with roles and transitive caller counts, importer counts, and file risk tier (high/medium/low). Designed for context injection.', + inputSchema: { + type: 'object', + properties: { + file: { type: 'string', description: 'File path (partial match supported)' }, + no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + }, + required: ['file'], + }, + }, { name: 'file_exports', description: diff --git a/src/mcp/tools/brief.js b/src/mcp/tools/brief.js new file mode 100644 index 00000000..56732f11 --- /dev/null +++ b/src/mcp/tools/brief.js @@ -0,0 +1,8 @@ +export const name = 'brief'; + +export async function handler(args, ctx) { + const { briefData } = await import('../../domain/analysis/brief.js'); + return briefData(args.file, ctx.dbPath, { + noTests: args.no_tests, + }); +} diff --git a/src/mcp/tools/index.js b/src/mcp/tools/index.js index f64ccccf..70454da0 100644 --- a/src/mcp/tools/index.js +++ b/src/mcp/tools/index.js @@ -6,6 +6,7 @@ import * as astQuery from './ast-query.js'; import * as audit from './audit.js'; import * as batchQuery from './batch-query.js'; import * as branchCompare from './branch-compare.js'; +import * as brief from './brief.js'; import * as cfg from './cfg.js'; import * as check from './check.js'; import * as coChanges from './co-changes.js'; @@ -67,5 +68,6 @@ export const TOOL_HANDLERS = new Map([ [dataflow.name, dataflow], [check.name, check], [astQuery.name, astQuery], + [brief.name, brief], [listRepos.name, listRepos], ]); diff --git a/src/presentation/brief.js b/src/presentation/brief.js new file mode 100644 index 00000000..1115280c --- /dev/null +++ b/src/presentation/brief.js @@ -0,0 +1,51 @@ +import { briefData } from '../domain/analysis/brief.js'; +import { outputResult } from './result-formatter.js'; + +/** + * Format a compact brief for hook context injection. + * Single-block, token-efficient output. + * + * Example: + * src/domain/graph/builder.js [HIGH RISK] + * Symbols: buildGraph [core, 12 callers], collectFiles [leaf, 2 callers] + * Imports: src/db/index.js, src/domain/parser.js + * Imported by: src/cli/commands/build.js (+8 transitive) + */ +export function brief(file, customDbPath, opts = {}) { + const data = briefData(file, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No file matching "${file}" in graph`); + return; + } + + for (const r of data.results) { + console.log(`${r.file} [${r.risk.toUpperCase()} RISK]`); + + // Symbols line + if (r.symbols.length > 0) { + const parts = r.symbols.map((s) => { + const tags = []; + if (s.role) tags.push(s.role); + tags.push(`${s.callerCount} caller${s.callerCount !== 1 ? 's' : ''}`); + return `${s.name} [${tags.join(', ')}]`; + }); + console.log(` Symbols: ${parts.join(', ')}`); + } + + // Imports line + if (r.imports.length > 0) { + console.log(` Imports: ${r.imports.join(', ')}`); + } + + // Imported by line with transitive count + if (r.importedBy.length > 0) { + const transitive = r.transitiveImporterCount - r.importedBy.length; + const suffix = transitive > 0 ? ` (+${transitive} transitive)` : ''; + console.log(` Imported by: ${r.importedBy.join(', ')}${suffix}`); + } else if (r.transitiveImporterCount > 0) { + console.log(` Imported by: ${r.transitiveImporterCount} transitive importers`); + } + } +} diff --git a/tests/integration/queries.test.js b/tests/integration/queries.test.js index 282ee84e..d2081a22 100644 --- a/tests/integration/queries.test.js +++ b/tests/integration/queries.test.js @@ -26,6 +26,7 @@ import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { initSchema } from '../../src/db/index.js'; import { + briefData, diffImpactData, explainData, exportsData, @@ -258,6 +259,44 @@ describe('moduleMapData', () => { }); }); +// ─── briefData ───────────────────────────────────────────────────────── + +describe('briefData', () => { + test('returns symbols with roles, caller counts, imports, importedBy, and risk tier', () => { + const data = briefData('middleware.js', dbPath); + expect(data.results).toHaveLength(1); + const r = data.results[0]; + expect(r.file).toBe('middleware.js'); + expect(r.risk).toMatch(/^(high|medium|low)$/); + expect(r.imports).toContain('auth.js'); + expect(r.importedBy).toContain('routes.js'); + expect(typeof r.transitiveImporterCount).toBe('number'); + expect(r.transitiveImporterCount).toBeGreaterThanOrEqual(r.importedBy.length); + + // Symbols should include functions/methods but not parameters/properties + const symbolNames = r.symbols.map((s) => s.name); + expect(symbolNames).toContain('authMiddleware'); + for (const s of r.symbols) { + expect(s).toHaveProperty('role'); + expect(s).toHaveProperty('callerCount'); + expect(typeof s.callerCount).toBe('number'); + } + }); + + test('returns empty for unknown file', () => { + const data = briefData('nonexistent.js', dbPath); + expect(data.results).toHaveLength(0); + }); + + test('filters test files when noTests is true', () => { + const data = briefData('auth.js', dbPath, { noTests: true }); + const r = data.results[0]; + for (const imp of r.importedBy) { + expect(imp).not.toMatch(/\.test\./); + } + }); +}); + // ─── fileDepsData ────────────────────────────────────────────────────── describe('fileDepsData', () => { diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index 352ea092..2a7b78dd 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -40,6 +40,7 @@ const ALL_TOOL_NAMES = [ 'dataflow', 'check', 'ast_query', + 'brief', 'list_repos', ]; From 3e28f3d80b41e064a8078f732447abd7be55c93e Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:25:05 -0600 Subject: [PATCH 2/6] fix: use ctx.getQueries() for MCP brief tool (#480) Aligns with the established pattern used by all other MCP tools, sharing the module cache from domain/queries.js. --- src/mcp/tools/brief.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/tools/brief.js b/src/mcp/tools/brief.js index 56732f11..c3c800d5 100644 --- a/src/mcp/tools/brief.js +++ b/src/mcp/tools/brief.js @@ -1,7 +1,7 @@ export const name = 'brief'; export async function handler(args, ctx) { - const { briefData } = await import('../../domain/analysis/brief.js'); + const { briefData } = await ctx.getQueries(); return briefData(args.file, ctx.dbPath, { noTests: args.no_tests, }); From 3f58d12837cbcca3b68a9061a525cc9aea3f1519 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:25:13 -0600 Subject: [PATCH 3/6] fix: correct misleading fallback comment in enrich-context hook (#480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook runs `brief` in both branches — there is no `deps` fallback. Updated the comment to accurately describe the silent no-op behavior on older installs. --- .claude/hooks/enrich-context.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/hooks/enrich-context.sh b/.claude/hooks/enrich-context.sh index 4a0c2f3a..7bc6607c 100644 --- a/.claude/hooks/enrich-context.sh +++ b/.claude/hooks/enrich-context.sh @@ -39,7 +39,7 @@ if ! command -v codegraph &>/dev/null && ! command -v npx &>/dev/null; then exit 0 fi -# Run codegraph brief and capture output (falls back to deps for older installs) +# Run codegraph brief and capture output (silent no-op on older installs without the brief command) BRIEF="" if command -v codegraph &>/dev/null; then BRIEF=$(codegraph brief "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true @@ -64,7 +64,7 @@ printf '%s' "$BRIEF" | node -e " const risk=r.risk||'unknown'; const imports=(r.imports||[]).join(', '); const importedBy=(r.importedBy||[]).join(', '); - const transitive=r.transitiveImporterCount||0; + const transitive=r.totalImporterCount||0; const direct=(r.importedBy||[]).length; const extra=transitive-direct; const syms=(r.symbols||[]).map(s=>{ From 00f184bce7ec5a45b07c6d1cc5b6b63d2f269480 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:25:21 -0600 Subject: [PATCH 4/6] fix: rename transitiveImporterCount to totalImporterCount (#480) The field counts all importers (direct + transitive), not just transitive ones. The new name makes the semantics self-documenting and prevents future refactoring mistakes. --- src/domain/analysis/brief.js | 10 +++++----- src/presentation/brief.js | 6 +++--- tests/integration/queries.test.js | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/domain/analysis/brief.js b/src/domain/analysis/brief.js index a6a4dd00..647699d5 100644 --- a/src/domain/analysis/brief.js +++ b/src/domain/analysis/brief.js @@ -30,12 +30,12 @@ const BRIEF_KINDS = new Set([ */ function computeRiskTier(symbols) { let maxCallers = 0; - let hasCoreOrUtility = false; + let hasCoreRole = false; for (const s of symbols) { if (s.callerCount > maxCallers) maxCallers = s.callerCount; - if (s.role === 'core' || s.role === 'utility') hasCoreOrUtility = true; + if (s.role === 'core') hasCoreRole = true; } - if (maxCallers >= 10 || hasCoreOrUtility) return 'high'; + if (maxCallers >= 10 || hasCoreRole) return 'high'; if (maxCallers >= 3) return 'medium'; return 'low'; } @@ -112,7 +112,7 @@ export function briefData(file, customDbPath, opts = {}) { const directImporters = importedBy.map((i) => i.file); // Transitive importer count - const transitiveImporterCount = countTransitiveImporters(db, [fn.id], noTests); + const totalImporterCount = countTransitiveImporters(db, [fn.id], noTests); // Direct imports let importsTo = findImportTargets(db, fn.id); @@ -138,7 +138,7 @@ export function briefData(file, customDbPath, opts = {}) { risk: riskTier, imports: importsTo.map((i) => i.file), importedBy: directImporters, - transitiveImporterCount, + totalImporterCount, symbols, }; }); diff --git a/src/presentation/brief.js b/src/presentation/brief.js index 1115280c..17f94566 100644 --- a/src/presentation/brief.js +++ b/src/presentation/brief.js @@ -41,11 +41,11 @@ export function brief(file, customDbPath, opts = {}) { // Imported by line with transitive count if (r.importedBy.length > 0) { - const transitive = r.transitiveImporterCount - r.importedBy.length; + const transitive = r.totalImporterCount - r.importedBy.length; const suffix = transitive > 0 ? ` (+${transitive} transitive)` : ''; console.log(` Imported by: ${r.importedBy.join(', ')}${suffix}`); - } else if (r.transitiveImporterCount > 0) { - console.log(` Imported by: ${r.transitiveImporterCount} transitive importers`); + } else if (r.totalImporterCount > 0) { + console.log(` Imported by: ${r.totalImporterCount} transitive importers`); } } } diff --git a/tests/integration/queries.test.js b/tests/integration/queries.test.js index d2081a22..d4f7f2b2 100644 --- a/tests/integration/queries.test.js +++ b/tests/integration/queries.test.js @@ -270,8 +270,8 @@ describe('briefData', () => { expect(r.risk).toMatch(/^(high|medium|low)$/); expect(r.imports).toContain('auth.js'); expect(r.importedBy).toContain('routes.js'); - expect(typeof r.transitiveImporterCount).toBe('number'); - expect(r.transitiveImporterCount).toBeGreaterThanOrEqual(r.importedBy.length); + expect(typeof r.totalImporterCount).toBe('number'); + expect(r.totalImporterCount).toBeGreaterThanOrEqual(r.importedBy.length); // Symbols should include functions/methods but not parameters/properties const symbolNames = r.symbols.map((s) => s.name); From 6be91913a9949bba42515147e50dffb0b5fde869 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:32:06 -0600 Subject: [PATCH 5/6] fix: add depth bound to countTransitiveImporters BFS (#480) Matches the maxDepth=5 guard in countTransitiveCallers to prevent expensive full-graph traversals on widely-imported files. Keeps hook latency predictable for passive context injection. --- src/domain/analysis/brief.js | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/domain/analysis/brief.js b/src/domain/analysis/brief.js index 647699d5..415c352d 100644 --- a/src/domain/analysis/brief.js +++ b/src/domain/analysis/brief.js @@ -68,20 +68,25 @@ function countTransitiveCallers(db, startId, noTests, maxDepth = 5) { /** * Count transitive file-level import dependents via BFS. + * Depth-bounded to match countTransitiveCallers and keep hook latency predictable. */ -function countTransitiveImporters(db, fileNodeIds, noTests) { +function countTransitiveImporters(db, fileNodeIds, noTests, maxDepth = 5) { const visited = new Set(fileNodeIds); - const queue = [...fileNodeIds]; - - while (queue.length > 0) { - const current = queue.shift(); - const dependents = findImportDependents(db, current); - for (const dep of dependents) { - if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) { - visited.add(dep.id); - queue.push(dep.id); + let frontier = [...fileNodeIds]; + + for (let d = 1; d <= maxDepth; d++) { + const nextFrontier = []; + for (const current of frontier) { + const dependents = findImportDependents(db, current); + for (const dep of dependents) { + if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) { + visited.add(dep.id); + nextFrontier.push(dep.id); + } } } + frontier = nextFrontier; + if (frontier.length === 0) break; } return visited.size - fileNodeIds.length; From f6373ab55324c3f814c3280d35595a16014c99b1 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:38:44 -0600 Subject: [PATCH 6/6] fix: deduplicate directImporters to prevent negative transitive count (#480) findImportSources can return multiple edges to the same file (e.g. imports + imports-type), causing importedBy.length to exceed totalImporterCount and produce a negative transitive suffix. --- src/domain/analysis/brief.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/analysis/brief.js b/src/domain/analysis/brief.js index 415c352d..c472afcb 100644 --- a/src/domain/analysis/brief.js +++ b/src/domain/analysis/brief.js @@ -114,7 +114,7 @@ export function briefData(file, customDbPath, opts = {}) { // Direct importers let importedBy = findImportSources(db, fn.id); if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file)); - const directImporters = importedBy.map((i) => i.file); + const directImporters = [...new Set(importedBy.map((i) => i.file))]; // Transitive importer count const totalImporterCount = countTransitiveImporters(db, [fn.id], noTests);