ucn 4.2.2 → 5.0.1

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 +445 -300
  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 -131
  13. package/core/cache.js +533 -11
  14. package/core/callers.js +5533 -494
  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 +421 -20
  20. package/core/discovery.js +359 -46
  21. package/core/entrypoints.js +204 -42
  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 +216 -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 -177
  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 +371 -116
  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 +428 -16
  65. package/languages/javascript.js +452 -49
  66. package/languages/python.js +1041 -32
  67. package/languages/rust.js +1415 -152
  68. package/languages/utils.js +40 -3
  69. package/mcp/server.js +254 -636
  70. package/package.json +41 -24
  71. package/eslint.config.js +0 -43
  72. package/jsconfig.json +0 -10
package/core/search.js CHANGED
@@ -8,11 +8,19 @@
8
8
  'use strict';
9
9
 
10
10
  const path = require('path');
11
- const { escapeRegExp, codeUnitCompare, inlineTestRanges, lineInRanges, classDispatchNames } = require('./shared');
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,65 +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
- let _importedHasDef = null;
224
- const importedFileHasDef = () => {
225
- if (_importedHasDef !== null) return _importedHasDef;
226
- const importedFiles = index.importGraph.get(filePath);
227
- _importedHasDef = false;
228
- if (importedFiles) for (const imp of importedFiles) {
229
- const impEntry = index.files.get(imp);
230
- if (impEntry?.symbols?.some(s => s.name === name)) {
231
- _importedHasDef = true;
232
- break;
233
- }
234
- // A module namespace may expose the target through a
235
- // re-export chain (`import httpx; httpx.URL(...)`,
236
- // where httpx/__init__.py re-exports URL). Direct-file
237
- // symbol checks silently dropped these compiler-true
238
- // usages. Reuse the caller engine's conservative
239
- // name-ownership chase: yes confirms; unknown stays
240
- // visible in this raw-usage inventory; only a proven
241
- // no is filtering evidence.
242
- if (targetFiles.size > 0 &&
243
- _nameBindingReaches(index, imp, name, targetFiles) !== 'no') {
244
- _importedHasDef = true;
245
- break;
246
- }
247
- }
248
- return _importedHasDef;
249
- };
250
-
251
- // A qualified usage whose RECEIVER is a symbol defined in this
252
- // file is never external-package noise — `Geometry.area(3, 4)`
253
- // in the file declaring namespace Geometry was dropped by the
254
- // receiver filter while find's usageCounts counted it (fix
255
- // #241). Keyed on the receiver, not the target name: a file
256
- // defining its own `Separator` while using external
257
- // `Ns.Separator` must still filter the latter (bug #23).
258
- const receiverDefinedHere = (recv) =>
259
- !!recv && fileEntry.symbols && fileEntry.symbols.some(s => s.name === recv);
260
-
261
324
  for (const u of astUsages) {
262
325
  // Skip if this is a definition line (already added above)
263
- 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))) {
264
328
  continue;
265
329
  }
266
330
 
267
- // Filter member expressions with unrelated receivers in JS/TS/Python.
268
- // Keeps: standalone usages, self/this/cls/super, method calls on known types,
269
- // qualified usages whose receiver this file defines,
270
- // and module access (output.fn()) when the imported file defines the name.
271
- // Filters: namespace access to external packages (DropdownMenuPrimitive.Separator).
272
- if (u.receiver && !['self', 'this', 'cls', 'super'].includes(u.receiver) &&
273
- fileEntry.language !== 'go' && fileEntry.language !== 'java' && fileEntry.language !== 'rust') {
274
- const hasMethodDef = definitions.some(d => d.className);
275
- if (!hasMethodDef && !receiverDefinedHere(u.receiver) && !importedFileHasDef()) {
276
- continue;
277
- }
278
- }
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.
279
335
 
280
336
  const lineContent = lines[u.line - 1] || '';
281
337
 
@@ -317,6 +373,17 @@ function usages(index, name, options = {}) {
317
373
 
318
374
  usagesList.push(usage);
319
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
+ }
320
387
  continue; // Skip to next file
321
388
  }
322
389
 
@@ -336,13 +403,13 @@ function usages(index, name, options = {}) {
336
403
  return;
337
404
  }
338
405
 
339
- // Skip if the match is inside a string literal
340
- if (index.isInsideStringAST(content, lineNum, line, name, filePath)) {
341
- return;
342
- }
343
-
344
406
  // Classify usage type (AST-based, defaults to 'reference' for unsupported languages)
345
- 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');
346
413
 
347
414
  // BUG-4: enrich call usages with enclosing-function info.
