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,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
+ }