ucn 4.2.3 → 5.0.2

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 (72) hide show
  1. package/.claude/skills/ucn/SKILL.md +89 -77
  2. package/.claude/skills/ucn/references/commands.md +62 -68
  3. package/.claude/skills/ucn/references/trust-contract.md +31 -6
  4. package/README.md +438 -305
  5. package/assets/demo.svg +31 -0
  6. package/cli/index.js +430 -1385
  7. package/core/account.js +144 -34
  8. package/core/analysis.js +182 -72
  9. package/core/ast-analysis.js +279 -0
  10. package/core/bridge.js +205 -24
  11. package/core/brief.js +27 -58
  12. package/core/build-worker.js +21 -140
  13. package/core/cache.js +513 -11
  14. package/core/callers.js +4920 -456
  15. package/core/check.js +13 -4
  16. package/core/command-contracts.js +402 -0
  17. package/core/compilation-database.js +276 -0
  18. package/core/confidence.js +4 -1
  19. package/core/deadcode.js +397 -19
  20. package/core/discovery.js +359 -46
  21. package/core/entrypoints.js +195 -41
  22. package/core/execute.js +887 -81
  23. package/core/graph-build.js +162 -7
  24. package/core/graph.js +53 -77
  25. package/core/imports.js +65 -6
  26. package/core/index-ir.js +138 -0
  27. package/core/ir.js +195 -0
  28. package/core/output/analysis.js +212 -22
  29. package/core/output/brief.js +23 -0
  30. package/core/output/check.js +4 -0
  31. package/core/output/doctor.js +37 -6
  32. package/core/output/endpoints.js +5 -2
  33. package/core/output/extraction.js +24 -12
  34. package/core/output/find.js +141 -36
  35. package/core/output/graph.js +11 -5
  36. package/core/output/public.js +462 -0
  37. package/core/output/refactoring.js +42 -10
  38. package/core/output/reporting.js +97 -20
  39. package/core/output/search.js +24 -16
  40. package/core/output/shared.js +22 -1
  41. package/core/output/tracing.js +30 -15
  42. package/core/output-budget.js +295 -0
  43. package/core/output.js +1 -0
  44. package/core/parallel-build.js +44 -11
  45. package/core/parser.js +3 -3
  46. package/core/project.js +384 -187
  47. package/core/public-command.js +47 -0
  48. package/core/registry.js +247 -117
  49. package/core/reporting.js +312 -290
  50. package/core/search.js +317 -185
  51. package/core/semantic-provider.js +110 -0
  52. package/core/stacktrace.js +25 -0
  53. package/core/tracing.js +101 -51
  54. package/core/trust-matrix.js +19 -40
  55. package/core/verify.js +534 -37
  56. package/languages/adapter.js +218 -0
  57. package/languages/c-family.js +2791 -0
  58. package/languages/c.js +3 -0
  59. package/languages/cpp.js +3 -0
  60. package/languages/csharp.js +1402 -0
  61. package/languages/go.js +60 -21
  62. package/languages/html.js +2 -2
  63. package/languages/index.js +85 -7
  64. package/languages/java.js +396 -13
  65. package/languages/javascript.js +199 -19
  66. package/languages/python.js +964 -22
  67. package/languages/rust.js +1317 -152
  68. package/languages/utils.js +40 -3
  69. package/mcp/server.js +254 -636
  70. package/package.json +39 -22
  71. package/eslint.config.js +0 -43
  72. package/jsconfig.json +0 -10
package/core/search.js CHANGED
@@ -10,9 +10,17 @@
10
10
  const path = require('path');
11
11
  const { escapeRegExp, codeUnitCompare, inlineTestRanges, lineInRanges, classDispatchNames, CALLABLE_SYMBOL_KINDS } = require('./shared');
12
12
  const { isTestFile } = require('./discovery');
13
- const { detectLanguage, getParser, getLanguageModule, langTraits } = require('../languages');
14
- const { getCachedCalls, _nameBindingReaches } = require('./callers');
13
+ const { detectLanguage, getParser, getLanguageAdapter, langTraits } = require('../languages');
14
+ const { getCachedCalls } = require('./callers');
15
15
  const { extractImports } = require('./imports');
16
+ const isSafeRegex = require('safe-regex2');
17
+ const { RE2JS } = require('re2js');
18
+
19
+ // Keep the established search-input contract for the canonical catastrophic
20
+ // nested-single-atom family even though the RE2-compatible engine could run it
21
+ // safely. Rejecting these patterns avoids silently changing behavior if an
22
+ // advanced construct later forces the guarded JavaScript fallback.
23
+ const NESTED_SINGLE_ATOM_REPEAT = /\((?:\?:)?(?:\\.|\[[^\]]*\]|[^\\()[\]{}|?+*])(?:[+*]|\{\d+(?:,\d*)?\})\)(?:[+*]|\{\d+(?:,\d*)?\})/;
16
24
 