348
415
  let callerSym = null;
@@ -357,6 +424,9 @@ function usages(index, name, options = {}) {
357
424
  content: line,
358
425
  usageType,
359
426
  isDefinition: false,
427
+ ...(usageType === 'text' && {
428
+ textKind: 'comment-or-string',
429
+ }),
360
430
  ...(callerSym && {
361
431
  callerName: callerSym.name,
362
432
  callerStartLine: callerSym.startLine
@@ -414,16 +484,39 @@ function search(index, term, options = {}) {
414
484
  let filesSkipped = 0;
415
485
  let filesFilteredByFlag = 0;
416
486
  const regexFlags = options.caseSensitive ? 'g' : 'gi';
417
- const useRegex = options.regex !== false; // Default: regex ON
487
+ const useRegex = options.regex === true; // Safe default: literal text
418
488
  let regex;
419
- let regexFallback = false;
489
+ let linearRegex = null;
420
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
+ }
421
496
  try {
422
497
  regex = new RegExp(term, regexFlags);
423
498
  } catch (e) {
424
- // Invalid regex — fall back to plain text
425
- regex = new RegExp(escapeRegExp(term), regexFlags);
426
- 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
+ }
427
520
  }
428
521
  } else {
429
522
  regex = new RegExp(escapeRegExp(term), regexFlags);
@@ -458,7 +551,12 @@ function search(index, term, options = {}) {
458
551
  try {
459
552
  const parser = getParser(language);
460
553
  const { findMatchesWithASTFilter } = require('../languages/utils');
461
- 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
+ });
462
560
 
463
561
  for (const m of astMatches) {
464
562
  const match = {
@@ -497,8 +595,9 @@ function search(index, term, options = {}) {
497
595
 
498
596
  // Fallback to regex-based search (non-codeOnly or unsupported language)
499
597
  lines.forEach((line, idx) => {
500
- regex.lastIndex = 0; // Reset regex state
501
- if (regex.test(line)) {
598
+ regex.lastIndex = 0; // Reset V8 fallback state
599
+ if ((linearRegex && linearRegex.test(line)) ||
600
+ (!linearRegex && regex.test(line))) {
502
601
  const lineNum = idx + 1;
503
602
  // Skip if codeOnly and line is comment/string
504
603
  if (options.codeOnly && index.isCommentOrStringAtPosition(content, lineNum, 0, filePath)) {
@@ -540,6 +639,7 @@ function search(index, term, options = {}) {
540
639
 
541
640
  // Apply top limit (limits total matches across all files)
542
641
  const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0);
642
+ const totalMatchedFiles = results.length;
543
643
  let truncatedMatches = 0;
544
644
  if (options.top && options.top > 0 && totalMatches > options.top) {
545
645
  let remaining = options.top;
@@ -559,7 +659,19 @@ function search(index, term, options = {}) {
559
659
  results.push(...truncated);
560
660
  }
561
661
 
562
- 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
+ };
563
675
  return results;
564
676
  } finally { index._endOp(); }
565
677
  }
@@ -647,6 +759,9 @@ function structuralSearch(index, options = {}) {
647
759
  const calls = getCachedCalls(index, filePath);
648
760
  if (!calls) continue;
649
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;
650
765
  if (nameMatcher && !nameMatcher(call.name)) continue;
651
766
  // Field-hop receivers (`tm.service.Save()`) carry
652
767
  // receiverRoot/receiverField instead of receiver —
@@ -681,6 +796,8 @@ function structuralSearch(index, options = {}) {
681
796
  const classTypes = new Set(['class', 'struct', 'interface', 'impl', 'trait', 'record', 'enum']);
682
797
  const typeTypes = new Set(['type', 'enum', 'interface', 'trait', 'record', 'namespace']);
683
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']);
684
801
 
685
802
  for (const [symbolName, definitions] of index.symbols) {
686
803
  if (nameMatcher && !nameMatcher(symbolName)) continue;
@@ -691,6 +808,8 @@ function structuralSearch(index, options = {}) {
691
808
  if (type === 'class' && !classTypes.has(def.type)) continue;
692
809
  if (type === 'method' && !methodTypes.has(def.type) && !def.isMethod) continue;
693
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;
694
813
 
695
814
  // File filters
696
815
  const fileEntry = index.files.get(def.file);
@@ -728,16 +847,21 @@ function structuralSearch(index, options = {}) {
728
847
 
729
848
  // Exported filter
730
849
  if (exported) {
731
- const mods = def.modifiers || [];
732
- const isExp = (fileEntry && fileEntry.exports.includes(symbolName)) ||
733
- mods.includes('export') || mods.includes('public') ||
734
- mods.some(m => m.startsWith('pub')) ||
735
- (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 || []));
736
853
  if (!isExp) continue;
737
854
  }
738
855
 
739
856
  // Unused filter (expensive — last check)
740
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;
861
+ // Named function expressions are consumed by their
862
+ // expression position — never "unused" (the deadcode
863
+ // twin of the bodyScopedName audit skip).
864
+ if (def.bodyScopedName) continue;
741
865
  index.buildCalleeIndex();
742
866
  // A name whose every call site is its own recursion
743
867
  // has zero callers (fix #253c — the deadcode
@@ -767,7 +891,7 @@ function structuralSearch(index, options = {}) {
767
891
  // #234, campaign G2 ×4 languages: Go main/init, Java
768
892
  // main, Rust main/#[test] all listed — the deadcode
769
893
  // protection, applied here).
770
- const langModule = fileEntry && getLanguageModule(fileEntry.language);
894
+ const langModule = fileEntry && getLanguageAdapter(fileEntry.language);
771
895
  if (langModule?.isEntryPoint?.(def)) continue;
772
896
  // A bare decorator application (@with_logging) invokes
773
897
  // the decorator at import time but is recorded as a
@@ -830,6 +954,10 @@ function structuralSearch(index, options = {}) {
830
954
  }).filter(([, v]) => v !== undefined && v !== null)),
831
955
  totalMatched: total,
832
956
  shown: results.length,
957
+ ...(unused && {
958
+ unusedScope: 'callable-symbols-only',
959
+ unusedSafety: 'candidate-only; use deadcode before deletion',
960
+ }),
833
961
  }
834
962
  };
835
963
  } finally { index._endOp(); }
@@ -873,7 +1001,21 @@ function example(index, name, options = {}) {
873
1001
  .map(c => ({ ...c, evidenceTier: 'unverified' })),
874
1002
  ...(rawCallers.unverifiedEntries || [])
875
1003
  .map(c => ({ ...c, evidenceTier: 'unverified' })),
876
- ].filter(c => !c.functionReference && c.calledAs !== 'bound');
1004
+ ].filter(c => !c.functionReference && !c.isFunctionReference && c.calledAs !== 'bound');
1005
+
1006
+ // `about`/`context` expose conserved call lines that no engine candidate
1007
+ // claimed as `call-not-resolved`. `example` must preserve the same
1008
+ // evidence contract: when those are the only candidate sites, abstain
1009
+ // explicitly instead of returning a misleading "no examples" error.
1010
+ // Compose the text-ground account only on this empty-candidate path so
1011
+ // normal example selection does not pay for a second project-wide scan.
1012
+ if (candidates.length === 0) {
1013
+ const { composeAccount, callNotResolvedEntries } = require('./analysis');
1014
+ const callerAccount = composeAccount(index, name, rawCallers);
1015
+ candidates.push(...callNotResolvedEntries(index, callerAccount)
1016
+ .filter(c => !c.functionReference && c.calledAs !== 'bound')
1017
+ .map(c => ({ ...c, evidenceTier: 'unverified' })));
1018
+ }
877
1019
 
878
1020
  // Dedupe by site, preferring confirmed evidence when a parser shape
879
1021
  // reaches the same line through more than one resolution path.
@@ -1168,6 +1310,50 @@ function tests(index, nameOrFile, options = {}) {
1168
1310
  if (targetRels.size === 0) targetRels = null;
1169
1311
  }
1170
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
+
1171
1357
  const className = options.className || null;
1172
1358
  // className scoping accepts the class plus its non-overriding
1173
1359
  // descendants — a subclass instance without its own override dispatches
@@ -1200,22 +1386,41 @@ function tests(index, nameOrFile, options = {}) {
1200
1386
  if (hits.length > 0) linkedRecords = hits;
1201
1387
  }
1202
1388
  }
1389
+ const provenFileSites = fileTargetCallerSites.get(testPath) || [];
1203
1390
 
1204
1391
  // Fast pre-check: skip if searchTerm doesn't appear in file
1205
- if (!content.includes(searchTerm) && !linkedRecords) continue;
1206
- // className scoping: skip test files that reference neither the
1207
- // class nor any non-overriding subclass (fix #246 — a test
1208
- // holding only a Circle exercises Shape.describe).
1209
- if (className && ![...dispatchNames].some(dn => content.includes(dn))) continue;
1210
-
1211
- // --file scoping: only include test files that import from the target source
1212
- if (sourceFileFilter && !sourceFileFilter.has(testPath)) {
1213
- continue;
1214
- }
1392
+ if (!isFilePath && !content.includes(searchTerm) && !linkedRecords) continue;
1393
+ if (isFilePath && !linkedRecords && provenFileSites.length === 0) continue;
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');
1215
1403
 
1216
1404
  // AST-based usage detection
1217
- const astUsages = index._getCachedUsages(testPath, searchTerm) || [];
1218
- 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;
1411
+ // Compiler attributes can invoke generated builder methods across
1412
+ // workspace/facade boundaries that the source import graph cannot
1413
+ // represent (`#[arg(value_delimiter = ',')]`). Keep these explicit
1414
+ // AST references as unverified instead of silently dropping a real
1415
+ // test. Ordinary calls/references still require source ownership.
1416
+ const hasAttributeReference = astUsages.some(u => u.inAttribute &&
1417
+ (!testRanges || lineInRanges(u.line, testRanges)));
1418
+ if (!sourceFileLinked && !hasAttributeReference && !classIdentityHint) continue;
1419
+ // className scoping normally requires the class or a dispatching
1420
+ // descendant in the file. Generated attribute references are the
1421
+ // conservative exception: they carry an explicit unverified tier.
1422
+ if (className && ![...dispatchNames].some(dn => content.includes(dn)) &&
1423
+ !hasAttributeReference) continue;
1219
1424
 
1220
1425
  // A test file that DEFINES searchTerm itself owns bare-name
1221
1426
  // calls of it (fix #246 — the #244 affectedTests rule, tests()
@@ -1254,6 +1459,7 @@ function tests(index, nameOrFile, options = {}) {
1254
1459
 
1255
1460
  for (const usage of astUsages) {
1256
1461
  if (usage.usageType === 'definition') continue; // not relevant in test files
1462
+ if (!sourceFileLinked && !usage.inAttribute && !classIdentityHint) continue;
1257
1463
  // Inline-test-promoted file: only lines inside the test
1258
1464
  // ranges are test code (fix #244).
1259
1465
  if (testRanges && !lineInRanges(usage.line, testRanges)) continue;
@@ -1271,7 +1477,7 @@ function tests(index, nameOrFile, options = {}) {
1271
1477
  if (usage.usageType === 'import') {
1272
1478
  matchType = 'import';
1273
1479
  } else if (usage.usageType === 'call') {
1274
- matchType = 'call';
1480
+ matchType = classIdentityHint ? 'unverified-call' : 'call';
1275
1481
  } else {
1276
1482
  // 'reference' — check if inside string literal
1277
1483
  matchType = strPattern.test(lineContent) ? 'string-ref' :
@@ -1279,7 +1485,7 @@ function tests(index, nameOrFile, options = {}) {
1279
1485
  }
1280
1486
 
1281
1487
  // className scoping for calls: check receiver
1282
- if (className && matchType === 'call') {
1488
+ if (className && (matchType === 'call' || matchType === 'unverified-call')) {
1283
1489
  if (!_receiverMatchesClass(usage, dispatchNames, instanceTypeMap, lineContent, searchTerm)) continue;
1284
1490
  }
1285
1491
 
@@ -1309,7 +1515,11 @@ function tests(index, nameOrFile, options = {}) {
1309
1515
  matches.push({
1310
1516
  line: usage.line,
1311
1517
  content: lineContent.trim(),
1312
- matchType
1518
+ matchType,
1519
+ ...(matchType === 'unverified-call' && {
1520
+ evidenceTier: 'unverified',
1521
+ reason: 'class-reference-without-source-ownership',
1522
+ }),
1313
1523
  });
1314
1524
  }
1315
1525
 
@@ -1348,10 +1558,30 @@ function tests(index, nameOrFile, options = {}) {
1348
1558
  }
1349
1559
  }
1350
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
+
1351
1580
  // Language-aware test-case detection. Under a local same-name
1352
1581
  // shadow the term on a test line is the file's OWN helper —
1353
1582
  // only anchor test cases to matches that survived the shadow.
1354
- if (!localShadow || matches.some(m => m.matchType !== 'import')) {
1583
+ if (!isFilePath && sourceFileLinked &&
1584
+ (!localShadow || matches.some(m => m.matchType !== 'import'))) {
1355
1585
  _addTestCaseMatches(index, testPath, entry, searchTerm, className, instanceTypeMap, matches);
1356
1586
  }
1357
1587
 
@@ -1369,7 +1599,7 @@ function tests(index, nameOrFile, options = {}) {
1369
1599
  }
1370
1600
 
1371
1601
  const filtered = options.callsOnly
1372
- ? finalMatches.filter(m => m.matchType === 'call' || m.matchType === 'test-case')
1602
+ ? finalMatches.filter(m => ['call', 'unverified-call', 'test-case'].includes(m.matchType))
1373
1603
  : finalMatches;
1374
1604
  if (filtered.length > 0) {
1375
1605
  results.push({
@@ -1434,7 +1664,8 @@ function tests(index, nameOrFile, options = {}) {
1434
1664
  const localSameName = (info.entry.symbols || []).some(s => s.name === searchTerm);
1435
1665
  const importsTargetName = (info.entry.importBindings || []).some(b => b.name === searchTerm);
1436
1666
  const explicitlyScopedToLocal = !!options.file && targetDefs.some(d => d.file === site.file);
1437
- if (localSameName && !importsTargetName && !explicitlyScopedToLocal && !site.receiver) continue;
1667
+ if (localSameName && !importsTargetName && !explicitlyScopedToLocal &&
1668
+ !site.receiver && !site.isMethod) continue;
1438
1669
 
1439
1670
  let fileResult = results.find(r => r.file === info.entry.relativePath);
1440
1671
  if (!fileResult) {
@@ -1531,7 +1762,16 @@ function _buildSourceFileImporters(index, defs) {
1531
1762
  // If so, add it to the queue so its importers are also discovered.
1532
1763
  if (!visited.has(imp)) {
1533
1764
  const fe = index.files.get(imp);
1534
- if (fe && _fileReExportsSymbol(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))) {
1535
1775
  visited.add(imp);
1536
1776
  queue.push(imp);
1537
1777
  }
@@ -1636,13 +1876,28 @@ function _buildSourceFileImporters(index, defs) {
1636
1876
  * Handles: named re-exports, `module.exports = require(...)` blanket re-exports,
1637
1877
  * `export * from ...`, and files that both import from source and export the symbol.
1638
1878
  */
1639
- function _fileReExportsSymbol(fileEntry, symbolName, sourceAbsPath) {
1640
- if (!fileEntry.exports || fileEntry.exports.length === 0) return false;
1641
- // Check if any export matches the symbol name
1642
- if (symbolName && fileEntry.exports.some(exp => exp.name === symbolName)) return true;
1879
+ function _fileReExportsSymbol(index, fileEntry, symbolName, sourceAbsPath) {
1880
+ // Python module imports become module attributes. Package __init__.py
1881
+ // commonly exposes its public API through `from .core import Context as
1882
+ // Context`, which has no separate export statement in the AST.
1883
+ if (fileEntry.language === 'python' && symbolName) {
1884
+ for (const binding of (fileEntry.importBindings || [])) {
1885
+ if (binding.name !== symbolName && binding.alias !== symbolName) continue;
1886
+ const rel = fileEntry.moduleResolved?.[binding.module];
1887
+ if (rel && path.join(index.root, rel) === sourceAbsPath) return true;
1888
+ }
1889
+ }
1890
+
1891
+ const exportNames = fileEntry.exports || [];
1892
+ const details = fileEntry.exportDetails || [];
1893
+ if (exportNames.length === 0 && details.length === 0) return false;
1894
+ // Check if any export matches the symbol name. fileEntry.exports is the
1895
+ // persisted string-name list; exportDetails carries source/type metadata.
1896
+ if (symbolName && (exportNames.includes(symbolName) ||
1897
+ details.some(exp => exp.name === symbolName || exp.alias === symbolName))) return true;
1643
1898
  // Blanket re-exports: module.exports = require(...), export * from ...
1644
1899
  // These have undefined or generic names but re-export everything from the imported module
1645
- const hasBlanketExport = fileEntry.exports.some(exp =>
1900
+ const hasBlanketExport = details.some(exp =>
1646
1901
  !exp.name || exp.type === 'module.exports' || exp.type === 're-export' || exp.type === 'export-all'
1647
1902
  );
1648
1903
  if (hasBlanketExport) return true;
@@ -1798,7 +2053,7 @@ function _addTestCaseMatches(index, filePath, fileEntry, searchTerm, className,
1798
2053
  // Go/Python/Java/Rust: check if any AST usage falls within a test function's range
1799
2054
  if (!fileEntry.symbols) return;
1800
2055
  try {
1801
- const langModule = getLanguageModule(lang);
2056
+ const langModule = getLanguageAdapter(lang);
1802
2057
  if (!langModule) return;
1803
2058
  // Prefer kinded predicate so fn main() and fn init() aren't classified
1804
2059
  // as test cases (BUG-CX). Fall back to isEntryPoint for compat.