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.
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/dist/config.js +223 -0
- package/dist/git/analyzer.js +177 -0
- package/dist/git/git-service.js +568 -0
- package/dist/git/head-watcher.js +113 -0
- package/dist/git/runner.js +204 -0
- package/dist/index.js +138 -0
- package/dist/indexer/code-index.js +1801 -0
- package/dist/indexer/complexity.js +633 -0
- package/dist/indexer/extractor.js +354 -0
- package/dist/indexer/languages/cpp.js +934 -0
- package/dist/indexer/languages/csharp.js +854 -0
- package/dist/indexer/languages/dart.js +777 -0
- package/dist/indexer/languages/go.js +665 -0
- package/dist/indexer/languages/java.js +507 -0
- package/dist/indexer/languages/kotlin.js +709 -0
- package/dist/indexer/languages/objc.js +397 -0
- package/dist/indexer/languages/php.js +771 -0
- package/dist/indexer/languages/python.js +455 -0
- package/dist/indexer/languages/ruby.js +697 -0
- package/dist/indexer/languages/rust.js +754 -0
- package/dist/indexer/languages/swift.js +691 -0
- package/dist/indexer/languages/typescript.js +485 -0
- package/dist/indexer/parser.js +175 -0
- package/dist/indexer/pipeline.js +342 -0
- package/dist/indexer/scanner.js +279 -0
- package/dist/indexer/watcher.js +353 -0
- package/dist/logger.js +16 -0
- package/dist/server.js +170 -0
- package/dist/tools/common.js +207 -0
- package/dist/tools/find-references.js +224 -0
- package/dist/tools/find-symbol.js +94 -0
- package/dist/tools/get-context.js +370 -0
- package/dist/tools/impact.js +218 -0
- package/dist/tools/overview.js +482 -0
- package/dist/tools/search-structure.js +303 -0
- package/dist/types.js +61 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-dart.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-objc.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- 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
|
+
}
|