ucn 3.8.13 → 3.8.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.claude/skills/ucn/SKILL.md +3 -1
  2. package/.github/workflows/ci.yml +13 -1
  3. package/README.md +1 -0
  4. package/cli/index.js +165 -246
  5. package/core/analysis.js +1400 -0
  6. package/core/build-worker.js +194 -0
  7. package/core/cache.js +105 -7
  8. package/core/callers.js +194 -64
  9. package/core/deadcode.js +22 -66
  10. package/core/discovery.js +9 -54
  11. package/core/execute.js +139 -54
  12. package/core/graph.js +615 -0
  13. package/core/output/analysis-ext.js +271 -0
  14. package/core/output/analysis.js +491 -0
  15. package/core/output/extraction.js +188 -0
  16. package/core/output/find.js +355 -0
  17. package/core/output/graph.js +399 -0
  18. package/core/output/refactoring.js +293 -0
  19. package/core/output/reporting.js +331 -0
  20. package/core/output/search.js +307 -0
  21. package/core/output/shared.js +271 -0
  22. package/core/output/tracing.js +416 -0
  23. package/core/output.js +15 -3293
  24. package/core/parallel-build.js +165 -0
  25. package/core/project.js +299 -3633
  26. package/core/registry.js +59 -0
  27. package/core/reporting.js +258 -0
  28. package/core/search.js +890 -0
  29. package/core/stacktrace.js +1 -1
  30. package/core/tracing.js +631 -0
  31. package/core/verify.js +10 -13
  32. package/eslint.config.js +43 -0
  33. package/jsconfig.json +10 -0
  34. package/languages/go.js +21 -2
  35. package/languages/html.js +8 -0
  36. package/languages/index.js +102 -40
  37. package/languages/java.js +13 -0
  38. package/languages/javascript.js +17 -1
  39. package/languages/python.js +14 -0
  40. package/languages/rust.js +13 -0
  41. package/languages/utils.js +1 -1
  42. package/mcp/server.js +45 -28
  43. package/package.json +8 -3
