ucn 5.5.3 → 5.6.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 (77) hide show
  1. package/.claude/skills/ucn/SKILL.md +37 -12
  2. package/.claude/skills/ucn/references/commands.md +18 -14
  3. package/.claude/skills/ucn/references/resolution-rules.md +4 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +4 -4
  5. package/CHANGELOG.md +485 -0
  6. package/README.md +66 -29
  7. package/cli/flags.js +184 -0
  8. package/cli/index.js +137 -324
  9. package/core/account.js +44 -87
  10. package/core/analysis.js +274 -108
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +14 -0
  13. package/core/cache.js +103 -16
  14. package/core/call-fit.js +130 -0
  15. package/core/callers.js +353 -71
  16. package/core/capture.js +363 -0
  17. package/core/change-kinds.js +1107 -0
  18. package/core/check.js +1150 -139
  19. package/core/command-contracts.js +8 -7
  20. package/core/constructed-class.js +1195 -0
  21. package/core/contract-membership.js +142 -0
  22. package/core/deadcode.js +455 -106
  23. package/core/deletion.js +421 -0
  24. package/core/discovery.js +104 -9
  25. package/core/entrypoint-toml.js +110 -0
  26. package/core/entrypoints.js +12 -6
  27. package/core/evidence-bands.js +68 -0
  28. package/core/evidence-bands.json +44 -0
  29. package/core/execute.js +172 -161
  30. package/core/hot-summary.js +241 -0
  31. package/core/index-ir.js +5 -3
  32. package/core/ir.js +6 -2
  33. package/core/member-signatures.js +789 -0
  34. package/core/outgoing-check.js +116 -0
  35. package/core/output/analysis-ext.js +20 -10
  36. package/core/output/analysis.js +98 -60
  37. package/core/output/check.js +101 -37
  38. package/core/output/doctor.js +34 -1
  39. package/core/output/find.js +2 -1
  40. package/core/output/lines.js +63 -3
  41. package/core/output/public.js +29 -11
  42. package/core/output/refactoring.js +32 -0
  43. package/core/output/reporting.js +19 -2
  44. package/core/output/search.js +14 -2
  45. package/core/output/tracing.js +36 -8
  46. package/core/output-budget.js +146 -61
  47. package/core/parallel-build.js +3 -3
  48. package/core/project.js +106 -23
  49. package/core/provenance-type.js +12 -5
  50. package/core/public-command.js +35 -9
  51. package/core/python-binding-identity.js +707 -0
  52. package/core/python-modules.js +6 -0
  53. package/core/reflection.js +248 -31
  54. package/core/registry.js +623 -72
  55. package/core/reporting.js +69 -34
  56. package/core/review-items.js +75 -0
  57. package/core/runtime-entrypoints.js +159 -0
  58. package/core/search.js +148 -164
  59. package/core/shared.js +0 -45
  60. package/core/snapshot.js +350 -0
  61. package/core/source-universe.js +707 -0
  62. package/core/test-units.js +3698 -0
  63. package/core/tracing.js +270 -658
  64. package/core/verify.js +63 -22
  65. package/languages/adapter.js +8 -3
  66. package/languages/c-family.js +12 -0
  67. package/languages/csharp.js +634 -0
  68. package/languages/go.js +120 -0
  69. package/languages/index.js +466 -4
  70. package/languages/java.js +712 -0
  71. package/languages/javascript.js +1498 -6
  72. package/languages/lexical-scope.js +68 -0
  73. package/languages/python.js +1708 -3
  74. package/languages/rust.js +209 -104
  75. package/languages/static-js.js +88 -0
  76. package/mcp/server.js +44 -110
  77. package/package.json +11 -5
package/core/tracing.js CHANGED
@@ -20,10 +20,11 @@
20
20
  'use strict';
21
21
 
22
22
  const path = require('path');
23
- const { escapeRegExp, codeUnitCompare, inlineTestRanges, lineInRanges, classDispatchNames } = require('./shared');
23
+ const { codeUnitCompare, lineInRanges } = require('./shared');
24
+ const { paramDefault } = require('./registry');
24
25
  const { isTestFile } = require('./discovery');
25
- const { getCachedCalls } = require('./callers');
26
26
  const { getLanguageAdapter } = require('../languages');
27
+ const { createTestModel, addTestCaseRows, linkFiles, commandsFor, testSite, testSiteKey } = require('./test-units');
27
28
 
28
29
  /**
29
30
  * Contract-mode caller expansion for the tree commands. Memoizes the full
@@ -32,10 +33,10 @@ const { getLanguageAdapter } = require('../languages');
32
33
  * frontier display and the affected-tests possible closure need them all.
33
34
  */
