Skip to content

Commit fdc61a8

Browse files
authored
feat: add codegraph brief command for hook context injection (#480)
* feat: add `codegraph brief` command for token-efficient file summaries 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 <file>` - 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 * 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. * fix: correct misleading fallback comment in enrich-context hook (#480) 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. * 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. * 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. * 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.
1 parent 7c77d10 commit fdc61a8

12 files changed

Lines changed: 316 additions & 16 deletions

File tree

.claude/hooks/enrich-context.sh

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,35 +39,47 @@ if ! command -v codegraph &>/dev/null && ! command -v npx &>/dev/null; then
3939
exit 0
4040
fi
4141

42-
# Run codegraph deps and capture output
43-
DEPS=""
42+
# Run codegraph brief and capture output (silent no-op on older installs without the brief command)
43+
BRIEF=""
4444
if command -v codegraph &>/dev/null; then
45-
DEPS=$(codegraph deps "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true
45+
BRIEF=$(codegraph brief "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true
4646
else
47-
DEPS=$(npx --yes @optave/codegraph deps "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true
47+
BRIEF=$(npx --yes @optave/codegraph brief "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true
4848
fi
4949

5050
# Guard: no output or error
51-
if [ -z "$DEPS" ] || [ "$DEPS" = "null" ]; then
51+
if [ -z "$BRIEF" ] || [ "$BRIEF" = "null" ]; then
5252
exit 0
5353
fi
5454

5555
# Output as additionalContext so it surfaces in Claude's context
56-
printf '%s' "$DEPS" | node -e "
56+
printf '%s' "$BRIEF" | node -e "
5757
let d='';
5858
process.stdin.on('data',c=>d+=c);
5959
process.stdin.on('end',()=>{
6060
try {
6161
const o=JSON.parse(d);
6262
const r=o.results?.[0]||{};
63-
const imports=(r.imports||[]).map(i=>i.file).join(', ');
64-
const importedBy=(r.importedBy||[]).map(i=>i.file).join(', ');
65-
const defs=(r.definitions||[]).map(d=>d.kind+' '+d.name).join(', ');
66-
const file=o.file||'unknown';
67-
let ctx='[codegraph] '+file;
63+
const file=r.file||o.file||'unknown';
64+
const risk=r.risk||'unknown';
65+
const imports=(r.imports||[]).join(', ');
66+
const importedBy=(r.importedBy||[]).join(', ');
67+
const transitive=r.totalImporterCount||0;
68+
const direct=(r.importedBy||[]).length;
69+
const extra=transitive-direct;
70+
const syms=(r.symbols||[]).map(s=>{
71+
const tags=[];
72+
if(s.role)tags.push(s.role);
73+
tags.push(s.callerCount+' caller'+(s.callerCount!==1?'s':''));
74+
return s.name+' ['+tags.join(', ')+']';
75+
}).join(', ');
76+
let ctx='[codegraph] '+file+' ['+risk.toUpperCase()+' RISK]';
77+
if(syms)ctx+='\n Symbols: '+syms;
6878
if(imports)ctx+='\n Imports: '+imports;
69-
if(importedBy)ctx+='\n Imported by: '+importedBy;
70-
if(defs)ctx+='\n Defines: '+defs;
79+
if(importedBy){
80+
const suffix=extra>0?' (+'+extra+' transitive)':'';
81+
ctx+='\n Imported by: '+importedBy+suffix;
82+
}
7183
console.log(JSON.stringify({
7284
hookSpecificOutput: {
7385
hookEventName: 'PreToolUse',

src/cli/commands/brief.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { brief } from '../../presentation/brief.js';
2+
3+
export const command = {
4+
name: 'brief <file>',
5+
description: 'Token-efficient file summary: symbols with roles, caller counts, risk tier',
6+
queryOpts: true,
7+
execute([file], opts, ctx) {
8+
brief(file, opts.db, {
9+
...ctx.resolveQueryOpts(opts),
10+
});
11+
},
12+
};

src/cli/commands/deps.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1+
import { brief } from '../../presentation/brief.js';
12
import { fileDeps } from '../../presentation/queries-cli.js';
23

34
export const command = {
45
name: 'deps <file>',
56
description: 'Show what this file imports and what imports it',
67
queryOpts: true,
8+
options: [['--brief', 'Compact output with symbol roles, caller counts, and risk tier']],
79
execute([file], opts, ctx) {
8-
fileDeps(file, opts.db, {
9-
...ctx.resolveQueryOpts(opts),
10-
});
10+
const qOpts = ctx.resolveQueryOpts(opts);
11+
if (opts.brief) {
12+
brief(file, opts.db, qOpts);
13+
} else {
14+
fileDeps(file, opts.db, qOpts);
15+
}
1116
},
1217
};

src/domain/analysis/brief.js

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import {
2+
findDistinctCallers,
3+
findFileNodes,
4+
findImportDependents,
5+
findImportSources,
6+
findImportTargets,
7+
findNodesByFile,
8+
openReadonlyOrFail,
9+
} from '../../db/index.js';
10+
import { isTestFile } from '../../infrastructure/test-filter.js';
11+
12+
/** Symbol kinds meaningful for a file brief — excludes parameters, properties, constants. */
13+
const BRIEF_KINDS = new Set([
14+
'function',
15+
'method',
16+
'class',
17+
'interface',
18+
'type',
19+
'struct',
20+
'enum',
21+
'trait',
22+
'record',
23+
'module',
24+
]);
25+
26+
/**
27+
* Compute file risk tier from symbol roles and max fan-in.
28+
* @param {{ role: string|null, callerCount: number }[]} symbols
29+
* @returns {'high'|'medium'|'low'}
30+
*/
31+
function computeRiskTier(symbols) {
32+
let maxCallers = 0;
33+
let hasCoreRole = false;
34+
for (const s of symbols) {
35+
if (s.callerCount > maxCallers) maxCallers = s.callerCount;
36+
if (s.role === 'core') hasCoreRole = true;
37+
}
38+
if (maxCallers >= 10 || hasCoreRole) return 'high';
39+
if (maxCallers >= 3) return 'medium';
40+
return 'low';
41+
}
42+
43+
/**
44+
* BFS to count transitive callers for a single node.
45+
* Lightweight variant — only counts, does not collect details.
46+
*/
47+
function countTransitiveCallers(db, startId, noTests, maxDepth = 5) {
48+
const visited = new Set([startId]);
49+
let frontier = [startId];
50+
51+
for (let d = 1; d <= maxDepth; d++) {
52+
const nextFrontier = [];
53+
for (const fid of frontier) {
54+
const callers = findDistinctCallers(db, fid);
55+
for (const c of callers) {
56+
if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
57+
visited.add(c.id);
58+
nextFrontier.push(c.id);
59+
}
60+
}
61+
}
62+
frontier = nextFrontier;
63+
if (frontier.length === 0) break;
64+
}
65+
66+
return visited.size - 1;
67+
}
68+
69+
/**
70+
* Count transitive file-level import dependents via BFS.
71+
* Depth-bounded to match countTransitiveCallers and keep hook latency predictable.
72+
*/
73+
function countTransitiveImporters(db, fileNodeIds, noTests, maxDepth = 5) {
74+
const visited = new Set(fileNodeIds);
75+
let frontier = [...fileNodeIds];
76+
77+
for (let d = 1; d <= maxDepth; d++) {
78+
const nextFrontier = [];
79+
for (const current of frontier) {
80+
const dependents = findImportDependents(db, current);
81+
for (const dep of dependents) {
82+
if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) {
83+
visited.add(dep.id);
84+
nextFrontier.push(dep.id);
85+
}
86+
}
87+
}
88+
frontier = nextFrontier;
89+
if (frontier.length === 0) break;
90+
}
91+
92+
return visited.size - fileNodeIds.length;
93+
}
94+
95+
/**
96+
* Produce a token-efficient file brief: symbols with roles and caller counts,
97+
* importer info with transitive count, and file risk tier.
98+
*
99+
* @param {string} file - File path (partial match)
100+
* @param {string} customDbPath - Path to graph.db
101+
* @param {{ noTests?: boolean }} opts
102+
* @returns {{ file: string, results: object[] }}
103+
*/
104+
export function briefData(file, customDbPath, opts = {}) {
105+
const db = openReadonlyOrFail(customDbPath);
106+
try {
107+
const noTests = opts.noTests || false;
108+
const fileNodes = findFileNodes(db, `%${file}%`);
109+
if (fileNodes.length === 0) {
110+
return { file, results: [] };
111+
}
112+
113+
const results = fileNodes.map((fn) => {
114+
// Direct importers
115+
let importedBy = findImportSources(db, fn.id);
116+
if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file));
117+
const directImporters = [...new Set(importedBy.map((i) => i.file))];
118+
119+
// Transitive importer count
120+
const totalImporterCount = countTransitiveImporters(db, [fn.id], noTests);
121+
122+
// Direct imports
123+
let importsTo = findImportTargets(db, fn.id);
124+
if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file));
125+
126+
// Symbol definitions with roles and caller counts
127+
const defs = findNodesByFile(db, fn.file).filter((d) => BRIEF_KINDS.has(d.kind));
128+
const symbols = defs.map((d) => {
129+
const callerCount = countTransitiveCallers(db, d.id, noTests);
130+
return {
131+
name: d.name,
132+
kind: d.kind,
133+
line: d.line,
134+
role: d.role || null,
135+
callerCount,
136+
};
137+
});
138+
139+
const riskTier = computeRiskTier(symbols);
140+
141+
return {
142+
file: fn.file,
143+
risk: riskTier,
144+
imports: importsTo.map((i) => i.file),
145+
importedBy: directImporters,
146+
totalImporterCount,
147+
symbols,
148+
};
149+
});
150+
151+
return { file, results };
152+
} finally {
153+
db.close();
154+
}
155+
}

src/domain/queries.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export {
2222
} from '../shared/kinds.js';
2323
// ── Shared utilities ─────────────────────────────────────────────────────
2424
export { kindIcon, normalizeSymbol } from '../shared/normalize.js';
25+
export { briefData } from './analysis/brief.js';
2526
export { contextData, explainData } from './analysis/context.js';
2627
export { fileDepsData, fnDepsData, pathData } from './analysis/dependencies.js';
2728
export { exportsData } from './analysis/exports.js';

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
export { buildGraph } from './domain/graph/builder.js';
1313
export { findCycles } from './domain/graph/cycles.js';
1414
export {
15+
briefData,
1516
childrenData,
1617
contextData,
1718
diffImpactData,

src/mcp/tool-registry.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,19 @@ const BASE_TOOLS = [
9999
required: ['file'],
100100
},
101101
},
102+
{
103+
name: 'brief',
104+
description:
105+
'Token-efficient file summary: symbols with roles and transitive caller counts, importer counts, and file risk tier (high/medium/low). Designed for context injection.',
106+
inputSchema: {
107+
type: 'object',
108+
properties: {
109+
file: { type: 'string', description: 'File path (partial match supported)' },
110+
no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
111+
},
112+
required: ['file'],
113+
},
114+
},
102115
{
103116
name: 'file_exports',
104117
description:

src/mcp/tools/brief.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const name = 'brief';
2+
3+
export async function handler(args, ctx) {
4+
const { briefData } = await ctx.getQueries();
5+
return briefData(args.file, ctx.dbPath, {
6+
noTests: args.no_tests,
7+
});
8+
}

src/mcp/tools/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as astQuery from './ast-query.js';
66
import * as audit from './audit.js';
77
import * as batchQuery from './batch-query.js';
88
import * as branchCompare from './branch-compare.js';
9+
import * as brief from './brief.js';
910
import * as cfg from './cfg.js';
1011
import * as check from './check.js';
1112
import * as coChanges from './co-changes.js';
@@ -67,5 +68,6 @@ export const TOOL_HANDLERS = new Map([
6768
[dataflow.name, dataflow],
6869
[check.name, check],
6970
[astQuery.name, astQuery],
71+
[brief.name, brief],
7072
[listRepos.name, listRepos],
7173
]);

src/presentation/brief.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { briefData } from '../domain/analysis/brief.js';
2+
import { outputResult } from './result-formatter.js';
3+
4+
/**
5+
* Format a compact brief for hook context injection.
6+
* Single-block, token-efficient output.
7+
*
8+
* Example:
9+
* src/domain/graph/builder.js [HIGH RISK]
10+
* Symbols: buildGraph [core, 12 callers], collectFiles [leaf, 2 callers]
11+
* Imports: src/db/index.js, src/domain/parser.js
12+
* Imported by: src/cli/commands/build.js (+8 transitive)
13+
*/
14+
export function brief(file, customDbPath, opts = {}) {
15+
const data = briefData(file, customDbPath, opts);
16+
if (outputResult(data, 'results', opts)) return;
17+
18+
if (data.results.length === 0) {
19+
console.log(`No file matching "${file}" in graph`);
20+
return;
21+
}
22+
23+
for (const r of data.results) {
24+
console.log(`${r.file} [${r.risk.toUpperCase()} RISK]`);
25+
26+
// Symbols line
27+
if (r.symbols.length > 0) {
28+
const parts = r.symbols.map((s) => {
29+
const tags = [];
30+
if (s.role) tags.push(s.role);
31+
tags.push(`${s.callerCount} caller${s.callerCount !== 1 ? 's' : ''}`);
32+
return `${s.name} [${tags.join(', ')}]`;
33+
});
34+
console.log(` Symbols: ${parts.join(', ')}`);
35+
}
36+
37+
// Imports line
38+
if (r.imports.length > 0) {
39+
console.log(` Imports: ${r.imports.join(', ')}`);
40+
}
41+
42+
// Imported by line with transitive count
43+
if (r.importedBy.length > 0) {
44+
const transitive = r.totalImporterCount - r.importedBy.length;
45+
const suffix = transitive > 0 ? ` (+${transitive} transitive)` : '';
46+
console.log(` Imported by: ${r.importedBy.join(', ')}${suffix}`);
47+
} else if (r.totalImporterCount > 0) {
48+
console.log(` Imported by: ${r.totalImporterCount} transitive importers`);
49+
}
50+
}
51+
}

0 commit comments

Comments
 (0)