Skip to content

Commit a6013cd

Browse files
committed
feat: add dedicated exports <file> command with per-symbol consumers
Implements feature N11 from the Narsil competitive analysis. The new command provides a focused export map showing which symbols a file exports and who calls each one, filling the gap between `explain` (public/internal split without consumers) and `where --file` (just export names). Adds exportsData/fileExports to queries.js, CLI command, MCP tool, batch support, programmatic API, and integration tests. Impact: 7 functions changed, 15 affected
1 parent d1ec920 commit a6013cd

File tree

8 files changed

+370
-2
lines changed

8 files changed

+370
-2
lines changed

src/batch.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { flowData } from './flow.js';
1111
import {
1212
contextData,
1313
explainData,
14+
exportsData,
1415
fileDepsData,
1516
fnDepsData,
1617
fnImpactData,
@@ -34,6 +35,7 @@ export const BATCH_COMMANDS = {
3435
query: { fn: fnDepsData, sig: 'name' },
3536
impact: { fn: impactAnalysisData, sig: 'file' },
3637
deps: { fn: fileDepsData, sig: 'file' },
38+
exports: { fn: exportsData, sig: 'file' },
3739
flow: { fn: flowData, sig: 'name' },
3840
dataflow: { fn: dataflowData, sig: 'name' },
3941
complexity: { fn: complexityData, sig: 'dbOnly' },

src/cli.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
diffImpact,
2626
explain,
2727
fileDeps,
28+
fileExports,
2829
fnDeps,
2930
fnImpact,
3031
impactAnalysis,
@@ -97,10 +98,18 @@ program
9798
.description('Parse repo and build graph in .codegraph/graph.db')
9899
.option('--no-incremental', 'Force full rebuild (ignore file hashes)')
99100
.option('--dataflow', 'Extract data flow edges (flows_to, returns, mutates)')
101+
.option('--scope <files...>', 'Rebuild only specified files (for agent-level rollback)')
102+
.option('--no-reverse-deps', 'Skip reverse dependency cascade (only meaningful with --scope)')
100103
.action(async (dir, opts) => {
101104
const root = path.resolve(dir || '.');
102105
const engine = program.opts().engine;
103-
await buildGraph(root, { incremental: opts.incremental, engine, dataflow: opts.dataflow });
106+
await buildGraph(root, {
107+
incremental: opts.incremental,
108+
engine,
109+
dataflow: opts.dataflow,
110+
scope: opts.scope,
111+
noReverseDeps: opts.reverseDeps === false,
112+
});
104113
});
105114

106115
program
@@ -217,6 +226,26 @@ program
217226
});
218227
});
219228

229+
program
230+
.command('exports <file>')
231+
.description('Show exported symbols with per-symbol consumers (who calls each export)')
232+
.option('-d, --db <path>', 'Path to graph.db')
233+
.option('-T, --no-tests', 'Exclude test/spec files from results')
234+
.option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
235+
.option('-j, --json', 'Output as JSON')
236+
.option('--limit <number>', 'Max results to return')
237+
.option('--offset <number>', 'Skip N results (default: 0)')
238+
.option('--ndjson', 'Newline-delimited JSON output')
239+
.action((file, opts) => {
240+
fileExports(file, opts.db, {
241+
noTests: resolveNoTests(opts),
242+
json: opts.json,
243+
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
244+
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
245+
ndjson: opts.ndjson,
246+
});
247+
});
248+
220249
program
221250
.command('fn-impact <name>')
222251
.description('Function-level impact: what functions break if this one changes')

src/index.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@ export { evaluateBoundaries, PRESETS, validateBoundaryConfig } from './boundarie
2121
// Branch comparison
2222
export { branchCompareData, branchCompareMermaid } from './branch-compare.js';
2323
// Graph building
24-
export { buildGraph, collectFiles, loadPathAliases, resolveImportPath } from './builder.js';
24+
export {
25+
buildGraph,
26+
collectFiles,
27+
loadPathAliases,
28+
purgeFilesFromGraph,
29+
resolveImportPath,
30+
} from './builder.js';
2531
// Check (CI validation predicates)
2632
export { check, checkData } from './check.js';
2733
// Co-change analysis
@@ -111,9 +117,11 @@ export {
111117
diffImpactData,
112118
diffImpactMermaid,
113119
explainData,
120+
exportsData,
114121
FALSE_POSITIVE_CALLER_THRESHOLD,
115122
FALSE_POSITIVE_NAMES,
116123
fileDepsData,
124+
fileExports,
117125
fnDepsData,
118126
fnImpactData,
119127
impactAnalysisData,

src/mcp.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@ const BASE_TOOLS = [
8282
required: ['file'],
8383
},
8484
},
85+
{
86+
name: 'file_exports',
87+
description:
88+
'Show exported symbols of a file with per-symbol consumers — who calls each export and from where',
89+
inputSchema: {
90+
type: 'object',
91+
properties: {
92+
file: { type: 'string', description: 'File path (partial match supported)' },
93+
no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
94+
...PAGINATION_PROPS,
95+
},
96+
required: ['file'],
97+
},
98+
},
8599
{
86100
name: 'impact_analysis',
87101
description: 'Show files affected by changes to a given file (transitive)',
@@ -667,6 +681,31 @@ const BASE_TOOLS = [
667681
},
668682
},
669683
},
684+
// Write tool — intentional for multi-agent orchestration (Titan Paradigm).
685+
// Allows an agent to surgically rebuild only its changed files without
686+
// nuking every other agent's graph state.
687+
{
688+
name: 'scoped_rebuild',
689+
description:
690+
'Rebuild the graph for specific files only, leaving all other data untouched. Designed for agent-level rollback: revert source files via git, then call this to update the graph surgically.',
691+
inputSchema: {
692+
type: 'object',
693+
properties: {
694+
files: {
695+
type: 'array',
696+
items: { type: 'string' },
697+
description: 'Relative file paths to rebuild (deleted files are purged from graph)',
698+
},
699+
no_reverse_deps: {
700+
type: 'boolean',
701+
description:
702+
'Skip reverse dependency cascade — use when exports did not change (e.g. reverting to the exact same version)',
703+
default: false,
704+
},
705+
},
706+
required: ['files'],
707+
},
708+
},
670709
];
671710