package/core/search.js ADDED
@@ -0,0 +1,890 @@
1
+ /**
2
+ * core/search.js — Symbol search, text search, usages, example, typedef, tests
3
+ *
4
+ * Extracted from project.js. All functions take an `index` (ProjectIndex)
5
+ * as the first argument instead of using `this`.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const path = require('path');
11
+ const { escapeRegExp } = require('./shared');
12
+ const { isTestFile } = require('./discovery');
13
+ const { detectLanguage, getParser, langTraits } = require('../languages');
14
+
15
+ /**
16
+ * Build a glob-style matcher: * matches any sequence, ? matches one char.
17
+ * Case-insensitive by default. Returns a function (string) => boolean.
18
+ */
19
+ function buildGlobMatcher(pattern, caseSensitive) {
20
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
21
+ .replace(/\*/g, '.*')
22
+ .replace(/\?/g, '.');
23
+ const regex = new RegExp('^' + escaped + '$', caseSensitive ? '' : 'i');
24
+ return (name) => regex.test(name);
25
+ }
26
+
27
+ const STRUCTURAL_TYPES = new Set(['function', 'class', 'call', 'method', 'type']);
28
+
29
+ /**
30
+ * Substring match. Case-insensitive by default.
31
+ */
32
+ function matchesSubstring(text, pattern, caseSensitive) {
33
+ if (!text) return false;
34
+ if (caseSensitive) return text.includes(pattern);
35
+ return text.toLowerCase().includes(pattern.toLowerCase());
36
+ }
37
+
38
+ /**
39
+ * Find symbols by name with fuzzy/glob matching.
40
+ *
41
+ * @param {object} index - ProjectIndex instance
42
+ * @param {string} name - Symbol name (supports glob patterns)
43
+ * @param {object} options - { exact, file, className, exclude, in, skipCounts }
44
+ * @returns {Array} Matching symbols with usage counts
45
+ */
46
+ function find(index, name, options = {}) {
47
+ index._beginOp();
48
+ try {
49
+ // Glob pattern matching (e.g., _update*, handle*Request, get?ata)
50
+ const isGlob = name.includes('*') || name.includes('?');
51
+ if (isGlob && !options.exact) {
52
+ // Bare wildcard: return all symbols
53
+ const stripped = name.replace(/[*?]/g, '');
54
+ if (stripped.length === 0) {
55
+ const all = [];
56
+ for (const [, symbols] of index.symbols) {
57
+ for (const sym of symbols) {
58
+ all.push({ ...sym, _fuzzyScore: 800 });
59
+ }
60
+ }
61
+ all.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
62
+ return _applyFindFilters(index, all, options);
63
+ }
64
+ const globRegex = new RegExp('^' + name.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
65
+ const matches = [];
66
+ for (const [symName, symbols] of index.symbols) {
67
+ if (globRegex.test(symName)) {
68
+ for (const sym of symbols) {
69
+ matches.push({ ...sym, _fuzzyScore: 800 });
70
+ }
71
+ }
72
+ }
73
+ matches.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
74
+ return _applyFindFilters(index, matches, options);
75
+ }
76
+
77
+ const matches = index.symbols.get(name) || [];
78
+
79
+ if (matches.length === 0 && !options.exact) {
80
+ // Smart fuzzy search with scoring
81
+ const candidates = [];
82
+ for (const [symName, symbols] of index.symbols) {
83
+ const score = index.fuzzyScore(name, symName);
84
+ if (score > 0) {
85
+ for (const sym of symbols) {
86
+ candidates.push({ ...sym, _fuzzyScore: score });
87
+ }
88
+ }
89
+ }
90
+ // Sort by fuzzy score descending
91
+ candidates.sort((a, b) => b._fuzzyScore - a._fuzzyScore);
92
+ matches.push(...candidates);
93
+ }
94
+
95
+ return _applyFindFilters(index, matches, options);
96
+ } finally { index._endOp(); }
97
+ }
98
+
99
+ /**
100
+ * Apply file/exclude/in filters and usage counts to find results
101
+ *
102
+ * @param {object} index - ProjectIndex instance
103
+ * @param {Array} matches - Raw symbol matches
104
+ * @param {object} options - { className, file, exclude, in, skipCounts }
105
+ * @returns {Array} Filtered and sorted results
106
+ */
107
+ function _applyFindFilters(index, matches, options) {
108
+ let filtered = matches;
109
+
110
+ // Filter by class name (Class.method syntax)
111
+ if (options.className) {
112
+ filtered = filtered.filter(m => m.className === options.className);
113
+ }
114
+
115
+ // Filter by file pattern
116
+ if (options.file) {
117
+ filtered = filtered.filter(m =>
118
+ m.relativePath && m.relativePath.includes(options.file)
119
+ );
120
+ }
121
+
122
+ // Apply semantic filters (--exclude, --in)
123
+ if (options.exclude || options.in) {
124
+ filtered = filtered.filter(m =>
125
+ index.matchesFilters(m.relativePath, { exclude: options.exclude, in: options.in })
126
+ );
127
+ }
128
+
129
+ // Skip expensive usage counting when caller doesn't need it
130
+ if (options.skipCounts) {
131
+ return filtered;
132
+ }
133
+
134
+ // Add per-symbol usage counts for disambiguation
135
+ const withCounts = filtered.map(m => {
136
+ const counts = index.countSymbolUsages(m);
137
+ return {
138
+ ...m,
139
+ usageCount: counts.total,
140
+ usageCounts: counts // { total, calls, definitions, imports, references }
141
+ };
142
+ });
143
+
144
+ // Sort by usage count (most-used first)
145
+ withCounts.sort((a, b) => b.usageCount - a.usageCount);
146
+
147
+ return withCounts;
148
+ }
149
+
150
+ /**
151
+ * Find all usages of a symbol grouped by type
152
+ *
153
+ * @param {object} index - ProjectIndex instance
154
+ * @param {string} name - Symbol name
155
+ * @param {object} options - { codeOnly, context, exclude, in, file, className }
156
+ * @returns {Array} Usages grouped as definitions, calls, imports, references
157
+ */
158
+ function usages(index, name, options = {}) {
159
+ index._beginOp();
160
+ try {
161
+ const usagesList = [];
162
+
163
+ // Resolve file pattern for --file filter
164
+ const fileFilter = options.file ? index.resolveFilePathForQuery(options.file) : null;
165
+
166
+ // Get definitions (filtered)
167
+ let allDefinitions = index.symbols.get(name) || [];
168
+ if (options.className) {
169
+ allDefinitions = allDefinitions.filter(d => d.className === options.className);
170
+ }
171
+ if (fileFilter) {
172
+ allDefinitions = allDefinitions.filter(d => d.file === fileFilter);
173
+ }
174
+ const definitions = options.exclude || options.in
175
+ ? allDefinitions.filter(d => index.matchesFilters(d.relativePath, options))
176
+ : allDefinitions;
177
+
178
+ for (const def of definitions) {
179
+ usagesList.push({
180
+ ...def,
181
+ isDefinition: true,
182
+ line: def.startLine,
183
+ content: index.getLineContent(def.file, def.startLine),
184
+ signature: index.formatSignature(def)
185
+ });
186
+ }
187
+
188
+ // Scan all files for usages
189
+ for (const [filePath, fileEntry] of index.files) {
190
+ // Apply --file filter
191
+ if (fileFilter && filePath !== fileFilter) {
192
+ continue;
193
+ }
194
+ // Apply filters
195
+ if (!index.matchesFilters(fileEntry.relativePath, options)) {
196
+ continue;
197
+ }
198
+
199
+ try {
200
+ const content = index._readFile(filePath);
201
+
202
+ // Fast pre-check: skip if name doesn't appear in file at all
203
+ if (!content.includes(name)) continue;
204
+
205
+ const lines = content.split('\n');
206
+
207
+ // Try AST-based detection first (with per-operation cache)
208
+ const astUsages = index._getCachedUsages(filePath, name);
209
+ if (astUsages !== null) {
210
+ // Pre-compute: does any imported project file define this name?
211
+ // Used to filter namespace member expressions (e.g., DropdownMenuPrimitive.Separator)
212
+ // while keeping module access patterns (e.g., output.formatExample())
213
+ let _importedHasDef = null;
214
+ const importedFileHasDef = () => {
215
+ if (_importedHasDef !== null) return _importedHasDef;
216
+ const importedFiles = index.importGraph.get(filePath) || [];
217
+ _importedHasDef = importedFiles.some(imp => {
218
+ const impEntry = index.files.get(imp);
219
+ return impEntry?.symbols?.some(s => s.name === name);
220
+ });
221
+ return _importedHasDef;
222
+ };
223
+
224
+ for (const u of astUsages) {
225
+ // Skip if this is a definition line (already added above)
226
+ if (definitions.some(d => d.file === filePath && d.startLine === u.line)) {
227
+ continue;
228
+ }
229
+
230
+ // Filter member expressions with unrelated receivers in JS/TS/Python.
231
+ // Keeps: standalone usages, self/this/cls/super, method calls on known types,
232
+ // and module access (output.fn()) when the imported file defines the name.
233
+ // Filters: namespace access to external packages (DropdownMenuPrimitive.Separator).
234
+ if (u.receiver && !['self', 'this', 'cls', 'super'].includes(u.receiver) &&
235
+ fileEntry.language !== 'go' && fileEntry.language !== 'java' && fileEntry.language !== 'rust') {
236
+ const hasMethodDef = definitions.some(d => d.className);
237
+ if (!hasMethodDef && !importedFileHasDef()) {
238
+ continue;
239
+ }
240
+ }
241
+
242
+ const lineContent = lines[u.line - 1] || '';
243
+
244
+ const usage = {
245
+ file: filePath,
246
+ relativePath: fileEntry.relativePath,
247
+ line: u.line,
248
+ content: lineContent,
249
+ usageType: u.usageType,
250
+ isDefinition: false,
251
+ ...(u.receiver && { receiver: u.receiver })
252
+ };
253
+
254
+ // Add context lines if requested
255
+ if (options.context && options.context > 0) {
256
+ const idx = u.line - 1;
257
+ const before = [];
258
+ const after = [];
259
+ for (let i = 1; i <= options.context; i++) {
260
+ if (idx - i >= 0) before.unshift(lines[idx - i]);
261
+ if (idx + i < lines.length) after.push(lines[idx + i]);
262
+ }
263
+ usage.before = before;
264
+ usage.after = after;
265
+ }
266
+
267
+ usagesList.push(usage);
268
+ }
269
+ continue; // Skip to next file
270
+ }
271
+
272
+ // Fallback to regex-based detection
273
+ const regex = new RegExp('\\b' + escapeRegExp(name) + '\\b');
274
+ lines.forEach((line, idx) => {
275
+ const lineNum = idx + 1;
276
+
277
+ // Skip if this is a definition line
278
+ if (definitions.some(d => d.file === filePath && d.startLine === lineNum)) {
279
+ return;
280
+ }
281
+
282
+ if (regex.test(line)) {
283
+ // Skip if codeOnly and line is comment/string
284
+ if (options.codeOnly && index.isCommentOrStringAtPosition(content, lineNum, 0, filePath)) {
285
+ return;
286
+ }
287
+
288
+ // Skip if the match is inside a string literal
289
+ if (index.isInsideStringAST(content, lineNum, line, name, filePath)) {
290
+ return;
291
+ }
292
+
293
+ // Classify usage type (AST-based, defaults to 'reference' for unsupported languages)
294
+ const usageType = index.classifyUsageAST(content, lineNum, name, filePath) ?? 'reference';
295
+
296
+ const usage = {
297
+ file: filePath,
298
+ relativePath: fileEntry.relativePath,
299
+ line: lineNum,
300
+ content: line,
301
+ usageType,
302
+ isDefinition: false
303
+ };
304
+
305
+ // Add context lines if requested
306
+ if (options.context && options.context > 0) {
307
+ const before = [];
308
+ const after = [];
309
+ for (let i = 1; i <= options.context; i++) {
310
+ if (idx - i >= 0) before.unshift(lines[idx - i]);
311
+ if (idx + i < lines.length) after.push(lines[idx + i]);
312
+ }
313
+ usage.before = before;
314
+ usage.after = after;
315
+ }
316
+
317
+ usagesList.push(usage);
318
+ }
319
+ });
320
+ } catch (e) {
321
+ // Skip unreadable files
322
+ }
323
+ }
324
+
325
+ // Deduplicate same-file, same-line, same-usageType entries
326
+ // (e.g., `detectLanguage: parser.detectLanguage` has the name twice on one line)
327
+ const seen = new Set();
328
+ const deduped = [];
329
+ for (const u of usagesList) {
330
+ const key = `${u.file}:${u.line}:${u.usageType}:${u.isDefinition}`;
331
+ if (!seen.has(key)) {
332
+ seen.add(key);
333
+ deduped.push(u);
334
+ }
335
+ }
336
+ return deduped;
337
+ } finally { index._endOp(); }
338
+ }
339
+
340
+ /**
341
+ * Text/regex search across all project files
342
+ *
343
+ * @param {object} index - ProjectIndex instance
344
+ * @param {string} term - Search term (string or regex)
345
+ * @param {object} options - { caseSensitive, regex, codeOnly, file, exclude, in, context, top }
346
+ * @returns {Array} Search results with meta
347
+ */
348
+ function search(index, term, options = {}) {
349
+ index._beginOp();
350
+ try {
351
+ const results = [];
352
+ let filesScanned = 0;
353
+ let filesSkipped = 0;
354
+ let filesFilteredByFlag = 0;
355
+ const regexFlags = options.caseSensitive ? 'g' : 'gi';
356
+ const useRegex = options.regex !== false; // Default: regex ON
357
+ let regex;
358
+ let regexFallback = false;
359
+ if (useRegex) {
360
+ try {
361
+ regex = new RegExp(term, regexFlags);
362
+ } catch (e) {
363
+ // Invalid regex — fall back to plain text
364
+ regex = new RegExp(escapeRegExp(term), regexFlags);
365
+ regexFallback = e.message;
366
+ }
367
+ } else {
368
+ regex = new RegExp(escapeRegExp(term), regexFlags);
369
+ }
370
+
371
+ for (const [filePath, fileEntry] of index.files) {
372
+ // Apply --file filter
373
+ if (options.file) {
374
+ const fp = fileEntry.relativePath;
375
+ if (!fp.includes(options.file) && !fp.endsWith(options.file)) {
376
+ filesFilteredByFlag++;
377
+ continue;
378
+ }
379
+ }
380
+ // Apply exclude/in filters
381
+ if ((options.exclude && options.exclude.length > 0) || options.in) {
382
+ if (!index.matchesFilters(fileEntry.relativePath, { exclude: options.exclude, in: options.in })) {
383
+ filesSkipped++;
384
+ continue;
385
+ }
386
+ }
387
+ filesScanned++;
388
+ try {
389
+ const content = index._readFile(filePath);
390
+ const lines = content.split('\n');
391
+ const matches = [];
392
+
393
+ // Use AST-based filtering for codeOnly mode when language is supported
394
+ if (options.codeOnly) {
395
+ const language = detectLanguage(filePath);
396
+ if (language) {
397
+ try {
398
+ const parser = getParser(language);
399
+ const { findMatchesWithASTFilter } = require('../languages/utils');
400
+ const astMatches = findMatchesWithASTFilter(content, term, parser, { codeOnly: true, regex: useRegex });
401
+
402
+ for (const m of astMatches) {
403
+ const match = {
404
+ line: m.line,
405
+ content: m.content
406
+ };
407
+
408
+ // Add context lines if requested
409
+ if (options.context && options.context > 0) {
410
+ const idx = m.line - 1;
411
+ const before = [];
412
+ const after = [];
413
+ for (let i = 1; i <= options.context; i++) {
414
+ if (idx - i >= 0) before.unshift(lines[idx - i]);
415
+ if (idx + i < lines.length) after.push(lines[idx + i]);
416
+ }
417
+ match.before = before;
418
+ match.after = after;
419
+ }
420
+
421
+ matches.push(match);
422
+ }
423
+
424
+ if (matches.length > 0) {
425
+ results.push({
426
+ file: fileEntry.relativePath,
427
+ matches
428
+ });
429
+ }
430
+ continue; // Skip to next file
431
+ } catch (e) {
432
+ // Fall through to regex-based search
433
+ }
434
+ }
435
+ }
436
+
437
+ // Fallback to regex-based search (non-codeOnly or unsupported language)
438
+ lines.forEach((line, idx) => {
439
+ regex.lastIndex = 0; // Reset regex state
440
+ if (regex.test(line)) {
441
+ const lineNum = idx + 1;
442
+ // Skip if codeOnly and line is comment/string
443
+ if (options.codeOnly && index.isCommentOrStringAtPosition(content, lineNum, 0, filePath)) {
444
+ return;
445
+ }
446
+
447
+ const match = {
448
+ line: idx + 1,
449
+ content: line
450
+ };
451
+
452
+ // Add context lines if requested
453
+ if (options.context && options.context > 0) {
454
+ const before = [];
455
+ const after = [];
456
+ for (let i = 1; i <= options.context; i++) {
457
+ if (idx - i >= 0) before.unshift(lines[idx - i]);
458
+ if (idx + i < lines.length) after.push(lines[idx + i]);
459
+ }
460
+ match.before = before;
461
+ match.after = after;
462
+ }
463
+
464
+ matches.push(match);
465
+ }
466
+ });
467
+
468
+ if (matches.length > 0) {
469
+ results.push({
470
+ file: fileEntry.relativePath,
471
+ matches
472
+ });
473
+ }
474
+ } catch (e) {
475
+ // Expected: binary/minified files fail to read or parse.
476
+ // These are not actionable errors — silently skip.
477
+ }
478
+ }
479
+
480
+ // Apply top limit (limits total matches across all files)
481
+ const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0);
482
+ let truncatedMatches = 0;
483
+ if (options.top && options.top > 0 && totalMatches > options.top) {
484
+ let remaining = options.top;
485
+ const truncated = [];
486
+ for (const r of results) {
487
+ if (remaining <= 0) break;
488
+ if (r.matches.length <= remaining) {
489
+ truncated.push(r);
490
+ remaining -= r.matches.length;
491
+ } else {
492
+ truncated.push({ ...r, matches: r.matches.slice(0, remaining) });
493
+ remaining = 0;
494
+ }
495
+ }
496
+ truncatedMatches = totalMatches - options.top;
497
+ results.length = 0;
498
+ results.push(...truncated);
499
+ }
500
+
501
+ results.meta = { filesScanned, filesSkipped, filesFilteredByFlag, totalFiles: index.files.size, regexFallback, totalMatches, truncatedMatches, projectLanguage: index._getPredominantLanguage() };
502
+ return results;
503
+ } finally { index._endOp(); }
504
+ }
505
+
506
+ /**
507
+ * Structural search — query the symbol table and call index, not raw text.
508
+ * Answers questions like "functions taking Request param", "all db.* calls",
509
+ * "exported async functions", "decorated route handlers".
510
+ *
511
+ * @param {object} index - ProjectIndex instance
512
+ * @param {object} options
513
+ * @param {string} [options.term] - Name filter (glob: * and ? supported)
514
+ * @param {string} [options.type] - Symbol kind: function, class, call, method, type
515
+ * @param {string} [options.param] - Parameter name or type substring
516
+ * @param {string} [options.receiver] - Call receiver pattern (for type=call)
517
+ * @param {string} [options.returns] - Return type substring
518
+ * @param {string} [options.decorator] - Decorator/annotation name substring
519
+ * @param {boolean} [options.exported] - Only exported symbols
520
+ * @param {boolean} [options.unused] - Only symbols with zero callers
521
+ * @param {string[]} [options.exclude] - Exclude file patterns
522
+ * @param {string} [options.in] - Restrict to subdirectory
523
+ * @param {string} [options.file] - File pattern filter
524
+ * @param {number} [options.top] - Limit results
525
+ * @returns {{ results: Array, meta: object }}
526
+ */
527
+ function structuralSearch(index, options = {}) {
528
+ index._beginOp();
529
+ try {
530
+ const { term, param, receiver, returns: returnType, decorator, exported, unused } = options;
531
+ // Auto-infer type: --receiver implies type=call
532
+ const type = options.type || (receiver ? 'call' : undefined);
533
+ const results = [];
534
+
535
+ // Validate type if provided
536
+ if (type && !STRUCTURAL_TYPES.has(type)) {
537
+ return {
538
+ results: [],
539
+ meta: {
540
+ mode: 'structural',
541
+ query: { type },
542
+ totalMatched: 0,
543
+ shown: 0,
544
+ error: `Invalid type "${type}". Valid types: ${[...STRUCTURAL_TYPES].join(', ')}`,
545
+ }
546
+ };
547
+ }
548
+
549
+ // Build glob-style name matcher from term
550
+ const nameMatcher = term ? buildGlobMatcher(term, options.caseSensitive) : null;
551
+
552
+ // Helper: check if file passes filters
553
+ const passesFileFilter = (fileEntry) => {
554
+ if (!fileEntry) return false;
555
+ if (options.file) {
556
+ const rp = fileEntry.relativePath;
557
+ if (!rp.includes(options.file) && !rp.endsWith(options.file)) return false;
558
+ }
559
+ if ((options.exclude && options.exclude.length > 0) || options.in) {
560
+ if (!index.matchesFilters(fileEntry.relativePath, { exclude: options.exclude, in: options.in })) return false;
561
+ }
562
+ return true;
563
+ };
564
+
565
+ if (type === 'call') {
566
+ // Search call sites from callee index
567
+ const { getCachedCalls } = require('./callers');
568
+ const seenFiles = new Set();
569
+
570
+ // If term is given, only scan files that might contain that call
571
+ if (term && !term.includes('*') && !term.includes('?')) {
572
+ // Exact or substring — use callee index for fast lookup
573
+ index.buildCalleeIndex();
574
+ const files = index.calleeIndex.get(term);
575
+ if (files) for (const f of files) seenFiles.add(f);
576
+ } else {
577
+ // Scan all files
578
+ for (const fp of index.files.keys()) seenFiles.add(fp);
579
+ }
580
+
581
+ for (const filePath of seenFiles) {
582
+ const fileEntry = index.files.get(filePath);
583
+ if (!passesFileFilter(fileEntry)) continue;
584
+ const calls = getCachedCalls(index, filePath);
585
+ if (!calls) continue;
586
+ for (const call of calls) {
587
+ if (nameMatcher && !nameMatcher(call.name)) continue;
588
+ if (receiver) {
589
+ if (!call.receiver) continue;
590
+ if (!matchesSubstring(call.receiver, receiver, options.caseSensitive)) continue;
591
+ }
592
+ results.push({
593
+ kind: 'call',
594
+ name: call.receiver ? `${call.receiver}.${call.name}` : call.name,
595
+ file: fileEntry.relativePath,
596
+ line: call.line,
597
+ receiver: call.receiver || null,
598
+ isMethod: call.isMethod || false,
599
+ });
600
+ }
601
+ }
602
+ } else {
603
+ // Search symbols (functions, classes, methods, types)
604
+ const functionTypes = new Set(['function', 'constructor', 'method', 'arrow', 'static', 'classmethod', 'abstract']);
605
+ const classTypes = new Set(['class', 'struct', 'interface', 'impl', 'trait']);
606
+ const typeTypes = new Set(['type', 'enum', 'interface', 'trait']);
607
+ const methodTypes = new Set(['method', 'constructor']);
608
+
609
+ for (const [symbolName, definitions] of index.symbols) {
610
+ if (nameMatcher && !nameMatcher(symbolName)) continue;
611
+
612
+ for (const def of definitions) {
613
+ // Type filter
614
+ if (type === 'function' && !functionTypes.has(def.type)) continue;
615
+ if (type === 'class' && !classTypes.has(def.type)) continue;
616
+ if (type === 'method' && !methodTypes.has(def.type) && !def.isMethod) continue;
617
+ if (type === 'type' && !typeTypes.has(def.type)) continue;
618
+
619
+ // File filters
620
+ const fileEntry = index.files.get(def.file);
621
+ if (!passesFileFilter(fileEntry)) continue;
622
+
623
+ // Param filter: match param name or type
624
+ if (param) {
625
+ const cs = options.caseSensitive;
626
+ const ps = def.paramsStructured || [];
627
+ const paramStr = def.params || '';
628
+ const hasMatch = ps.some(p =>
629
+ matchesSubstring(p.name, param, cs) ||
630
+ (p.type && matchesSubstring(p.type, param, cs))
631
+ ) || matchesSubstring(paramStr, param, cs);
632
+ if (!hasMatch) continue;
633
+ }
634
+
635
+ // Receiver filter: match className for methods
636
+ if (receiver) {
637
+ if (!def.className || !matchesSubstring(def.className, receiver, options.caseSensitive)) continue;
638
+ }
639
+
640
+ // Return type filter
641
+ if (returnType) {
642
+ if (!def.returnType || !matchesSubstring(def.returnType, returnType, options.caseSensitive)) continue;
643
+ }
644
+
645
+ // Decorator filter: checks decorators (Python), modifiers (Java annotations stored lowercase)
646
+ if (decorator) {
647
+ const cs = options.caseSensitive;
648
+ const hasDecorator = (def.decorators && def.decorators.some(d => matchesSubstring(d, decorator, cs))) ||
649
+ (def.modifiers && def.modifiers.some(m => matchesSubstring(m, decorator, cs)));
650
+ if (!hasDecorator) continue;
651
+ }
652
+
653
+ // Exported filter
654
+ if (exported) {
655
+ const mods = def.modifiers || [];
656
+ const isExp = (fileEntry && fileEntry.exports.includes(symbolName)) ||
657
+ mods.includes('export') || mods.includes('public') ||
658
+ mods.some(m => m.startsWith('pub')) ||
659
+ (fileEntry && langTraits(fileEntry.language)?.exportVisibility === 'capitalization' && /^[A-Z]/.test(symbolName));
660
+ if (!isExp) continue;
661
+ }
662
+
663
+ // Unused filter (expensive — last check)
664
+ if (unused) {
665
+ index.buildCalleeIndex();
666
+ if (index.calleeIndex.has(symbolName)) continue;
667
+ }
668
+
669
+ // Merge decorators from both Python-style decorators and Java-style modifiers
670
+ const allDecorators = def.decorators || null;
671
+
672
+ results.push({
673
+ kind: def.type,
674
+ name: symbolName,
675
+ file: def.relativePath,
676
+ line: def.startLine,
677
+ params: def.params || null,
678
+ returnType: def.returnType || null,
679
+ decorators: allDecorators,
680
+ className: def.className || null,
681
+ exported: exported ? true : undefined,
682
+ });
683
+ }
684
+ }
685
+ }
686
+
687
+ // Sort by file, then line
688
+ results.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
689
+
690
+ // Apply top limit
691
+ const total = results.length;
692
+ const top = options.top;
693
+ if (top && top > 0 && results.length > top) {
694
+ results.length = top;
695
+ }
696
+
697
+ return {
698
+ results,
699
+ meta: {
700
+ mode: 'structural',
701
+ query: Object.fromEntries(Object.entries({
702
+ type: type || 'any', term, param, receiver, returns: returnType,
703
+ decorator, exported: exported || undefined, unused: unused || undefined,
704
+ }).filter(([, v]) => v !== undefined && v !== null)),
705
+ totalMatched: total,
706
+ shown: results.length,
707
+ }
708
+ };
709
+ } finally { index._endOp(); }
710
+ }
711
+
712
+ /**
713
+ * Find the best usage example of a function
714
+ *
715
+ * @param {object} index - ProjectIndex instance
716
+ * @param {string} name - Function name
717
+ * @param {object} options - { className }
718
+ * @returns {object|null} Best example with score
719
+ */
720
+ function example(index, name, options = {}) {
721
+ index._beginOp();
722
+ try {
723
+ const usageResults = usages(index, name, {
724
+ codeOnly: true,
725
+ className: options.className,
726
+ exclude: ['test', 'spec', '__tests__', '__mocks__', 'fixture', 'mock'],
727
+ context: 5
728
+ });
729
+
730
+ const calls = usageResults.filter(u => u.usageType === 'call' && !u.isDefinition);
731
+ if (calls.length === 0) return null;
732
+
733
+ const scored = calls.map(call => {
734
+ let score = 0;
735
+ const reasons = [];
736
+ const line = call.content.trim();
737
+
738
+ const astInfo = index._analyzeCallSiteAST(call.file, call.line, name);
739
+
740
+ if (astInfo.isTypedAssignment) { score += 15; reasons.push('typed assignment'); }
741
+ if (astInfo.isInReturn) { score += 10; reasons.push('in return'); }
742
+ if (astInfo.isAwait) { score += 10; reasons.push('async usage'); }
743
+ if (astInfo.isDestructured) { score += 8; reasons.push('destructured'); }
744
+ if (astInfo.isStandalone) { score += 5; reasons.push('standalone'); }
745
+ if (astInfo.hasComment) { score += 3; reasons.push('documented'); }
746
+ if (astInfo.isInCatch) { score -= 5; reasons.push('in catch block'); }
747
+ if (astInfo.isInConditional) { score -= 3; reasons.push('in conditional'); }
748
+
749
+ if (score === 0) {
750
+ if (/^(const|let|var|return)\s/.test(line) || /^\w+\s*=/.test(line)) {
751
+ score += 10; reasons.push('return value used');
752
+ }
753
+ if (line.startsWith(name + '(') || /^(const|let|var)\s+\w+\s*=\s*\w*$/.test(line.split(name)[0])) {
754
+ score += 5; reasons.push('clear usage');
755
+ }
756
+ }
757
+
758
+ if (call.before && call.before.length > 0) score += 3;
759
+ if (call.after && call.after.length > 0) score += 3;
760
+ if (call.before?.length > 0 && call.after?.length > 0) reasons.push('has context');
761
+
762
+ const beforeCall = line.split(name + '(')[0];
763
+ if (!beforeCall.includes('(') || /^\s*(const|let|var|return)?\s*\w+\s*=\s*$/.test(beforeCall)) {
764
+ score += 2;
765
+ }
766
+ if (call.line < 100) score += 1;
767
+
768
+ return { ...call, score, reasons };
769
+ });
770
+
771
+ scored.sort((a, b) => b.score - a.score);
772
+ return { best: scored[0], totalCalls: calls.length };
773
+ } finally { index._endOp(); }
774
+ }
775
+
776
+ /**
777
+ * Find type definitions
778
+ *
779
+ * @param {object} index - ProjectIndex instance
780
+ * @param {string} name - Type name to find
781
+ * @param {object} options - Find options
782
+ * @returns {Array} Matching type definitions
783
+ */
784
+ function typedef(index, name, options = {}) {
785
+ const typeKinds = ['type', 'interface', 'enum', 'struct', 'trait', 'class', 'record'];
786
+ const matches = find(index, name, options);
787
+
788
+ return matches.filter(m => typeKinds.includes(m.type)).map(m => ({
789
+ ...m,
790
+ code: index.extractCode(m)
791
+ }));
792
+ }
793
+
794
+ /**
795
+ * Find tests for a function or file
796
+ *
797
+ * @param {object} index - ProjectIndex instance
798
+ * @param {string} nameOrFile - Function name or file path
799
+ * @param {object} options - { callsOnly }
800
+ * @returns {Array} Test files and matches
801
+ */
802
+ function tests(index, nameOrFile, options = {}) {
803
+ index._beginOp();
804
+ try {
805
+ const results = [];
806
+
807
+ // Check if it's a file path
808
+ const isFilePath = nameOrFile.includes('/') || nameOrFile.includes('\\') ||
809
+ nameOrFile.endsWith('.js') || nameOrFile.endsWith('.ts') ||
810
+ nameOrFile.endsWith('.py') || nameOrFile.endsWith('.go') ||
811
+ nameOrFile.endsWith('.java') || nameOrFile.endsWith('.rs');
812
+
813
+ // Find all test files
814
+ const testFiles = [];
815
+ for (const [filePath, fileEntry] of index.files) {
816
+ if (isTestFile(fileEntry.relativePath, fileEntry.language)) {
817
+ testFiles.push({ path: filePath, entry: fileEntry });
818
+ } else if (fileEntry.language === 'rust') {
819
+ // Rust idiomatically puts tests in #[cfg(test)] modules inside source files.
820
+ // Check if file has any symbols with 'test' modifier (#[test] attribute).
821
+ const hasInlineTests = fileEntry.symbols?.some(s =>
822
+ s.modifiers?.includes('test')
823
+ );
824
+ if (hasInlineTests) {
825
+ testFiles.push({ path: filePath, entry: fileEntry });
826
+ }
827
+ }
828
+ }
829
+
830
+ const searchTerm = isFilePath
831
+ ? path.basename(nameOrFile, path.extname(nameOrFile))
832
+ : nameOrFile;
833
+
834
+ // Note: no 'g' flag - we only need to test for presence per line
835
+ // The 'i' flag is kept for case-insensitive matching
836
+ const regex = new RegExp('\\b' + escapeRegExp(searchTerm) + '\\b', 'i');
837
+ // Pre-compile patterns used inside per-line loop
838
+ const callPattern = new RegExp(escapeRegExp(searchTerm) + '\\s*\\(');
839
+ const strPattern = new RegExp("['\"`]" + escapeRegExp(searchTerm) + "['\"`]");
840
+
841
+ for (const { path: testPath, entry } of testFiles) {
842
+ try {
843
+ const content = index._readFile(testPath);
844
+ const lines = content.split('\n');
845
+ const matches = [];
846
+
847
+ lines.forEach((line, idx) => {
848
+ if (regex.test(line)) {
849
+ let matchType = 'reference';
850
+ if (/\b(describe|it|test|spec)\s*\(/.test(line)) {
851
+ matchType = 'test-case';
852
+ } else if (/\b(import|require|from)\b/.test(line)) {
853
+ matchType = 'import';
854
+ } else if (callPattern.test(line)) {
855
+ matchType = 'call';
856
+ }
857
+ // Detect if the match is inside a string literal (e.g., 'parseFile' or "parseFile")
858
+ if (matchType === 'reference' || matchType === 'call') {
859
+ if (strPattern.test(line)) {
860
+ matchType = 'string-ref';
861
+ }
862
+ }
863
+
864
+ matches.push({
865
+ line: idx + 1,
866
+ content: line.trim(),
867
+ matchType
868
+ });
869
+ }
870
+ });
871
+
872
+ const filtered = options.callsOnly
873
+ ? matches.filter(m => m.matchType === 'call' || m.matchType === 'test-case')
874
+ : matches;
875
+ if (filtered.length > 0) {
876
+ results.push({
877
+ file: entry.relativePath,
878
+ matches: filtered
879
+ });
880
+ }
881
+ } catch (e) {
882
+ // Skip unreadable files
883
+ }
884
+ }
885
+
886
+ return results;
887
+ } finally { index._endOp(); }
888
+ }
889
+
890
+ module.exports = { find, _applyFindFilters, usages, search, structuralSearch, example, typedef, tests };