codedeep-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/dist/config.js +223 -0
  4. package/dist/git/analyzer.js +177 -0
  5. package/dist/git/git-service.js +568 -0
  6. package/dist/git/head-watcher.js +113 -0
  7. package/dist/git/runner.js +204 -0
  8. package/dist/index.js +138 -0
  9. package/dist/indexer/code-index.js +1801 -0
  10. package/dist/indexer/complexity.js +633 -0
  11. package/dist/indexer/extractor.js +354 -0
  12. package/dist/indexer/languages/cpp.js +934 -0
  13. package/dist/indexer/languages/csharp.js +854 -0
  14. package/dist/indexer/languages/dart.js +777 -0
  15. package/dist/indexer/languages/go.js +665 -0
  16. package/dist/indexer/languages/java.js +507 -0
  17. package/dist/indexer/languages/kotlin.js +709 -0
  18. package/dist/indexer/languages/objc.js +397 -0
  19. package/dist/indexer/languages/php.js +771 -0
  20. package/dist/indexer/languages/python.js +455 -0
  21. package/dist/indexer/languages/ruby.js +697 -0
  22. package/dist/indexer/languages/rust.js +754 -0
  23. package/dist/indexer/languages/swift.js +691 -0
  24. package/dist/indexer/languages/typescript.js +485 -0
  25. package/dist/indexer/parser.js +175 -0
  26. package/dist/indexer/pipeline.js +342 -0
  27. package/dist/indexer/scanner.js +279 -0
  28. package/dist/indexer/watcher.js +353 -0
  29. package/dist/logger.js +16 -0
  30. package/dist/server.js +170 -0
  31. package/dist/tools/common.js +207 -0
  32. package/dist/tools/find-references.js +224 -0
  33. package/dist/tools/find-symbol.js +94 -0
  34. package/dist/tools/get-context.js +370 -0
  35. package/dist/tools/impact.js +218 -0
  36. package/dist/tools/overview.js +482 -0
  37. package/dist/tools/search-structure.js +303 -0
  38. package/dist/types.js +61 -0
  39. package/grammars/tree-sitter-c.wasm +0 -0
  40. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  41. package/grammars/tree-sitter-cpp.wasm +0 -0
  42. package/grammars/tree-sitter-dart.wasm +0 -0
  43. package/grammars/tree-sitter-go.wasm +0 -0
  44. package/grammars/tree-sitter-java.wasm +0 -0
  45. package/grammars/tree-sitter-javascript.wasm +0 -0
  46. package/grammars/tree-sitter-kotlin.wasm +0 -0
  47. package/grammars/tree-sitter-objc.wasm +0 -0
  48. package/grammars/tree-sitter-php.wasm +0 -0
  49. package/grammars/tree-sitter-python.wasm +0 -0
  50. package/grammars/tree-sitter-ruby.wasm +0 -0
  51. package/grammars/tree-sitter-rust.wasm +0 -0
  52. package/grammars/tree-sitter-swift.wasm +0 -0
  53. package/grammars/tree-sitter-tsx.wasm +0 -0
  54. package/grammars/tree-sitter-typescript.wasm +0 -0
  55. package/package.json +67 -0
