ucn 5.4.2 → 5.5.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 (85) hide show
  1. package/.claude/skills/ucn/SKILL.md +58 -236
  2. package/.claude/skills/ucn/references/commands.md +144 -68
  3. package/.claude/skills/ucn/references/resolution-rules.md +171 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +27 -33
  5. package/README.md +27 -16
  6. package/cli/index.js +45 -14
  7. package/core/accessors.js +118 -3
  8. package/core/account.js +62 -11
  9. package/core/analysis.js +517 -266
  10. package/core/ast-analysis.js +76 -117
  11. package/core/async-producers.js +1729 -0
  12. package/core/bridge.js +1877 -268
  13. package/core/brief.js +6 -0
  14. package/core/build-worker.js +78 -14
  15. package/core/cache.js +529 -31
  16. package/core/callers.js +12228 -921
  17. package/core/check.js +30 -38
  18. package/core/class-identity.js +1028 -0
  19. package/core/command-contracts.js +6 -6
  20. package/core/configuration-items.js +86 -0
  21. package/core/contract-membership.js +2262 -0
  22. package/core/cpp-scope.js +532 -0
  23. package/core/deadcode.js +171 -49
  24. package/core/decorator-binding.js +260 -0
  25. package/core/discovery.js +281 -164
  26. package/core/errors.js +52 -0
  27. package/core/execute.js +21 -9
  28. package/core/external-macros.js +326 -0
  29. package/core/graph-build.js +277 -8
  30. package/core/graph.js +35 -2
  31. package/core/ignore-rules.js +472 -0
  32. package/core/imports.js +504 -42
  33. package/core/index-ir.js +94 -11
  34. package/core/inprocess-clients.js +636 -0
  35. package/core/ir.js +87 -8
  36. package/core/macro-expansion.js +1416 -0
  37. package/core/output/analysis.js +18 -2
  38. package/core/output/brief.js +3 -0
  39. package/core/output/doctor.js +4 -0
  40. package/core/output/endpoints.js +15 -6
  41. package/core/output/find.js +7 -2
  42. package/core/output/refactoring.js +121 -14
  43. package/core/output/reporting.js +31 -3
  44. package/core/output/shared.js +14 -0
  45. package/core/output-budget.js +305 -122
  46. package/core/parallel-build.js +124 -47
  47. package/core/parser.js +3 -2
  48. package/core/project.js +472 -66
  49. package/core/provenance-facts.js +156 -12
  50. package/core/provenance-overload.js +30 -3
  51. package/core/provenance.js +104 -30
  52. package/core/pytest-fixtures.js +434 -0
  53. package/core/python-fixture-flow.js +17 -3
  54. package/core/python-modules.js +173 -0
  55. package/core/reflection.js +877 -0
  56. package/core/registry.js +1 -1
  57. package/core/reporting.js +234 -52
  58. package/core/request-helpers.js +561 -0
  59. package/core/route-graph.js +1922 -0
  60. package/core/route-lists.js +2414 -0
  61. package/core/rust-macro-expansion.js +3026 -0
  62. package/core/rust-modules.js +203 -0
  63. package/core/search.js +121 -29
  64. package/core/shared.js +19 -1
  65. package/core/test-name-bindings.js +256 -0
  66. package/core/type-denotation.js +1185 -0
  67. package/core/type-references.js +3229 -0
  68. package/core/verify.js +3360 -672
  69. package/languages/adapter.js +31 -5
  70. package/languages/c-family.js +4061 -212
  71. package/languages/c-macro-invocations.js +380 -0
  72. package/languages/c-preprocessor.js +254 -0
  73. package/languages/csharp.js +1406 -49
  74. package/languages/go.js +1243 -70
  75. package/languages/index.js +728 -6
  76. package/languages/java.js +473 -61
  77. package/languages/javascript.js +1270 -197
  78. package/languages/lexical-scope.js +1280 -0
  79. package/languages/python.js +1557 -72
  80. package/languages/rust-macro-rules.js +1020 -0
  81. package/languages/rust-value-flow.js +416 -0
  82. package/languages/rust.js +2265 -112
  83. package/languages/utils.js +298 -4
  84. package/mcp/server.js +22 -8
  85. package/package.json +1 -1
