ucn 3.0.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.

Potentially problematic release.


This version of ucn might be problematic. Click here for more details.

Files changed (45) hide show
  1. package/.claude/skills/ucn/SKILL.md +77 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -0
  4. package/cli/index.js +2437 -0
  5. package/core/discovery.js +513 -0
  6. package/core/imports.js +558 -0
  7. package/core/output.js +1274 -0
  8. package/core/parser.js +279 -0
  9. package/core/project.js +3261 -0
  10. package/index.js +52 -0
  11. package/languages/go.js +653 -0
  12. package/languages/index.js +267 -0
  13. package/languages/java.js +826 -0
  14. package/languages/javascript.js +1346 -0
  15. package/languages/python.js +667 -0
  16. package/languages/rust.js +950 -0
  17. package/languages/utils.js +457 -0
  18. package/package.json +42 -0
  19. package/test/fixtures/go/go.mod +3 -0
  20. package/test/fixtures/go/main.go +257 -0
  21. package/test/fixtures/go/service.go +187 -0
  22. package/test/fixtures/java/DataService.java +279 -0
  23. package/test/fixtures/java/Main.java +287 -0
  24. package/test/fixtures/java/Utils.java +199 -0
  25. package/test/fixtures/java/pom.xml +6 -0
  26. package/test/fixtures/javascript/main.js +109 -0
  27. package/test/fixtures/javascript/package.json +1 -0
  28. package/test/fixtures/javascript/service.js +88 -0
  29. package/test/fixtures/javascript/utils.js +67 -0
  30. package/test/fixtures/python/main.py +198 -0
  31. package/test/fixtures/python/pyproject.toml +3 -0
  32. package/test/fixtures/python/service.py +166 -0
  33. package/test/fixtures/python/utils.py +118 -0
  34. package/test/fixtures/rust/Cargo.toml +3 -0
  35. package/test/fixtures/rust/main.rs +253 -0
  36. package/test/fixtures/rust/service.rs +210 -0
  37. package/test/fixtures/rust/utils.rs +154 -0
  38. package/test/fixtures/typescript/main.ts +154 -0
  39. package/test/fixtures/typescript/package.json +1 -0
  40. package/test/fixtures/typescript/repository.ts +149 -0
  41. package/test/fixtures/typescript/types.ts +114 -0
  42. package/test/parser.test.js +3661 -0
  43. package/test/public-repos-test.js +477 -0
  44. package/test/systematic-test.js +619 -0
  45. package/ucn.js +8 -0