34
35
  function _contractCallers(index, funcDef, { includeMethods, callerCache, pin }) {
35
- const nodeKey = `${funcDef.file}:${funcDef.startLine}`;
36
- const cacheKey = funcDef.bindingId
37
- ? `${funcDef.name}:${funcDef.bindingId}`
38
- : `${funcDef.name}:${nodeKey}`;
36
+ if (funcDef.syntheticScope) return { confirmed: [], unverified: [], raw: [] };
37
+ const nodeKey = declarationKey(funcDef);
38
+ const definitionKey = `${funcDef.name}:${nodeKey}`;
39
+ const cacheKey = `${pin ? 'pinned:' : ''}${definitionKey}`;
39
40
  let res = callerCache.get(cacheKey);
40
41
  if (!res) {
41
42
  const raw = index.findCallers(funcDef.name, {
@@ -83,56 +84,40 @@ function _aggregateExcluded(treeAccount, raw) {
83
84
  * to its symbol-table definition (pseudo-definition when absent). Shared by
84
85
  * the confirmed trunk and the expand-unverified path.
85
86
  */
87
+ function declarationKey(def) {
88
+ return [def.file, def.name, def.type || '', def.className || '', def.namespace || '',
89
+ def.sourceSpan?.start ?? def.startLine, def.sourceSpan?.end ?? def.endLine].join(':');
90
+ }
91
+
92
+ function callerDefinition(index, site) {
93
+ const candidates = (index.symbols.get(site.callerName) || []).filter(def =>
94
+ def.file === site.callerFile && def.startLine === site.callerStartLine);
95
+ const { containsCall } = require('./callable-scope');
96
+ const exact = candidates.filter(def => containsCall(def, testSite(site)) === true)
97
+ .sort((a, b) => (a.sourceSpan.end - a.sourceSpan.start) - (b.sourceSpan.end - b.sourceSpan.start));
98
+ if (exact.length > 0) return exact[0];
99
+ return candidates.length === 1 ? candidates[0] : null;
100
+ }
101
+
86
102
  function _resolveCallerEntries(index, callers, exclude) {
87
103
  const uniqueCallers = new Map();
88
- for (const c of callers) {
89
- if (exclude.length > 0 && !index.matchesFilters(c.relativePath, { exclude })) continue;
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
94
- ? `${c.callerFile}:${c.callerStartLine}`
95
- : `${c.callerFile}:${callerName}:${callerStartLine || c.line || 0}`;
96
- if (!uniqueCallers.has(callerKey)) {
97
- uniqueCallers.set(callerKey, {
98
- name: callerName,
99
- file: c.callerFile,
100
- relativePath: c.relativePath,
101
- startLine: callerStartLine,
102
- endLine: hasNamedCaller ? c.callerEndLine : c.line,
103
- callSites: 1,
104
- reason: c.reason,
105
- syntheticScope: !hasNamedCaller,
106
- });
107
- } else {
108
- uniqueCallers.get(callerKey).callSites++;
109
- }
110
- }
111
-
112
- const callerEntries = [];
113
- for (const [, caller] of uniqueCallers) {
114
- const defs = index.symbols.get(caller.name);
115
- let callerDef = defs?.find(d => d.file === caller.file && d.startLine === caller.startLine);
116
- if (!callerDef) {
117
- // Pseudo-definition for callers not in symbol table
118
- callerDef = {
119
- name: caller.name,
120
- file: caller.file,
121
- relativePath: caller.relativePath,
122
- startLine: caller.startLine,
123
- endLine: caller.endLine,
124
- type: 'function'
125
- };
126
- }
127
- if (caller.syntheticScope) callerDef.syntheticScope = true;
128
- callerEntries.push({ def: callerDef, callSites: caller.callSites, reason: caller.reason });
104
+ for (const site of callers) {
105
+ if (exclude.length > 0 && !index.matchesFilters(site.relativePath, { exclude })) continue;
106
+ const named = callerDefinition(index, site);
107
+ const def = named || {
108
+ name: site.callerName || '(module/anonymous scope)',
109
+ file: site.callerFile || site.file, relativePath: site.relativePath,
110
+ startLine: site.callerStartLine || site.line, endLine: site.callerEndLine || site.line,
111
+ type: 'function', syntheticScope: true,
112
+ };
113
+ const key = named ? declarationKey(named) : `${declarationKey(def)}:${testSiteKey(site)}`;
114
+ if (uniqueCallers.has(key)) uniqueCallers.get(key).callSites++;
115
+ else uniqueCallers.set(key, { def, callSites: 1, reason: site.reason });
129
116
  }
130
-
131
- // Stable sort by file + line
132
- callerEntries.sort((a, b) =>
133
- codeUnitCompare(a.def.file, b.def.file) || a.def.startLine - b.def.startLine
134
- );
135
- return callerEntries;
117
+ return [...uniqueCallers.values()].sort((a, b) =>
118
+ codeUnitCompare(a.def.file, b.def.file) || a.def.startLine - b.def.startLine ||
119
+ (a.def.sourceSpan?.start || 0) - (b.def.sourceSpan?.start || 0) ||
120
+ codeUnitCompare(declarationKey(a.def), declarationKey(b.def)));
136
121
  }
137
122
 
138
123
  /**
@@ -147,7 +132,7 @@ function trace(index, name, options = {}) {
147
132
  index._beginOp();
148
133
  try {
149
134
  // Sanitize depth: use default for null/undefined, clamp negative to 0
150
- const rawDepth = options.depth ?? 3;
135
+ const rawDepth = options.depth ?? paramDefault('depth', 'trace');
151
136
  const maxDepth = Math.max(0, rawDepth);
152
137
  const direction = options.direction || 'down'; // 'down' = callees, 'up' = callers, 'both'
153
138
  const maxChildren = options.all ? Infinity : 10;
@@ -174,7 +159,7 @@ function trace(index, name, options = {}) {
174
159
 
175
160
  const buildTree = (funcDef, currentDepth, dir) => {
176
161
  const funcName = funcDef.name;
177
- const key = `${funcDef.file}:${funcDef.startLine}`;
162
+ const key = declarationKey(funcDef);
178
163
  if (currentDepth > maxDepth) {
179
164
  return null;
180
165
  }
@@ -340,7 +325,7 @@ function trace(index, name, options = {}) {
340
325
  function blast(index, name, options = {}) {
341
326
  index._beginOp();
342
327
  try {
343
- const maxDepth = Math.max(0, options.depth ?? 3);
328
+ const maxDepth = Math.max(0, options.depth ?? paramDefault('depth', 'trace'));
344
329
  const maxChildren = options.all ? Infinity : 10;
345
330
  const includeMethods = options.includeMethods ?? true;
346
331
  const exclude = options.exclude || [];
@@ -354,7 +339,9 @@ function blast(index, name, options = {}) {
354
339
  if (!def) return null;
355
340
 
356
341
  const visited = new Set();
357
- const callerCache = new Map();
342
+ // A caller's answer about one definition is the same whichever
343
+ // root the walk started from: a selection over many roots shares it.
344
+ const callerCache = options._callerCache || new Map();
358
345
  const affectedFunctions = new Set();
359
346
  const possiblyAffectedSet = new Set();
360
347
  const affectedFiles = new Set();
@@ -377,7 +364,7 @@ function blast(index, name, options = {}) {
377
364
  };
378
365
 
379
366
  const buildCallerTree = (funcDef, currentDepth, chainUnverified) => {
380
- const key = `${funcDef.file}:${funcDef.startLine}`;
367
+ const key = declarationKey(funcDef);
381
368
  if (currentDepth > maxDepth) return null;
382
369
  if (visited.has(key)) {
383
370
  return {
@@ -409,9 +396,12 @@ function blast(index, name, options = {}) {
409
396
  ...(chainUnverified && { chainUnverified: true }),
410
397
  children: []
411
398
  };
399
+ if (collect && !chainUnverified) collect.onNode?.(funcDef);
412
400
 
413
401
  if (currentDepth < maxDepth) {
414
- const { confirmed, unverified, raw } = _contractCallers(index, funcDef, { includeMethods, callerCache });
402
+ const { confirmed, unverified, raw } = _contractCallers(index, funcDef, {
403
+ includeMethods, callerCache, pin: options._pinDefinitions,
404
+ });
415
405
  treeAccount.nodesExpanded++;
416
406
  _aggregateExcluded(treeAccount, raw);
417
407
  if (currentDepth === 0) rootRaw = raw;
@@ -426,8 +416,7 @@ function blast(index, name, options = {}) {
426
416
  if (currentDepth === 0) rootFiltered += before - callers.length;
427
417
  }
428
418
  const recursiveCallers = callers.filter(c =>
429
- c.callerFile === funcDef.file &&
430
- c.callerStartLine === funcDef.startLine);
419
+ declarationKey(callerDefinition(index, c) || {}) === declarationKey(funcDef));
431
420
  if (recursiveCallers.length > 0) {
432
421
  callers = callers.filter(c => !recursiveCallers.includes(c));
433
422
  node.selfRecursive = true;
@@ -475,7 +464,7 @@ function blast(index, name, options = {}) {
475
464
  treeAccount.truncatedChildren += node.truncatedChildren;
476
465
  // Count truncated callers in summary
477
466
  for (const { def: cDef } of callerEntries.slice(maxChildren)) {
478
- const tKey = `${cDef.file}:${cDef.startLine}`;
467
+ const tKey = declarationKey(cDef);
479
468
  if (!visited.has(tKey)) {
480
469
  if (chainUnverified) {
481
470
  possiblyAffectedSet.add(tKey);
@@ -501,7 +490,7 @@ function blast(index, name, options = {}) {
501
490
  }
502
491
  }
503
492
  for (const { def: cDef } of unvEntries.slice(maxChildren)) {
504
- const tKey = `${cDef.file}:${cDef.startLine}`;
493
+ const tKey = declarationKey(cDef);
505
494
  if (!visited.has(tKey)) possiblyAffectedSet.add(tKey);
506
495
  }
507
496
  }
@@ -515,6 +504,14 @@ function blast(index, name, options = {}) {
515
504
 
516
505
  const tree = buildCallerTree(def, 0, false);
517
506
 
507
+ if (options._testSitesOnly) {
508
+ // Internal check selection needs only the declarations observed
509
+ // by _collect. Caller tiering above still uses collectAccount;
510
+ // the root account/frontier below are public presentation, not
511
+ // evidence that can change the confirmed traversal.
512
+ return { root: name, file: def.relativePath, line: def.startLine, maxDepth };
513
+ }
514
+
518
515
  // Root text-ground account (the context/impact contract at hop 1).
519
516
  // Ground call-lines no candidate claimed are frontier entries too —
520
517
  // counted in the account's unverified total, listed here.
@@ -590,7 +587,7 @@ function blast(index, name, options = {}) {
590
587
  function reverseTrace(index, name, options = {}) {
591
588
  index._beginOp();
592
589
  try {
593
- const maxDepth = Math.max(0, options.depth ?? 5);
590
+ const maxDepth = Math.max(0, options.depth ?? paramDefault('depth', 'trace', 'entrypoints'));
594
591
  const maxChildren = options.all ? Infinity : 10;
595
592
  const includeMethods = options.includeMethods ?? true;
596
593
  const exclude = options.exclude || [];
@@ -631,8 +628,7 @@ function reverseTrace(index, name, options = {}) {
631
628
  if (isExpansion) treeAccount.filteredEdges += before - conf.length - unv.length;
632
629
  }
633
630
  const recursive = conf.filter(c =>
634
- c.callerFile === funcDef.file &&
635
- c.callerStartLine === funcDef.startLine);
631
+ declarationKey(callerDefinition(index, c) || {}) === declarationKey(funcDef));
636
632
  if (recursive.length > 0) {
637
633
  conf = conf.filter(c => !recursive.includes(c));
638
634
  if (isExpansion) treeAccount.recursiveEdges += recursive.length;
@@ -641,7 +637,7 @@ function reverseTrace(index, name, options = {}) {
641
637
  };
642
638
 
643
639
  const buildCallerTree = (funcDef, currentDepth, chainUnverified) => {
644
- const key = `${funcDef.file}:${funcDef.startLine}`;
640
+ const key = declarationKey(funcDef);
645
641
  if (currentDepth > maxDepth) return null;
646
642
  if (visited.has(key)) {
647
643
  return {
@@ -701,7 +697,7 @@ function reverseTrace(index, name, options = {}) {
701
697
  node.truncatedChildren = callerEntries.length - maxChildren;
702
698
  // Count entry points in truncated branches so summary is accurate
703
699
  for (const { def: cDef } of callerEntries.slice(maxChildren)) {
704
- const cKey = `${cDef.file}:${cDef.startLine}`;
700
+ const cKey = declarationKey(cDef);
705
701
  if (!visited.has(cKey)) {
706
702
  const tiers = nodeCallers(cDef, false);
707
703
  if (tiers.confirmed.length === 0 && tiers.unverified.length === 0 &&
@@ -842,446 +838,208 @@ function affectedTests(index, name, options = {}) {
842
838
  index._beginOp();
843
839
  try {
844
840
  const maxDepth = Math.max(0, options.depth ?? 3);
845
- // Step 1: confirmed closure via blast (contract mode, no truncation).
846
- // The collector records, per trunk name, (a) whether the engine
847
- // searched its callers and (b) where the confirmed edges sit — the
848
- // coverage bands below must agree with that answer instead of
849
- // re-deciding it with a text scan (fix #246: the scan credited call
850
- // sites the same payload's account excluded, and missed confirmed
851
- // edges text can't see — renamed destructure aliases, callback refs).
852
- const expandedNames = new Set();
853
- const confirmedSitesByName = new Map(); // name → Map(relPath → Set(line))
841
+ // Traverse production callers only to the requested depth. Every
842
+ // declaration reached, including a boundary declaration, then has
843
+ // its immediate test sites adjudicated against its own identity.
844
+ // Inspecting those sites never extends the production closure.
845
+ const callerCache = options._callerCache || new Map();
846
+ const definitions = new Map();
847
+ const keyOf = declarationKey;
854
848
  const blastResult = index.blast(name, {
855
- depth: maxDepth,
856
- file: options.file,
857
- className: options.className,
858
- all: true,
859
- exclude: options.exclude,
860
- includeMethods: options.includeMethods,
861
- _collect: {
862
- onExpand: (funcDef) => expandedNames.add(funcDef.name),
863
- onConfirmed: (funcDef, c) => {
864
- let byFile = confirmedSitesByName.get(funcDef.name);
865
- if (!byFile) { byFile = new Map(); confirmedSitesByName.set(funcDef.name, byFile); }
866
- let lines = byFile.get(c.relativePath);
867
- if (!lines) { lines = new Set(); byFile.set(c.relativePath, lines); }
868
- lines.add(c.line);
869
- },
870
- },
849
+ depth: maxDepth, file: options.file, className: options.className,
850
+ line: options.line, _callerCache: callerCache, _pinDefinitions: true,
851
+ all: true, exclude: options.exclude, includeMethods: options.includeMethods,
852
+ _collect: { onNode: def => definitions.set(keyOf(def), def) },
853
+ _testSitesOnly: options._testSitesOnly,
871
854
  });
872
855
  if (!blastResult) return null;
873
-
874
- // Step 2: Collect confirmed-affected function names (and node keys).
875
- // Also record each name's CLASS identity from the tree — interior
876
- // names were scanned bare (post-#239), so a test of a DIFFERENT
877
- // class's same-named method got credited as coverage (fix #244:
878
- // `asyncio.run(...)` credited as covering Mgr.run).
879
- const affectedNames = new Set();
880
- const confirmedKeys = new Set();
881
- const nameToClasses = new Map(); // name → Set(className|null)
882
- affectedNames.add(name);
883
- const collectNames = (node) => {
884
- if (!node) return;
885
- affectedNames.add(node.name);
886
- confirmedKeys.add(`${node.file}:${node.line}`);
887
- const defs = index.symbols.get(node.name);
888
- const d = defs?.find(x => x.relativePath === node.file && x.startLine === node.line);
889
- let set = nameToClasses.get(node.name);
890
- if (!set) { set = new Set(); nameToClasses.set(node.name, set); }
891
- set.add(d?.className || null);
892
- for (const child of node.children || []) collectNames(child);
893
- };
894
- collectNames(blastResult.tree);
895
-
896
- // Step 2b: possible closure — BFS seeded by the frontier's enclosing
897
- // functions, following both edge tiers, bounded by the same depth.
898
- // Names reached only this way are possibly affected, never confirmed.
856
+ const affectedNames = new Set([...definitions.values()].map(def => def.name));
857
+ const possiblyNames = new Set();
899
858
  const exclude = options.exclude;
900
859
  const excludeArr = exclude ? (Array.isArray(exclude) ? exclude : [exclude]) : [];
901
860
  const includeMethods = options.includeMethods ?? true;
902
- const possiblyNames = new Set();
903
- // Test FILES containing an unverified call site of an affected name
904
- // (relativePath → funcName → Set(lines)) — merged into
905
- // possiblyAffectedTests after the scan (fix #244).
906
- const frontierTestHits = new Map();
907
- {
908
- const callerCache = new Map();
909
- const possibleVisited = new Set();
910
- const queue = [];
911
- const enqueueCaller = (c, depth) => {
912
- if (!c.callerName || !c.callerFile) return;
913
- if (excludeArr.length > 0 && !index.matchesFilters(c.relativePath, { exclude: excludeArr })) return;
914
- const defs = index.symbols.get(c.callerName);
915
- let cDef = defs?.find(d => d.file === c.callerFile && d.startLine === c.callerStartLine);
916
- if (!cDef) {
917
- cDef = {
918
- name: c.callerName,
919
- file: c.callerFile,
920
- relativePath: c.relativePath,
921
- startLine: c.callerStartLine,
922
- endLine: c.callerEndLine,
923
- type: 'function'
924
- };
925
- }
926
- queue.push({ def: cDef, depth });
927
- };
928
- for (const fe of blastResult.unverifiedFrontier || []) {
929
- enqueueCaller(fe, fe.hop);
930
- // A frontier caller that is ITSELF a test function landed in
931
- // neither band (fix #244, the unittest setUp idiom):
932
- // isProductionName filters it from possiblyAffected, and the
933
- // test scan greps for calls OF it — tests are never called.
934
- // Route its FILE into possiblyAffectedTests directly.
935
- // Anonymous test callbacks (`it('...', () => { x.save() })`)
936
- // have no enclosing NAMED caller at all — the site's file is
937
- // still test evidence, so route by the site file whenever it
938
- // is a test file (fix #246; the caller-file and site file are
939
- // the same file by construction when both exist).
940
- const siteFile = fe.callerFile || fe.file;
941
- if (siteFile) {
942
- const cfe = index.files.get(siteFile);
943
- if (cfe) {
944
- let isTestCaller = isTestFile(cfe.relativePath, cfe.language);
945
- if (!isTestCaller && fe.callerName) {
946
- const defs = index.symbols.get(fe.callerName);
947
- const d = defs?.find(x => x.file === fe.callerFile && x.startLine === fe.callerStartLine);
948
- const kindOf = getLanguageAdapter(cfe.language)?.getEntryPointKind;
949
- if (d && kindOf && kindOf(d) === 'test') isTestCaller = true;
950
- }
951
- if (!isTestCaller && fe.line != null) {
952
- // Inline #[cfg(test)] regions of production files
953
- // are test code too — but only within the ranges.
954
- const ranges = inlineTestRanges(cfe);
955
- if (ranges.length > 0 && lineInRanges(fe.line, ranges)) isTestCaller = true;
956
- }
957
- if (isTestCaller &&
958
- (excludeArr.length === 0 || index.matchesFilters(cfe.relativePath, { exclude: excludeArr }))) {
959
- const affName = fe.atNode?.name || name;
960
- let entry = frontierTestHits.get(siteFile);
961
- if (!entry) {
962
- entry = { rel: cfe.relativePath, byName: new Map() };
963
- frontierTestHits.set(siteFile, entry);
964
- }
965
- let hitLines = entry.byName.get(affName);
966
- if (!hitLines) { hitLines = new Set(); entry.byName.set(affName, hitLines); }
967
- if (fe.line != null) hitLines.add(fe.line);
968
- }
969
- }
970
- }
861
+ const model = options._testModel || createTestModel(index);
862
+ // The same multi-symbol operation reuses path classification. No
863
+ // source/configuration fact survives the outermost operation.
864
+ let testPaths = index._opMemos.get('affected-test-paths');
865
+ if (!testPaths) {
866
+ testPaths = new Map();
867
+ index._opMemos.set('affected-test-paths', testPaths);
868
+ }
869
+ const testInfo = filePath => {
870
+ const entry = index.files.get(filePath);
871
+ if (!entry) return null;
872
+ if (!testPaths.has(filePath)) {
873
+ testPaths.set(filePath, isTestFile(entry.relativePath, entry.language));
971
874
  }
972
- while (queue.length > 0) {
973
- const { def: d, depth } = queue.shift();
974
- if (depth > maxDepth) continue;
975
- const k = `${d.relativePath || path.relative(index.root, d.file)}:${d.startLine}`;
976
- if (possibleVisited.has(k)) continue;
977
- possibleVisited.add(k);
978
- if (!confirmedKeys.has(k) && !affectedNames.has(d.name)) {
979
- possiblyNames.add(d.name);
980
- }
981
- if (depth < maxDepth) {
982
- const { confirmed, unverified } = _contractCallers(index, d, { includeMethods, callerCache });
983
- for (const c of confirmed) enqueueCaller(c, depth + 1);
984
- for (const c of unverified) enqueueCaller(c, depth + 1);
875
+ if (testPaths.get(filePath)) return { entry, ranges: null };
876
+ const ranges = model.testRangesOf(filePath);
877
+ return ranges.length ? { entry, ranges } : null;
878
+ };
879
+ const absoluteOf = new Map();
880
+ const confirmedRows = new Map();
881
+ const possibleRows = new Map();
882
+ const addSite = (site, functionName, confirmed) => {
883
+ const filePath = site.file || site.callerFile;
884
+ const info = testInfo(filePath);
885
+ if (!info || (info.ranges && !lineInRanges(site.line, info.ranges)) ||
886
+ (excludeArr.length && !index.matchesFilters(info.entry.relativePath, { exclude: excludeArr }))) return;
887
+ absoluteOf.set(info.entry.relativePath, filePath);
888
+ const rows = confirmed ? confirmedRows : possibleRows;
889
+ let row = rows.get(info.entry.relativePath);
890
+ if (!row) {
891
+ row = { file: info.entry.relativePath, linkedFunctions: [], matches: [] };
892
+ rows.set(row.file, row);
893
+ }
894
+ if (!row.linkedFunctions.includes(functionName)) row.linkedFunctions.push(functionName);
895
+ if (!row.matches.some(match => testSiteKey(match) === testSiteKey(site) && match.functionName === functionName)) {
896
+ row.matches.push({
897
+ ...testSite(site),
898
+ content: (site.content || index.getLineContent(filePath, site.line) || '').trim(),
899
+ matchType: 'call', functionName,
900
+ ...(!confirmed && { evidenceTier: 'unverified', reason: site.reason || 'unverified-chain' }),
901
+ });
902
+ }
903
+ };
904
+ const finishRows = rows => [...rows.values()].map(row => {
905
+ const filePath = absoluteOf.get(row.file);
906
+ const matches = [];
907
+ for (const functionName of row.linkedFunctions.sort(codeUnitCompare)) {
908
+ const own = row.matches.filter(match => match.functionName === functionName);
909
+ // Imports remain useful context once an actual test site
910
+ // links this file. They never create a link or select units.
911
+ const ranges = testInfo(filePath)?.ranges;
912
+ for (const usage of index._getCachedUsages(filePath, functionName) || []) {
913
+ if (usage.usageType !== 'import' ||
914
+ (ranges && !lineInRanges(usage.line, ranges)) ||
915
+ own.some(match => match.line === usage.line)) continue;
916
+ own.push({ line: usage.line,
917
+ content: (index.getLineContent(filePath, usage.line) || '').trim(),
918
+ matchType: 'import', functionName });
985
919
  }
920
+ addTestCaseRows(model, index, filePath, functionName, own, {
921
+ counts: match => match.matchType === 'call', titleNeedsMatch: true,
922
+ extra: { functionName },
923
+ });
924
+ matches.push(...own);
986
925
  }
987
- // A name in both bands is confirmed — the possible band only adds.
988
- for (const n of affectedNames) possiblyNames.delete(n);
926
+ row.matches = matches.sort((a, b) => a.line - b.line ||
927
+ codeUnitCompare(a.functionName, b.functionName) || codeUnitCompare(a.matchType, b.matchType));
928
+ row.matchCount = row.matches.length;
929
+ return row;
930
+ });
931
+ const queue = [];
932
+ const enqueue = (site, depth) => {
933
+ if (!site.callerName || !site.callerFile || depth > maxDepth) return;
934
+ if (excludeArr.length && !index.matchesFilters(site.relativePath, { exclude: excludeArr })) return;
935
+ const def = callerDefinition(index, site);
936
+ // Anonymous/module sites are test evidence but not declarations
937
+ // whose spelling can seed another production traversal.
938
+ if (def) queue.push({ def, depth });
939
+ };
940
+ for (const def of definitions.values()) {
941
+ const { confirmed, unverified } = _contractCallers(index, def, { includeMethods, callerCache, pin: true });
942
+ for (const site of confirmed) addSite(site, def.name, true);
943
+ if (!options._testSitesOnly) {
944
+ for (const site of unverified) addSite(site, def.name, false);
945
+ }
946
+ // Possible descendants are seeded by blast's bounded frontier
947
+ // below, never by the extra boundary site inspection.
989
948
  }
990
-
991
- // Step 3: Scan test files for all affected names using AST
992
- // Only count call and test-case matches as real coverage — not imports or bare references.
993
- const className = options.className || null;
994
- const results = [];
995
- const possibleResults = [];
996
- const scanNames = [...affectedNames, ...possiblyNames];
997
- const dispatchNamesCache = new Map(); // `${class}:${name}` → Set(classNames)
998
- for (const [filePath, fileEntry] of index.files) {
999
- let isTest = isTestFile(fileEntry.relativePath, fileEntry.language);
1000
- // Rust inline #[cfg(test)] modules: source files with #[test]-marked symbols
1001
- // or symbols inside a #[cfg(test)] mod block (BUG-CY). Such files
1002
- // are test code ONLY within the inline test ranges — production
1003
- // lines were counted as test matches (fix #244, false coverage).
1004
- let testRanges = null;
1005
- if (!isTest && fileEntry.language === 'rust') {
1006
- const ranges = inlineTestRanges(fileEntry);
1007
- if (ranges.length > 0) {
1008
- isTest = true;
1009
- testRanges = ranges;
1010
- }
949
+ if (options._testSitesOnly) {
950
+ // check unions the confirmed physical sites of every changed
951
+ // declaration, then links their test units once. Preserve the
952
+ // synthetic case-header sites used by that final link, but do
953
+ // not traverse the unused possible band or project per-root
954
+ // units, inherited rows and commands that check discards.
955
+ return { testFiles: finishRows(confirmedRows) };
956
+ }
957
+ for (const site of blastResult.unverifiedFrontier || []) {
958
+ addSite(site, site.atNode?.name || name, false);
959
+ enqueue(site, site.hop);
960
+ }
961
+ const visited = new Set();
962
+ for (let next = 0; next < queue.length; next++) {
963
+ const { def, depth } = queue[next];
964
+ const key = keyOf(def);
965
+ if (visited.has(key) || definitions.has(key)) continue;
966
+ visited.add(key);
967
+ possiblyNames.add(def.name);
968
+ const { confirmed, unverified } = _contractCallers(index, def, { includeMethods, callerCache, pin: true });
969
+ for (const site of [...confirmed, ...unverified]) {
970
+ addSite(site, def.name, false);
971
+ if (depth < maxDepth) enqueue(site, depth + 1);
1011
972
  }
1012
- if (!isTest) continue;
1013
- if (excludeArr.length > 0 && !index.matchesFilters(fileEntry.relativePath, { exclude: excludeArr })) continue;
1014
- try {
1015
- const content = index._readFile(filePath);
1016
- const fileMatches = new Map();
1017
-
1018
- for (const funcName of scanNames) {
1019
- // Confirmed trunk edges of this name in THIS file — the
1020
- // engine's answer for the sites the text scan is about
1021
- // to re-derive (fix #246).
1022
- const engineFileSites = confirmedSitesByName.get(funcName)?.get(fileEntry.relativePath) || null;
1023
- // Whether the engine searched this name's callers at all
1024
- // (depth-limit nodes were never adjudicated — the text
1025
- // scan remains their best-effort fallback).
1026
- const engineAdjudicated = expandedNames.has(funcName);
1027
-
1028
- // Fast pre-check
1029
- if (!content.includes(funcName) && !engineFileSites) continue;
1030
-
1031
- // AST-based usage detection
1032
- const astUsages = index._getCachedUsages(filePath, funcName) || [];
1033
- if (astUsages.length === 0 && !engineFileSites) continue;
1034
-
1035
- // className scoping: the user's pin applies to the ROOT
1036
- // symbol's own name only (fix #239, G3-go-measured) — a
1037
- // bare call to a standalone wrapper (SaveAll) can never
1038
- // carry the root's class receiver. INTERIOR names with a
1039
- // single unambiguous class identity in the blast tree
1040
- // scope to it (fix #244): a test of a different class's
1041
- // same-named method is not coverage of THIS closure.
1042
- // Standalone/mixed-identity names keep the bare scan.
1043
- let scopeToClass = null;
1044
- if (funcName === name) {
1045
- scopeToClass = className || null;
1046
- } else {
1047
- const classes = nameToClasses.get(funcName);
1048
- if (classes && classes.size === 1) {
1049
- const only = classes.values().next().value;
1050
- if (only) scopeToClass = only;
1051
- }
1052
- }
1053
-
1054
- // A test file that DEFINES funcName itself owns bare-name
1055
- // calls of it (#215/#222(3) scope physics — fix #244: a
1056
- // private same-name helper let the coverage band claim
1057
- // 100% while the same payload's account excluded the
1058
- // site as other-definition). Exception: when the local
1059
- // def IS part of the blast closure (Rust inline-test
1060
- // files define the symbol AND test it), bare calls bind
1061
- // the affected symbol — genuine coverage.
1062
- const localDefs = (fileEntry.symbols || []).filter(s => s.name === funcName);
1063
- const localShadow = localDefs.length > 0 &&
1064
- localDefs.every(s => !confirmedKeys.has(`${fileEntry.relativePath}:${s.startLine}`)) &&
1065
- !(fileEntry.importBindings || []).some(b => b.name === funcName);
1066
-
1067
- // Build instance type map for className scoping (if applicable).
1068
- // Scoping accepts the class plus its non-overriding
1069
- // descendants — instances of a subclass that doesn't
1070
- // override funcName dispatch to the target (fix #246).
1071
- let instanceTypeMap = null;
1072
- let dispatchNames = null;
1073
- if (scopeToClass) {
1074
- const dnKey = `${scopeToClass}:${funcName}`;
1075
- dispatchNames = dispatchNamesCache.get(dnKey);
1076
- if (!dispatchNames) {
1077
- dispatchNames = classDispatchNames(index, scopeToClass, funcName);
1078
- dispatchNamesCache.set(dnKey, dispatchNames);
1079
- }
1080
- instanceTypeMap = new Map();
1081
- for (const dn of dispatchNames) {
1082
- for (const [recv, cls] of _buildInstanceTypeMapForTracing(index, filePath, content, dn)) {
1083
- if (!instanceTypeMap.has(recv)) instanceTypeMap.set(recv, cls);
1084
- }
1085
- }
1086
- }
1087
-
1088
- const seenLines = new Set();
1089
- for (const usage of astUsages) {
1090
- if (usage.usageType === 'definition') continue;
1091
- // Inline-test-promoted file: only lines inside the
1092
- // test ranges are test code (fix #244).
1093
- if (testRanges && !lineInRanges(usage.line, testRanges)) continue;
1094
- // The engine confirmed this exact site as a caller
1095
- // edge — its receiver physics subsume every text
1096
- // heuristic below (fix #246: exact-class scoping
1097
- // rejected a non-overriding subclass receiver the
1098
- // engine had confirmed).
1099
- const engineConfirmedHere = engineFileSites ? engineFileSites.has(usage.line) : false;
1100
- // Local same-name shadow: bare calls bind the test
1101
- // file's OWN definition, never the affected symbol.
1102
- if (localShadow && !engineConfirmedHere && !usage.receiver && usage.usageType !== 'import') continue;
1103
- const lineKey = `${usage.line}:${usage.usageType}`;
1104
- if (seenLines.has(lineKey)) continue;
1105
- seenLines.add(lineKey);
1106
-
1107
- let matchType;
1108
- if (usage.usageType === 'import') {
1109
- matchType = 'import';
1110
- } else if (usage.usageType === 'call') {
1111
- matchType = 'call';
1112
- } else {
1113
- matchType = 'reference';
1114
- }
1115
- // A confirmed usage-style edge (callback reference,
1116
- // method value — fix #221) IS coverage: the test
1117
- // executes the symbol through the reference.
1118
- if (engineConfirmedHere && matchType === 'reference') matchType = 'call';
1119
-
1120
- if (matchType === 'call' && !engineConfirmedHere && engineAdjudicated) {
1121
- // The engine searched this name's callers and did
1122
- // NOT confirm this site — the same payload's
1123
- // account holds it excluded-with-reason or
1124
- // unverified. Crediting it as confirmed coverage
1125
- // would contradict the account (fix #246);
1126
- // unverified sites surface in
1127
- // possiblyAffectedTests via the frontier.
1128
- continue;
1129
- }
1130
-
1131
- // className scoping for calls (text physics — only
1132
- // depth-limit names reach this un-adjudicated)
1133
- if (scopeToClass && matchType === 'call' && !engineConfirmedHere) {
1134
- if (!_receiverMatchesClassTracing(usage, dispatchNames, instanceTypeMap,
1135
- index.getLineContent(filePath, usage.line), funcName)) continue;
1136
- }
1137
-
1138
- // className scoping for references: require class-associated receiver
1139
- if (scopeToClass && matchType === 'reference') {
1140
- if (!usage.receiver) continue;
1141
- if (!dispatchNames.has(usage.receiver) &&
1142
- !(instanceTypeMap && dispatchNames.has(instanceTypeMap.get(usage.receiver)))) {
1143
- continue;
1144
- }
1145
- }
1146
-
1147
- const lineContent = index.getLineContent(filePath, usage.line);
1148
- if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
1149
- fileMatches.get(funcName).push({
1150
- line: usage.line, content: lineContent.trim(),
1151
- matchType, functionName: funcName
1152
- });
1153
- }
1154
-
1155
- // Confirmed engine sites the text scan could not see —
1156
- // renamed destructure aliases (`{ save: persist }`),
1157
- // beyondText lines — are coverage too (fix #246). The
1158
- // dedup map below collapses any line the loop already
1159
- // matched (call outranks reference).
1160
- if (engineFileSites) {
1161
- for (const ln of [...engineFileSites].sort((a, b) => a - b)) {
1162
- if (testRanges && !lineInRanges(ln, testRanges)) continue;
1163
- const lineKey = `${ln}:call`;
1164
- if (seenLines.has(lineKey)) continue;
1165
- seenLines.add(lineKey);
1166
- if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
1167
- fileMatches.get(funcName).push({
1168
- line: ln,
1169
- content: (index.getLineContent(filePath, ln) || '').trim(),
1170
- matchType: 'call', functionName: funcName
1171
- });
1172
- }
1173
- }
1174
-
1175
- // Language-aware test-case detection
1176
- _addAffectedTestCases(index, filePath, fileEntry, funcName, fileMatches);
1177
- }
1178
-
1179
- if (fileMatches.size > 0) {
1180
- const linkedFunctions = [...fileMatches.keys()];
1181
- const allMatches = [];
1182
- for (const matches of fileMatches.values()) allMatches.push(...matches);
1183
- // Deduplicate same line+function (test-case line might overlap with call line)
1184
- const dedupMap = new Map();
1185
- for (const m of allMatches) {
1186
- const key = `${m.line}:${m.functionName}`;
1187
- const existing = dedupMap.get(key);
1188
- if (!existing || _matchPriority(m.matchType) > _matchPriority(existing.matchType)) {
1189
- dedupMap.set(key, m);
1190
- }
1191
- }
1192
- const deduped = [...dedupMap.values()].sort((a, b) => a.line - b.line);
1193
-
1194
- // Only count functions with call or test-case matches as
1195
- // statically linked. Import-only or reference-only records
1196
- // are useful evidence, but are not an exercising path.
1197
- const realLinkedAll = linkedFunctions.filter(fn => {
1198
- const fnMatches = deduped.filter(m => m.functionName === fn);
1199
- return fnMatches.some(m => m.matchType === 'call' || m.matchType === 'test-case');
1200
- });
1201
- const staticallyLinkedFunctions = realLinkedAll.filter(fn => affectedNames.has(fn));
1202
- const possiblyLinkedFunctions = realLinkedAll.filter(fn => possiblyNames.has(fn));
1203
-
1204
- if (staticallyLinkedFunctions.length > 0) {
1205
- // Confirmed band: matches for confirmed-linked names
1206
- const realMatches = deduped.filter(m =>
1207
- affectedNames.has(m.functionName) &&
1208
- (m.matchType === 'call' || m.matchType === 'test-case' ||
1209
- staticallyLinkedFunctions.includes(m.functionName))
1210
- );
1211
- results.push({
1212
- file: fileEntry.relativePath,
1213
- linkedFunctions: staticallyLinkedFunctions,
1214
- ...(possiblyLinkedFunctions.length > 0 && { possiblyLinkedFunctions }),
1215
- matchCount: realMatches.length,
1216
- matches: realMatches
1217
- });
1218
- } else if (possiblyLinkedFunctions.length > 0) {
1219
- // Possible band: file reaches the change only through
1220
- // unverified chains.
1221
- const possibleMatches = deduped.filter(m =>
1222
- possiblyNames.has(m.functionName) &&
1223
- (m.matchType === 'call' || m.matchType === 'test-case' ||
1224
- possiblyLinkedFunctions.includes(m.functionName))
1225
- );
1226
- possibleResults.push({
1227
- file: fileEntry.relativePath,
1228
- linkedFunctions: possiblyLinkedFunctions,
1229
- matchCount: possibleMatches.length,
1230
- matches: possibleMatches
1231
- });
1232
- }
1233
- }
1234
- } catch (e) { /* skip unreadable */ }
973
+ }
974
+ for (const n of affectedNames) possiblyNames.delete(n);
975
+ const results = finishRows(confirmedRows);
976
+ // Keep uncertainty even when the same file also has a confirmed
977
+ // link: its possible sites may belong to other test units.
978
+ const possibleResults = finishRows(possibleRows);
979
+ for (const row of results) {
980
+ const possible = possibleRows.get(row.file);
981
+ if (possible) row.possiblyLinkedFunctions = [...possible.linkedFunctions];
1235
982
  }
1236
983
 
1237
- // Merge frontier test hits: a test file whose own test function IS
1238
- // the unverified caller never surfaces via the scan (the scan greps
1239
- // for calls of scanNames; the test's name is not among them) — add
1240
- // it to the possible band with its unverified call sites (fix #244).
1241
- // A file already present for OTHER names still gets this name listed
1242
- // as possibly covered (fix #246 — the file-level dedup silently
1243
- // dropped per-name possible coverage).
1244
- for (const [absFile, entry] of frontierTestHits) {
1245
- const inResults = results.find(r => r.file === entry.rel);
1246
- const inPossible = possibleResults.find(r => r.file === entry.rel);
1247
- if (inResults || inPossible) {
1248
- const existing = inResults || inPossible;
1249
- const have = new Set([
1250
- ...existing.linkedFunctions,
1251
- ...(existing.possiblyLinkedFunctions || []),
1252
- ]);
1253
- const extra = [...entry.byName.keys()].filter(n => !have.has(n)).sort(codeUnitCompare);
1254
- if (extra.length > 0) {
1255
- if (inResults) {
1256
- existing.possiblyLinkedFunctions = [
1257
- ...(existing.possiblyLinkedFunctions || []),
1258
- ...extra,
1259
- ].sort(codeUnitCompare);
1260
- } else {
1261
- existing.linkedFunctions = [...existing.linkedFunctions, ...extra].sort(codeUnitCompare);
1262
- }
1263
- }
1264
- continue;
984
+ // What each file is to its runner, which tests hold the links, and
985
+ // how many tests that is.
986
+ const confirmedTests = new Set();
987
+ const conditionalTests = new Set();
988
+ const describeBand = (rows, confirmed) => {
989
+ const linesByFile = new Map();
990
+ for (const row of rows) {
991
+ const absFile = absoluteOf.get(row.file);
992
+ if (!absFile) continue;
993
+ linesByFile.set(absFile, row.matches
994
+ .filter(match => match.matchType === 'call' || match.matchType === 'test-case'));
1265
995
  }
1266
- const linked = [...entry.byName.keys()].sort(codeUnitCompare);
1267
- const matches = [];
1268
- for (const fn of linked) {
1269
- for (const ln of [...entry.byName.get(fn)].sort((a, b) => a - b)) {
1270
- matches.push({
1271
- line: ln,
1272
- content: (index.getLineContent(absFile, ln) || '').trim(),
1273
- matchType: 'call',
1274
- functionName: fn,
996
+ const byFile = new Map(rows.map(row => [row.file, row]));
997
+ for (const [absFile, linked] of linkFiles(model, index, linesByFile)) {
998
+ const relativePath = index.files.get(absFile).relativePath;
999
+ let row = byFile.get(relativePath);
1000
+ if (!row) {
1001
+ // The file of a class that inherits a linked test: the
1002
+ // test runs there, whatever file its body stands in.
1003
+ const sources = (linked.from || []).map(from => byFile.get(from)).filter(Boolean);
1004
+ row = {
1005
+ file: relativePath,
1006
+ linkedFunctions: [...new Set(sources.flatMap(source => source.linkedFunctions))]
1007
+ .sort(codeUnitCompare),
1008
+ matchCount: 0,
1009
+ matches: [],
1010
+ };
1011
+ byFile.set(relativePath, row);
1012
+ absoluteOf.set(relativePath, absFile);
1013
+ rows.push(row);
1014
+ }
1015
+ for (const unit of linked.units) {
1016
+ if (!unit.inheritedFrom) continue;
1017
+ row.matches.push({
1018
+ line: unit.line,
1019
+ content: (index.getLineContent(absFile, unit.line) || '').trim(),
1020
+ matchType: 'inherited-test',
1021
+ functionName: row.linkedFunctions[0] || name,
1022
+ name: unit.name,
1023
+ from: unit.inheritedFrom,
1275
1024
  });
1025
+ row.matchCount = row.matches.length;
1026
+ }
1027
+ row.role = linked.role;
1028
+ if (linked.runner) row.runner = linked.runner;
1029
+ row.testCount = linked.tests;
1030
+ if (linked.moreTests) row.moreTests = true;
1031
+ if (linked.conditionalTests) row.conditionalTests = linked.conditionalTests;
1032
+ if (linked.units.length > 0) row.units = linked.units;
1033
+ if (linked.run) row.run = linked.run;
1034
+ else if (linked.runWithheld) row.runWithheld = linked.runWithheld;
1035
+ if (confirmed) {
1036
+ for (const key of linked.testKeys) confirmedTests.add(key);
1037
+ for (const key of linked.conditionalKeys) conditionalTests.add(key);
1276
1038
  }
1277
1039
  }
1278
- possibleResults.push({
1279
- file: entry.rel,
1280
- linkedFunctions: linked,
1281
- matchCount: matches.length,
1282
- matches,
1283
- });
1284
- }
1040
+ };
1041
+ describeBand(results, true);
1042
+ describeBand(possibleResults, false);
1285
1043
 
1286
1044
  // Sort by linkage breadth then alphabetically
1287
1045
  results.sort((a, b) => b.linkedFunctions.length - a.linkedFunctions.length || codeUnitCompare(a.file, b.file));
@@ -1290,21 +1048,18 @@ function affectedTests(index, name, options = {}) {
1290
1048
  // Compute static-linkage stats.
1291
1049
  // Filter out test function names from affectedNames — they are callers,
1292
1050
  // not production symbols that need test coverage.
1293
- const isProductionName = (n) => {
1294
- // Check if this name is only found in test files. Inline test
1295
- // functions (#[test] fns in Rust's #[cfg(test)] mods, Go Test*)
1296
- // live in production-path FILES but are tests themselves — the
1297
- // language's getEntryPointKind says so; they need no coverage.
1298
- for (const [, fe] of index.files) {
1299
- if (isTestFile(fe.relativePath, fe.language)) continue;
1300
- const langModule = getLanguageAdapter(fe.language);
1301
- const kindOf = langModule?.getEntryPointKind;
1302
- if (fe.symbols?.some(s => s.name === n && (!kindOf || kindOf(s) !== 'test'))) {
1303
- return true;
1304
- }
1051
+ const isProductionName = n => (index.symbols.get(n) || []).some(symbol => {
1052
+ const fe = index.files.get(symbol.file);
1053
+ if (!fe) return false;
1054
+ if (!testPaths.has(symbol.file)) {
1055
+ testPaths.set(symbol.file, isTestFile(fe.relativePath, fe.language));
1305
1056
  }
1306
- return false;
1307
- };
1057
+ if (testPaths.get(symbol.file)) return false;
1058
+ const kindOf = getLanguageAdapter(fe.language)?.getEntryPointKind;
1059
+ if (kindOf && kindOf(symbol) === 'test') return false;
1060
+ const ranges = model.ranges.get(symbol.file) || [];
1061
+ return !lineInRanges(symbol.startLine, ranges);
1062
+ });
1308
1063
  const productionNames = new Set();
1309
1064
  for (const n of affectedNames) {
1310
1065
  if (isProductionName(n)) productionNames.add(n);
@@ -1331,11 +1086,21 @@ function affectedTests(index, name, options = {}) {
1331
1086
  possiblyAffected: possiblyProduction,
1332
1087
  testFiles: results,
1333
1088
  possiblyAffectedTests: possibleResults,
1089
+ // The commands that run the confirmed files, where a runner is
1090
+ // proven and its command is known.
1091
+ commands: commandsFor(model, results.filter(row => row.role === 'tests')
1092
+ .map(row => absoluteOf.get(row.file)).filter(Boolean)),
1334
1093
  ...(blastResult.account && { account: blastResult.account }),
1335
1094
  treeAccount: blastResult.treeAccount,
1336
1095
  summary: {
1337
1096
  totalAffected: namesForCoverage.size,
1338
1097
  totalTestFiles: results.length,
1098
+ totalTests: confirmedTests.size,
1099
+ // A file among them holds tests that are not listed: the
1100
+ // total is a lower bound.
1101
+ ...(results.some(row => row.moreTests) && { moreTests: true }),
1102
+ ...(conditionalTests.size > 0 && { conditionalTests: conditionalTests.size }),
1103
+ supportFiles: results.filter(row => row.role === 'support').length,
1339
1104
  staticallyLinkedFunctions: linkedSet.size,
1340
1105
  notStaticallyLinkedCount: notStaticallyLinked.length,
1341
1106
  possiblyAffected: possiblyProduction.length,
@@ -1348,157 +1113,4 @@ function affectedTests(index, name, options = {}) {
1348
1113
  } finally { index._endOp(); }
1349
1114
  }
1350
1115
 
1351
- /**
1352
- * Add test-case matches for a function name in a test file (language-aware).
1353
- * Only adds test-case when the test body has a call match (not just import/reference).
1354
- */
1355
- function _addAffectedTestCases(index, filePath, fileEntry, funcName, fileMatches) {
1356
- const lang = fileEntry.language;
1357
- const existingMatches = fileMatches.get(funcName) || [];
1358
- const existingLines = new Set(existingMatches.map(m => m.line));
1359
-
1360
- if (lang === 'javascript' || lang === 'typescript' || lang === 'tsx') {
1361
- const calls = getCachedCalls(index, filePath);
1362
- if (!calls) return;
1363
- const testFrameworkCalls = new Set(['describe', 'it', 'test', 'spec']);
1364
- // Word-boundary match (fix #246): substring matched mid-word titles.
1365
- const termPattern = new RegExp('(^|[^\\w$])' + escapeRegExp(funcName) + '([^\\w$]|$)');
1366
- for (const call of calls) {
1367
- if (!testFrameworkCalls.has(call.name)) continue;
1368
- const lineContent = index.getLineContent(filePath, call.line);
1369
- if (termPattern.test(lineContent) && !existingLines.has(call.line)) {
1370
- // Only add test-case if a call match exists in the test body
1371
- const endLine = _estimateTestBlockEndTracing(index, filePath, call.line);
1372
- const hasCallMatch = existingMatches.some(m =>
1373
- m.line >= call.line && m.line <= endLine &&
1374
- m.matchType === 'call'
1375
- );
1376
- if (!hasCallMatch) continue;
1377
- if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
1378
- fileMatches.get(funcName).push({
1379
- line: call.line, content: lineContent.trim(),
1380
- matchType: 'test-case', functionName: funcName
1381
- });
1382
- existingLines.add(call.line);
1383
- }
1384
- }
1385
- } else {
1386
- if (!fileEntry.symbols) return;
1387
- try {
1388
- const langModule = getLanguageAdapter(lang);
1389
- if (!langModule) return;
1390
- // Prefer the kinded predicate so we don't mis-tag fn main() / fn init()
1391
- // (runtime entries) as test cases (BUG-CX). Fall back to isEntryPoint
1392
- // for backward compat with language modules that haven't been migrated.
1393
- const classify = langModule.getEntryPointKind
1394
- ? (s) => langModule.getEntryPointKind(s) === 'test'
1395
- : langModule.isEntryPoint;
1396
- if (!classify) return;
1397
- for (const symbol of fileEntry.symbols) {
1398
- if (!classify(symbol)) continue;
1399
- // Only add test-case if a call match exists in the test body
1400
- const hasCallInRange = existingMatches.some(m =>
1401
- m.line >= symbol.startLine && m.line <= symbol.endLine &&
1402
- m.matchType === 'call'
1403
- );
1404
- if (hasCallInRange && !existingLines.has(symbol.startLine)) {
1405
- const lineContent = index.getLineContent(filePath, symbol.startLine);
1406
- if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
1407
- fileMatches.get(funcName).push({
1408
- line: symbol.startLine, content: lineContent.trim(),
1409
- matchType: 'test-case', functionName: funcName
1410
- });
1411
- existingLines.add(symbol.startLine);
1412
- }
1413
- }
1414
- } catch (e) { /* skip */ }
1415
- }
1416
- }
1417
-
1418
- /**
1419
- * Build instance type map for className scoping in affectedTests.
1420
- * Same logic as _buildInstanceTypeMap in search.js.
1421
- */
1422
- function _buildInstanceTypeMapForTracing(index, filePath, content, targetClassName) {
1423
- const typeMap = new Map();
1424
- const calls = getCachedCalls(index, filePath);
1425
- if (calls) {
1426
- for (const call of calls) {
1427
- if (call.isMethod && call.receiver && call.receiverType === targetClassName) {
1428
- typeMap.set(call.receiver, targetClassName);
1429
- }
1430
- if (call.name === targetClassName && !call.isMethod) {
1431
- const lineContent = index.getLineContent(filePath, call.line);
1432
- const assignMatch = lineContent.match(/(?:const|let|var|)\s*(\w+)\s*:?=\s/);
1433
- if (assignMatch) typeMap.set(assignMatch[1], targetClassName);
1434
- }
1435
- if (call.isMethod && call.receiver === targetClassName) {
1436
- const lineContent = index.getLineContent(filePath, call.line);
1437
- const assignMatch = lineContent.match(/(?:const|let|var|)\s*(\w+)\s*:?=\s/);
1438
- if (assignMatch) typeMap.set(assignMatch[1], targetClassName);
1439
- }
1440
- }
1441
- }
1442
- const classUsages = index._getCachedUsages(filePath, targetClassName);
1443
- if (classUsages) {
1444
- for (const u of classUsages) {
1445
- if (u.usageType === 'import' || u.usageType === 'definition') continue;
1446
- const lineContent = index.getLineContent(filePath, u.line);
1447
- const assignMatch = lineContent.match(/(?:const|let|var|)\s*(\w+)\s*:?=\s/);
1448
- if (assignMatch && assignMatch[1] !== targetClassName) {
1449
- typeMap.set(assignMatch[1], targetClassName);
1450
- }
1451
- }
1452
- }
1453
- return typeMap;
1454
- }
1455
-
1456
- /**
1457
- * Check if a usage's receiver matches any of the target dispatch class names
1458
- * (the class + its non-overriding descendants — fix #246) for affectedTests.
1459
- * Same logic as _receiverMatchesClass in search.js.
1460
- */
1461
- function _receiverMatchesClassTracing(usage, dispatchNames, instanceTypeMap, lineContent, searchTerm) {
1462
- if (usage.receiver && dispatchNames.has(usage.receiver)) return true;
1463
- if (usage.receiver && instanceTypeMap && dispatchNames.has(instanceTypeMap.get(usage.receiver))) return true;
1464
- if (usage.receiver) return false;
1465
- if (lineContent && searchTerm) {
1466
- for (const className of dispatchNames) {
1467
- const pat = new RegExp(
1468
- '\\b' + escapeRegExp(className) + '\\s*(?:(?:\\([^)]*\\)|\\{[^}]*\\})\\s*\\.\\s*' +
1469
- escapeRegExp(searchTerm) + '\\s*\\(|' +
1470
- 'new\\s+' + escapeRegExp(className) + '\\s*\\([^)]*\\)\\s*\\.\\s*' +
1471
- escapeRegExp(searchTerm) + '\\s*\\()'
1472
- );
1473
- if (pat.test(lineContent)) return true;
1474
- }
1475
- }
1476
- return false;
1477
- }
1478
-
1479
- /**
1480
- * Estimate the end line of a test block by tracking brace/paren nesting.
1481
- */
1482
- function _estimateTestBlockEndTracing(index, filePath, startLine) {
1483
- const content = index._readFile(filePath);
1484
- if (!content) return startLine + 5;
1485
- const lines = content.split('\n');
1486
- let depth = 0;
1487
- let started = false;
1488
- for (let i = startLine - 1; i < lines.length; i++) {
1489
- const line = lines[i];
1490
- for (const ch of line) {
1491
- if (ch === '{' || ch === '(') { depth++; started = true; }
1492
- else if (ch === '}' || ch === ')') { depth--; }
1493
- }
1494
- if (started && depth <= 0) return i + 1;
1495
- }
1496
- return Math.min(startLine + 10, lines.length);
1497
- }
1498
-
1499
- function _matchPriority(matchType) {
1500
- const p = { 'test-case': 5, 'call': 4, 'import': 3, 'string-ref': 2, 'reference': 1 };
1501
- return p[matchType] || 0;
1502
- }
1503
-
1504
1116
  module.exports = { trace, blast, reverseTrace, affectedTests };