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,207 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { partnerOf } from '../git/analyzer.js';
|
|
4
|
+
export function textResponse(text) {
|
|
5
|
+
return { content: [{ type: 'text', text }] };
|
|
6
|
+
}
|
|
7
|
+
// `resolve` collapses `..` segments so a traversal attempt produces a
|
|
8
|
+
// relative path starting with `..` — that's how we detect escapes.
|
|
9
|
+
export function normalizeFilePath(input, projectRoot) {
|
|
10
|
+
const cleaned = input.replace(/\\/g, '/');
|
|
11
|
+
const absolute = resolve(projectRoot, cleaned);
|
|
12
|
+
const rel = relative(projectRoot, absolute).replace(/\\/g, '/');
|
|
13
|
+
if (rel === '' || rel === '..' || rel.startsWith('../'))
|
|
14
|
+
return null;
|
|
15
|
+
return rel;
|
|
16
|
+
}
|
|
17
|
+
// projectRoot is fixed for the process lifetime, so its realpath is too —
|
|
18
|
+
// caching it spares one syscall per safeReadIndexedFile call (pattern
|
|
19
|
+
// scans call this once per candidate file).
|
|
20
|
+
const realRootCache = new Map();
|
|
21
|
+
async function realProjectRoot(projectRoot) {
|
|
22
|
+
let cached = realRootCache.get(projectRoot);
|
|
23
|
+
if (cached === undefined) {
|
|
24
|
+
cached = await fs.realpath(projectRoot);
|
|
25
|
+
realRootCache.set(projectRoot, cached);
|
|
26
|
+
}
|
|
27
|
+
return cached;
|
|
28
|
+
}
|
|
29
|
+
// Re-check scanner admission rules at read time so stale on-disk
|
|
30
|
+
// state (symlink-swap, growth past cap, became-directory) can't
|
|
31
|
+
// bypass the indexer's contract.
|
|
32
|
+
export async function safeReadIndexedFile(relPath, config) {
|
|
33
|
+
const abs = join(config.projectRoot, relPath);
|
|
34
|
+
const stats = await fs.lstat(abs);
|
|
35
|
+
if (stats.isSymbolicLink()) {
|
|
36
|
+
throw new Error('refusing to follow symlink');
|
|
37
|
+
}
|
|
38
|
+
if (!stats.isFile()) {
|
|
39
|
+
throw new Error('not a regular file');
|
|
40
|
+
}
|
|
41
|
+
if (stats.size > config.maxFileSize) {
|
|
42
|
+
throw new Error(`exceeds maxFileSize (${stats.size} > ${config.maxFileSize})`);
|
|
43
|
+
}
|
|
44
|
+
// lstat only checks the final component. Resolve parent-directory
|
|
45
|
+
// symlinks so a swap higher up in the path can't escape projectRoot.
|
|
46
|
+
const [real, realRoot] = await Promise.all([
|
|
47
|
+
fs.realpath(abs),
|
|
48
|
+
realProjectRoot(config.projectRoot),
|
|
49
|
+
]);
|
|
50
|
+
if (real !== realRoot && !real.startsWith(realRoot + sep)) {
|
|
51
|
+
throw new Error('path escapes project root');
|
|
52
|
+
}
|
|
53
|
+
return fs.readFile(abs, 'utf8');
|
|
54
|
+
}
|
|
55
|
+
// Among ranges that contain `line`, pick the smallest — targets the innermost
|
|
56
|
+
// match (e.g. a method inside a same-named class). Returns null when no
|
|
57
|
+
// candidate spans `line`; callers that want a fallback handle it themselves.
|
|
58
|
+
export function innermostEnclosing(candidates, line) {
|
|
59
|
+
let innermost = null;
|
|
60
|
+
let innermostSize = Infinity;
|
|
61
|
+
for (const s of candidates) {
|
|
62
|
+
if (s.startLine > line || line > s.endLine)
|
|
63
|
+
continue;
|
|
64
|
+
const size = s.endLine - s.startLine;
|
|
65
|
+
if (size < innermostSize) {
|
|
66
|
+
innermost = s;
|
|
67
|
+
innermostSize = size;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return innermost;
|
|
71
|
+
}
|
|
72
|
+
// Innermost containing range when one exists; otherwise the candidate
|
|
73
|
+
// nearest by startLine.
|
|
74
|
+
export function pickByLine(candidates, line) {
|
|
75
|
+
const innermost = innermostEnclosing(candidates, line);
|
|
76
|
+
if (innermost)
|
|
77
|
+
return innermost;
|
|
78
|
+
let best = candidates[0];
|
|
79
|
+
let bestDist = Math.abs(line - best.startLine);
|
|
80
|
+
for (let i = 1; i < candidates.length; i++) {
|
|
81
|
+
const d = Math.abs(line - candidates[i].startLine);
|
|
82
|
+
if (d < bestDist) {
|
|
83
|
+
best = candidates[i];
|
|
84
|
+
bestDist = d;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return best;
|
|
88
|
+
}
|
|
89
|
+
export function renderAmbiguous(name, file, candidates) {
|
|
90
|
+
const lines = [`Multiple symbols named '${name}' in ${file}:`];
|
|
91
|
+
for (const c of candidates) {
|
|
92
|
+
lines.push(`- ${c.kind} ${c.startLine}-${c.endLine}: ${displaySignature(c)}`);
|
|
93
|
+
}
|
|
94
|
+
lines.push('', 'Pass `line` to disambiguate.');
|
|
95
|
+
return lines.join('\n');
|
|
96
|
+
}
|
|
97
|
+
export function omittedSuffix(omitted) {
|
|
98
|
+
return `- (${omitted} more omitted; raise \`limit\` to see all)`;
|
|
99
|
+
}
|
|
100
|
+
// Always emits the header; uses `(none)` as the body placeholder when
|
|
101
|
+
// empty, so absence of callers/callees/exports is explicit in output.
|
|
102
|
+
export function sectionOrNone(header, body) {
|
|
103
|
+
if (body.length === 0)
|
|
104
|
+
return `${header}\n(none)`;
|
|
105
|
+
return [header, ...body].join('\n');
|
|
106
|
+
}
|
|
107
|
+
// Elides the section when empty — for outlines where a missing section
|
|
108
|
+
// is preferable to a `(none)` placeholder (imports, internal symbols).
|
|
109
|
+
export function sectionOrEmpty(header, body) {
|
|
110
|
+
if (body.length === 0)
|
|
111
|
+
return '';
|
|
112
|
+
return [header, ...body].join('\n');
|
|
113
|
+
}
|
|
114
|
+
// Falls back to `name` when no signature was extracted (variables, some
|
|
115
|
+
// Python defs) so list rows never trail an empty token.
|
|
116
|
+
export function displaySignature(s) {
|
|
117
|
+
return s.signature || s.name;
|
|
118
|
+
}
|
|
119
|
+
// Builds the "Did you mean:" block for fuzzy suggestion replies. Returns
|
|
120
|
+
// the lines (including a leading blank separator) when non-empty, [] when
|
|
121
|
+
// empty so the calling site's leading sentence is the entire output.
|
|
122
|
+
export function renderSuggestions(suggestions) {
|
|
123
|
+
if (suggestions.length === 0)
|
|
124
|
+
return [];
|
|
125
|
+
const lines = ['', 'Did you mean:'];
|
|
126
|
+
for (const s of suggestions) {
|
|
127
|
+
const tag = s.exported ? ' [exported]' : '';
|
|
128
|
+
lines.push(`- ${s.name} (${s.kind}, ${s.file}:${s.startLine})${tag}`);
|
|
129
|
+
}
|
|
130
|
+
return lines;
|
|
131
|
+
}
|
|
132
|
+
export const INDEXING_BANNER = '⏳ Indexing in progress. Results may be incomplete.';
|
|
133
|
+
// Caller-row label for refs at file scope (no enclosing source symbol).
|
|
134
|
+
export const MODULE_LEVEL = '(module-level)';
|
|
135
|
+
// Per-line tag stamped on every approximate-name-match caller row so
|
|
136
|
+
// consumers can distinguish AST-only matches from precise (LSP) refs.
|
|
137
|
+
// Shared between `find_references` and `get_context`'s file-mode export
|
|
138
|
+
// caller summary — both render the same data path.
|
|
139
|
+
export const NAME_MATCH_TAG = '[name match, unverified]';
|
|
140
|
+
// Tag for unresolved member-call rows (`obj.method()` / `ns.fn()`):
|
|
141
|
+
// noisier than bare-name matches because the receiver could bind to any
|
|
142
|
+
// object, so the property match alone carries the evidence.
|
|
143
|
+
export const MEMBER_MATCH_TAG = '[member call, unverified]';
|
|
144
|
+
// Heading qualifier that pairs with `NAME_MATCH_TAG`. Section headers
|
|
145
|
+
// compose their own prefix and append this so the precision tier is
|
|
146
|
+
// announced consistently.
|
|
147
|
+
export const NAME_MATCH_HEADER_QUALIFIER = '(approximate — from AST name matching)';
|
|
148
|
+
// Counterpart to `NAME_MATCH_TAG` for symbol-mode rows derived from the
|
|
149
|
+
// id-keyed adjacency (precise within-file resolution). Emitted by
|
|
150
|
+
// `get_context` symbol-mode caller/callee lists.
|
|
151
|
+
export const STRUCTURAL_TAG = '[structural]';
|
|
152
|
+
// Tag-less complexity body ("cyc N / cog M") for anything carrying the two
|
|
153
|
+
// optional metrics, or null when neither is present. Cyclomatic is omitted at
|
|
154
|
+
// the trivial 1, cognitive at 0, so a value may carry either, both, or neither;
|
|
155
|
+
// show whichever the extractor populated. The param is a minimal structural
|
|
156
|
+
// shape (not full `Symbol`) so a RiskRow can render its offender's complexity
|
|
157
|
+
// without a synthetic Symbol. Used where the caller adds the [structural] tag
|
|
158
|
+
// (formatComplexity) or deliberately suppresses it (Risk Hotspots rows, already
|
|
159
|
+
// under a single [behavioral] heading).
|
|
160
|
+
export function formatComplexityMetrics(sym) {
|
|
161
|
+
const parts = [];
|
|
162
|
+
if (sym.complexity !== undefined)
|
|
163
|
+
parts.push(`cyc ${sym.complexity}`);
|
|
164
|
+
if (sym.cognitiveComplexity !== undefined)
|
|
165
|
+
parts.push(`cog ${sym.cognitiveComplexity}`);
|
|
166
|
+
return parts.length === 0 ? null : parts.join(' / ');
|
|
167
|
+
}
|
|
168
|
+
// Renders the combined complexity body with the [structural] tag for a symbol,
|
|
169
|
+
// or null when neither metric is present. Both metrics are genuinely structural
|
|
170
|
+
// (no name-match approximation, unlike fan-in), so one [structural] tag covers
|
|
171
|
+
// the line. Callers add their own prefix ("Complexity:" / "- Complexity:").
|
|
172
|
+
export function formatComplexity(sym) {
|
|
173
|
+
const body = formatComplexityMetrics(sym);
|
|
174
|
+
return body === null ? null : `${body} ${STRUCTURAL_TAG}`;
|
|
175
|
+
}
|
|
176
|
+
// Tier tag for git-derived data: commit co-occurrence and history, not
|
|
177
|
+
// code structure. Pairs with the design-notes tier vocabulary
|
|
178
|
+
// ([structural] / [approximate] / [behavioral]).
|
|
179
|
+
export const BEHAVIORAL_TAG = '[behavioral]';
|
|
180
|
+
// The confidence direction is the most invertible bug in the git layer —
|
|
181
|
+
// confidenceAB is the from-self direction only when the queried file is
|
|
182
|
+
// fileA. Centralized (sharing analyzer's partnerOf for the orientation
|
|
183
|
+
// pick) so get_context and find_references cannot disagree. Both tools
|
|
184
|
+
// use the same default partner cap.
|
|
185
|
+
export function topCoChangePartners(coChanges, selfPath, limit = 5) {
|
|
186
|
+
const rows = coChanges.map((c) => {
|
|
187
|
+
const selfIsA = c.fileA === selfPath;
|
|
188
|
+
return {
|
|
189
|
+
partner: partnerOf(c, selfPath),
|
|
190
|
+
// Floor at 1%: a pair only exists because coupling registered, so a
|
|
191
|
+
// "0% confidence" row (3 shared commits against a 600-commit hub
|
|
192
|
+
// file) would be self-contradictory output.
|
|
193
|
+
pct: Math.max(1, Math.round((selfIsA ? c.confidenceAB : c.confidenceBA) * 100)),
|
|
194
|
+
shared: c.sharedCommits,
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
rows.sort((a, b) => b.pct - a.pct ||
|
|
198
|
+
b.shared - a.shared ||
|
|
199
|
+
(a.partner < b.partner ? -1 : a.partner > b.partner ? 1 : 0));
|
|
200
|
+
return rows.slice(0, limit);
|
|
201
|
+
}
|
|
202
|
+
export function readinessBanner(ready) {
|
|
203
|
+
return ready ? '' : `${INDEXING_BANNER}\n\n`;
|
|
204
|
+
}
|
|
205
|
+
export function plural(word, count) {
|
|
206
|
+
return count === 1 ? word : `${word}s`;
|
|
207
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
2
|
+
import { fileImportsName, fileImportsReceiver, isCallerOf, isClassMember, } from '../indexer/code-index.js';
|
|
3
|
+
import { errMsg } from '../logger.js';
|
|
4
|
+
import { RECEIVER_OPAQUE } from '../types.js';
|
|
5
|
+
import { MEMBER_MATCH_TAG, MODULE_LEVEL, NAME_MATCH_HEADER_QUALIFIER, NAME_MATCH_TAG, normalizeFilePath, omittedSuffix, pickByLine, readinessBanner, renderAmbiguous, renderSuggestions, sectionOrEmpty, sectionOrNone, textResponse, topCoChangePartners, } from './common.js';
|
|
6
|
+
const DEFAULT_LIMIT = 20;
|
|
7
|
+
const MAX_LIMIT = 100;
|
|
8
|
+
const SUGGEST_LIMIT = 5;
|
|
9
|
+
// rankRefs' weakest tier: unresolved member refs with an unknown (non-import,
|
|
10
|
+
// opaque/chained) receiver. Listed rows of this tier are capped so a hot
|
|
11
|
+
// method's member-call sites can't dominate the caller list.
|
|
12
|
+
const WEAK_MEMBER_TIER = 5;
|
|
13
|
+
const WEAK_MEMBER_ROW_CAP = 8;
|
|
14
|
+
const CALLERS_HEADER = `### Callers ${NAME_MATCH_HEADER_QUALIFIER}`;
|
|
15
|
+
const CALLEES_HEADER = '### Callees (within-file — from AST resolution)';
|
|
16
|
+
const PHASE_2_NOTE = '(none — ships with LSP in Phase 2)';
|
|
17
|
+
export async function runFindReferences(args, deps) {
|
|
18
|
+
try {
|
|
19
|
+
const file = normalizeFilePath(args.file, deps.config.projectRoot);
|
|
20
|
+
if (file === null) {
|
|
21
|
+
return textResponse(`Error: file "${args.file}" is outside the project root.`);
|
|
22
|
+
}
|
|
23
|
+
const trimmed = args.symbol.trim();
|
|
24
|
+
if (trimmed.length === 0) {
|
|
25
|
+
return textResponse('Error: symbol must be non-empty.');
|
|
26
|
+
}
|
|
27
|
+
// Compute banner before any index-dependent miss so partial first-pass
|
|
28
|
+
// results don't surface as definitive errors.
|
|
29
|
+
const banner = readinessBanner(deps.indexer.ready);
|
|
30
|
+
if (!deps.index.hasFile(file)) {
|
|
31
|
+
return textResponse(banner + `Error: file '${file}' not found in index.`);
|
|
32
|
+
}
|
|
33
|
+
const candidates = deps.index
|
|
34
|
+
.getSymbolsInFile(file)
|
|
35
|
+
.filter((s) => s.name === trimmed);
|
|
36
|
+
if (candidates.length === 0) {
|
|
37
|
+
const suggestions = deps.index.suggest(trimmed, SUGGEST_LIMIT, undefined, file);
|
|
38
|
+
return textResponse(banner + renderNoSymbol(trimmed, file, suggestions));
|
|
39
|
+
}
|
|
40
|
+
let target;
|
|
41
|
+
if (candidates.length === 1) {
|
|
42
|
+
target = candidates[0];
|
|
43
|
+
}
|
|
44
|
+
else if (args.line !== undefined) {
|
|
45
|
+
target = pickByLine(candidates, args.line);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
return textResponse(banner + renderAmbiguous(trimmed, file, candidates));
|
|
49
|
+
}
|
|
50
|
+
const limit = Math.min(args.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
|
|
51
|
+
const kind = args.kind ?? 'all';
|
|
52
|
+
const sections = [];
|
|
53
|
+
sections.push(`## References for \`${target.name}\` (${target.file}:${target.startLine})`);
|
|
54
|
+
if (kind === 'callers' || kind === 'all') {
|
|
55
|
+
sections.push(renderCallers(target, deps.index, limit));
|
|
56
|
+
}
|
|
57
|
+
if (kind === 'callees' || kind === 'all') {
|
|
58
|
+
sections.push(renderCallees(target, deps.index, limit));
|
|
59
|
+
}
|
|
60
|
+
if (kind === 'implementations' || kind === 'all') {
|
|
61
|
+
sections.push(renderPhase2('Implementations'));
|
|
62
|
+
}
|
|
63
|
+
if (kind === 'type_references' || kind === 'all') {
|
|
64
|
+
sections.push(renderPhase2('Type References'));
|
|
65
|
+
}
|
|
66
|
+
// Behavioral coupling rides along with kind 'all' only: it is
|
|
67
|
+
// file-granularity enrichment, not a reference kind, and it stays a
|
|
68
|
+
// separate section rather than a rankRefs tier — a co-committing
|
|
69
|
+
// file says nothing about whether any given AST row is a real call
|
|
70
|
+
// site, so it must not outrank verified rows. Vanishes (no header)
|
|
71
|
+
// outside git repos, unlike the structural sections above whose
|
|
72
|
+
// "(none)" is a real answer.
|
|
73
|
+
if (kind === 'all') {
|
|
74
|
+
const coChanges = renderCoChangePartners(target.file, deps.index);
|
|
75
|
+
if (coChanges)
|
|
76
|
+
sections.push(coChanges);
|
|
77
|
+
}
|
|
78
|
+
return textResponse(banner + sections.join('\n\n'));
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
return textResponse(`Error: ${errMsg(err)}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function renderCallers(target, index, limit) {
|
|
85
|
+
const filtered = index
|
|
86
|
+
.getReferencesByNameOrAlias(target.name, target.file, isClassMember(target))
|
|
87
|
+
.filter((ref) => isCallerOf(ref, target));
|
|
88
|
+
const ranked = rankRefs(filtered, target, index);
|
|
89
|
+
ranked.sort((a, b) => {
|
|
90
|
+
if (a.tier !== b.tier)
|
|
91
|
+
return a.tier - b.tier;
|
|
92
|
+
if (a.ref.file !== b.ref.file) {
|
|
93
|
+
return a.ref.file < b.ref.file ? -1 : 1;
|
|
94
|
+
}
|
|
95
|
+
return a.ref.line - b.ref.line;
|
|
96
|
+
});
|
|
97
|
+
// Capturing chained/member calls makes the weakest tier (unresolved member
|
|
98
|
+
// refs with an unknown receiver — `obj.m()` / chained) explode on hot method
|
|
99
|
+
// names. They already sort last, so resolved/import-connected rows are never
|
|
100
|
+
// starved; ALSO cap how many we list so they don't dominate the output — the
|
|
101
|
+
// rest fold into the omitted count (find_symbol's `References: ~N` still counts
|
|
102
|
+
// them all). WEAK_MEMBER_TIER mirrors rankRefs' tier-5 (unknown-receiver member).
|
|
103
|
+
const strong = ranked.filter((r) => r.tier < WEAK_MEMBER_TIER);
|
|
104
|
+
const weak = ranked.filter((r) => r.tier >= WEAK_MEMBER_TIER);
|
|
105
|
+
const weakShown = weak.slice(0, WEAK_MEMBER_ROW_CAP);
|
|
106
|
+
const shown = [...strong, ...weakShown].slice(0, limit);
|
|
107
|
+
const body = shown.map((r) => {
|
|
108
|
+
const callerLabel = r.source ? `${r.source.name}()` : MODULE_LEVEL;
|
|
109
|
+
// Resolved member refs (this.x() bound at extract time) carry the
|
|
110
|
+
// same confidence as resolved bare refs and keep the name-match tag;
|
|
111
|
+
// unresolved member rows get the noisier member tag.
|
|
112
|
+
const tag = r.ref.receiver !== undefined && r.ref.targetId === null
|
|
113
|
+
? MEMBER_MATCH_TAG
|
|
114
|
+
: NAME_MATCH_TAG;
|
|
115
|
+
return `- ${r.ref.file}:${r.ref.line} — ${callerLabel} ${tag}`;
|
|
116
|
+
});
|
|
117
|
+
const omitted = ranked.length - shown.length;
|
|
118
|
+
if (omitted > 0) {
|
|
119
|
+
// Two INDEPENDENT omission reasons, each with its own lever:
|
|
120
|
+
// (1) `limit` truncated the list — raising it reveals more rows. Those rows
|
|
121
|
+
// are HIGH-confidence only when STRONG rows were the ones cut
|
|
122
|
+
// (`strong.length > limit`); if every strong row is already shown,
|
|
123
|
+
// raising `limit` surfaces only more low-confidence `[member call]` rows,
|
|
124
|
+
// so the high-confidence hint must NOT be advertised then.
|
|
125
|
+
// (2) the weak-member cap dropped tier-5 rows beyond WEAK_MEMBER_ROW_CAP —
|
|
126
|
+
// those are NOT revealable by `limit` (the full count lives in
|
|
127
|
+
// find_symbol's `References: ~N`).
|
|
128
|
+
// Both can hold at once, so gate the high-confidence `limit` hint on
|
|
129
|
+
// `strongCut` (NOT on "limit cut anything" — weakShown rows can be limit-cut
|
|
130
|
+
// while all strong rows show) and the cap note on `capHidden`.
|
|
131
|
+
const capHidden = weak.length > weakShown.length;
|
|
132
|
+
const strongCut = strong.length > limit;
|
|
133
|
+
if (capHidden) {
|
|
134
|
+
const limitHint = strongCut ? ' raise `limit` to see more high-confidence rows;' : '';
|
|
135
|
+
body.push(`- (${omitted} more omitted;${limitHint} low-confidence \`[member call]\` sites are capped — full count via find_symbol's \`References: ~N\`)`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
// No cap fired (every weak row is within WEAK_MEMBER_ROW_CAP), so all
|
|
139
|
+
// omitted rows are purely limit-truncated and fully revealable by `limit`.
|
|
140
|
+
body.push(omittedSuffix(omitted));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return sectionOrNone(CALLERS_HEADER, body);
|
|
144
|
+
}
|
|
145
|
+
function renderCallees(target, index, limit) {
|
|
146
|
+
// Within-file only — cross-file callee resolution waits for LSP (Phase 2).
|
|
147
|
+
const callees = index.getCallees(target.id);
|
|
148
|
+
const body = callees
|
|
149
|
+
.slice(0, limit)
|
|
150
|
+
.map((c) => `- ${c.file}:${c.startLine} — ${c.name}()`);
|
|
151
|
+
if (callees.length > limit) {
|
|
152
|
+
body.push(omittedSuffix(callees.length - limit));
|
|
153
|
+
}
|
|
154
|
+
return sectionOrNone(CALLEES_HEADER, body);
|
|
155
|
+
}
|
|
156
|
+
function renderPhase2(label) {
|
|
157
|
+
return `### ${label}\n${PHASE_2_NOTE}`;
|
|
158
|
+
}
|
|
159
|
+
// Confidence-only rows — find_references is the breadth view; the
|
|
160
|
+
// shared-commit detail lives in get_context's co-change section.
|
|
161
|
+
function renderCoChangePartners(file, index) {
|
|
162
|
+
const rows = topCoChangePartners(index.getCoChanges(file), file);
|
|
163
|
+
return sectionOrEmpty('### Co-change Partners (behavioral — from git)', rows.map((r) => `- ${r.partner} ${r.pct}% confidence`));
|
|
164
|
+
}
|
|
165
|
+
function rankRefs(refs, target, index) {
|
|
166
|
+
const targetDir = dirname(target.file);
|
|
167
|
+
const targetParent = dirname(targetDir);
|
|
168
|
+
const importsCache = new Map();
|
|
169
|
+
const importsFor = (file) => {
|
|
170
|
+
let cached = importsCache.get(file);
|
|
171
|
+
if (cached === undefined) {
|
|
172
|
+
cached = index.getImports(file);
|
|
173
|
+
importsCache.set(file, cached);
|
|
174
|
+
}
|
|
175
|
+
return cached;
|
|
176
|
+
};
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const ref of refs) {
|
|
179
|
+
const refDir = dirname(ref.file);
|
|
180
|
+
let tier;
|
|
181
|
+
if (ref.targetId === null && ref.receiver !== undefined) {
|
|
182
|
+
// Unresolved member refs rank by their receiver binding: an
|
|
183
|
+
// import-connected receiver (`import * as u; u.fn()`) is as strong
|
|
184
|
+
// as an import-connected bare name; anything else is the noisiest
|
|
185
|
+
// tier — the property match alone is weak evidence. An opaque
|
|
186
|
+
// (chained/computed) receiver can never name an import, so it goes
|
|
187
|
+
// straight to tier 5 — skipping the guaranteed-false import scan that
|
|
188
|
+
// chained-call capture makes the dominant unresolved-member case.
|
|
189
|
+
tier =
|
|
190
|
+
ref.receiver !== RECEIVER_OPAQUE &&
|
|
191
|
+
fileImportsReceiver(importsFor(ref.file), ref.receiver)
|
|
192
|
+
? 2
|
|
193
|
+
: WEAK_MEMBER_TIER;
|
|
194
|
+
}
|
|
195
|
+
else if (refDir === targetDir) {
|
|
196
|
+
tier = 1;
|
|
197
|
+
}
|
|
198
|
+
else if (fileImportsName(importsFor(ref.file), target.name)) {
|
|
199
|
+
tier = 2;
|
|
200
|
+
}
|
|
201
|
+
else if (refDir === targetParent || dirname(refDir) === targetParent) {
|
|
202
|
+
tier = 3;
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
tier = 4;
|
|
206
|
+
}
|
|
207
|
+
const source = ref.sourceId ? index.getSymbolById(ref.sourceId) ?? null : null;
|
|
208
|
+
out.push({ ref, tier, source });
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
// rankRefs uses the shared fileImportsReceiver / fileImportsName (code-index.ts)
|
|
213
|
+
// so impact's edge-strength classification and find_references' caller tiers
|
|
214
|
+
// stay in lockstep. (Only namespace/module-object receivers are
|
|
215
|
+
// "import-connected"; a value import merely NAMED like the receiver says
|
|
216
|
+
// nothing about where the object's class lives. A wildcard import is a strong
|
|
217
|
+
// signal — refs reach the ranker only after primaryRefMatchesTarget admitted
|
|
218
|
+
// them.)
|
|
219
|
+
function renderNoSymbol(name, file, suggestions) {
|
|
220
|
+
return [
|
|
221
|
+
`Error: no symbol '${name}' in '${file}'.`,
|
|
222
|
+
...renderSuggestions(suggestions),
|
|
223
|
+
].join('\n');
|
|
224
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { depthOf } from '../indexer/scanner.js';
|
|
2
|
+
import { errMsg } from '../logger.js';
|
|
3
|
+
import { formatComplexity, readinessBanner, renderSuggestions, textResponse, } from './common.js';
|
|
4
|
+
const DEFAULT_LIMIT = 10;
|
|
5
|
+
const MAX_LIMIT = 100;
|
|
6
|
+
const SUGGEST_LIMIT = 5;
|
|
7
|
+
export async function runFindSymbol(args, deps) {
|
|
8
|
+
try {
|
|
9
|
+
const trimmed = args.name.trim();
|
|
10
|
+
if (trimmed.length === 0) {
|
|
11
|
+
return textResponse('Error: name must be non-empty.');
|
|
12
|
+
}
|
|
13
|
+
const limit = Math.min(args.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
|
|
14
|
+
const scope = normalizeScope(args.scope, deps.index);
|
|
15
|
+
const kind = args.kind;
|
|
16
|
+
const exactList = deps.index.findSymbolByName(trimmed, kind, scope);
|
|
17
|
+
const exactIds = new Set(exactList.map((s) => s.id));
|
|
18
|
+
const merged = [...exactList];
|
|
19
|
+
if (merged.length < limit) {
|
|
20
|
+
const prefixList = deps.index.findSymbolsByPrefix(trimmed, Infinity, kind, scope);
|
|
21
|
+
for (const sym of prefixList) {
|
|
22
|
+
if (exactIds.has(sym.id))
|
|
23
|
+
continue;
|
|
24
|
+
merged.push(sym);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const banner = readinessBanner(deps.indexer.ready);
|
|
28
|
+
if (merged.length === 0) {
|
|
29
|
+
const filtered = deps.index.suggest(trimmed, SUGGEST_LIMIT, kind, scope);
|
|
30
|
+
return textResponse(banner + renderNoMatch(trimmed, filtered));
|
|
31
|
+
}
|
|
32
|
+
merged.sort((a, b) => compareEntries(a, b, exactIds));
|
|
33
|
+
const blocks = merged
|
|
34
|
+
.slice(0, limit)
|
|
35
|
+
.map((sym) => renderMatch(sym, deps.index));
|
|
36
|
+
return textResponse(banner + blocks.join('\n\n'));
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return textResponse(`Error: ${errMsg(err)}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function normalizeScope(scope, index) {
|
|
43
|
+
if (!scope)
|
|
44
|
+
return undefined;
|
|
45
|
+
if (scope.endsWith('/'))
|
|
46
|
+
return scope;
|
|
47
|
+
// '.storybook' and 'packages/api.v2' have dots but aren't files; ask the
|
|
48
|
+
// index instead of guessing from the path shape.
|
|
49
|
+
if (index.hasFile(scope))
|
|
50
|
+
return scope;
|
|
51
|
+
return `${scope}/`;
|
|
52
|
+
}
|
|
53
|
+
function compareEntries(a, b, exactIds) {
|
|
54
|
+
const ea = exactIds.has(a.id);
|
|
55
|
+
const eb = exactIds.has(b.id);
|
|
56
|
+
if (ea !== eb)
|
|
57
|
+
return ea ? -1 : 1;
|
|
58
|
+
if (a.exported !== b.exported)
|
|
59
|
+
return a.exported ? -1 : 1;
|
|
60
|
+
const da = depthOf(a.file);
|
|
61
|
+
const db = depthOf(b.file);
|
|
62
|
+
if (da !== db)
|
|
63
|
+
return da - db;
|
|
64
|
+
if (a.file !== b.file)
|
|
65
|
+
return a.file < b.file ? -1 : 1;
|
|
66
|
+
const nameCmp = a.name.localeCompare(b.name);
|
|
67
|
+
if (nameCmp !== 0)
|
|
68
|
+
return nameCmp;
|
|
69
|
+
return a.startLine - b.startLine;
|
|
70
|
+
}
|
|
71
|
+
function renderMatch(sym, index) {
|
|
72
|
+
const range = `${sym.startLine}-${sym.endLine}`;
|
|
73
|
+
const exportedSuffix = sym.exported ? ' | exported' : '';
|
|
74
|
+
const lines = [
|
|
75
|
+
`${sym.file}:${range} | ${sym.kind}${exportedSuffix}`,
|
|
76
|
+
sym.signature,
|
|
77
|
+
];
|
|
78
|
+
if (sym.doc && sym.doc.length > 0)
|
|
79
|
+
lines.push(sym.doc);
|
|
80
|
+
// Fan-in (reference-granular, cross-file) and fan-out (resolved within-file
|
|
81
|
+
// callees, a lower bound). Both O(1) — no caller-tree walk per match.
|
|
82
|
+
lines.push(`References: ~${index.getCallerCount(sym.id)}`);
|
|
83
|
+
lines.push(`Fan-out: ${index.getFanOut(sym.id)}`);
|
|
84
|
+
// Cyclomatic + cognitive complexity — genuinely structural (no name-match
|
|
85
|
+
// approximation, unlike fan-in). Present only for function/method symbols
|
|
86
|
+
// above each metric's trivial value (cyc 1 / cog 0).
|
|
87
|
+
const complexity = formatComplexity(sym);
|
|
88
|
+
if (complexity)
|
|
89
|
+
lines.push(`Complexity: ${complexity}`);
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
}
|
|
92
|
+
function renderNoMatch(name, suggestions) {
|
|
93
|
+
return [`No symbol '${name}' found.`, ...renderSuggestions(suggestions)].join('\n');
|
|
94
|
+
}
|