17
25
  /**
18
26
  * Build a glob-style matcher: * matches any sequence, ? matches one char.
@@ -26,7 +34,11 @@ function buildGlobMatcher(pattern, caseSensitive) {
26
34
  return (name) => regex.test(name);
27
35
  }
28
36
 
29
- const STRUCTURAL_TYPES = new Set(['function', 'class', 'call', 'method', 'type']);
37
+ const STRUCTURAL_TYPES = new Set([
38
+ 'function', 'class', 'call', 'method', 'type', 'constructor',
39
+ 'state', 'field', 'constant', 'macro', 'variable',
40
+ 'interface', 'enum', 'struct', 'trait', 'record', 'namespace',
41
+ ]);
30
42
 
31
43
  /**
32
44
  * Substring match. Case-insensitive by default.
@@ -37,6 +49,61 @@ function matchesSubstring(text, pattern, caseSensitive) {
37
49
  return text.toLowerCase().includes(pattern.toLowerCase());
38
50
  }
39
51
 
52
+ function literalNameRegex(name) {
53
+ return new RegExp(
54
+ `(?<![A-Za-z0-9_$])${escapeRegExp(name)}(?![A-Za-z0-9_$])`,
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Complete the AST-classified usage inventory with literal-name lines that
60
+ * occur only in comments, strings, or docstrings. Do not reintroduce code
61
+ * references that the language adapter intentionally filtered (for example,
62
+ * a Rust enum variant that shares a struct's name).
63
+ * `usages` promises this observed-text complement unless codeOnly=true.
64
+ */
65
+ function appendTextComplements(index, {
66
+ usagesList,
67
+ filePath,
68
+ fileEntry,
69
+ content,
70
+ lines,
71
+ name,
72
+ context,
73
+ }) {
74
+ const represented = new Set(usagesList
75
+ .filter(usage => usage.file === filePath)
76
+ .map(usage => usage.line));
77
+ const pattern = literalNameRegex(name);
78
+ for (let idx = 0; idx < lines.length; idx++) {
79
+ const line = lines[idx];
80
+ const match = pattern.exec(line);
81
+ if (!match || represented.has(idx + 1)) continue;
82
+ const lineNum = idx + 1;
83
+ const commentOrString = index.isCommentOrStringAtPosition(
84
+ content, lineNum, match.index, filePath);
85
+ if (!commentOrString) continue;
86
+ const usage = {
87
+ file: filePath,
88
+ relativePath: fileEntry.relativePath,
89
+ line: lineNum,
90
+ content: line,
91
+ usageType: 'text',
92
+ textKind: 'comment-or-string',
93
+ isDefinition: false,
94
+ };
95
+ if (context > 0) {
96
+ usage.before = [];
97
+ usage.after = [];
98
+ for (let i = 1; i <= context; i++) {
99
+ if (idx - i >= 0) usage.before.unshift(lines[idx - i]);
100
+ if (idx + i < lines.length) usage.after.push(lines[idx + i]);
101
+ }
102
+ }
103
+ usagesList.push(usage);
104
+ }
105
+ }
106
+
40
107
  /**
41
108
  * Find symbols by name with fuzzy/glob matching.
42
109
  *
@@ -116,9 +183,11 @@ function _applyFindFilters(index, matches, options) {
116
183
 
117
184
  // Filter by file pattern
118
185
  if (options.file) {
119
- filtered = filtered.filter(m =>
120
- m.relativePath && m.relativePath.includes(options.file)
121
- );
186
+ const resolved = index.resolveFilePathForQuery(options.file);
187
+ filtered = typeof resolved === 'string'
188
+ ? filtered.filter(m => m.file === resolved)
189
+ : filtered.filter(m =>
190
+ m.relativePath && m.relativePath.includes(options.file));
122
191
  }
123
192
 
124
193
  // Apply semantic filters (--exclude, --in)
@@ -136,10 +205,40 @@ function _applyFindFilters(index, matches, options) {
136
205
  // Add per-symbol usage counts for disambiguation
137
206
  const withCounts = filtered.map(m => {
138
207
  const counts = index.countSymbolUsages(m);
208
+ // The fast count supplies cheap definition/import totals, but its
209
+ // name-only call bucket cannot distinguish json.dumps from a project
210
+ // dumps, or one class's save from another's. The public `find`
211
+ // activity count must use the same pinned caller adjudication as
212
+ // show/impact so command answers never contradict each other.
213
+ const callers = index.findCallers(m.name, {
214
+ targetDefinitions: [m],
215
+ includeTests: true,
216
+ collectAccount: true,
217
+ });
218
+ const confirmedCalls = callers.filter(caller =>
219
+ caller.tier !== 'unverified').length;
220
+ const unverifiedCalls = callers.unverifiedEntries?.length || 0;
221
+ const otherTargetCalls = callers.accountRaw?.excludedEntries?.length || 0;
222
+ // `find` is an inventory/orientation command. Its activity headline
223
+ // must not turn engine abstention into "0 calls", but it also must not
224
+ // attribute calls adjudicated as a different same-name target to this
225
+ // pinned definition. Report confirmed + visible unverified candidates;
226
+ // retain excluded same-name calls as an explicit boundary only.
227
+ const targetCallCandidates = confirmedCalls + unverifiedCalls;
228
+ const exactCounts = {
229
+ ...counts,
230
+ calls: targetCallCandidates,
231
+ confirmedCalls,
232
+ unverifiedCalls,
233
+ otherTargetCalls,
234
+ total: targetCallCandidates + counts.definitions + counts.imports +
235
+ (counts.references || 0),
236
+ countKind: 'target-call-candidates-with-tier-breakdown-excludes-references',
237
+ };
139
238
  return {
140
239
  ...m,
141
- usageCount: counts.total,
142
- usageCounts: counts // { total, calls, definitions, imports, references }
240
+ usageCount: exactCounts.total,
241
+ usageCounts: exactCounts,
143
242
  };
144
243
  });
145
244
 
@@ -162,7 +261,8 @@ function usages(index, name, options = {}) {
162
261
  try {
163
262
  const usagesList = [];
164
263
 
165
- // Resolve file pattern for --file filter
264
+ // Resolve file pattern for --file scan filter. A stable-handle caller can
265
+ // separately pin definitions with definitionFile while scanning globally.
166
266
  const fileFilterRaw = options.file ? index.resolveFilePathForQuery(options.file) : null;
167
267
  // resolveFilePathForQuery may return error objects for ambiguous/not-found — fall back to substring matching
168
268
  const fileFilter = typeof fileFilterRaw === 'string' ? fileFilterRaw : null;
@@ -173,7 +273,12 @@ function usages(index, name, options = {}) {
173
273
  if (options.className) {
174
274
  allDefinitions = allDefinitions.filter(d => d.className === options.className);
175
275
  }
176
- if (fileFilter) {
276
+ const definitionFileRaw = options.definitionFile
277
+ ? index.resolveFilePathForQuery(options.definitionFile) : null;
278
+ const definitionFile = typeof definitionFileRaw === 'string' ? definitionFileRaw : null;
279
+ if (definitionFile) {
280
+ allDefinitions = allDefinitions.filter(d => d.file === definitionFile);
281
+ } else if (fileFilter) {
177
282
  allDefinitions = allDefinitions.filter(d => d.file === fileFilter);
178
283
  } else if (fileSubstring) {
179
284
  allDefinitions = allDefinitions.filter(d => d.relativePath && d.relativePath.includes(fileSubstring));
@@ -181,14 +286,13 @@ function usages(index, name, options = {}) {
181
286
  const definitions = options.exclude || options.in
182
287
  ? allDefinitions.filter(d => index.matchesFilters(d.relativePath, options))
183
288
  : allDefinitions;
184
- const targetFiles = new Set(allDefinitions.map(d => d.file).filter(Boolean));
185
-
186
289
  for (const def of definitions) {
187
290
  usagesList.push({
188
291
  ...def,
292
+ usageType: 'definition',
189
293
  isDefinition: true,
190
- line: def.startLine,
191
- content: index.getLineContent(def.file, def.startLine),
294
+ line: def.nameLine || def.startLine,
295
+ content: index.getLineContent(def.file, def.nameLine || def.startLine),
192
296
  signature: index.formatSignature(def)
193
297
  });
194
298
  }
@@ -217,148 +321,17 @@ function usages(index, name, options = {}) {
217
321
  // Try AST-based detection first (with per-operation cache)
218
322
  const astUsages = index._getCachedUsages(filePath, name);
219
323
  if (astUsages !== null) {
220
- // Pre-compute: does any imported project file define this name?
221
- // Used to filter namespace member expressions (e.g., DropdownMenuPrimitive.Separator)
222
- // while keeping module access patterns (e.g., output.formatExample())
223
- const _importedHasDef = new Map();
224
- const importedFileHasDef = (receiver) => {
225
- const cacheKey = receiver || '';
226
- if (_importedHasDef.has(cacheKey)) return _importedHasDef.get(cacheKey);
227
- const importedFiles = index.importGraph.get(filePath);
228
- let found = false;
229
- if (importedFiles) for (const imp of importedFiles) {
230
- const impEntry = index.files.get(imp);
231
- if (impEntry?.symbols?.some(s => s.name === name)) {
232
- found = true;
233
- break;
234
- }
235
- // A module namespace may expose the target through a
236
- // re-export chain (`import httpx; httpx.URL(...)`,
237
- // where httpx/__init__.py re-exports URL). Direct-file
238
- // symbol checks silently dropped these compiler-true
239
- // usages. Reuse the caller engine's conservative
240
- // name-ownership chase: yes confirms; unknown stays
241
- // visible in this raw-usage inventory; only a proven
242
- // no is filtering evidence.
243
- if (targetFiles.size > 0 &&
244
- _nameBindingReaches(index, imp, name, targetFiles) !== 'no') {
245
- found = true;
246
- break;
247
- }
248
- }
249
-
250
- // Named namespace surfaces need one more ownership axis:
251
- // `import { util } from './core'; util.helper()` where
252
- // core exports `* as util` from the helper's module. The
253
- // imported file does not itself define `helper`; entering
254
- // the namespace is what reaches the owning file.
255
- if (!found && receiver && targetFiles.size > 0) {
256
- const queue = [];
257
- let unknown = false;
258
- for (const b of (fileEntry.importBindings || [])) {
259
- if (b.name !== receiver && b.alias !== receiver) continue;
260
- const rel = fileEntry.moduleResolved?.[b.module];
261
- if (rel) queue.push({ file: path.join(index.root, rel), attr: b.name, depth: 0 });
262
- else if (String(b.module).startsWith('.')) unknown = true;
263
- }
264
- const seen = new Set();
265
- while (!found && queue.length > 0) {
266
- const cur = queue.shift();
267
- const state = `${cur.file}\0${cur.attr || ''}`;
268
- if (seen.has(state)) continue;
269
- seen.add(state);
270
- if (targetFiles.has(cur.file)) { found = true; break; }
271
- if (cur.depth >= 4) { unknown = true; continue; }
272
- const fe = index.files.get(cur.file);
273
- if (!fe) { unknown = true; continue; }
274
- for (const e of (fe.exportDetails || [])) {
275
- if (!e.source) continue;
276
- let nextAttr;
277
- if (e.type === 're-export-all' && e.alias === cur.attr) {
278
- nextAttr = null; // entered the namespace
279
- } else if (e.type === 're-export-all' && !e.alias && cur.attr) {
280
- nextAttr = cur.attr; // transparent barrel
281
- } else if (e.type === 're-export' &&
282
- (e.alias || e.name) === cur.attr) {
283
- nextAttr = e.name;
284
- } else {
285
- continue;
286
- }
287
- const rel = fe.moduleResolved?.[e.source];
288
- if (rel) {
289
- queue.push({
290
- file: path.join(index.root, rel),
291
- attr: nextAttr,
292
- depth: cur.depth + 1,
293
- });
294
- } else if (String(e.source).startsWith('.')) {
295
- unknown = true;
296
- }
297
- }
298
- // Dynamic/CJS export objects cannot prove that a
299
- // namespace property is unrelated. Keep the usage
300
- // visible rather than manufacturing a false miss.
301
- if ((fe.exportDetails || []).some(e =>
302
- e.type === 'exports' || e.type === 'module.exports') ||
303
- (fe.moduleAssignedNames || []).includes(cur.attr)) {
304
- unknown = true;
305
- }
306
- }
307
- if (!found && unknown) found = true;
308
- }
309
- _importedHasDef.set(cacheKey, found);
310
- return found;
311
- };
312
-
313
- // A qualified usage whose RECEIVER is a symbol defined in this
314
- // file is never external-package noise — `Geometry.area(3, 4)`
315
- // in the file declaring namespace Geometry was dropped by the
316
- // receiver filter while find's usageCounts counted it (fix
317
- // #241). Keyed on the receiver, not the target name: a file
318
- // defining its own `Separator` while using external
319
- // `Ns.Separator` must still filter the latter (bug #23).
320
- const receiverDefinedHere = (recv) =>
321
- !!recv && fileEntry.symbols && fileEntry.symbols.some(s => s.name === recv);
322
-
323
324
  for (const u of astUsages) {
324
325
  // Skip if this is a definition line (already added above)
325
- if (definitions.some(d => d.file === filePath && d.startLine === u.line)) {
326
+ if (definitions.some(d => d.file === filePath &&
327
+ (d.startLine === u.line || d.nameLine === u.line))) {
326
328
  continue;
327
329
  }
328
330
 
329
- // Rust `Type::method` method values carry their owner from
330
- // the AST. Keep them only for a matching method owner;
331
- // class/struct queries must not absorb `Enum::Variant`
332
- // references that happen to share the target type name.
333
- // Owners are CALLABLE member defs only — an enum variant
334
- // member also carries className, and counting it as an
335
- // owner would let `Boundary::Grid` survive a struct-Grid
336
- // query via its own enum. Lowercase receivers are module
337
- // paths (`render::draw` reaches the free fn); `Self` is
338
- // the enclosing type — both keep escape-hatch visibility.
339
- if (fileEntry.language === 'rust' && u.scopedReference &&
340
- u.receiver && /^[A-Z]/.test(u.receiver) && u.receiver !== 'Self') {
341
- const methodOwners = new Set(definitions
342
- .filter(d => d.className && CALLABLE_SYMBOL_KINDS.has(d.type))
343
- .map(d => d.className));
344
- if (!methodOwners.has(u.receiver)) continue;
345
- }
346
-
347
- // Filter member expressions with unrelated receivers in JS/TS/Python.
348
- // Keeps: standalone usages, self/this/cls/super, method calls on known types,
349
- // qualified usages whose receiver this file defines,
350
- // and module access (output.fn()) when the imported file defines the name.
351
- // Filters: namespace access to external packages (DropdownMenuPrimitive.Separator).
352
- if (u.receiver && !['self', 'this', 'cls', 'super'].includes(u.receiver) &&
353
- fileEntry.language !== 'go' && fileEntry.language !== 'java' && fileEntry.language !== 'rust') {
354
- const hasMethodDef = definitions.some(d => d.className);
355
- const sameFileMember = definitions.some(d =>
356
- d.file === filePath && d.memberAssigned);
357
- if (!hasMethodDef && !sameFileMember && !receiverDefinedHere(u.receiver) &&
358
- !importedFileHasDef(u.receiver)) {
359
- continue;
360
- }
361
- }
331
+ // `usages` is the literal-name escape hatch. Unlike callers,
332
+ // it must never discard an AST occurrence merely because
333
+ // ownership or receiver dispatch cannot be proven. Consumers
334
+ // can use usageType/receiver plus `show` for semantic tiers.
362
335
 
363
336
  const lineContent = lines[u.line - 1] || '';
364
337
 
@@ -400,6 +373,17 @@ function usages(index, name, options = {}) {
400
373
 
401
374
  usagesList.push(usage);
402
375
  }
376
+ if (!options.codeOnly) {
377
+ appendTextComplements(index, {
378
+ usagesList,
379
+ filePath,
380
+ fileEntry,
381
+ content,
382
+ lines,
383
+ name,
384
+ context: options.context || 0,
385
+ });
386
+ }
403
387
  continue; // Skip to next file
404
388
  }
405
389
 
@@ -419,13 +403,13 @@ function usages(index, name, options = {}) {
419
403
  return;
420
404
  }
421
405
 
422
- // Skip if the match is inside a string literal
423
- if (index.isInsideStringAST(content, lineNum, line, name, filePath)) {
424
- return;
425
- }
426
-
427
406
  // Classify usage type (AST-based, defaults to 'reference' for unsupported languages)
428
- const usageType = index.classifyUsageAST(content, lineNum, name, filePath) ?? 'reference';
407
+ const inText = index.isInsideStringAST(
408
+ content, lineNum, line, name, filePath);
409
+ const usageType = !options.codeOnly && inText
410
+ ? 'text'
411
+ : (index.classifyUsageAST(
412
+ content, lineNum, name, filePath) ?? 'reference');
429
413
 
430
414
  // BUG-4: enrich call usages with enclosing-function info.
431
415
  let callerSym = null;
@@ -440,6 +424,9 @@ function usages(index, name, options = {}) {
440
424
  content: line,
441
425
  usageType,
442
426
  isDefinition: false,
427
+ ...(usageType === 'text' && {
428
+ textKind: 'comment-or-string',
429
+ }),
443
430
  ...(callerSym && {
444
431
  callerName: callerSym.name,
445
432
  callerStartLine: callerSym.startLine
@@ -497,16 +484,39 @@ function search(index, term, options = {}) {
497
484
  let filesSkipped = 0;
498
485
  let filesFilteredByFlag = 0;
499
486
  const regexFlags = options.caseSensitive ? 'g' : 'gi';
500
- const useRegex = options.regex !== false; // Default: regex ON
487
+ const useRegex = options.regex === true; // Safe default: literal text
501
488
  let regex;
502
- let regexFallback = false;
489
+ let linearRegex = null;
503
490
  if (useRegex) {
491
+ if (NESTED_SINGLE_ATOM_REPEAT.test(term)) {
492
+ throw new Error(
493
+ `Unsafe regular expression "${term}": nested repetition is not accepted. Simplify it or use ripgrep.`,
494
+ );
495
+ }
504
496
  try {
505
497
  regex = new RegExp(term, regexFlags);
506
498
  } catch (e) {
507
- // Invalid regex — fall back to plain text
508
- regex = new RegExp(escapeRegExp(term), regexFlags);
509
- regexFallback = e.message;
499
+ throw new Error(
500
+ `Invalid regular expression "${term}": ${e.message}`,
501
+ { cause: e },
502
+ );
503
+ }
504
+ // Prefer the RE2-compatible DFA engine: ordinary code-search patterns
505
+ // stay linear-time even for ambiguous repetition such as `(a|a)*`.
506
+ // Advanced JavaScript-only constructs (backreferences/lookarounds)
507
+ // retain V8 compatibility only when the conservative fallback guard
508
+ // can establish that their repetition shape is safe.
509
+ try {
510
+ let re2Flags = options.caseSensitive ? 0 : RE2JS.CASE_INSENSITIVE;
511
+ if (/\(\?<([=!])/.test(term)) re2Flags |= RE2JS.LOOKBEHINDS;
512
+ linearRegex = RE2JS.compile(term, re2Flags);
513
+ } catch (re2Error) {
514
+ if (!isSafeRegex(term)) {
515
+ throw new Error(
516
+ `Unsafe regular expression "${term}": this JavaScript-only pattern cannot run in UCN's linear-time engine and its repetition shape may backtrack catastrophically. Simplify it or use ripgrep.`,
517
+ { cause: re2Error },
518
+ );
519
+ }
510
520
  }
511
521
  } else {
512
522
  regex = new RegExp(escapeRegExp(term), regexFlags);
@@ -541,7 +551,12 @@ function search(index, term, options = {}) {
541
551
  try {
542
552
  const parser = getParser(language);
543
553
  const { findMatchesWithASTFilter } = require('../languages/utils');
544
- const astMatches = findMatchesWithASTFilter(content, term, parser, { codeOnly: true, regex: useRegex });
554
+ const astMatches = findMatchesWithASTFilter(content, term, parser, {
555
+ codeOnly: true,
556
+ regex: useRegex,
557
+ caseSensitive: options.caseSensitive,
558
+ compiledRegex: linearRegex,
559
+ });
545
560
 
546
561
  for (const m of astMatches) {
547
562
  const match = {
@@ -580,8 +595,9 @@ function search(index, term, options = {}) {
580
595
 
581
596
  // Fallback to regex-based search (non-codeOnly or unsupported language)
582
597
  lines.forEach((line, idx) => {
583
- regex.lastIndex = 0; // Reset regex state
584
- if (regex.test(line)) {
598
+ regex.lastIndex = 0; // Reset V8 fallback state
599
+ if ((linearRegex && linearRegex.test(line)) ||
600
+ (!linearRegex && regex.test(line))) {
585
601
  const lineNum = idx + 1;
586
602
  // Skip if codeOnly and line is comment/string
587
603
  if (options.codeOnly && index.isCommentOrStringAtPosition(content, lineNum, 0, filePath)) {
@@ -623,6 +639,7 @@ function search(index, term, options = {}) {
623
639
 
624
640
  // Apply top limit (limits total matches across all files)
625
641
  const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0);
642
+ const totalMatchedFiles = results.length;
626
643
  let truncatedMatches = 0;
627
644
  if (options.top && options.top > 0 && totalMatches > options.top) {
628
645
  let remaining = options.top;
@@ -642,7 +659,19 @@ function search(index, term, options = {}) {
642
659
  results.push(...truncated);
643
660
  }
644
661
 
645
- results.meta = { filesScanned, filesSkipped, filesFilteredByFlag, totalFiles: index.files.size, regexFallback, totalMatches, truncatedMatches, projectLanguage: index._getPredominantLanguage() };
662
+ results.meta = {
663
+ filesScanned,
664
+ filesSkipped,
665
+ filesFilteredByFlag,
666
+ totalFiles: index.files.size,
667
+ mode: useRegex ? 'regex' : 'literal',
668
+ totalMatches,
669
+ totalMatchedFiles,
670
+ shownMatches: totalMatches - truncatedMatches,
671
+ truncatedMatches,
672
+ limit: options.top || null,
673
+ projectLanguage: index._getPredominantLanguage(),
674
+ };
646
675
  return results;
647
676
  } finally { index._endOp(); }
648
677
  }
@@ -730,6 +759,9 @@ function structuralSearch(index, options = {}) {
730
759
  const calls = getCachedCalls(index, filePath);
731
760
  if (!calls) continue;
732
761
  for (const call of calls) {
762
+ // Potential callback/value references are useful to the
763
+ // caller engine, but they are not invocations.
764
+ if (call.isFunctionReference || call.isTypeReference) continue;
733
765
  if (nameMatcher && !nameMatcher(call.name)) continue;
734
766
  // Field-hop receivers (`tm.service.Save()`) carry
735
767
  // receiverRoot/receiverField instead of receiver —
@@ -764,6 +796,8 @@ function structuralSearch(index, options = {}) {
764
796
  const classTypes = new Set(['class', 'struct', 'interface', 'impl', 'trait', 'record', 'enum']);
765
797
  const typeTypes = new Set(['type', 'enum', 'interface', 'trait', 'record', 'namespace']);
766
798
  const methodTypes = new Set(['method', 'constructor']);
799
+ const variableTypes = new Set(['state', 'field', 'constant', 'macro']);
800
+ const groupedTypes = new Set(['function', 'class', 'method', 'type', 'variable']);
767
801
 
768
802
  for (const [symbolName, definitions] of index.symbols) {
769
803
  if (nameMatcher && !nameMatcher(symbolName)) continue;
@@ -774,6 +808,8 @@ function structuralSearch(index, options = {}) {
774
808
  if (type === 'class' && !classTypes.has(def.type)) continue;
775
809
  if (type === 'method' && !methodTypes.has(def.type) && !def.isMethod) continue;
776
810
  if (type === 'type' && !typeTypes.has(def.type)) continue;
811
+ if (type === 'variable' && !variableTypes.has(def.type)) continue;
812
+ if (type && !groupedTypes.has(type) && def.type !== type) continue;
777
813
 
778
814
  // File filters
779
815
  const fileEntry = index.files.get(def.file);
@@ -811,16 +847,17 @@ function structuralSearch(index, options = {}) {
811
847
 
812
848
  // Exported filter
813
849
  if (exported) {
814
- const mods = def.modifiers || [];
815
- const isExp = (fileEntry && fileEntry.exports.includes(symbolName)) ||
816
- mods.includes('export') || mods.includes('public') ||
817
- mods.some(m => m.startsWith('pub')) ||
818
- (fileEntry && langTraits(fileEntry.language)?.exportVisibility === 'capitalization' && /^[A-Z]/.test(symbolName));
850
+ const { symbolIsExported } = require('./graph');
851
+ const isExp = fileEntry && symbolIsExported(
852
+ def, fileEntry, new Set(fileEntry.exports || []));
819
853
  if (!isExp) continue;
820
854
  }
821
855
 
822
856
  // Unused filter (expensive — last check)
823
857
  if (unused) {
858
+ // This query has call-edge semantics. Reference-live
859
+ // types/classes/fields are handled by `deadcode`.
860
+ if (!functionTypes.has(def.type)) continue;
824
861
  // Named function expressions are consumed by their
825
862
  // expression position — never "unused" (the deadcode
826
863
  // twin of the bodyScopedName audit skip).
@@ -854,7 +891,7 @@ function structuralSearch(index, options = {}) {
854
891
  // #234, campaign G2 ×4 languages: Go main/init, Java
855
892
  // main, Rust main/#[test] all listed — the deadcode
856
893
  // protection, applied here).
857
- const langModule = fileEntry && getLanguageModule(fileEntry.language);
894
+ const langModule = fileEntry && getLanguageAdapter(fileEntry.language);
858
895
  if (langModule?.isEntryPoint?.(def)) continue;
859
896
  // A bare decorator application (@with_logging) invokes
860
897
  // the decorator at import time but is recorded as a
@@ -917,6 +954,10 @@ function structuralSearch(index, options = {}) {
917
954
  }).filter(([, v]) => v !== undefined && v !== null)),
918
955
  totalMatched: total,
919
956
  shown: results.length,
957
+ ...(unused && {
958
+ unusedScope: 'callable-symbols-only',
959
+ unusedSafety: 'candidate-only; use deadcode before deletion',
960
+ }),
920
961
  }
921
962
  };
922
963
  } finally { index._endOp(); }
@@ -1269,6 +1310,50 @@ function tests(index, nameOrFile, options = {}) {
1269
1310
  if (targetRels.size === 0) targetRels = null;
1270
1311
  }
1271
1312
 
1313
+ // File targets answer "which tests exercise code from this file?", not
1314
+ // "which tests mention the file's basename?". The old basename scan turned
1315
+ // `tests cli/index.js` into a project-wide search for the symbol `index`,
1316
+ // producing huge, unrelated answers. Pin every callable declared by the
1317
+ // resolved file and let the caller engine prove test call sites instead.
1318
+ // Import-linked tests are still handled below so class/type-only use and
1319
+ // module-level coverage remain visible.
1320
+ const fileTargetCallerSites = new Map(); // absolute test file → tiered sites
1321
+ if (isFilePath && targetRels) {
1322
+ const testFileInfo = new Map(testFiles.map(info => [info.path, info]));
1323
+ const fileTargetDefs = [];
1324
+ for (const [, fe] of index.files) {
1325
+ if (!targetRels.has(fe.relativePath)) continue;
1326
+ for (const symbol of fe.symbols || []) {
1327
+ if (CALLABLE_SYMBOL_KINDS.has(symbol.type)) fileTargetDefs.push(symbol);
1328
+ }
1329
+ }
1330
+ for (const def of fileTargetDefs) {
1331
+ const raw = index.findCallers(def.name, {
1332
+ targetDefinitions: [def],
1333
+ collectAccount: true,
1334
+ });
1335
+ const globallyUnique = index.find(def.name, { exact: true }).length === 1;
1336
+ const sites = [
1337
+ ...raw.map(site => ({ ...site, _testEvidenceTier: 'confirmed' })),
1338
+ ...(globallyUnique ? (raw.unverifiedEntries || []).map(site => ({
1339
+ ...site,
1340
+ _testEvidenceTier: 'unverified',
1341
+ })) : []),
1342
+ ];
1343
+ for (const site of sites) {
1344
+ const info = testFileInfo.get(site.file);
1345
+ if (!info) continue;
1346
+ if (info.testRanges && !lineInRanges(site.line, info.testRanges)) continue;
1347
+ let rows = fileTargetCallerSites.get(site.file);
1348
+ if (!rows) {
1349
+ rows = [];
1350
+ fileTargetCallerSites.set(site.file, rows);
1351
+ }
1352
+ rows.push(site);
1353
+ }
1354
+ }
1355
+ }
1356
+
1272
1357
  const className = options.className || null;
1273
1358
  // className scoping accepts the class plus its non-overriding
1274
1359
  // descendants — a subclass instance without its own override dispatches
@@ -1301,14 +1386,28 @@ function tests(index, nameOrFile, options = {}) {
1301
1386
  if (hits.length > 0) linkedRecords = hits;
1302
1387
  }
1303
1388
  }
1389
+ const provenFileSites = fileTargetCallerSites.get(testPath) || [];
1304
1390
 
1305
1391
  // Fast pre-check: skip if searchTerm doesn't appear in file
1306
- if (!content.includes(searchTerm) && !linkedRecords) continue;
1392
+ if (!isFilePath && !content.includes(searchTerm) && !linkedRecords) continue;
1393
+ if (isFilePath && !linkedRecords && provenFileSites.length === 0) continue;
1307
1394
  const sourceFileLinked = !sourceFileFilter || sourceFileFilter.has(testPath);
1395
+ // A class-qualified query can still identify a possible test link
1396
+ // in source layouts that have no modeled module edge (notably
1397
+ // Rust macro/test fixtures). Keep it visibly unverified when the
1398
+ // test contains an AST reference to the exact class name; never
1399
+ // promote that hint to confirmed source ownership.
1400
+ const classIdentityHint = !sourceFileLinked && className &&
1401
+ (index._getCachedUsages(testPath, className) || []).some(u =>
1402
+ u.usageType !== 'definition' && u.usageType !== 'import');
1308
1403
 
1309
1404
  // AST-based usage detection
1310
- const astUsages = index._getCachedUsages(testPath, searchTerm) || [];
1311
- if (astUsages.length === 0 && !linkedRecords) continue;
1405
+ // A file path is not a symbol. Its basename must never be sent
1406
+ // through the usage index (e.g. `index.js` → every local `index`).
1407
+ const astUsages = isFilePath
1408
+ ? []
1409
+ : (index._getCachedUsages(testPath, searchTerm) || []);
1410
+ if (astUsages.length === 0 && !linkedRecords && provenFileSites.length === 0) continue;
1312
1411
  // Compiler attributes can invoke generated builder methods across
1313
1412
  // workspace/facade boundaries that the source import graph cannot
1314
1413
  // represent (`#[arg(value_delimiter = ',')]`). Keep these explicit
@@ -1316,7 +1415,7 @@ function tests(index, nameOrFile, options = {}) {
1316
1415
  // test. Ordinary calls/references still require source ownership.
1317
1416
  const hasAttributeReference = astUsages.some(u => u.inAttribute &&
1318
1417
  (!testRanges || lineInRanges(u.line, testRanges)));
1319
- if (!sourceFileLinked && !hasAttributeReference) continue;
1418
+ if (!sourceFileLinked && !hasAttributeReference && !classIdentityHint) continue;
1320
1419
  // className scoping normally requires the class or a dispatching
1321
1420
  // descendant in the file. Generated attribute references are the
1322
1421
  // conservative exception: they carry an explicit unverified tier.
@@ -1360,7 +1459,7 @@ function tests(index, nameOrFile, options = {}) {
1360
1459
 
1361
1460
  for (const usage of astUsages) {
1362
1461
  if (usage.usageType === 'definition') continue; // not relevant in test files
1363
- if (!sourceFileLinked && !usage.inAttribute) continue;
1462
+ if (!sourceFileLinked && !usage.inAttribute && !classIdentityHint) continue;
1364
1463
  // Inline-test-promoted file: only lines inside the test
1365
1464
  // ranges are test code (fix #244).
1366
1465
  if (testRanges && !lineInRanges(usage.line, testRanges)) continue;
@@ -1378,7 +1477,7 @@ function tests(index, nameOrFile, options = {}) {
1378
1477
  if (usage.usageType === 'import') {
1379
1478
  matchType = 'import';
1380
1479
  } else if (usage.usageType === 'call') {
1381
- matchType = 'call';
1480
+ matchType = classIdentityHint ? 'unverified-call' : 'call';
1382
1481
  } else {
1383
1482
  // 'reference' — check if inside string literal
1384
1483
  matchType = strPattern.test(lineContent) ? 'string-ref' :
@@ -1386,7 +1485,7 @@ function tests(index, nameOrFile, options = {}) {
1386
1485
  }
1387
1486
 
1388
1487
  // className scoping for calls: check receiver
1389
- if (className && matchType === 'call') {
1488
+ if (className && (matchType === 'call' || matchType === 'unverified-call')) {
1390
1489
  if (!_receiverMatchesClass(usage, dispatchNames, instanceTypeMap, lineContent, searchTerm)) continue;
1391
1490
  }
1392
1491
 
@@ -1416,7 +1515,11 @@ function tests(index, nameOrFile, options = {}) {
1416
1515
  matches.push({
1417
1516
  line: usage.line,
1418
1517
  content: lineContent.trim(),
1419
- matchType
1518
+ matchType,
1519
+ ...(matchType === 'unverified-call' && {
1520
+ evidenceTier: 'unverified',
1521
+ reason: 'class-reference-without-source-ownership',
1522
+ }),
1420
1523
  });
1421
1524
  }
1422
1525
 
@@ -1455,10 +1558,29 @@ function tests(index, nameOrFile, options = {}) {
1455
1558
  }
1456
1559
  }
1457
1560
 
1561
+ // Exact-target caller evidence for callable declarations in a file
1562
+ // target. This covers same-package tests (notably Go) that do not
1563
+ // import the implementation file, without falling back to a
1564
+ // basename text heuristic.
1565
+ for (const site of provenFileSites) {
1566
+ const matchType = site._testEvidenceTier === 'confirmed'
1567
+ ? 'call'
1568
+ : 'unverified-call';
1569
+ if (matches.some(m => m.line === site.line && m.matchType === matchType)) continue;
1570
+ matches.push({
1571
+ line: site.line,
1572
+ content: (site.content || index.getLineContent(testPath, site.line) || '').trim(),
1573
+ matchType,
1574
+ evidenceTier: site._testEvidenceTier,
1575
+ ...(site.resolution && { resolution: site.resolution }),
1576
+ ...(site.reason && { reason: site.reason }),
1577
+ });
1578
+ }
1579
+
1458
1580
  // Language-aware test-case detection. Under a local same-name
1459
1581
  // shadow the term on a test line is the file's OWN helper —
1460
1582
  // only anchor test cases to matches that survived the shadow.
1461
- if (sourceFileLinked &&
1583
+ if (!isFilePath && sourceFileLinked &&
1462
1584
  (!localShadow || matches.some(m => m.matchType !== 'import'))) {
1463
1585
  _addTestCaseMatches(index, testPath, entry, searchTerm, className, instanceTypeMap, matches);
1464
1586
  }
@@ -1477,7 +1599,7 @@ function tests(index, nameOrFile, options = {}) {
1477
1599
  }
1478
1600
 
1479
1601
  const filtered = options.callsOnly
1480
- ? finalMatches.filter(m => m.matchType === 'call' || m.matchType === 'test-case')
1602
+ ? finalMatches.filter(m => ['call', 'unverified-call', 'test-case'].includes(m.matchType))
1481
1603
  : finalMatches;
1482
1604
  if (filtered.length > 0) {
1483
1605
  results.push({
@@ -1542,7 +1664,8 @@ function tests(index, nameOrFile, options = {}) {
1542
1664
  const localSameName = (info.entry.symbols || []).some(s => s.name === searchTerm);
1543
1665
  const importsTargetName = (info.entry.importBindings || []).some(b => b.name === searchTerm);
1544
1666
  const explicitlyScopedToLocal = !!options.file && targetDefs.some(d => d.file === site.file);
1545
- if (localSameName && !importsTargetName && !explicitlyScopedToLocal && !site.receiver) continue;
1667
+ if (localSameName && !importsTargetName && !explicitlyScopedToLocal &&
1668
+ !site.receiver && !site.isMethod) continue;
1546
1669
 
1547
1670
  let fileResult = results.find(r => r.file === info.entry.relativePath);
1548
1671
  if (!fileResult) {
@@ -1639,7 +1762,16 @@ function _buildSourceFileImporters(index, defs) {
1639
1762
  // If so, add it to the queue so its importers are also discovered.
1640
1763
  if (!visited.has(imp)) {
1641
1764
  const fe = index.files.get(imp);
1642
- if (fe && _fileReExportsSymbol(index, fe, symbolName, current)) {
1765
+ const currentEntry = index.files.get(current);
1766
+ // C/C++ #include is textual inclusion, not a module import.
1767
+ // Names flow through every transitive include edge regardless
1768
+ // of extension (`common.h` may intentionally include a .c
1769
+ // implementation), so no re-export declaration is required.
1770
+ const cFamilyIncludeChain =
1771
+ ['c', 'cpp'].includes(currentEntry?.language) &&
1772
+ ['c', 'cpp'].includes(fe?.language);
1773
+ if (fe && (cFamilyIncludeChain ||
1774
+ _fileReExportsSymbol(index, fe, symbolName, current))) {
1643
1775
  visited.add(imp);
1644
1776
  queue.push(imp);
1645
1777
  }
@@ -1921,7 +2053,7 @@ function _addTestCaseMatches(index, filePath, fileEntry, searchTerm, className,
1921
2053
  // Go/Python/Java/Rust: check if any AST usage falls within a test function's range
1922
2054
  if (!fileEntry.symbols) return;
1923
2055
  try {
1924
- const langModule = getLanguageModule(lang);
2056
+ const langModule = getLanguageAdapter(lang);
1925
2057
  if (!langModule) return;
1926
2058
  // Prefer kinded predicate so fn main() and fn init() aren't classified
1927
2059
  // as test cases (BUG-CX). Fall back to isEntryPoint for compat.