@@ -0,0 +1,203 @@
1
+ /**
2
+ * core/rust-modules.js - Rust module tree (fix #377).
3
+ *
4
+ * Every Rust source file belongs to exactly one module of one crate: crate
5
+ * roots are the files no `mod` declaration loads, and a declared file's
6
+ * module path is its parent's path plus the enclosing inline modules plus
7
+ * the declared name. Inline `mod NAME { ... }` blocks are modules too.
8
+ *
9
+ * `rustPathModule` resolves the qualifier of a path call (`crate::a::b`,
10
+ * `super::super`, `self::inner`, `net::conn`) from the call site's own
11
+ * module to the module entries (file + inline chain) that hold its items;
12
+ * `rustModuleItems` lists the definitions of a name declared directly in
13
+ * those modules. Anything the tree cannot prove (a `use` alias, an extern
14
+ * crate, a type, a re-export) resolves to null: the caller keeps its other
15
+ * evidence rules.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const { rustModDeclarationFiles, rustInlineModules, rustInlineChainAt } = require('./imports');
21
+
22
+ const FUNCTION_KINDS = new Set(['function', 'method', 'constructor', 'static']);
23
+
24
+ function isRustEntry(entry) {
25
+ return entry?.language === 'rust';
26
+ }
27
+
28
+ function moduleTree(index) {
29
+ const memo = index._rustScopeMemo || (index._rustScopeMemo = new Map());
30
+ const cached = memo.get('\x01rustModuleTree');
31
+ if (cached) return cached;
32
+ const parents = new Map(); // child file -> { file, chain, name }
33
+ const inlineByFile = new Map();
34
+ const exists = candidate => isRustEntry(index.files.get(candidate));
35
+ for (const [file, entry] of index.files) {
36
+ if (!isRustEntry(entry)) continue;
37
+ const inline = rustInlineModules(entry);
38
+ inlineByFile.set(file, inline);
39
+ const declared = rustModDeclarationFiles(file, entry, exists, inline);
40
+ if (declared.size === 0) continue;
41
+ for (const detail of entry.importDetails || []) {
42
+ if (detail.type !== 'mod' || !declared.has(detail.module)) continue;
43
+ const chain = rustInlineChainAt(inline, detail.line);
44
+ for (const child of declared.get(detail.module)) {
45
+ if (child === file || parents.has(child)) continue;
46
+ parents.set(child, { file, chain, name: detail.module });
47
+ }
48
+ }
49
+ }
50
+ const fileModules = new Map(); // file -> { root, segs }
51
+ const moduleOf = (file, seen = new Set()) => {
52
+ const hit = fileModules.get(file);
53
+ if (hit) return hit;
54
+ const parent = parents.get(file);
55
+ let result;
56
+ if (!parent || seen.has(file)) {
57
+ result = { root: file, segs: [] };
58
+ } else {
59
+ seen.add(file);
60
+ const up = moduleOf(parent.file, seen);
61
+ result = { root: up.root, segs: [...up.segs, ...parent.chain, parent.name] };
62
+ }
63
+ fileModules.set(file, result);
64
+ return result;
65
+ };
66
+ const entries = new Map(); // root \0 path -> [{ file, chain }]
67
+ const add = (root, segs, file, chain) => {
68
+ const key = `${root}\0${segs.join('::')}`;
69
+ if (!entries.has(key)) entries.set(key, []);
70
+ entries.get(key).push({ file, chain });
71
+ };
72
+ for (const [file, inline] of inlineByFile) {
73
+ const own = moduleOf(file);
74
+ add(own.root, own.segs, file, []);
75
+ for (const mod of inline) {
76
+ const chain = rustInlineChainAt(inline, mod.startLine);
77
+ add(own.root, [...own.segs, ...chain], file, chain);
78
+ }
79
+ }
80
+ const tree = { moduleOf, entries, inlineByFile };
81
+ memo.set('\x01rustModuleTree', tree);
82
+ return tree;
83
+ }
84
+
85
+ /**
86
+ * Module entries a path qualifier names from (filePath, line), or null when
87
+ * the qualifier is not provably a module path of this crate.
88
+ * @returns {{ root: string, segs: string[], entries: Array<{file: string, chain: string[]}> }|null}
89
+ */
90
+ function rustPathModule(index, filePath, line, qualifier) {
91
+ if (!isRustEntry(index.files.get(filePath)) || !qualifier) return null;
92
+ const tree = moduleTree(index);
93
+ const inline = tree.inlineByFile.get(filePath) || [];
94
+ // The answer depends on the line only through the inline modules around
95
+ // it (fix #405: path calls asked once per site; fix #405b: files with
96
+ // inline modules too).
97
+ const memo = tree.pathMemo || (tree.pathMemo = new Map());
98
+ let byFile = memo.get(filePath);
99
+ if (!byFile) memo.set(filePath, byFile = new Map());
100
+ const chain = inline.length > 0 ? rustInlineChainAt(inline, line) : null;
101
+ const key = chain?.length > 0 ? `${chain.join('::')}\0${qualifier}` : qualifier;
102
+ let resolved = byFile.get(key);
103
+ if (resolved === undefined) {
104
+ resolved = resolvePathModule(tree, filePath, line, qualifier, inline);
105
+ byFile.set(key, resolved);
106
+ }
107
+ return resolved;
108
+ }
109
+
110
+ function resolvePathModule(tree, filePath, line, qualifier, inline) {
111
+ const segments = String(qualifier).split('::');
112
+ if (segments.some(segment => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment))) return null;
113
+ const own = tree.moduleOf(filePath);
114
+ const current = [...own.segs, ...rustInlineChainAt(inline, line)];
115
+ const has = segs => tree.entries.has(`${own.root}\0${segs.join('::')}`);
116
+ let segs;
117
+ let i = 0;
118
+ if (segments[0] === 'crate') {
119
+ segs = [];
120
+ i = 1;
121
+ } else if (segments[0] === 'self' || segments[0] === 'super') {
122
+ segs = current;
123
+ if (segments[0] === 'self') i = 1;
124
+ while (segments[i] === 'super') {
125
+ if (segs.length === 0) return null;
126
+ segs = segs.slice(0, -1);
127
+ i++;
128
+ }
129
+ } else {
130
+ // A leading plain name is a child module of the current module; a
131
+ // `use` binding, extern crate, or type is not the tree's to prove.
132
+ segs = current;
133
+ }
134
+ for (; i < segments.length; i++) {
135
+ const next = [...segs, segments[i]];
136
+ if (!has(next)) return null;
137
+ segs = next;
138
+ }
139
+ return { root: own.root, segs, entries: tree.entries.get(`${own.root}\0${segs.join('::')}`) || [] };
140
+ }
141
+
142
+ /**
143
+ * Definitions of `name` declared as items directly in the resolved module
144
+ * (not inside an impl, trait, nested inline module, or function body).
145
+ */
146
+ function rustModuleItems(index, resolved, name) {
147
+ if (!resolved?.entries?.length) return [];
148
+ const tree = moduleTree(index);
149
+ const out = [];
150
+ for (const def of index.symbols.get(name) || []) {
151
+ if (def.className || def.receiver || def.type === 'module' || def.type === 'field' ||
152
+ def.type === 'macro' || def.type === 'impl') continue;
153
+ const entry = resolved.entries.find(candidate => candidate.file === def.file);
154
+ if (!entry) continue;
155
+ const chain = rustInlineChainAt(tree.inlineByFile.get(def.file) || [], def.startLine);
156
+ if (chain.length !== entry.chain.length || chain.some((part, k) => part !== entry.chain[k])) continue;
157
+ const fileEntry = index.files.get(def.file);
158
+ const nested = (fileEntry?.symbols || []).some(symbol => symbol !== def &&
159
+ FUNCTION_KINDS.has(symbol.type) && symbol.startLine <= def.startLine &&
160
+ symbol.endLine >= def.endLine && !(symbol.startLine === def.startLine && symbol.name === def.name));
161
+ if (nested) continue;
162
+ out.push(def);
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /**
168
+ * The crate root and module path of a source position (fix #399), or null
169
+ * for a file that is not Rust.
170
+ * @returns {{ root: string, segs: string[] }|null}
171
+ */
172
+ function rustModulePathAt(index, filePath, line) {
173
+ if (!isRustEntry(index.files.get(filePath))) return null;
174
+ const tree = moduleTree(index);
175
+ const own = tree.moduleOf(filePath);
176
+ return { root: own.root, segs: [...own.segs, ...rustInlineChainAt(tree.inlineByFile.get(filePath) || [], line)] };
177
+ }
178
+
179
+ /**
180
+ * Is an item with Rust visibility `modifiers`, declared at (file, line),
181
+ * accessible at (siteFile, siteLine)? `pub` items are; `pub(crate)` ones
182
+ * within their crate; private, `pub(self)` and `pub(super)` items within
183
+ * the module tree under their module (or its parent). `pub(in path)` and
184
+ * unknown layouts are not provable (false).
185
+ */
186
+ function rustItemAccessible(index, modifiers, file, line, siteFile, siteLine) {
187
+ const mods = (modifiers || []).map(m => String(m).replace(/\s+/g, ''));
188
+ if (mods.includes('pub')) return true;
189
+ const item = rustModulePathAt(index, file, line);
190
+ const site = rustModulePathAt(index, siteFile, siteLine);
191
+ if (!item || !site || item.root !== site.root) return false;
192
+ if (mods.includes('pub(crate)')) return true;
193
+ let scope = item.segs;
194
+ if (mods.includes('pub(super)')) {
195
+ if (scope.length === 0) return false;
196
+ scope = scope.slice(0, -1);
197
+ } else if (mods.some(m => m.startsWith('pub(') && m !== 'pub(self)')) {
198
+ return false;
199
+ }
200
+ return scope.length <= site.segs.length && scope.every((seg, i) => site.segs[i] === seg);
201
+ }
202
+
203
+ module.exports = { rustPathModule, rustModuleItems, rustModulePathAt, rustItemAccessible };
package/core/search.js CHANGED
@@ -13,8 +13,10 @@ const { isTestFile } = require('./discovery');
13
13
  const { detectLanguage, getParser, getLanguageAdapter, langTraits } = require('../languages');
14
14
  const { getCachedCalls } = require('./callers');
15
15
  const { extractImports } = require('./imports');
16
+ const { isAccessorDefinition, findAccessorReferences } = require('./accessors');
16
17
  const isSafeRegex = require('safe-regex2');
17
18
  const { RE2JS } = require('re2js');
19
+ const { UcnError } = require('./errors');
18
20
 
19
21
  // Keep the established search-input contract for the canonical catastrophic
20
22
  // nested-single-atom family even though the RE2-compatible engine could run it
@@ -331,7 +333,10 @@ function usages(index, name, options = {}) {
331
333
  const lines = content.split('\n');
332
334
 
333
335
  // Try AST-based detection first (with per-operation cache)
334
- const astUsages = index._getCachedUsages(filePath, name);
336
+ // Refactoring internals also read each bare reference's lexical
337
+ // scope verdict (fix #392).
338
+ const astUsages = index._getCachedUsages(filePath, name,
339
+ options.internalEvidence ? { lexicalScopes: true } : undefined);
335
340
  if (astUsages !== null) {
336
341
  for (const u of astUsages) {
337
342
  // Skip if this is a definition line (already added above)
@@ -372,6 +377,11 @@ function usages(index, name, options = {}) {
372
377
  ...(Number.isInteger(u.column) && { column: u.column }),
373
378
  ...(u.receiverIsModule && { receiverIsModule: true }),
374
379
  ...(u.receiverLocalBinding && { receiverLocalBinding: true }),
380
+ ...(u.namespace && { namespace: u.namespace }),
381
+ ...(u.scopeBinding && { scopeBinding: u.scopeBinding }),
382
+ ...(u.scopeDefLines && { scopeDefLines: u.scopeDefLines }),
383
+ ...(u.shorthandProperty && { shorthandProperty: true }),
384
+ ...(u.importAlias && { importAlias: true }),
375
385
  }),
376
386
  ...(callerSym && {
377
387
  callerName: callerSym.name,
@@ -476,13 +486,23 @@ function usages(index, name, options = {}) {
476
486
 
477
487
  // Deduplicate same-file, same-line, same-usageType entries
478
488
  // (e.g., `detectLanguage: parser.detectLanguage` has the name twice on one line)
479
- const seen = new Set();
489
+ // Refactoring internals keep the other same-line records on the kept
490
+ // one (`sameLine`): every token of a line is decided (fix #392).
491
+ const seen = new Map();
480
492
  const deduped = [];
481
493
  for (const u of usagesList) {
482
494
  const key = `${u.file}:${u.line}:${u.usageType}:${u.isDefinition}`;
483
495
  if (!seen.has(key)) {
484
- seen.add(key);
496
+ seen.set(key, u);
485
497
  deduped.push(u);
498
+ } else if (options.internalEvidence && !u.isDefinition) {
499
+ const kept = seen.get(key);
500
+ if (!kept.sameLine) {
501
+ Object.defineProperty(kept, 'sameLine', {
502
+ value: [], enumerable: false, writable: true, configurable: true,
503
+ });
504
+ }
505
+ kept.sameLine.push(u);
486
506
  }
487
507
  }
488
508
  return deduped;
@@ -510,14 +530,14 @@ function search(index, term, options = {}) {
510
530
  let linearRegex = null;
511
531
  if (useRegex) {
512
532
  if (NESTED_SINGLE_ATOM_REPEAT.test(term)) {
513
- throw new Error(
533
+ throw new UcnError(
514
534
  `Unsafe regular expression "${term}": nested repetition is not accepted. Simplify it or use ripgrep.`,
515
535
  );
516
536
  }
517
537
  try {
518
538
  regex = new RegExp(term, regexFlags);
519
539
  } catch (e) {
520
- throw new Error(
540
+ throw new UcnError(
521
541
  `Invalid regular expression "${term}": ${e.message}`,
522
542
  { cause: e },
523
543
  );
@@ -533,7 +553,7 @@ function search(index, term, options = {}) {
533
553
  linearRegex = RE2JS.compile(term, re2Flags);
534
554
  } catch (re2Error) {
535
555
  if (!isSafeRegex(term)) {
536
- throw new Error(
556
+ throw new UcnError(
537
557
  `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.`,
538
558
  { cause: re2Error },
539
559
  );
@@ -1423,7 +1443,10 @@ function tests(index, nameOrFile, options = {}) {
1423
1443
  // Fast pre-check: skip if searchTerm doesn't appear in file
1424
1444
  if (!isFilePath && !content.includes(searchTerm) && !linkedRecords) continue;
1425
1445
  if (isFilePath && !linkedRecords && provenFileSites.length === 0) continue;
1426
- const sourceFileLinked = !sourceFileFilter || sourceFileFilter.has(testPath);
1446
+ // A test importing the name through any chain of re-exports and
1447
+ // renames the caller engine follows is linked too (fix #400).
1448
+ const sourceFileLinked = !sourceFileFilter || sourceFileFilter.has(testPath) ||
1449
+ _importBindingReachesTargets(index, entry, searchTerm, targetDefs);
1427
1450
  // A class-qualified query can still identify a possible test link
1428
1451
  // in source layouts that have no modeled module edge (notably
1429
1452
  // Rust macro/test fixtures). Keep it visibly unverified when the
@@ -1499,9 +1522,12 @@ function tests(index, nameOrFile, options = {}) {
1499
1522
  // own definition (fix #246).
1500
1523
  if (localShadow && !usage.receiver && usage.usageType !== 'import') continue;
1501
1524
 
1525
+ // A line is claimed only by a usage that is recorded: two
1526
+ // same-kind tokens on one line (`a.m = b.m`) are judged
1527
+ // separately, so a rejected first token never hides a
1528
+ // matching second one.
1502
1529
  const lineKey = `${usage.line}:${usage.usageType}`;
1503
1530
  if (seenLines.has(lineKey)) continue;
1504
- seenLines.add(lineKey);
1505
1531
 
1506
1532
  const lineContent = index.getLineContent(testPath, usage.line);
1507
1533
 
@@ -1529,6 +1555,7 @@ function tests(index, nameOrFile, options = {}) {
1529
1555
  // where the receiver matches the target class.
1530
1556
  if (!usage.receiver && matchType !== 'unverified-reference') continue;
1531
1557
  if (!usage.receiver && matchType === 'unverified-reference') {
1558
+ seenLines.add(lineKey);
1532
1559
  matches.push({
1533
1560
  line: usage.line,
1534
1561
  content: lineContent.trim(),
@@ -1544,6 +1571,7 @@ function tests(index, nameOrFile, options = {}) {
1544
1571
  }
1545
1572
  }
1546
1573
 
1574
+ seenLines.add(lineKey);
1547
1575
  matches.push({
1548
1576
  line: usage.line,
1549
1577
  content: lineContent.trim(),
@@ -1676,28 +1704,26 @@ function tests(index, nameOrFile, options = {}) {
1676
1704
  const info = testInfo.get(site.file);
1677
1705
  if (!info) continue;
1678
1706
  if (info.testRanges && !lineInRanges(site.line, info.testRanges)) continue;
1679
- // For a repeated name, the source-import scope is an additional
1680
- // identity guard. For a globally unique name, exact-target caller
1681
- // evidence is stronger than filesystem layout: workspace-level
1682
- // Rust tests, Go black-box packages, and generated test trees may
1683
- // exercise a crate/package without importing the defining file.
1684
- // Unverified sites remain visibly tiered; they are not promoted.
1685
- if (sourceFileFilter && !globallyUniqueTarget && !sourceFileFilter.has(site.file)) continue;
1686
- // A bare callback/reference/call can bind a standalone function
1687
- // with the same name, but it cannot identify a class method
1688
- // target. Use the parser's call-kind bit rather than requiring a
1689
- // textual receiver: fluent chains such as `.addStaticBlock(...)`
1690
- // have a call receiver but no simple receiver identifier. Rust
1691
- // proc-macro attribute references are handled by the AST usage
1692
- // branch above as explicitly unverified references.
1693
- if (className && !site.isMethod) continue;
1707
+ // Every site here was adjudicated against the exact target
1708
+ // definition by the caller engine (fix #402), so neither the file
1709
+ // layout nor the call shape is a second identity test: a test
1710
+ // reaches the target through a fixture-injected or inherited
1711
+ // receiver without importing the defining file, and a
1712
+ // receiver-less call reaches a method through implicit `this`, a
1713
+ // static import, a destructured member or a macro-generated test
1714
+ // body, exactly when the engine says so and in the tier it says.
1715
+ // Unverified sites stay visibly tiered; they are not promoted.
1694
1716
  if (excludeArr.length > 0 &&
1695
1717
  !index.matchesFilters(info.entry.relativePath, { exclude: excludeArr })) continue;
1696
- const localSameName = (info.entry.symbols || []).some(s => s.name === searchTerm);
1697
- const importsTargetName = (info.entry.importBindings || []).some(b => b.name === searchTerm);
1698
- const explicitlyScopedToLocal = !!options.file && targetDefs.some(d => d.file === site.file);
1699
- if (localSameName && !importsTargetName && !explicitlyScopedToLocal &&
1700
- !site.receiver && !site.isMethod) continue;
1718
+ if (site._testEvidenceTier !== 'confirmed') {
1719
+ // An unverified bare call in a test file that defines the
1720
+ // name itself most likely binds that local definition.
1721
+ const localSameName = (info.entry.symbols || []).some(s => s.name === searchTerm);
1722
+ const importsTargetName = (info.entry.importBindings || []).some(b => b.name === searchTerm);
1723
+ const explicitlyScopedToLocal = !!options.file && targetDefs.some(d => d.file === site.file);
1724
+ if (localSameName && !importsTargetName && !explicitlyScopedToLocal &&
1725
+ !site.receiver && !site.isMethod) continue;
1726
+ }
1701
1727
 
1702
1728
  let fileResult = results.find(r => r.file === info.entry.relativePath);
1703
1729
  if (!fileResult) {
@@ -1718,6 +1744,50 @@ function tests(index, nameOrFile, options = {}) {
1718
1744
  });
1719
1745
  }
1720
1746
 
1747
+ // Accessor targets (fix #398F): a property is consumed by reads and
1748
+ // writes, never call syntax, so the caller supplement above sees
1749
+ // none of `result.stdout`. Test-file accessor references whose
1750
+ // receiver identity the accessor engine proves are test links; the
1751
+ // unverified ones join only for a globally unique name, as above.
1752
+ const accessorDefs = targetDefs.filter(isAccessorDefinition);
1753
+ if (accessorDefs.length > 0) {
1754
+ const testPaths = new Set(testFiles.map(t => t.path));
1755
+ const globallyUnique = index.find(searchTerm, { exact: true }).length === 1;
1756
+ for (const def of accessorDefs) {
1757
+ const refs = findAccessorReferences(index, searchTerm, def, { files: testPaths });
1758
+ if (!refs) continue;
1759
+ const rows = [
1760
+ ...refs.confirmed.map(r => ({ ...r, tierName: 'confirmed' })),
1761
+ ...(globallyUnique ? refs.unverified.map(r => ({ ...r, tierName: 'unverified' })) : []),
1762
+ ];
1763
+ for (const ref of rows) {
1764
+ const info = testInfo.get(ref.absoluteFile);
1765
+ if (!info) continue;
1766
+ if (info.testRanges && !lineInRanges(ref.line, info.testRanges)) continue;
1767
+ if (excludeArr.length > 0 &&
1768
+ !index.matchesFilters(info.entry.relativePath, { exclude: excludeArr })) continue;
1769
+ let fileResult = results.find(r => r.file === info.entry.relativePath);
1770
+ if (!fileResult) {
1771
+ fileResult = { file: info.entry.relativePath, matches: [] };
1772
+ results.push(fileResult);
1773
+ }
1774
+ const matchType = ref.tierName === 'confirmed' ? 'reference' : 'unverified-reference';
1775
+ if (fileResult.matches.some(m => m.line === ref.line &&
1776
+ ['reference', 'unverified-reference', 'call', 'unverified-call'].includes(m.matchType))) continue;
1777
+ fileResult.matches = fileResult.matches.filter(m =>
1778
+ !(m.line === ref.line && m.matchType === 'test-case'));
1779
+ fileResult.matches.push({
1780
+ line: ref.line,
1781
+ content: ref.expression,
1782
+ matchType,
1783
+ evidenceTier: ref.tierName,
1784
+ ...(ref.resolution && { resolution: ref.resolution }),
1785
+ ...(ref.reason && { reason: ref.reason }),
1786
+ });
1787
+ }
1788
+ }
1789
+ }
1790
+
1721
1791
  for (const fileResult of results) {
1722
1792
  fileResult.matches.sort((a, b) => (a.line || 0) - (b.line || 0) ||
1723
1793
  codeUnitCompare(a.matchType || '', b.matchType || ''));
@@ -1903,6 +1973,23 @@ function _buildSourceFileImporters(index, defs) {
1903
1973
  return importers;
1904
1974
  }
1905
1975
 
1976
+ /** Does an import binding of `name` in this file reach a target
1977
+ * definition's file under that name (fix #400)? The caller engine's
1978
+ * name-level export chase. */
1979
+ function _importBindingReachesTargets(index, fileEntry, name, targetDefs) {
1980
+ if (!targetDefs || targetDefs.length === 0) return false;
1981
+ const targetFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
1982
+ const { _nameBindingReaches } = require('./callers');
1983
+ for (const binding of fileEntry.importBindings || []) {
1984
+ if ((binding.alias || binding.name) !== name || binding.module == null) continue;
1985
+ const rel = fileEntry.moduleResolved?.[binding.module];
1986
+ if (!rel) continue;
1987
+ const abs = path.isAbsolute(rel) ? rel : path.join(index.root, rel);
1988
+ if (_nameBindingReaches(index, abs, binding.name, targetFiles, 8, { exactName: true }) === 'yes') return true;
1989
+ }
1990
+ return false;
1991
+ }
1992
+
1906
1993
  /**
1907
1994
  * Check if a file re-exports a symbol from a source file.
1908
1995
  * Handles: named re-exports, `module.exports = require(...)` blanket re-exports,
@@ -1933,7 +2020,12 @@ function _fileReExportsSymbol(index, fileEntry, symbolName, sourceAbsPath) {
1933
2020
  !exp.name || exp.type === 'module.exports' || exp.type === 're-export' || exp.type === 'export-all'
1934
2021
  );
1935
2022
  if (hasBlanketExport) return true;
1936
- return false;
2023
+ // `export * from './m'` re-exports every name of the module it names
2024
+ // (fix #400): it carries the symbol on when that module is the file the
2025
+ // chain came from.
2026
+ return details.some(exp => exp.type === 're-export-all' && exp.source &&
2027
+ fileEntry.moduleResolved?.[exp.source] &&
2028
+ path.join(index.root, fileEntry.moduleResolved[exp.source]) === sourceAbsPath);
1937
2029
  }
1938
2030
 
1939
2031
  /**
package/core/shared.js CHANGED
@@ -59,7 +59,10 @@ function codeUnitCompare(a, b) {
59
59
  */
60
60
  function isTestPath(rp) {
61
61
  if (!rp) return false;
62
- const language = detectLanguage(rp);
62
+ // A `.h` header tests the same under the C and C++ conventions (neither
63
+ // has a `.spec` form, only C's patterns name `.h`): no header-language
64
+ // detection, which walks the project directory (fix #396).
65
+ const language = path.extname(rp).toLowerCase() === '.h' ? 'c' : detectLanguage(rp);
63
66
  const { langTraits } = require('../languages');
64
67
  const specConvention = langTraits(language)?.testFileCandidates?.('sample', '')
65
68
  .some(candidate => candidate.includes('.spec'));
@@ -484,6 +487,20 @@ function maskBlockComments(content, language) {
484
487
  return out.join('');
485
488
  }
486
489
 
490
+ /**
491
+ * Is a definition invoked through the macro namespace (fix #377)? A Rust
492
+ * `macro_rules!` macro, or a procedural macro function (`#[proc_macro]`,
493
+ * `#[proc_macro_attribute]`, `#[proc_macro_derive]`). C/C++ preprocessor
494
+ * macros are type 'macro' too; their invocations are ordinary call syntax.
495
+ */
496
+ function isMacroNamespaceDefinition(def) {
497
+ if (!def) return false;
498
+ if (def.type === 'macro') return true;
499
+ const modifiers = def.modifiers || [];
500
+ return modifiers.includes('proc_macro') || modifiers.includes('proc_macro_attribute') ||
501
+ modifiers.includes('proc_macro_derive');
502
+ }
503
+
487
504
  module.exports = { literalNameRegex,
488
505
  pickBestDefinition,
489
506
  addTestExclusions,
@@ -503,4 +520,5 @@ module.exports = { literalNameRegex,
503
520
  hasTextBlindspots,
504
521
  countTextBlindspots,
505
522
  isPathInsideRoot,
523
+ isMacroNamespaceDefinition,
506
524
  };