ucn 4.2.3 → 5.0.2
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 +89 -77
- package/.claude/skills/ucn/references/commands.md +62 -68
- package/.claude/skills/ucn/references/trust-contract.md +31 -6
- package/README.md +438 -305
- package/assets/demo.svg +31 -0
- package/cli/index.js +430 -1385
- package/core/account.js +144 -34
- package/core/analysis.js +182 -72
- package/core/ast-analysis.js +279 -0
- package/core/bridge.js +205 -24
- package/core/brief.js +27 -58
- package/core/build-worker.js +21 -140
- package/core/cache.js +513 -11
- package/core/callers.js +4920 -456
- package/core/check.js +13 -4
- package/core/command-contracts.js +402 -0
- package/core/compilation-database.js +276 -0
- package/core/confidence.js +4 -1
- package/core/deadcode.js +397 -19
- package/core/discovery.js +359 -46
- package/core/entrypoints.js +195 -41
- package/core/execute.js +887 -81
- package/core/graph-build.js +162 -7
- package/core/graph.js +53 -77
- package/core/imports.js +65 -6
- package/core/index-ir.js +138 -0
- package/core/ir.js +195 -0
- package/core/output/analysis.js +212 -22
- package/core/output/brief.js +23 -0
- package/core/output/check.js +4 -0
- package/core/output/doctor.js +37 -6
- package/core/output/endpoints.js +5 -2
- package/core/output/extraction.js +24 -12
- package/core/output/find.js +141 -36
- package/core/output/graph.js +11 -5
- package/core/output/public.js +462 -0
- package/core/output/refactoring.js +42 -10
- package/core/output/reporting.js +97 -20
- package/core/output/search.js +24 -16
- package/core/output/shared.js +22 -1
- package/core/output/tracing.js +30 -15
- package/core/output-budget.js +295 -0
- package/core/output.js +1 -0
- package/core/parallel-build.js +44 -11
- package/core/parser.js +3 -3
- package/core/project.js +384 -187
- package/core/public-command.js +47 -0
- package/core/registry.js +247 -117
- package/core/reporting.js +312 -290
- package/core/search.js +317 -185
- package/core/semantic-provider.js +110 -0
- package/core/stacktrace.js +25 -0
- package/core/tracing.js +101 -51
- package/core/trust-matrix.js +19 -40
- package/core/verify.js +534 -37
- package/languages/adapter.js +218 -0
- package/languages/c-family.js +2791 -0
- package/languages/c.js +3 -0
- package/languages/cpp.js +3 -0
- package/languages/csharp.js +1402 -0
- package/languages/go.js +60 -21
- package/languages/html.js +2 -2
- package/languages/index.js +85 -7
- package/languages/java.js +396 -13
- package/languages/javascript.js +199 -19
- package/languages/python.js +964 -22
- package/languages/rust.js +1317 -152
- package/languages/utils.js +40 -3
- package/mcp/server.js +254 -636
- package/package.json +39 -22
- package/eslint.config.js +0 -43
- package/jsconfig.json +0 -10
package/core/deadcode.js
CHANGED
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
* as the first argument instead of using `this`.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
const { detectLanguage, getParser,
|
|
9
|
-
const
|
|
8
|
+
const { detectLanguage, getParser, getLanguageAdapter, safeParse, langTraits } = require('../languages');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { dirname: pathDirname } = path;
|
|
10
12
|
const { isTestFile } = require('./discovery');
|
|
11
13
|
const { isFrameworkEntrypoint } = require('./entrypoints');
|
|
12
14
|
const { splitParentList } = require('./graph-build');
|
|
13
|
-
const { isOverrideMarked, codeUnitCompare, lineInRanges, maskBlockComments } = require('./shared');
|
|
15
|
+
const { isOverrideMarked, codeUnitCompare, lineInRanges, maskBlockComments, escapeRegExp } = require('./shared');
|
|
16
|
+
const { projectComputedDispatch } = require('./ast-analysis');
|
|
14
17
|
|
|
15
18
|
const _CLASS_KINDS = ['class', 'struct', 'interface', 'trait', 'record'];
|
|
16
19
|
|
|
@@ -20,6 +23,45 @@ const _CLASS_KINDS = ['class', 'struct', 'interface', 'trait', 'record'];
|
|
|
20
23
|
// aliases and macros stay out (deferred — each is its own claim family).
|
|
21
24
|
const CLASS_AUDIT_KINDS = ['class', 'struct', 'interface', 'trait', 'record', 'enum', 'namespace'];
|
|
22
25
|
|
|
26
|
+
// These Python decorators change descriptor/call syntax, but do not register
|
|
27
|
+
// the callable with an external runtime. They therefore remain eligible for a
|
|
28
|
+
// name/usage-based dead-code proof. Every other unknown decorator/annotation
|
|
29
|
+
// stays conservative: it may be the only evidence of framework registration.
|
|
30
|
+
const _NON_REGISTERING_DECORATORS = new Set([
|
|
31
|
+
'property',
|
|
32
|
+
'classmethod',
|
|
33
|
+
'staticmethod',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function _decoratorName(value) {
|
|
37
|
+
const raw = typeof value === 'string'
|
|
38
|
+
? value
|
|
39
|
+
: value?.name || value?.decorator || value?.annotation || value?.attribute;
|
|
40
|
+
if (!raw) return null;
|
|
41
|
+
return String(raw).trim().replace(/^@/, '').split('(')[0].trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hasUnknownRegistrationDecorator(symbol) {
|
|
45
|
+
const values = [
|
|
46
|
+
symbol.decorators,
|
|
47
|
+
symbol.decoratorsWithArgs,
|
|
48
|
+
symbol.annotationsWithArgs,
|
|
49
|
+
symbol.attributesWithArgs,
|
|
50
|
+
].flatMap(items => Array.isArray(items) ? items : []);
|
|
51
|
+
|
|
52
|
+
return values.some(value => {
|
|
53
|
+
const qualified = _decoratorName(value);
|
|
54
|
+
if (!qualified) return false;
|
|
55
|
+
const bare = qualified.split(/\.|::/).pop();
|
|
56
|
+
if (_NON_REGISTERING_DECORATORS.has(bare)) return false;
|
|
57
|
+
// `@name.getter`, `@name.setter`, and `@name.deleter` install an
|
|
58
|
+
// accessor on an already local property; they are not external
|
|
59
|
+
// framework registration boundaries.
|
|
60
|
+
if (['getter', 'setter', 'deleter'].includes(bare)) return false;
|
|
61
|
+
return true;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
23
65
|
/** Strip a base-type expression to its bare name: `Mapping[str, int]`→Mapping, `java.util.List<Foo>`→List, `a::b::C`→C. */
|
|
24
66
|
function _bareBaseName(raw) {
|
|
25
67
|
return String(raw).replace(/[<[(].*$/s, '').split('.').pop().split('::').pop().trim();
|
|
@@ -46,11 +88,17 @@ const _UNIVERSAL_ROOTS = new Set(['object', 'Object']);
|
|
|
46
88
|
const _PY_NON_DISPATCHING_BASES = new Set(['Generic']);
|
|
47
89
|
|
|
48
90
|
/** True when a base name resolves to NO in-project class/struct/interface/trait/record (an out-of-tree type). */
|
|
49
|
-
function _baseIsExternal(index, bare, lang) {
|
|
91
|
+
function _baseIsExternal(index, bare, lang, fromDef) {
|
|
50
92
|
if (!bare || _UNIVERSAL_ROOTS.has(bare)) return false;
|
|
51
93
|
if (lang === 'python' && _PY_NON_DISPATCHING_BASES.has(bare)) return false;
|
|
52
94
|
const defs = index.symbols.get(bare);
|
|
53
|
-
return !(defs && defs.some(d => _CLASS_KINDS.includes(d.type)
|
|
95
|
+
return !(defs && defs.some(d => _CLASS_KINDS.includes(d.type) &&
|
|
96
|
+
// `class EnvironBuilder(werkzeug.test.EnvironBuilder)` must not
|
|
97
|
+
// resolve its qualified OUT-OF-TREE base back to itself merely
|
|
98
|
+
// because both have the same bare name. A definition cannot be its
|
|
99
|
+
// own base; only a distinct project definition is resolution evidence.
|
|
100
|
+
!(fromDef && d.file === fromDef.file && d.startLine === fromDef.startLine &&
|
|
101
|
+
d.endLine === fromDef.endLine && d.type === fromDef.type)));
|
|
54
102
|
}
|
|
55
103
|
|
|
56
104
|
// Bounded heritage-closure depth (fix #270) — matches the engine's other
|
|
@@ -99,7 +147,7 @@ function _heritageReachesExternalBase(index, classDef, lang, followImplements) {
|
|
|
99
147
|
const bare = _bareBaseName(raw);
|
|
100
148
|
if (!bare || seen.has(bare)) continue;
|
|
101
149
|
seen.add(bare);
|
|
102
|
-
if (_baseIsExternal(index, bare, lang)) return true;
|
|
150
|
+
if (_baseIsExternal(index, bare, lang, def)) return true;
|
|
103
151
|
for (const pd of index.symbols.get(bare) || []) {
|
|
104
152
|
if (_CLASS_KINDS.includes(pd.type)) next.push(pd);
|
|
105
153
|
}
|
|
@@ -173,6 +221,29 @@ function overridesOutOfTreeBase(index, symbol) {
|
|
|
173
221
|
return false;
|
|
174
222
|
}
|
|
175
223
|
|
|
224
|
+
// Go satisfies interfaces implicitly, so no `implements` edge exists for
|
|
225
|
+
// compiler/stdlib callbacks. A zero textual usage for one of these exact
|
|
226
|
+
// method shapes is not evidence of deadness (sort.Interface, http.Handler,
|
|
227
|
+
// io.Reader, encoding marshalers, database/sql contracts, errors helpers).
|
|
228
|
+
const GO_PROTOCOL_METHODS = new Map([
|
|
229
|
+
['Len', 0], ['Less', 2], ['Swap', 2], ['ServeHTTP', 2],
|
|
230
|
+
['Error', 0], ['String', 0], ['Read', 1], ['Write', 1], ['Close', 0],
|
|
231
|
+
['Seek', 2], ['ReadAt', 2], ['WriteAt', 2], ['ReadByte', 0],
|
|
232
|
+
['WriteByte', 1], ['ReadRune', 0], ['UnreadByte', 0], ['UnreadRune', 0],
|
|
233
|
+
['MarshalJSON', 0], ['UnmarshalJSON', 1], ['MarshalText', 0],
|
|
234
|
+
['UnmarshalText', 1], ['MarshalBinary', 0], ['UnmarshalBinary', 1],
|
|
235
|
+
['Scan', 1], ['Value', 0], ['LogValue', 0], ['Unwrap', 0],
|
|
236
|
+
['Is', 1], ['As', 1],
|
|
237
|
+
]);
|
|
238
|
+
|
|
239
|
+
function goImplicitExternalContract(symbol) {
|
|
240
|
+
if (!symbol.className || !GO_PROTOCOL_METHODS.has(symbol.name)) return false;
|
|
241
|
+
const arity = Array.isArray(symbol.paramsStructured)
|
|
242
|
+
? symbol.paramsStructured.length
|
|
243
|
+
: null;
|
|
244
|
+
return arity == null || arity === GO_PROTOCOL_METHODS.get(symbol.name);
|
|
245
|
+
}
|
|
246
|
+
|
|
176
247
|
// Symbol types whose definition NAME line provably cannot reference a
|
|
177
248
|
// same-name VALUE — used to stop same-name defs keeping each other alive
|
|
178
249
|
// (fix #243). 'state' and 'field' stay OUT: `helper = other.helper` and
|
|
@@ -213,14 +284,126 @@ function nameOnlySelfRecursive(index, name) {
|
|
|
213
284
|
for (const call of calls) {
|
|
214
285
|
if (call.name !== name && call.resolvedName !== name &&
|
|
215
286
|
!(call.resolvedNames && call.resolvedNames.includes(name))) continue;
|
|
216
|
-
const
|
|
287
|
+
const containing = defs.filter(d => d.file === f &&
|
|
217
288
|
call.line >= d.startLine && call.line <= d.endLine);
|
|
218
|
-
if (
|
|
289
|
+
if (containing.length !== 1) return false;
|
|
290
|
+
const def = containing[0];
|
|
291
|
+
const enc = call.enclosingFunction;
|
|
292
|
+
|
|
293
|
+
// Same spelling and lexical containment do not make an overload
|
|
294
|
+
// recursive. Nominal compilers bind by signature: render() calling
|
|
295
|
+
// render("x", 2) reaches its sibling overload, so treating that
|
|
296
|
+
// edge as self-recursion creates a false-dead deletion claim.
|
|
297
|
+
const { _callArityCompatible } = require('./callers');
|
|
298
|
+
const language = index.files.get(f)?.language;
|
|
299
|
+
if (!_callArityCompatible(call, [def], language)) return false;
|
|
300
|
+
|
|
301
|
+
// A class constructed only inside its own otherwise-unreachable
|
|
302
|
+
// impl/class body is not made externally live by that cycle.
|
|
303
|
+
if ((def.type === 'impl' || _CLASS_KINDS.includes(def.type) ||
|
|
304
|
+
CLASS_AUDIT_KINDS.includes(def.type)) && call.isConstructor) {
|
|
305
|
+
// Java enum constants invoke their matching constructor by
|
|
306
|
+
// language definition. That is compiler-required wiring, not
|
|
307
|
+
// the constructor recursively calling itself.
|
|
308
|
+
if (call.enumConstant) return false;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (!enc || enc.name !== def.name ||
|
|
312
|
+
(enc.startLine != null && enc.startLine !== def.startLine)) return false;
|
|
313
|
+
|
|
314
|
+
// Name equality and lexical containment are not dispatch
|
|
315
|
+
// identity. `new Inner().emit()` inside Outer.emit(),
|
|
316
|
+
// `super().m()`, and `self.storage.clear()` all used to be called
|
|
317
|
+
// "self recursion". Only receiver-less standalone functions and
|
|
318
|
+
// explicit self/this receivers pinned to the same class qualify.
|
|
319
|
+
if (def.className) {
|
|
320
|
+
const nominalUnqualified = !call.isMethod && !call.receiver &&
|
|
321
|
+
langTraits(language)?.typeSystem === 'nominal';
|
|
322
|
+
if (!nominalUnqualified &&
|
|
323
|
+
!['self', 'this', 'cls', 'Self'].includes(call.receiver)) return false;
|
|
324
|
+
if (enc.className && enc.className !== def.className) return false;
|
|
325
|
+
} else if (call.isMethod || call.receiver || call.receiverType ||
|
|
326
|
+
call.receiverCall || call.receiverRoot) {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
219
329
|
}
|
|
220
330
|
}
|
|
221
331
|
return true;
|
|
222
332
|
}
|
|
223
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Scan source that was discovered but could not be semantically indexed.
|
|
336
|
+
* Dead-code is a deletion-oriented command, so a readable skipped file that
|
|
337
|
+
* mentions a candidate suppresses that candidate; an unreadable or
|
|
338
|
+
* directory-sized gap withdraws all claims because no name-specific proof is
|
|
339
|
+
* possible. The returned object is deliberately serializable and becomes
|
|
340
|
+
* part of both text and JSON output.
|
|
341
|
+
*/
|
|
342
|
+
function scanDeadcodeCoverage(index, names) {
|
|
343
|
+
const matchedNames = new Set();
|
|
344
|
+
const files = new Set();
|
|
345
|
+
const unreadableFiles = new Set();
|
|
346
|
+
const reasons = {};
|
|
347
|
+
let unknownCoverage = false;
|
|
348
|
+
let parseRecoveries = 0;
|
|
349
|
+
|
|
350
|
+
const recordReason = (reason) => {
|
|
351
|
+
const key = reason || 'unknown';
|
|
352
|
+
reasons[key] = (reasons[key] || 0) + 1;
|
|
353
|
+
};
|
|
354
|
+
const scanContent = (content) => {
|
|
355
|
+
const identifiers = new Set(String(content).match(/\b[a-zA-Z_]\w*\b/g) || []);
|
|
356
|
+
for (const name of names) {
|
|
357
|
+
if (/^[a-zA-Z_]\w*$/.test(name)
|
|
358
|
+
? identifiers.has(name)
|
|
359
|
+
: String(content).includes(name)) matchedNames.add(name);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
const scanFile = (absPath, rel, reason) => {
|
|
363
|
+
files.add(rel);
|
|
364
|
+
recordReason(reason);
|
|
365
|
+
try {
|
|
366
|
+
const stat = fs.statSync(absPath);
|
|
367
|
+
if (!stat.isFile()) {
|
|
368
|
+
unknownCoverage = true;
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
scanContent(index._readFile(absPath));
|
|
372
|
+
} catch (_) {
|
|
373
|
+
unreadableFiles.add(rel);
|
|
374
|
+
unknownCoverage = true;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
for (const failedPath of index.failedFiles || []) {
|
|
379
|
+
if (index.files.has(failedPath)) continue;
|
|
380
|
+
scanFile(failedPath, path.relative(index.root, failedPath), 'parse-failure');
|
|
381
|
+
}
|
|
382
|
+
for (const skipped of index.unsupportedFiles || []) {
|
|
383
|
+
const rel = skipped.relativePath;
|
|
384
|
+
scanFile(path.join(index.root, rel), rel, 'unsupported-language');
|
|
385
|
+
}
|
|
386
|
+
for (const issue of index.discoveryIssues || []) {
|
|
387
|
+
const rel = issue.relativePath || '.';
|
|
388
|
+
scanFile(path.join(index.root, rel), rel, issue.reason || 'discovery-gap');
|
|
389
|
+
}
|
|
390
|
+
for (const [, fileEntry] of index.files) {
|
|
391
|
+
if (fileEntry.parseRecovery || fileEntry.parseError) parseRecoveries++;
|
|
392
|
+
}
|
|
393
|
+
if (parseRecoveries > 0) reasons['parse-recovery'] = parseRecoveries;
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
complete: files.size === 0 && parseRecoveries === 0,
|
|
397
|
+
claimsWithdrawn: unknownCoverage,
|
|
398
|
+
suppressedMatched: matchedNames.size,
|
|
399
|
+
files: [...files].sort(codeUnitCompare),
|
|
400
|
+
unreadableFiles: [...unreadableFiles].sort(codeUnitCompare),
|
|
401
|
+
reasons,
|
|
402
|
+
parseRecoveries,
|
|
403
|
+
matchedNames,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
224
407
|
/** Check if a position in a line is inside a string literal (quotes/backticks).
|
|
225
408
|
* Language-aware (fix #259, clap-measured): a Rust apostrophe is a LIFETIME
|
|
226
409
|
* unless it closes as a char literal within a few chars — `impl<E: Send +
|
|
@@ -303,7 +486,7 @@ function buildUsageIndex(index, filterNames) {
|
|
|
303
486
|
// (HTML tree-sitter sees script content as raw_text, not JS identifiers)
|
|
304
487
|
let tree;
|
|
305
488
|
if (language === 'html') {
|
|
306
|
-
const htmlModule =
|
|
489
|
+
const htmlModule = getLanguageAdapter('html');
|
|
307
490
|
const htmlParser = getParser('html');
|
|
308
491
|
const jsParser = getParser('javascript');
|
|
309
492
|
const blocks = htmlModule.extractScriptBlocks(content, htmlParser);
|
|
@@ -402,7 +585,7 @@ function buildUsageIndex(index, filterNames) {
|
|
|
402
585
|
// For HTML files, also extract identifiers from event handler attributes
|
|
403
586
|
// (onclick="foo()" etc. — these are in HTML, not in <script> blocks)
|
|
404
587
|
if (language === 'html') {
|
|
405
|
-
const htmlModule =
|
|
588
|
+
const htmlModule = getLanguageAdapter('html');
|
|
406
589
|
const htmlParser = getParser('html');
|
|
407
590
|
const handlerCalls = htmlModule.extractEventHandlerCalls(content, htmlParser);
|
|
408
591
|
for (const call of handlerCalls) {
|
|
@@ -444,6 +627,21 @@ function symbolIsExported(index, symbol, fileEntry) {
|
|
|
444
627
|
return true;
|
|
445
628
|
}
|
|
446
629
|
const traits = langTraits(fileEntry.language);
|
|
630
|
+
// Python's language-level public surface is every top-level non-underscore
|
|
631
|
+
// name when __all__ is absent. Apply the same rule here as `api`: deadcode
|
|
632
|
+
// is deletion-oriented and cannot see downstream package consumers.
|
|
633
|
+
// When __all__ exists it remains the authoritative allow-list.
|
|
634
|
+
const hasPythonAll = fileEntry.language === 'python' &&
|
|
635
|
+
(fileEntry.exportDetails || []).some(exp => exp.type === '__all__');
|
|
636
|
+
if (fileEntry.language === 'python' && isPythonPackageFile(index, fileEntry) && !hasPythonAll &&
|
|
637
|
+
name && !name.startsWith('_')) {
|
|
638
|
+
if (!symbol.className && !symbol.isMethod) return true;
|
|
639
|
+
if (symbol.className && !mods.includes('private')) {
|
|
640
|
+
const cls = (index.symbols.get(symbol.className) || []).find(candidate =>
|
|
641
|
+
candidate.file === symbol.file && candidate.type === 'class');
|
|
642
|
+
if (cls && !cls.name.startsWith('_')) return true;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
447
645
|
if (traits?.exportVisibility === 'capitalization') {
|
|
448
646
|
return /^[A-Z]/.test(name);
|
|
449
647
|
}
|
|
@@ -463,6 +661,53 @@ function symbolIsExported(index, symbol, fileEntry) {
|
|
|
463
661
|
return false;
|
|
464
662
|
}
|
|
465
663
|
|
|
664
|
+
function isPythonPackageFile(index, fileEntry) {
|
|
665
|
+
if (fileEntry?.language !== 'python' || !fileEntry.relativePath) return false;
|
|
666
|
+
let dir = path.dirname(path.join(index.root, fileEntry.relativePath));
|
|
667
|
+
while (dir === index.root || dir.startsWith(`${index.root}${path.sep}`)) {
|
|
668
|
+
if (fs.existsSync(path.join(dir, '__init__.py'))) return true;
|
|
669
|
+
if (dir === index.root) break;
|
|
670
|
+
const parent = path.dirname(dir);
|
|
671
|
+
if (parent === dir) break;
|
|
672
|
+
dir = parent;
|
|
673
|
+
}
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const JAVA_SERIALIZATION_CALLBACKS = new Set([
|
|
678
|
+
'readResolve', 'writeReplace', 'readObject', 'writeObject',
|
|
679
|
+
'readObjectNoData',
|
|
680
|
+
]);
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Java serialization invokes these callbacks reflectively, so there is no
|
|
684
|
+
* text call edge. Require a Serializable/Externalizable heritage path before
|
|
685
|
+
* suppressing a dead-code claim; name alone is not enough.
|
|
686
|
+
*/
|
|
687
|
+
function isJavaSerializationCallback(index, symbol, fileEntry) {
|
|
688
|
+
if (fileEntry?.language !== 'java' || !symbol.className ||
|
|
689
|
+
!JAVA_SERIALIZATION_CALLBACKS.has(symbol.name)) return false;
|
|
690
|
+
const head = value => String(value || '')
|
|
691
|
+
.replace(/<.*$/, '').split(/[.$]/).pop().trim();
|
|
692
|
+
const seen = new Set();
|
|
693
|
+
const serializable = className => {
|
|
694
|
+
const normalized = head(className);
|
|
695
|
+
if (!normalized || seen.has(normalized)) return false;
|
|
696
|
+
if (normalized === 'Serializable' || normalized === 'Externalizable') return true;
|
|
697
|
+
seen.add(normalized);
|
|
698
|
+
const defs = (index.symbols.get(normalized) || []).filter(candidate =>
|
|
699
|
+
['class', 'interface', 'record', 'enum'].includes(candidate.type));
|
|
700
|
+
return defs.some(definition => {
|
|
701
|
+
const implemented = Array.isArray(definition.implements)
|
|
702
|
+
? definition.implements : String(definition.implements || '').split(',');
|
|
703
|
+
const parents = [definition.extends, ...implemented]
|
|
704
|
+
.filter(Boolean).map(head);
|
|
705
|
+
return parents.some(serializable);
|
|
706
|
+
});
|
|
707
|
+
};
|
|
708
|
+
return serializable(symbol.className);
|
|
709
|
+
}
|
|
710
|
+
|
|
466
711
|
/**
|
|
467
712
|
* Find dead code (unused functions/classes)
|
|
468
713
|
* @param {object} index - ProjectIndex instance
|
|
@@ -476,6 +721,23 @@ function deadcode(index, options = {}) {
|
|
|
476
721
|
let excludedDecorated = 0;
|
|
477
722
|
let excludedExported = 0;
|
|
478
723
|
let excludedExternalContract = 0;
|
|
724
|
+
let excludedRuntimeContract = 0;
|
|
725
|
+
let excludedDynamicDispatch = 0;
|
|
726
|
+
const computedDispatchByFile = projectComputedDispatch(index);
|
|
727
|
+
const computedDispatchReceivers = new Set();
|
|
728
|
+
const computedDispatchInfo = { count: 0, fileCount: 0, files: [] };
|
|
729
|
+
for (const [dispatchFile, sites] of computedDispatchByFile) {
|
|
730
|
+
const fe = index.files.get(dispatchFile);
|
|
731
|
+
if (!fe || !index.matchesFilters(fe.relativePath, options)) continue;
|
|
732
|
+
computedDispatchInfo.count += sites.length;
|
|
733
|
+
for (const site of sites) {
|
|
734
|
+
if (site.receiver) computedDispatchReceivers.add(site.receiver);
|
|
735
|
+
}
|
|
736
|
+
computedDispatchInfo.fileCount++;
|
|
737
|
+
if (computedDispatchInfo.files.length < 10) {
|
|
738
|
+
computedDispatchInfo.files.push(fe.relativePath);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
479
741
|
|
|
480
742
|
// Ensure callee index is built (lazy, reused across operations)
|
|
481
743
|
if (!index.calleeIndex) {
|
|
@@ -548,6 +810,16 @@ function deadcode(index, options = {}) {
|
|
|
548
810
|
potentiallyDeadNames = filteredNames;
|
|
549
811
|
}
|
|
550
812
|
|
|
813
|
+
const coverage = scanDeadcodeCoverage(index, potentiallyDeadNames);
|
|
814
|
+
const coverageSuppressedNames = new Set(coverage.matchedNames);
|
|
815
|
+
if (coverage.claimsWithdrawn) {
|
|
816
|
+
// An unreadable file/directory can contain a use of any candidate.
|
|
817
|
+
// Returning no deletion candidates is the only sound verdict.
|
|
818
|
+
potentiallyDeadNames.clear();
|
|
819
|
+
} else {
|
|
820
|
+
for (const name of coverage.matchedNames) potentiallyDeadNames.delete(name);
|
|
821
|
+
}
|
|
822
|
+
|
|
551
823
|
// Export-site line ranges per file (lazy, --include-exported only): the
|
|
552
824
|
// precise AST regions where a name's appearance is a re-statement of the
|
|
553
825
|
// export, not consumption (fix #247 — the line-prefix check missed
|
|
@@ -645,6 +917,7 @@ function deadcode(index, options = {}) {
|
|
|
645
917
|
// alive). Other languages keep the string skip: their in-string names
|
|
646
918
|
// are reflection (a documented limitation), not a language feature.
|
|
647
919
|
const classKindNames = new Set();
|
|
920
|
+
const memberReferenceNames = new Set();
|
|
648
921
|
for (const name of potentiallyDeadNames) {
|
|
649
922
|
const defs = index.symbols.get(name) || [];
|
|
650
923
|
if (defs.some(s => ACCESSOR_KINDS.has(s.type))) {
|
|
@@ -653,6 +926,9 @@ function deadcode(index, options = {}) {
|
|
|
653
926
|
if (defs.some(s => classAuditSet.has(s.type))) {
|
|
654
927
|
classKindNames.add(name);
|
|
655
928
|
}
|
|
929
|
+
if (defs.some(s => !!s.className || classAuditSet.has(s.type))) {
|
|
930
|
+
memberReferenceNames.add(name);
|
|
931
|
+
}
|
|
656
932
|
}
|
|
657
933
|
|
|
658
934
|
const usageIndex = new Map();
|
|
@@ -713,8 +989,12 @@ function deadcode(index, options = {}) {
|
|
|
713
989
|
// Skip if inside a string literal — EXCEPT class-
|
|
714
990
|
// kind names in Python (fix #253a): `x: "Foo"`
|
|
715
991
|
// forward references are real type references.
|
|
992
|
+
const rustAttributePath = fileEntry.language === 'rust' &&
|
|
993
|
+
/^\s*#\[\s*serde\s*\(/.test(line) &&
|
|
994
|
+
new RegExp(`\\b(?:serialize_with|deserialize_with|default|with)\\s*=\\s*["'][^"']*\\b${escapeRegExp(name)}\\b`).test(line);
|
|
716
995
|
if (isInsideString(line, pos, fileEntry.language) &&
|
|
717
|
-
!(fileEntry.language === 'python' && classKindNames.has(name))
|
|
996
|
+
!(fileEntry.language === 'python' && classKindNames.has(name)) &&
|
|
997
|
+
!rustAttributePath) continue;
|
|
718
998
|
// Property/field access (preceded by '.'), not a
|
|
719
999
|
// call: resolve the RECEIVER (fix #216, express-
|
|
720
1000
|
// measured false-dead — `app.all(route, user.load)`
|
|
@@ -753,9 +1033,18 @@ function deadcode(index, options = {}) {
|
|
|
753
1033
|
// v3 getters whose only references are
|
|
754
1034
|
// chained reads; the empty-receiver drop
|
|
755
1035
|
// fired before the accessor exemption).
|
|
756
|
-
if (!receiver && !isDecoratorRef &&
|
|
1036
|
+
if (!receiver && !isDecoratorRef &&
|
|
1037
|
+
!memberReferenceNames.has(name) &&
|
|
1038
|
+
!accessorNames.has(name)) continue;
|
|
1039
|
+
// Unknown/chained receivers are unresolved,
|
|
1040
|
+
// not negative evidence. Keep the reference
|
|
1041
|
+
// unscoped so method values and nested static
|
|
1042
|
+
// paths cannot produce false deletion claims.
|
|
757
1043
|
if (['this', 'self', 'cls'].includes(receiver)) {
|
|
758
|
-
|
|
1044
|
+
// An inherited member may live in another
|
|
1045
|
+
// file; same-file scoping drops exactly the
|
|
1046
|
+
// base definition this syntax consumes.
|
|
1047
|
+
dottedScope = undefined;
|
|
759
1048
|
} else {
|
|
760
1049
|
const binding = (fileEntry.importBindings || [])
|
|
761
1050
|
.find(b => b.name === receiver);
|
|
@@ -777,7 +1066,9 @@ function deadcode(index, options = {}) {
|
|
|
777
1066
|
}
|
|
778
1067
|
if (resolved) {
|
|
779
1068
|
dottedScope = resolved;
|
|
780
|
-
} else if (!isDecoratorRef &&
|
|
1069
|
+
} else if (!isDecoratorRef &&
|
|
1070
|
+
!memberReferenceNames.has(name) &&
|
|
1071
|
+
!accessorNames.has(name)) {
|
|
781
1072
|
continue;
|
|
782
1073
|
}
|
|
783
1074
|
// decorator with unresolvable receiver, or
|
|
@@ -823,6 +1114,7 @@ function deadcode(index, options = {}) {
|
|
|
823
1114
|
}
|
|
824
1115
|
|
|
825
1116
|
for (const [name, symbols] of index.symbols) {
|
|
1117
|
+
if (coverage.claimsWithdrawn || coverageSuppressedNames.has(name)) continue;
|
|
826
1118
|
// Definition NAME lines of same-name def-kind symbols are
|
|
827
1119
|
// declarations, not usages — two never-called same-name methods used
|
|
828
1120
|
// to keep each other alive (fix #243: three unreferenced `delete`
|
|
@@ -875,7 +1167,6 @@ function deadcode(index, options = {}) {
|
|
|
875
1167
|
if (symbol.bodyScopedName) {
|
|
876
1168
|
continue;
|
|
877
1169
|
}
|
|
878
|
-
|
|
879
1170
|
const fileEntry = index.files.get(symbol.file);
|
|
880
1171
|
const lang = fileEntry?.language;
|
|
881
1172
|
|
|
@@ -884,6 +1175,26 @@ function deadcode(index, options = {}) {
|
|
|
884
1175
|
continue;
|
|
885
1176
|
}
|
|
886
1177
|
|
|
1178
|
+
// These callable surfaces are invoked by syntax that never
|
|
1179
|
+
// contains their indexed name (`a < b`, `bag[i]`). A name-based
|
|
1180
|
+
// dead-code proof is impossible without compiler operator/indexer
|
|
1181
|
+
// binding, so they are outside this audit rather than false
|
|
1182
|
+
// deletion candidates.
|
|
1183
|
+
if (/^operator(?:\b|[^a-zA-Z0-9_$])/.test(symbol.name) ||
|
|
1184
|
+
symbol.name === 'this[]' ||
|
|
1185
|
+
((lang === 'c' || lang === 'cpp') && symbol.name.startsWith('~'))) {
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// Rust procedural-macro runtime surfaces are invoked outside the
|
|
1190
|
+
// source call graph. PyO3 exposes every #[pymethods] member to
|
|
1191
|
+
// CPython and invokes #[pymodule_init] from generated module glue.
|
|
1192
|
+
if (lang === 'rust' && (symbol.modifiers || []).some(modifier =>
|
|
1193
|
+
modifier === 'pymethods' || modifier === 'pymodule_init')) {
|
|
1194
|
+
excludedRuntimeContract++;
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
887
1198
|
// Skip test files unless requested
|
|
888
1199
|
if (!options.includeTests && isTestFile(symbol.relativePath, lang)) {
|
|
889
1200
|
continue;
|
|
@@ -914,24 +1225,55 @@ function deadcode(index, options = {}) {
|
|
|
914
1225
|
|
|
915
1226
|
// Language-specific entry points (called by runtime/test runner, not user code)
|
|
916
1227
|
// Each language module declares its own isEntryPoint() rules.
|
|
917
|
-
const langModule =
|
|
1228
|
+
const langModule = getLanguageAdapter(lang);
|
|
918
1229
|
if (langModule.isEntryPoint?.(symbol)) {
|
|
919
1230
|
continue;
|
|
920
1231
|
}
|
|
1232
|
+
if (isJavaSerializationCallback(index, symbol, fileEntry)) {
|
|
1233
|
+
excludedRuntimeContract++;
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// Explicit interface implementations are compiler-required and
|
|
1238
|
+
// are normally invoked only through the interface, so a name scan
|
|
1239
|
+
// cannot observe their liveness. Removing one breaks the type's
|
|
1240
|
+
// contract (CS0535); classify it with other external contracts.
|
|
1241
|
+
if (lang === 'csharp' && symbol.explicitInterface) {
|
|
1242
|
+
excludedExternalContract++;
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
921
1245
|
|
|
922
1246
|
// Framework entry point detection — excluded by default to reduce noise
|
|
923
1247
|
// Detects decorator/annotation patterns (Python, Java, Rust, JS/TS) and
|
|
924
1248
|
// call-pattern-based registration (Express routes, Gin handlers, etc.)
|
|
925
1249
|
// These functions are invoked by frameworks, not by user code.
|
|
926
1250
|
const hasFrameworkEntrypoint = isFrameworkEntrypoint(symbol, index);
|
|
1251
|
+
const hasDecoratorMetadata = hasUnknownRegistrationDecorator(symbol);
|
|
927
1252
|
|
|
928
|
-
|
|
1253
|
+
// Unknown decorators are conservative runtime-registration
|
|
1254
|
+
// boundaries. A decorator necessarily consumes/replaces the
|
|
1255
|
+
// callable, and framework registries commonly invoke it without
|
|
1256
|
+
// a textual name reference. The curated detector adds labels,
|
|
1257
|
+
// but must never be the safety boundary for deletion claims.
|
|
1258
|
+
if ((hasFrameworkEntrypoint || hasDecoratorMetadata) &&
|
|
1259
|
+
!options.includeDecorated) {
|
|
929
1260
|
excludedDecorated++;
|
|
930
1261
|
continue;
|
|
931
1262
|
}
|
|
932
1263
|
|
|
933
1264
|
const isExported = symbolIsExported(index, symbol, fileEntry);
|
|
934
1265
|
|
|
1266
|
+
// `target.onmessage = function ...` / `target.onopen = (...) =>`
|
|
1267
|
+
// is itself the runtime registration. The assigned function has
|
|
1268
|
+
// no free name that could produce a later usage record, so a zero
|
|
1269
|
+
// name count is not deletion evidence. CommonJS export assignments
|
|
1270
|
+
// use the same syntax shape but remain auditable when the caller
|
|
1271
|
+
// explicitly enables exported-surface review.
|
|
1272
|
+
if (symbol.memberAssigned && !symbol.registryMember && !isExported) {
|
|
1273
|
+
excludedRuntimeContract++;
|
|
1274
|
+
continue;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
935
1277
|
// Skip exported unless requested
|
|
936
1278
|
if (isExported && !options.includeExported) {
|
|
937
1279
|
excludedExported++;
|
|
@@ -963,10 +1305,17 @@ function deadcode(index, options = {}) {
|
|
|
963
1305
|
if (classAuditSet.has(symbol.type)) {
|
|
964
1306
|
const members = (fileEntry?.symbols || []).filter(s =>
|
|
965
1307
|
s !== symbol && s.className === name);
|
|
1308
|
+
// A type reached only through one of its members is still
|
|
1309
|
+
// live. This is the normal invocation form for C# extension
|
|
1310
|
+
// containers and other static helper types.
|
|
1311
|
+
if (members.some(m => index.calleeIndex.has(m.name))) {
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
966
1314
|
if (members.some(m => langModule.isEntryPoint?.(m))) {
|
|
967
1315
|
continue;
|
|
968
1316
|
}
|
|
969
|
-
if (members.some(m => isFrameworkEntrypoint(m, index)
|
|
1317
|
+
if (members.some(m => isFrameworkEntrypoint(m, index) ||
|
|
1318
|
+
hasUnknownRegistrationDecorator(m))) {
|
|
970
1319
|
if (!options.includeDecorated) {
|
|
971
1320
|
excludedDecorated++;
|
|
972
1321
|
continue;
|
|
@@ -1034,7 +1383,8 @@ function deadcode(index, options = {}) {
|
|
|
1034
1383
|
// in-project references is not evidence of deadness there.
|
|
1035
1384
|
const isExternalContract = classAuditSet.has(symbol.type)
|
|
1036
1385
|
? _heritageReachesExternalBase(index, symbol, lang, false)
|
|
1037
|
-
: overridesOutOfTreeBase(index, symbol)
|
|
1386
|
+
: overridesOutOfTreeBase(index, symbol) ||
|
|
1387
|
+
(lang === 'go' && goImplicitExternalContract(symbol));
|
|
1038
1388
|
if (isExternalContract && !options.includeExported) {
|
|
1039
1389
|
excludedExternalContract++;
|
|
1040
1390
|
continue;
|
|
@@ -1071,6 +1421,19 @@ function deadcode(index, options = {}) {
|
|
|
1071
1421
|
return { kind: enclosing.type, name: symbol.className };
|
|
1072
1422
|
})();
|
|
1073
1423
|
|
|
1424
|
+
// Object-literal registry members invoked through
|
|
1425
|
+
// `registry[key]()` have no statically named call edge. The
|
|
1426
|
+
// parser records their owning registry, so a matching
|
|
1427
|
+
// computed receiver is positive evidence that "unused" is
|
|
1428
|
+
// unsafe. Hide them from candidates and report the exclusion.
|
|
1429
|
+
const dynamicallyDispatched = symbol.registryMember &&
|
|
1430
|
+
symbol.registryContainer &&
|
|
1431
|
+
computedDispatchReceivers.has(symbol.registryContainer);
|
|
1432
|
+
if (dynamicallyDispatched) {
|
|
1433
|
+
excludedDynamicDispatch++;
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1074
1437
|
results.push({
|
|
1075
1438
|
name: symbol.name,
|
|
1076
1439
|
type: symbol.type,
|
|
@@ -1104,6 +1467,21 @@ function deadcode(index, options = {}) {
|
|
|
1104
1467
|
results.excludedDecorated = excludedDecorated;
|
|
1105
1468
|
results.excludedExported = excludedExported;
|
|
1106
1469
|
results.excludedExternalContract = excludedExternalContract;
|
|
1470
|
+
results.excludedRuntimeContract = excludedRuntimeContract;
|
|
1471
|
+
results.excludedDynamicDispatch = excludedDynamicDispatch;
|
|
1472
|
+
results.computedDispatch = computedDispatchInfo;
|
|
1473
|
+
results.coverage = {
|
|
1474
|
+
complete: coverage.complete,
|
|
1475
|
+
claimsWithdrawn: coverage.claimsWithdrawn,
|
|
1476
|
+
suppressedMatched: coverage.suppressedMatched,
|
|
1477
|
+
files: coverage.files,
|
|
1478
|
+
unreadableFiles: coverage.unreadableFiles,
|
|
1479
|
+
reasons: coverage.reasons,
|
|
1480
|
+
parseRecoveries: coverage.parseRecoveries,
|
|
1481
|
+
};
|
|
1482
|
+
results.pythonImplicitExportFiles = [...index.files.values()].filter(entry =>
|
|
1483
|
+
isPythonPackageFile(index, entry) &&
|
|
1484
|
+
!(entry.exportDetails || []).some(exp => exp.type === '__all__')).length;
|
|
1107
1485
|
|
|
1108
1486
|
return results;
|
|
1109
1487
|
} finally { index._endOp(); }
|