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/analysis.js CHANGED
@@ -11,9 +11,10 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
  const { execFileSync } = require('child_process');
13
13
  const { UcnError } = require('./errors');
14
+ const { paramFact } = require('./registry');
14
15
  const { parse } = require('./parser');
15
16
  const { detectLanguage, langTraits } = require('../languages');
16
- const { NON_CALLABLE_TYPES, addTestExclusions, countTextBlindspots,
17
+ const { NON_CALLABLE_TYPES, addTestExclusions,
17
18
  codeUnitCompare, formatSymbolHandle, isValueDefinition } = require('./shared');
18
19
  const { isTestFile } = require('./discovery');
19
20
  // Entry-point detection loads on first use (fix #375).
@@ -24,12 +25,12 @@ const symbolKey = (file, line) => entrypoints().symbolKey(file, line);
24
25
  const { getLanguageAdapter } = require('../languages');
25
26
  const { parseOpenCallShape } = require('../languages/rust-value-flow');
26
27
  const { projectComputedDispatch, declarationSnapshots } = require('./ast-analysis');
28
+ const { callableContract, classifyChange, baseCounterpart, parseRevision, moduleChange, declaresPlainReturn } = require('./change-kinds');
27
29
  const { findAccessorReferences } = require('./accessors');
28
30
 
29
31
  // JS/TS test framework helpers — calls to these bracket a test case.
30
32
  // Used to flag call sites whose enclosing function is an arrow callback
31
33
  // passed to one of these (the common pattern in mocha/jest/vitest).
32
- const _JS_TEST_FRAMEWORK_CALLS = new Set(['describe', 'it', 'test', 'spec', 'context', 'suite']);
33
34
 
34
35
  /**
35
36
  * Give agents the exact indexed definitions behind a same-name ambiguity.
@@ -88,66 +89,31 @@ function buildAmbiguityCandidates(index, name, selected, unverified) {
88
89
  }
89
90
 
90
91
  /**
91
- * Tag each call site with `inTestCase` based on its enclosing function's
92
- * entry-point classification. Uses each language's `getEntryPointKind`
93
- * predicate (kind === 'test') so results match `affectedTests`/etc.
94
- *
95
- * For JS/TS/HTML, function-level entry-point classification only covers
96
- * framework lifecycle methods. Test bodies are framework callbacks
97
- * (`it('name', () => {...})`), so we additionally tag a site as inTestCase
98
- * when its file has a `describe`/`it`/`test` framework call that brackets
99
- * the enclosing function — mirroring `_addAffectedTestCases` in tracing.js.
92
+ * Tag each call site with `inTestCase`: its enclosing function is one the
93
+ * language's harness invokes (`getEntryPointKind` says 'test'), or its line
94
+ * sits in a test or suite the file declares (core/test-units.js, the one
95
+ * model of what a test is).
100
96
  *
101
97
  * Mutates each site in place (sets `site.inTestCase = boolean`).
102
98
  */
103
99
  function tagInTestCase(index, sites) {
104
100
  if (!Array.isArray(sites) || sites.length === 0) return;
105
- // Per-file cache: language module + JS framework call ranges.
106
- const fileMeta = new Map();
107
- function getFileMeta(filePath) {
108
- if (!filePath) return null;
109
- if (fileMeta.has(filePath)) return fileMeta.get(filePath);
110
- const fe = index.files.get(filePath);
111
- if (!fe) { fileMeta.set(filePath, null); return null; }
112
- let langModule = null;
113
- try { langModule = getLanguageAdapter(fe.language); } catch (_) { /* ignore */ }
114
- const meta = { fileEntry: fe, langModule, language: fe.language, jsTestRanges: null };
115
- // For JS-family files, build line ranges of describe/it/test framework calls.
116
- if (langModule && isTestFile(fe.relativePath, fe.language) &&
117
- (fe.language === 'javascript' || fe.language === 'typescript' ||
118
- fe.language === 'tsx' || fe.language === 'html')) {
119
- try {
120
- const calls = index.getCachedCalls ? index.getCachedCalls(filePath) : null;
121
- if (Array.isArray(calls)) {
122
- const ranges = [];
123
- for (const call of calls) {
124
- if (!_JS_TEST_FRAMEWORK_CALLS.has(call.name)) continue;
125
- // Get the enclosing test-block end via existing fn-bound estimate.
126
- // Without a proper bracket scan we use the next-fn-boundary or +200 lines.
127
- let endLine = call.line + 200;
128
- // Try using the enclosing-symbol range as an upper bound when possible.
129
- if (fe.symbols) {
130
- for (const sym of fe.symbols) {
131
- if (sym.startLine <= call.line && (sym.endLine || 0) >= call.line) {
132
- endLine = Math.min(endLine, sym.endLine || endLine);
133
- }
134
- }
135
- }
136
- ranges.push({ start: call.line, end: endLine });
137
- }
138
- meta.jsTestRanges = ranges;
139
- }
140
- } catch (_) { /* ignore */ }
141
- }
142
- fileMeta.set(filePath, meta);
143
- return meta;
144
- }
101
+ const model = require('./test-units').createTestModel(index);
102
+ const kindOf = new Map();
145
103
  for (const site of sites) {
146
104
  site.inTestCase = false;
147
- const meta = getFileMeta(site.callerFile);
148
- if (!meta || !meta.langModule) continue;
149
- // First: kinded entry-point predicate (Python/Go/Java/Rust + JS lifecycle).
150
- const classify = meta.langModule.getEntryPointKind;
105
+ // A call in a test's own callback has no named function around it:
106
+ // the site's file is where it is.
107
+ const sitePath = site.callerFile ||
108
+ (site.file ? (path.isAbsolute(site.file) ? site.file : path.join(index.root, site.file)) : null);
109
+ const fileEntry = sitePath ? index.files.get(sitePath) : null;
110
+ if (!fileEntry) continue;
111
+ if (!kindOf.has(fileEntry.language)) {
112
+ let adapter = null;
113
+ try { adapter = getLanguageAdapter(fileEntry.language); } catch (_) { /* ignore */ }
114
+ kindOf.set(fileEntry.language, adapter && adapter.getEntryPointKind ? adapter.getEntryPointKind : null);
115
+ }
116
+ const classify = kindOf.get(fileEntry.language);
151
117
  if (classify && site.callerFile && site.callerStartLine != null) {
152
118
  const encl = index.findEnclosingFunction
153
119
  ? index.findEnclosingFunction(site.callerFile, site.line, true)
@@ -157,15 +123,9 @@ function tagInTestCase(index, sites) {
157
123
  continue;
158
124
  }
159
125
  }
160
- // Second: JS/TS framework-call brackets (it/test/describe). The site is
161
- // inside a test case when its line falls within a describe/it block.
162
- if (meta.jsTestRanges && meta.jsTestRanges.length > 0 && site.line != null) {
163
- for (const r of meta.jsTestRanges) {
164
- if (site.line >= r.start && site.line <= r.end) {
165
- site.inTestCase = true;
166
- break;
167
- }
168
- }
126
+ if (site.line != null && isTestFile(fileEntry.relativePath, fileEntry.language) &&
127
+ model.unitAt(sitePath, site.line)) {
128
+ site.inTestCase = true;
169
129
  }
170
130
  }
171
131
  }
@@ -267,6 +227,16 @@ function tagCalleesSideEffects(index, callees) {
267
227
  }
268
228
 
269
229
 
230
+ /**
231
+ * How many unverified entries get their line text and caller looked up: the
232
+ * rows a formatter prints. Every one with `all`, `top` when it asks for more
233
+ * than the default ten, the engine's default otherwise.
234
+ */
235
+ function unverifiedEnrichLimit(all, top) {
236
+ if (all) return Infinity;
237
+ return Number.isInteger(top) && top > paramFact('top', 'unverifiedRows') ? top : undefined;
238
+ }
239
+
270
240
  /**
271
241
  * Compose the conservation account for a caller query (the tiered
272
242
  * caller contract). Claims come from the PRE-display-filter findCallers result —
@@ -395,7 +365,8 @@ function context(index, name, options = {}) {
395
365
  // Pin caller resolution to the resolved class definition — same as the
396
366
  // function path below. Without this, same-name classes in other files
397
367
  // conflate (their usages attribute to whichever def displays).
398
- let typeCallers = index.findCallers(name, { includeMethods, includeUncertain: options.includeUncertain, collectAccount: true, targetDefinitions: [def] });
368
+ let typeCallers = index.findCallers(name, { includeMethods, includeUncertain: options.includeUncertain, collectAccount: true, targetDefinitions: [def],
369
+ unverifiedEnrichLimit: unverifiedEnrichLimit(options.all, options.maxCallers) });
399
370
  const rawTypeCallers = typeCallers;
400
371
  const typeFilteredByFlag = { exclude: 0, minConfidence: 0, unreachableOnly: 0 };
401
372
  // Tier partition — same contract as the function path: constructor/usage
@@ -457,7 +428,9 @@ function context(index, name, options = {}) {
457
428
  ...(typeAmbiguityCandidates && {
458
429
  ambiguityCandidates: typeAmbiguityCandidates,
459
430
  }),
460
- meta: { account: typeAccount, callerTotal }
431
+ meta: { account: typeAccount, callerTotal,
432
+ ...(options.all && { all: true }),
433
+ ...(options.maxCallers > 0 && { top: options.maxCallers }) }
461
434
  };
462
435
 
463
436
  if (warnings.length > 0) {
@@ -474,8 +447,9 @@ function context(index, name, options = {}) {
474
447
  stats,
475
448
  targetDefinitions: [def],
476
449
  collectAccount: true,
477
- // --all lifts the unverified enrichment cap (content + caller lookup)
478
- unverifiedEnrichLimit: options.all ? Infinity : undefined,
450
+ // --all lifts the unverified enrichment cap (content + caller
451
+ // lookup), --top moves it with the rows it prints.
452
+ unverifiedEnrichLimit: unverifiedEnrichLimit(options.all, options.maxCallers),
479
453
  });
480
454
  // Callee side runs the contract too (v4): uncertain/unresolved calls in
481
455
  // the def's scope become VISIBLE unverifiedCallees entries + a conserved
@@ -630,6 +604,7 @@ function context(index, name, options = {}) {
630
604
  hasEntrypoints: reachableSet.size > 0,
631
605
  }),
632
606
  ...(options.all && { all: true }),
607
+ ...(options.maxCallers > 0 && { top: options.maxCallers }),
633
608
  // Structural facts for reliability hints
634
609
  ...(def.isMethod && { isMethod: true }),
635
610
  ...(def.className && { className: def.className }),
@@ -758,28 +733,22 @@ function detectCompleteness(index) {
758
733
  // Skip node_modules - we don't care about their patterns
759
734
  if (filePath.includes('node_modules')) continue;
760
735
 
761
- try {
762
- const content = index._readFile(filePath);
763
-
764
- if (langTraits(fileEntry.language)?.hasDynamicImports) {
765
- // Dynamic imports: use the parser's structural count — the SAME
766
- // source `doctor` uses — instead of a text regex. The old
767
- // /import\s*\(/ matched Python grouped imports `from x import
768
- // (...)`, flashing a false "N dynamic imports" incompleteness
769
- // warning on essentially every Python project (field-report #2,
770
- // reviewer-confirmed: doctor and about now agree on one count).
771
- dynamicImports += fileEntry.dynamicImports || 0;
772
- }
773
-
774
- // eval/exec and reflection: the SAME shared counter doctor uses, so
775
- // the about footer and the trust report never diverge (field-report #2).
776
- const bs = countTextBlindspots(content, fileEntry.language);
777
- evalUsage += bs.eval;
778
- reflectionUsage += bs.reflection;
779
- computedDispatchUsage += computedDispatch.get(filePath)?.length || 0;
780
- } catch (e) {
781
- // Skip unreadable files
736
+ if (langTraits(fileEntry.language)?.hasDynamicImports) {
737
+ // Dynamic imports: use the parser's structural count — the SAME
738
+ // source `doctor` uses — instead of a text regex. The old
739
+ // /import\s*\(/ matched Python grouped imports `from x import
740
+ // (...)`, flashing a false "N dynamic imports" incompleteness
741
+ // warning on essentially every Python project (field-report #2,
742
+ // reviewer-confirmed: doctor and about now agree on one count).
743
+ dynamicImports += fileEntry.dynamicImports || 0;
782
744
  }
745
+
746
+ // eval/exec and reflection: the two numbers `doctor` reports, counted
747
+ // in each file's tree when it was indexed (core/reflection.js), so
748
+ // the footer and the trust report never diverge and no file is read.
749
+ evalUsage += fileEntry.evalUses || 0;
750
+ reflectionUsage += fileEntry.reflectionUses || 0;
751
+ computedDispatchUsage += computedDispatch.get(filePath)?.length || 0;
783
752
  }
784
753
 
785
754
  if (dynamicImports > 0) {
@@ -1204,6 +1173,7 @@ function impact(index, name, options = {}) {
1204
1173
  includeUncertain: impactIncludeUncertain,
1205
1174
  targetDefinitions: [def],
1206
1175
  collectAccount: true,
1176
+ unverifiedEnrichLimit: unverifiedEnrichLimit(options.all, options.top),
1207
1177
  });
1208
1178
  impactAccountRaw = callerResults.accountRaw;
1209
1179
  impactRoutedUnverified = callerResults.unverifiedEntries || [];
@@ -1251,6 +1221,7 @@ function impact(index, name, options = {}) {
1251
1221
  includeUncertain: impactIncludeUncertain,
1252
1222
  targetDefinitions: [def],
1253
1223
  collectAccount: true,
1224
+ unverifiedEnrichLimit: unverifiedEnrichLimit(options.all, options.top),
1254
1225
  });
1255
1226
  impactAccountRaw = callerResults.accountRaw;
1256
1227
  impactRoutedUnverified = callerResults.unverifiedEntries || [];
@@ -1717,7 +1688,8 @@ function about(index, name, options = {}) {
1717
1688
  // BUG-H1: pass needsTotal:true so the returned array's `totalCount` reflects the
1718
1689
  // true pre-truncation candidate count. Without this, `about` would report the
1719
1690
  // capped count as the total (e.g. "showing 10 of 30" when there are actually 153).
1720
- const rawCallers = index.findCallers(symbolName, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [primary], maxResults: callerCap, needsTotal: true, collectAccount: true });
1691
+ const rawCallers = index.findCallers(symbolName, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [primary], maxResults: callerCap, needsTotal: true, collectAccount: true,
1692
+ unverifiedEnrichLimit: unverifiedEnrichLimit(options.all, options.maxCallers) });
1721
1693
  const shadowCallers = rawCallers.shadowEntries || [];
1722
1694
  allCallers = rawCallers;
1723
1695
  const aboutFilteredByFlag = { exclude: 0, minConfidence: 0, unreachableOnly: 0 };
@@ -1838,9 +1810,18 @@ function about(index, name, options = {}) {
1838
1810
  if (fa !== fb) return codeUnitCompare(fa, fb);
1839
1811
  return (a.line || 0) - (b.line || 0);
1840
1812
  });
1813
+ // Every unverified entry's reason, counted: the listed rows are a part.
1814
+ const unverifiedReasons = {};
1815
+ for (const entry of unverifiedPool) {
1816
+ const reason = entry.reason || 'unknown';
1817
+ unverifiedReasons[reason] = (unverifiedReasons[reason] || 0) + 1;
1818
+ }
1841
1819
  aboutUnverified = {
1842
1820
  total: unverifiedPool.length + unverifiedShadowCount,
1843
- top: unverifiedPool.slice(0, 10).map(c => ({
1821
+ reasons: Object.fromEntries(Object.keys(unverifiedReasons).sort(codeUnitCompare)
1822
+ .map(reason => [reason, unverifiedReasons[reason]])),
1823
+ top: unverifiedPool.slice(0, options.all ? Infinity
1824
+ : options.maxCallers > 0 ? options.maxCallers : 10).map(c => ({
1844
1825
  file: c.relativePath,
1845
1826
  line: c.line,
1846
1827
  ...(c.callerStartLine && c.callerName && {
@@ -2054,6 +2035,79 @@ function about(index, name, options = {}) {
2054
2035
  } finally { index._endOp(); }
2055
2036
  }
2056
2037
 
2038
+ /**
2039
+ * What one revision of a file, and the project, say about the callees a
2040
+ * body calls as bare statements (core/change-kinds.js `provablyReturns`).
2041
+ *
2042
+ * fileNames every name the revision declares or imports: a builtin of
2043
+ * that name means something else there;
2044
+ * returning (name, ownerClass) => every declaration the name can mean
2045
+ * is one a call of which returns (`declaresPlainReturn`): a
2046
+ * function the revision declares, or imports by name from a
2047
+ * project module, or the method its own class declares.
2048
+ *
2049
+ * Declarations are read as the project holds them now, for both revisions:
2050
+ * a callee whose own declaration changed is a changed symbol of its own.
2051
+ *
2052
+ * @param {Map} cache - facts by file entry, kept for ONE diff: what a
2053
+ * callee declares can change between two diffs of the same index
2054
+ */
2055
+ const NAMED_IMPORT_KINDS = new Set(['named', 'from']);
2056
+ function calleeFacts(index, entry, filePath, cache) {
2057
+ if (!entry) return null;
2058
+ if (cache.has(entry)) return cache.get(entry);
2059
+ const live = index.files.get(filePath);
2060
+ const traits = langTraits(live && live.language) || {};
2061
+ const fileNames = new Set();
2062
+ for (const symbol of entry.symbols || []) fileNames.add(symbol.name);
2063
+ for (const binding of entry.importBindings || []) fileNames.add(binding.alias || binding.name);
2064
+ const plain = definition => {
2065
+ const owner = index.files.get(definition.file);
2066
+ const never = owner && langTraits(owner.language)?.neverReturnTypes;
2067
+ if (!Array.isArray(never)) return false;
2068
+ try {
2069
+ const content = index._readFile(definition.file);
2070
+ const root = index._getParsedTree(definition.file, content, owner.language)?.rootNode ||
2071
+ parseRevision(content, owner.language);
2072
+ return declaresPlainReturn(root, definition, never);
2073
+ } catch (_) {
2074
+ return false;
2075
+ }
2076
+ };
2077
+ const allPlain = (name, mine) => {
2078
+ const definitions = (index.symbols.get(name) || []).filter(mine);
2079
+ return definitions.length > 0 && definitions.every(plain);
2080
+ };
2081
+ const verdicts = new Map();
2082
+ const facts = {
2083
+ fileNames,
2084
+ returning(name, ownerClass) {
2085
+ if (!Array.isArray(traits.neverReturnTypes)) return false;
2086
+ const key = `${ownerClass || ''}\0${name}`;
2087
+ if (verdicts.has(key)) return verdicts.get(key);
2088
+ let verdict;
2089
+ if (ownerClass) {
2090
+ verdict = (entry.symbols || []).some(symbol => symbol.name === name && symbol.className === ownerClass) &&
2091
+ allPlain(name, definition => definition.className === ownerClass && definition.file === filePath);
2092
+ } else {
2093
+ let declared = (entry.symbols || []).some(symbol => symbol.name === name && !symbol.className) ? name : null;
2094
+ if (!declared) {
2095
+ const resolved = (live && live.moduleResolved) || {};
2096
+ const binding = (entry.importBindings || []).find(candidate =>
2097
+ (candidate.alias || candidate.name) === name && NAMED_IMPORT_KINDS.has(candidate.kind) &&
2098
+ resolved[candidate.module]);
2099
+ if (binding) declared = binding.name;
2100
+ }
2101
+ verdict = !!declared && allPlain(declared, definition => !definition.className);
2102
+ }
2103
+ verdicts.set(key, verdict);
2104
+ return verdict;
2105
+ },
2106
+ };
2107
+ cache.set(entry, facts);
2108
+ return facts;
2109
+ }
2110
+
2057
2111
  /**
2058
2112
  * Diff-based impact analysis: find which functions changed and who calls them.
2059
2113
  *
@@ -2065,6 +2119,10 @@ function diffImpact(index, options = {}) {
2065
2119
  index._beginOp();
2066
2120
  try {
2067
2121
  const { base = 'HEAD', staged = false, file } = options;
2122
+ // Where git commands run. The index's own root, except for a snapshot
2123
+ // exported from the git index (core/snapshot.js): its files stand in
2124
+ // for the repository's, and the repository is elsewhere.
2125
+ const repoRoot = options.repoRoot || index.repoRoot || index.root;
2068
2126
 
2069
2127
  // Validate base ref format to prevent argument injection
2070
2128
  if (base && !/^[a-zA-Z0-9._\-~\/^@{}:]+$/.test(base)) { // eslint-disable-line no-useless-escape
@@ -2074,7 +2132,7 @@ function diffImpact(index, options = {}) {
2074
2132
  // Verify git repo
2075
2133
  let gitRoot;
2076
2134
  try {
2077
- gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: index.root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
2135
+ gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: repoRoot, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
2078
2136
  } catch (e) {
2079
2137
  throw new UcnError('Not a git repository. impact without a symbol requires git.', { cause: e });
2080
2138
  }
@@ -2096,7 +2154,7 @@ function diffImpact(index, options = {}) {
2096
2154
  // "fatal: ambiguous argument" straight to the terminal AND repeat it
2097
2155
  // inside the error message.
2098
2156
  diffText = execFileSync('git', diffArgs, {
2099
- cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024,
2157
+ cwd: repoRoot, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024,
2100
2158
  stdio: ['ignore', 'pipe', 'pipe'],
2101
2159
  });
2102
2160
  } catch (e) {
@@ -2115,7 +2173,7 @@ function diffImpact(index, options = {}) {
2115
2173
  // Normalize both through realpath to handle macOS /var → /private/var symlinks.
2116
2174
  let realGitRoot, realProjectRoot;
2117
2175
  try { realGitRoot = fs.realpathSync(gitRoot); } catch (_) { realGitRoot = gitRoot; }
2118
- try { realProjectRoot = fs.realpathSync(index.root); } catch (_) { realProjectRoot = index.root; }
2176
+ try { realProjectRoot = fs.realpathSync(repoRoot); } catch (_) { realProjectRoot = repoRoot; }
2119
2177
  const projectPrefix = realGitRoot === realProjectRoot
2120
2178
  ? ''
2121
2179
  : path.relative(realGitRoot, realProjectRoot);
@@ -2143,7 +2201,7 @@ function diffImpact(index, options = {}) {
2143
2201
  let untrackedText = '';
2144
2202
  try {
2145
2203
  untrackedText = execFileSync('git', lsArgs, {
2146
- cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024,
2204
+ cwd: repoRoot, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024,
2147
2205
  stdio: ['ignore', 'pipe', 'ignore'],
2148
2206
  });
2149
2207
  } catch (_) { untrackedText = ''; }
@@ -2192,6 +2250,13 @@ function diffImpact(index, options = {}) {
2192
2250
 
2193
2251
  const functions = [];
2194
2252
  const moduleLevelChanges = [];
2253
+ // What each changed file did outside its declarations: names it no
2254
+ // longer provides to importers, import statements, loose module code.
2255
+ const moduleChanges = [];
2256
+ // Files the diff removed: whoever imports one as a module lost it.
2257
+ const deletedFiles = [];
2258
+ const addedFiles = [];
2259
+ const calleeFactsByEntry = new Map();
2195
2260
  const newFunctions = [];
2196
2261
  const deletedFunctions = [];
2197
2262
  const symbolChanges = { symbols: [], newSymbols: [], deletedSymbols: [] };
@@ -2214,22 +2279,24 @@ function diffImpact(index, options = {}) {
2214
2279
 
2215
2280
  // Handle deleted files: entire file was removed, all functions are deleted
2216
2281
  if (!fileEntry) {
2282
+ if (change.isDeleted) deletedFiles.push(change.relativePath);
2217
2283
  if (change.isDeleted && change.deletedLines.length > 0) {
2218
2284
  const ref = staged ? 'HEAD' : base;
2219
2285
  try {
2220
2286
  const oldContent = execFileSync(
2221
2287
  'git', ['show', `${ref}:${change.gitRelativePath}`],
2222
- { cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
2288
+ { cwd: repoRoot, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
2223
2289
  );
2224
2290
  const oldParsed = parse(oldContent, lang);
2225
- collectNonCallableChanges(index, change, oldParsed, [], symbolChanges);
2291
+ collectNonCallableChanges(index, change, oldParsed, [], symbolChanges, null, options);
2292
+ const baseEntry = index.fileEntryForContent(change.filePath, oldContent);
2226
2293
  for (const oldFn of extractCallableSymbols(oldParsed)) {
2227
- deletedFunctions.push({
2294
+ deletedFunctions.push(withBaseDeclaration({
2228
2295
  name: oldFn.name,
2229
2296
  filePath: change.filePath,
2230
2297
  relativePath: change.relativePath,
2231
2298
  startLine: oldFn.startLine
2232
- });
2299
+ }, baseEntry, oldFn));
2233
2300
  }
2234
2301
  } catch (e) {
2235
2302
  // git show failed — skip
@@ -2237,6 +2304,8 @@ function diffImpact(index, options = {}) {
2237
2304
  }
2238
2305
  continue;
2239
2306
  }
2307
+ // A file that did not exist at the base.
2308
+ if (change.untracked || change.isNew) addedFiles.push(fileEntry.relativePath);
2240
2309
 
2241
2310
  // Track which functions are affected by added/modified lines
2242
2311
  const affectedSymbols = new Map(); // symbolName -> { symbol, addedLines, deletedLines }
@@ -2255,7 +2324,7 @@ function diffImpact(index, options = {}) {
2255
2324
  try {
2256
2325
  const oldContent = execFileSync(
2257
2326
  'git', ['show', `${ref}:${change.gitRelativePath}`],
2258
- { cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
2327
+ { cwd: repoRoot, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
2259
2328
  );
2260
2329
  const fileLang = index.languageOf(change.filePath);
2261
2330
  if (fileLang) {
@@ -2273,7 +2342,33 @@ function diffImpact(index, options = {}) {
2273
2342
  }
2274
2343
  }
2275
2344
 
2276
- collectNonCallableChanges(index, change, oldParsed, fileEntry.symbols, symbolChanges, oldLines);
2345
+ collectNonCallableChanges(index, change, oldParsed, fileEntry.symbols, symbolChanges, oldLines, options);
2346
+
2347
+ // The base revision read with the index's own eyes, and both
2348
+ // revisions' syntax trees: what a changed callable's contract was
2349
+ // and is (core/change-kinds.js). Built on first use.
2350
+ let baseEntry;
2351
+ const baseEntryOf = () => {
2352
+ if (baseEntry === undefined) baseEntry = oldText != null ? index.fileEntryForContent(change.filePath, oldText) : null;
2353
+ return baseEntry;
2354
+ };
2355
+ let baseRoot;
2356
+ let currentRoot;
2357
+ const contractChange = symbol => {
2358
+ if (change.untracked || oldText == null) return { kind: 'unknown', reasons: ['contract-unreadable'] };
2359
+ const fileLang = fileEntry.language;
2360
+ if (baseRoot === undefined) baseRoot = parseRevision(oldText, fileLang);
2361
+ if (currentRoot === undefined) {
2362
+ let content;
2363
+ try { content = index._readFile(change.filePath); } catch { content = null; }
2364
+ currentRoot = content == null ? null
2365
+ : (index._getParsedTree(change.filePath, content, fileLang)?.rootNode || parseRevision(content, fileLang));
2366
+ }
2367
+ const base = baseCounterpart(baseEntryOf()?.symbols, symbol, isDiffCallable);
2368
+ return classifyChange(
2369
+ base ? callableContract(baseRoot, fileLang, base, calleeFacts(index, baseEntryOf(), change.filePath, calleeFactsByEntry)) : null,
2370
+ callableContract(currentRoot, fileLang, symbol, calleeFacts(index, fileEntry, change.filePath, calleeFactsByEntry)), fileLang);
2371
+ };
2277
2372
 
2278
2373
  // Several callables can share a changed line (`class Box {
2279
2374
  // constructor(v) {..} get w() {..} }`, fix #434): the line belongs
@@ -2474,7 +2569,10 @@ function diffImpact(index, options = {}) {
2474
2569
  relativePath: change.relativePath,
2475
2570
  startLine: symbol.startLine,
2476
2571
  endLine: symbol.endLine,
2477
- signature: index.formatSignature(symbol)
2572
+ signature: index.formatSignature(symbol),
2573
+ ...(symbol.className && { className: symbol.className }),
2574
+ // Nothing in a file the base does not have existed before.
2575
+ ...((change.untracked || change.isNew) && { newFile: true }),
2478
2576
  });
2479
2577
  affectedSymbols.delete(key);
2480
2578
  }
@@ -2505,17 +2603,57 @@ function diffImpact(index, options = {}) {
2505
2603
  // Report the extra ones (by startLine descending — later ones more likely deleted)
2506
2604
  const toReport = matching.slice(curCount);
2507
2605
  for (const oldFn of toReport) {
2508
- deletedFunctions.push({
2606
+ deletedFunctions.push(withBaseDeclaration({
2509
2607
  name: oldFn.name,
2510
2608
  filePath: change.filePath,
2511
2609
  relativePath: change.relativePath,
2512
2610
  startLine: oldFn.startLine
2513
- });
2611
+ }, baseEntryOf(), oldFn));
2514
2612
  }
2515
2613
  }
2516
2614
  }
2517
2615
  }
2518
2616
 
2617
+ // Outside the declarations: what the module provides and what it
2618
+ // runs. Both revisions are needed; a new file has no "before".
2619
+ if (oldText != null && !change.untracked) {
2620
+ const fileLang = fileEntry.language;
2621
+ const before = { fileEntry: baseEntryOf(), root: parseRevision(oldText, fileLang) };
2622
+ let currentContent;
2623
+ try { currentContent = index._readFile(change.filePath); } catch { currentContent = null; }
2624
+ // Like with like: both revisions as the parser alone records
2625
+ // them. The live entry also holds what later build phases add
2626
+ // (declarations a macro invocation expands to), which no base
2627
+ // entry has, and a macro invocation would read as code that
2628
+ // exists only before.
2629
+ const after = {
2630
+ fileEntry: currentContent == null ? null : index.fileEntryForContent(change.filePath, currentContent),
2631
+ root: currentContent == null ? null : parseRevision(currentContent, fileLang),
2632
+ };
2633
+ if (before.fileEntry && before.root && after.fileEntry && after.root) {
2634
+ const moved = moduleChange(before, after, langTraits(fileLang) || {});
2635
+ const gone = new Set(deletedFunctions.filter(item => item.filePath === change.filePath).map(item => item.name));
2636
+ for (const item of symbolChanges.deletedSymbols) {
2637
+ if (item.filePath === change.filePath) gone.add(item.name);
2638
+ }
2639
+ // A deleted declaration's name is assessed as a deletion.
2640
+ const removedNames = moved.removedNames.filter(name => !gone.has(name));
2641
+ if (removedNames.length > 0 || moved.importsRemoved > 0 || moved.importsAdded > 0 ||
2642
+ moved.codeChanged || moved.starExportRemoved || moved.reboundBindings.length > 0) {
2643
+ moduleChanges.push({
2644
+ filePath: change.filePath,
2645
+ relativePath: change.relativePath,
2646
+ ...moved,
2647
+ removedNames,
2648
+ removedExports: moved.removedExports.filter(name => !gone.has(name)),
2649
+ // Does a newly imported name already mean something in the file?
2650
+ shadowed: moved.addedBindings.filter(name =>
2651
+ new RegExp(`(?<![\\w$])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w$])`).test(oldText)),
2652
+ });
2653
+ }
2654
+ }
2655
+ }
2656
+
2519
2657
  // For each affected function, find callers under the tiered caller
2520
2658
  // contract (v4): confirmed edges populate `callers`; evidence-less
2521
2659
  // candidates render in `unverifiedCallers` with reasons; positively
@@ -2534,6 +2672,7 @@ function diffImpact(index, options = {}) {
2534
2672
  targetDefinitions: targetDefs.length > 0 ? targetDefs : undefined,
2535
2673
  includeMethods: true,
2536
2674
  collectAccount: true,
2675
+ unverifiedEnrichLimit: unverifiedEnrichLimit(options.all, options.top),
2537
2676
  });
2538
2677
  // Call sites the base declaration bound that the changed one's
2539
2678
  // sweep excludes (fix #394) are the change's likeliest breaks:
@@ -2591,6 +2730,9 @@ function diffImpact(index, options = {}) {
2591
2730
  signature: index.formatSignature(symbol),
2592
2731
  addedLines: aLines,
2593
2732
  deletedLines: dLines,
2733
+ // What the change did to the contract callers rely on:
2734
+ // 'inert', 'contract' (with what moved) or 'unknown'.
2735
+ change: contractChange(symbol),
2594
2736
  callers: confirmed.map(c => ({
2595
2737
  file: c.file,
2596
2738
  relativePath: c.relativePath,
@@ -2688,7 +2830,13 @@ function diffImpact(index, options = {}) {
2688
2830
  for (const site of refs?.unverifiedSites || []) dependencyFiles.add(site.file);
2689
2831
  }
2690
2832
 
2691
- return {
2833
+ // Which lines of which file the diff added: what "outside this change"
2834
+ // means for a name. Not enumerable: the report's shape is unchanged.
2835
+ const addedLinesByFile = new Map();
2836
+ for (const change of changes) {
2837
+ if (change.addedLines.length > 0) addedLinesByFile.set(change.relativePath, new Set(change.addedLines));
2838
+ }
2839
+ const report = Object.defineProperty({
2692
2840
  base: staged ? '(staged)' : base,
2693
2841
  changedPaths: changes.length,
2694
2842
  nonSourcePaths,
@@ -2712,13 +2860,30 @@ function diffImpact(index, options = {}) {
2712
2860
  unverifiedCallSites: totalUnverifiedSites,
2713
2861
  affectedFiles: callerFileSet.size
2714
2862
  }
2715
- };
2863
+ }, 'addedLinesByFile', { value: addedLinesByFile, enumerable: false });
2864
+ Object.defineProperty(report, 'moduleChanges', { value: moduleChanges, enumerable: false });
2865
+ Object.defineProperty(report, 'deletedFiles', { value: deletedFiles, enumerable: false });
2866
+ Object.defineProperty(report, 'addedFiles', { value: addedFiles, enumerable: false });
2867
+ return report;
2716
2868
  } finally { index._endOp(); }
2717
2869
  }
2718
2870
 
2871
+ /**
2872
+ * A deleted callable keeps the declaration it was: the base revision's own
2873
+ * record and that revision's file facts, for a deletion assessment
2874
+ * (core/deletion.js). Not enumerable: the report's shape is unchanged.
2875
+ */
2876
+ function withBaseDeclaration(item, baseEntry, oldSymbol) {
2877
+ const declaration = (baseEntry?.symbols || []).find(symbol => symbol.name === oldSymbol.name &&
2878
+ (symbol.className || '') === (oldSymbol.className || '') && symbol.startLine === oldSymbol.startLine);
2879
+ Object.defineProperty(item, 'declaration', { value: declaration || null, enumerable: false });
2880
+ Object.defineProperty(item, 'baseFileEntry', { value: baseEntry || null, enumerable: false });
2881
+ return item;
2882
+ }
2883
+
2719
2884
  // Non-callable declarations have change dependencies too. Keep them separate
2720
2885
  // from functions so type references never inflate call-site accounting.
2721
- function collectNonCallableChanges(index, change, oldParsed, currentSymbols, output, oldLines = null) {
2886
+ function collectNonCallableChanges(index, change, oldParsed, currentSymbols, output, oldLines = null, options = {}) {
2722
2887
  const nonCallable = s => NON_CALLABLE_TYPES.has(s.type) || s.type === 'record';
2723
2888
  const oldSymbols = [];
2724
2889
  for (const cls of oldParsed?.classes || []) {
@@ -2770,6 +2935,7 @@ function collectNonCallableChanges(index, change, oldParsed, currentSymbols, out
2770
2935
  } else {
2771
2936
  item.impact = impact(index, symbol.name, {
2772
2937
  file: change.relativePath, line: symbol.startLine, className: symbol.className,
2938
+ all: options.all, top: options.top,
2773
2939
  });
2774
2940
  }
2775
2941
  output[kind].push(item);
@@ -437,6 +437,8 @@ function projectReflectionSites(index) {
437
437
  }
438
438
 
439
439
  module.exports = {
440
+ CALLABLE_NODES,
441
+ COMMENT_NODES,
440
442
  declarationSnapshots,
441
443
  computeAstComplexity,
442
444
  computedDispatchSites,