Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions .claude/hooks/enrich-context.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 (silent no-op on older installs without the brief command)
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.totalImporterCount||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',
Expand Down
12 changes: 12 additions & 0 deletions src/cli/commands/brief.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { brief } from '../../presentation/brief.js';

export const command = {
name: 'brief <file>',
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),
});
},
};
11 changes: 8 additions & 3 deletions src/cli/commands/deps.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { brief } from '../../presentation/brief.js';
import { fileDeps } from '../../presentation/queries-cli.js';

export const command = {
name: 'deps <file>',
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);
}
},
};
155 changes: 155 additions & 0 deletions src/domain/analysis/brief.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
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 hasCoreRole = false;
for (const s of symbols) {
if (s.callerCount > maxCallers) maxCallers = s.callerCount;
if (s.role === 'core') hasCoreRole = true;
}
if (maxCallers >= 10 || hasCoreRole) 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.
* Depth-bounded to match countTransitiveCallers and keep hook latency predictable.
*/
function countTransitiveImporters(db, fileNodeIds, noTests, maxDepth = 5) {
const visited = new Set(fileNodeIds);
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;
}

/**
* 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 = [...new Set(importedBy.map((i) => i.file))];

// Transitive importer count
const totalImporterCount = 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,
totalImporterCount,
symbols,
};
});

return { file, results };
} finally {
db.close();
}
}
1 change: 1 addition & 0 deletions src/domain/queries.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
export { buildGraph } from './domain/graph/builder.js';
export { findCycles } from './domain/graph/cycles.js';
export {
briefData,
childrenData,
contextData,
diffImpactData,
Expand Down
13 changes: 13 additions & 0 deletions src/mcp/tool-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions src/mcp/tools/brief.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const name = 'brief';

export async function handler(args, ctx) {
const { briefData } = await ctx.getQueries();
return briefData(args.file, ctx.dbPath, {
noTests: args.no_tests,
});
}
2 changes: 2 additions & 0 deletions src/mcp/tools/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
]);
51 changes: 51 additions & 0 deletions src/presentation/brief.js
Original file line number Diff line number Diff line change
@@ -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.totalImporterCount - r.importedBy.length;
const suffix = transitive > 0 ? ` (+${transitive} transitive)` : '';
console.log(` Imported by: ${r.importedBy.join(', ')}${suffix}`);
} else if (r.totalImporterCount > 0) {
console.log(` Imported by: ${r.totalImporterCount} transitive importers`);
}
}
}
Loading
Loading