@@ -0,0 +1,370 @@
1
+ import { isCallerOf, isClassMember, } from '../indexer/code-index.js';
2
+ import { errMsg } from '../logger.js';
3
+ import { classNameFromFqn } from '../types.js';
4
+ import { BEHAVIORAL_TAG, MODULE_LEVEL, NAME_MATCH_HEADER_QUALIFIER, NAME_MATCH_TAG, STRUCTURAL_TAG, displaySignature, formatComplexity, normalizeFilePath, pickByLine, plural, readinessBanner, renderAmbiguous, renderSuggestions, safeReadIndexedFile, sectionOrEmpty, sectionOrNone, textResponse, topCoChangePartners, } from './common.js';
5
+ const DEFAULT_MAX_TOKENS = 3000;
6
+ const SUGGEST_LIMIT = 5;
7
+ // `exported_by` is intentionally absent: the TS extractor skips re-export
8
+ // statements (`export { x } from './y'`), so any "exported by" listing in
9
+ // Phase 1a would only surface coincidentally same-named exports from
10
+ // unrelated files. The section returns when re-export edges land.
11
+ // `co_changes` and `git` sit last: they render at the end and are the
12
+ // first casualties under max_tokens pressure (enrichment, not core).
13
+ const ALL_SECTIONS = ['body', 'callers', 'callees', 'coupling', 'imports', 'co_changes', 'git'];
14
+ function truncationNote(at, maxTokens) {
15
+ return `(Sections from \`${at}\` onward omitted to stay within max_tokens=${maxTokens}.)`;
16
+ }
17
+ async function renderBudgeted(header, items, include, maxTokens, neverDrop) {
18
+ const blocks = [header];
19
+ let used = estimate(header);
20
+ let truncatedAt = null;
21
+ for (const item of items) {
22
+ if (!include.has(item.includeKey))
23
+ continue;
24
+ // Once the budget is spent, a cheap peek avoids rendering work whose
25
+ // output would be discarded: known-empty sections skip silently,
26
+ // known non-empty ones become the truncation point without paying
27
+ // their render. Sections without a peek (recent-changes subprocess)
28
+ // still render below so the note stays honest.
29
+ if (item.includeKey !== neverDrop && used >= maxTokens) {
30
+ const peek = item.peekNonEmpty?.();
31
+ if (peek === false)
32
+ continue;
33
+ if (peek === true) {
34
+ truncatedAt = item.name;
35
+ break;
36
+ }
37
+ }
38
+ // Render BEFORE deciding truncation: a section that renders empty
39
+ // (e.g. git sections outside a repo) is silently elided either way,
40
+ // so the truncation note can never name it and promise content a
41
+ // larger max_tokens would not reveal.
42
+ const text = await item.render();
43
+ if (!text)
44
+ continue;
45
+ const cost = estimate(text);
46
+ if (item.includeKey !== neverDrop && used + cost > maxTokens) {
47
+ truncatedAt = item.name;
48
+ break;
49
+ }
50
+ blocks.push(text);
51
+ used += cost;
52
+ }
53
+ if (truncatedAt)
54
+ blocks.push(truncationNote(truncatedAt, maxTokens));
55
+ return blocks.join('\n\n');
56
+ }
57
+ export async function runGetContext(args, deps) {
58
+ try {
59
+ const file = normalizeFilePath(args.file, deps.config.projectRoot);
60
+ if (file === null) {
61
+ return textResponse(`Error: file "${args.file}" is outside the project root.`);
62
+ }
63
+ const include = parseInclude(args.include);
64
+ const maxTokens = args.max_tokens ?? DEFAULT_MAX_TOKENS;
65
+ const banner = readinessBanner(deps.indexer.ready);
66
+ if (args.symbol !== undefined) {
67
+ const trimmed = args.symbol.trim();
68
+ if (trimmed.length === 0) {
69
+ return textResponse('Error: symbol must be non-empty.');
70
+ }
71
+ const body = await renderSymbolMode(file, trimmed, args.line, include, maxTokens, deps);
72
+ return textResponse(banner + body);
73
+ }
74
+ return textResponse(banner + (await renderFileMode(file, include, maxTokens, deps)));
75
+ }
76
+ catch (err) {
77
+ return textResponse(`Error: ${errMsg(err)}`);
78
+ }
79
+ }
80
+ function parseInclude(input) {
81
+ if (!input)
82
+ return new Set(ALL_SECTIONS);
83
+ const set = new Set();
84
+ for (const s of input) {
85
+ const lower = s.toLowerCase();
86
+ if (ALL_SECTIONS.includes(lower)) {
87
+ set.add(lower);
88
+ }
89
+ }
90
+ return set;
91
+ }
92
+ async function renderSymbolMode(file, name, line, include, maxTokens, deps) {
93
+ const candidates = deps.index
94
+ .getSymbolsInFile(file)
95
+ .filter((s) => s.name === name);
96
+ if (candidates.length === 0) {
97
+ const suggestions = deps.index.suggest(name, SUGGEST_LIMIT, undefined, file);
98
+ return renderNoSymbol(name, file, suggestions);
99
+ }
100
+ let target;
101
+ if (candidates.length > 1) {
102
+ if (line === undefined) {
103
+ return renderAmbiguous(name, file, candidates);
104
+ }
105
+ target = pickByLine(candidates, line);
106
+ }
107
+ else {
108
+ target = candidates[0];
109
+ }
110
+ return renderSymbolBlock(target, file, include, maxTokens, deps);
111
+ }
112
+ function renderNoSymbol(name, file, suggestions) {
113
+ return [
114
+ `No symbol '${name}' found in ${file}.`,
115
+ ...renderSuggestions(suggestions),
116
+ ].join('\n');
117
+ }
118
+ async function renderSymbolBlock(target, file, include, maxTokens, deps) {
119
+ const headerLines = [];
120
+ const exportedTag = target.exported ? ' | exported' : '';
121
+ headerLines.push(`${target.file}:${target.startLine}-${target.endLine} | ${target.kind}${exportedTag}`);
122
+ if (target.signature)
123
+ headerLines.push(target.signature);
124
+ if (target.doc && target.doc.length > 0)
125
+ headerLines.push(target.doc);
126
+ const header = headerLines.join('\n');
127
+ // `body` is the highest-priority section and is never dropped to fit budget.
128
+ const items = [
129
+ {
130
+ name: 'body',
131
+ includeKey: 'body',
132
+ render: () => renderBody(target, deps.config),
133
+ },
134
+ {
135
+ name: 'callers',
136
+ includeKey: 'callers',
137
+ render: () => renderCallerEdges(deps.index.getCallerEdges(target.id)),
138
+ peekNonEmpty: () => true, // sectionOrNone renders "(none)"
139
+ },
140
+ {
141
+ name: 'callees',
142
+ includeKey: 'callees',
143
+ render: () => renderCalleeEdges(deps.index.getCallees(target.id)),
144
+ peekNonEmpty: () => true,
145
+ },
146
+ {
147
+ name: 'coupling',
148
+ includeKey: 'coupling',
149
+ render: () => renderCoupling(target, deps.index),
150
+ peekNonEmpty: () => true,
151
+ },
152
+ {
153
+ name: 'imports',
154
+ includeKey: 'imports',
155
+ render: () => renderImports(file, deps.index),
156
+ peekNonEmpty: () => deps.index.getImports(file).length > 0,
157
+ },
158
+ ...gitSectionItems(target.file, deps),
159
+ ];
160
+ return renderBudgeted(header, items, include, maxTokens, 'body');
161
+ }
162
+ // The two git sections are identical in both modes and always trail the
163
+ // list (first to drop under budget pressure).
164
+ function gitSectionItems(file, deps) {
165
+ return [
166
+ {
167
+ name: 'co-change partners',
168
+ includeKey: 'co_changes',
169
+ render: () => renderCoChanges(file, deps.index),
170
+ peekNonEmpty: () => deps.index.getCoChanges(file).length > 0,
171
+ },
172
+ {
173
+ name: 'recent changes',
174
+ includeKey: 'git',
175
+ render: () => renderRecentChanges(file, deps.git),
176
+ },
177
+ ];
178
+ }
179
+ function renderCoChanges(file, index) {
180
+ const rows = topCoChangePartners(index.getCoChanges(file), file);
181
+ return sectionOrEmpty(`### Co-change Partners (${rows.length} behavioral)`, rows.map((r) => `- ${r.partner} ${r.pct}% confidence (${r.shared} shared ${plural('commit', r.shared)})`));
182
+ }
183
+ // Defensive try/catch at the tool boundary: GitService promises not to
184
+ // throw, but a git failure must never turn a get_context call into an
185
+ // in-band "Error:" response — the section just vanishes.
186
+ async function renderRecentChanges(file, git) {
187
+ let commits;
188
+ try {
189
+ commits = await git.recentCommits(file);
190
+ }
191
+ catch {
192
+ return '';
193
+ }
194
+ return sectionOrEmpty(`### Recent Changes ${BEHAVIORAL_TAG}`, commits.map((c) => `- ${c.date} ${c.hash} "${c.subject}"`));
195
+ }
196
+ async function renderBody(target, config) {
197
+ let content;
198
+ try {
199
+ content = await safeReadIndexedFile(target.file, config);
200
+ }
201
+ catch (err) {
202
+ return `### Body\n(unable to read ${target.file}: ${errMsg(err)})`;
203
+ }
204
+ const lines = content.split('\n');
205
+ const start = Math.max(0, target.startLine - 1);
206
+ const end = Math.min(lines.length, target.endLine);
207
+ // Strip the trailing CR of each CRLF pair so a Windows-authored source file
208
+ // doesn't leak a stray '\r' onto every rendered body line (the same class of
209
+ // bug fixed for search_structure's snippet renderer). The split stays on
210
+ // '\n' to keep line indices aligned with tree-sitter's row numbering, which
211
+ // treats '\r\n' as a single row — so only a trailing '\r' can remain.
212
+ const slice = lines
213
+ .slice(start, end)
214
+ .map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l))
215
+ .join('\n');
216
+ return `### Body\n\`\`\`${target.language}\n${slice}\n\`\`\``;
217
+ }
218
+ function renderCallerEdges(edges) {
219
+ return sectionOrNone('### Callers', edges.map((e) => {
220
+ const label = e.symbol ? displaySignature(e.symbol) : MODULE_LEVEL;
221
+ return `- ${e.file}:${e.line} — ${label} ${STRUCTURAL_TAG}`;
222
+ }));
223
+ }
224
+ // Per-symbol coupling. The lines sit in different precision tiers, so each
225
+ // carries its own tag rather than a blanket one: fan-out is the id-keyed
226
+ // RESOLVED callee count ([structural]); fan-in (getCallerCount) and the
227
+ // transitive blast radius walk the SAME approximate name-match caller set
228
+ // find_references tags [name match, unverified]. Blast radius uses depth-2
229
+ // (enough to tell a leaf from a hub, cheap for one symbol) and the SAME
230
+ // counting method as `impact` via countDistinctCallers — the method matches,
231
+ // not necessarily the number, since `impact` defaults to a deeper walk; a
232
+ // trailing `+` flags an undercount from ANY cap (breadth/node OR the depth-2
233
+ // wall), so a deep caller chain is never silently truncated here.
234
+ function renderCoupling(target, index) {
235
+ const fanIn = index.getCallerCount(target.id);
236
+ const fanOut = index.getFanOut(target.id);
237
+ const blast = index.getBlastRadius(target.id, { maxDepth: 2 });
238
+ const cap = blast.truncated ? '+' : '';
239
+ const blastLine = blast.callers === 0
240
+ ? `- Blast radius: 0 callers (no upstream call sites in the index) ${NAME_MATCH_TAG}`
241
+ : `- Blast radius: ${blast.callers}${cap} ${plural('caller', blast.callers)} across ` +
242
+ `${blast.depths} ${plural('depth', blast.depths)} ` +
243
+ `(${blast.files} ${plural('file', blast.files)}) ${NAME_MATCH_TAG}`;
244
+ const complexity = formatComplexity(target);
245
+ return [
246
+ '### Coupling',
247
+ `- Fan-in: ~${fanIn} (callers) ${NAME_MATCH_TAG}`,
248
+ `- Fan-out: ${fanOut} (callees) ${STRUCTURAL_TAG}`,
249
+ ...(complexity ? [`- Complexity: ${complexity}`] : []),
250
+ blastLine,
251
+ ].join('\n');
252
+ }
253
+ function renderCalleeEdges(edges) {
254
+ return sectionOrNone('### Callees', edges.map((e) => `- ${e.file}:${e.startLine} — ${displaySignature(e)} ${STRUCTURAL_TAG}`));
255
+ }
256
+ function renderImportLines(imports) {
257
+ return imports.map((imp) => `- ${imp.sourceModule}: ${formatImportNames(imp)}`);
258
+ }
259
+ function renderImports(file, index) {
260
+ return sectionOrEmpty('### Imports', renderImportLines(index.getImports(file)));
261
+ }
262
+ function formatImportNames(imp) {
263
+ return imp.importedNames
264
+ .map((n) => n.alias && n.alias !== n.name ? `${n.name} as ${n.alias}` : n.name)
265
+ .join(', ');
266
+ }
267
+ async function renderFileMode(file, include, maxTokens, deps) {
268
+ const symbols = deps.index.getSymbolsInFile(file);
269
+ const imports = deps.index.getImports(file);
270
+ const indexed = deps.index.hasFile(file);
271
+ let lineCount = 0;
272
+ if (indexed) {
273
+ try {
274
+ const content = await safeReadIndexedFile(file, deps.config);
275
+ lineCount = content.length === 0 ? 0 : content.split('\n').length;
276
+ }
277
+ catch { }
278
+ }
279
+ const indexedNote = indexed
280
+ ? ''
281
+ : '\n(File is not in the index — likely excluded by config, oversized, or not yet scanned. Use `overview` to see indexed paths.)';
282
+ const header = `## File: ${file} (${lineCount} ${plural('line', lineCount)}, ${symbols.length} ${plural('symbol', symbols.length)})${indexedNote}`;
283
+ // Methods/getters/setters inherit `exported` from their enclosing class;
284
+ // the file-mode outline hides members to avoid duplicating each class's
285
+ // surface area in the export list — but only when that class is declared
286
+ // in THIS file. Go/Rust/Swift/Kotlin members routinely live apart from
287
+ // their type (Go methods in handlers.go beside server.go; Rust impl blocks;
288
+ // Swift/Kotlin extensions; Kotlin companion objects) — a methods-apart file
289
+ // would otherwise render as "Exports (none)". The check is purely
290
+ // name-presence (no per-language allow-list), so TS/Py/Java members (always
291
+ // co-located with their class) keep their unchanged outlines automatically.
292
+ const typeNamesInFile = new Set(symbols.filter((s) => !isClassMember(s)).map((s) => s.name));
293
+ const topLevel = symbols.filter((s) => {
294
+ const cls = classNameFromFqn(s.fqn);
295
+ return cls === null || !typeNamesInFile.has(cls);
296
+ });
297
+ const exported = topLevel.filter((s) => s.exported);
298
+ const internal = topLevel.filter((s) => !s.exported);
299
+ // `body` covers the outline (Exports + Internal); `callees` has no
300
+ // file-mode analogue. Renderers are lazy so dropped sections don't pay
301
+ // for caller scans.
302
+ const items = [
303
+ {
304
+ name: 'Exports',
305
+ includeKey: 'body',
306
+ render: () => sectionOrNone('### Exports', exported.map(formatFileSymbolLine)),
307
+ },
308
+ {
309
+ name: 'Internal',
310
+ includeKey: 'body',
311
+ render: () => sectionOrEmpty('### Internal', internal.map(formatFileSymbolLine)),
312
+ },
313
+ {
314
+ name: 'Imports',
315
+ includeKey: 'imports',
316
+ render: () => sectionOrEmpty('### Imports', renderImportLines(imports)),
317
+ peekNonEmpty: () => imports.length > 0,
318
+ },
319
+ {
320
+ name: "Callers of this file's exports",
321
+ includeKey: 'callers',
322
+ render: () => sectionOrNone(`### Callers of this file's exports ${NAME_MATCH_HEADER_QUALIFIER}`, collectExportCallers(exported, deps.index)),
323
+ // sectionOrNone always renders; peeking spares the reference scan
324
+ // when the budget is already gone.
325
+ peekNonEmpty: () => true,
326
+ },
327
+ ...gitSectionItems(file, deps),
328
+ ];
329
+ return renderBudgeted(header, items, include, maxTokens);
330
+ }
331
+ // Uses `getReferencesByNameOrAlias + isCallerOf` (same data path as
332
+ // find_references) so cross-file callers — the common case for exported
333
+ // symbols — are surfaced. `getCallerEdges` powers symbol-mode's strict
334
+ // [structural] view; this file-mode summary aggregates per-file and
335
+ // benefits from including import-scoped name-match refs. The
336
+ // `[name match, unverified]` tag matches find_references's caller list
337
+ // so consumers know the data is approximate (same precision tier).
338
+ // Member refs to top-level exports (`utils.foo()` through a namespace
339
+ // import) flow in here via getReferencesByNameOrAlias like bare-name
340
+ // refs; the aggregate per-file rows keep the shared name-match tag.
341
+ function collectExportCallers(exportedSyms, index) {
342
+ const byFile = new Map();
343
+ for (const exp of exportedSyms) {
344
+ // Pass targetIsMember so member-ref scoping matches find_references
345
+ // exactly — the file-mode outline now admits members (a Go method whose
346
+ // receiver type lives in another file), and a `pkg.Method()` module call
347
+ // must not be scoped as if it could reach a member.
348
+ for (const ref of index.getReferencesByNameOrAlias(exp.name, exp.file, isClassMember(exp))) {
349
+ if (!isCallerOf(ref, exp))
350
+ continue;
351
+ let set = byFile.get(ref.file);
352
+ if (!set) {
353
+ set = new Set();
354
+ byFile.set(ref.file, set);
355
+ }
356
+ set.add(exp.name);
357
+ }
358
+ }
359
+ const lines = [];
360
+ for (const [f, names] of byFile) {
361
+ lines.push(`- ${f} — uses ${[...names].sort().join(', ')} ${NAME_MATCH_TAG}`);
362
+ }
363
+ return lines.sort();
364
+ }
365
+ function formatFileSymbolLine(s) {
366
+ return `- ${s.name} (${s.kind}, line ${s.startLine}) — ${displaySignature(s)}`;
367
+ }
368
+ function estimate(text) {
369
+ return Math.ceil(text.length / 4);
370
+ }
@@ -0,0 +1,218 @@
1
+ import { countDistinctCallers, } from '../indexer/code-index.js';
2
+ import { errMsg } from '../logger.js';
3
+ import { BEHAVIORAL_TAG, MEMBER_MATCH_TAG, MODULE_LEVEL, NAME_MATCH_TAG, STRUCTURAL_TAG, normalizeFilePath, pickByLine, plural, readinessBanner, renderAmbiguous, renderSuggestions, textResponse, topCoChangePartners, } from './common.js';
4
+ const DEFAULT_DEPTH = 3;
5
+ // Past 5 hops the heuristic name-match noise compounds multiplicatively and
6
+ // the tree is mostly candidates; cap is a precision guardrail, not just budget.
7
+ const MAX_DEPTH = 5;
8
+ const DEFAULT_MAX_TOKENS = 3000;
9
+ const SUGGEST_LIMIT = 5;
10
+ export async function runImpact(args, deps) {
11
+ try {
12
+ const file = normalizeFilePath(args.file, deps.config.projectRoot);
13
+ if (file === null) {
14
+ return textResponse(`Error: file "${args.file}" is outside the project root.`);
15
+ }
16
+ const trimmed = args.symbol.trim();
17
+ if (trimmed.length === 0) {
18
+ return textResponse('Error: symbol must be non-empty.');
19
+ }
20
+ // Computed before any miss so partial first-pass results aren't surfaced
21
+ // as definitive errors (matches find_references).
22
+ const banner = readinessBanner(deps.indexer.ready);
23
+ if (!deps.index.hasFile(file)) {
24
+ return textResponse(banner + `Error: file '${file}' not found in index.`);
25
+ }
26
+ const candidates = deps.index
27
+ .getSymbolsInFile(file)
28
+ .filter((s) => s.name === trimmed);
29
+ if (candidates.length === 0) {
30
+ const suggestions = deps.index.suggest(trimmed, SUGGEST_LIMIT, undefined, file);
31
+ return textResponse(banner + renderNoSymbol(trimmed, file, suggestions));
32
+ }
33
+ let target;
34
+ if (candidates.length === 1) {
35
+ target = candidates[0];
36
+ }
37
+ else if (args.line !== undefined) {
38
+ target = pickByLine(candidates, args.line);
39
+ }
40
+ else {
41
+ return textResponse(banner + renderAmbiguous(trimmed, file, candidates));
42
+ }
43
+ // Clamp to [1, MAX_DEPTH]. The MCP schema enforces .positive(), but
44
+ // runImpact is also invoked directly (tests / internal callers); a
45
+ // non-positive depth would make maxDepth<=0 and falsely report "0 callers".
46
+ const depth = Math.max(1, Math.min(args.depth ?? DEFAULT_DEPTH, MAX_DEPTH));
47
+ const maxTokens = args.max_tokens ?? DEFAULT_MAX_TOKENS;
48
+ const includeWeak = args.include_weak ?? false;
49
+ const tree = deps.index.getCallerTree(target.id, {
50
+ maxDepth: depth,
51
+ includeWeak,
52
+ });
53
+ return textResponse(banner + renderImpact(tree, target, depth, maxTokens, deps.index));
54
+ }
55
+ catch (err) {
56
+ return textResponse(`Error: ${errMsg(err)}`);
57
+ }
58
+ }
59
+ function renderNoSymbol(name, file, suggestions) {
60
+ return [
61
+ `Error: no symbol '${name}' in '${file}'.`,
62
+ ...renderSuggestions(suggestions),
63
+ ].join('\n');
64
+ }
65
+ // Edge strength -> the existing per-row provenance tier tag. Honest by
66
+ // construction: resolved within-file edges are [structural]; everything
67
+ // cross-file is a name/member match, never asserted as verified.
68
+ function tagFor(strength) {
69
+ if (strength === 'resolved')
70
+ return STRUCTURAL_TAG;
71
+ if (strength === 'weak-member')
72
+ return MEMBER_MATCH_TAG;
73
+ return NAME_MATCH_TAG;
74
+ }
75
+ function estimate(text) {
76
+ return Math.ceil(text.length / 4);
77
+ }
78
+ function flattenByDepth(root) {
79
+ const byDepth = new Map();
80
+ const walk = (node) => {
81
+ for (const child of node.children) {
82
+ let bucket = byDepth.get(child.depth);
83
+ if (!bucket) {
84
+ bucket = [];
85
+ byDepth.set(child.depth, bucket);
86
+ }
87
+ bucket.push(child);
88
+ walk(child);
89
+ }
90
+ };
91
+ walk(root);
92
+ return byDepth;
93
+ }
94
+ function renderNodeRow(node) {
95
+ const label = node.isModuleLevel ? MODULE_LEVEL : `${node.name}()`;
96
+ let row = `- ${node.file}:${node.line} — ${label} ${tagFor(node.strength)}`;
97
+ const marks = [];
98
+ if (node.isCycle)
99
+ marks.push('cycle — already shown on this path');
100
+ if (node.leafByPolicy) {
101
+ // Distinguish WHY this real caller was not expanded: a resolved /
102
+ // import-connected leaf stopped on the depth-confidence floor; a weaker
103
+ // edge stopped on its class. Both are lifted by include_weak.
104
+ const reason = node.strength === 'resolved' || node.strength === 'import-connected'
105
+ ? 'low confidence at this depth'
106
+ : 'weak edge';
107
+ marks.push(`not expanded (${reason}) — pass include_weak`);
108
+ }
109
+ if (node.truncatedChildren > 0) {
110
+ marks.push(`+${node.truncatedChildren} more truncated`);
111
+ }
112
+ // The grouped call sites are otherwise unsurfaced; a caller that hits the
113
+ // target several times (or a file with several module-level call sites)
114
+ // would otherwise read as a single call.
115
+ if (node.sites.length > 1) {
116
+ marks.push(`${node.sites.length} call sites`);
117
+ }
118
+ if (marks.length > 0)
119
+ row += ` (${marks.join('; ')})`;
120
+ // BFS-flattening loses the tree edges; name the caller this row reaches the
121
+ // target through.
122
+ if (node.via)
123
+ row += `\n ← via ${node.via}()`;
124
+ return row;
125
+ }
126
+ function renderDepthGroup(depth, nodes) {
127
+ const label = depth === 1 ? 'direct callers' : 'callers of the above';
128
+ const gloss = depth === 1 ? ' — highest risk' : '';
129
+ const header = `### Depth ${depth} — ${label} (${nodes.length})${gloss}`;
130
+ return [header, ...nodes.map(renderNodeRow)].join('\n');
131
+ }
132
+ function renderCoChanges(file, index) {
133
+ const rows = topCoChangePartners(index.getCoChanges(file), file);
134
+ if (rows.length === 0)
135
+ return '';
136
+ return [
137
+ `### Co-change Partners ${BEHAVIORAL_TAG}`,
138
+ ...rows.map((r) => `- ${r.partner} ${r.pct}% confidence`),
139
+ '(May also be affected: no call edge, but these files historically change with this one.)',
140
+ ].join('\n');
141
+ }
142
+ // Depth-first budget loop: title/disclaimer/summary always; Depth 1 is the
143
+ // floor (never dropped, like get_context's body); deeper hops and then the
144
+ // behavioral section drop first, each with an honest, distinctly-worded note.
145
+ function renderImpact(tree, target, depth, maxTokens, index) {
146
+ const header = `## Impact of \`${target.name}\` (${target.file}:${target.startLine})`;
147
+ const disclaimer = `Upstream callers traced to depth ${depth}. Edges are AST name-matches, ` +
148
+ `not compiler-verified — treat as candidates.`;
149
+ const blocks = [header, disclaimer];
150
+ if (tree.totalNodes === 0) {
151
+ blocks.push('0 callers found.');
152
+ blocks.push('(No upstream call sites in the index — not proof of dead code; calls ' +
153
+ 'through dynamic dispatch or inheritance may be invisible.)');
154
+ const cc = renderCoChanges(target.file, index);
155
+ if (cc)
156
+ blocks.push(cc);
157
+ pushLimitations(blocks, tree);
158
+ return blocks.join('\n\n');
159
+ }
160
+ const byDepth = flattenByDepth(tree.root);
161
+ const depths = [...byDepth.keys()].sort((a, b) => a - b);
162
+ // Distinct caller/file/depth counts (and the depth-wall flag) via the shared
163
+ // helper (a DAG diamond is ONE caller, not N) — the single source of truth
164
+ // get_context and overview reuse, so the surfaces never report divergent
165
+ // numbers. impact keeps tree.truncated and depthCapped SEPARATE below: they
166
+ // map to two distinct remediation hints (raise max_tokens/narrow vs raise
167
+ // depth), unlike the scalar BlastRadius which collapses both into one `+`.
168
+ const blast = countDistinctCallers(tree.root);
169
+ const depthCapped = blast.depthCapped;
170
+ const callerCount = blast.callers;
171
+ // A trailing `+` flags that a breadth/size cap fired, so the true total may
172
+ // be higher than the number shown.
173
+ blocks.push(`${callerCount}${tree.truncated ? '+' : ''} ${plural('caller', callerCount)} across ` +
174
+ `${blast.depths} ${plural('depth', blast.depths)} ` +
175
+ `(${blast.files} ${plural('file', blast.files)}).`);
176
+ let used = estimate(blocks.join('\n\n'));
177
+ let cutoff = null;
178
+ for (const d of depths) {
179
+ const group = renderDepthGroup(d, byDepth.get(d) ?? []);
180
+ const cost = estimate(group);
181
+ // Depth 1 is the floor — never dropped to fit budget.
182
+ if (d > 1 && used + cost > maxTokens) {
183
+ cutoff = d;
184
+ break;
185
+ }
186
+ blocks.push(group);
187
+ used += cost;
188
+ }
189
+ // Incompleteness notes — distinctly worded so the agent knows which lever to
190
+ // pull: budget cutoff (raise max_tokens) vs depth wall (raise depth) vs the
191
+ // graph-size cap (the walk itself was bounded; counts may understate).
192
+ if (cutoff !== null) {
193
+ blocks.push(`(Depth ${cutoff}+ omitted to stay within max_tokens=${maxTokens}; the counts above include the omitted callers — raise max_tokens to see them.)`);
194
+ }
195
+ else if (depthCapped) {
196
+ blocks.push(`(Some depth-${depth} callers may have further callers; raise \`depth\` to expand.)`);
197
+ }
198
+ if (tree.truncated) {
199
+ blocks.push('(Caller discovery hit the breadth/size limit; some callers are not shown ' +
200
+ 'and the counts above may understate the true total — narrow the scope to see them.)');
201
+ }
202
+ // Co-change rides last and only when the budget did not already cut off.
203
+ if (cutoff === null) {
204
+ const cc = renderCoChanges(target.file, index);
205
+ if (cc && used + estimate(cc) <= maxTokens)
206
+ blocks.push(cc);
207
+ }
208
+ pushLimitations(blocks, tree);
209
+ return blocks.join('\n\n');
210
+ }
211
+ // Render the structured CallerTreeResult.limitations as a trailing footnote so
212
+ // the disclosure cannot drift from a hand-written copy of it (the top
213
+ // disclaimer stays short and the scope caveats live here, once).
214
+ function pushLimitations(blocks, tree) {
215
+ if (tree.limitations.length === 0)
216
+ return;
217
+ blocks.push(['Limitations:', ...tree.limitations.map((l) => `- ${l}`)].join('\n'));
218
+ }