ucn 5.5.3 → 5.6.0

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 (71) hide show
  1. package/.claude/skills/ucn/SKILL.md +35 -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 +474 -0
  6. package/README.md +69 -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 +273 -107
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +14 -0
  13. package/core/cache.js +101 -16
  14. package/core/callers.js +352 -70
  15. package/core/capture.js +331 -0
  16. package/core/change-kinds.js +888 -0
  17. package/core/check.js +1117 -139
  18. package/core/command-contracts.js +7 -6
  19. package/core/constructed-class.js +1195 -0
  20. package/core/contract-membership.js +142 -0
  21. package/core/deadcode.js +362 -84
  22. package/core/deletion.js +401 -0
  23. package/core/discovery.js +104 -9
  24. package/core/entrypoints.js +12 -6
  25. package/core/evidence-bands.js +68 -0
  26. package/core/evidence-bands.json +44 -0
  27. package/core/execute.js +172 -161
  28. package/core/hot-summary.js +241 -0
  29. package/core/index-ir.js +3 -1
  30. package/core/ir.js +5 -1
  31. package/core/member-signatures.js +789 -0
  32. package/core/output/analysis-ext.js +20 -10
  33. package/core/output/analysis.js +98 -60
  34. package/core/output/check.js +98 -37
  35. package/core/output/doctor.js +34 -1
  36. package/core/output/find.js +2 -1
  37. package/core/output/lines.js +63 -3
  38. package/core/output/public.js +29 -11
  39. package/core/output/refactoring.js +32 -0
  40. package/core/output/reporting.js +15 -2
  41. package/core/output/search.js +11 -2
  42. package/core/output/tracing.js +35 -7
  43. package/core/output-budget.js +124 -57
  44. package/core/parallel-build.js +3 -3
  45. package/core/project.js +106 -23
  46. package/core/provenance-type.js +12 -5
  47. package/core/public-command.js +35 -9
  48. package/core/python-modules.js +6 -0
  49. package/core/reflection.js +151 -23
  50. package/core/registry.js +623 -72
  51. package/core/reporting.js +69 -34
  52. package/core/review-items.js +75 -0
  53. package/core/search.js +89 -128
  54. package/core/shared.js +0 -45
  55. package/core/snapshot.js +350 -0
  56. package/core/source-universe.js +707 -0
  57. package/core/test-units.js +3624 -0
  58. package/core/tracing.js +150 -96
  59. package/core/verify.js +27 -11
  60. package/languages/adapter.js +8 -3
  61. package/languages/c-family.js +12 -0
  62. package/languages/csharp.js +634 -0
  63. package/languages/go.js +120 -0
  64. package/languages/index.js +422 -4
  65. package/languages/java.js +712 -0
  66. package/languages/javascript.js +1460 -2
  67. package/languages/lexical-scope.js +68 -0
  68. package/languages/python.js +1672 -1
  69. package/languages/rust.js +91 -0
  70. package/mcp/server.js +44 -110
  71. package/package.json +10 -5
package/core/tracing.js CHANGED
@@ -21,9 +21,11 @@
21
21
 
22
22
  const path = require('path');
23
23
  const { escapeRegExp, codeUnitCompare, inlineTestRanges, lineInRanges, classDispatchNames } = require('./shared');
24
+ const { paramDefault } = require('./registry');
24
25
  const { isTestFile } = require('./discovery');
25
26
  const { getCachedCalls } = require('./callers');
26
27
  const { getLanguageAdapter } = require('../languages');
28
+ const { createTestModel, addTestCaseRows, linkFiles, commandsFor } = require('./test-units');
27
29
 