672711
const LIST_REPOS_TOOL = {
@@ -740,6 +779,7 @@ export async function startMCPServer(customDbPath, options = {}) {
740779
fnImpactData,
741780
pathData,
742781
contextData,
782+
exportsData,
743783
explainData,
744784
whereData,
745785
diffImpactData,
@@ -825,6 +865,13 @@ export async function startMCPServer(customDbPath, options = {}) {
825865
offset: args.offset ?? 0,
826866
});
827867
break;
868+
case 'file_exports':
869+
result = exportsData(args.file, dbPath, {
870+
noTests: args.no_tests,
871+
limit: Math.min(args.limit ?? MCP_DEFAULTS.file_exports, MCP_MAX_LIMIT),
872+
offset: args.offset ?? 0,
873+
});
874+
break;
828875
case 'impact_analysis':
829876
result = impactAnalysisData(args.file, dbPath, {
830877
noTests: args.no_tests,
@@ -1204,6 +1251,26 @@ export async function startMCPServer(customDbPath, options = {}) {
12041251
});
12051252
break;
12061253
}
1254+
case 'scoped_rebuild': {
1255+
if (!args.files || args.files.length === 0) {
1256+
result = { error: 'files array is required and must not be empty' };
1257+
break;
1258+
}
1259+
const path = await import('node:path');
1260+
const rootDir = dbPath
1261+
? path.dirname(path.dirname(dbPath))
1262+
: process.cwd();
1263+
const { buildGraph } = await import('./builder.js');
1264+
await buildGraph(rootDir, {
1265+
scope: args.files,
1266+
noReverseDeps: args.no_reverse_deps,
1267+
});
1268+
result = {
1269+
rebuilt: args.files,
1270+
noReverseDeps: !!args.no_reverse_deps,
1271+
};
1272+
break;
1273+
}
12071274
case 'list_repos': {
12081275
const { listRepos, pruneRegistry } = await import('./registry.js');
12091276
pruneRegistry();

src/paginate.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const MCP_DEFAULTS = {
1818
context: 5,
1919
explain: 10,
2020
file_deps: 20,
21+
file_exports: 20,
2122
diff_impact: 30,
2223
impact_analysis: 20,
2324
semantic_search: 20,

src/queries.js

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3006,6 +3006,166 @@ export function roles(customDbPath, opts = {}) {
30063006
}
30073007
}
30083008

3009+
// ─── exportsData ─────────────────────────────────────────────────────
3010+
3011+
function exportsFileImpl(db, target, noTests, getFileLines) {
3012+
const fileNodes = db
3013+
.prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
3014+
.all(`%${target}%`);
3015+
if (fileNodes.length === 0) return [];
3016+
3017+
return fileNodes.map((fn) => {
3018+
const symbols = db
3019+
.prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
3020+
.all(fn.file);
3021+
3022+
// IDs of symbols that have incoming calls from other files (exported)
3023+
const exportedIds = new Set(
3024+
db
3025+
.prepare(
3026+
`SELECT DISTINCT e.target_id FROM edges e
3027+
JOIN nodes caller ON e.source_id = caller.id
3028+
JOIN nodes target ON e.target_id = target.id
3029+
WHERE target.file = ? AND caller.file != ? AND e.kind = 'calls'`,
3030+
)
3031+
.all(fn.file, fn.file)
3032+
.map((r) => r.target_id),
3033+
);
3034+
3035+
const exported = symbols.filter((s) => exportedIds.has(s.id));
3036+
const internalCount = symbols.length - exported.length;
3037+
3038+
const results = exported.map((s) => {
3039+
const fileLines = getFileLines(fn.file);
3040+
3041+
let consumers = db
3042+
.prepare(
3043+
`SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id
3044+
WHERE e.target_id = ? AND e.kind = 'calls'`,
3045+
)
3046+
.all(s.id);
3047+
if (noTests) consumers = consumers.filter((c) => !isTestFile(c.file));
3048+
3049+
return {
3050+
name: s.name,
3051+
kind: s.kind,
3052+
line: s.line,
3053+
endLine: s.end_line ?? null,
3054+
role: s.role || null,
3055+
signature: fileLines ? extractSignature(fileLines, s.line) : null,
3056+
summary: fileLines ? extractSummary(fileLines, s.line) : null,
3057+
consumers: consumers.map((c) => ({ name: c.name, file: c.file, line: c.line })),
3058+
consumerCount: consumers.length,
3059+
};
3060+
});
3061+
3062+
// Reexport edges from this file node
3063+
const reexports = db
3064+
.prepare(
3065+
`SELECT n.file FROM edges e JOIN nodes n ON e.target_id = n.id
3066+
WHERE e.source_id = ? AND e.kind = 'reexports'`,
3067+
)
3068+
.all(fn.id)
3069+
.map((r) => ({ file: r.file }));
3070+
3071+
return {
3072+
file: fn.file,
3073+
results,
3074+
reexports,
3075+
totalExported: exported.length,
3076+
totalInternal: internalCount,
3077+
};
3078+
});
3079+
}
3080+
3081+
export function exportsData(file, customDbPath, opts = {}) {
3082+
const db = openReadonlyOrFail(customDbPath);
3083+
const noTests = opts.noTests || false;
3084+
3085+
const dbFilePath = findDbPath(customDbPath);
3086+
const repoRoot = path.resolve(path.dirname(dbFilePath), '..');
3087+
3088+
const fileCache = new Map();
3089+
function getFileLines(file) {
3090+
if (fileCache.has(file)) return fileCache.get(file);
3091+
try {
3092+
const absPath = safePath(repoRoot, file);
3093+
if (!absPath) {
3094+
fileCache.set(file, null);
3095+
return null;
3096+
}
3097+
const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
3098+
fileCache.set(file, lines);
3099+
return lines;
3100+
} catch {
3101+
fileCache.set(file, null);
3102+
return null;
3103+
}
3104+
}
3105+
3106+
const fileResults = exportsFileImpl(db, file, noTests, getFileLines);
3107+
db.close();
3108+
3109+
if (fileResults.length === 0) {
3110+
return paginateResult(
3111+
{ file, results: [], reexports: [], totalExported: 0, totalInternal: 0 },
3112+
'results',
3113+
{ limit: opts.limit, offset: opts.offset },
3114+
);
3115+
}
3116+
3117+
// For single-file match return flat; for multi-match return first (like explainData)
3118+
const first = fileResults[0];
3119+
const base = {
3120+
file: first.file,
3121+
results: first.results,
3122+
reexports: first.reexports,
3123+
totalExported: first.totalExported,
3124+
totalInternal: first.totalInternal,
3125+
};
3126+
return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
3127+
}
3128+
3129+
export function fileExports(file, customDbPath, opts = {}) {
3130+
const data = exportsData(file, customDbPath, opts);
3131+
if (opts.ndjson) {
3132+
printNdjson(data, 'results');
3133+
return;
3134+
}
3135+
if (opts.json) {
3136+
console.log(JSON.stringify(data, null, 2));
3137+
return;
3138+
}
3139+
3140+
if (data.results.length === 0) {
3141+
console.log(`No exported symbols found for "${file}". Run "codegraph build" first.`);
3142+
return;
3143+
}
3144+
3145+
console.log(
3146+
`\n# ${data.file}${data.totalExported} exported, ${data.totalInternal} internal\n`,
3147+
);
3148+
3149+
for (const sym of data.results) {
3150+
const icon = kindIcon(sym.kind);
3151+
const sig = sym.signature?.params ? `(${sym.signature.params})` : '';
3152+
const role = sym.role ? ` [${sym.role}]` : '';
3153+
console.log(` ${icon} ${sym.name}${sig}${role} :${sym.line}`);
3154+
if (sym.consumers.length === 0) {
3155+
console.log(' (no consumers)');
3156+
} else {
3157+
for (const c of sym.consumers) {
3158+
console.log(` <- ${c.name} (${c.file}:${c.line})`);
3159+
}
3160+
}
3161+
}
3162+
3163+
if (data.reexports.length > 0) {
3164+
console.log(`\n Re-exports: ${data.reexports.map((r) => r.file).join(', ')}`);
3165+
}
3166+
console.log();
3167+
}
3168+
30093169
export function fnImpact(name, customDbPath, opts = {}) {
30103170
const data = fnImpactData(name, customDbPath, opts);
30113171
if (opts.ndjson) {

0 commit comments

Comments
 (0)