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.
- package/.claude/skills/ucn/SKILL.md +35 -12
- package/.claude/skills/ucn/references/commands.md +18 -14
- package/.claude/skills/ucn/references/resolution-rules.md +4 -0
- package/.claude/skills/ucn/references/trust-contract.md +4 -4
- package/CHANGELOG.md +474 -0
- package/README.md +69 -29
- package/cli/flags.js +184 -0
- package/cli/index.js +137 -324
- package/core/account.js +44 -87
- package/core/analysis.js +273 -107
- package/core/ast-analysis.js +2 -0
- package/core/bridge.js +14 -0
- package/core/cache.js +101 -16
- package/core/callers.js +352 -70
- package/core/capture.js +331 -0
- package/core/change-kinds.js +888 -0
- package/core/check.js +1117 -139
- package/core/command-contracts.js +7 -6
- package/core/constructed-class.js +1195 -0
- package/core/contract-membership.js +142 -0
- package/core/deadcode.js +362 -84
- package/core/deletion.js +401 -0
- package/core/discovery.js +104 -9
- package/core/entrypoints.js +12 -6
- package/core/evidence-bands.js +68 -0
- package/core/evidence-bands.json +44 -0
- package/core/execute.js +172 -161
- package/core/hot-summary.js +241 -0
- package/core/index-ir.js +3 -1
- package/core/ir.js +5 -1
- package/core/member-signatures.js +789 -0
- package/core/output/analysis-ext.js +20 -10
- package/core/output/analysis.js +98 -60
- package/core/output/check.js +98 -37
- package/core/output/doctor.js +34 -1
- package/core/output/find.js +2 -1
- package/core/output/lines.js +63 -3
- package/core/output/public.js +29 -11
- package/core/output/refactoring.js +32 -0
- package/core/output/reporting.js +15 -2
- package/core/output/search.js +11 -2
- package/core/output/tracing.js +35 -7
- package/core/output-budget.js +124 -57
- package/core/parallel-build.js +3 -3
- package/core/project.js +106 -23
- package/core/provenance-type.js +12 -5
- package/core/public-command.js +35 -9
- package/core/python-modules.js +6 -0
- package/core/reflection.js +151 -23
- package/core/registry.js +623 -72
- package/core/reporting.js +69 -34
- package/core/review-items.js +75 -0
- package/core/search.js +89 -128
- package/core/shared.js +0 -45
- package/core/snapshot.js +350 -0
- package/core/source-universe.js +707 -0
- package/core/test-units.js +3624 -0
- package/core/tracing.js +150 -96
- package/core/verify.js +27 -11
- package/languages/adapter.js +8 -3
- package/languages/c-family.js +12 -0
- package/languages/csharp.js +634 -0
- package/languages/go.js +120 -0
- package/languages/index.js +422 -4
- package/languages/java.js +712 -0
- package/languages/javascript.js +1460 -2
- package/languages/lexical-scope.js +68 -0
- package/languages/python.js +1672 -1
- package/languages/rust.js +91 -0
- package/mcp/server.js +44 -110
- 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,
|
|
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
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
-
|
|
106
|
-
const
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
478
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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);
|