28
30
  /**
29
31
  * Contract-mode caller expansion for the tree commands. Memoizes the full
@@ -147,7 +149,7 @@ function trace(index, name, options = {}) {
147
149
  index._beginOp();
148
150
  try {
149
151
  // Sanitize depth: use default for null/undefined, clamp negative to 0
150
- const rawDepth = options.depth ?? 3;
152
+ const rawDepth = options.depth ?? paramDefault('depth', 'trace');
151
153
  const maxDepth = Math.max(0, rawDepth);
152
154
  const direction = options.direction || 'down'; // 'down' = callees, 'up' = callers, 'both'
153
155
  const maxChildren = options.all ? Infinity : 10;
@@ -340,7 +342,7 @@ function trace(index, name, options = {}) {
340
342
  function blast(index, name, options = {}) {
341
343
  index._beginOp();
342
344
  try {
343
- const maxDepth = Math.max(0, options.depth ?? 3);
345
+ const maxDepth = Math.max(0, options.depth ?? paramDefault('depth', 'trace'));
344
346
  const maxChildren = options.all ? Infinity : 10;
345
347
  const includeMethods = options.includeMethods ?? true;
346
348
  const exclude = options.exclude || [];
@@ -354,7 +356,9 @@ function blast(index, name, options = {}) {
354
356
  if (!def) return null;
355
357
 
356
358
  const visited = new Set();
357
- const callerCache = new Map();
359
+ // A caller's answer about one definition is the same whichever
360
+ // root the walk started from: a selection over many roots shares it.
361
+ const callerCache = options._callerCache || new Map();
358
362
  const affectedFunctions = new Set();
359
363
  const possiblyAffectedSet = new Set();
360
364
  const affectedFiles = new Set();
@@ -590,7 +594,7 @@ function blast(index, name, options = {}) {
590
594
  function reverseTrace(index, name, options = {}) {
591
595
  index._beginOp();
592
596
  try {
593
- const maxDepth = Math.max(0, options.depth ?? 5);
597
+ const maxDepth = Math.max(0, options.depth ?? paramDefault('depth', 'trace', 'entrypoints'));
594
598
  const maxChildren = options.all ? Infinity : 10;
595
599
  const includeMethods = options.includeMethods ?? true;
596
600
  const exclude = options.exclude || [];
@@ -855,6 +859,10 @@ function affectedTests(index, name, options = {}) {
855
859
  depth: maxDepth,
856
860
  file: options.file,
857
861
  className: options.className,
862
+ // A pin on the declaration's line: one of several same-name
863
+ // definitions in a file.
864
+ line: options.line,
865
+ _callerCache: options._callerCache,
858
866
  all: true,
859
867
  exclude: options.exclude,
860
868
  includeMethods: options.includeMethods,
@@ -900,12 +908,22 @@ function affectedTests(index, name, options = {}) {
900
908
  const excludeArr = exclude ? (Array.isArray(exclude) ? exclude : [exclude]) : [];
901
909
  const includeMethods = options.includeMethods ?? true;
902
910
  const possiblyNames = new Set();
911
+ // The one model of what a test is (core/test-units.js); a caller
912
+ // that asks about several names hands in its own.
913
+ const model = options._testModel || createTestModel(index);
914
+ // Code a test executes in a file whose name is no test file's: the
915
+ // lines the model says so (a test of its own, the body of a test a
916
+ // class of another file inherits), or null.
917
+ const modelRanges = filePath => {
918
+ const ranges = model.testRangesOf(filePath);
919
+ return ranges.length > 0 ? ranges : null;
920
+ };
903
921
  // Test FILES containing an unverified call site of an affected name
904
922
  // (relativePath → funcName → Set(lines)) — merged into
905
923
  // possiblyAffectedTests after the scan (fix #244).
906
924
  const frontierTestHits = new Map();
907
925
  {
908
- const callerCache = new Map();
926
+ const callerCache = options._callerCache || new Map();
909
927
  const possibleVisited = new Set();
910
928
  const queue = [];
911
929
  const enqueueCaller = (c, depth) => {
@@ -954,6 +972,11 @@ function affectedTests(index, name, options = {}) {
954
972
  const ranges = inlineTestRanges(cfe);
955
973
  if (ranges.length > 0 && lineInRanges(fe.line, ranges)) isTestCaller = true;
956
974
  }
975
+ if (!isTestCaller && fe.line != null) {
976
+ // A line the test model says a test executes.
977
+ const ranges = modelRanges(siteFile);
978
+ if (ranges && lineInRanges(fe.line, ranges)) isTestCaller = true;
979
+ }
957
980
  if (isTestCaller &&
958
981
  (excludeArr.length === 0 || index.matchesFilters(cfe.relativePath, { exclude: excludeArr }))) {
959
982
  const affName = fe.atNode?.name || name;
@@ -991,10 +1014,26 @@ function affectedTests(index, name, options = {}) {
991
1014
  // Step 3: Scan test files for all affected names using AST
992
1015
  // Only count call and test-case matches as real coverage — not imports or bare references.
993
1016
  const className = options.className || null;
1017
+ const absoluteOf = new Map(); // relative test path -> absolute
994
1018
  const results = [];
995
1019
  const possibleResults = [];
996
1020
  const scanNames = [...affectedNames, ...possiblyNames];
997
1021
  const dispatchNamesCache = new Map(); // `${class}:${name}` → Set(classNames)
1022
+ // Files whose name is no test file's that hold a call of a scanned
1023
+ // name: a confirmed edge of the engine, or (a name whose callers
1024
+ // the engine did not search) a call by that name. Only these are
1025
+ // asked of the test model.
1026
+ const callSiteFiles = new Set();
1027
+ for (const byFile of confirmedSitesByName.values()) {
1028
+ for (const relativePath of byFile.keys()) callSiteFiles.add(relativePath);
1029
+ }
1030
+ for (const funcName of scanNames) {
1031
+ if (expandedNames.has(funcName)) continue;
1032
+ for (const calling of index.getCalleeFiles(funcName) || []) {
1033
+ const entry = index.files.get(calling);
1034
+ if (entry) callSiteFiles.add(entry.relativePath);
1035
+ }
1036
+ }
998
1037
  for (const [filePath, fileEntry] of index.files) {
999
1038
  let isTest = isTestFile(fileEntry.relativePath, fileEntry.language);
1000
1039
  // Rust inline #[cfg(test)] modules: source files with #[test]-marked symbols
@@ -1009,6 +1048,17 @@ function affectedTests(index, name, options = {}) {
1009
1048
  testRanges = ranges;
1010
1049
  }
1011
1050
  }
1051
+ // A file's name does not decide what a test executes: the body
1052
+ // of a test a class inherits stands where its base class is,
1053
+ // and a runner takes tests from files of any name. Such a file
1054
+ // is test code within the lines the test model names.
1055
+ if (!isTest && callSiteFiles.has(fileEntry.relativePath)) {
1056
+ const ranges = modelRanges(filePath);
1057
+ if (ranges) {
1058
+ isTest = true;
1059
+ testRanges = ranges;
1060
+ }
1061
+ }
1012
1062
  if (!isTest) continue;
1013
1063
  if (excludeArr.length > 0 && !index.matchesFilters(fileEntry.relativePath, { exclude: excludeArr })) continue;
1014
1064
  try {
@@ -1025,6 +1075,12 @@ function affectedTests(index, name, options = {}) {
1025
1075
  // scan remains their best-effort fallback).
1026
1076
  const engineAdjudicated = expandedNames.has(funcName);
1027
1077
 
1078
+ // The engine searched this name's callers and confirmed
1079
+ // none in this file: a call here is not credited (below),
1080
+ // and an import or a bare reference links nothing by
1081
+ // itself, so there is nothing a scan could add.
1082
+ if (engineAdjudicated && !engineFileSites) continue;
1083
+
1028
1084
  // Fast pre-check
1029
1085
  if (!content.includes(funcName) && !engineFileSites) continue;
1030
1086
 
@@ -1172,9 +1228,17 @@ function affectedTests(index, name, options = {}) {
1172
1228
  }
1173
1229
  }
1174
1230
 
1175
- // Language-aware test-case detection
1176
- _addAffectedTestCases(index, filePath, fileEntry, funcName, fileMatches);
1231
+ // The tests that hold a call, and the titled ones that
1232
+ // name the function around one (core/test-units.js).
1233
+ if (fileMatches.has(funcName)) {
1234
+ addTestCaseRows(model, index, filePath, funcName, fileMatches.get(funcName), {
1235
+ counts: match => match.matchType === 'call',
1236
+ titleNeedsMatch: true,
1237
+ extra: { functionName: funcName },
1238
+ });
1239
+ }
1177
1240
  }
1241
+ if (fileMatches.size > 0) absoluteOf.set(fileEntry.relativePath, filePath);
1178
1242
 
1179
1243
  if (fileMatches.size > 0) {
1180
1244
  const linkedFunctions = [...fileMatches.keys()];
@@ -1283,6 +1347,68 @@ function affectedTests(index, name, options = {}) {
1283
1347
  });
1284
1348
  }
1285
1349
 
1350
+ // What each file is to its runner, which tests hold the links, and
1351
+ // how many tests that is.
1352
+ for (const [absFile, entry] of frontierTestHits) absoluteOf.set(entry.rel, absFile);
1353
+ const confirmedTests = new Set();
1354
+ const conditionalTests = new Set();
1355
+ const describeBand = (rows, confirmed) => {
1356
+ const linesByFile = new Map();
1357
+ for (const row of rows) {
1358
+ const absFile = absoluteOf.get(row.file);
1359
+ if (!absFile) continue;
1360
+ linesByFile.set(absFile, row.matches
1361
+ .filter(match => match.matchType === 'call' || match.matchType === 'test-case')
1362
+ .map(match => match.line));
1363
+ }
1364
+ const byFile = new Map(rows.map(row => [row.file, row]));
1365
+ for (const [absFile, linked] of linkFiles(model, index, linesByFile)) {
1366
+ const relativePath = index.files.get(absFile).relativePath;
1367
+ let row = byFile.get(relativePath);
1368
+ if (!row) {
1369
+ // The file of a class that inherits a linked test: the
1370
+ // test runs there, whatever file its body stands in.
1371
+ const sources = (linked.from || []).map(from => byFile.get(from)).filter(Boolean);
1372
+ row = {
1373
+ file: relativePath,
1374
+ linkedFunctions: [...new Set(sources.flatMap(source => source.linkedFunctions))]
1375
+ .sort(codeUnitCompare),
1376
+ matchCount: 0,
1377
+ matches: [],
1378
+ };
1379
+ byFile.set(relativePath, row);
1380
+ absoluteOf.set(relativePath, absFile);
1381
+ rows.push(row);
1382
+ }
1383
+ for (const unit of linked.units) {
1384
+ if (!unit.inheritedFrom) continue;
1385
+ row.matches.push({
1386
+ line: unit.line,
1387
+ content: (index.getLineContent(absFile, unit.line) || '').trim(),
1388
+ matchType: 'inherited-test',
1389
+ functionName: row.linkedFunctions[0] || name,
1390
+ name: unit.name,
1391
+ from: unit.inheritedFrom,
1392
+ });
1393
+ row.matchCount = row.matches.length;
1394
+ }
1395
+ row.role = linked.role;
1396
+ if (linked.runner) row.runner = linked.runner;
1397
+ row.testCount = linked.tests;
1398
+ if (linked.moreTests) row.moreTests = true;
1399
+ if (linked.conditionalTests) row.conditionalTests = linked.conditionalTests;
1400
+ if (linked.units.length > 0) row.units = linked.units;
1401
+ if (linked.run) row.run = linked.run;
1402
+ else if (linked.runWithheld) row.runWithheld = linked.runWithheld;
1403
+ if (confirmed) {
1404
+ for (const key of linked.testKeys) confirmedTests.add(key);
1405
+ for (const key of linked.conditionalKeys) conditionalTests.add(key);
1406
+ }
1407
+ }
1408
+ };
1409
+ describeBand(results, true);
1410
+ describeBand(possibleResults, false);
1411
+
1286
1412
  // Sort by linkage breadth then alphabetically
1287
1413
  results.sort((a, b) => b.linkedFunctions.length - a.linkedFunctions.length || codeUnitCompare(a.file, b.file));
1288
1414
  possibleResults.sort((a, b) => b.linkedFunctions.length - a.linkedFunctions.length || codeUnitCompare(a.file, b.file));
@@ -1295,11 +1421,16 @@ function affectedTests(index, name, options = {}) {
1295
1421
  // functions (#[test] fns in Rust's #[cfg(test)] mods, Go Test*)
1296
1422
  // live in production-path FILES but are tests themselves — the
1297
1423
  // language's getEntryPointKind says so; they need no coverage.
1298
- for (const [, fe] of index.files) {
1424
+ for (const [filePath, fe] of index.files) {
1299
1425
  if (isTestFile(fe.relativePath, fe.language)) continue;
1300
1426
  const langModule = getLanguageAdapter(fe.language);
1301
1427
  const kindOf = langModule?.getEntryPointKind;
1302
- if (fe.symbols?.some(s => s.name === n && (!kindOf || kindOf(s) !== 'test'))) {
1428
+ // A declaration the test model found test code in (asked
1429
+ // above, where the file held a call): a test, not a
1430
+ // production symbol.
1431
+ const ranges = model.ranges.get(filePath) || [];
1432
+ if (fe.symbols?.some(s => s.name === n && (!kindOf || kindOf(s) !== 'test') &&
1433
+ !(ranges.length > 0 && lineInRanges(s.startLine, ranges)))) {
1303
1434
  return true;
1304
1435
  }
1305
1436
  }
@@ -1331,11 +1462,21 @@ function affectedTests(index, name, options = {}) {
1331
1462
  possiblyAffected: possiblyProduction,
1332
1463
  testFiles: results,
1333
1464
  possiblyAffectedTests: possibleResults,
1465
+ // The commands that run the confirmed files, where a runner is
1466
+ // proven and its command is known.
1467
+ commands: commandsFor(model, results.filter(row => row.role === 'tests')
1468
+ .map(row => absoluteOf.get(row.file)).filter(Boolean)),
1334
1469
  ...(blastResult.account && { account: blastResult.account }),
1335
1470
  treeAccount: blastResult.treeAccount,
1336
1471
  summary: {
1337
1472
  totalAffected: namesForCoverage.size,
1338
1473
  totalTestFiles: results.length,
1474
+ totalTests: confirmedTests.size,
1475
+ // A file among them holds tests that are not listed: the
1476
+ // total is a lower bound.
1477
+ ...(results.some(row => row.moreTests) && { moreTests: true }),
1478
+ ...(conditionalTests.size > 0 && { conditionalTests: conditionalTests.size }),
1479
+ supportFiles: results.filter(row => row.role === 'support').length,
1339
1480
  staticallyLinkedFunctions: linkedSet.size,
1340
1481
  notStaticallyLinkedCount: notStaticallyLinked.length,
1341
1482
  possiblyAffected: possiblyProduction.length,
@@ -1348,73 +1489,6 @@ function affectedTests(index, name, options = {}) {
1348
1489
  } finally { index._endOp(); }
1349
1490
  }
1350
1491
 
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
1492
  /**
1419
1493
  * Build instance type map for className scoping in affectedTests.
1420
1494
  * Same logic as _buildInstanceTypeMap in search.js.
@@ -1476,26 +1550,6 @@ function _receiverMatchesClassTracing(usage, dispatchNames, instanceTypeMap, lin
1476
1550
  return false;
1477
1551
  }
1478
1552
 
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
1553
  function _matchPriority(matchType) {
1500
1554
  const p = { 'test-case': 5, 'call': 4, 'import': 3, 'string-ref': 2, 'reference': 1 };
1501
1555
  return p[matchType] || 0;
package/core/verify.js CHANGED
@@ -1215,7 +1215,7 @@ const GIT_REF_FORMAT = /^[a-zA-Z0-9._\-~\/^@{}:]+$/; // eslint-disable-line no-
1215
1215
  */