@@ -0,0 +1,3261 @@
1
+ /**
2
+ * core/project.js - Project symbol table and cross-file analysis
3
+ *
4
+ * Builds an in-memory index of all symbols in a project for fast queries.
5
+ * Includes dependency weighting and disambiguation support.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const crypto = require('crypto');
11
+ const { expandGlob, findProjectRoot, detectProjectPattern, isTestFile } = require('./discovery');
12
+ const { extractImports, extractExports, resolveImport } = require('./imports');
13
+ const { parseFile } = require('./parser');
14
+ const { detectLanguage, getParser, getLanguageModule, PARSE_OPTIONS } = require('../languages');
15
+ const { getTokenTypeAtPosition } = require('../languages/utils');
16
+
17
+ /**
18
+ * Escape special regex characters
19
+ */
20
+ function escapeRegExp(text) {
21
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
22
+ }
23
+
24
+ /**
25
+ * ProjectIndex - Manages symbol table for a project
26
+ */
27
+ class ProjectIndex {
28
+ /**
29
+ * Create a new ProjectIndex
30
+ * @param {string} rootDir - Project root directory
31
+ */
32
+ constructor(rootDir) {
33
+ this.root = findProjectRoot(rootDir);
34
+ this.files = new Map(); // path -> FileEntry
35
+ this.symbols = new Map(); // name -> SymbolEntry[]
36
+ this.importGraph = new Map(); // file -> [imported files]
37
+ this.exportGraph = new Map(); // file -> [files that import it]
38
+ this.extendsGraph = new Map(); // className -> parentName
39
+ this.extendedByGraph = new Map(); // parentName -> [childInfo]
40
+ this.config = this.loadConfig();
41
+ this.buildTime = null;
42
+ this.callsCache = new Map(); // filePath -> { mtime, hash, calls, content }
43
+ }
44
+
45
+ /**
46
+ * Load .ucn.js config if present
47
+ */
48
+ loadConfig() {
49
+ const configPath = path.join(this.root, '.ucn.js');
50
+ if (fs.existsSync(configPath)) {
51
+ try {
52
+ delete require.cache[require.resolve(configPath)];
53
+ return require(configPath);
54
+ } catch (e) {
55
+ // Config load failed, use defaults
56
+ }
57
+ }
58
+ return {};
59
+ }
60
+
61
+ /**
62
+ * Build index for files matching pattern
63
+ *
64
+ * @param {string} pattern - Glob pattern (e.g., "**\/*.js")
65
+ * @param {object} options - { forceRebuild, maxFiles, quiet }
66
+ */
67
+ build(pattern, options = {}) {
68
+ const startTime = Date.now();
69
+ const quiet = options.quiet !== false;
70
+
71
+ if (!pattern) {
72
+ pattern = detectProjectPattern(this.root);
73
+ }
74
+
75
+ const files = expandGlob(pattern, {
76
+ root: this.root,
77
+ maxFiles: options.maxFiles || 10000,
78
+ followSymlinks: options.followSymlinks
79
+ });
80
+
81
+ if (!quiet) {
82
+ console.error(`Indexing ${files.length} files in ${this.root}...`);
83
+ }
84
+
85
+ if (options.forceRebuild) {
86
+ this.files.clear();
87
+ this.symbols.clear();
88
+ this.importGraph.clear();
89
+ this.exportGraph.clear();
90
+ }
91
+
92
+ let indexed = 0;
93
+ for (const file of files) {
94
+ try {
95
+ this.indexFile(file);
96
+ indexed++;
97
+ } catch (e) {
98
+ if (!quiet) {
99
+ console.error(` Warning: Could not index ${file}: ${e.message}`);
100
+ }
101
+ }
102
+ }
103
+
104
+ this.buildImportGraph();
105
+ this.buildInheritanceGraph();
106
+
107
+ this.buildTime = Date.now() - startTime;
108
+
109
+ if (!quiet) {
110
+ console.error(`Index complete: ${this.symbols.size} symbols in ${indexed} files (${this.buildTime}ms)`);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Index a single file
116
+ */
117
+ indexFile(filePath) {
118
+ const content = fs.readFileSync(filePath, 'utf-8');
119
+ const hash = crypto.createHash('md5').update(content).digest('hex');
120
+ const stat = fs.statSync(filePath);
121
+
122
+ // Check if already indexed and unchanged
123
+ const existing = this.files.get(filePath);
124
+ if (existing && existing.hash === hash && existing.mtime === stat.mtimeMs) {
125
+ return;
126
+ }
127
+
128
+ if (existing) {
129
+ this.removeFileSymbols(filePath);
130
+ }
131
+
132
+ const language = detectLanguage(filePath);
133
+ if (!language) return;
134
+
135
+ const parsed = parseFile(filePath);
136
+ const { imports } = extractImports(content, language);
137
+ const { exports } = extractExports(content, language);
138
+
139
+ const fileEntry = {
140
+ path: filePath,
141
+ relativePath: path.relative(this.root, filePath),
142
+ language,
143
+ lines: content.split('\n').length,
144
+ hash,
145
+ mtime: stat.mtimeMs,
146
+ size: stat.size,
147
+ imports: imports.map(i => i.module),
148
+ exports: exports.map(e => e.name),
149
+ symbols: []
150
+ };
151
+
152
+ // Add symbols
153
+ const addSymbol = (item, type) => {
154
+ const symbol = {
155
+ name: item.name,
156
+ type,
157
+ file: filePath,
158
+ relativePath: fileEntry.relativePath,
159
+ startLine: item.startLine,
160
+ endLine: item.endLine,
161
+ params: item.params,
162
+ paramsStructured: item.paramsStructured,
163
+ returnType: item.returnType,
164
+ modifiers: item.modifiers,
165
+ docstring: item.docstring,
166
+ ...(item.extends && { extends: item.extends }),
167
+ ...(item.implements && { implements: item.implements }),
168
+ ...(item.indent !== undefined && { indent: item.indent }),
169
+ ...(item.isNested && { isNested: item.isNested })
170
+ };
171
+ fileEntry.symbols.push(symbol);
172
+
173
+ if (!this.symbols.has(item.name)) {
174
+ this.symbols.set(item.name, []);
175
+ }
176
+ this.symbols.get(item.name).push(symbol);
177
+ };
178
+
179
+ for (const fn of parsed.functions) {
180
+ addSymbol(fn, fn.isConstructor ? 'constructor' : 'function');
181
+ }
182
+
183
+ for (const cls of parsed.classes) {
184
+ addSymbol(cls, cls.type || 'class');
185
+ if (cls.members) {
186
+ for (const m of cls.members) {
187
+ const memberType = m.memberType || 'method';
188
+ addSymbol({ ...m, className: cls.name }, memberType);
189
+ }
190
+ }
191
+ }
192
+
193
+ for (const state of parsed.stateObjects) {
194
+ addSymbol(state, 'state');
195
+ }
196
+
197
+ this.files.set(filePath, fileEntry);
198
+ }
199
+
200
+ /**
201
+ * Remove a file's symbols from the global map
202
+ */
203
+ removeFileSymbols(filePath) {
204
+ const existing = this.files.get(filePath);
205
+ if (!existing) return;
206
+
207
+ for (const symbol of existing.symbols) {
208
+ const entries = this.symbols.get(symbol.name);
209
+ if (entries) {
210
+ const filtered = entries.filter(e => e.file !== filePath);
211
+ if (filtered.length > 0) {
212
+ this.symbols.set(symbol.name, filtered);
213
+ } else {
214
+ this.symbols.delete(symbol.name);
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Build import/export relationship graphs
222
+ */
223
+ buildImportGraph() {
224
+ this.importGraph.clear();
225
+ this.exportGraph.clear();
226
+
227
+ for (const [filePath, fileEntry] of this.files) {
228
+ const importedFiles = [];
229
+
230
+ for (const importModule of fileEntry.imports) {
231
+ const resolved = resolveImport(importModule, filePath, {
232
+ aliases: this.config.aliases,
233
+ language: fileEntry.language,
234
+ root: this.root
235
+ });
236
+
237
+ if (resolved && this.files.has(resolved)) {
238
+ importedFiles.push(resolved);
239
+
240
+ if (!this.exportGraph.has(resolved)) {
241
+ this.exportGraph.set(resolved, []);
242
+ }
243
+ this.exportGraph.get(resolved).push(filePath);
244
+ }
245
+ }
246
+
247
+ this.importGraph.set(filePath, importedFiles);
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Build inheritance relationship graphs
253
+ */
254
+ buildInheritanceGraph() {
255
+ this.extendsGraph.clear();
256
+ this.extendedByGraph.clear();
257
+
258
+ for (const [filePath, fileEntry] of this.files) {
259
+ for (const symbol of fileEntry.symbols) {
260
+ if (!['class', 'interface', 'struct', 'trait'].includes(symbol.type)) {
261
+ continue;
262
+ }
263
+
264
+ if (symbol.extends) {
265
+ this.extendsGraph.set(symbol.name, symbol.extends);
266
+
267
+ if (!this.extendedByGraph.has(symbol.extends)) {
268
+ this.extendedByGraph.set(symbol.extends, []);
269
+ }
270
+ this.extendedByGraph.get(symbol.extends).push({
271
+ name: symbol.name,
272
+ type: symbol.type,
273
+ file: filePath
274
+ });
275
+ }
276
+ }
277
+ }
278
+ }
279
+
280
+ // ========================================================================
281
+ // QUERY METHODS
282
+ // ========================================================================
283
+
284
+ /**
285
+ * Check if a file path matches filter criteria
286
+ * @param {string} filePath - File path to check
287
+ * @param {object} filters - { exclude: string[], in: string }
288
+ * @returns {boolean} True if file passes filters
289
+ */
290
+ matchesFilters(filePath, filters = {}) {
291
+ // Check exclusions (patterns like 'test', 'mock', 'spec')
292
+ if (filters.exclude && filters.exclude.length > 0) {
293
+ const lowerPath = filePath.toLowerCase();
294
+ for (const pattern of filters.exclude) {
295
+ if (lowerPath.includes(pattern.toLowerCase())) {
296
+ return false;
297
+ }
298
+ }
299
+ }
300
+
301
+ // Check inclusion (must be within specified directory)
302
+ if (filters.in) {
303
+ if (!filePath.includes(filters.in)) {
304
+ return false;
305
+ }
306
+ }
307
+
308
+ return true;
309
+ }
310
+
311
+ /**
312
+ * Calculate fuzzy match score (higher = better match)
313
+ * Prefers: exact match > prefix match > camelCase match > substring match
314
+ */
315
+ fuzzyScore(query, target) {
316
+ const lowerQuery = query.toLowerCase();
317
+ const lowerTarget = target.toLowerCase();
318
+
319
+ // Exact match
320
+ if (target === query) return 1000;
321
+ if (lowerTarget === lowerQuery) return 900;
322
+
323
+ // Prefix match (handleReq -> handleRequest)
324
+ if (lowerTarget.startsWith(lowerQuery)) return 800 + (query.length / target.length) * 100;
325
+
326
+ // CamelCase match (hR -> handleRequest)
327
+ const camelParts = target.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().split(' ');
328
+ const queryParts = query.toLowerCase();
329
+ let camelMatch = true;
330
+ let partIndex = 0;
331
+ for (const char of queryParts) {
332
+ while (partIndex < camelParts.length && !camelParts[partIndex].startsWith(char)) {
333
+ partIndex++;
334
+ }
335
+ if (partIndex >= camelParts.length) {
336
+ camelMatch = false;
337
+ break;
338
+ }
339
+ partIndex++;
340
+ }
341
+ if (camelMatch && query.length >= 2) return 600;
342
+
343
+ // Substring match
344
+ if (lowerTarget.includes(lowerQuery)) return 400 + (query.length / target.length) * 100;
345
+
346
+ // Word boundary match (parse -> parseFile, fileParse)
347
+ const words = lowerTarget.split(/(?=[A-Z])|_|-/);
348
+ if (words.some(w => w.startsWith(lowerQuery))) return 300;
349
+
350
+ return 0;
351
+ }
352
+
353
+ /**
354
+ * Find symbol by name with disambiguation
355
+ *
356
+ * @param {string} name - Symbol name to find
357
+ * @param {object} options - { file, prefer, exact, exclude, in }
358
+ * @returns {Array} Matching symbols with usage counts
359
+ */
360
+ find(name, options = {}) {
361
+ const matches = this.symbols.get(name) || [];
362
+
363
+ if (matches.length === 0) {
364
+ // Smart fuzzy search with scoring
365
+ const candidates = [];
366
+ for (const [symName, symbols] of this.symbols) {
367
+ const score = this.fuzzyScore(name, symName);
368
+ if (score > 0) {
369
+ for (const sym of symbols) {
370
+ candidates.push({ ...sym, _fuzzyScore: score });
371
+ }
372
+ }
373
+ }
374
+ // Sort by fuzzy score descending
375
+ candidates.sort((a, b) => b._fuzzyScore - a._fuzzyScore);
376
+ matches.push(...candidates);
377
+ }
378
+
379
+ // Apply filters
380
+ let filtered = matches;
381
+
382
+ // Filter by file pattern
383
+ if (options.file) {
384
+ filtered = filtered.filter(m =>
385
+ m.relativePath && m.relativePath.includes(options.file)
386
+ );
387
+ }
388
+
389
+ // Apply semantic filters (--exclude, --in)
390
+ if (options.exclude || options.in) {
391
+ filtered = filtered.filter(m =>
392
+ this.matchesFilters(m.relativePath, { exclude: options.exclude, in: options.in })
393
+ );
394
+ }
395
+
396
+ // Add per-symbol usage counts for disambiguation
397
+ const withCounts = filtered.map(m => {
398
+ const counts = this.countSymbolUsages(m);
399
+ return {
400
+ ...m,
401
+ usageCount: counts.total,
402
+ usageCounts: counts // { total, calls, definitions, imports, references }
403
+ };
404
+ });
405
+
406
+ // Sort by usage count (most-used first)
407
+ withCounts.sort((a, b) => b.usageCount - a.usageCount);
408
+
409
+ return withCounts;
410
+ }
411
+
412
+ /**
413
+ * Count usages of a symbol across the codebase
414
+ */
415
+ countUsages(name) {
416
+ let count = 0;
417
+ const regex = new RegExp('\\b' + escapeRegExp(name) + '\\b', 'g');
418
+
419
+ for (const [filePath, fileEntry] of this.files) {
420
+ try {
421
+ const content = fs.readFileSync(filePath, 'utf-8');
422
+ const matches = content.match(regex);
423
+ if (matches) count += matches.length;
424
+ } catch (e) {
425
+ // Skip unreadable files
426
+ }
427
+ }
428
+
429
+ return count;
430
+ }
431
+
432
+ /**
433
+ * Count usages of a specific symbol (not just by name)
434
+ * Only counts usages in files that could reference this specific definition
435
+ * @param {object} symbol - Symbol with file, name, etc.
436
+ * @returns {object} { total, calls, definitions, imports, references }
437
+ */
438
+ countSymbolUsages(symbol) {
439
+ const name = symbol.name;
440
+ const defFile = symbol.file;
441
+ // Note: no 'g' flag - we only need to test for presence per line
442
+ const regex = new RegExp('\\b' + escapeRegExp(name) + '\\b');
443
+
444
+ // Get files that could reference this symbol:
445
+ // 1. The file where it's defined
446
+ // 2. Files that import from the definition file
447
+ const relevantFiles = new Set([defFile]);
448
+ const importers = this.exportGraph.get(defFile) || [];
449
+ for (const importer of importers) {
450
+ relevantFiles.add(importer);
451
+ }
452
+
453
+ let calls = 0;
454
+ let definitions = 0;
455
+ let imports = 0;
456
+ let references = 0;
457
+
458
+ for (const filePath of relevantFiles) {
459
+ if (!this.files.has(filePath)) continue;
460
+
461
+ try {
462
+ const content = fs.readFileSync(filePath, 'utf-8');
463
+
464
+ // Try AST-based counting first
465
+ const language = detectLanguage(filePath);
466
+ const langModule = getLanguageModule(language);
467
+
468
+ if (langModule && typeof langModule.findUsagesInCode === 'function') {
469
+ try {
470
+ const parser = getParser(language);
471
+ if (parser) {
472
+ const usages = langModule.findUsagesInCode(content, name, parser);
473
+ for (const u of usages) {
474
+ switch (u.usageType) {
475
+ case 'call': calls++; break;
476
+ case 'definition': definitions++; break;
477
+ case 'import': imports++; break;
478
+ default: references++; break;
479
+ }
480
+ }
481
+ continue; // Skip to next file
482
+ }
483
+ } catch (e) {
484
+ // Fall through to regex-based counting
485
+ }
486
+ }
487
+
488
+ // Fallback: count regex matches as references (unsupported language)
489
+ const lines = content.split('\n');
490
+ lines.forEach((line) => {
491
+ if (regex.test(line)) {
492
+ references++;
493
+ }
494
+ });
495
+ } catch (e) {
496
+ // Skip unreadable files
497
+ }
498
+ }
499
+
500
+ return {
501
+ total: calls + definitions + imports + references,
502
+ calls,
503
+ definitions,
504
+ imports,
505
+ references
506
+ };
507
+ }
508
+
509
+ /**
510
+ * Find all usages of a symbol grouped by type
511
+ *
512
+ * @param {string} name - Symbol name
513
+ * @param {object} options - { codeOnly, context, exclude, in }
514
+ * @returns {Array} Usages grouped as definitions, calls, imports, references
515
+ */
516
+ usages(name, options = {}) {
517
+ const usages = [];
518
+
519
+ // Get definitions (filtered)
520
+ const allDefinitions = this.symbols.get(name) || [];
521
+ const definitions = options.exclude || options.in
522
+ ? allDefinitions.filter(d => this.matchesFilters(d.relativePath, options))
523
+ : allDefinitions;
524
+
525
+ for (const def of definitions) {
526
+ usages.push({
527
+ ...def,
528
+ isDefinition: true,
529
+ line: def.startLine,
530
+ content: this.getLineContent(def.file, def.startLine),
531
+ signature: this.formatSignature(def)
532
+ });
533
+ }
534
+
535
+ // Scan all files for usages
536
+ for (const [filePath, fileEntry] of this.files) {
537
+ // Apply filters
538
+ if (!this.matchesFilters(fileEntry.relativePath, options)) {
539
+ continue;
540
+ }
541
+
542
+ try {
543
+ const content = fs.readFileSync(filePath, 'utf-8');
544
+ const lines = content.split('\n');
545
+
546
+ // Try AST-based detection first
547
+ const lang = detectLanguage(filePath);
548
+ const langModule = getLanguageModule(lang);
549
+
550
+ if (langModule && typeof langModule.findUsagesInCode === 'function') {
551
+ // AST-based detection
552
+ try {
553
+ const parser = getParser(lang);
554
+ if (parser) {
555
+ const astUsages = langModule.findUsagesInCode(content, name, parser);
556
+
557
+ for (const u of astUsages) {
558
+ // Skip if this is a definition line (already added above)
559
+ if (definitions.some(d => d.file === filePath && d.startLine === u.line)) {
560
+ continue;
561
+ }
562
+
563
+ const lineContent = lines[u.line - 1] || '';
564
+
565
+ const usage = {
566
+ file: filePath,
567
+ relativePath: fileEntry.relativePath,
568
+ line: u.line,
569
+ content: lineContent,
570
+ usageType: u.usageType,
571
+ isDefinition: false
572
+ };
573
+
574
+ // Add context lines if requested
575
+ if (options.context && options.context > 0) {
576
+ const idx = u.line - 1;
577
+ const before = [];
578
+ const after = [];
579
+ for (let i = 1; i <= options.context; i++) {
580
+ if (idx - i >= 0) before.unshift(lines[idx - i]);
581
+ if (idx + i < lines.length) after.push(lines[idx + i]);
582
+ }
583
+ usage.before = before;
584
+ usage.after = after;
585
+ }
586
+
587
+ usages.push(usage);
588
+ }
589
+ continue; // Skip to next file
590
+ }
591
+ } catch (e) {
592
+ // Fall through to regex-based detection
593
+ }
594
+ }
595
+
596
+ // Fallback to regex-based detection
597
+ const regex = new RegExp('\\b' + escapeRegExp(name) + '\\b');
598
+ lines.forEach((line, idx) => {
599
+ const lineNum = idx + 1;
600
+
601
+ // Skip if this is a definition line
602
+ if (definitions.some(d => d.file === filePath && d.startLine === lineNum)) {
603
+ return;
604
+ }
605
+
606
+ if (regex.test(line)) {
607
+ // Skip if codeOnly and line is comment/string
608
+ if (options.codeOnly && this.isCommentOrStringAtPosition(content, lineNum, 0, filePath)) {
609
+ return;
610
+ }
611
+
612
+ // Skip if the match is inside a string literal
613
+ if (this.isInsideStringAST(content, lineNum, line, name, filePath)) {
614
+ return;
615
+ }
616
+
617
+ // Classify usage type (AST-based, defaults to 'reference' for unsupported languages)
618
+ const usageType = this.classifyUsageAST(content, lineNum, name, filePath) ?? 'reference';
619
+
620
+ const usage = {
621
+ file: filePath,
622
+ relativePath: fileEntry.relativePath,
623
+ line: lineNum,
624
+ content: line,
625
+ usageType,
626
+ isDefinition: false
627
+ };
628
+
629
+ // Add context lines if requested
630
+ if (options.context && options.context > 0) {
631
+ const before = [];
632
+ const after = [];
633
+ for (let i = 1; i <= options.context; i++) {
634
+ if (idx - i >= 0) before.unshift(lines[idx - i]);
635
+ if (idx + i < lines.length) after.push(lines[idx + i]);
636
+ }
637
+ usage.before = before;
638
+ usage.after = after;
639
+ }
640
+
641
+ usages.push(usage);
642
+ }
643
+ });
644
+ } catch (e) {
645
+ // Skip unreadable files
646
+ }
647
+ }
648
+
649
+ return usages;
650
+ }
651
+
652
+ /**
653
+ * Get context for a symbol (callers + callees)
654
+ */
655
+ context(name, options = {}) {
656
+ const definitions = this.symbols.get(name) || [];
657
+ if (definitions.length === 0) {
658
+ return { function: name, file: null, callers: [], callees: [] };
659
+ }
660
+
661
+ const def = definitions[0]; // Use first definition
662
+ const callers = this.findCallers(name, { includeMethods: options.includeMethods });
663
+ const callees = this.findCallees(def, { includeMethods: options.includeMethods });
664
+
665
+ const result = {
666
+ function: name,
667
+ file: def.relativePath,
668
+ startLine: def.startLine,
669
+ endLine: def.endLine,
670
+ params: def.params,
671
+ returnType: def.returnType,
672
+ callers,
673
+ callees
674
+ };
675
+
676
+ // Add disambiguation warning if multiple definitions exist
677
+ if (definitions.length > 1) {
678
+ result.warnings = [{
679
+ type: 'ambiguous',
680
+ message: `Found ${definitions.length} definitions for "${name}". Using ${def.relativePath}:${def.startLine}. Use --file to disambiguate.`,
681
+ alternatives: definitions.slice(1).map(d => ({
682
+ file: d.relativePath,
683
+ line: d.startLine
684
+ }))
685
+ }];
686
+ }
687
+
688
+ return result;
689
+ }
690
+
691
+ /**
692
+ * Get cached calls for a file, parsing if necessary
693
+ * Uses mtime for fast cache validation, falls back to hash if mtime matches but content changed
694
+ * @param {string} filePath - Path to the file
695
+ * @param {object} [options] - Options
696
+ * @param {boolean} [options.includeContent] - Also return file content (avoids double read)
697
+ * @returns {Array|null|{calls: Array, content: string}} Array of calls, or object with content if requested
698
+ */
699
+ getCachedCalls(filePath, options = {}) {
700
+ try {
701
+ const cached = this.callsCache.get(filePath);
702
+
703
+ // Fast path: check mtime first (stat is much faster than read+hash)
704
+ const stat = fs.statSync(filePath);
705
+ const mtime = stat.mtimeMs;
706
+
707
+ if (cached && cached.mtime === mtime) {
708
+ // mtime matches - cache is likely valid
709
+ if (options.includeContent) {
710
+ // Need content, read if not cached
711
+ const content = cached.content || fs.readFileSync(filePath, 'utf-8');
712
+ return { calls: cached.calls, content };
713
+ }
714
+ return cached.calls;
715
+ }
716
+
717
+ // mtime changed or no cache - need to read and possibly reparse
718
+ const content = fs.readFileSync(filePath, 'utf-8');
719
+ const hash = crypto.createHash('md5').update(content).digest('hex');
720
+
721
+ // Check if content actually changed (mtime can change without content change)
722
+ if (cached && cached.hash === hash) {
723
+ // Content unchanged, just update mtime
724
+ cached.mtime = mtime;
725
+ cached.content = options.includeContent ? content : undefined;
726
+ if (options.includeContent) {
727
+ return { calls: cached.calls, content };
728
+ }
729
+ return cached.calls;
730
+ }
731
+
732
+ // Content changed - need to reparse
733
+ const language = detectLanguage(filePath);
734
+ if (!language) return null;
735
+
736
+ const langModule = getLanguageModule(language);
737
+ if (!langModule.findCallsInCode) return null;
738
+
739
+ const parser = getParser(language);
740
+ const calls = langModule.findCallsInCode(content, parser);
741
+
742
+ this.callsCache.set(filePath, {
743
+ mtime,
744
+ hash,
745
+ calls,
746
+ content: options.includeContent ? content : undefined
747
+ });
748
+
749
+ if (options.includeContent) {
750
+ return { calls, content };
751
+ }
752
+ return calls;
753
+ } catch (e) {
754
+ return null;
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Find all callers of a function using AST-based detection
760
+ * @param {string} name - Function name to find callers for
761
+ * @param {object} [options] - Options
762
+ * @param {boolean} [options.includeMethods] - Include method calls (default: false)
763
+ */
764
+ findCallers(name, options = {}) {
765
+ const callers = [];
766
+
767
+ // Get definition lines to exclude them
768
+ const definitions = this.symbols.get(name) || [];
769
+ const definitionLines = new Set();
770
+ for (const def of definitions) {
771
+ definitionLines.add(`${def.file}:${def.startLine}`);
772
+ }
773
+
774
+ for (const [filePath, fileEntry] of this.files) {
775
+ try {
776
+ const result = this.getCachedCalls(filePath, { includeContent: true });
777
+ if (!result) continue;
778
+
779
+ const { calls, content } = result;
780
+ const lines = content.split('\n');
781
+
782
+ for (const call of calls) {
783
+ // Skip if not matching our target name
784
+ if (call.name !== name) continue;
785
+
786
+ // Smart method call handling
787
+ if (call.isMethod) {
788
+ // Always skip this/self/cls calls (internal state access, not function calls)
789
+ if (['this', 'self', 'cls'].includes(call.receiver)) continue;
790
+ // Skip other method calls unless explicitly requested
791
+ if (!options.includeMethods) continue;
792
+ }
793
+
794
+ // Skip definition lines
795
+ if (definitionLines.has(`${filePath}:${call.line}`)) continue;
796
+
797
+ // Find the enclosing function (get full symbol info)
798
+ const callerSymbol = this.findEnclosingFunction(filePath, call.line, true);
799
+
800
+ callers.push({
801
+ file: filePath,
802
+ relativePath: fileEntry.relativePath,
803
+ line: call.line,
804
+ content: lines[call.line - 1] || '',
805
+ callerName: callerSymbol ? callerSymbol.name : null,
806
+ callerFile: callerSymbol ? filePath : null,
807
+ callerStartLine: callerSymbol ? callerSymbol.startLine : null,
808
+ callerEndLine: callerSymbol ? callerSymbol.endLine : null,
809
+ isMethod: call.isMethod || false,
810
+ receiver: call.receiver
811
+ });
812
+ }
813
+ } catch (e) {
814
+ // Skip files that can't be processed
815
+ }
816
+ }
817
+
818
+ return callers;
819
+ }
820
+
821
+ /**
822
+ * Check if a name appears inside a string literal using AST
823
+ * @param {string} content - Full file content
824
+ * @param {number} lineNum - 1-indexed line number
825
+ * @param {string} line - Line content
826
+ * @param {string} name - Name to check
827
+ * @param {string} filePath - File path for language detection
828
+ * @returns {boolean} true if ALL occurrences of name are inside strings
829
+ */
830
+ isInsideStringAST(content, lineNum, line, name, filePath) {
831
+ const language = detectLanguage(filePath);
832
+ if (!language) {
833
+ return false; // Unsupported language - assume not inside string
834
+ }
835
+
836
+ try {
837
+ const parser = getParser(language);
838
+ if (!parser) {
839
+ return false;
840
+ }
841
+
842
+ const tree = parser.parse(content, undefined, PARSE_OPTIONS);
843
+
844
+ // Find all occurrences of name in the line
845
+ const nameRegex = new RegExp('(?<![a-zA-Z0-9_$])' + escapeRegExp(name) + '(?![a-zA-Z0-9_$])', 'g');
846
+ let match;
847
+
848
+ while ((match = nameRegex.exec(line)) !== null) {
849
+ const column = match.index;
850
+ const tokenType = getTokenTypeAtPosition(tree.rootNode, lineNum, column);
851
+
852
+ // If this occurrence is NOT in a string, the name appears in code
853
+ if (tokenType !== 'string') {
854
+ return false;
855
+ }
856
+ }
857
+
858
+ // All occurrences were inside strings (or no occurrences found)
859
+ return true;
860
+ } catch (e) {
861
+ return false; // On error, assume not inside string
862
+ }
863
+ }
864
+
865
+ /**
866
+ * Find all functions called by a function using AST-based detection
867
+ * @param {object} def - Symbol definition with file, name, startLine, endLine
868
+ * @param {object} [options] - Options
869
+ * @param {boolean} [options.includeMethods] - Include method calls (default: false)
870
+ */
871
+ findCallees(def, options = {}) {
872
+ try {
873
+ // Get all calls from the file's cache (now includes enclosingFunction)
874
+ const calls = this.getCachedCalls(def.file);
875
+ if (!calls) return [];
876
+
877
+ const callees = new Map(); // name -> count
878
+
879
+ for (const call of calls) {
880
+ // Filter to calls within this function's scope using enclosingFunction
881
+ if (!call.enclosingFunction) continue;
882
+ if (call.enclosingFunction.name !== def.name) continue;
883
+ if (call.enclosingFunction.startLine !== def.startLine) continue;
884
+
885
+ // Skip method calls unless explicitly requested
886
+ if (call.isMethod && !options.includeMethods) continue;
887
+
888
+ // Skip keywords and built-ins
889
+ if (this.isKeyword(call.name)) continue;
890
+
891
+ callees.set(call.name, (callees.get(call.name) || 0) + 1);
892
+ }
893
+
894
+ // Look up each callee in the symbol table
895
+ const result = [];
896
+ for (const [calleeName, count] of callees) {
897
+ const symbols = this.symbols.get(calleeName);
898
+ if (symbols && symbols.length > 0) {
899
+ const callee = symbols[0];
900
+ result.push({
901
+ ...callee,
902
+ callCount: count,
903
+ weight: this.calculateWeight(count)
904
+ });
905
+ }
906
+ }
907
+
908
+ // Sort by call count (core dependencies first)
909
+ result.sort((a, b) => b.callCount - a.callCount);
910
+
911
+ return result;
912
+ } catch (e) {
913
+ return [];
914
+ }
915
+ }
916
+
917
+ /**
918
+ * Calculate dependency weight based on usage
919
+ */
920
+ calculateWeight(callCount) {
921
+ if (callCount >= 10) return 'core';
922
+ if (callCount >= 3) return 'regular';
923
+ if (callCount === 1) return 'utility';
924
+ return 'normal';
925
+ }
926
+
927
+ /**
928
+ * Smart extraction: function + dependencies
929
+ */
930
+ smart(name, options = {}) {
931
+ const definitions = this.symbols.get(name) || [];
932
+ if (definitions.length === 0) {
933
+ return null;
934
+ }
935
+
936
+ const def = definitions[0];
937
+ const code = this.extractCode(def);
938
+ const callees = this.findCallees(def, { includeMethods: options.includeMethods });
939
+
940
+ // Extract code for each dependency, excluding the main function itself
941
+ const dependencies = callees
942
+ .filter(callee => callee.name !== name) // Don't include self
943
+ .map(callee => ({
944
+ ...callee,
945
+ code: this.extractCode(callee)
946
+ }));
947
+
948
+ // Find type definitions if requested
949
+ const types = [];
950
+ if (options.withTypes) {
951
+ // Look for type annotations in params/return type
952
+ const typeNames = this.extractTypeNames(def);
953
+ for (const typeName of typeNames) {
954
+ const typeSymbols = this.symbols.get(typeName);
955
+ if (typeSymbols) {
956
+ for (const sym of typeSymbols) {
957
+ if (['type', 'interface', 'class', 'struct'].includes(sym.type)) {
958
+ types.push({
959
+ ...sym,
960
+ code: this.extractCode(sym)
961
+ });
962
+ }
963
+ }
964
+ }
965
+ }
966
+ }
967
+
968
+ return {
969
+ target: {
970
+ ...def,
971
+ code
972
+ },
973
+ dependencies,
974
+ types
975
+ };
976
+ }
977
+
978
+ // ========================================================================
979
+ // HELPER METHODS
980
+ // ========================================================================
981
+
982
+ /**
983
+ * Get line content from a file
984
+ */
985
+ getLineContent(filePath, lineNum) {
986
+ try {
987
+ const content = fs.readFileSync(filePath, 'utf-8');
988
+ const lines = content.split('\n');
989
+ return lines[lineNum - 1] || '';
990
+ } catch (e) {
991
+ return '';
992
+ }
993
+ }
994
+
995
+ /**
996
+ * Extract code for a symbol
997
+ */
998
+ extractCode(symbol) {
999
+ try {
1000
+ const content = fs.readFileSync(symbol.file, 'utf-8');
1001
+ const lines = content.split('\n');
1002
+ return lines.slice(symbol.startLine - 1, symbol.endLine).join('\n');
1003
+ } catch (e) {
1004
+ return '';
1005
+ }
1006
+ }
1007
+
1008
+ /**
1009
+ * Format a signature for display
1010
+ */
1011
+ formatSignature(def) {
1012
+ const parts = [];
1013
+ if (def.modifiers && def.modifiers.length) {
1014
+ parts.push(def.modifiers.join(' '));
1015
+ }
1016
+ parts.push(def.name);
1017
+ if (def.params !== undefined) {
1018
+ parts.push(`(${def.params})`);
1019
+ }
1020
+ if (def.returnType) {
1021
+ parts.push(`: ${def.returnType}`);
1022
+ }
1023
+ return parts.join(' ');
1024
+ }
1025
+
1026
+ /**
1027
+ * Classify a usage as call, import, definition, or reference using AST
1028
+ * @param {string} content - File content
1029
+ * @param {number} lineNum - 1-indexed line number
1030
+ * @param {string} name - Symbol name
1031
+ * @param {string} filePath - File path for language detection
1032
+ * @returns {string} 'call', 'import', 'definition', or 'reference'
1033
+ */
1034
+ classifyUsageAST(content, lineNum, name, filePath) {
1035
+ const language = detectLanguage(filePath);
1036
+ if (!language) {
1037
+ return null; // Signal to use fallback
1038
+ }
1039
+
1040
+ const langModule = getLanguageModule(language);
1041
+ if (!langModule || typeof langModule.findUsagesInCode !== 'function') {
1042
+ return null;
1043
+ }
1044
+
1045
+ try {
1046
+ const parser = getParser(language);
1047
+ if (!parser) {
1048
+ return null;
1049
+ }
1050
+
1051
+ const usages = langModule.findUsagesInCode(content, name, parser);
1052
+
1053
+ // Find usage at this line
1054
+ const usage = usages.find(u => u.line === lineNum);
1055
+ if (usage) {
1056
+ return usage.usageType;
1057
+ }
1058
+
1059
+ return 'reference'; // Default if not found
1060
+ } catch (e) {
1061
+ return null;
1062
+ }
1063
+ }
1064
+
1065
+ /**
1066
+ * Check if a position in code is inside a comment or string using AST
1067
+ * @param {string} content - File content
1068
+ * @param {number} lineNum - 1-indexed line number
1069
+ * @param {number} column - 0-indexed column
1070
+ * @param {string} filePath - File path (for language detection)
1071
+ * @returns {boolean}
1072
+ */
1073
+ isCommentOrStringAtPosition(content, lineNum, column, filePath) {
1074
+ const language = detectLanguage(filePath);
1075
+ if (!language) {
1076
+ return false; // Can't determine, assume code
1077
+ }
1078
+
1079
+ try {
1080
+ const parser = getParser(language);
1081
+ if (!parser) {
1082
+ return false;
1083
+ }
1084
+
1085
+ const tree = parser.parse(content, undefined, PARSE_OPTIONS);
1086
+ const tokenType = getTokenTypeAtPosition(tree.rootNode, lineNum, column);
1087
+ return tokenType === 'comment' || tokenType === 'string';
1088
+ } catch (e) {
1089
+ return false; // On error, assume code
1090
+ }
1091
+ }
1092
+
1093
+ /**
1094
+ * Check if a name is a language keyword
1095
+ */
1096
+ isKeyword(name) {
1097
+ const keywords = new Set([
1098
+ 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break',
1099
+ 'continue', 'return', 'function', 'class', 'const', 'let', 'var',
1100
+ 'new', 'this', 'super', 'import', 'export', 'default', 'from',
1101
+ 'try', 'catch', 'finally', 'throw', 'async', 'await', 'yield',
1102
+ 'typeof', 'instanceof', 'in', 'of', 'delete', 'void', 'with',
1103
+ 'def', 'print', 'range', 'len', 'str', 'int', 'float', 'list',
1104
+ 'dict', 'set', 'tuple', 'True', 'False', 'None', 'self', 'cls',
1105
+ 'func', 'type', 'struct', 'interface', 'package', 'make', 'append',
1106
+ 'fn', 'impl', 'pub', 'mod', 'use', 'crate', 'self', 'super',
1107
+ 'match', 'loop', 'unsafe', 'move', 'ref', 'mut', 'where'
1108
+ ]);
1109
+ return keywords.has(name);
1110
+ }
1111
+
1112
+ /**
1113
+ * Find the enclosing function at a line
1114
+ * @param {string} filePath - File path
1115
+ * @param {number} lineNum - Line number
1116
+ * @param {boolean} returnSymbol - If true, return full symbol info instead of just name
1117
+ * @returns {string|object|null} Function name, symbol object, or null
1118
+ */
1119
+ findEnclosingFunction(filePath, lineNum, returnSymbol = false) {
1120
+ const fileEntry = this.files.get(filePath);
1121
+ if (!fileEntry) return null;
1122
+
1123
+ for (const symbol of fileEntry.symbols) {
1124
+ if (symbol.type === 'function' &&
1125
+ symbol.startLine <= lineNum &&
1126
+ symbol.endLine >= lineNum) {
1127
+ if (returnSymbol) {
1128
+ return symbol;
1129
+ }
1130
+ return symbol.name;
1131
+ }
1132
+ }
1133
+ return null;
1134
+ }
1135
+
1136
+ /**
1137
+ * Extract type names from a function definition
1138
+ */
1139
+ extractTypeNames(def) {
1140
+ const types = new Set();
1141
+
1142
+ // From params
1143
+ if (def.paramsStructured) {
1144
+ for (const param of def.paramsStructured) {
1145
+ if (param.type) {
1146
+ // Extract base type name (before < or [)
1147
+ const match = param.type.match(/^([A-Z]\w*)/);
1148
+ if (match) types.add(match[1]);
1149
+ }
1150
+ }
1151
+ }
1152
+
1153
+ // From return type
1154
+ if (def.returnType) {
1155
+ const match = def.returnType.match(/^([A-Z]\w*)/);
1156
+ if (match) types.add(match[1]);
1157
+ }
1158
+
1159
+ return types;
1160
+ }
1161
+
1162
+ // ========================================================================
1163
+ // NEW COMMANDS (v2 Migration)
1164
+ // ========================================================================
1165
+
1166
+ /**
1167
+ * Get imports for a file
1168
+ * @param {string} filePath - File to get imports for
1169
+ * @returns {Array} Imports with resolved paths
1170
+ */
1171
+ imports(filePath) {
1172
+ const normalizedPath = path.isAbsolute(filePath)
1173
+ ? filePath
1174
+ : path.join(this.root, filePath);
1175
+
1176
+ const fileEntry = this.files.get(normalizedPath);
1177
+ if (!fileEntry) {
1178
+ // Try to find by relative path
1179
+ for (const [absPath, entry] of this.files) {
1180
+ if (entry.relativePath === filePath || absPath.endsWith(filePath)) {
1181
+ return this.imports(absPath);
1182
+ }
1183
+ }
1184
+ return [];
1185
+ }
1186
+
1187
+ try {
1188
+ const content = fs.readFileSync(normalizedPath, 'utf-8');
1189
+ const { imports: rawImports } = extractImports(content, fileEntry.language);
1190
+
1191
+ return rawImports.map(imp => {
1192
+ const resolved = resolveImport(imp.module, normalizedPath, {
1193
+ aliases: this.config.aliases,
1194
+ language: fileEntry.language,
1195
+ root: this.root
1196
+ });
1197
+
1198
+ // Find line number of import
1199
+ const lines = content.split('\n');
1200
+ let line = null;
1201
+ for (let i = 0; i < lines.length; i++) {
1202
+ if (lines[i].includes(imp.module)) {
1203
+ line = i + 1;
1204
+ break;
1205
+ }
1206
+ }
1207
+
1208
+ return {
1209
+ module: imp.module,
1210
+ names: imp.names,
1211
+ type: imp.type,
1212
+ resolved: resolved ? path.relative(this.root, resolved) : null,
1213
+ isExternal: !resolved,
1214
+ line
1215
+ };
1216
+ });
1217
+ } catch (e) {
1218
+ return [];
1219
+ }
1220
+ }
1221
+
1222
+ /**
1223
+ * Get files that import a given file
1224
+ * @param {string} filePath - File to check
1225
+ * @returns {Array} Files that import this file
1226
+ */
1227
+ exporters(filePath) {
1228
+ const normalizedPath = path.isAbsolute(filePath)
1229
+ ? filePath
1230
+ : path.join(this.root, filePath);
1231
+
1232
+ // Try to find the file
1233
+ let targetPath = normalizedPath;
1234
+ if (!this.files.has(normalizedPath)) {
1235
+ for (const [absPath, entry] of this.files) {
1236
+ if (entry.relativePath === filePath || absPath.endsWith(filePath)) {
1237
+ targetPath = absPath;
1238
+ break;
1239
+ }
1240
+ }
1241
+ }
1242
+
1243
+ const importers = this.exportGraph.get(targetPath) || [];
1244
+
1245
+ return importers.map(importerPath => {
1246
+ const fileEntry = this.files.get(importerPath);
1247
+
1248
+ // Find the import line
1249
+ let importLine = null;
1250
+ try {
1251
+ const content = fs.readFileSync(importerPath, 'utf-8');
1252
+ const lines = content.split('\n');
1253
+ const targetRelative = path.relative(this.root, targetPath);
1254
+ const targetBasename = path.basename(targetPath, path.extname(targetPath));
1255
+
1256
+ for (let i = 0; i < lines.length; i++) {
1257
+ if (lines[i].includes(targetBasename) &&
1258
+ (lines[i].includes('import') || lines[i].includes('require') || lines[i].includes('from'))) {
1259
+ importLine = i + 1;
1260
+ break;
1261
+ }
1262
+ }
1263
+ } catch (e) {
1264
+ // Skip
1265
+ }
1266
+
1267
+ return {
1268
+ file: fileEntry ? fileEntry.relativePath : path.relative(this.root, importerPath),
1269
+ importLine
1270
+ };
1271
+ });
1272
+ }
1273
+
1274
+ /**
1275
+ * Find type definitions
1276
+ * @param {string} name - Type name to find
1277
+ * @returns {Array} Matching type definitions
1278
+ */
1279
+ typedef(name) {
1280
+ const typeKinds = ['type', 'interface', 'enum', 'struct', 'trait', 'class'];
1281
+ const matches = this.find(name);
1282
+
1283
+ return matches.filter(m => typeKinds.includes(m.type));
1284
+ }
1285
+
1286
+ /**
1287
+ * Find tests for a function or file
1288
+ * @param {string} nameOrFile - Function name or file path
1289
+ * @returns {Array} Test files and matches
1290
+ */
1291
+ tests(nameOrFile) {
1292
+ const results = [];
1293
+
1294
+ // Check if it's a file path
1295
+ const isFilePath = nameOrFile.includes('/') || nameOrFile.includes('\\') ||
1296
+ nameOrFile.endsWith('.js') || nameOrFile.endsWith('.ts') ||
1297
+ nameOrFile.endsWith('.py') || nameOrFile.endsWith('.go') ||
1298
+ nameOrFile.endsWith('.java') || nameOrFile.endsWith('.rs');
1299
+
1300
+ // Find all test files
1301
+ const testFiles = [];
1302
+ for (const [filePath, fileEntry] of this.files) {
1303
+ if (isTestFile(filePath, fileEntry.language)) {
1304
+ testFiles.push({ path: filePath, entry: fileEntry });
1305
+ }
1306
+ }
1307
+
1308
+ const searchTerm = isFilePath
1309
+ ? path.basename(nameOrFile, path.extname(nameOrFile))
1310
+ : nameOrFile;
1311
+
1312
+ // Note: no 'g' flag - we only need to test for presence per line
1313
+ // The 'i' flag is kept for case-insensitive matching
1314
+ const regex = new RegExp('\\b' + escapeRegExp(searchTerm) + '\\b', 'i');
1315
+
1316
+ for (const { path: testPath, entry } of testFiles) {
1317
+ try {
1318
+ const content = fs.readFileSync(testPath, 'utf-8');
1319
+ const lines = content.split('\n');
1320
+ const matches = [];
1321
+
1322
+ lines.forEach((line, idx) => {
1323
+ if (regex.test(line)) {
1324
+ let matchType = 'reference';
1325
+ if (/\b(describe|it|test|spec)\s*\(/.test(line)) {
1326
+ matchType = 'test-case';
1327
+ } else if (/\b(import|require|from)\b/.test(line)) {
1328
+ matchType = 'import';
1329
+ } else if (new RegExp(searchTerm + '\\s*\\(').test(line)) {
1330
+ matchType = 'call';
1331
+ }
1332
+
1333
+ matches.push({
1334
+ line: idx + 1,
1335
+ content: line.trim(),
1336
+ matchType
1337
+ });
1338
+ }
1339
+ });
1340
+
1341
+ if (matches.length > 0) {
1342
+ results.push({
1343
+ file: entry.relativePath,
1344
+ matches
1345
+ });
1346
+ }
1347
+ } catch (e) {
1348
+ // Skip unreadable files
1349
+ }
1350
+ }
1351
+
1352
+ return results;
1353
+ }
1354
+
1355
+ /**
1356
+ * Get all exported/public symbols
1357
+ * @param {string} [filePath] - Optional file to limit to
1358
+ * @returns {Array} Exported symbols
1359
+ */
1360
+ api(filePath) {
1361
+ const results = [];
1362
+
1363
+ const filesToCheck = filePath
1364
+ ? [this.findFile(filePath)].filter(Boolean)
1365
+ : Array.from(this.files.entries());
1366
+
1367
+ for (const [absPath, fileEntry] of (filePath ? [[this.findFile(filePath), this.files.get(this.findFile(filePath))]] : this.files.entries())) {
1368
+ if (!fileEntry) continue;
1369
+
1370
+ const exportedNames = new Set(fileEntry.exports);
1371
+
1372
+ for (const symbol of fileEntry.symbols) {
1373
+ const isExported = exportedNames.has(symbol.name) ||
1374
+ (symbol.modifiers && symbol.modifiers.includes('export')) ||
1375
+ (symbol.modifiers && symbol.modifiers.includes('public')) ||
1376
+ (fileEntry.language === 'go' && /^[A-Z]/.test(symbol.name));
1377
+
1378
+ if (isExported) {
1379
+ results.push({
1380
+ name: symbol.name,
1381
+ type: symbol.type,
1382
+ file: fileEntry.relativePath,
1383
+ startLine: symbol.startLine,
1384
+ endLine: symbol.endLine,
1385
+ params: symbol.params,
1386
+ returnType: symbol.returnType,
1387
+ signature: this.formatSignature(symbol)
1388
+ });
1389
+ }
1390
+ }
1391
+ }
1392
+
1393
+ return results;
1394
+ }
1395
+
1396
+ /**
1397
+ * Find a file by path (supports partial paths)
1398
+ */
1399
+ findFile(filePath) {
1400
+ const normalizedPath = path.isAbsolute(filePath)
1401
+ ? filePath
1402
+ : path.join(this.root, filePath);
1403
+
1404
+ if (this.files.has(normalizedPath)) {
1405
+ return normalizedPath;
1406
+ }
1407
+
1408
+ // Try partial match
1409
+ for (const [absPath, entry] of this.files) {
1410
+ if (entry.relativePath === filePath || absPath.endsWith(filePath)) {
1411
+ return absPath;
1412
+ }
1413
+ }
1414
+
1415
+ return null;
1416
+ }
1417
+
1418
+ /**
1419
+ * Get exports for a specific file
1420
+ * @param {string} filePath - File path
1421
+ * @returns {Array} Exported symbols from that file
1422
+ */
1423
+ fileExports(filePath) {
1424
+ const absPath = this.findFile(filePath);
1425
+ if (!absPath) {
1426
+ return [];
1427
+ }
1428
+
1429
+ const fileEntry = this.files.get(absPath);
1430
+ if (!fileEntry) {
1431
+ return [];
1432
+ }
1433
+
1434
+ const results = [];
1435
+ const exportedNames = new Set(fileEntry.exports);
1436
+
1437
+ for (const symbol of fileEntry.symbols) {
1438
+ const isExported = exportedNames.has(symbol.name) ||
1439
+ (symbol.modifiers && symbol.modifiers.includes('export')) ||
1440
+ (symbol.modifiers && symbol.modifiers.includes('public')) ||
1441
+ (fileEntry.language === 'go' && /^[A-Z]/.test(symbol.name));
1442
+
1443
+ if (isExported) {
1444
+ results.push({
1445
+ name: symbol.name,
1446
+ type: symbol.type,
1447
+ file: fileEntry.relativePath,
1448
+ startLine: symbol.startLine,
1449
+ endLine: symbol.endLine,
1450
+ params: symbol.params,
1451
+ returnType: symbol.returnType,
1452
+ signature: this.formatSignature(symbol)
1453
+ });
1454
+ }
1455
+ }
1456
+
1457
+ return results;
1458
+ }
1459
+
1460
+ /**
1461
+ * Check if a function is used as a callback anywhere in the codebase
1462
+ * @param {string} name - Function name
1463
+ * @returns {Array} Callback usages
1464
+ */
1465
+ findCallbackUsages(name) {
1466
+ const usages = [];
1467
+
1468
+ for (const [filePath, fileEntry] of this.files) {
1469
+ try {
1470
+ const content = fs.readFileSync(filePath, 'utf-8');
1471
+ const language = detectLanguage(filePath);
1472
+ if (!language) continue;
1473
+
1474
+ const langModule = getLanguageModule(language);
1475
+ if (!langModule.findCallbackUsages) continue;
1476
+
1477
+ const parser = getParser(language);
1478
+ const callbacks = langModule.findCallbackUsages(content, name, parser);
1479
+
1480
+ for (const cb of callbacks) {
1481
+ usages.push({
1482
+ file: filePath,
1483
+ relativePath: fileEntry.relativePath,
1484
+ ...cb
1485
+ });
1486
+ }
1487
+ } catch (e) {
1488
+ // Skip files that can't be processed
1489
+ }
1490
+ }
1491
+
1492
+ return usages;
1493
+ }
1494
+
1495
+ /**
1496
+ * Find re-exports of a symbol across the codebase
1497
+ * @param {string} name - Symbol name
1498
+ * @returns {Array} Re-export locations
1499
+ */
1500
+ findReExportsOf(name) {
1501
+ const reExports = [];
1502
+
1503
+ for (const [filePath, fileEntry] of this.files) {
1504
+ try {
1505
+ const content = fs.readFileSync(filePath, 'utf-8');
1506
+ const language = detectLanguage(filePath);
1507
+ if (!language) continue;
1508
+
1509
+ const langModule = getLanguageModule(language);
1510
+ if (!langModule.findReExports) continue;
1511
+
1512
+ const parser = getParser(language);
1513
+ const exports = langModule.findReExports(content, parser);
1514
+
1515
+ for (const exp of exports) {
1516
+ if (exp.name === name) {
1517
+ reExports.push({
1518
+ file: filePath,
1519
+ relativePath: fileEntry.relativePath,
1520
+ ...exp
1521
+ });
1522
+ }
1523
+ }
1524
+ } catch (e) {
1525
+ // Skip files that can't be processed
1526
+ }
1527
+ }
1528
+
1529
+ return reExports;
1530
+ }
1531
+
1532
+ /**
1533
+ * Build a usage index for all identifiers in the codebase (optimized for deadcode)
1534
+ * Scans all files ONCE and builds a reverse index: name -> [usages]
1535
+ * @returns {Map<string, Array>} Usage index
1536
+ */
1537
+ buildUsageIndex() {
1538
+ const usageIndex = new Map(); // name -> [{file, line}]
1539
+
1540
+ for (const [filePath, fileEntry] of this.files) {
1541
+ try {
1542
+ const language = detectLanguage(filePath);
1543
+ if (!language) continue;
1544
+
1545
+ const parser = getParser(language);
1546
+ if (!parser) continue;
1547
+
1548
+ const content = fs.readFileSync(filePath, 'utf-8');
1549
+ const tree = parser.parse(content, undefined, PARSE_OPTIONS);
1550
+
1551
+ // Collect all identifiers from this file in one pass
1552
+ const traverse = (node) => {
1553
+ // Match all identifier-like nodes across languages
1554
+ if (node.type === 'identifier' ||
1555
+ node.type === 'property_identifier' ||
1556
+ node.type === 'type_identifier' ||
1557
+ node.type === 'shorthand_property_identifier' ||
1558
+ node.type === 'shorthand_property_identifier_pattern' ||
1559
+ node.type === 'field_identifier') {
1560
+ const name = node.text;
1561
+ if (!usageIndex.has(name)) {
1562
+ usageIndex.set(name, []);
1563
+ }
1564
+ usageIndex.get(name).push({
1565
+ file: filePath,
1566
+ line: node.startPosition.row + 1,
1567
+ relativePath: fileEntry.relativePath
1568
+ });
1569
+ }
1570
+ for (let i = 0; i < node.childCount; i++) {
1571
+ traverse(node.child(i));
1572
+ }
1573
+ };
1574
+ traverse(tree.rootNode);
1575
+ } catch (e) {
1576
+ // Skip files that can't be processed
1577
+ }
1578
+ }
1579
+
1580
+ return usageIndex;
1581
+ }
1582
+
1583
+ /**
1584
+ * Find dead code (unused functions/classes)
1585
+ * @param {object} options - { includeExported, includeTests }
1586
+ * @returns {Array} Unused symbols
1587
+ */
1588
+ deadcode(options = {}) {
1589
+ const results = [];
1590
+
1591
+ // Build usage index once (instead of per-symbol)
1592
+ const usageIndex = this.buildUsageIndex();
1593
+
1594
+ for (const [name, symbols] of this.symbols) {
1595
+ for (const symbol of symbols) {
1596
+ // Skip non-function/class types
1597
+ if (!['function', 'class', 'method'].includes(symbol.type)) {
1598
+ continue;
1599
+ }
1600
+
1601
+ // Skip test files unless requested
1602
+ if (!options.includeTests && isTestFile(symbol.file, symbol.language)) {
1603
+ continue;
1604
+ }
1605
+
1606
+ // Check if exported
1607
+ const fileEntry = this.files.get(symbol.file);
1608
+ const isExported = fileEntry && (
1609
+ fileEntry.exports.includes(name) ||
1610
+ (symbol.modifiers && symbol.modifiers.includes('export')) ||
1611
+ (symbol.modifiers && symbol.modifiers.includes('public')) ||
1612
+ (fileEntry.language === 'go' && /^[A-Z]/.test(name))
1613
+ );
1614
+
1615
+ // Skip exported unless requested
1616
+ if (isExported && !options.includeExported) {
1617
+ continue;
1618
+ }
1619
+
1620
+ // Use pre-built index for O(1) lookup instead of O(files) scan
1621
+ const allUsages = usageIndex.get(name) || [];
1622
+
1623
+ // Filter out usages that are at the definition location
1624
+ const nonDefUsages = allUsages.filter(u =>
1625
+ !(u.file === symbol.file && u.line === symbol.startLine)
1626
+ );
1627
+
1628
+ // Total includes all usage types (calls, references, callbacks, re-exports)
1629
+ const totalUsages = nonDefUsages.length;
1630
+
1631
+ if (totalUsages === 0) {
1632
+ results.push({
1633
+ name: symbol.name,
1634
+ type: symbol.type,
1635
+ file: symbol.relativePath,
1636
+ startLine: symbol.startLine,
1637
+ endLine: symbol.endLine,
1638
+ isExported,
1639
+ usageCount: 0
1640
+ });
1641
+ }
1642
+ }
1643
+ }
1644
+
1645
+ // Sort by file then line
1646
+ results.sort((a, b) => {
1647
+ if (a.file !== b.file) return a.file.localeCompare(b.file);
1648
+ return a.startLine - b.startLine;
1649
+ });
1650
+
1651
+ return results;
1652
+ }
1653
+
1654
+ /**
1655
+ * Get dependency graph for a file
1656
+ * @param {string} filePath - Starting file
1657
+ * @param {object} options - { direction: 'imports' | 'importers' | 'both', maxDepth }
1658
+ * @returns {object} - Graph structure with root, nodes, edges
1659
+ */
1660
+ graph(filePath, options = {}) {
1661
+ const direction = options.direction || 'imports';
1662
+ // Sanitize depth: use default for null/undefined, clamp negative to 0
1663
+ const rawDepth = options.maxDepth ?? 5;
1664
+ const maxDepth = Math.max(0, rawDepth);
1665
+
1666
+ const absPath = path.isAbsolute(filePath)
1667
+ ? filePath
1668
+ : path.resolve(this.root, filePath);
1669
+
1670
+ // Try to find file if not exact match
1671
+ let targetPath = absPath;
1672
+ if (!this.files.has(absPath)) {
1673
+ for (const [p, entry] of this.files) {
1674
+ if (entry.relativePath === filePath || p.endsWith(filePath)) {
1675
+ targetPath = p;
1676
+ break;
1677
+ }
1678
+ }
1679
+ }
1680
+
1681
+ if (!this.files.has(targetPath)) {
1682
+ return { root: filePath, nodes: [], edges: [] };
1683
+ }
1684
+
1685
+ const visited = new Set();
1686
+ const graph = {
1687
+ root: targetPath,
1688
+ nodes: [],
1689
+ edges: []
1690
+ };
1691
+
1692
+ const traverse = (file, depth) => {
1693
+ if (depth > maxDepth || visited.has(file)) return;
1694
+ visited.add(file);
1695
+
1696
+ const fileEntry = this.files.get(file);
1697
+ const relPath = fileEntry ? fileEntry.relativePath : path.relative(this.root, file);
1698
+ graph.nodes.push({ file, relativePath: relPath, depth });
1699
+
1700
+ let neighbors = [];
1701
+ if (direction === 'imports' || direction === 'both') {
1702
+ const imports = this.importGraph.get(file) || [];
1703
+ neighbors = neighbors.concat(imports);
1704
+ }
1705
+ if (direction === 'importers' || direction === 'both') {
1706
+ const importers = this.exportGraph.get(file) || [];
1707
+ neighbors = neighbors.concat(importers);
1708
+ }
1709
+
1710
+ for (const neighbor of neighbors) {
1711
+ graph.edges.push({ from: file, to: neighbor });
1712
+ traverse(neighbor, depth + 1);
1713
+ }
1714
+ };
1715
+
1716
+ traverse(targetPath, 0);
1717
+ return graph;
1718
+ }
1719
+
1720
+ /**
1721
+ * Detect patterns that may cause incomplete results
1722
+ * Returns warnings about dynamic code patterns
1723
+ * Cached to avoid rescanning on every query
1724
+ */
1725
+ detectCompleteness() {
1726
+ // Return cached result if available
1727
+ if (this._completenessCache) {
1728
+ return this._completenessCache;
1729
+ }
1730
+
1731
+ const warnings = [];
1732
+ let dynamicImports = 0;
1733
+ let evalUsage = 0;
1734
+ let reflectionUsage = 0;
1735
+
1736
+ for (const [filePath, fileEntry] of this.files) {
1737
+ // Skip node_modules - we don't care about their patterns
1738
+ if (filePath.includes('node_modules')) continue;
1739
+
1740
+ try {
1741
+ const content = fs.readFileSync(filePath, 'utf-8');
1742
+
1743
+ // Dynamic imports: import(), require(variable), __import__
1744
+ const dynamicMatches = content.match(/import\s*\([^'"]/g) ||
1745
+ content.match(/require\s*\([^'"]/g) ||
1746
+ content.match(/__import__\s*\(/g);
1747
+ if (dynamicMatches) {
1748
+ dynamicImports += dynamicMatches.length;
1749
+ }
1750
+
1751
+ // eval, Function constructor, exec (but not exec in comments/strings context)
1752
+ const evalMatches = content.match(/[^a-zA-Z_]eval\s*\(/g) ||
1753
+ content.match(/new\s+Function\s*\(/g);
1754
+ if (evalMatches) {
1755
+ evalUsage += evalMatches.length;
1756
+ }
1757
+
1758
+ // Reflection: getattr, hasattr, Reflect
1759
+ const reflectMatches = content.match(/\bgetattr\s*\(/g) ||
1760
+ content.match(/\bhasattr\s*\(/g) ||
1761
+ content.match(/\bReflect\./g);
1762
+ if (reflectMatches) {
1763
+ reflectionUsage += reflectMatches.length;
1764
+ }
1765
+ } catch (e) {
1766
+ // Skip unreadable files
1767
+ }
1768
+ }
1769
+
1770
+ if (dynamicImports > 0) {
1771
+ warnings.push({
1772
+ type: 'dynamic_imports',
1773
+ count: dynamicImports,
1774
+ message: `${dynamicImports} dynamic import(s) detected - some dependencies may be missed`
1775
+ });
1776
+ }
1777
+
1778
+ if (evalUsage > 0) {
1779
+ warnings.push({
1780
+ type: 'eval',
1781
+ count: evalUsage,
1782
+ message: `${evalUsage} eval/exec usage(s) detected - dynamically generated code not analyzed`
1783
+ });
1784
+ }
1785
+
1786
+ if (reflectionUsage > 0) {
1787
+ warnings.push({
1788
+ type: 'reflection',
1789
+ count: reflectionUsage,
1790
+ message: `${reflectionUsage} reflection usage(s) detected - dynamic attribute access not tracked`
1791
+ });
1792
+ }
1793
+
1794
+ this._completenessCache = {
1795
+ complete: warnings.length === 0,
1796
+ warnings
1797
+ };
1798
+
1799
+ return this._completenessCache;
1800
+ }
1801
+
1802
+ /**
1803
+ * Add completeness info to a result
1804
+ */
1805
+ withCompleteness(result, totalResults, maxResults = 100) {
1806
+ const completeness = {
1807
+ warnings: []
1808
+ };
1809
+
1810
+ if (totalResults > maxResults) {
1811
+ completeness.warnings.push({
1812
+ type: 'truncated',
1813
+ message: `Showing ${maxResults} of ${totalResults} results`
1814
+ });
1815
+ }
1816
+
1817
+ // Get project-wide completeness
1818
+ const projectCompleteness = this.detectCompleteness();
1819
+ completeness.warnings.push(...projectCompleteness.warnings);
1820
+
1821
+ completeness.complete = completeness.warnings.length === 0;
1822
+
1823
+ return {
1824
+ ...result,
1825
+ completeness
1826
+ };
1827
+ }
1828
+
1829
+ /**
1830
+ * Find related functions - same file, similar names, shared dependencies
1831
+ * This is the "what else should I look at" command
1832
+ *
1833
+ * @param {string} name - Function name
1834
+ * @returns {object} Related functions grouped by relationship type
1835
+ */
1836
+ related(name) {
1837
+ const definitions = this.symbols.get(name);
1838
+ if (!definitions || definitions.length === 0) {
1839
+ return null;
1840
+ }
1841
+
1842
+ const def = definitions[0];
1843
+ const related = {
1844
+ target: {
1845
+ name: def.name,
1846
+ file: def.relativePath,
1847
+ line: def.startLine,
1848
+ type: def.type
1849
+ },
1850
+ sameFile: [],
1851
+ similarNames: [],
1852
+ sharedCallers: [],
1853
+ sharedCallees: []
1854
+ };
1855
+
1856
+ // 1. Same file functions
1857
+ const fileEntry = this.files.get(def.file);
1858
+ if (fileEntry) {
1859
+ for (const sym of fileEntry.symbols) {
1860
+ if (sym.name !== name && sym.type === 'function') {
1861
+ related.sameFile.push({
1862
+ name: sym.name,
1863
+ line: sym.startLine,
1864
+ params: sym.params
1865
+ });
1866
+ }
1867
+ }
1868
+ }
1869
+
1870
+ // 2. Similar names (shared prefix/suffix, camelCase similarity)
1871
+ const nameParts = name.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase().split('_');
1872
+ for (const [symName, symbols] of this.symbols) {
1873
+ if (symName === name) continue;
1874
+ const symParts = symName.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase().split('_');
1875
+
1876
+ // Check for shared parts
1877
+ const sharedParts = nameParts.filter(p => symParts.includes(p) && p.length > 2);
1878
+ if (sharedParts.length > 0) {
1879
+ const sym = symbols[0];
1880
+ related.similarNames.push({
1881
+ name: symName,
1882
+ file: sym.relativePath,
1883
+ line: sym.startLine,
1884
+ sharedParts,
1885
+ type: sym.type
1886
+ });
1887
+ }
1888
+ }
1889
+ // Sort by number of shared parts
1890
+ related.similarNames.sort((a, b) => b.sharedParts.length - a.sharedParts.length);
1891
+ related.similarNames = related.similarNames.slice(0, 10);
1892
+
1893
+ // 3. Shared callers - functions called by the same callers
1894
+ const myCallers = new Set(this.findCallers(name).map(c => c.callerName).filter(Boolean));
1895
+ if (myCallers.size > 0) {
1896
+ const callerCounts = new Map();
1897
+ for (const callerName of myCallers) {
1898
+ const callerDef = this.symbols.get(callerName)?.[0];
1899
+ if (callerDef) {
1900
+ const callees = this.findCallees(callerDef);
1901
+ for (const callee of callees) {
1902
+ if (callee.name !== name) {
1903
+ callerCounts.set(callee.name, (callerCounts.get(callee.name) || 0) + 1);
1904
+ }
1905
+ }
1906
+ }
1907
+ }
1908
+ // Sort by shared caller count
1909
+ const sorted = Array.from(callerCounts.entries())
1910
+ .sort((a, b) => b[1] - a[1])
1911
+ .slice(0, 5);
1912
+ for (const [symName, count] of sorted) {
1913
+ const sym = this.symbols.get(symName)?.[0];
1914
+ if (sym) {
1915
+ related.sharedCallers.push({
1916
+ name: symName,
1917
+ file: sym.relativePath,
1918
+ line: sym.startLine,
1919
+ sharedCallerCount: count
1920
+ });
1921
+ }
1922
+ }
1923
+ }
1924
+
1925
+ // 4. Shared callees - functions that call the same things
1926
+ if (def.type === 'function' || def.params !== undefined) {
1927
+ const myCallees = new Set(this.findCallees(def).map(c => c.name));
1928
+ if (myCallees.size > 0) {
1929
+ const calleeCounts = new Map();
1930
+ for (const [symName, symbols] of this.symbols) {
1931
+ if (symName === name) continue;
1932
+ const sym = symbols[0];
1933
+ if (sym.type !== 'function' && sym.params === undefined) continue;
1934
+
1935
+ const theirCallees = this.findCallees(sym);
1936
+ const shared = theirCallees.filter(c => myCallees.has(c.name));
1937
+ if (shared.length > 0) {
1938
+ calleeCounts.set(symName, shared.length);
1939
+ }
1940
+ }
1941
+ // Sort by shared callee count
1942
+ const sorted = Array.from(calleeCounts.entries())
1943
+ .sort((a, b) => b[1] - a[1])
1944
+ .slice(0, 5);
1945
+ for (const [symName, count] of sorted) {
1946
+ const sym = this.symbols.get(symName)?.[0];
1947
+ if (sym) {
1948
+ related.sharedCallees.push({
1949
+ name: symName,
1950
+ file: sym.relativePath,
1951
+ line: sym.startLine,
1952
+ sharedCalleeCount: count
1953
+ });
1954
+ }
1955
+ }
1956
+ }
1957
+ }
1958
+
1959
+ return related;
1960
+ }
1961
+
1962
+ /**
1963
+ * Trace call flow - show call tree visualization
1964
+ * This is the "what calls what" command
1965
+ *
1966
+ * @param {string} name - Function name to trace from
1967
+ * @param {object} options - { depth, direction }
1968
+ * @returns {object} Call tree structure
1969
+ */
1970
+ trace(name, options = {}) {
1971
+ // Sanitize depth: use default for null/undefined, clamp negative to 0
1972
+ const rawDepth = options.depth ?? 3;
1973
+ const maxDepth = Math.max(0, rawDepth);
1974
+ const direction = options.direction || 'down'; // 'down' = callees, 'up' = callers, 'both'
1975
+
1976
+ const definitions = this.symbols.get(name);
1977
+ if (!definitions || definitions.length === 0) {
1978
+ return null;
1979
+ }
1980
+
1981
+ const def = definitions[0];
1982
+ const visited = new Set();
1983
+
1984
+ const buildTree = (funcName, currentDepth, dir) => {
1985
+ if (currentDepth > maxDepth || visited.has(funcName)) {
1986
+ return null;
1987
+ }
1988
+ visited.add(funcName);
1989
+
1990
+ const funcDefs = this.symbols.get(funcName);
1991
+ if (!funcDefs || funcDefs.length === 0) {
1992
+ return {
1993
+ name: funcName,
1994
+ external: true,
1995
+ children: []
1996
+ };
1997
+ }
1998
+
1999
+ const funcDef = funcDefs[0];
2000
+ const node = {
2001
+ name: funcName,
2002
+ file: funcDef.relativePath,
2003
+ line: funcDef.startLine,
2004
+ type: funcDef.type,
2005
+ children: []
2006
+ };
2007
+
2008
+ if (dir === 'down' || dir === 'both') {
2009
+ const callees = this.findCallees(funcDef);
2010
+ for (const callee of callees.slice(0, 10)) { // Limit children
2011
+ const childTree = buildTree(callee.name, currentDepth + 1, 'down');
2012
+ if (childTree) {
2013
+ node.children.push({
2014
+ ...childTree,
2015
+ callCount: callee.callCount,
2016
+ weight: callee.weight
2017
+ });
2018
+ }
2019
+ }
2020
+ }
2021
+
2022
+ return node;
2023
+ };
2024
+
2025
+ const tree = buildTree(name, 0, direction);
2026
+
2027
+ // Also get callers if direction is 'up' or 'both'
2028
+ let callers = [];
2029
+ if (direction === 'up' || direction === 'both') {
2030
+ callers = this.findCallers(name).slice(0, 10).map(c => ({
2031
+ name: c.callerName || '(anonymous)',
2032
+ file: c.relativePath,
2033
+ line: c.line,
2034
+ expression: c.content.trim()
2035
+ }));
2036
+ }
2037
+
2038
+ return {
2039
+ root: name,
2040
+ file: def.relativePath,
2041
+ line: def.startLine,
2042
+ direction,
2043
+ maxDepth,
2044
+ tree,
2045
+ callers: direction !== 'down' ? callers : undefined
2046
+ };
2047
+ }
2048
+
2049
+ /**
2050
+ * Analyze impact of changing a function - what call sites would need updating
2051
+ * This is the "what breaks if I change this" command
2052
+ *
2053
+ * @param {string} name - Function name
2054
+ * @param {object} options - { groupByFile }
2055
+ * @returns {object} Impact analysis
2056
+ */
2057
+ impact(name, options = {}) {
2058
+ const definitions = this.symbols.get(name);
2059
+ if (!definitions || definitions.length === 0) {
2060
+ return null;
2061
+ }
2062
+
2063
+ const def = definitions[0];
2064
+ const usages = this.usages(name, { codeOnly: true });
2065
+ const calls = usages.filter(u => u.usageType === 'call' && !u.isDefinition);
2066
+
2067
+ // Analyze each call site
2068
+ const callSites = calls.map(call => {
2069
+ const analysis = this.analyzeCallSite(call, name);
2070
+ return {
2071
+ file: call.relativePath,
2072
+ line: call.line,
2073
+ expression: call.content.trim(),
2074
+ callerName: this.findEnclosingFunction(call.file, call.line),
2075
+ ...analysis
2076
+ };
2077
+ });
2078
+
2079
+ // Group by file if requested
2080
+ const byFile = new Map();
2081
+ for (const site of callSites) {
2082
+ if (!byFile.has(site.file)) {
2083
+ byFile.set(site.file, []);
2084
+ }
2085
+ byFile.get(site.file).push(site);
2086
+ }
2087
+
2088
+ // Identify patterns
2089
+ const patterns = this.identifyCallPatterns(callSites, name);
2090
+
2091
+ return {
2092
+ function: name,
2093
+ file: def.relativePath,
2094
+ startLine: def.startLine,
2095
+ signature: this.formatSignature(def),
2096
+ params: def.params,
2097
+ paramsStructured: def.paramsStructured,
2098
+ totalCallSites: calls.length,
2099
+ byFile: Array.from(byFile.entries()).map(([file, sites]) => ({
2100
+ file,
2101
+ count: sites.length,
2102
+ sites
2103
+ })),
2104
+ patterns
2105
+ };
2106
+ }
2107
+
2108
+ /**
2109
+ * Plan a refactoring operation
2110
+ * @param {string} name - Function name
2111
+ * @param {object} options - { addParam, removeParam, renameTo, defaultValue }
2112
+ * @returns {object} Plan with before/after signatures and affected call sites
2113
+ */
2114
+ plan(name, options = {}) {
2115
+ const definitions = this.symbols.get(name);
2116
+ if (!definitions || definitions.length === 0) {
2117
+ return { found: false, function: name };
2118
+ }
2119
+
2120
+ const def = definitions[0];
2121
+ const impact = this.impact(name);
2122
+ const currentParams = def.paramsStructured || [];
2123
+ const currentSignature = this.formatSignature(def);
2124
+
2125
+ let newParams = [...currentParams];
2126
+ let newSignature = currentSignature;
2127
+ let operation = null;
2128
+ let changes = [];
2129
+
2130
+ if (options.addParam) {
2131
+ operation = 'add-param';
2132
+ const newParam = {
2133
+ name: options.addParam,
2134
+ ...(options.defaultValue && { default: options.defaultValue })
2135
+ };
2136
+ newParams.push(newParam);
2137
+
2138
+ // Generate new signature
2139
+ const paramsList = newParams.map(p => {
2140
+ let str = p.name;
2141
+ if (p.type) str += `: ${p.type}`;
2142
+ if (p.default) str += ` = ${p.default}`;
2143
+ return str;
2144
+ }).join(', ');
2145
+ newSignature = `${name}(${paramsList})`;
2146
+ if (def.returnType) newSignature += `: ${def.returnType}`;
2147
+
2148
+ // Describe changes needed at each call site
2149
+ for (const fileGroup of impact.byFile) {
2150
+ for (const site of fileGroup.sites) {
2151
+ const suggestion = options.defaultValue
2152
+ ? `No change needed (has default value)`
2153
+ : `Add argument: ${options.addParam}`;
2154
+ changes.push({
2155
+ file: site.file,
2156
+ line: site.line,
2157
+ expression: site.expression,
2158
+ suggestion,
2159
+ args: site.args
2160
+ });
2161
+ }
2162
+ }
2163
+ }
2164
+
2165
+ if (options.removeParam) {
2166
+ operation = 'remove-param';
2167
+ const paramIndex = currentParams.findIndex(p => p.name === options.removeParam);
2168
+ if (paramIndex === -1) {
2169
+ return {
2170
+ found: true,
2171
+ error: `Parameter "${options.removeParam}" not found in ${name}`,
2172
+ currentParams: currentParams.map(p => p.name)
2173
+ };
2174
+ }
2175
+
2176
+ newParams = currentParams.filter(p => p.name !== options.removeParam);
2177
+
2178
+ // Generate new signature
2179
+ const paramsList = newParams.map(p => {
2180
+ let str = p.name;
2181
+ if (p.type) str += `: ${p.type}`;
2182
+ if (p.default) str += ` = ${p.default}`;
2183
+ return str;
2184
+ }).join(', ');
2185
+ newSignature = `${name}(${paramsList})`;
2186
+ if (def.returnType) newSignature += `: ${def.returnType}`;
2187
+
2188
+ // Describe changes at each call site
2189
+ for (const fileGroup of impact.byFile) {
2190
+ for (const site of fileGroup.sites) {
2191
+ if (site.args && site.argCount > paramIndex) {
2192
+ changes.push({
2193
+ file: site.file,
2194
+ line: site.line,
2195
+ expression: site.expression,
2196
+ suggestion: `Remove argument ${paramIndex + 1}: ${site.args[paramIndex] || '?'}`,
2197
+ args: site.args
2198
+ });
2199
+ }
2200
+ }
2201
+ }
2202
+ }
2203
+
2204
+ if (options.renameTo) {
2205
+ operation = 'rename';
2206
+ newSignature = currentSignature.replace(name, options.renameTo);
2207
+
2208
+ // All call sites need renaming
2209
+ for (const fileGroup of impact.byFile) {
2210
+ for (const site of fileGroup.sites) {
2211
+ const newExpression = site.expression.replace(
2212
+ new RegExp('\\b' + escapeRegExp(name) + '\\b'),
2213
+ options.renameTo
2214
+ );
2215
+ changes.push({
2216
+ file: site.file,
2217
+ line: site.line,
2218
+ expression: site.expression,
2219
+ suggestion: `Rename to: ${newExpression}`,
2220
+ newExpression
2221
+ });
2222
+ }
2223
+ }
2224
+ }
2225
+
2226
+ return {
2227
+ found: true,
2228
+ function: name,
2229
+ file: def.relativePath,
2230
+ startLine: def.startLine,
2231
+ operation,
2232
+ before: {
2233
+ signature: currentSignature,
2234
+ params: currentParams.map(p => p.name)
2235
+ },
2236
+ after: {
2237
+ signature: newSignature,
2238
+ params: newParams.map(p => p.name)
2239
+ },
2240
+ totalChanges: changes.length,
2241
+ filesAffected: new Set(changes.map(c => c.file)).size,
2242
+ changes
2243
+ };
2244
+ }
2245
+
2246
+ /**
2247
+ * Parse a stack trace and show code for each frame
2248
+ * @param {string} stackText - Stack trace text
2249
+ * @returns {object} Parsed frames with code context
2250
+ */
2251
+ parseStackTrace(stackText) {
2252
+ const frames = [];
2253
+ const lines = stackText.split(/\\n|\n/);
2254
+
2255
+ // Stack trace patterns for different languages/runtimes
2256
+ // Order matters - more specific patterns first
2257
+ const patterns = [
2258
+ // JavaScript Node.js: "at functionName (file.js:line:col)" or "at file.js:line:col"
2259
+ { regex: /at\s+(?:async\s+)?(?:(.+?)\s+\()?([^():]+):(\d+)(?::(\d+))?\)?/, extract: (m) => ({ funcName: m[1] || null, file: m[2], line: parseInt(m[3]), col: m[4] ? parseInt(m[4]) : null }) },
2260
+ // Deno: "at functionName (file:///path/to/file.ts:line:col)"
2261
+ { regex: /at\s+(?:async\s+)?(?:(.+?)\s+\()?file:\/\/([^:]+):(\d+)(?::(\d+))?\)?/, extract: (m) => ({ funcName: m[1] || null, file: m[2], line: parseInt(m[3]), col: m[4] ? parseInt(m[4]) : null }) },
2262
+ // Bun: "at functionName (file.js:line:col)" - similar to Node but may have different formatting
2263
+ { regex: /^\s+at\s+(.+?)\s+\[as\s+\w+\]\s+\(([^:]+):(\d+):(\d+)\)/, extract: (m) => ({ funcName: m[1], file: m[2], line: parseInt(m[3]), col: parseInt(m[4]) }) },
2264
+ // Browser Chrome/V8: "at functionName (http://... or file:// ...)"
2265
+ { regex: /at\s+(?:async\s+)?(?:(.+?)\s+\()?(?:https?:\/\/[^/]+)?([^():]+):(\d+)(?::(\d+))?\)?/, extract: (m) => ({ funcName: m[1] || null, file: m[2], line: parseInt(m[3]), col: m[4] ? parseInt(m[4]) : null }) },
2266
+ // Firefox: "functionName@file:line:col"
2267
+ { regex: /^(.+)@(.+):(\d+):(\d+)$/, extract: (m) => ({ funcName: m[1] || null, file: m[2], line: parseInt(m[3]), col: parseInt(m[4]) }) },
2268
+ // Safari: "functionName@file:line:col" (similar to Firefox)
2269
+ { regex: /^(.+)@(?:https?:\/\/[^/]+)?([^:]+):(\d+)(?::(\d+))?$/, extract: (m) => ({ funcName: m[1] || null, file: m[2], line: parseInt(m[3]), col: m[4] ? parseInt(m[4]) : null }) },
2270
+ // Python: "File \"file.py\", line N, in function"
2271
+ { regex: /File\s+"([^"]+)",\s+line\s+(\d+)(?:,\s+in\s+(.+))?/, extract: (m) => ({ file: m[1], line: parseInt(m[2]), funcName: m[3] || null, col: null }) },
2272
+ // Go: "file.go:line" or "package/file.go:line +0x..."
2273
+ { regex: /^\s*([^\s:]+\.go):(\d+)(?:\s|$)/, extract: (m) => ({ file: m[1], line: parseInt(m[2]), funcName: null, col: null }) },
2274
+ // Go with function: "package.FunctionName()\n\tfile.go:line"
2275
+ { regex: /^\s*([^\s(]+)\(\)$/, extract: null }, // Skip function-only lines
2276
+ // Java: "at package.Class.method(File.java:line)"
2277
+ { regex: /at\s+([^\(]+)\(([^:]+):(\d+)\)/, extract: (m) => ({ funcName: m[1].split('.').pop(), file: m[2], line: parseInt(m[3]), col: null }) },
2278
+ // Rust: "at src/main.rs:line:col" or panic location
2279
+ { regex: /(?:at\s+)?([^\s:]+\.rs):(\d+)(?::(\d+))?/, extract: (m) => ({ file: m[1], line: parseInt(m[2]), col: m[3] ? parseInt(m[3]) : null, funcName: null }) },
2280
+ // Generic: "file:line" as last resort
2281
+ { regex: /([^\s:]+\.\w+):(\d+)(?::(\d+))?/, extract: (m) => ({ file: m[1], line: parseInt(m[2]), col: m[3] ? parseInt(m[3]) : null, funcName: null }) }
2282
+ ];
2283
+
2284
+ for (const line of lines) {
2285
+ const trimmed = line.trim();
2286
+ if (!trimmed) continue;
2287
+
2288
+ // Try each pattern until one matches
2289
+ for (const pattern of patterns) {
2290
+ const match = pattern.regex.exec(trimmed);
2291
+ if (match && pattern.extract) {
2292
+ const extracted = pattern.extract(match);
2293
+ if (extracted && extracted.file && extracted.line) {
2294
+ frames.push(this.createStackFrame(
2295
+ extracted.file,
2296
+ extracted.line,
2297
+ extracted.funcName,
2298
+ extracted.col,
2299
+ trimmed
2300
+ ));
2301
+ break; // Move to next line
2302
+ }
2303
+ }
2304
+ }
2305
+ }
2306
+
2307
+ return {
2308
+ frameCount: frames.length,
2309
+ frames
2310
+ };
2311
+ }
2312
+
2313
+ /**
2314
+ * Calculate path similarity score between two file paths
2315
+ * Higher score = better match
2316
+ * @param {string} query - The path from stack trace
2317
+ * @param {string} candidate - The candidate file path
2318
+ * @returns {number} Similarity score
2319
+ */
2320
+ calculatePathSimilarity(query, candidate) {
2321
+ // Normalize paths for comparison
2322
+ const queryParts = query.replace(/\\/g, '/').split('/').filter(Boolean);
2323
+ const candidateParts = candidate.replace(/\\/g, '/').split('/').filter(Boolean);
2324
+
2325
+ let score = 0;
2326
+
2327
+ // Exact match on full path
2328
+ if (candidate.endsWith(query)) {
2329
+ score += 100;
2330
+ }
2331
+
2332
+ // Compare from the end (most important part)
2333
+ let matches = 0;
2334
+ const minLen = Math.min(queryParts.length, candidateParts.length);
2335
+ for (let i = 0; i < minLen; i++) {
2336
+ const queryPart = queryParts[queryParts.length - 1 - i];
2337
+ const candPart = candidateParts[candidateParts.length - 1 - i];
2338
+ if (queryPart === candPart) {
2339
+ matches++;
2340
+ // Earlier parts (closer to filename) score more
2341
+ score += (10 - i) * 5;
2342
+ } else {
2343
+ break; // Stop at first mismatch
2344
+ }
2345
+ }
2346
+
2347
+ // Bonus for matching most of the query path
2348
+ if (matches === queryParts.length) {
2349
+ score += 50;
2350
+ }
2351
+
2352
+ // Filename match is essential
2353
+ const queryFile = queryParts[queryParts.length - 1];
2354
+ const candFile = candidateParts[candidateParts.length - 1];
2355
+ if (queryFile !== candFile) {
2356
+ score = 0; // No match if filename doesn't match
2357
+ }
2358
+
2359
+ return score;
2360
+ }
2361
+
2362
+ /**
2363
+ * Find the best matching file for a stack trace path
2364
+ * @param {string} filePath - Path from stack trace
2365
+ * @param {string|null} funcName - Function name for verification
2366
+ * @param {number} lineNum - Line number for verification
2367
+ * @returns {{path: string, relativePath: string, confidence: number}|null}
2368
+ */
2369
+ findBestMatchingFile(filePath, funcName, lineNum) {
2370
+ const candidates = [];
2371
+
2372
+ // Collect all potential matches with scores
2373
+ for (const [absPath, fileEntry] of this.files) {
2374
+ const score = this.calculatePathSimilarity(filePath, absPath);
2375
+ const relScore = this.calculatePathSimilarity(filePath, fileEntry.relativePath);
2376
+ const bestScore = Math.max(score, relScore);
2377
+
2378
+ if (bestScore > 0) {
2379
+ candidates.push({
2380
+ absPath,
2381
+ relativePath: fileEntry.relativePath,
2382
+ score: bestScore,
2383
+ fileEntry
2384
+ });
2385
+ }
2386
+ }
2387
+
2388
+ if (candidates.length === 0) {
2389
+ // Try absolute path
2390
+ const absPath = path.isAbsolute(filePath) ? filePath : path.join(this.root, filePath);
2391
+ if (fs.existsSync(absPath)) {
2392
+ return {
2393
+ path: absPath,
2394
+ relativePath: path.relative(this.root, absPath),
2395
+ confidence: 0.5 // Low confidence for unindexed files
2396
+ };
2397
+ }
2398
+ return null;
2399
+ }
2400
+
2401
+ // Sort by score descending
2402
+ candidates.sort((a, b) => b.score - a.score);
2403
+
2404
+ // If there's a function name, verify it exists at the line
2405
+ if (funcName && candidates.length > 1) {
2406
+ for (const cand of candidates) {
2407
+ const symbols = this.symbols.get(funcName);
2408
+ if (symbols) {
2409
+ const match = symbols.find(s =>
2410
+ s.file === cand.absPath &&
2411
+ s.startLine <= lineNum && s.endLine >= lineNum
2412
+ );
2413
+ if (match) {
2414
+ // This candidate has the function at the right line - strong match
2415
+ return {
2416
+ path: cand.absPath,
2417
+ relativePath: cand.relativePath,
2418
+ confidence: 1.0,
2419
+ verifiedFunction: true
2420
+ };
2421
+ }
2422
+ }
2423
+ }
2424
+ }
2425
+
2426
+ // Return best scoring candidate
2427
+ const best = candidates[0];
2428
+ const confidence = candidates.length === 1 ? 0.9 :
2429
+ (best.score > 100 ? 0.8 : 0.6);
2430
+
2431
+ return {
2432
+ path: best.absPath,
2433
+ relativePath: best.relativePath,
2434
+ confidence
2435
+ };
2436
+ }
2437
+
2438
+ /**
2439
+ * Create a stack frame with code context
2440
+ */
2441
+ createStackFrame(filePath, lineNum, funcName, col, rawLine) {
2442
+ const frame = {
2443
+ file: filePath,
2444
+ line: lineNum,
2445
+ function: funcName,
2446
+ column: col,
2447
+ raw: rawLine,
2448
+ found: false,
2449
+ code: null,
2450
+ context: null,
2451
+ confidence: 0
2452
+ };
2453
+
2454
+ // Find the best matching file using improved algorithm
2455
+ const match = this.findBestMatchingFile(filePath, funcName, lineNum);
2456
+
2457
+ if (match) {
2458
+ const resolvedPath = match.path;
2459
+ frame.found = true;
2460
+ frame.resolvedFile = match.relativePath;
2461
+ frame.confidence = match.confidence;
2462
+ if (match.verifiedFunction) {
2463
+ frame.verifiedFunction = true;
2464
+ }
2465
+
2466
+ try {
2467
+ const content = fs.readFileSync(resolvedPath, 'utf-8');
2468
+ const lines = content.split('\n');
2469
+
2470
+ // Get the exact line
2471
+ if (lineNum > 0 && lineNum <= lines.length) {
2472
+ frame.code = lines[lineNum - 1];
2473
+
2474
+ // Get context (2 lines before, 2 after)
2475
+ const contextLines = [];
2476
+ for (let i = Math.max(0, lineNum - 3); i < Math.min(lines.length, lineNum + 2); i++) {
2477
+ contextLines.push({
2478
+ line: i + 1,
2479
+ code: lines[i],
2480
+ isCurrent: i + 1 === lineNum
2481
+ });
2482
+ }
2483
+ frame.context = contextLines;
2484
+ }
2485
+
2486
+ // Try to find function info (verify it contains the line)
2487
+ if (funcName) {
2488
+ const symbols = this.symbols.get(funcName);
2489
+ if (symbols) {
2490
+ const funcMatch = symbols.find(s =>
2491
+ s.file === resolvedPath &&
2492
+ s.startLine <= lineNum && s.endLine >= lineNum
2493
+ );
2494
+ if (funcMatch) {
2495
+ frame.functionInfo = {
2496
+ name: funcMatch.name,
2497
+ startLine: funcMatch.startLine,
2498
+ endLine: funcMatch.endLine,
2499
+ params: funcMatch.params
2500
+ };
2501
+ frame.confidence = 1.0; // High confidence when function verified
2502
+ } else {
2503
+ // Function exists but line doesn't match - lower confidence
2504
+ const anyMatch = symbols.find(s => s.file === resolvedPath);
2505
+ if (anyMatch) {
2506
+ frame.functionInfo = {
2507
+ name: anyMatch.name,
2508
+ startLine: anyMatch.startLine,
2509
+ endLine: anyMatch.endLine,
2510
+ params: anyMatch.params,
2511
+ lineMismatch: true
2512
+ };
2513
+ frame.confidence = Math.min(frame.confidence, 0.5);
2514
+ }
2515
+ }
2516
+ }
2517
+ } else {
2518
+ // No function name in stack - find enclosing function
2519
+ const enclosing = this.findEnclosingFunction(resolvedPath, lineNum, true);
2520
+ if (enclosing) {
2521
+ frame.functionInfo = {
2522
+ name: enclosing.name,
2523
+ startLine: enclosing.startLine,
2524
+ endLine: enclosing.endLine,
2525
+ params: enclosing.params,
2526
+ inferred: true
2527
+ };
2528
+ }
2529
+ }
2530
+ } catch (e) {
2531
+ frame.error = e.message;
2532
+ }
2533
+ }
2534
+
2535
+ return frame;
2536
+ }
2537
+
2538
+ /**
2539
+ * Verify that all call sites match a function's signature
2540
+ * @param {string} name - Function name
2541
+ * @returns {object} Verification results with mismatches
2542
+ */
2543
+ verify(name) {
2544
+ const definitions = this.symbols.get(name);
2545
+ if (!definitions || definitions.length === 0) {
2546
+ return { found: false, function: name };
2547
+ }
2548
+
2549
+ const def = definitions[0];
2550
+ const expectedParamCount = def.paramsStructured?.length || 0;
2551
+ const optionalCount = (def.paramsStructured || []).filter(p => p.optional || p.default !== undefined).length;
2552
+ const minArgs = expectedParamCount - optionalCount;
2553
+ const hasRest = (def.paramsStructured || []).some(p => p.rest);
2554
+
2555
+ // Get all call sites
2556
+ const usages = this.usages(name, { codeOnly: true });
2557
+ const calls = usages.filter(u => u.usageType === 'call' && !u.isDefinition);
2558
+
2559
+ const valid = [];
2560
+ const mismatches = [];
2561
+ const uncertain = [];
2562
+
2563
+ for (const call of calls) {
2564
+ const analysis = this.analyzeCallSite(call, name);
2565
+
2566
+ if (analysis.args === null) {
2567
+ // Couldn't parse arguments
2568
+ uncertain.push({
2569
+ file: call.relativePath,
2570
+ line: call.line,
2571
+ expression: call.content.trim(),
2572
+ reason: 'Could not parse call arguments'
2573
+ });
2574
+ continue;
2575
+ }
2576
+
2577
+ if (analysis.hasSpread) {
2578
+ // Spread args - can't verify count
2579
+ uncertain.push({
2580
+ file: call.relativePath,
2581
+ line: call.line,
2582
+ expression: call.content.trim(),
2583
+ reason: 'Uses spread operator'
2584
+ });
2585
+ continue;
2586
+ }
2587
+
2588
+ const argCount = analysis.argCount;
2589
+
2590
+ // Check if arg count is valid
2591
+ if (hasRest) {
2592
+ // With rest param, need at least minArgs
2593
+ if (argCount >= minArgs) {
2594
+ valid.push({ file: call.relativePath, line: call.line });
2595
+ } else {
2596
+ mismatches.push({
2597
+ file: call.relativePath,
2598
+ line: call.line,
2599
+ expression: call.content.trim(),
2600
+ expected: `at least ${minArgs} arg(s)`,
2601
+ actual: argCount,
2602
+ args: analysis.args
2603
+ });
2604
+ }
2605
+ } else {
2606
+ // Without rest, need between minArgs and expectedParamCount
2607
+ if (argCount >= minArgs && argCount <= expectedParamCount) {
2608
+ valid.push({ file: call.relativePath, line: call.line });
2609
+ } else {
2610
+ mismatches.push({
2611
+ file: call.relativePath,
2612
+ line: call.line,
2613
+ expression: call.content.trim(),
2614
+ expected: minArgs === expectedParamCount
2615
+ ? `${expectedParamCount} arg(s)`
2616
+ : `${minArgs}-${expectedParamCount} arg(s)`,
2617
+ actual: argCount,
2618
+ args: analysis.args
2619
+ });
2620
+ }
2621
+ }
2622
+ }
2623
+
2624
+ return {
2625
+ found: true,
2626
+ function: name,
2627
+ file: def.relativePath,
2628
+ startLine: def.startLine,
2629
+ signature: this.formatSignature(def),
2630
+ params: def.paramsStructured?.map(p => ({
2631
+ name: p.name,
2632
+ optional: p.optional || p.default !== undefined,
2633
+ hasDefault: p.default !== undefined
2634
+ })) || [],
2635
+ expectedArgs: { min: minArgs, max: hasRest ? '∞' : expectedParamCount },
2636
+ totalCalls: calls.length,
2637
+ valid: valid.length,
2638
+ mismatches: mismatches.length,
2639
+ uncertain: uncertain.length,
2640
+ mismatchDetails: mismatches,
2641
+ uncertainDetails: uncertain
2642
+ };
2643
+ }
2644
+
2645
+ /**
2646
+ * Analyze a call site to understand how it's being called
2647
+ */
2648
+ analyzeCallSite(call, funcName) {
2649
+ const content = call.content;
2650
+
2651
+ // Extract arguments from the call
2652
+ const callMatch = new RegExp('\\b' + escapeRegExp(funcName) + '\\s*\\(([^)]*)\\)').exec(content);
2653
+ if (!callMatch) {
2654
+ return { args: null, argCount: 0 };
2655
+ }
2656
+
2657
+ const argsStr = callMatch[1].trim();
2658
+ if (!argsStr) {
2659
+ return { args: [], argCount: 0 };
2660
+ }
2661
+
2662
+ // Simple arg parsing (doesn't handle nested parens/strings perfectly but good enough)
2663
+ const args = this.parseArguments(argsStr);
2664
+
2665
+ return {
2666
+ args,
2667
+ argCount: args.length,
2668
+ hasSpread: args.some(a => a.startsWith('...')),
2669
+ hasVariable: args.some(a => /^[a-zA-Z_]\w*$/.test(a))
2670
+ };
2671
+ }
2672
+
2673
+ /**
2674
+ * Parse function call arguments (simple version)
2675
+ */
2676
+ parseArguments(argsStr) {
2677
+ const args = [];
2678
+ let current = '';
2679
+ let depth = 0;
2680
+ let inString = false;
2681
+ let stringChar = '';
2682
+
2683
+ for (let i = 0; i < argsStr.length; i++) {
2684
+ const ch = argsStr[i];
2685
+
2686
+ if (inString) {
2687
+ current += ch;
2688
+ if (ch === stringChar && argsStr[i - 1] !== '\\') {
2689
+ inString = false;
2690
+ }
2691
+ continue;
2692
+ }
2693
+
2694
+ if (ch === '"' || ch === "'" || ch === '`') {
2695
+ inString = true;
2696
+ stringChar = ch;
2697
+ current += ch;
2698
+ continue;
2699
+ }
2700
+
2701
+ if (ch === '(' || ch === '[' || ch === '{') {
2702
+ depth++;
2703
+ current += ch;
2704
+ continue;
2705
+ }
2706
+
2707
+ if (ch === ')' || ch === ']' || ch === '}') {
2708
+ depth--;
2709
+ current += ch;
2710
+ continue;
2711
+ }
2712
+
2713
+ if (ch === ',' && depth === 0) {
2714
+ args.push(current.trim());
2715
+ current = '';
2716
+ continue;
2717
+ }
2718
+
2719
+ current += ch;
2720
+ }
2721
+
2722
+ if (current.trim()) {
2723
+ args.push(current.trim());
2724
+ }
2725
+
2726
+ return args;
2727
+ }
2728
+
2729
+ /**
2730
+ * Identify common calling patterns
2731
+ */
2732
+ identifyCallPatterns(callSites, funcName) {
2733
+ const patterns = {
2734
+ constantArgs: 0, // Call sites with literal/constant arguments
2735
+ variableArgs: 0, // Call sites passing variables
2736
+ chainedCalls: 0, // Calls that are part of method chains
2737
+ awaitedCalls: 0, // Async calls with await
2738
+ spreadCalls: 0 // Calls using spread operator
2739
+ };
2740
+
2741
+ for (const site of callSites) {
2742
+ const expr = site.expression;
2743
+
2744
+ if (site.hasSpread) patterns.spreadCalls++;
2745
+ if (/await\s/.test(expr)) patterns.awaitedCalls++;
2746
+ if (new RegExp('\\.' + escapeRegExp(funcName) + '\\s*\\(').test(expr)) patterns.chainedCalls++;
2747
+
2748
+ if (site.args && site.args.length > 0) {
2749
+ const hasLiteral = site.args.some(a =>
2750
+ /^[\d'"{\[]/.test(a) || a === 'true' || a === 'false' || a === 'null'
2751
+ );
2752
+ if (hasLiteral) patterns.constantArgs++;
2753
+ if (site.hasVariable) patterns.variableArgs++;
2754
+ }
2755
+ }
2756
+
2757
+ return patterns;
2758
+ }
2759
+
2760
+ /**
2761
+ * Get complete information about a symbol - definition, usages, callers, callees, tests, code
2762
+ * This is the "tell me everything" command for AI agents
2763
+ *
2764
+ * @param {string} name - Symbol name
2765
+ * @param {object} options - { maxCallers, maxCallees, withCode, withTypes }
2766
+ * @returns {object} Complete symbol info
2767
+ */
2768
+ about(name, options = {}) {
2769
+ const maxCallers = options.maxCallers || 5;
2770
+ const maxCallees = options.maxCallees || 5;
2771
+
2772
+ // Find symbol definition(s)
2773
+ const definitions = this.find(name, { exact: true });
2774
+ if (definitions.length === 0) {
2775
+ // Try fuzzy match
2776
+ const fuzzy = this.find(name);
2777
+ if (fuzzy.length === 0) {
2778
+ return null;
2779
+ }
2780
+ // Return suggestion
2781
+ return {
2782
+ found: false,
2783
+ suggestions: fuzzy.slice(0, 5).map(s => ({
2784
+ name: s.name,
2785
+ file: s.relativePath,
2786
+ line: s.startLine,
2787
+ type: s.type,
2788
+ usageCount: s.usageCount
2789
+ }))
2790
+ };
2791
+ }
2792
+
2793
+ // Use the definition with highest usage count (primary implementation)
2794
+ const primary = definitions[0];
2795
+ const others = definitions.slice(1);
2796
+
2797
+ // Use the actual symbol name (may differ from query if fuzzy matched)
2798
+ const symbolName = primary.name;
2799
+
2800
+ // Get usage counts by type
2801
+ const usages = this.usages(symbolName, { codeOnly: true });
2802
+ const usagesByType = {
2803
+ definitions: usages.filter(u => u.isDefinition).length,
2804
+ calls: usages.filter(u => u.usageType === 'call').length,
2805
+ imports: usages.filter(u => u.usageType === 'import').length,
2806
+ references: usages.filter(u => u.usageType === 'reference').length
2807
+ };
2808
+
2809
+ // Get callers and callees (only for functions)
2810
+ let callers = [];
2811
+ let callees = [];
2812
+ let allCallers = null;
2813
+ let allCallees = null;
2814
+ if (primary.type === 'function' || primary.params !== undefined) {
2815
+ allCallers = this.findCallers(symbolName);
2816
+ callers = allCallers.slice(0, maxCallers).map(c => ({
2817
+ file: c.relativePath,
2818
+ line: c.line,
2819
+ expression: c.content.trim(),
2820
+ callerName: c.callerName
2821
+ }));
2822
+
2823
+ allCallees = this.findCallees(primary);
2824
+ callees = allCallees.slice(0, maxCallees).map(c => ({
2825
+ name: c.name,
2826
+ file: c.relativePath,
2827
+ line: c.startLine,
2828
+ startLine: c.startLine,
2829
+ endLine: c.endLine,
2830
+ weight: c.weight,
2831
+ callCount: c.callCount
2832
+ }));
2833
+ }
2834
+
2835
+ // Find tests
2836
+ const tests = this.tests(symbolName);
2837
+ const testSummary = {
2838
+ fileCount: tests.length,
2839
+ totalMatches: tests.reduce((sum, t) => sum + t.matches.length, 0),
2840
+ files: tests.slice(0, 3).map(t => t.file)
2841
+ };
2842
+
2843
+ // Extract code if requested (default: true)
2844
+ let code = null;
2845
+ if (options.withCode !== false) {
2846
+ code = this.extractCode(primary);
2847
+ }
2848
+
2849
+ // Get type definitions if requested
2850
+ let types = [];
2851
+ if (options.withTypes && (primary.params !== undefined || primary.returnType)) {
2852
+ const typeNames = this.extractTypeNames(primary);
2853
+ for (const typeName of typeNames) {
2854
+ const typeSymbols = this.symbols.get(typeName);
2855
+ if (typeSymbols) {
2856
+ for (const sym of typeSymbols) {
2857
+ if (['type', 'interface', 'class', 'struct'].includes(sym.type)) {
2858
+ types.push({
2859
+ name: sym.name,
2860
+ type: sym.type,
2861
+ file: sym.relativePath,
2862
+ line: sym.startLine
2863
+ });
2864
+ }
2865
+ }
2866
+ }
2867
+ }
2868
+ }
2869
+
2870
+ const result = {
2871
+ found: true,
2872
+ symbol: {
2873
+ name: primary.name,
2874
+ type: primary.type,
2875
+ file: primary.relativePath,
2876
+ startLine: primary.startLine,
2877
+ endLine: primary.endLine,
2878
+ params: primary.params,
2879
+ returnType: primary.returnType,
2880
+ modifiers: primary.modifiers,
2881
+ docstring: primary.docstring,
2882
+ signature: this.formatSignature(primary)
2883
+ },
2884
+ usages: usagesByType,
2885
+ totalUsages: usagesByType.calls + usagesByType.imports + usagesByType.references,
2886
+ callers: {
2887
+ total: allCallers?.length ?? 0,
2888
+ top: callers
2889
+ },
2890
+ callees: {
2891
+ total: allCallees?.length ?? 0,
2892
+ top: callees
2893
+ },
2894
+ tests: testSummary,
2895
+ otherDefinitions: others.slice(0, 3).map(d => ({
2896
+ file: d.relativePath,
2897
+ line: d.startLine,
2898
+ usageCount: d.usageCount
2899
+ })),
2900
+ types,
2901
+ code,
2902
+ completeness: this.detectCompleteness()
2903
+ };
2904
+
2905
+ return result;
2906
+ }
2907
+
2908
+ /**
2909
+ * Search for text across the project
2910
+ * @param {string} term - Search term
2911
+ * @param {object} options - { codeOnly, context }
2912
+ */
2913
+ search(term, options = {}) {
2914
+ const results = [];
2915
+ // Escape the term to handle special regex characters
2916
+ const regex = new RegExp(escapeRegExp(term), 'gi');
2917
+
2918
+ for (const [filePath, fileEntry] of this.files) {
2919
+ try {
2920
+ const content = fs.readFileSync(filePath, 'utf-8');
2921
+ const lines = content.split('\n');
2922
+ const matches = [];
2923
+
2924
+ // Use AST-based filtering for codeOnly mode when language is supported
2925
+ if (options.codeOnly) {
2926
+ const language = detectLanguage(filePath);
2927
+ if (language) {
2928
+ try {
2929
+ const parser = getParser(language);
2930
+ const { findMatchesWithASTFilter } = require('../languages/utils');
2931
+ const astMatches = findMatchesWithASTFilter(content, term, parser, { codeOnly: true });
2932
+
2933
+ for (const m of astMatches) {
2934
+ const match = {
2935
+ line: m.line,
2936
+ content: m.content
2937
+ };
2938
+
2939
+ // Add context lines if requested
2940
+ if (options.context && options.context > 0) {
2941
+ const idx = m.line - 1;
2942
+ const before = [];
2943
+ const after = [];
2944
+ for (let i = 1; i <= options.context; i++) {
2945
+ if (idx - i >= 0) before.unshift(lines[idx - i]);
2946
+ if (idx + i < lines.length) after.push(lines[idx + i]);
2947
+ }
2948
+ match.before = before;
2949
+ match.after = after;
2950
+ }
2951
+
2952
+ matches.push(match);
2953
+ }
2954
+
2955
+ if (matches.length > 0) {
2956
+ results.push({
2957
+ file: fileEntry.relativePath,
2958
+ matches
2959
+ });
2960
+ }
2961
+ continue; // Skip to next file
2962
+ } catch (e) {
2963
+ // Fall through to regex-based search
2964
+ }
2965
+ }
2966
+ }
2967
+
2968
+ // Fallback to regex-based search (non-codeOnly or unsupported language)
2969
+ lines.forEach((line, idx) => {
2970
+ regex.lastIndex = 0; // Reset regex state
2971
+ if (regex.test(line)) {
2972
+ const lineNum = idx + 1;
2973
+ // Skip if codeOnly and line is comment/string
2974
+ if (options.codeOnly && this.isCommentOrStringAtPosition(content, lineNum, 0, filePath)) {
2975
+ return;
2976
+ }
2977
+
2978
+ const match = {
2979
+ line: idx + 1,
2980
+ content: line
2981
+ };
2982
+
2983
+ // Add context lines if requested
2984
+ if (options.context && options.context > 0) {
2985
+ const before = [];
2986
+ const after = [];
2987
+ for (let i = 1; i <= options.context; i++) {
2988
+ if (idx - i >= 0) before.unshift(lines[idx - i]);
2989
+ if (idx + i < lines.length) after.push(lines[idx + i]);
2990
+ }
2991
+ match.before = before;
2992
+ match.after = after;
2993
+ }
2994
+
2995
+ matches.push(match);
2996
+ }
2997
+ });
2998
+
2999
+ if (matches.length > 0) {
3000
+ results.push({
3001
+ file: fileEntry.relativePath,
3002
+ matches
3003
+ });
3004
+ }
3005
+ } catch (e) {
3006
+ // Skip unreadable files
3007
+ }
3008
+ }
3009
+
3010
+ return results;
3011
+ }
3012
+
3013
+ // ========================================================================
3014
+ // PROJECT INFO
3015
+ // ========================================================================
3016
+
3017
+ /**
3018
+ * Get project statistics
3019
+ */
3020
+ getStats() {
3021
+ // Count total symbols (not just unique names)
3022
+ let totalSymbols = 0;
3023
+ for (const [name, symbols] of this.symbols) {
3024
+ totalSymbols += symbols.length;
3025
+ }
3026
+
3027
+ const stats = {
3028
+ root: this.root,
3029
+ files: this.files.size,
3030
+ symbols: totalSymbols, // Total symbol count, not unique names
3031
+ buildTime: this.buildTime,
3032
+ byLanguage: {},
3033
+ byType: {}
3034
+ };
3035
+
3036
+ for (const [filePath, fileEntry] of this.files) {
3037
+ const lang = fileEntry.language;
3038
+ if (!stats.byLanguage[lang]) {
3039
+ stats.byLanguage[lang] = { files: 0, lines: 0, symbols: 0 };
3040
+ }
3041
+ stats.byLanguage[lang].files++;
3042
+ stats.byLanguage[lang].lines += fileEntry.lines;
3043
+ stats.byLanguage[lang].symbols += fileEntry.symbols.length;
3044
+ }
3045
+
3046
+ for (const [name, symbols] of this.symbols) {
3047
+ for (const sym of symbols) {
3048
+ if (!Object.hasOwn(stats.byType, sym.type)) {
3049
+ stats.byType[sym.type] = 0;
3050
+ }
3051
+ stats.byType[sym.type]++;
3052
+ }
3053
+ }
3054
+
3055
+ return stats;
3056
+ }
3057
+
3058
+ /**
3059
+ * Get TOC for all files
3060
+ */
3061
+ getToc() {
3062
+ const files = [];
3063
+ let totalFunctions = 0;
3064
+ let totalClasses = 0;
3065
+ let totalState = 0;
3066
+ let totalLines = 0;
3067
+
3068
+ for (const [filePath, fileEntry] of this.files) {
3069
+ const functions = fileEntry.symbols.filter(s => s.type === 'function');
3070
+ const classes = fileEntry.symbols.filter(s =>
3071
+ ['class', 'interface', 'type', 'enum', 'struct', 'trait', 'impl'].includes(s.type)
3072
+ );
3073
+ const state = fileEntry.symbols.filter(s => s.type === 'state');
3074
+
3075
+ totalFunctions += functions.length;
3076
+ totalClasses += classes.length;
3077
+ totalState += state.length;
3078
+ totalLines += fileEntry.lines;
3079
+
3080
+ files.push({
3081
+ file: fileEntry.relativePath,
3082
+ language: fileEntry.language,
3083
+ lines: fileEntry.lines,
3084
+ functions,
3085
+ classes,
3086
+ state
3087
+ });
3088
+ }
3089
+
3090
+ return {
3091
+ totalFiles: files.length,
3092
+ totalLines,
3093
+ totalFunctions,
3094
+ totalClasses,
3095
+ totalState,
3096
+ byFile: files
3097
+ };
3098
+ }
3099
+
3100
+ // ========================================================================
3101
+ // CACHE METHODS
3102
+ // ========================================================================
3103
+
3104
+ /**
3105
+ * Save index to cache file
3106
+ *
3107
+ * @param {string} [cachePath] - Optional custom cache path
3108
+ * @returns {string} - Path to cache file
3109
+ */
3110
+ saveCache(cachePath) {
3111
+ const cacheDir = cachePath
3112
+ ? path.dirname(cachePath)
3113
+ : path.join(this.root, '.ucn-cache');
3114
+
3115
+ if (!fs.existsSync(cacheDir)) {
3116
+ fs.mkdirSync(cacheDir, { recursive: true });
3117
+ }
3118
+
3119
+ const cacheFile = cachePath || path.join(cacheDir, 'index.json');
3120
+
3121
+ // Prepare callsCache for serialization (exclude content to save space)
3122
+ const callsCacheData = [];
3123
+ for (const [filePath, entry] of this.callsCache) {
3124
+ callsCacheData.push([filePath, {
3125
+ mtime: entry.mtime,
3126
+ hash: entry.hash,
3127
+ calls: entry.calls
3128
+ // content is not persisted - will be read on demand
3129
+ }]);
3130
+ }
3131
+
3132
+ const cacheData = {
3133
+ version: 2, // Bump version for new cache format
3134
+ root: this.root,
3135
+ buildTime: this.buildTime,
3136
+ timestamp: Date.now(),
3137
+ files: Array.from(this.files.entries()),
3138
+ symbols: Array.from(this.symbols.entries()),
3139
+ importGraph: Array.from(this.importGraph.entries()),
3140
+ exportGraph: Array.from(this.exportGraph.entries()),
3141
+ extendsGraph: Array.from(this.extendsGraph.entries()),
3142
+ extendedByGraph: Array.from(this.extendedByGraph.entries()),
3143
+ callsCache: callsCacheData
3144
+ };
3145
+
3146
+ fs.writeFileSync(cacheFile, JSON.stringify(cacheData));
3147
+ return cacheFile;
3148
+ }
3149
+
3150
+ /**
3151
+ * Load index from cache file
3152
+ *
3153
+ * @param {string} [cachePath] - Optional custom cache path
3154
+ * @returns {boolean} - True if loaded successfully
3155
+ */
3156
+ loadCache(cachePath) {
3157
+ const cacheFile = cachePath || path.join(this.root, '.ucn-cache', 'index.json');
3158
+
3159
+ if (!fs.existsSync(cacheFile)) {
3160
+ return false;
3161
+ }
3162
+
3163
+ try {
3164
+ const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf-8'));
3165
+
3166
+ // Check version compatibility (support v1 and v2)
3167
+ if (cacheData.version !== 1 && cacheData.version !== 2) {
3168
+ return false;
3169
+ }
3170
+
3171
+ // Validate cache structure has required fields
3172
+ if (!Array.isArray(cacheData.files) ||
3173
+ !Array.isArray(cacheData.symbols) ||
3174
+ !Array.isArray(cacheData.importGraph) ||
3175
+ !Array.isArray(cacheData.exportGraph)) {
3176
+ return false;
3177
+ }
3178
+
3179
+ this.files = new Map(cacheData.files);
3180
+ this.symbols = new Map(cacheData.symbols);
3181
+ this.importGraph = new Map(cacheData.importGraph);
3182
+ this.exportGraph = new Map(cacheData.exportGraph);
3183
+ this.buildTime = cacheData.buildTime;
3184
+
3185
+ // Restore optional graphs if present
3186
+ if (Array.isArray(cacheData.extendsGraph)) {
3187
+ this.extendsGraph = new Map(cacheData.extendsGraph);
3188
+ }
3189
+ if (Array.isArray(cacheData.extendedByGraph)) {
3190
+ this.extendedByGraph = new Map(cacheData.extendedByGraph);
3191
+ }
3192
+
3193
+ // Restore callsCache if present (v2+)
3194
+ if (Array.isArray(cacheData.callsCache)) {
3195
+ this.callsCache = new Map(cacheData.callsCache);
3196
+ }
3197
+
3198
+ // Rebuild derived graphs to ensure consistency with current config
3199
+ this.buildImportGraph();
3200
+ this.buildInheritanceGraph();
3201
+
3202
+ return true;
3203
+ } catch (e) {
3204
+ return false;
3205
+ }
3206
+ }
3207
+
3208
+ /**
3209
+ * Check if cache is stale (any files changed or new files added)
3210
+ *
3211
+ * @returns {boolean} - True if cache needs rebuilding
3212
+ */
3213
+ isCacheStale() {
3214
+ // Check for new files added to project
3215
+ const pattern = detectProjectPattern(this.root);
3216
+ const currentFiles = expandGlob(pattern, { root: this.root });
3217
+ const cachedPaths = new Set(this.files.keys());
3218
+
3219
+ for (const file of currentFiles) {
3220
+ if (!cachedPaths.has(file)) {
3221
+ return true; // New file found
3222
+ }
3223
+ }
3224
+
3225
+ // Check existing cached files for modifications/deletions
3226
+ for (const [filePath, fileEntry] of this.files) {
3227
+ // File deleted
3228
+ if (!fs.existsSync(filePath)) {
3229
+ return true;
3230
+ }
3231
+
3232
+ // File modified - check size first, then mtime, then hash
3233
+ try {
3234
+ const stat = fs.statSync(filePath);
3235
+
3236
+ // If size changed, file changed
3237
+ if (fileEntry.size !== undefined && stat.size !== fileEntry.size) {
3238
+ return true;
3239
+ }
3240
+
3241
+ // If mtime matches, file hasn't changed
3242
+ if (fileEntry.mtime && stat.mtimeMs === fileEntry.mtime) {
3243
+ continue;
3244
+ }
3245
+
3246
+ // mtime changed or not stored - verify with hash
3247
+ const content = fs.readFileSync(filePath, 'utf-8');
3248
+ const hash = crypto.createHash('md5').update(content).digest('hex');
3249
+ if (hash !== fileEntry.hash) {
3250
+ return true;
3251
+ }
3252
+ } catch (e) {
3253
+ return true;
3254
+ }
3255
+ }
3256
+
3257
+ return false;
3258
+ }
3259
+ }
3260
+
3261
+ module.exports = { ProjectIndex };