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
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Optional compiler/LSP semantic-provider contract.
5
+ *
6
+ * Portable AST analysis remains available without a provider. Hybrid mode
7
+ * calls this interface explicitly and must surface `unavailable`,
8
+ * `unsupported`, or `failed`; callers are never allowed to silently relabel
9
+ * an AST fallback as provider-backed evidence.
10
+ */
11
+
12
+ const PROVIDER_OPERATIONS = Object.freeze([
13
+ 'definitions',
14
+ 'references',
15
+ 'callers',
16
+ 'callees',
17
+ 'types',
18
+ 'diagnostics',
19
+ ]);
20
+
21
+ function createSemanticProvider(spec) {
22
+ if (!spec?.id) throw new Error('Semantic provider id is required');
23
+ if (!Array.isArray(spec.languages) || spec.languages.length === 0) {
24
+ throw new Error(`${spec.id}: at least one language is required`);
25
+ }
26
+ const operations = {};
27
+ for (const operation of PROVIDER_OPERATIONS) {
28
+ if (typeof spec[operation] === 'function') operations[operation] = spec[operation];
29
+ }
30
+ return Object.freeze({
31
+ id: spec.id,
32
+ languages: Object.freeze([...spec.languages]),
33
+ operations: Object.freeze(Object.keys(operations)),
34
+ prepare: typeof spec.prepare === 'function' ? spec.prepare : async () => null,
35
+ close: typeof spec.close === 'function' ? spec.close : async () => {},
36
+ ...operations,
37
+ });
38
+ }
39
+
40
+ function validateSemanticProvider(provider) {
41
+ const failures = [];
42
+ if (!provider?.id) failures.push('provider id is required');
43
+ if (!Array.isArray(provider?.languages) || provider.languages.length === 0) {
44
+ failures.push(`${provider?.id || 'provider'}: languages are required`);
45
+ }
46
+ if (!Array.isArray(provider?.operations)) {
47
+ failures.push(`${provider?.id || 'provider'}: operations are required`);
48
+ } else {
49
+ for (const operation of provider.operations) {
50
+ if (!PROVIDER_OPERATIONS.includes(operation)) {
51
+ failures.push(`${provider.id}: unknown operation ${operation}`);
52
+ } else if (typeof provider[operation] !== 'function') {
53
+ failures.push(`${provider.id}: ${operation} is not callable`);
54
+ }
55
+ }
56
+ }
57
+ if (typeof provider?.prepare !== 'function') {
58
+ failures.push(`${provider?.id || 'provider'}: prepare() is required`);
59
+ }
60
+ if (typeof provider?.close !== 'function') {
61
+ failures.push(`${provider?.id || 'provider'}: close() is required`);
62
+ }
63
+ return failures;
64
+ }
65
+
66
+ async function invokeSemanticProvider(provider, operation, request) {
67
+ if (!provider) {
68
+ return {
69
+ status: 'unavailable',
70
+ provider: null,
71
+ operation,
72
+ reason: 'no semantic provider configured',
73
+ };
74
+ }
75
+ if (!PROVIDER_OPERATIONS.includes(operation)) {
76
+ throw new Error(`Unknown semantic provider operation: ${operation}`);
77
+ }
78
+ if (!provider.operations.includes(operation) ||
79
+ typeof provider[operation] !== 'function') {
80
+ return {
81
+ status: 'unsupported',
82
+ provider: provider.id,
83
+ operation,
84
+ reason: `${provider.id} does not implement ${operation}`,
85
+ };
86
+ }
87
+ try {
88
+ const data = await provider[operation](request);
89
+ return {
90
+ status: 'ok',
91
+ provider: provider.id,
92
+ operation,
93
+ data,
94
+ };
95
+ } catch (error) {
96
+ return {
97
+ status: 'failed',
98
+ provider: provider.id,
99
+ operation,
100
+ reason: error.message || String(error),
101
+ };
102
+ }
103
+ }
104
+
105
+ module.exports = {
106
+ PROVIDER_OPERATIONS,
107
+ createSemanticProvider,
108
+ validateSemanticProvider,
109
+ invokeSemanticProvider,
110
+ };
@@ -276,6 +276,15 @@ function parseStackTrace(index, stackText) {
276
276
  // Stack trace patterns for different languages/runtimes
277
277
  // Order matters - more specific patterns first
278
278
  const patterns = [
279
+ // .NET: "at Namespace.Type.Method(...) in /src/File.cs:line 42"
280
+ // and Windows drive-letter paths.
281
+ { regex: /at\s+([^\s(]+)(?:\([^)]*\))?\s+in\s+(.+?):line\s+(\d+)/, extract: (m) => ({ funcName: m[1].split('.').pop(), file: m[2], line: parseInt(m[3]), col: null }) },
282
+ // Native sanitizers: "#0 0x... in ns::func /src/file.cpp:42:7"
283
+ { regex: /#\d+\s+(?:0x[0-9a-f]+\s+)?in\s+([^\s(]+)(?:\([^)]*\))?\s+(.+\.(?:c|cc|cpp|cxx|h|hpp)):(\d+)(?::(\d+))?/i, extract: (m) => ({ funcName: m[1].split('::').pop(), file: m[2], line: parseInt(m[3]), col: m[4] ? parseInt(m[4]) : null }) },
284
+ // GDB/lldb: "#0 ns::func (...) at src/file.cpp:42"
285
+ { regex: /#\d+\s+(?:0x[0-9a-f]+\s+in\s+)?([^\s(]+).*?\bat\s+(.+\.(?:c|cc|cpp|cxx|h|hpp)):(\d+)/i, extract: (m) => ({ funcName: m[1].split('::').pop(), file: m[2], line: parseInt(m[3]), col: null }) },
286
+ // MSVC diagnostics and native exception locations: "src\\file.cpp(42)"
287
+ { regex: /(.+\.(?:c|cc|cpp|cxx|h|hpp|cs))\((\d+)(?:,(\d+))?\)/i, extract: (m) => ({ file: m[1], line: parseInt(m[2]), col: m[3] ? parseInt(m[3]) : null, funcName: null }) },
279
288
  // Rust pre-1.65 panic header: "panicked at 'message', src/main.rs:150:9"
280
289
  // MUST precede the Node pattern — its [^():]+ file group has no
281
290
  // space/comma guard, so the quoted message glued into the file field
@@ -314,12 +323,22 @@ function parseStackTrace(index, stackText) {
314
323
 
315
324
  // Track Go function names that appear on a line before the file:line
316
325
  let pendingGoFuncName = null;
326
+ // Rust backtraces likewise split `N: crate::path` and `at file.rs:L:C`
327
+ // across two physical lines.
328
+ let pendingRustFuncName = null;
317
329
  let skippedFrames = 0;
318
330
 
319
331
  for (const line of lines) {
320
332
  const trimmed = line.trim();
321
333
  if (!trimmed) continue;
322
334
 
335
+ const rustName = trimmed.match(/^\d+:\s+(.+)$/);
336
+ if (rustName && !/\.rs:\d+/.test(rustName[1])) {
337
+ pendingRustFuncName = rustName[1].trim();
338
+ pendingGoFuncName = null;
339
+ continue;
340
+ }
341
+
323
342
  // Try each pattern until one matches
324
343
  let matched = false;
325
344
  for (const pattern of patterns) {
@@ -349,7 +368,12 @@ function parseStackTrace(index, stackText) {
349
368
  if (!extracted.funcName && pendingGoFuncName) {
350
369
  extracted.funcName = pendingGoFuncName;
351
370
  }
371
+ if (!extracted.funcName && pendingRustFuncName &&
372
+ /\.rs$/i.test(extracted.file)) {
373
+ extracted.funcName = pendingRustFuncName;
374
+ }
352
375
  pendingGoFuncName = null;
376
+ pendingRustFuncName = null;
353
377
  frames.push(createStackFrame(
354
378
  index,
355
379
  extracted.file,
@@ -370,6 +394,7 @@ function parseStackTrace(index, stackText) {
370
394
  // unfound-file frames, which render found=false).
371
395
  if (/^at\s/.test(trimmed)) skippedFrames++;
372
396
  pendingGoFuncName = null; // Reset if line doesn't match any pattern
397
+ pendingRustFuncName = null;
373
398
  }
374
399
  }
375
400
 
package/core/tracing.js CHANGED
@@ -23,7 +23,7 @@ const path = require('path');
23
23
  const { escapeRegExp, codeUnitCompare, inlineTestRanges, lineInRanges, classDispatchNames } = require('./shared');
24
24
  const { isTestFile } = require('./discovery');
25
25
  const { getCachedCalls } = require('./callers');
26
- const { getLanguageModule } = require('../languages');
26
+ const { getLanguageAdapter } = require('../languages');
27
27
 
28
28
  /**
29
29
  * Contract-mode caller expansion for the tree commands. Memoizes the full
@@ -86,20 +86,23 @@ function _aggregateExcluded(treeAccount, raw) {
86
86
  function _resolveCallerEntries(index, callers, exclude) {
87
87
  const uniqueCallers = new Map();
88
88
  for (const c of callers) {
89
- if (!c.callerName) continue; // skip module-level code
90
89
  if (exclude.length > 0 && !index.matchesFilters(c.relativePath, { exclude })) continue;
91
- const callerKey = c.callerStartLine
90
+ const hasNamedCaller = !!c.callerName;
91
+ const callerName = c.callerName || '(module/anonymous scope)';
92
+ const callerStartLine = hasNamedCaller ? c.callerStartLine : c.line;
93
+ const callerKey = hasNamedCaller && c.callerStartLine
92
94
  ? `${c.callerFile}:${c.callerStartLine}`
93
- : `${c.callerFile}:${c.callerName}`;
95
+ : `${c.callerFile}:${callerName}:${callerStartLine || c.line || 0}`;
94
96
  if (!uniqueCallers.has(callerKey)) {
95
97
  uniqueCallers.set(callerKey, {
96
- name: c.callerName,
98
+ name: callerName,
97
99
  file: c.callerFile,
98
100
  relativePath: c.relativePath,
99
- startLine: c.callerStartLine,
100
- endLine: c.callerEndLine,
101
+ startLine: callerStartLine,
102
+ endLine: hasNamedCaller ? c.callerEndLine : c.line,
101
103
  callSites: 1,
102
104
  reason: c.reason,
105
+ syntheticScope: !hasNamedCaller,
103
106
  });
104
107
  } else {
105
108
  uniqueCallers.get(callerKey).callSites++;
@@ -121,6 +124,7 @@ function _resolveCallerEntries(index, callers, exclude) {
121
124
  type: 'function'
122
125
  };
123
126
  }
127
+ if (caller.syntheticScope) callerDef.syntheticScope = true;
124
128
  callerEntries.push({ def: callerDef, callSites: caller.callSites, reason: caller.reason });
125
129
  }
126
130
 
@@ -195,6 +199,11 @@ function trace(index, name, options = {}) {
195
199
  children: []
196
200
  };
197
201
 
202
+ // A depth frontier is a node label, not a partially analyzed node.
203
+ // Do not expose only its unverified callee band while suppressing its
204
+ // confirmed children; both evidence tiers expand at the same depth.
205
+ if (currentDepth >= maxDepth) return node;
206
+
198
207
  if (dir === 'down' || dir === 'both') {
199
208
  let callees = calleeCache.get(key);
200
209
  if (!callees) {
@@ -347,15 +356,18 @@ function blast(index, name, options = {}) {
347
356
  let maxDepthReached = 0;
348
357
  let rootRaw = null;
349
358
  let rootFiltered = 0;
359
+ let rootSelfRecursive = false;
350
360
  const treeAccount = {
351
361
  nodesExpanded: 0,
352
362
  confirmedEdges: 0,
363
+ recursiveEdges: 0,
353
364
  unverifiedEdges: 0,
354
365
  unverifiedByReason: {},
355
366
  excludedTotal: 0,
356
367
  excludedByReason: {},
357
368
  filteredEdges: 0,
358
369
  depthLimitNodes: 0,
370
+ truncatedChildren: 0,
359
371
  };
360
372
 
361
373
  const buildCallerTree = (funcDef, currentDepth, chainUnverified) => {
@@ -407,6 +419,15 @@ function blast(index, name, options = {}) {
407
419
  treeAccount.filteredEdges += before - callers.length;
408
420
  if (currentDepth === 0) rootFiltered += before - callers.length;
409
421
  }
422
+ const recursiveCallers = callers.filter(c =>
423
+ c.callerFile === funcDef.file &&
424
+ c.callerStartLine === funcDef.startLine);
425
+ if (recursiveCallers.length > 0) {
426
+ callers = callers.filter(c => !recursiveCallers.includes(c));
427
+ node.selfRecursive = true;
428
+ treeAccount.recursiveEdges += recursiveCallers.length;
429
+ if (currentDepth === 0) rootSelfRecursive = true;
430
+ }
410
431
  treeAccount.confirmedEdges += callers.length;
411
432
  if (collect && !chainUnverified) {
412
433
  for (const c of callers) collect.onConfirmed?.(funcDef, c);
@@ -445,6 +466,7 @@ function blast(index, name, options = {}) {
445
466
 
446
467
  if (callerEntries.length > maxChildren) {
447
468
  node.truncatedChildren = callerEntries.length - maxChildren;
469
+ treeAccount.truncatedChildren += node.truncatedChildren;
448
470
  // Count truncated callers in summary
449
471
  for (const { def: cDef } of callerEntries.slice(maxChildren)) {
450
472
  const tKey = `${cDef.file}:${cDef.startLine}`;
@@ -536,6 +558,7 @@ function blast(index, name, options = {}) {
536
558
  maxDepthReached,
537
559
  unverifiedEdges: treeAccount.unverifiedEdges,
538
560
  ...(expandUnverified && { possiblyAffected: possiblyAffectedSet.size }),
561
+ ...(rootSelfRecursive && { selfRecursive: true }),
539
562
  },
540
563
  warnings: warnings.length > 0 ? warnings : undefined
541
564
  };
@@ -581,6 +604,7 @@ function reverseTrace(index, name, options = {}) {
581
604
  const treeAccount = {
582
605
  nodesExpanded: 0,
583
606
  confirmedEdges: 0,
607
+ recursiveEdges: 0,
584
608
  unverifiedEdges: 0,
585
609
  unverifiedByReason: {},
586
610
  excludedTotal: 0,
@@ -600,7 +624,14 @@ function reverseTrace(index, name, options = {}) {
600
624
  unv = unv.filter(c => index.matchesFilters(c.relativePath, { exclude }));
601
625
  if (isExpansion) treeAccount.filteredEdges += before - conf.length - unv.length;
602
626
  }
603
- return { confirmed: conf, unverified: unv, raw };
627
+ const recursive = conf.filter(c =>
628
+ c.callerFile === funcDef.file &&
629
+ c.callerStartLine === funcDef.startLine);
630
+ if (recursive.length > 0) {
631
+ conf = conf.filter(c => !recursive.includes(c));
632
+ if (isExpansion) treeAccount.recursiveEdges += recursive.length;
633
+ }
634
+ return { confirmed: conf, unverified: unv, recursive, raw };
604
635
  };
605
636
 
606
637
  const buildCallerTree = (funcDef, currentDepth, chainUnverified) => {
@@ -629,7 +660,8 @@ function reverseTrace(index, name, options = {}) {
629
660
  };
630
661
 
631
662
  if (currentDepth < maxDepth) {
632
- const { confirmed, unverified, raw } = nodeCallers(funcDef, true);
663
+ const { confirmed, unverified, recursive, raw } = nodeCallers(funcDef, true);
664
+ if (recursive.length > 0) node.selfRecursive = true;
633
665
  treeAccount.nodesExpanded++;
634
666
  _aggregateExcluded(treeAccount, raw);
635
667
  if (currentDepth === 0) {
@@ -666,7 +698,8 @@ function reverseTrace(index, name, options = {}) {
666
698
  const cKey = `${cDef.file}:${cDef.startLine}`;
667
699
  if (!visited.has(cKey)) {
668
700
  const tiers = nodeCallers(cDef, false);
669
- if (tiers.confirmed.length === 0 && tiers.unverified.length === 0) {
701
+ if (tiers.confirmed.length === 0 && tiers.unverified.length === 0 &&
702
+ tiers.recursive.length === 0) {
670
703
  entryPoints.push({ name: cDef.name, file: cDef.relativePath || path.relative(index.root, cDef.file), line: cDef.startLine });
671
704
  }
672
705
  }
@@ -688,9 +721,11 @@ function reverseTrace(index, name, options = {}) {
688
721
  // Entry point only when BOTH tiers are empty; unverified-only
689
722
  // nodes are visibly not-confirmed instead.
690
723
  if (callerEntries.length === 0 && currentDepth > 0) {
691
- if (unverified.length === 0) {
724
+ if (unverified.length === 0 && recursive.length === 0) {
692
725
  node.entryPoint = true;
693
726
  entryPoints.push({ name: funcDef.name, file: funcDef.relativePath, line: funcDef.startLine });
727
+ } else if (recursive.length > 0) {
728
+ node.selfRecursive = true;
694
729
  } else {
695
730
  node.unverifiedCallerCount = unverified.length;
696
731
  }
@@ -699,10 +734,12 @@ function reverseTrace(index, name, options = {}) {
699
734
  // At depth limit: check if this node is an entry point
700
735
  treeAccount.depthLimitNodes++;
701
736
  const tiers = nodeCallers(funcDef, false);
702
- if (tiers.confirmed.filter(c => c.callerName).length === 0) {
703
- if (tiers.unverified.length === 0) {
737
+ if (tiers.confirmed.length === 0) {
738
+ if (tiers.unverified.length === 0 && tiers.recursive.length === 0) {
704
739
  node.entryPoint = true;
705
740
  entryPoints.push({ name: funcDef.name, file: funcDef.relativePath, line: funcDef.startLine });
741
+ } else if (tiers.recursive.length > 0) {
742
+ node.selfRecursive = true;
706
743
  } else {
707
744
  node.unverifiedCallerCount = tiers.unverified.length;
708
745
  }
@@ -716,7 +753,10 @@ function reverseTrace(index, name, options = {}) {
716
753
 
717
754
  // Also mark root as entry point if it has no callers in either tier
718
755
  if (tree && tree.children.length === 0 && maxDepth > 0) {
719
- if (rootUnverifiedCount === 0) {
756
+ const rootConfirmedCount = rootRaw
757
+ ? rootRaw.filter(c => c.tier !== 'unverified').length
758
+ : 0;
759
+ if (rootConfirmedCount === 0 && rootUnverifiedCount === 0 && !tree.selfRecursive) {
720
760
  tree.entryPoint = true;
721
761
  entryPoints.push({ name: def.name, file: def.relativePath, line: def.startLine });
722
762
  } else {
@@ -771,6 +811,7 @@ function reverseTrace(index, name, options = {}) {
771
811
  totalFunctions: visited.size - 1, // exclude root
772
812
  maxDepthReached,
773
813
  unverifiedEdges: treeAccount.unverifiedEdges,
814
+ ...(tree?.selfRecursive && { selfRecursive: true }),
774
815
  },
775
816
  warnings: warnings.length > 0 ? warnings : undefined
776
817
  };
@@ -784,12 +825,12 @@ function reverseTrace(index, name, options = {}) {
784
825
  * Two bands (tree contract): `affectedFunctions`/`testFiles` come from the
785
826
  * confirmed-chain closure; names reachable only through >= 1 unverified hop
786
827
  * land in `possiblyAffected`, their additional test files in
787
- * `possiblyAffectedTests`. Coverage/uncovered claims are confirmed-band only.
828
+ * `possiblyAffectedTests`. Static-linkage claims are confirmed-band only.
788
829
  *
789
830
  * @param {object} index - ProjectIndex instance
790
831
  * @param {string} name - Function name
791
832
  * @param {object} options - { depth, file, className, exclude, includeMethods }
792
- * @returns {object|null} Affected test files with coverage stats
833
+ * @returns {object|null} Affected test files with static-linkage stats
793
834
  */
794
835
  function affectedTests(index, name, options = {}) {
795
836
  index._beginOp();
@@ -898,7 +939,7 @@ function affectedTests(index, name, options = {}) {
898
939
  if (!isTestCaller && fe.callerName) {
899
940
  const defs = index.symbols.get(fe.callerName);
900
941
  const d = defs?.find(x => x.file === fe.callerFile && x.startLine === fe.callerStartLine);
901
- const kindOf = getLanguageModule(cfe.language)?.getEntryPointKind;
942
+ const kindOf = getLanguageAdapter(cfe.language)?.getEntryPointKind;
902
943
  if (d && kindOf && kindOf(d) === 'test') isTestCaller = true;
903
944
  }
904
945
  if (!isTestCaller && fe.line != null) {
@@ -1130,7 +1171,7 @@ function affectedTests(index, name, options = {}) {
1130
1171
  }
1131
1172
 
1132
1173
  if (fileMatches.size > 0) {
1133
- const coveredFunctions = [...fileMatches.keys()];
1174
+ const linkedFunctions = [...fileMatches.keys()];
1134
1175
  const allMatches = [];
1135
1176
  for (const matches of fileMatches.values()) allMatches.push(...matches);
1136
1177
  // Deduplicate same line+function (test-case line might overlap with call line)
@@ -1144,40 +1185,41 @@ function affectedTests(index, name, options = {}) {
1144
1185
  }
1145
1186
  const deduped = [...dedupMap.values()].sort((a, b) => a.line - b.line);
1146
1187
 
1147
- // Only count functions with call or test-case matches as covered.
1148
- // Import-only or reference-only functions are not real coverage.
1149
- const realCoveredAll = coveredFunctions.filter(fn => {
1188
+ // Only count functions with call or test-case matches as
1189
+ // statically linked. Import-only or reference-only records
1190
+ // are useful evidence, but are not an exercising path.
1191
+ const realLinkedAll = linkedFunctions.filter(fn => {
1150
1192
  const fnMatches = deduped.filter(m => m.functionName === fn);
1151
1193
  return fnMatches.some(m => m.matchType === 'call' || m.matchType === 'test-case');
1152
1194
  });
1153
- const realCoveredFunctions = realCoveredAll.filter(fn => affectedNames.has(fn));
1154
- const possiblyCovered = realCoveredAll.filter(fn => possiblyNames.has(fn));
1195
+ const staticallyLinkedFunctions = realLinkedAll.filter(fn => affectedNames.has(fn));
1196
+ const possiblyLinkedFunctions = realLinkedAll.filter(fn => possiblyNames.has(fn));
1155
1197
 
1156
- if (realCoveredFunctions.length > 0) {
1157
- // Confirmed band: matches for confirmed-covered names
1198
+ if (staticallyLinkedFunctions.length > 0) {
1199
+ // Confirmed band: matches for confirmed-linked names
1158
1200
  const realMatches = deduped.filter(m =>
1159
1201
  affectedNames.has(m.functionName) &&
1160
1202
  (m.matchType === 'call' || m.matchType === 'test-case' ||
1161
- realCoveredFunctions.includes(m.functionName))
1203
+ staticallyLinkedFunctions.includes(m.functionName))
1162
1204
  );
1163
1205
  results.push({
1164
1206
  file: fileEntry.relativePath,
1165
- coveredFunctions: realCoveredFunctions,
1166
- ...(possiblyCovered.length > 0 && { possiblyCovered }),
1207
+ linkedFunctions: staticallyLinkedFunctions,
1208
+ ...(possiblyLinkedFunctions.length > 0 && { possiblyLinkedFunctions }),
1167
1209
  matchCount: realMatches.length,
1168
1210
  matches: realMatches
1169
1211
  });
1170
- } else if (possiblyCovered.length > 0) {
1212
+ } else if (possiblyLinkedFunctions.length > 0) {
1171
1213
  // Possible band: file reaches the change only through
1172
1214
  // unverified chains.
1173
1215
  const possibleMatches = deduped.filter(m =>
1174
1216
  possiblyNames.has(m.functionName) &&
1175
1217
  (m.matchType === 'call' || m.matchType === 'test-case' ||
1176
- possiblyCovered.includes(m.functionName))
1218
+ possiblyLinkedFunctions.includes(m.functionName))
1177
1219
  );
1178
1220
  possibleResults.push({
1179
1221
  file: fileEntry.relativePath,
1180
- coveredFunctions: possiblyCovered,
1222
+ linkedFunctions: possiblyLinkedFunctions,
1181
1223
  matchCount: possibleMatches.length,
1182
1224
  matches: possibleMatches
1183
1225
  });
@@ -1199,22 +1241,25 @@ function affectedTests(index, name, options = {}) {
1199
1241
  if (inResults || inPossible) {
1200
1242
  const existing = inResults || inPossible;
1201
1243
  const have = new Set([
1202
- ...existing.coveredFunctions,
1203
- ...(existing.possiblyCovered || []),
1244
+ ...existing.linkedFunctions,
1245
+ ...(existing.possiblyLinkedFunctions || []),
1204
1246
  ]);
1205
1247
  const extra = [...entry.byName.keys()].filter(n => !have.has(n)).sort(codeUnitCompare);
1206
1248
  if (extra.length > 0) {
1207
1249
  if (inResults) {
1208
- existing.possiblyCovered = [...(existing.possiblyCovered || []), ...extra].sort(codeUnitCompare);
1250
+ existing.possiblyLinkedFunctions = [
1251
+ ...(existing.possiblyLinkedFunctions || []),
1252
+ ...extra,
1253
+ ].sort(codeUnitCompare);
1209
1254
  } else {
1210
- existing.coveredFunctions = [...existing.coveredFunctions, ...extra].sort(codeUnitCompare);
1255
+ existing.linkedFunctions = [...existing.linkedFunctions, ...extra].sort(codeUnitCompare);
1211
1256
  }
1212
1257
  }
1213
1258
  continue;
1214
1259
  }
1215
- const covered = [...entry.byName.keys()].sort(codeUnitCompare);
1260
+ const linked = [...entry.byName.keys()].sort(codeUnitCompare);
1216
1261
  const matches = [];
1217
- for (const fn of covered) {
1262
+ for (const fn of linked) {
1218
1263
  for (const ln of [...entry.byName.get(fn)].sort((a, b) => a - b)) {
1219
1264
  matches.push({
1220
1265
  line: ln,
@@ -1226,17 +1271,17 @@ function affectedTests(index, name, options = {}) {
1226
1271
  }
1227
1272
  possibleResults.push({
1228
1273
  file: entry.rel,
1229
- coveredFunctions: covered,
1274
+ linkedFunctions: linked,
1230
1275
  matchCount: matches.length,
1231
1276
  matches,
1232
1277
  });
1233
1278
  }
1234
1279
 
1235
- // Sort by coverage breadth then alphabetically
1236
- results.sort((a, b) => b.coveredFunctions.length - a.coveredFunctions.length || codeUnitCompare(a.file, b.file));
1237
- possibleResults.sort((a, b) => b.coveredFunctions.length - a.coveredFunctions.length || codeUnitCompare(a.file, b.file));
1280
+ // Sort by linkage breadth then alphabetically
1281
+ results.sort((a, b) => b.linkedFunctions.length - a.linkedFunctions.length || codeUnitCompare(a.file, b.file));
1282
+ possibleResults.sort((a, b) => b.linkedFunctions.length - a.linkedFunctions.length || codeUnitCompare(a.file, b.file));
1238
1283
 
1239
- // Compute coverage stats.
1284
+ // Compute static-linkage stats.
1240
1285
  // Filter out test function names from affectedNames — they are callers,
1241
1286
  // not production symbols that need test coverage.
1242
1287
  const isProductionName = (n) => {
@@ -1246,7 +1291,7 @@ function affectedTests(index, name, options = {}) {
1246
1291
  // language's getEntryPointKind says so; they need no coverage.
1247
1292
  for (const [, fe] of index.files) {
1248
1293
  if (isTestFile(fe.relativePath, fe.language)) continue;
1249
- const langModule = getLanguageModule(fe.language);
1294
+ const langModule = getLanguageAdapter(fe.language);
1250
1295
  const kindOf = langModule?.getEntryPointKind;
1251
1296
  if (fe.symbols?.some(s => s.name === n && (!kindOf || kindOf(s) !== 'test'))) {
1252
1297
  return true;
@@ -1262,15 +1307,20 @@ function affectedTests(index, name, options = {}) {
1262
1307
  const namesForCoverage = productionNames.size > 0 ? productionNames : affectedNames;
1263
1308
  const possiblyProduction = [...possiblyNames].filter(isProductionName);
1264
1309
 
1265
- const coveredSet = new Set();
1266
- for (const r of results) for (const f of r.coveredFunctions) {
1267
- if (namesForCoverage.has(f)) coveredSet.add(f);
1310
+ const linkedSet = new Set();
1311
+ for (const r of results) for (const f of r.linkedFunctions) {
1312
+ if (namesForCoverage.has(f)) linkedSet.add(f);
1268
1313
  }
1269
- const uncovered = [...namesForCoverage].filter(n => !coveredSet.has(n));
1314
+ const notStaticallyLinked = [...namesForCoverage].filter(n => !linkedSet.has(n));
1270
1315
 
1271
1316
  return {
1272
1317
  root: blastResult.root, file: blastResult.file, line: blastResult.line,
1273
1318
  depth: blastResult.maxDepth,
1319
+ selection: {
1320
+ basis: 'static-call-and-reference-links',
1321
+ runtimeCoverage: false,
1322
+ absenceProvesUntested: false,
1323
+ },
1274
1324
  affectedFunctions: [...namesForCoverage],
1275
1325
  possiblyAffected: possiblyProduction,
1276
1326
  testFiles: results,
@@ -1280,13 +1330,13 @@ function affectedTests(index, name, options = {}) {
1280
1330
  summary: {
1281
1331
  totalAffected: namesForCoverage.size,
1282
1332
  totalTestFiles: results.length,
1283
- coveredFunctions: coveredSet.size,
1284
- uncoveredCount: uncovered.length,
1333
+ staticallyLinkedFunctions: linkedSet.size,
1334
+ notStaticallyLinkedCount: notStaticallyLinked.length,
1285
1335
  possiblyAffected: possiblyProduction.length,
1286
1336
  possiblyAffectedTests: possibleResults.length,
1287
1337
  unverifiedEdges: blastResult.summary ? blastResult.summary.unverifiedEdges : 0,
1288
1338
  },
1289
- uncovered,
1339
+ notStaticallyLinked,
1290
1340
  warnings: blastResult.warnings,
1291
1341
  };
1292
1342
  } finally { index._endOp(); }
@@ -1329,7 +1379,7 @@ function _addAffectedTestCases(index, filePath, fileEntry, funcName, fileMatches
1329
1379
  } else {
1330
1380
  if (!fileEntry.symbols) return;
1331
1381
  try {
1332
- const langModule = getLanguageModule(lang);
1382
+ const langModule = getLanguageAdapter(lang);
1333
1383
  if (!langModule) return;
1334
1384
  // Prefer the kinded predicate so we don't mis-tag fn main() / fn init()
1335
1385
  // (runtime entries) as test cases (BUG-CX). Fall back to isEntryPoint