ucn 5.6.0 → 5.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/ucn/SKILL.md +4 -2
- package/.claude/skills/ucn/references/commands.md +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +5 -8
- package/core/analysis.js +3 -3
- package/core/cache.js +3 -1
- package/core/call-fit.js +130 -0
- package/core/callers.js +1 -1
- package/core/capture.js +40 -8
- package/core/change-kinds.js +221 -2
- package/core/check.js +48 -15
- package/core/command-contracts.js +2 -2
- package/core/deadcode.js +94 -23
- package/core/deletion.js +29 -9
- package/core/entrypoint-toml.js +110 -0
- package/core/index-ir.js +2 -2
- package/core/ir.js +1 -1
- package/core/outgoing-check.js +116 -0
- package/core/output/check.js +5 -2
- package/core/output/reporting.js +4 -0
- package/core/output/search.js +3 -0
- package/core/output/tracing.js +1 -1
- package/core/output-budget.js +29 -11
- package/core/python-binding-identity.js +707 -0
- package/core/reflection.js +99 -10
- package/core/registry.js +2 -2
- package/core/runtime-entrypoints.js +159 -0
- package/core/search.js +61 -38
- package/core/test-units.js +116 -42
- package/core/tracing.js +193 -635
- package/core/verify.js +37 -12
- package/languages/csharp.js +4 -4
- package/languages/go.js +1 -1
- package/languages/index.js +45 -1
- package/languages/java.js +4 -4
- package/languages/javascript.js +40 -6
- package/languages/python.js +49 -15
- package/languages/rust.js +126 -112
- package/languages/static-js.js +88 -0
- package/mcp/server.js +1 -1
- package/package.json +4 -3
package/core/deadcode.js
CHANGED
|
@@ -19,6 +19,9 @@ const { protocolMemberOf, protocolTypeOf, typedLiteralContract, rustProjectTrait
|
|
|
19
19
|
hierarchySlotClosure, goContractClosure, structuralContractsRequiring,
|
|
20
20
|
requirementIn, resetContractIndex } = require('./contract-membership');
|
|
21
21
|
const { projectKind, downstreamExposureBlocks } = require('./deletion');
|
|
22
|
+
const { pythonIdentity, registrationUnknown, pythonSourceName, pythonNormalizedNames,
|
|
23
|
+
pythonProjectIdentityAffected } = require('./python-binding-identity');
|
|
24
|
+
const { runtimeEntrypoints, runtimeTargetReaches } = require('./runtime-entrypoints');
|
|
22
25
|
|
|
23
26
|
const _CLASS_KINDS = ['class', 'struct', 'interface', 'trait', 'record'];
|
|
24
27
|
|
|
@@ -32,12 +35,6 @@ const CLASS_AUDIT_KINDS = ['class', 'struct', 'interface', 'trait', 'record', 'e
|
|
|
32
35
|
// the callable with an external runtime. They therefore remain eligible for a
|
|
33
36
|
// name/usage-based dead-code proof. Every other unknown decorator/annotation
|
|
34
37
|
// stays conservative: it may be the only evidence of framework registration.
|
|
35
|
-
const _NON_REGISTERING_DECORATORS = new Set([
|
|
36
|
-
'property',
|
|
37
|
-
'classmethod',
|
|
38
|
-
'staticmethod',
|
|
39
|
-
]);
|
|
40
|
-
|
|
41
38
|
function _decoratorName(value) {
|
|
42
39
|
const raw = typeof value === 'string'
|
|
43
40
|
? value
|
|
@@ -46,7 +43,33 @@ function _decoratorName(value) {
|
|
|
46
43
|
return String(raw).trim().replace(/^@/, '').split('(')[0].trim();
|
|
47
44
|
}
|
|
48
45
|
|
|
49
|
-
|
|
46
|
+
const PYTHON_DECLARATIONS = new WeakMap();
|
|
47
|
+
|
|
48
|
+
function pythonDeclaration(index, symbol) {
|
|
49
|
+
try {
|
|
50
|
+
const operation = index._opTreeCache;
|
|
51
|
+
let byFile = operation && PYTHON_DECLARATIONS.get(operation);
|
|
52
|
+
if (!byFile) {
|
|
53
|
+
byFile = new Map();
|
|
54
|
+
if (operation) PYTHON_DECLARATIONS.set(operation, byFile);
|
|
55
|
+
}
|
|
56
|
+
let byName = byFile.get(symbol.file);
|
|
57
|
+
if (!byName) {
|
|
58
|
+
byName = new Map();
|
|
59
|
+
const tree = index._getParsedTree(symbol.file, index._readFile(symbol.file), 'python');
|
|
60
|
+
for (const node of tree.rootNode.descendantsOfType(['function_definition', 'class_definition'])) {
|
|
61
|
+
const name = node.childForFieldName('name')?.text;
|
|
62
|
+
if (!byName.has(name)) byName.set(name, []);
|
|
63
|
+
byName.get(name).push(node);
|
|
64
|
+
}
|
|
65
|
+
byFile.set(symbol.file, byName);
|
|
66
|
+
}
|
|
67
|
+
return (byName.get(symbol.name) || []).find(node =>
|
|
68
|
+
node.startPosition.row + 1 >= symbol.startLine && node.endPosition.row + 1 <= symbol.endLine);
|
|
69
|
+
} catch { return null; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hasUnknownRegistrationDecorator(symbol, index) {
|
|
50
73
|
const values = [
|
|
51
74
|
symbol.decorators,
|
|
52
75
|
symbol.decoratorsWithArgs,
|
|
@@ -54,15 +77,15 @@ function hasUnknownRegistrationDecorator(symbol) {
|
|
|
54
77
|
symbol.attributesWithArgs,
|
|
55
78
|
].flatMap(items => Array.isArray(items) ? items : []);
|
|
56
79
|
|
|
80
|
+
if (values.length === 0) return false;
|
|
81
|
+
if (index?.files.get(symbol.file)?.language === 'python') {
|
|
82
|
+
if (typeof symbol.registrationUnknown === 'boolean') return symbol.registrationUnknown || pythonProjectIdentityAffected(index, 'builtins.*');
|
|
83
|
+
const declaration = pythonDeclaration(index, symbol);
|
|
84
|
+
return declaration ? registrationUnknown(declaration) || pythonProjectIdentityAffected(index, 'builtins.*') : true;
|
|
85
|
+
}
|
|
57
86
|
return values.some(value => {
|
|
58
87
|
const qualified = _decoratorName(value);
|
|
59
88
|
if (!qualified) return false;
|
|
60
|
-
const bare = qualified.split(/\.|::/).pop();
|
|
61
|
-
if (_NON_REGISTERING_DECORATORS.has(bare)) return false;
|
|
62
|
-
// `@name.getter`, `@name.setter`, and `@name.deleter` install an
|
|
63
|
-
// accessor on an already local property; they are not external
|
|
64
|
-
// framework registration boundaries.
|
|
65
|
-
if (['getter', 'setter', 'deleter'].includes(bare)) return false;
|
|
66
89
|
return true;
|
|
67
90
|
});
|
|
68
91
|
}
|
|
@@ -93,9 +116,20 @@ const _UNIVERSAL_ROOTS = new Set(['object', 'Object']);
|
|
|
93
116
|
const _PY_NON_DISPATCHING_BASES = new Set(['Generic']);
|
|
94
117
|
|
|
95
118
|
/** True when a base name resolves to NO in-project class/struct/interface/trait/record (an out-of-tree type). */
|
|
96
|
-
function _baseIsExternal(index, bare, lang, fromDef) {
|
|
97
|
-
if (!bare
|
|
98
|
-
if (lang === 'python' &&
|
|
119
|
+
function _baseIsExternal(index, bare, lang, fromDef, raw) {
|
|
120
|
+
if (!bare) return false;
|
|
121
|
+
if (lang === 'python' && fromDef) {
|
|
122
|
+
const declaration = pythonDeclaration(index, fromDef);
|
|
123
|
+
let base = declaration?.childForFieldName('superclasses')?.namedChildren.find(node =>
|
|
124
|
+
node.text === raw || (node.type === 'subscript' && node.childForFieldName('value')?.text === raw));
|
|
125
|
+
while (base?.type === 'subscript') base = base.childForFieldName('value');
|
|
126
|
+
const identity = pythonIdentity(base);
|
|
127
|
+
if (identity === 'builtins.object' || identity === 'typing.Generic') return pythonProjectIdentityAffected(index, identity);
|
|
128
|
+
// A source import can reuse a builtin's spelling without its semantics.
|
|
129
|
+
if (identity && (_UNIVERSAL_ROOTS.has(bare) || _PY_NON_DISPATCHING_BASES.has(bare))) return true;
|
|
130
|
+
} else if (_UNIVERSAL_ROOTS.has(bare) &&
|
|
131
|
+
!(index.files.get(fromDef?.file)?.importBindings || []).some(binding => (binding.alias || binding.name) === bare) &&
|
|
132
|
+
!(index.symbols.get(bare) || []).some(def => def.file === fromDef?.file)) return false;
|
|
99
133
|
const defs = index.symbols.get(bare);
|
|
100
134
|
return !(defs && defs.some(d => _CLASS_KINDS.includes(d.type) &&
|
|
101
135
|
// `class EnvironBuilder(werkzeug.test.EnvironBuilder)` must not
|
|
@@ -152,7 +186,7 @@ function _heritageReachesExternalBase(index, classDef, lang, followImplements) {
|
|
|
152
186
|
const bare = _bareBaseName(raw);
|
|
153
187
|
if (!bare || seen.has(bare)) continue;
|
|
154
188
|
seen.add(bare);
|
|
155
|
-
if (_baseIsExternal(index, bare, lang, def)) return true;
|
|
189
|
+
if (_baseIsExternal(index, bare, lang, def, raw)) return true;
|
|
156
190
|
for (const pd of index.symbols.get(bare) || []) {
|
|
157
191
|
if (_CLASS_KINDS.includes(pd.type)) next.push(pd);
|
|
158
192
|
}
|
|
@@ -349,7 +383,7 @@ function contractFactsOf(index, declared, lang) {
|
|
|
349
383
|
typedLiteralExternalMember(index, declared) ||
|
|
350
384
|
implicitStandardContract(declared, lang)),
|
|
351
385
|
projectContract: requiredByProjectContract(index, declared),
|
|
352
|
-
registered: !!(isFrameworkEntrypoint(declared, index) || hasUnknownRegistrationDecorator(declared)),
|
|
386
|
+
registered: !!(isFrameworkEntrypoint(declared, index) || hasUnknownRegistrationDecorator(declared, index)),
|
|
353
387
|
};
|
|
354
388
|
}
|
|
355
389
|
|
|
@@ -885,6 +919,7 @@ function deadcode(index, options = {}) {
|
|
|
885
919
|
// reports every outcome (detail: the audit row of a claim, the sites of
|
|
886
920
|
// a use). Neither changes what is claimed.
|
|
887
921
|
const onlyNames = options.names instanceof Set ? options.names : null;
|
|
922
|
+
const packagingEntrypoints = runtimeEntrypoints(index);
|
|
888
923
|
const explain = typeof options.explain === 'function' ? options.explain : null;
|
|
889
924
|
const withhold = (symbol, reason, detail) => { if (explain) explain(symbol, reason, detail); };
|
|
890
925
|
// Assessment only: names whose every call site the caller has shown to
|
|
@@ -990,8 +1025,8 @@ function deadcode(index, options = {}) {
|
|
|
990
1025
|
if (!fe || sites.length === 0) continue;
|
|
991
1026
|
for (const site of sites) {
|
|
992
1027
|
if (site.dynamic) dynamicReflectionFiles.add(reflectionFile);
|
|
993
|
-
if (site.name) literalReflectionNames.add(site.name);
|
|
994
|
-
if (site.patterns && site.patterns.length > 0 && !site.name) {
|
|
1028
|
+
if (site.name && !site.runtimeTarget) literalReflectionNames.add(site.name);
|
|
1029
|
+
if (site.patterns && site.patterns.length > 0 && !site.name && !site.runtimeTarget) {
|
|
995
1030
|
// Exact alternatives of a multi-spelling site are literals.
|
|
996
1031
|
for (const pattern of site.patterns) {
|
|
997
1032
|
if (!pattern.includes('*')) literalReflectionNames.add(pattern);
|
|
@@ -1283,6 +1318,18 @@ function deadcode(index, options = {}) {
|
|
|
1283
1318
|
// name inside /* ... */ counted as usage — commented-out code
|
|
1284
1319
|
// silently kept its symbols "alive" and hid true dead claims.
|
|
1285
1320
|
const lines = maskBlockComments(content, fileEntry.language).split('\n');
|
|
1321
|
+
let stringTree;
|
|
1322
|
+
const multilinePython = fileEntry.language === 'python' && (content.includes('"""') || content.includes("'''"));
|
|
1323
|
+
const inMultilineString = (line, column) => {
|
|
1324
|
+
if (!multilinePython) return false;
|
|
1325
|
+
stringTree ||= index._getParsedTree(filePath, content, fileEntry.language);
|
|
1326
|
+
let node = stringTree.rootNode.descendantForPosition({ row: line, column });
|
|
1327
|
+
for (; node; node = node.parent) {
|
|
1328
|
+
if (node.type === 'interpolation') return false;
|
|
1329
|
+
if (node.type === 'string') return true;
|
|
1330
|
+
}
|
|
1331
|
+
return false;
|
|
1332
|
+
};
|
|
1286
1333
|
for (const name of namesInFile) {
|
|
1287
1334
|
const nameLen = name.length;
|
|
1288
1335
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -1318,7 +1365,7 @@ function deadcode(index, options = {}) {
|
|
|
1318
1365
|
const rustAttributePath = fileEntry.language === 'rust' &&
|
|
1319
1366
|
/^\s*#\[\s*serde\s*\(/.test(line) &&
|
|
1320
1367
|
new RegExp(`\\b(?:serialize_with|deserialize_with|default|with)\\s*=\\s*["'][^"']*\\b${escapeRegExp(name)}\\b`).test(line);
|
|
1321
|
-
if (isInsideString(line, pos, fileEntry.language) &&
|
|
1368
|
+
if ((isInsideString(line, pos, fileEntry.language) || inMultilineString(i, pos)) &&
|
|
1322
1369
|
!(fileEntry.language === 'python' && classKindNames.has(name)) &&
|
|
1323
1370
|
!rustAttributePath) continue;
|
|
1324
1371
|
// Property/field access (preceded by '.'), not a
|
|
@@ -1639,7 +1686,18 @@ function deadcode(index, options = {}) {
|
|
|
1639
1686
|
// call-pattern-based registration (Express routes, Gin handlers, etc.)
|
|
1640
1687
|
// These functions are invoked by frameworks, not by user code.
|
|
1641
1688
|
const hasFrameworkEntrypoint = isFrameworkEntrypoint(symbol, index);
|
|
1642
|
-
const hasDecoratorMetadata = hasUnknownRegistrationDecorator(symbol);
|
|
1689
|
+
const hasDecoratorMetadata = hasUnknownRegistrationDecorator(symbol, index);
|
|
1690
|
+
if (lang === 'python' && packagingEntrypoints.some(entry => entry.unknown || runtimeTargetReaches(index, symbol, entry.target))) {
|
|
1691
|
+
withhold(symbol, 'runtime-entrypoint');
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
if (langTraits(lang)?.reflectionApi?.pythonBindingIdentity && symbol.registrationUnknown === false &&
|
|
1696
|
+
(symbol.decorators || []).length && pythonProjectIdentityAffected(index, 'builtins.*')) {
|
|
1697
|
+
excludedDecorated++;
|
|
1698
|
+
withhold(symbol, 'builtin-namespace-effect');
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1643
1701
|
|
|
1644
1702
|
// Unknown decorators are conservative runtime-registration
|
|
1645
1703
|
// boundaries. A decorator necessarily consumes/replaces the
|
|
@@ -1653,6 +1711,19 @@ function deadcode(index, options = {}) {
|
|
|
1653
1711
|
continue;
|
|
1654
1712
|
}
|
|
1655
1713
|
|
|
1714
|
+
// The caller graph retains source spellings. Python can bind a
|
|
1715
|
+
// different written identifier to this runtime name via NFKC;
|
|
1716
|
+
// until that graph models the equivalence, do not clear it. The
|
|
1717
|
+
// inventory is AST-only and shared by every symbol in this op.
|
|
1718
|
+
if (langTraits(lang)?.reflectionApi?.pythonBindingIdentity &&
|
|
1719
|
+
(pythonSourceName(symbol.name) !== symbol.name ||
|
|
1720
|
+
(symbol.runtimeOwnerPath || [symbol.className]).filter(Boolean)
|
|
1721
|
+
.some(owner => pythonSourceName(owner) !== owner) ||
|
|
1722
|
+
pythonNormalizedNames(index).has(pythonSourceName(symbol.name)))) {
|
|
1723
|
+
withhold(symbol, 'identifier-normalization');
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1656
1727
|
const isExported = symbolIsExported(index, symbol, fileEntry);
|
|
1657
1728
|
|
|
1658
1729
|
// `target.onmessage = function ...` / `target.onopen = (...) =>`
|
|
@@ -1707,7 +1778,7 @@ function deadcode(index, options = {}) {
|
|
|
1707
1778
|
continue;
|
|
1708
1779
|
}
|
|
1709
1780
|
if (members.some(m => isFrameworkEntrypoint(m, index) ||
|
|
1710
|
-
hasUnknownRegistrationDecorator(m))) {
|
|
1781
|
+
hasUnknownRegistrationDecorator(m, index))) {
|
|
1711
1782
|
if (!options.includeDecorated) {
|
|
1712
1783
|
excludedDecorated++;
|
|
1713
1784
|
withhold(symbol, 'registered');
|
package/core/deletion.js
CHANGED
|
@@ -13,11 +13,10 @@
|
|
|
13
13
|
* no-static-dependents the name has no use in project source outside its
|
|
14
14
|
* own declarations, and no shield applies: nothing
|
|
15
15
|
* the engine can see depends on it;
|
|
16
|
-
* has-static-dependents a call or
|
|
17
|
-
* the declaration. The first such site is
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* of them the use reaches;
|
|
16
|
+
* has-static-dependents a confirmed call or an unambiguous use exists
|
|
17
|
+
* OUTSIDE the declaration. The first such site is
|
|
18
|
+
* given. Unresolved name-only evidence remains
|
|
19
|
+
* review and never borrows another target's site;
|
|
21
20
|
* review no use outside the declaration was found, but a
|
|
22
21
|
* zero is not evidence here: a shield applies
|
|
23
22
|
* (consumers outside the repository, a contract, a
|
|
@@ -68,7 +67,10 @@ const DEPENDENT_REASONS = new Set(['has-callers', 'has-usages', 'member-in-use']
|
|
|
68
67
|
/** Why the pipeline did or did not claim a declaration, in one sentence. */
|
|
69
68
|
const REASON_TEXT = Object.freeze({
|
|
70
69
|
'claimed': 'the name has no use in project source outside its own declarations, and no shield applies',
|
|
71
|
-
'has-callers': 'a call
|
|
70
|
+
'has-callers': 'a call is proven to reach this declaration from outside it',
|
|
71
|
+
'unresolved-use': 'uses of the name exist, but none is proven to reach this declaration',
|
|
72
|
+
'identifier-normalization': 'Python normalizes a source identifier spelling that the caller graph does not model',
|
|
73
|
+
'builtin-namespace-effect': 'project source can change the builtin namespace that this declaration relies on',
|
|
72
74
|
'has-usages': 'the name is used outside the declaration',
|
|
73
75
|
'member-in-use': 'a member of this type is called outside the declaration',
|
|
74
76
|
'used-only-within-itself': 'every use found is inside the declaration itself, and the audit does not clear a name that is in use',
|
|
@@ -80,6 +82,7 @@ const REASON_TEXT = Object.freeze({
|
|
|
80
82
|
'runtime-contract': 'invoked by the language runtime, the compiler or an assignment, not by name',
|
|
81
83
|
'registered': 'decorated or registered with a framework, which may invoke it without naming it',
|
|
82
84
|
'entry-point': 'an entry point the runtime or a test runner invokes',
|
|
85
|
+
'runtime-entrypoint': 'packaging metadata registers this declaration, or its runtime entrypoints cannot be resolved',
|
|
83
86
|
'constructor': 'a constructor: part of its type\'s allocation protocol, assessed with the type',
|
|
84
87
|
'reflection': 'reflection in the project can reach this name',
|
|
85
88
|
'dynamic-dispatch': 'member of a registry that is called through a computed key',
|
|
@@ -177,7 +180,21 @@ function callSiteOutside(index, declaration, indexed, names) {
|
|
|
177
180
|
function useOutsideDeclaration(index, declaration, indexed, deadcode, options) {
|
|
178
181
|
const name = declaration.name;
|
|
179
182
|
const call = callSiteOutside(index, declaration, indexed, [name]);
|
|
180
|
-
if (
|
|
183
|
+
if (!indexed) {
|
|
184
|
+
const definite = definiteDependentsOf(index, declaration).find(site => site.kind === 'call');
|
|
185
|
+
if (definite) return { reason: 'has-callers', site: { file: definite.file, line: definite.line } };
|
|
186
|
+
}
|
|
187
|
+
const duplicateName = (index.symbols.get(name) || []).some(def => !sameDeclaration(def, declaration));
|
|
188
|
+
if (call || duplicateName) {
|
|
189
|
+
const callers = index.findCallers(name, { targetDefinitions: [declaration], collectAccount: true,
|
|
190
|
+
includeMethods: true, maxResults: Infinity });
|
|
191
|
+
const proven = callers.filter(c => c.tier !== 'unverified' &&
|
|
192
|
+
!withinDeclaration(declaration, indexed, c.file || path.join(index.root, c.relativePath), c.line))
|
|
193
|
+
.map(c => ({ file: c.relativePath || relativeOf(index, c.file), line: c.line })).sort(compareSites);
|
|
194
|
+
if (proven.length) return { reason: 'has-callers', site: proven[0] };
|
|
195
|
+
// A callee index is a name inventory, not selected-target evidence.
|
|
196
|
+
// Its conservative audit veto survives without a misleading site.
|
|
197
|
+
}
|
|
181
198
|
let found = null;
|
|
182
199
|
deadcode(index, {
|
|
183
200
|
names: new Set([name]),
|
|
@@ -196,13 +213,16 @@ function useOutsideDeclaration(index, declaration, indexed, deadcode, options) {
|
|
|
196
213
|
} else if (reason === 'has-usages') {
|
|
197
214
|
const sites = ((detail && detail.usages) || [])
|
|
198
215
|
.filter(usage => !withinDeclaration(declaration, indexed, usage.file, usage.line))
|
|
216
|
+
.filter(usage => !(index.getCachedCalls(usage.file) || []).some(candidate =>
|
|
217
|
+
candidate.line === usage.line && !candidate.isFunctionReference &&
|
|
218
|
+
(candidate.name === name || candidate.resolvedName === name)))
|
|
199
219
|
.map(usage => ({ file: relativeOf(index, usage.file), line: usage.line }))
|
|
200
220
|
.sort(compareSites);
|
|
201
|
-
if (sites.length > 0) found = { reason, site: sites[0] };
|
|
221
|
+
if (sites.length > 0) found = duplicateName ? { reason: 'unresolved-use' } : { reason, site: sites[0] };
|
|
202
222
|
}
|
|
203
223
|
},
|
|
204
224
|
});
|
|
205
|
-
return found;
|
|
225
|
+
return found || (call ? { reason: 'unresolved-use' } : null);
|
|
206
226
|
}
|
|
207
227
|
|
|
208
228
|
/**
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A data-only TOML reader for the PEP 621 entrypoint subtree. Other values
|
|
4
|
+
// remain opaque; malformed or nonliteral entrypoint values return unknown.
|
|
5
|
+
function entrypointToml(source) {
|
|
6
|
+
const tokens = [];
|
|
7
|
+
let line = 1;
|
|
8
|
+
let offset = 0;
|
|
9
|
+
while (offset < source.length) {
|
|
10
|
+
const ch = source[offset];
|
|
11
|
+
if (ch === '\n') { tokens.push({ kind: 'newline', line }); offset++; line++; continue; }
|
|
12
|
+
if (/\s/.test(ch)) { offset++; continue; }
|
|
13
|
+
if (ch === '#') { while (offset < source.length && source[offset] !== '\n') offset++; continue; }
|
|
14
|
+
if ('[]=.,{}'.includes(ch)) { tokens.push({ kind: ch, line }); offset++; continue; }
|
|
15
|
+
if (ch === '"' || ch === "'") {
|
|
16
|
+
const startLine = line;
|
|
17
|
+
const triple = source.slice(offset, offset + 3) === ch.repeat(3);
|
|
18
|
+
const delimiter = triple ? ch.repeat(3) : ch;
|
|
19
|
+
offset += delimiter.length;
|
|
20
|
+
let value = '';
|
|
21
|
+
let valid = true;
|
|
22
|
+
let closed = false;
|
|
23
|
+
while (offset < source.length) {
|
|
24
|
+
if (source.slice(offset, offset + delimiter.length) === delimiter) {
|
|
25
|
+
offset += delimiter.length; closed = true; break;
|
|
26
|
+
}
|
|
27
|
+
const current = source[offset++];
|
|
28
|
+
if (current === '\n') line++;
|
|
29
|
+
if (current !== '\\' || ch === "'") { value += current; continue; }
|
|
30
|
+
const escaped = source[offset++];
|
|
31
|
+
const simple = { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', '"': '"', '\\': '\\' };
|
|
32
|
+
if (Object.hasOwn(simple, escaped)) value += simple[escaped];
|
|
33
|
+
else if (escaped === 'u' || escaped === 'U') {
|
|
34
|
+
const width = escaped === 'u' ? 4 : 8;
|
|
35
|
+
const digits = source.slice(offset, offset + width);
|
|
36
|
+
if (!/^[0-9a-fA-F]+$/.test(digits) || digits.length !== width || parseInt(digits, 16) > 0x10ffff) valid = false;
|
|
37
|
+
else value += String.fromCodePoint(parseInt(digits, 16));
|
|
38
|
+
offset += width;
|
|
39
|
+
} else if (triple && /\s/.test(escaped || '')) {
|
|
40
|
+
if (escaped === '\n') line++;
|
|
41
|
+
while (offset < source.length && /\s/.test(source[offset])) {
|
|
42
|
+
if (source[offset++] === '\n') line++;
|
|
43
|
+
}
|
|
44
|
+
} else valid = false;
|
|
45
|
+
}
|
|
46
|
+
tokens.push({ kind: 'string', value: valid && closed ? value.replace(/^\n/, '') : null, line: startLine });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const start = offset;
|
|
50
|
+
while (offset < source.length && !/[\s[\]=.,{}#]/.test(source[offset])) offset++;
|
|
51
|
+
if (offset === start) offset++;
|
|
52
|
+
tokens.push({ kind: 'bare', value: source.slice(start, offset), line });
|
|
53
|
+
}
|
|
54
|
+
const rows = [];
|
|
55
|
+
let at = 0;
|
|
56
|
+
let section = [];
|
|
57
|
+
const keys = () => {
|
|
58
|
+
const parts = [];
|
|
59
|
+
while (tokens[at] && ['bare', 'string'].includes(tokens[at].kind)) {
|
|
60
|
+
parts.push(tokens[at++].value);
|
|
61
|
+
if (tokens[at]?.kind !== '.') break;
|
|
62
|
+
at++;
|
|
63
|
+
}
|
|
64
|
+
return parts;
|
|
65
|
+
};
|
|
66
|
+
const entryPath = parts => parts[0] === 'project' &&
|
|
67
|
+
(['scripts', 'gui-scripts'].includes(parts[1]) || parts[1] === 'entry-points');
|
|
68
|
+
const readValue = (parts, depth = 0) => {
|
|
69
|
+
const token = tokens[at++];
|
|
70
|
+
if (!token || depth > 32) { if (entryPath(parts)) rows.push({ unknown: true, line: token?.line }); return; }
|
|
71
|
+
if (token.kind === '{') {
|
|
72
|
+
while (tokens[at] && tokens[at].kind !== '}') {
|
|
73
|
+
if (['newline', ','].includes(tokens[at].kind)) { at++; continue; }
|
|
74
|
+
const nested = keys();
|
|
75
|
+
if (!nested.length || tokens[at++]?.kind !== '=') { if (entryPath(parts)) rows.push({ unknown: true, line: token.line }); break; }
|
|
76
|
+
readValue([...parts, ...nested], depth + 1);
|
|
77
|
+
}
|
|
78
|
+
if (tokens[at]?.kind === '}') at++;
|
|
79
|
+
} else if (token.kind === '[') {
|
|
80
|
+
let balance = 1;
|
|
81
|
+
while (tokens[at] && balance) {
|
|
82
|
+
const item = tokens[at++];
|
|
83
|
+
if (item.kind === '[') balance++;
|
|
84
|
+
if (item.kind === ']') balance--;
|
|
85
|
+
if (parts.join('.') === 'project.dynamic' && item.kind === 'string' &&
|
|
86
|
+
['scripts', 'gui-scripts', 'entry-points'].includes(item.value)) rows.push({ unknown: true, line: item.line });
|
|
87
|
+
}
|
|
88
|
+
if (entryPath(parts)) rows.push({ unknown: true, line: token.line });
|
|
89
|
+
} else if (entryPath(parts)) {
|
|
90
|
+
rows.push(token.kind === 'string' && token.value != null
|
|
91
|
+
? { value: token.value, line: token.line } : { unknown: true, line: token.line });
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
while (at < tokens.length) {
|
|
95
|
+
if (tokens[at].kind === 'newline') { at++; continue; }
|
|
96
|
+
if (tokens[at].kind === '[') {
|
|
97
|
+
at++;
|
|
98
|
+
section = keys();
|
|
99
|
+
while (tokens[at] && ![']', 'newline'].includes(tokens[at].kind)) at++;
|
|
100
|
+
if (tokens[at]?.kind === ']') at++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const parts = keys();
|
|
104
|
+
if (parts.length && tokens[at]?.kind === '=') { at++; readValue([...section, ...parts]); }
|
|
105
|
+
else while (tokens[at] && tokens[at].kind !== 'newline') at++;
|
|
106
|
+
}
|
|
107
|
+
return rows;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { entrypointToml };
|
package/core/index-ir.js
CHANGED
|
@@ -185,7 +185,7 @@ const OPTIONAL_SYMBOL_FIELDS = Object.freeze([
|
|
|
185
185
|
'isGenerator', 'generics', 'ownerGenerics', 'genericBounds', 'typeParameterConstraints', 'extends', 'implements', 'indent', 'isNested',
|
|
186
186
|
'enclosingType', 'isMethod', 'receiver', 'memberType', 'fieldType', 'embedded',
|
|
187
187
|
'aliasOf', 'aliasQualifier', 'aliasMembers', 'aliasTypeText', 'aliasTypeParameters', 'aliasTypeDefaults', 'derefTarget', 'decorators', 'decoratorsWithArgs',
|
|
188
|
-
'annotationsWithArgs', 'attributesWithArgs', 'nameLine', 'traitImpl',
|
|
188
|
+
'annotationsWithArgs', 'attributesWithArgs', 'registrationUnknown', 'runtimeOwnerPath', 'nameLine', 'traitImpl',
|
|
189
189
|
'traitName', 'isSignature', 'memberAssigned', 'assignedReceiver', 'assignedObject', 'selfNamed', 'bodyScopedName',
|
|
190
190
|
'registryMember', 'registryContainer', 'registryContainerType', 'objectLiteralLine', 'namespace', 'isConstructor',
|
|
191
191
|
'isExtensionMethod', 'extensionReceiver', 'explicitInterface', 'primaryParams',
|
|
@@ -230,7 +230,7 @@ function materializeSymbol(fileEntry, item) {
|
|
|
230
230
|
// Most false feature flags are omitted for compactness, but
|
|
231
231
|
// functionLike=false is the semantic distinction between an
|
|
232
232
|
// object-like macro and a callable macro (UCN5-170).
|
|
233
|
-
if (item[field] === false && field !== 'functionLike') continue;
|
|
233
|
+
if (item[field] === false && field !== 'functionLike' && field !== 'registrationUnknown') continue;
|
|
234
234
|
symbol[field] = item[field];
|
|
235
235
|
}
|
|
236
236
|
return symbol;
|
package/core/ir.js
CHANGED
|
@@ -66,7 +66,7 @@ function normalizeSymbol(symbol, family, language, kind, owner = null) {
|
|
|
66
66
|
'isAsync', 'isGenerator', 'generics', 'ownerGenerics', 'genericBounds', 'typeParameterConstraints', 'extends', 'implements', 'indent',
|
|
67
67
|
'isNested', 'enclosingType', 'isMethod', 'memberType', 'fieldType', 'embedded',
|
|
68
68
|
'aliasOf', 'aliasQualifier', 'aliasMembers', 'aliasTypeText', 'aliasTypeParameters', 'aliasTypeDefaults', 'derefTarget', 'decorators', 'decoratorsWithArgs',
|
|
69
|
-
'annotationsWithArgs', 'attributesWithArgs', 'nameLine', 'traitImpl',
|
|
69
|
+
'annotationsWithArgs', 'attributesWithArgs', 'registrationUnknown', 'runtimeOwnerPath', 'nameLine', 'traitImpl',
|
|
70
70
|
'traitName', 'isSignature', 'memberAssigned', 'assignedReceiver', 'assignedObject', 'selfNamed', 'bodyScopedName',
|
|
71
71
|
'registryMember', 'registryContainer', 'registryContainerType', 'objectLiteralLine', 'isConstructor',
|
|
72
72
|
'isExtensionMethod', 'extensionReceiver', 'explicitInterface', 'primaryParams',
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Validate changed calls against the project declarations they can reach. */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { getCachedCalls } = require('./callers');
|
|
5
|
+
const { verifyCallSites } = require('./verify');
|
|
6
|
+
const { langTraits } = require('../languages');
|
|
7
|
+
const { positivelyFits } = require('./call-fit');
|
|
8
|
+
|
|
9
|
+
/** One reader and result cache for a check, including all changed symbols. */
|
|
10
|
+
function outgoingChecker(index, judge, addedLinesByFile) {
|
|
11
|
+
const files = new Map();
|
|
12
|
+
const checked = new Map();
|
|
13
|
+
const read = file => {
|
|
14
|
+
if (!files.has(file)) files.set(file, {
|
|
15
|
+
calls: getCachedCalls(index, file) || [], lines: index._getFileLines(file),
|
|
16
|
+
});
|
|
17
|
+
return files.get(file);
|
|
18
|
+
};
|
|
19
|
+
return (definition, change, added) => {
|
|
20
|
+
const mismatches = [];
|
|
21
|
+
const unsettled = [];
|
|
22
|
+
if (!definition) return { mismatches, unsettled };
|
|
23
|
+
const data = read(definition.file);
|
|
24
|
+
const changedRanges = !added ? change?.changedCalls : null;
|
|
25
|
+
const lines = addedLinesByFile.get(definition.relativePath) || new Set();
|
|
26
|
+
const records = data.calls.filter(call => !call.isFunctionReference &&
|
|
27
|
+
call.line >= definition.startLine && call.line <= definition.endLine &&
|
|
28
|
+
(changedRanges ? changedRanges.some(range =>
|
|
29
|
+
range.start <= (call.callStart ?? call.callSite?.start) &&
|
|
30
|
+
range.end >= (call.callEnd ?? call.callSite?.end ?? call.callStart ?? call.callSite?.start))
|
|
31
|
+
: added || lines.has(call.line)));
|
|
32
|
+
if (records.length === 0) return { mismatches, unsettled };
|
|
33
|
+
const byStart = new Map(records.map(call => [call.callStart ?? call.callSite?.start, call]));
|
|
34
|
+
const callees = index.findCallees(definition, {
|
|
35
|
+
collectAccount: true, includeMethods: true, siteStarts: new Set(byStart.keys()),
|
|
36
|
+
});
|
|
37
|
+
const relative = definition.relativePath;
|
|
38
|
+
const positiveFamilies = new Set();
|
|
39
|
+
const possibleFamilies = new Set();
|
|
40
|
+
const issues = [];
|
|
41
|
+
const familyOf = (callee, site) => {
|
|
42
|
+
// The class's public constructor overloads are the call
|
|
43
|
+
// interface; an initializer edge also projects their body.
|
|
44
|
+
const owner = callee.initializerOf || (callee.type === 'class' ? callee.name : null);
|
|
45
|
+
return JSON.stringify([site.start, callee.file, owner || callee.name,
|
|
46
|
+
owner ? null : callee.className, callee.namespace, callee.enclosingType]);
|
|
47
|
+
};
|
|
48
|
+
const uncertain = (site, name, reason) => unsettled.push({ file: relative, line: site.line, column: site.column, name, reason });
|
|
49
|
+
for (const callee of callees) {
|
|
50
|
+
if (callee.functionReference) continue;
|
|
51
|
+
for (const site of callee.siteProvenance || []) {
|
|
52
|
+
const record = byStart.get(site.start);
|
|
53
|
+
if (!record) continue;
|
|
54
|
+
const family = familyOf(callee, site);
|
|
55
|
+
const key = JSON.stringify([definition.file, site.start, callee.file, callee.startLine,
|
|
56
|
+
callee.sourceSpan?.start, callee.sourceSpan?.end, callee.name, callee.className,
|
|
57
|
+
callee.type, callee.paramsStructured]);
|
|
58
|
+
let result = checked.get(key);
|
|
59
|
+
if (!result) {
|
|
60
|
+
result = verifyCallSites(index, callee, [{
|
|
61
|
+
...record, file: definition.file, relativePath: relative,
|
|
62
|
+
content: data.lines[site.line - 1] || '', calledAs: record.name,
|
|
63
|
+
provenance: site.provenance,
|
|
64
|
+
callerFile: definition.file, callerStartLine: definition.startLine,
|
|
65
|
+
}]);
|
|
66
|
+
checked.set(key, result);
|
|
67
|
+
}
|
|
68
|
+
for (const mismatch of result.mismatchDetails || []) {
|
|
69
|
+
const counts = String(mismatch.expected || '').match(/\d+/g);
|
|
70
|
+
const suppressed = judge.suppressed(mismatch, {
|
|
71
|
+
kind: 'arity', name: callee.name,
|
|
72
|
+
most: counts ? Number(counts[counts.length - 1]) : undefined,
|
|
73
|
+
span: mismatch.span, problem: mismatch.problem, indirect: mismatch.indirect,
|
|
74
|
+
});
|
|
75
|
+
// JavaScript deliberately permits omitted and extra
|
|
76
|
+
// arguments. A declared count is not runtime proof.
|
|
77
|
+
const flexible = langTraits(index.files.get(definition.file)?.language)?.lenientCallArity === true;
|
|
78
|
+
if ((callee.decorators || []).length) uncertain(site, callee.name, 'decorated-call-interface');
|
|
79
|
+
else if (flexible) uncertain(site, callee.name, 'call-arity-not-enforced');
|
|
80
|
+
else issues.push({ family, site, callee, mismatch: {
|
|
81
|
+
...mismatch, column: site.column, target: callee.name, ...(suppressed && { unchecked: true }),
|
|
82
|
+
} });
|
|
83
|
+
}
|
|
84
|
+
if (result.valid > 0 || result.uncertain > 0) possibleFamilies.add(family);
|
|
85
|
+
for (const row of result.uncertainDetails || []) issues.push({ family, site, callee, reason: row.reason });
|
|
86
|
+
if (result.valid > 0) {
|
|
87
|
+
const preserved = changedRanges?.some(range => range.stableInvocation &&
|
|
88
|
+
range.start <= site.start && range.end >= (record.callEnd ?? record.callSite?.end ?? site.start));
|
|
89
|
+
if (positivelyFits(index, callee, definition, record, result, preserved)) positiveFamilies.add(family);
|
|
90
|
+
else issues.push({ family, site, callee, reason: 'argument-compatibility-unverified' });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Overload declarations describe one call interface. A positively
|
|
95
|
+
// fitting alternative settles it; a merely possible alternative
|
|
96
|
+
// prevents a different signature's arity from claiming a break.
|
|
97
|
+
for (const issue of issues) {
|
|
98
|
+
if (positiveFamilies.has(issue.family)) continue;
|
|
99
|
+
if (issue.mismatch && !possibleFamilies.has(issue.family)) mismatches.push(issue.mismatch);
|
|
100
|
+
else uncertain(issue.site, issue.callee.name, issue.reason || 'overload-compatibility-unverified');
|
|
101
|
+
}
|
|
102
|
+
for (const entry of [...(callees.unverifiedCallees || []), ...(callees.initializerReviews || [])]) {
|
|
103
|
+
// External packages are outside this fit check. A candidate
|
|
104
|
+
// project declaration, however, cannot silently become READY.
|
|
105
|
+
if (!(entry.ownerCount > 0 || entry.initializerOf || index.symbols.has(entry.name))) continue;
|
|
106
|
+
for (const site of entry.siteProvenance || []) {
|
|
107
|
+
if (byStart.has(site.start)) uncertain(site, entry.name, entry.reason || 'outgoing-target-unresolved');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const unique = rows => [...new Map(rows.map(row => [JSON.stringify(row), row])).values()]
|
|
111
|
+
.sort((a, b) => a.line - b.line || (a.column || 0) - (b.column || 0) || ((a.name || a.target || '') < (b.name || b.target || '') ? -1 : (a.name || a.target || '') > (b.name || b.target || '') ? 1 : 0));
|
|
112
|
+
return { mismatches: unique(mismatches), unsettled: unique(unsettled) };
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { outgoingChecker };
|
package/core/output/check.js
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
const SECTIONS = [
|
|
12
12
|
['blocked', 'BLOCKED', 'a break was observed'],
|
|
13
|
-
['review', 'REVIEW', '
|
|
14
|
-
['ready', 'READY', '
|
|
13
|
+
['review', 'REVIEW', 'change exposure or call requirements not settled'],
|
|
14
|
+
['ready', 'READY', 'no unresolved exposure in the modeled checks'],
|
|
15
15
|
];
|
|
16
16
|
// Occurrences of a name are context, not a worklist: a head is enough.
|
|
17
17
|
const NAME_SITE_LIMIT = 10;
|
|
@@ -50,6 +50,9 @@ function itemLines(it) {
|
|
|
50
50
|
const expression = m.expression ? ` ${String(m.expression).trim()}` : '';
|
|
51
51
|
lines.push(` ↳ ${where}${expression} ${problem}${m.unchecked ? ' (not checked by the toolchain)' : ''}`);
|
|
52
52
|
}
|
|
53
|
+
for (const site of it.outgoingReview || []) {
|
|
54
|
+
lines.push(` ↳ ${site.file}:${site.line}${site.column != null ? `:${site.column + 1}` : ''} ${site.name}: ${site.reason}`);
|
|
55
|
+
}
|
|
53
56
|
const sites = it.sites || [];
|
|
54
57
|
const definite = it.verdict === 'blocked';
|
|
55
58
|
const shown = definite ? sites : sites.slice(0, NAME_SITE_LIMIT);
|
package/core/output/reporting.js
CHANGED
|
@@ -218,6 +218,8 @@ function formatDeadcode(results, options = {}) {
|
|
|
218
218
|
!results.excludedDynamicDispatch && !results.excludedRequiredSlot &&
|
|
219
219
|
results.projectKind !== 'application' && !results.computedDispatch?.count &&
|
|
220
220
|
!results.reflection?.withheld && !results.reflection?.dynamicCount && !results.reflection?.literalCount &&
|
|
221
|
+
!results.reflection?.withheldByTable?.length && !results.macroPaste?.withheld &&
|
|
222
|
+
!results.macroPaste?.blind?.count && !results.macroRulesBlind?.count &&
|
|
221
223
|
results.coverage?.complete !== false) {
|
|
222
224
|
return 'No dead code found.';
|
|
223
225
|
}
|
|
@@ -233,6 +235,8 @@ function formatDeadcode(results, options = {}) {
|
|
|
233
235
|
} else {
|
|
234
236
|
lines.push(`Dead code: ${results.length} unused symbol(s)\n`);
|
|
235
237
|
}
|
|
238
|
+
} else {
|
|
239
|
+
lines.push('Dead code: 0 unused symbol(s)\n');
|
|
236
240
|
}
|
|
237
241
|
|
|
238
242
|
let currentFile = null;
|
package/core/output/search.js
CHANGED
|
@@ -438,6 +438,9 @@ function formatTests(tests, name) {
|
|
|
438
438
|
}
|
|
439
439
|
if (testFile.run) lines.push(` run: ${selectorText(testFile.run)}`);
|
|
440
440
|
else if (testFile.role === 'tests' && testFile.runWithheld) lines.push(` no command: ${testFile.runWithheld}`);
|
|
441
|
+
for (const unit of testFile.units || []) {
|
|
442
|
+
if (unit.selector) lines.push(` run test: ${selectorText(unit.selector)}`);
|
|
443
|
+
}
|
|
441
444
|
lines.push('');
|
|
442
445
|
}
|
|
443
446
|
}
|
package/core/output/tracing.js
CHANGED
|
@@ -619,7 +619,7 @@ function formatAffectedTests(result, options = {}) {
|
|
|
619
619
|
}
|
|
620
620
|
const pat = result.possiblyAffectedTests || [];
|
|
621
621
|
if (pat.length > 0) {
|
|
622
|
-
lines.push(`
|
|
622
|
+
lines.push(` Test files with possible links (${pat.length}):`);
|
|
623
623
|
const MAX_POSSIBLE = options.all ? Infinity : 10;
|
|
624
624
|
for (const tf of pat.slice(0, MAX_POSSIBLE)) {
|
|
625
625
|
lines.push(` ${tf.file} (links: ${linkList(tf.linkedFunctions, options)})`);
|