1216
1216
  let baseReadSerial = 0;
1217
1217
 
1218
- function startBaseRead(file, base) {
1218
+ function startBaseRead(file, base, repoFile = file) {
1219
1219
  if (process.platform === 'win32' || !file || !GIT_REF_FORMAT.test(base)) return null;
1220
1220
  const out = path.join(os.tmpdir(),
1221
1221
  `ucn-base-${process.pid}-${++baseReadSerial}-${Math.random().toString(36).slice(2, 10)}`);
@@ -1225,8 +1225,8 @@ function startBaseRead(file, base) {
1225
1225
  // marks a revision that does not hold the file.
1226
1226
  child = spawn('/bin/sh', ['-c',
1227
1227
  'git show "$1" > "$2.part" 2>/dev/null && mv "$2.part" "$2" || { rm -f "$2.part"; : > "$2.none"; }',
1228
- 'ucn-base', `${base}:./${path.basename(file)}`, out],
1229
- { cwd: path.dirname(file), stdio: 'ignore' });
1228
+ 'ucn-base', `${base}:./${path.basename(repoFile)}`, out],
1229
+ { cwd: path.dirname(repoFile), stdio: 'ignore' });
1230
1230
  } catch {
1231
1231
  child = null;
1232
1232
  }
@@ -1299,8 +1299,11 @@ function baseDeclarationChanges(index, name, def, base = 'HEAD', baseRead = null
1299
1299
  if (baseContent === null) continue;
1300
1300
  if (baseContent === undefined) {
1301
1301
  try {
1302
- baseContent = execFileSync('git', ['show', `${base}:./${path.basename(file)}`], {
1303
- cwd: path.dirname(file), encoding: 'utf-8', maxBuffer: 256 * 1024 * 1024,
1302
+ // The repository's copy of the file (a snapshot exported
1303
+ // from the git index is not a repository).
1304
+ const repoFile = index.repoPathOf ? index.repoPathOf(file) : file;
1305
+ baseContent = execFileSync('git', ['show', `${base}:./${path.basename(repoFile)}`], {
1306
+ cwd: path.dirname(repoFile), encoding: 'utf-8', maxBuffer: 256 * 1024 * 1024,
1304
1307
  stdio: ['ignore', 'pipe', 'ignore'],
1305
1308
  });
1306
1309
  } catch {
@@ -1848,6 +1851,9 @@ function analyzeCallSite(index, call, funcName, occurrence = 0) {
1848
1851
  }
1849
1852
 
1850
1853
  return {
1854
+ // Where the call is in the source: the same node, whoever reads
1855
+ // the file next (an alias, the second call on a line).
1856
+ span: { start: callNode.startIndex, end: callNode.endIndex },
1851
1857
  args,
1852
1858
  argCount: args.length,
1853
1859
  hasSpread: args.some(a => a.startsWith('...')) || unpackingArgs > 0,
@@ -2199,8 +2205,9 @@ function verify(index, name, options = {}) {
2199
2205
  // A base the user names must be a commit: a misspelled ref would read as
2200
2206
  // a file absent at base and check nothing against the old declaration
2201
2207
  // (the target-less check already resolved its base through the diff).
2202
- if (options.base && options.checkBaseRef) assertGitCommit(def.file, baseRef);
2203
- const baseRead = startBaseRead(def.file, baseRef);
2208
+ const repoFile = index.repoPathOf ? index.repoPathOf(def.file) : def.file;
2209
+ if (options.base && options.checkBaseRef) assertGitCommit(repoFile, baseRef);
2210
+ const baseRead = startBaseRead(def.file, baseRef, repoFile);
2204
2211
  let sweep;
2205
2212
  try {
2206
2213
  sweep = contractedCallerSweep(index, name, def, {
@@ -2274,6 +2281,15 @@ function verify(index, name, options = {}) {
2274
2281
  };
2275
2282
  }
2276
2283
 
2284
+ // A mismatch remembers which call it is (not part of what is printed).
2285
+ const located = (record, analysis) => {
2286
+ if (analysis && analysis.span) Object.defineProperty(record, 'span', { value: analysis.span, enumerable: false });
2287
+ // `f.call(self, ..)`: the arguments start one slot later.
2288
+ if (analysis && analysis.indirectKind) {
2289
+ Object.defineProperty(record, 'indirect', { value: analysis.indirectKind, enumerable: false });
2290
+ }
2291
+ return record;
2292
+ };
2277
2293
  const verifyLineSeen = new Map(); // 'file:line' -> per-line ordinal (fix #231)
2278
2294
  for (const call of calls) {
2279
2295
  const siteKey = `${call.file}:${call.line}:${call.calledAs || name}`;
@@ -2364,7 +2380,7 @@ function verify(index, name, options = {}) {
2364
2380
  continue;
2365
2381
  }
2366
2382
  if (!countOk || call.invalidOverload) {
2367
- mismatches.push({
2383
+ mismatches.push(located({
2368
2384
  file: call.relativePath,
2369
2385
  line: call.line,
2370
2386
  expression: call.content.trim(),
@@ -2378,7 +2394,7 @@ function verify(index, name, options = {}) {
2378
2394
  args: analysis.args,
2379
2395
  patterns: patternFlagsFrom(analysis),
2380
2396
  ...carry,
2381
- });
2397
+ }, analysis));
2382
2398
  continue;
2383
2399
  }
2384
2400
 
@@ -2399,7 +2415,7 @@ function verify(index, name, options = {}) {
2399
2415
  ...carry,
2400
2416
  });
2401
2417
  } else {
2402
- mismatches.push({
2418
+ mismatches.push(located({
2403
2419
  file: call.relativePath,
2404
2420
  line: call.line,
2405
2421
  expression: call.content.trim(),
@@ -2413,7 +2429,7 @@ function verify(index, name, options = {}) {
2413
2429
  problem: bindingProblems.join('; '),
2414
2430
  patterns: patternFlagsFrom(analysis),
2415
2431
  ...carry,
2416
- });
2432
+ }, analysis));
2417
2433
  }
2418
2434
  continue;
2419
2435
  }
@@ -147,13 +147,18 @@ function createLanguageAdapter(config) {
147
147
  // safeParse returns the tree the extractors above just built.
148
148
  if (config.traits?.reflectionApi && !parsed.reflectionSites) {
149
149
  const { safeParse } = require('./index');
150
- const { reflectionSitesInTree } = require('../core/reflection');
150
+ const { reflectionFactsInTree } = require('../core/reflection');
151
151
  // A language reading the file through its own view (C#
152
152
  // conditional compilation, fix #391) supplies that tree.
153
153
  const analysisTree = typeof languageModule.analysisTree === 'function'
154
154
  ? languageModule.analysisTree(code, parser) : safeParse(parser, code);
155
- const sites = reflectionSitesInTree(analysisTree, config.name, code);
156
- if (sites.length > 0) parsed.reflectionSites = sites;
155
+ const facts = reflectionFactsInTree(analysisTree, config.name, code, { imports });
156
+ if (facts.sites.length > 0) parsed.reflectionSites = facts.sites;
157
+ // How often the file uses the reflection and code-execution
158
+ // vocabulary: what `repo` and the completeness footer count,
159
+ // read from the index instead of from every file's text.
160
+ if (facts.reflectionUses > 0) parsed.reflectionUses = facts.reflectionUses;
161
+ if (facts.evalUses > 0) parsed.evalUses = facts.evalUses;
157
162
  }
158
163
  // Computed dispatch sites (`handlers[name]()`) of the literal
159
164
  // parse the doctor's blind-spot inventory reads (fix #436): a
@@ -3633,6 +3633,17 @@ function memberQualifiers(declarator) {
3633
3633
  return out;
3634
3634
  }
3635
3635
 
3636
+ /**
3637
+ * `virtual R f(...) = 0;`: a member function declaration whose initializer
3638
+ * is the literal 0 is pure virtual. A concrete derived class must override
3639
+ * it. (A data member `int x = 0;` has no function declarator and never
3640
+ * reaches the caller.)
3641
+ */
3642
+ function isPureVirtual(node) {
3643
+ const value = templatedDeclaration(node).childForFieldName('default_value');
3644
+ return !!value && value.type === 'number_literal' && value.text === '0';
3645
+ }
3646
+
3636
3647
  function memberFromNode(node, className, access, lines, mode) {
3637
3648
  const declarator = functionDeclarator(node);
3638
3649
  if (!declarator) return null;
@@ -3662,6 +3673,7 @@ function memberFromNode(node, className, access, lines, mode) {
3662
3673
  memberType: isConstructor ? 'constructor' : 'method',
3663
3674
  isMethod: true,
3664
3675
  isConstructor,
3676
+ ...(mode === 'cpp' && !isConstructor && isPureVirtual(node) && { requiredMember: true }),
3665
3677
  ...(mode === 'cpp' && memberQualifiers(declarator)),
3666
3678
  ...(enclosingNamespace(node) && {
3667
3679
  namespace: enclosingNamespace(node),