diff --git a/.claude/hooks/enrich-context.sh b/.claude/hooks/enrich-context.sh index 494d9d8a..7bc6607c 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 (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', 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..c472afcb --- /dev/null +++ b/src/domain/analysis/brief.js @@ -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(); + } +} 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..c3c800d5 --- /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 ctx.getQueries(); + 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..17f94566 --- /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.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`); + } + } +} diff --git a/tests/integration/queries.test.js b/tests/integration/queries.test.js index 282ee84e..d4f7f2b2 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.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); + 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', ];