ucn 4.0.2 → 4.1.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 +31 -7
- package/README.md +90 -51
- package/cli/index.js +199 -94
- package/core/account.js +3 -1
- package/core/analysis.js +322 -304
- package/core/bridge.js +13 -8
- package/core/cache.js +109 -19
- package/core/callers.js +969 -77
- package/core/check.js +19 -8
- package/core/deadcode.js +368 -40
- package/core/discovery.js +31 -11
- package/core/entrypoints.js +149 -17
- package/core/execute.js +330 -43
- package/core/graph-build.js +61 -10
- package/core/graph.js +282 -61
- package/core/imports.js +72 -3
- package/core/output/analysis-ext.js +70 -10
- package/core/output/analysis.js +52 -33
- package/core/output/check.js +4 -1
- package/core/output/endpoints.js +8 -3
- package/core/output/extraction.js +12 -1
- package/core/output/find.js +23 -9
- package/core/output/graph.js +32 -9
- package/core/output/refactoring.js +147 -49
- package/core/output/reporting.js +104 -5
- package/core/output/search.js +30 -3
- package/core/output/shared.js +31 -4
- package/core/output/tracing.js +22 -11
- package/core/parser.js +8 -6
- package/core/project.js +167 -7
- package/core/registry.js +20 -16
- package/core/reporting.js +131 -13
- package/core/search.js +270 -55
- package/core/shared.js +240 -1
- package/core/stacktrace.js +23 -1
- package/core/tracing.js +278 -36
- package/core/verify.js +352 -349
- package/languages/go.js +29 -17
- package/languages/index.js +56 -0
- package/languages/java.js +133 -16
- package/languages/javascript.js +215 -27
- package/languages/python.js +113 -47
- package/languages/rust.js +89 -26
- package/languages/utils.js +35 -7
- package/mcp/server.js +69 -30
- package/package.json +5 -1
package/core/check.js
CHANGED
|
@@ -77,11 +77,16 @@ function check(index, options = {}) {
|
|
|
77
77
|
? verifyResult.mismatchDetails
|
|
78
78
|
: [];
|
|
79
79
|
|
|
80
|
-
// For modified functions, the
|
|
80
|
+
// For modified functions, the contracted diffImpact result carries both
|
|
81
|
+
// bands already (confirmed `callers` + visible `unverifiedCallers`).
|
|
81
82
|
let callers = Array.isArray(fn.callers) ? fn.callers : [];
|
|
83
|
+
let unverifiedCallers = Array.isArray(fn.unverifiedCallers) ? fn.unverifiedCallers : [];
|
|
82
84
|
if (callers.length === 0 && fn._kind === 'added') {
|
|
83
85
|
try {
|
|
84
|
-
|
|
86
|
+
const raw = index.findCallers(fn.name, { includeMethods: true, collectAccount: true }) || [];
|
|
87
|
+
callers = raw.filter(c => c.tier !== 'unverified');
|
|
88
|
+
unverifiedCallers = raw.filter(c => c.tier === 'unverified')
|
|
89
|
+
.concat(raw.unverifiedEntries || []);
|
|
85
90
|
} catch (e) { /* skip */ }
|
|
86
91
|
}
|
|
87
92
|
|
|
@@ -91,14 +96,17 @@ function check(index, options = {}) {
|
|
|
91
96
|
line: fn.startLine || fn.line,
|
|
92
97
|
kind: fn._kind,
|
|
93
98
|
callerCount: callers.length,
|
|
99
|
+
unverifiedCallerCount: unverifiedCallers.length,
|
|
94
100
|
signatureMismatches: mismatches.length,
|
|
95
101
|
...(mismatches.length > 0 && { mismatches: mismatches.slice(0, 5) }),
|
|
96
102
|
};
|
|
97
103
|
|
|
98
|
-
// Orphan = newly added with zero
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
104
|
+
// Orphan = newly added with zero caller CANDIDATES IN EITHER TIER and
|
|
105
|
+
// not detected as an entry point. Zero confirmed + N unverified is NOT
|
|
106
|
+
// orphan (the #223 reverseTrace entry-point soundness rule — claiming
|
|
107
|
+
// "nobody calls this" after routing candidates to the unverified tier
|
|
108
|
+
// would be the exact silent-drop the contract forbids).
|
|
109
|
+
if (item.kind === 'added' && callers.length === 0 && unverifiedCallers.length === 0) {
|
|
102
110
|
// Check entry points: if the symbol is a known entry-point pattern, not orphan
|
|
103
111
|
let isEntry = false;
|
|
104
112
|
try {
|
|
@@ -114,7 +122,9 @@ function check(index, options = {}) {
|
|
|
114
122
|
items.push(item);
|
|
115
123
|
}
|
|
116
124
|
|
|
117
|
-
// Surface deleted functions inline
|
|
125
|
+
// Surface deleted functions inline. remainingCallSites are name-level
|
|
126
|
+
// matches still present in the tree — a deleted function that is still
|
|
127
|
+
// called is a likely break, so they count as unverified callers here.
|
|
118
128
|
for (const d of deleted) {
|
|
119
129
|
items.push({
|
|
120
130
|
name: d.name || '(unnamed)',
|
|
@@ -122,6 +132,7 @@ function check(index, options = {}) {
|
|
|
122
132
|
line: d.startLine || 0,
|
|
123
133
|
kind: 'deleted',
|
|
124
134
|
callerCount: 0,
|
|
135
|
+
unverifiedCallerCount: (d.remainingCallSites || []).length,
|
|
125
136
|
signatureMismatches: 0,
|
|
126
137
|
});
|
|
127
138
|
}
|
|
@@ -157,7 +168,7 @@ function check(index, options = {}) {
|
|
|
157
168
|
actions.push({
|
|
158
169
|
severity: 'warn',
|
|
159
170
|
kind: 'orphan_new',
|
|
160
|
-
message: `${it.name} is new but has no callers and is not an entry point`,
|
|
171
|
+
message: `${it.name} is new but has no callers (confirmed or unverified) and is not an entry point`,
|
|
161
172
|
});
|
|
162
173
|
}
|
|
163
174
|
}
|
package/core/deadcode.js
CHANGED
|
@@ -6,13 +6,20 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const { detectLanguage, getParser, getLanguageModule, safeParse, langTraits } = require('../languages');
|
|
9
|
+
const { dirname: pathDirname } = require('path');
|
|
9
10
|
const { isTestFile } = require('./discovery');
|
|
10
11
|
const { isFrameworkEntrypoint } = require('./entrypoints');
|
|
11
12
|
const { splitParentList } = require('./graph-build');
|
|
12
|
-
const { isOverrideMarked } = require('./shared');
|
|
13
|
+
const { isOverrideMarked, codeUnitCompare, lineInRanges, maskBlockComments } = require('./shared');
|
|
13
14
|
|
|
14
15
|
const _CLASS_KINDS = ['class', 'struct', 'interface', 'trait', 'record'];
|
|
15
16
|
|
|
17
|
+
// Class-like kinds the audit claims directly (fix #253a — unused classes were
|
|
18
|
+
// never reported: the audit surface had no class kinds). 'impl' stays out (an
|
|
19
|
+
// impl block belongs to its struct — the struct claim covers it); 'type'
|
|
20
|
+
// aliases and macros stay out (deferred — each is its own claim family).
|
|
21
|
+
const CLASS_AUDIT_KINDS = ['class', 'struct', 'interface', 'trait', 'record', 'enum', 'namespace'];
|
|
22
|
+
|
|
16
23
|
/** Strip a base-type expression to its bare name: `Mapping[str, int]`→Mapping, `java.util.List<Foo>`→List, `a::b::C`→C. */
|
|
17
24
|
function _bareBaseName(raw) {
|
|
18
25
|
return String(raw).replace(/[<[(].*$/s, '').split('.').pop().split('::').pop().trim();
|
|
@@ -28,13 +35,31 @@ function _bareBaseName(raw) {
|
|
|
28
35
|
// convention, rule #9.)
|
|
29
36
|
const _UNIVERSAL_ROOTS = new Set(['object', 'Object']);
|
|
30
37
|
|
|
38
|
+
// Python typing bases with a fixed, known surface that never dispatch an
|
|
39
|
+
// arbitrary subclass method by name (fix #253b): `class DataService(Generic[T])`
|
|
40
|
+
// is exactly `class DataService` for dispatch purposes — Generic contributes
|
|
41
|
+
// __class_getitem__ machinery, nothing that calls subclass methods. Without
|
|
42
|
+
// this, the external-base shield hid every public method of every generic
|
|
43
|
+
// class. Python-gated: an external class literally named Generic in another
|
|
44
|
+
// language could genuinely dispatch. Protocol stays OUT — protocol classes
|
|
45
|
+
// are contract surface, and their members' "deadness" is a different claim.
|
|
46
|
+
const _PY_NON_DISPATCHING_BASES = new Set(['Generic']);
|
|
47
|
+
|
|
31
48
|
/** True when a base name resolves to NO in-project class/struct/interface/trait/record (an out-of-tree type). */
|
|
32
|
-
function _baseIsExternal(index, bare) {
|
|
49
|
+
function _baseIsExternal(index, bare, lang) {
|
|
33
50
|
if (!bare || _UNIVERSAL_ROOTS.has(bare)) return false;
|
|
51
|
+
if (lang === 'python' && _PY_NON_DISPATCHING_BASES.has(bare)) return false;
|
|
34
52
|
const defs = index.symbols.get(bare);
|
|
35
53
|
return !(defs && defs.some(d => _CLASS_KINDS.includes(d.type)));
|
|
36
54
|
}
|
|
37
55
|
|
|
56
|
+
/** Does this CLASS DEF extend at least one base that is not in the project index? */
|
|
57
|
+
function _classDefHasExternalBase(index, classDef, lang) {
|
|
58
|
+
if (!classDef.extends) return false;
|
|
59
|
+
const supers = Array.isArray(classDef.extends) ? classDef.extends : splitParentList(classDef.extends);
|
|
60
|
+
return supers.some(raw => _baseIsExternal(index, _bareBaseName(raw), lang));
|
|
61
|
+
}
|
|
62
|
+
|
|
38
63
|
/**
|
|
39
64
|
* Does the method's enclosing class EXTEND at least one base that is NOT in the
|
|
40
65
|
* project index? An out-of-tree base is a framework/library type UCN can't see;
|
|
@@ -50,16 +75,10 @@ function _baseIsExternal(index, bare) {
|
|
|
50
75
|
* `impl Display for`s.
|
|
51
76
|
*/
|
|
52
77
|
function _classHasExternalBase(index, symbol) {
|
|
78
|
+
const lang = index.files.get(symbol.file)?.language;
|
|
53
79
|
const classDefs = (index.symbols.get(symbol.className) || []).filter(c =>
|
|
54
80
|
c.file === symbol.file && _CLASS_KINDS.includes(c.type));
|
|
55
|
-
|
|
56
|
-
if (!cd.extends) continue;
|
|
57
|
-
const supers = Array.isArray(cd.extends) ? cd.extends : splitParentList(cd.extends);
|
|
58
|
-
for (const raw of supers) {
|
|
59
|
-
if (_baseIsExternal(index, _bareBaseName(raw))) return true;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return false;
|
|
81
|
+
return classDefs.some(cd => _classDefHasExternalBase(index, cd, lang));
|
|
63
82
|
}
|
|
64
83
|
|
|
65
84
|
/**
|
|
@@ -93,6 +112,55 @@ function overridesOutOfTreeBase(index, symbol) {
|
|
|
93
112
|
return false;
|
|
94
113
|
}
|
|
95
114
|
|
|
115
|
+
// Symbol types whose definition NAME line provably cannot reference a
|
|
116
|
+
// same-name VALUE — used to stop same-name defs keeping each other alive
|
|
117
|
+
// (fix #243). 'state' and 'field' stay OUT: `helper = other.helper` and
|
|
118
|
+
// Java `int helper = Other.helper();` genuinely reference the name.
|
|
119
|
+
const DEF_NAME_LINE_KINDS = new Set([
|
|
120
|
+
'function', 'method', 'static', 'public', 'abstract', 'constructor',
|
|
121
|
+
'private', 'classmethod', 'property', 'setter', 'deleter', 'get', 'set',
|
|
122
|
+
'override', 'static get', 'static set', 'override get', 'override set',
|
|
123
|
+
'static override', 'static override get', 'static override set',
|
|
124
|
+
'class', 'struct', 'interface', 'trait', 'record', 'enum', 'namespace', 'impl',
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Is EVERY call site of `name` inside the body of a same-name definition?
|
|
129
|
+
* Recursion is not liveness (fix #253c): if no code outside defs of the name
|
|
130
|
+
* calls the name, the whole same-name group is unreachable from outside —
|
|
131
|
+
* `function retry() { ...retry()... }` with no external caller is dead, but
|
|
132
|
+
* the calleeIndex fast path saw a call site and skipped it. Transitively
|
|
133
|
+
* sound for the group: a same-name def called only by another same-name def
|
|
134
|
+
* is dead exactly when its caller is. findCallees already excludes
|
|
135
|
+
* self-recursion; this mirrors that rule at the deadcode pre-filter.
|
|
136
|
+
* Conservative on any uncertainty: unknown def ranges or an outside site
|
|
137
|
+
* keep the name "used".
|
|
138
|
+
*/
|
|
139
|
+
function nameOnlySelfRecursive(index, name) {
|
|
140
|
+
const files = index.calleeIndex && index.calleeIndex.get(name);
|
|
141
|
+
if (!files || files.size === 0) return false;
|
|
142
|
+
const defs = (index.symbols.get(name) || []).filter(d => DEF_NAME_LINE_KINDS.has(d.type));
|
|
143
|
+
if (defs.length === 0) return false;
|
|
144
|
+
const defFiles = new Set(defs.map(d => d.file));
|
|
145
|
+
for (const f of files) {
|
|
146
|
+
if (!defFiles.has(f)) return false; // call site in a def-less file — external
|
|
147
|
+
}
|
|
148
|
+
const { getCachedCalls } = require('./callers');
|
|
149
|
+
for (const f of files) {
|
|
150
|
+
let calls;
|
|
151
|
+
try { calls = getCachedCalls(index, f); } catch { return false; }
|
|
152
|
+
if (!calls) return false;
|
|
153
|
+
for (const call of calls) {
|
|
154
|
+
if (call.name !== name && call.resolvedName !== name &&
|
|
155
|
+
!(call.resolvedNames && call.resolvedNames.includes(name))) continue;
|
|
156
|
+
const inside = defs.some(d => d.file === f &&
|
|
157
|
+
call.line >= d.startLine && call.line <= d.endLine);
|
|
158
|
+
if (!inside) return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
96
164
|
/** Check if a position in a line is inside a string literal (quotes/backticks) */
|
|
97
165
|
function isInsideString(line, pos) {
|
|
98
166
|
let inSingle = false, inDouble = false, inBacktick = false;
|
|
@@ -319,20 +387,41 @@ function deadcode(index, options = {}) {
|
|
|
319
387
|
index.buildCalleeIndex();
|
|
320
388
|
}
|
|
321
389
|
|
|
322
|
-
// Collect callable symbol names to reduce usage index scope
|
|
323
|
-
|
|
390
|
+
// Collect callable symbol names to reduce usage index scope.
|
|
391
|
+
// Accessor and visibility kinds joined in fix #247 — #private methods,
|
|
392
|
+
// getters/setters, Python underscore/@property/@classmethod/@x.setter/
|
|
393
|
+
// @x.deleter members were silently unaudited in BOTH modes (no exclusion
|
|
394
|
+
// counter said so). Property accessors count reads/writes as usage: the
|
|
395
|
+
// scan totals ALL usage types, so `obj.value` keeps a getter alive.
|
|
396
|
+
const callableTypes = ['function', 'method', 'static', 'public', 'abstract', 'constructor',
|
|
397
|
+
'private', 'get', 'set', 'property', 'setter', 'deleter', 'classmethod',
|
|
398
|
+
'override', 'static get', 'static set', 'override get', 'override set',
|
|
399
|
+
'static override', 'static override get', 'static override set',
|
|
400
|
+
// Class-like kinds joined in fix #253a — unused classes/structs/
|
|
401
|
+
// interfaces were never audited in either mode.
|
|
402
|
+
...CLASS_AUDIT_KINDS];
|
|
403
|
+
const auditTypeSet = new Set(callableTypes);
|
|
404
|
+
const classAuditSet = new Set(CLASS_AUDIT_KINDS);
|
|
324
405
|
const callableNames = new Set();
|
|
325
406
|
for (const [symbolName, symbols] of index.symbols) {
|
|
326
|
-
if (symbols.some(s =>
|
|
407
|
+
if (symbols.some(s => auditTypeSet.has(s.type))) {
|
|
327
408
|
callableNames.add(symbolName);
|
|
328
409
|
}
|
|
329
410
|
}
|
|
330
411
|
|
|
331
412
|
// Pre-filter: names in the callee index have call sites → definitely used → not dead.
|
|
413
|
+
// Exception (fix #253c): a name whose EVERY call site sits inside a
|
|
414
|
+
// same-name definition's own body is only self-recursive — recursion is
|
|
415
|
+
// not liveness. Those names fall through to the text scan, which excludes
|
|
416
|
+
// in-body references for them (selfRecursiveNames below).
|
|
332
417
|
let potentiallyDeadNames = new Set();
|
|
418
|
+
const selfRecursiveNames = new Set();
|
|
333
419
|
for (const name of callableNames) {
|
|
334
420
|
if (!index.calleeIndex.has(name)) {
|
|
335
421
|
potentiallyDeadNames.add(name);
|
|
422
|
+
} else if (nameOnlySelfRecursive(index, name)) {
|
|
423
|
+
selfRecursiveNames.add(name);
|
|
424
|
+
potentiallyDeadNames.add(name);
|
|
336
425
|
}
|
|
337
426
|
}
|
|
338
427
|
|
|
@@ -364,11 +453,113 @@ function deadcode(index, options = {}) {
|
|
|
364
453
|
potentiallyDeadNames = filteredNames;
|
|
365
454
|
}
|
|
366
455
|
|
|
456
|
+
// Export-site line ranges per file (lazy, --include-exported only): the
|
|
457
|
+
// precise AST regions where a name's appearance is a re-statement of the
|
|
458
|
+
// export, not consumption (fix #247 — the line-prefix check missed
|
|
459
|
+
// multi-line `export { a,\n b }` blocks, so every symbol exported that
|
|
460
|
+
// way was silently absent from the audit). Ranges are identifier-pure by
|
|
461
|
+
// construction: export clauses, `export default <identifier>`, and
|
|
462
|
+
// identifier/shorthand values of module.exports/exports.* object maps —
|
|
463
|
+
// a function body inside an export assignment still counts as usage.
|
|
464
|
+
const exportRangeCache = new Map();
|
|
465
|
+
const exportSiteRanges = (filePath) => {
|
|
466
|
+
let ranges = exportRangeCache.get(filePath);
|
|
467
|
+
if (ranges) return ranges;
|
|
468
|
+
ranges = [];
|
|
469
|
+
exportRangeCache.set(filePath, ranges);
|
|
470
|
+
const fe = index.files.get(filePath);
|
|
471
|
+
if (!fe || !['javascript', 'typescript', 'tsx'].includes(fe.language)) return ranges;
|
|
472
|
+
let content;
|
|
473
|
+
try { content = index._readFile(filePath); } catch { return ranges; }
|
|
474
|
+
const tree = index._getParsedTree(filePath, content, fe.language);
|
|
475
|
+
if (!tree) return ranges;
|
|
476
|
+
const push = (node) => ranges.push([node.startPosition.row + 1, node.endPosition.row + 1]);
|
|
477
|
+
// A re-statement is filterable only when consumers reach the symbol
|
|
478
|
+
// under ITS OWN NAME (text-visible elsewhere). Renaming surfaces are
|
|
479
|
+
// consumption wiring, never filtered: `export { x as y }` (consumers
|
|
480
|
+
// use y), `module.exports = x` and `export default x` (consumers
|
|
481
|
+
// rename at require/import) — the eval measured all three as
|
|
482
|
+
// FALSE-DEAD when filtered (express createApplication, zod
|
|
483
|
+
// instanceOfType).
|
|
484
|
+
const visit = (node) => {
|
|
485
|
+
if (node.type === 'export_statement') {
|
|
486
|
+
for (const child of node.namedChildren) {
|
|
487
|
+
if (child.type === 'export_clause') {
|
|
488
|
+
for (const spec of child.namedChildren) {
|
|
489
|
+
if (spec.type !== 'export_specifier') continue;
|
|
490
|
+
if (spec.childForFieldName('alias')) continue;
|
|
491
|
+
push(spec);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return; // never descend into exported declarations
|
|
496
|
+
}
|
|
497
|
+
if (node.type === 'assignment_expression') {
|
|
498
|
+
const lhs = node.childForFieldName('left');
|
|
499
|
+
const rhs = node.childForFieldName('right');
|
|
500
|
+
const lhsText = lhs ? lhs.text : '';
|
|
501
|
+
const wholeModule = lhsText === 'module.exports' || lhsText === 'exports';
|
|
502
|
+
const namedExport = lhsText.startsWith('module.exports.') || lhsText.startsWith('exports.');
|
|
503
|
+
if (rhs && (wholeModule || namedExport)) {
|
|
504
|
+
if (rhs.type === 'identifier') {
|
|
505
|
+
if (namedExport) push(rhs); // exports.helper = helper — name-preserving
|
|
506
|
+
} else if (rhs.type === 'object') {
|
|
507
|
+
for (const prop of rhs.namedChildren) {
|
|
508
|
+
if (prop.type === 'shorthand_property_identifier') push(prop);
|
|
509
|
+
else if (prop.type === 'pair') {
|
|
510
|
+
const v = prop.childForFieldName('value');
|
|
511
|
+
const k = prop.childForFieldName('key');
|
|
512
|
+
// `{ helper: helper }` filters; `{ other: helper }`
|
|
513
|
+
// renames — consumption wiring.
|
|
514
|
+
if (v && v.type === 'identifier' && k && k.text === v.text) push(v);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (!wholeModule) return;
|
|
519
|
+
// exports = module.exports = X chains: descend the RHS.
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
for (const child of node.namedChildren) visit(child);
|
|
523
|
+
};
|
|
524
|
+
try { visit(tree.rootNode); } catch { /* partial ranges are fine */ }
|
|
525
|
+
return ranges;
|
|
526
|
+
};
|
|
527
|
+
|
|
367
528
|
// Build usage index for potentially dead names using text scan (no tree-sitter reparsing).
|
|
368
529
|
// The callee index already covers all call-based usages. For remaining names, a word-boundary
|
|
369
530
|
// text scan catches imports, exports, shorthand properties, type refs, and variable refs.
|
|
370
531
|
// Trade-off: may match names in comments/strings (false "used" → fewer dead code reports),
|
|
371
532
|
// but avoids ~1.9s of tree-sitter re-parsing. buildUsageIndex() is kept for direct callers.
|
|
533
|
+
// Names owned by an ACCESSOR-kind definition (getter/setter/@property):
|
|
534
|
+
// their entire consumption form is a paren-less attribute read
|
|
535
|
+
// (`response.num_bytes_downloaded`), whose receiver is usually a local
|
|
536
|
+
// the #123/#216 dotted discipline can't resolve. Dropping those reads
|
|
537
|
+
// claimed 15 live properties dead across httpx/rich (fix #247, eval-
|
|
538
|
+
// measured) — for these names the read keeps the usage UNSCOPED
|
|
539
|
+
// (conservative: keeps every same-name symbol alive), exactly the #243
|
|
540
|
+
// decorator rule. Non-accessor names keep the strict discipline.
|
|
541
|
+
const ACCESSOR_KINDS = new Set(['property', 'setter', 'deleter', 'get', 'set',
|
|
542
|
+
'static get', 'static set', 'override get', 'override set',
|
|
543
|
+
'static override get', 'static override set']);
|
|
544
|
+
const accessorNames = new Set();
|
|
545
|
+
// Names owned by a class-kind definition (fix #253a): in PYTHON files,
|
|
546
|
+
// string-interior matches count as usage for these — PEP 484 forward
|
|
547
|
+
// references (`x: "Foo"`, `Optional["Foo"]`) are real type references
|
|
548
|
+
// the type checker resolves; skipping them claimed live classes dead.
|
|
549
|
+
// Conservative direction only (docstring mentions also keep the class
|
|
550
|
+
// alive). Other languages keep the string skip: their in-string names
|
|
551
|
+
// are reflection (a documented limitation), not a language feature.
|
|
552
|
+
const classKindNames = new Set();
|
|
553
|
+
for (const name of potentiallyDeadNames) {
|
|
554
|
+
const defs = index.symbols.get(name) || [];
|
|
555
|
+
if (defs.some(s => ACCESSOR_KINDS.has(s.type))) {
|
|
556
|
+
accessorNames.add(name);
|
|
557
|
+
}
|
|
558
|
+
if (defs.some(s => classAuditSet.has(s.type))) {
|
|
559
|
+
classKindNames.add(name);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
372
563
|
const usageIndex = new Map();
|
|
373
564
|
if (potentiallyDeadNames.size > 0) {
|
|
374
565
|
for (const [filePath, fileEntry] of index.files) {
|
|
@@ -390,7 +581,11 @@ function deadcode(index, options = {}) {
|
|
|
390
581
|
if (present) namesInFile.push(name);
|
|
391
582
|
}
|
|
392
583
|
if (namesInFile.length === 0) continue;
|
|
393
|
-
|
|
584
|
+
// Block-comment interiors masked to spaces (fix #253d): the
|
|
585
|
+
// per-line skip below only handles // and # comments, so a
|
|
586
|
+
// name inside /* ... */ counted as usage — commented-out code
|
|
587
|
+
// silently kept its symbols "alive" and hid true dead claims.
|
|
588
|
+
const lines = maskBlockComments(content, fileEntry.language).split('\n');
|
|
394
589
|
for (const name of namesInFile) {
|
|
395
590
|
const nameLen = name.length;
|
|
396
591
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -413,8 +608,11 @@ function deadcode(index, options = {}) {
|
|
|
413
608
|
// Skip if inside a # comment (Python — # preceded by whitespace or at start)
|
|
414
609
|
if (hashIdx !== -1 && hashIdx < pos &&
|
|
415
610
|
(hashIdx === 0 || /\s/.test(line[hashIdx - 1]))) continue;
|
|
416
|
-
// Skip if inside a string literal
|
|
417
|
-
|
|
611
|
+
// Skip if inside a string literal — EXCEPT class-
|
|
612
|
+
// kind names in Python (fix #253a): `x: "Foo"`
|
|
613
|
+
// forward references are real type references.
|
|
614
|
+
if (isInsideString(line, pos) &&
|
|
615
|
+
!(fileEntry.language === 'python' && classKindNames.has(name))) continue;
|
|
418
616
|
// Property/field access (preceded by '.'), not a
|
|
419
617
|
// call: resolve the RECEIVER (fix #216, express-
|
|
420
618
|
// measured false-dead — `app.all(route, user.load)`
|
|
@@ -431,10 +629,29 @@ function deadcode(index, options = {}) {
|
|
|
431
629
|
let dottedScope;
|
|
432
630
|
if (pos > 0 && line[pos - 1] === '.' &&
|
|
433
631
|
(pos + nameLen >= line.length || line[pos + nameLen] !== '(')) {
|
|
632
|
+
// Bare dotted DECORATOR application (@bus.subscribe,
|
|
633
|
+
// @a.b.helper) executes at import time — always a
|
|
634
|
+
// usage, whatever the receiver is (fix #243,
|
|
635
|
+
// FALSE-DEAD: deleting @bus.subscribe's subscribe
|
|
636
|
+
// breaks the module at import; the receiver `bus`
|
|
637
|
+
// is a local instance, not an import binding).
|
|
638
|
+
const before = line.slice(0, pos);
|
|
639
|
+
const atIdx = before.lastIndexOf('@');
|
|
640
|
+
const isDecoratorRef = atIdx !== -1 &&
|
|
641
|
+
/^[\w$.]*$/.test(before.slice(atIdx + 1)) &&
|
|
642
|
+
before.slice(0, atIdx).trim() === '';
|
|
434
643
|
let r = pos - 2;
|
|
435
644
|
while (r >= 0 && /[\w$]/.test(line[r])) r--;
|
|
436
645
|
const receiver = line.slice(r + 1, pos - 1);
|
|
437
|
-
|
|
646
|
+
// A CHAINED receiver (`z.string().email().isEmail`)
|
|
647
|
+
// extracts as empty — for accessor-kind names
|
|
648
|
+
// that read is still the consumption form, so
|
|
649
|
+
// the #247 unscoped fallback below must get
|
|
650
|
+
// its chance (zod-measured false-dead: eight
|
|
651
|
+
// v3 getters whose only references are
|
|
652
|
+
// chained reads; the empty-receiver drop
|
|
653
|
+
// fired before the accessor exemption).
|
|
654
|
+
if (!receiver && !isDecoratorRef && !accessorNames.has(name)) continue;
|
|
438
655
|
if (['this', 'self', 'cls'].includes(receiver)) {
|
|
439
656
|
dottedScope = 'same-file';
|
|
440
657
|
} else {
|
|
@@ -442,14 +659,37 @@ function deadcode(index, options = {}) {
|
|
|
442
659
|
.find(b => b.name === receiver);
|
|
443
660
|
const resolved = binding && fileEntry.moduleResolved &&
|
|
444
661
|
fileEntry.moduleResolved[binding.module];
|
|
445
|
-
if (
|
|
446
|
-
|
|
662
|
+
if (resolved) {
|
|
663
|
+
dottedScope = resolved;
|
|
664
|
+
} else if (!isDecoratorRef && !accessorNames.has(name)) {
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
// decorator with unresolvable receiver, or
|
|
668
|
+
// an attribute read of an ACCESSOR-kind
|
|
669
|
+
// name (fix #247 — the read IS how
|
|
670
|
+
// getters/properties are consumed): keep
|
|
671
|
+
// the usage UNSCOPED (conservative —
|
|
672
|
+
// keeps every same-name symbol alive)
|
|
447
673
|
}
|
|
448
674
|
}
|
|
449
|
-
// Skip object literal key: name followed by ':' (not '::' for Rust paths)
|
|
675
|
+
// Skip object literal key: name followed by ':' (not '::' for Rust paths).
|
|
676
|
+
// A DOTTED access is never a literal key — object keys are bare
|
|
677
|
+
// (fix #247, eval-measured: Python's block colon made
|
|
678
|
+
// `if merge_url.is_relative_url:` read as a key, dropping the
|
|
679
|
+
// property's only consumption).
|
|
680
|
+
// Python is exempt entirely (fix #253a): it has NO
|
|
681
|
+
// bare-identifier-key syntax where the name is not
|
|
682
|
+
// an expression — dict keys are expressions, and
|
|
683
|
+
// `except Foo:` / `-> Foo:` / `while Foo:` are all
|
|
684
|
+
// real usages the skip was dropping. `case Foo:`
|
|
685
|
+
// (switch/match) is likewise an expression usage in
|
|
686
|
+
// every language.
|
|
450
687
|
const afterChar = pos + nameLen < line.length ? line[pos + nameLen] : '';
|
|
451
688
|
const afterChar2 = pos + nameLen + 1 < line.length ? line[pos + nameLen + 1] : '';
|
|
452
|
-
if (afterChar === ':' && afterChar2 !== ':'
|
|
689
|
+
if (afterChar === ':' && afterChar2 !== ':' &&
|
|
690
|
+
fileEntry.language !== 'python' &&
|
|
691
|
+
!(pos > 0 && line[pos - 1] === '.') &&
|
|
692
|
+
!/(^|[^\w$])case\s+$/.test(line.slice(0, pos))) continue;
|
|
453
693
|
// Valid reference found
|
|
454
694
|
if (!usageIndex.has(name)) usageIndex.set(name, []);
|
|
455
695
|
usageIndex.get(name).push({
|
|
@@ -467,9 +707,47 @@ function deadcode(index, options = {}) {
|
|
|
467
707
|
}
|
|
468
708
|
|
|
469
709
|
for (const [name, symbols] of index.symbols) {
|
|
710
|
+
// Definition NAME lines of same-name def-kind symbols are
|
|
711
|
+
// declarations, not usages — two never-called same-name methods used
|
|
712
|
+
// to keep each other alive (fix #243: three unreferenced `delete`
|
|
713
|
+
// methods invisible). nameLine only (never a decorated def's
|
|
714
|
+
// startLine — a `@helper` decorator line IS a usage of the name).
|
|
715
|
+
let sameNameDefLines = null;
|
|
716
|
+
const defNameLines = () => {
|
|
717
|
+
if (sameNameDefLines) return sameNameDefLines;
|
|
718
|
+
sameNameDefLines = new Map();
|
|
719
|
+
for (const other of symbols) {
|
|
720
|
+
if (!DEF_NAME_LINE_KINDS.has(other.type)) continue;
|
|
721
|
+
const ln = other.nameLine ?? other.startLine;
|
|
722
|
+
if (ln == null) continue;
|
|
723
|
+
let set = sameNameDefLines.get(other.file);
|
|
724
|
+
if (!set) { set = new Set(); sameNameDefLines.set(other.file, set); }
|
|
725
|
+
set.add(ln);
|
|
726
|
+
}
|
|
727
|
+
return sameNameDefLines;
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
// Same-name definition BODY ranges — consulted only for names the
|
|
731
|
+
// self-recursion carve-out (fix #253c) admitted: for those, a usage
|
|
732
|
+
// inside a same-name def's own body is the recursion itself, never
|
|
733
|
+
// outside liveness. Scoped to carve-out names so a sibling-method
|
|
734
|
+
// call (`self.f()` from another method) keeps counting elsewhere.
|
|
735
|
+
let sameNameDefRanges = null;
|
|
736
|
+
const defRanges = () => {
|
|
737
|
+
if (sameNameDefRanges) return sameNameDefRanges;
|
|
738
|
+
sameNameDefRanges = new Map();
|
|
739
|
+
for (const other of symbols) {
|
|
740
|
+
if (!DEF_NAME_LINE_KINDS.has(other.type)) continue;
|
|
741
|
+
let arr = sameNameDefRanges.get(other.file);
|
|
742
|
+
if (!arr) { arr = []; sameNameDefRanges.set(other.file, arr); }
|
|
743
|
+
arr.push([other.startLine, other.endLine]);
|
|
744
|
+
}
|
|
745
|
+
return sameNameDefRanges;
|
|
746
|
+
};
|
|
747
|
+
|
|
470
748
|
for (const symbol of symbols) {
|
|
471
|
-
// Skip non-
|
|
472
|
-
if (!
|
|
749
|
+
// Skip non-audited types (callableTypes defined above)
|
|
750
|
+
if (!auditTypeSet.has(symbol.type)) {
|
|
473
751
|
continue;
|
|
474
752
|
}
|
|
475
753
|
|
|
@@ -527,9 +805,40 @@ function deadcode(index, options = {}) {
|
|
|
527
805
|
}
|
|
528
806
|
|
|
529
807
|
// Fast path: name has call sites in callee index → definitely used → not dead
|
|
530
|
-
|
|
808
|
+
// (unless every site is the name's own recursion — fix #253c).
|
|
809
|
+
if (index.calleeIndex.has(name) && !selfRecursiveNames.has(name)) {
|
|
531
810
|
continue;
|
|
532
811
|
}
|
|
812
|
+
// Constructor members are invoked through the CLASS name
|
|
813
|
+
// (fix #239, G2-js-measured: `new Widget()` indexes under
|
|
814
|
+
// 'Widget' — the member lookup claimed every instantiated
|
|
815
|
+
// class's constructor dead).
|
|
816
|
+
if (symbol.className &&
|
|
817
|
+
(symbol.type === 'constructor' || name === 'constructor' || name === '__init__') &&
|
|
818
|
+
index.calleeIndex.has(symbol.className) &&
|
|
819
|
+
!selfRecursiveNames.has(symbol.className)) {
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Class-kind claims (fix #253a): a class whose member the runtime
|
|
824
|
+
// or a framework invokes is live with zero textual references of
|
|
825
|
+
// the class NAME — `class Main { public static void main }`,
|
|
826
|
+
// a class with @app.route methods. Deleting the class deletes the
|
|
827
|
+
// entry point. Framework-registered members follow the same
|
|
828
|
+
// --include-decorated reveal as directly decorated symbols.
|
|
829
|
+
if (classAuditSet.has(symbol.type)) {
|
|
830
|
+
const members = (fileEntry?.symbols || []).filter(s =>
|
|
831
|
+
s !== symbol && s.className === name);
|
|
832
|
+
if (members.some(m => langModule.isEntryPoint?.(m))) {
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
if (members.some(m => isFrameworkEntrypoint(m, index))) {
|
|
836
|
+
if (!options.includeDecorated) {
|
|
837
|
+
excludedDecorated++;
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
533
842
|
|
|
534
843
|
// Slow path: check AST-based usage index for remaining names
|
|
535
844
|
const allUsages = usageIndex.get(name) || [];
|
|
@@ -542,30 +851,36 @@ function deadcode(index, options = {}) {
|
|
|
542
851
|
// unrelated module's same-name symbol.
|
|
543
852
|
let nonDefUsages = allUsages.filter(u =>
|
|
544
853
|
!(u.file === symbol.file && (u.line === symbol.startLine || u.line === symbol.nameLine)) &&
|
|
854
|
+
!(defNameLines().get(u.file)?.has(u.line)) &&
|
|
855
|
+
// Self-recursion carve-out names (fix #253c): a reference
|
|
856
|
+
// inside a same-name def's own body is the recursion itself.
|
|
857
|
+
!(selfRecursiveNames.has(name) &&
|
|
858
|
+
lineInRanges(u.line, defRanges().get(u.file) || [])) &&
|
|
545
859
|
(!u.dottedScope ||
|
|
546
860
|
(u.dottedScope === 'same-file'
|
|
547
861
|
? u.file === symbol.file
|
|
548
|
-
:
|
|
862
|
+
// Directory-scoped packages (Go): the import binds the
|
|
863
|
+
// package DIRECTORY, so the resolved module file may
|
|
864
|
+
// be any sibling of the symbol's file (fix #253,
|
|
865
|
+
// grpc-go-measured false-dead: the only usages of
|
|
866
|
+
// `internal.EnforceSubConnEmbedding` resolved to a
|
|
867
|
+
// sibling of internal/internal.go and were dropped).
|
|
868
|
+
: (u.dottedScope === symbol.relativePath ||
|
|
869
|
+
(langTraits(lang)?.packageScope === 'directory' &&
|
|
870
|
+
pathDirname(u.dottedScope) === pathDirname(symbol.relativePath)))))
|
|
549
871
|
);
|
|
550
872
|
|
|
551
873
|
// For exported symbols in --include-exported mode, also filter out export-site
|
|
552
874
|
// references (e.g., `module.exports = { helperC }` or `export { helperC }`).
|
|
553
875
|
// These are just re-statements of the export, not actual consumption.
|
|
876
|
+
// AST ranges, not line prefixes (fix #247): a multi-line
|
|
877
|
+
// `export { a,\n b }` block's continuation lines counted as
|
|
878
|
+
// consumption, silently hiding every symbol exported that way.
|
|
554
879
|
if (isExported && options.includeExported) {
|
|
555
880
|
nonDefUsages = nonDefUsages.filter(u => {
|
|
556
881
|
if (u.file !== symbol.file) return true; // cross-file usage always counts
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
try { content = index._readFile(u.file); } catch { return true; }
|
|
560
|
-
if (!content) return true;
|
|
561
|
-
const lines = content.split('\n');
|
|
562
|
-
const line = lines[u.line - 1] || '';
|
|
563
|
-
const trimmed = line.trim();
|
|
564
|
-
// CJS: module.exports = { ... } or exports.name = ...
|
|
565
|
-
if (trimmed.startsWith('module.exports') || /^exports\.\w+\s*=/.test(trimmed)) return false;
|
|
566
|
-
// ESM: export { ... } or export default
|
|
567
|
-
if (/^export\s*\{/.test(trimmed) || /^export\s+default\s/.test(trimmed)) return false;
|
|
568
|
-
return true;
|
|
882
|
+
const ranges = exportSiteRanges(u.file);
|
|
883
|
+
return !lineInRanges(u.line, ranges);
|
|
569
884
|
});
|
|
570
885
|
}
|
|
571
886
|
|
|
@@ -578,7 +893,14 @@ function deadcode(index, options = {}) {
|
|
|
578
893
|
// fix #210). A zero usage count is not evidence of deadness.
|
|
579
894
|
// Hidden by default; revealed under --include-exported, since
|
|
580
895
|
// it is external-reachable surface, not internal dead code.
|
|
581
|
-
|
|
896
|
+
// Class-kind claims (fix #253a) get the same shield when the
|
|
897
|
+
// class itself EXTENDS an unresolved base: frameworks discover
|
|
898
|
+
// such subclasses non-textually (django `Command(BaseCommand)`
|
|
899
|
+
// by module path, pytest plugins by registration) — zero
|
|
900
|
+
// in-project references is not evidence of deadness there.
|
|
901
|
+
const isExternalContract = classAuditSet.has(symbol.type)
|
|
902
|
+
? _classDefHasExternalBase(index, symbol, lang)
|
|
903
|
+
: overridesOutOfTreeBase(index, symbol);
|
|
582
904
|
if (isExternalContract && !options.includeExported) {
|
|
583
905
|
excludedExternalContract++;
|
|
584
906
|
continue;
|
|
@@ -621,8 +943,14 @@ function deadcode(index, options = {}) {
|
|
|
621
943
|
file: symbol.relativePath,
|
|
622
944
|
startLine: symbol.startLine,
|
|
623
945
|
endLine: symbol.endLine,
|
|
946
|
+
// Two dead constructors in one file rendered identically
|
|
947
|
+
// without their class (fix #247).
|
|
948
|
+
...(symbol.className && { className: symbol.className }),
|
|
624
949
|
isExported,
|
|
625
950
|
usageCount: 0,
|
|
951
|
+
// The name's only references are its own recursion
|
|
952
|
+
// (fix #253c) — say so, the reader will see call sites.
|
|
953
|
+
...(selfRecursiveNames.has(name) && { selfRecursive: true }),
|
|
626
954
|
...(decorators.length > 0 && { decorators }),
|
|
627
955
|
...(annotations.length > 0 && { annotations }),
|
|
628
956
|
...(declaredOn && { declaredOn }),
|
|
@@ -634,7 +962,7 @@ function deadcode(index, options = {}) {
|
|
|
634
962
|
|
|
635
963
|
// Sort by file then line
|
|
636
964
|
results.sort((a, b) => {
|
|
637
|
-
if (a.file !== b.file) return a.file
|
|
965
|
+
if (a.file !== b.file) return codeUnitCompare(a.file, b.file);
|
|
638
966
|
return a.startLine - b.startLine;
|
|
639
967
|
});
|
|
640
968
|
|
|
@@ -647,4 +975,4 @@ function deadcode(index, options = {}) {
|
|
|
647
975
|
} finally { index._endOp(); }
|
|
648
976
|
}
|
|
649
977
|
|
|
650
|
-
module.exports = { buildUsageIndex, deadcode };
|
|
978
|
+
module.exports = { buildUsageIndex, deadcode, nameOnlySelfRecursive, DEF_NAME_LINE_KINDS };
|