ucn 3.8.26 → 4.0.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 -17
- package/README.md +95 -28
- package/cli/index.js +32 -7
- package/core/account.js +354 -0
- package/core/analysis.js +335 -15
- package/core/build-worker.js +21 -1
- package/core/cache.js +52 -3
- package/core/callers.js +3421 -159
- package/core/confidence.js +82 -19
- package/core/deadcode.js +211 -21
- package/core/execute.js +6 -1
- package/core/graph-build.js +45 -3
- package/core/imports.js +118 -1
- package/core/output/analysis.js +345 -83
- package/core/output/reporting.js +19 -3
- package/core/output/shared.js +33 -2
- package/core/output/tracing.js +208 -10
- package/core/project.js +19 -2
- package/core/registry.js +15 -3
- package/core/shared.js +21 -0
- package/core/tracing.js +534 -190
- package/languages/go.js +317 -6
- package/languages/index.js +79 -0
- package/languages/java.js +243 -16
- package/languages/javascript.js +357 -24
- package/languages/python.js +423 -28
- package/languages/rust.js +377 -8
- package/languages/utils.js +72 -18
- package/mcp/server.js +5 -4
- package/package.json +9 -3
- package/.github/workflows/ci.yml +0 -45
- package/.github/workflows/publish.yml +0 -79
package/core/confidence.js
CHANGED
|
@@ -9,24 +9,56 @@
|
|
|
9
9
|
|
|
10
10
|
// Resolution types ordered from most to least confident
|
|
11
11
|
const RESOLUTION = {
|
|
12
|
-
EXACT_BINDING:
|
|
13
|
-
SAME_CLASS:
|
|
14
|
-
RECEIVER_HINT:
|
|
15
|
-
SCOPE_MATCH:
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
EXACT_BINDING: 'exact-binding',
|
|
13
|
+
SAME_CLASS: 'same-class',
|
|
14
|
+
RECEIVER_HINT: 'receiver-hint',
|
|
15
|
+
SCOPE_MATCH: 'scope-match',
|
|
16
|
+
POSSIBLE_DISPATCH: 'possible-dispatch',
|
|
17
|
+
NAME_ONLY: 'name-only',
|
|
18
|
+
METHOD_AMBIGUOUS: 'method-ambiguous',
|
|
19
|
+
UNCERTAIN: 'uncertain',
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
// Seed scores per resolution type (tunable)
|
|
21
23
|
const SCORES = {
|
|
22
|
-
[RESOLUTION.EXACT_BINDING]:
|
|
23
|
-
[RESOLUTION.SAME_CLASS]:
|
|
24
|
-
[RESOLUTION.RECEIVER_HINT]:
|
|
25
|
-
[RESOLUTION.SCOPE_MATCH]:
|
|
26
|
-
[RESOLUTION.
|
|
27
|
-
[RESOLUTION.
|
|
24
|
+
[RESOLUTION.EXACT_BINDING]: 0.98,
|
|
25
|
+
[RESOLUTION.SAME_CLASS]: 0.92,
|
|
26
|
+
[RESOLUTION.RECEIVER_HINT]: 0.80,
|
|
27
|
+
[RESOLUTION.SCOPE_MATCH]: 0.65,
|
|
28
|
+
[RESOLUTION.POSSIBLE_DISPATCH]: 0.50,
|
|
29
|
+
[RESOLUTION.NAME_ONLY]: 0.40,
|
|
30
|
+
[RESOLUTION.METHOD_AMBIGUOUS]: 0.35,
|
|
31
|
+
[RESOLUTION.UNCERTAIN]: 0.25,
|
|
28
32
|
};
|
|
29
33
|
|
|
34
|
+
// Trust tiers for the tiered caller contract. CONFIRMED = the resolution
|
|
35
|
+
// rests on binding/receiver/import evidence; UNVERIFIED = name match without
|
|
36
|
+
// evidence. The mapping is resolution-based, never language-based — evidence
|
|
37
|
+
// flags already come from langTraits dispatch in callers.js, so every
|
|
38
|
+
// language gets correct tiers automatically.
|
|
39
|
+
const TIER = { CONFIRMED: 'confirmed', UNVERIFIED: 'unverified' };
|
|
40
|
+
const RESOLUTION_TIER = {
|
|
41
|
+
[RESOLUTION.EXACT_BINDING]: TIER.CONFIRMED,
|
|
42
|
+
[RESOLUTION.SAME_CLASS]: TIER.CONFIRMED,
|
|
43
|
+
[RESOLUTION.RECEIVER_HINT]: TIER.CONFIRMED,
|
|
44
|
+
// scope-match is only assigned with import/receiver/callback evidence
|
|
45
|
+
// (see scoreEdge below) — that satisfies the contract's evidence clause.
|
|
46
|
+
[RESOLUTION.SCOPE_MATCH]: TIER.CONFIRMED,
|
|
47
|
+
// Nominal dispatch tiering: a call that CAN reach the target through
|
|
48
|
+
// virtual dispatch (interface/supertype-typed receiver) or whose untyped
|
|
49
|
+
// receiver faces multiple same-name owners is evidence a call happens —
|
|
50
|
+
// not evidence it reaches THIS definition. Unverified by construction.
|
|
51
|
+
[RESOLUTION.POSSIBLE_DISPATCH]: TIER.UNVERIFIED,
|
|
52
|
+
[RESOLUTION.NAME_ONLY]: TIER.UNVERIFIED,
|
|
53
|
+
[RESOLUTION.METHOD_AMBIGUOUS]: TIER.UNVERIFIED,
|
|
54
|
+
[RESOLUTION.UNCERTAIN]: TIER.UNVERIFIED,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Map a RESOLUTION value to its trust tier (unknown values are unverified). */
|
|
58
|
+
function tierForResolution(resolution) {
|
|
59
|
+
return RESOLUTION_TIER[resolution] || TIER.UNVERIFIED;
|
|
60
|
+
}
|
|
61
|
+
|
|
30
62
|
/**
|
|
31
63
|
* Score a caller/callee edge based on resolution evidence.
|
|
32
64
|
*
|
|
@@ -44,6 +76,25 @@ const SCORES = {
|
|
|
44
76
|
function scoreEdge(evidence) {
|
|
45
77
|
const reasons = [];
|
|
46
78
|
|
|
79
|
+
// Known receiver/path type mismatch — checked FIRST: positive evidence the
|
|
80
|
+
// call targets a different symbol overrides any receiver-type signal
|
|
81
|
+
// (without this, a known mismatch would score receiver-hint 0.80).
|
|
82
|
+
if (evidence.typeMismatch) {
|
|
83
|
+
reasons.push('receiver type mismatch');
|
|
84
|
+
return { confidence: SCORES[RESOLUTION.UNCERTAIN], resolution: RESOLUTION.UNCERTAIN, evidence: reasons };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Nominal dispatch tiering (contract surface only — callers.js sets these
|
|
88
|
+
// flags exclusively under collectAccount, so legacy paths never see them).
|
|
89
|
+
if (evidence.possibleDispatch) {
|
|
90
|
+
reasons.push('interface/supertype dispatch');
|
|
91
|
+
return { confidence: SCORES[RESOLUTION.POSSIBLE_DISPATCH], resolution: RESOLUTION.POSSIBLE_DISPATCH, evidence: reasons };
|
|
92
|
+
}
|
|
93
|
+
if (evidence.methodAmbiguous) {
|
|
94
|
+
reasons.push('untyped receiver, multiple same-name definitions');
|
|
95
|
+
return { confidence: SCORES[RESOLUTION.METHOD_AMBIGUOUS], resolution: RESOLUTION.METHOD_AMBIGUOUS, evidence: reasons };
|
|
96
|
+
}
|
|
97
|
+
|
|
47
98
|
// Exact binding match (highest confidence)
|
|
48
99
|
if (evidence.hasBindingId) {
|
|
49
100
|
reasons.push('binding-id match');
|
|
@@ -64,16 +115,25 @@ function scoreEdge(evidence) {
|
|
|
64
115
|
return { confidence: SCORES[RESOLUTION.RECEIVER_HINT], resolution: RESOLUTION.RECEIVER_HINT, evidence: reasons };
|
|
65
116
|
}
|
|
66
117
|
|
|
118
|
+
// Function reference (callback / passed-as-argument). Argument position is
|
|
119
|
+
// only confirming when the name demonstrably reaches the target (same file,
|
|
120
|
+
// same package, or an import edge) — otherwise it's a bare name match: a
|
|
121
|
+
// local variable or an unrelated same-name symbol shadows it invisibly.
|
|
122
|
+
if (evidence.isFunctionReference) {
|
|
123
|
+
reasons.push('function reference');
|
|
124
|
+
if (evidence.hasImportEvidence || evidence.hasSamePackageEvidence) {
|
|
125
|
+
reasons.push(evidence.hasImportEvidence ? 'import-supported' : 'same package/module');
|
|
126
|
+
return { confidence: SCORES[RESOLUTION.SCOPE_MATCH], resolution: RESOLUTION.SCOPE_MATCH, evidence: reasons };
|
|
127
|
+
}
|
|
128
|
+
reasons.push('no import evidence');
|
|
129
|
+
return { confidence: SCORES[RESOLUTION.NAME_ONLY], resolution: RESOLUTION.NAME_ONLY, evidence: reasons };
|
|
130
|
+
}
|
|
131
|
+
|
|
67
132
|
// Scope/import-supported match
|
|
68
|
-
if (evidence.hasImportEvidence || evidence.hasReceiverEvidence) {
|
|
133
|
+
if (evidence.hasImportEvidence || evidence.hasReceiverEvidence || evidence.hasSamePackageEvidence) {
|
|
69
134
|
if (evidence.hasImportEvidence) reasons.push('import-supported');
|
|
70
135
|
if (evidence.hasReceiverEvidence) reasons.push('receiver binding in scope');
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Function reference (callback)
|
|
75
|
-
if (evidence.isFunctionReference) {
|
|
76
|
-
reasons.push('function reference');
|
|
136
|
+
if (evidence.hasSamePackageEvidence) reasons.push('same package/module');
|
|
77
137
|
return { confidence: SCORES[RESOLUTION.SCOPE_MATCH], resolution: RESOLUTION.SCOPE_MATCH, evidence: reasons };
|
|
78
138
|
}
|
|
79
139
|
|
|
@@ -111,6 +171,9 @@ function filterByConfidence(edges, minConfidence) {
|
|
|
111
171
|
module.exports = {
|
|
112
172
|
RESOLUTION,
|
|
113
173
|
SCORES,
|
|
174
|
+
TIER,
|
|
175
|
+
RESOLUTION_TIER,
|
|
176
|
+
tierForResolution,
|
|
114
177
|
scoreEdge,
|
|
115
178
|
filterByConfidence,
|
|
116
179
|
};
|
package/core/deadcode.js
CHANGED
|
@@ -8,6 +8,90 @@
|
|
|
8
8
|
const { detectLanguage, getParser, getLanguageModule, safeParse, langTraits } = require('../languages');
|
|
9
9
|
const { isTestFile } = require('./discovery');
|
|
10
10
|
const { isFrameworkEntrypoint } = require('./entrypoints');
|
|
11
|
+
const { splitParentList } = require('./graph-build');
|
|
12
|
+
const { isOverrideMarked } = require('./shared');
|
|
13
|
+
|
|
14
|
+
const _CLASS_KINDS = ['class', 'struct', 'interface', 'trait', 'record'];
|
|
15
|
+
|
|
16
|
+
/** Strip a base-type expression to its bare name: `Mapping[str, int]`→Mapping, `java.util.List<Foo>`→List, `a::b::C`→C. */
|
|
17
|
+
function _bareBaseName(raw) {
|
|
18
|
+
return String(raw).replace(/[<[(].*$/s, '').split('.').pop().split('::').pop().trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The universal object root (Python `object`, Java/JS `Object`) has a fixed,
|
|
22
|
+
// known method surface — Object/dunder methods, themselves entry points — so it
|
|
23
|
+
// never dispatches an arbitrary subclass method by name or override. It is NOT
|
|
24
|
+
// an external *dispatching* base: `class Foo(object)` must still report its
|
|
25
|
+
// genuinely-dead inherent methods, exactly like `class Foo`, instead of
|
|
26
|
+
// diverging on a purely cosmetic base declaration. (Java `Object` is the
|
|
27
|
+
// universalSupertype trait; `object` is the Python equivalent — a language
|
|
28
|
+
// convention, rule #9.)
|
|
29
|
+
const _UNIVERSAL_ROOTS = new Set(['object', 'Object']);
|
|
30
|
+
|
|
31
|
+
/** 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) {
|
|
33
|
+
if (!bare || _UNIVERSAL_ROOTS.has(bare)) return false;
|
|
34
|
+
const defs = index.symbols.get(bare);
|
|
35
|
+
return !(defs && defs.some(d => _CLASS_KINDS.includes(d.type)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Does the method's enclosing class EXTEND at least one base that is NOT in the
|
|
40
|
+
* project index? An out-of-tree base is a framework/library type UCN can't see;
|
|
41
|
+
* via inheritance it may dispatch into a public method of the subclass
|
|
42
|
+
* polymorphically (Starlette → build_middleware_stack) or by name convention
|
|
43
|
+
* (Pydantic → bytes_schema). The class def is matched in the method's own file.
|
|
44
|
+
*
|
|
45
|
+
* `implements` is deliberately NOT consulted: implementing an external
|
|
46
|
+
* interface/trait makes only the INTERFACE'S OWN methods a contract (those
|
|
47
|
+
* carry traitImpl/@Override markers → handled by Rule A), not the class's
|
|
48
|
+
* unrelated inherent methods. Counting it would wrongly shield, e.g., a Rust
|
|
49
|
+
* struct's genuinely-dead inherent method just because the struct also
|
|
50
|
+
* `impl Display for`s.
|
|
51
|
+
*/
|
|
52
|
+
function _classHasExternalBase(index, symbol) {
|
|
53
|
+
const classDefs = (index.symbols.get(symbol.className) || []).filter(c =>
|
|
54
|
+
c.file === symbol.file && _CLASS_KINDS.includes(c.type));
|
|
55
|
+
for (const cd of classDefs) {
|
|
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;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A method that may be reached through an out-of-tree base class — the deadcode
|
|
67
|
+
* analog of fix #210's external-contract methods. A zero in-project usage count
|
|
68
|
+
* is NOT evidence of deadness here; claiming the symbol dead invites deleting a
|
|
69
|
+
* live framework override (e.g. FastAPI.build_middleware_stack overriding
|
|
70
|
+
* Starlette, or GenerateJsonSchema.bytes_schema name-dispatched by Pydantic —
|
|
71
|
+
* the only caller lives in an unindexed dependency).
|
|
72
|
+
* (A) explicit override marker (@Override / `override` / typing.@override /
|
|
73
|
+
* Rust `impl Trait for X`) AND a single project-wide method owner of the
|
|
74
|
+
* name (no in-project supertype defines it → the contract is external —
|
|
75
|
+
* the #210 ownerCount===1 rule).
|
|
76
|
+
* (B) a public-by-shape method whose class EXTENDS an unresolved base.
|
|
77
|
+
* Private/underscore members are never external-contract surface, so a
|
|
78
|
+
* genuinely-dead one stays claimable (the fix #211 shape predicate).
|
|
79
|
+
* Data-driven, not language-keyed: classes without an `extends` clause (Go
|
|
80
|
+
* embedding, Rust structs / inherent impls) never trip (B), and Rust trait
|
|
81
|
+
* impls trip (A) via traitImpl — so new languages inherit correct behavior.
|
|
82
|
+
*/
|
|
83
|
+
function overridesOutOfTreeBase(index, symbol) {
|
|
84
|
+
if (!symbol.className) return false; // standalone function can't override
|
|
85
|
+
if (isOverrideMarked(symbol)) {
|
|
86
|
+
const owners = (index.symbols.get(symbol.name) || []).filter(s => s.className);
|
|
87
|
+
if (owners.length <= 1) return true;
|
|
88
|
+
}
|
|
89
|
+
const mods = symbol.modifiers || [];
|
|
90
|
+
const publicByShape = !mods.includes('private') &&
|
|
91
|
+
!symbol.name.startsWith('#') && !symbol.name.startsWith('_');
|
|
92
|
+
if (publicByShape && _classHasExternalBase(index, symbol)) return true;
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
11
95
|
|
|
12
96
|
/** Check if a position in a line is inside a string literal (quotes/backticks) */
|
|
13
97
|
function isInsideString(line, pos) {
|
|
@@ -177,6 +261,45 @@ function buildUsageIndex(index, filterNames) {
|
|
|
177
261
|
return usageIndex;
|
|
178
262
|
}
|
|
179
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Is a symbol part of the public/exported API surface?
|
|
266
|
+
*
|
|
267
|
+
* Beyond direct evidence (export list, export/public modifiers, Go
|
|
268
|
+
* capitalization), methods of an exported class count as exported in
|
|
269
|
+
* languages where class members are public by default (implicitlyPublicMembers
|
|
270
|
+
* trait — JS/TS/Python): they are reachable through the class from outside
|
|
271
|
+
* the project, so claiming them dead invites deleting public API (fix #211 —
|
|
272
|
+
* zod's `strictImplement` is called by zero project files but is documented
|
|
273
|
+
* public API). Private-by-shape members (#name, _name, `private` modifier)
|
|
274
|
+
* stay claimable.
|
|
275
|
+
*/
|
|
276
|
+
function symbolIsExported(index, symbol, fileEntry) {
|
|
277
|
+
if (!fileEntry) return false;
|
|
278
|
+
const name = symbol.name;
|
|
279
|
+
const mods = symbol.modifiers || [];
|
|
280
|
+
if (fileEntry.exports.includes(name) || mods.includes('export') || mods.includes('public')) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
const traits = langTraits(fileEntry.language);
|
|
284
|
+
if (traits?.exportVisibility === 'capitalization') {
|
|
285
|
+
return /^[A-Z]/.test(name);
|
|
286
|
+
}
|
|
287
|
+
if (traits?.implicitlyPublicMembers && symbol.className &&
|
|
288
|
+
!mods.includes('private') && !name.startsWith('#') && !name.startsWith('_')) {
|
|
289
|
+
const classSyms = index.symbols.get(symbol.className) || [];
|
|
290
|
+
const cls = classSyms.find(c => c.file === symbol.file &&
|
|
291
|
+
(c.type === 'class' || c.type === 'interface'));
|
|
292
|
+
if (cls) {
|
|
293
|
+
const cmods = cls.modifiers || [];
|
|
294
|
+
if (fileEntry.exports.includes(symbol.className) ||
|
|
295
|
+
cmods.includes('export') || cmods.includes('public')) {
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
180
303
|
/**
|
|
181
304
|
* Find dead code (unused functions/classes)
|
|
182
305
|
* @param {object} index - ProjectIndex instance
|
|
@@ -189,6 +312,7 @@ function deadcode(index, options = {}) {
|
|
|
189
312
|
const results = [];
|
|
190
313
|
let excludedDecorated = 0;
|
|
191
314
|
let excludedExported = 0;
|
|
315
|
+
let excludedExternalContract = 0;
|
|
192
316
|
|
|
193
317
|
// Ensure callee index is built (lazy, reused across operations)
|
|
194
318
|
if (!index.calleeIndex) {
|
|
@@ -220,15 +344,7 @@ function deadcode(index, options = {}) {
|
|
|
220
344
|
for (const name of potentiallyDeadNames) {
|
|
221
345
|
const syms = index.symbols.get(name) || [];
|
|
222
346
|
// Keep the name only if at least one definition is NOT exported
|
|
223
|
-
const allExported = syms.every(s =>
|
|
224
|
-
const fe = index.files.get(s.file);
|
|
225
|
-
const lang = fe?.language;
|
|
226
|
-
if (!fe) return false;
|
|
227
|
-
return fe.exports.includes(name) ||
|
|
228
|
-
(s.modifiers || []).includes('export') ||
|
|
229
|
-
(s.modifiers || []).includes('public') ||
|
|
230
|
-
(langTraits(lang)?.exportVisibility === 'capitalization' && /^[A-Z]/.test(name));
|
|
231
|
-
});
|
|
347
|
+
const allExported = syms.every(s => symbolIsExported(index, s, index.files.get(s.file)));
|
|
232
348
|
if (!allExported) narrowed.add(name);
|
|
233
349
|
}
|
|
234
350
|
potentiallyDeadNames = narrowed;
|
|
@@ -260,10 +376,18 @@ function deadcode(index, options = {}) {
|
|
|
260
376
|
const content = index._readFile(filePath);
|
|
261
377
|
// Fast pre-filter: extract identifiers from file, intersect with target names.
|
|
262
378
|
// One regex pass over content (O(content)) vs O(names × content) substring searches.
|
|
379
|
+
// Names the identifier regex can never produce — quoted member names
|
|
380
|
+
// (zod's `"~validate"`), $-containing JS names — fall back to a substring
|
|
381
|
+
// check, or they would scan as zero-usage and be falsely claimed dead
|
|
382
|
+
// (fix #211: `this["~validate"](data)` is a real usage; the quotes in
|
|
383
|
+
// the symbol name make the substring search self-delimiting).
|
|
263
384
|
const fileIdentifiers = new Set(content.match(/\b[a-zA-Z_]\w*\b/g));
|
|
264
385
|
const namesInFile = [];
|
|
265
386
|
for (const name of potentiallyDeadNames) {
|
|
266
|
-
|
|
387
|
+
const present = /^[a-zA-Z_]\w*$/.test(name)
|
|
388
|
+
? fileIdentifiers.has(name)
|
|
389
|
+
: content.includes(name);
|
|
390
|
+
if (present) namesInFile.push(name);
|
|
267
391
|
}
|
|
268
392
|
if (namesInFile.length === 0) continue;
|
|
269
393
|
const lines = content.split('\n');
|
|
@@ -291,9 +415,37 @@ function deadcode(index, options = {}) {
|
|
|
291
415
|
(hashIdx === 0 || /\s/.test(line[hashIdx - 1]))) continue;
|
|
292
416
|
// Skip if inside a string literal
|
|
293
417
|
if (isInsideString(line, pos)) continue;
|
|
294
|
-
//
|
|
418
|
+
// Property/field access (preceded by '.'), not a
|
|
419
|
+
// call: resolve the RECEIVER (fix #216, express-
|
|
420
|
+
// measured false-dead — `app.all(route, user.load)`
|
|
421
|
+
// is a callback reference to user.js's load, and
|
|
422
|
+
// deleting it breaks the route).
|
|
423
|
+
// - import-bound module receiver → usage scoped
|
|
424
|
+
// to the module's resolved file
|
|
425
|
+
// - this/self/cls receiver → usage scoped to the
|
|
426
|
+
// same file (same-class member reference)
|
|
427
|
+
// - any other receiver (local object literal,
|
|
428
|
+
// instance) → NOT a usage of a standalone
|
|
429
|
+
// symbol (fix #123: `Primitives.Separator` has
|
|
430
|
+
// its own key; must not keep the export alive)
|
|
431
|
+
let dottedScope;
|
|
295
432
|
if (pos > 0 && line[pos - 1] === '.' &&
|
|
296
|
-
(pos + nameLen >= line.length || line[pos + nameLen] !== '('))
|
|
433
|
+
(pos + nameLen >= line.length || line[pos + nameLen] !== '(')) {
|
|
434
|
+
let r = pos - 2;
|
|
435
|
+
while (r >= 0 && /[\w$]/.test(line[r])) r--;
|
|
436
|
+
const receiver = line.slice(r + 1, pos - 1);
|
|
437
|
+
if (!receiver) continue;
|
|
438
|
+
if (['this', 'self', 'cls'].includes(receiver)) {
|
|
439
|
+
dottedScope = 'same-file';
|
|
440
|
+
} else {
|
|
441
|
+
const binding = (fileEntry.importBindings || [])
|
|
442
|
+
.find(b => b.name === receiver);
|
|
443
|
+
const resolved = binding && fileEntry.moduleResolved &&
|
|
444
|
+
fileEntry.moduleResolved[binding.module];
|
|
445
|
+
if (!resolved) continue;
|
|
446
|
+
dottedScope = resolved;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
297
449
|
// Skip object literal key: name followed by ':' (not '::' for Rust paths)
|
|
298
450
|
const afterChar = pos + nameLen < line.length ? line[pos + nameLen] : '';
|
|
299
451
|
const afterChar2 = pos + nameLen + 1 < line.length ? line[pos + nameLen + 1] : '';
|
|
@@ -303,7 +455,8 @@ function deadcode(index, options = {}) {
|
|
|
303
455
|
usageIndex.get(name).push({
|
|
304
456
|
file: filePath,
|
|
305
457
|
line: i + 1,
|
|
306
|
-
relativePath: fileEntry.relativePath
|
|
458
|
+
relativePath: fileEntry.relativePath,
|
|
459
|
+
...(dottedScope && { dottedScope })
|
|
307
460
|
});
|
|
308
461
|
break; // one match per line is enough for deadcode
|
|
309
462
|
}
|
|
@@ -365,12 +518,7 @@ function deadcode(index, options = {}) {
|
|
|
365
518
|
continue;
|
|
366
519
|
}
|
|
367
520
|
|
|
368
|
-
const isExported =
|
|
369
|
-
fileEntry.exports.includes(name) ||
|
|
370
|
-
mods.includes('export') ||
|
|
371
|
-
mods.includes('public') ||
|
|
372
|
-
(langTraits(lang)?.exportVisibility === 'capitalization' && /^[A-Z]/.test(name))
|
|
373
|
-
);
|
|
521
|
+
const isExported = symbolIsExported(index, symbol, fileEntry);
|
|
374
522
|
|
|
375
523
|
// Skip exported unless requested
|
|
376
524
|
if (isExported && !options.includeExported) {
|
|
@@ -389,8 +537,15 @@ function deadcode(index, options = {}) {
|
|
|
389
537
|
// Filter out usages that are at the definition location
|
|
390
538
|
// nameLine: when decorators/annotations are present, startLine is the decorator line
|
|
391
539
|
// but the name identifier is on a different line (nameLine). Check both.
|
|
540
|
+
// Dotted usages (fix #216) are scoped to the file their receiver
|
|
541
|
+
// resolves to — `user.load` keeps user.js's load alive, never an
|
|
542
|
+
// unrelated module's same-name symbol.
|
|
392
543
|
let nonDefUsages = allUsages.filter(u =>
|
|
393
|
-
!(u.file === symbol.file && (u.line === symbol.startLine || u.line === symbol.nameLine))
|
|
544
|
+
!(u.file === symbol.file && (u.line === symbol.startLine || u.line === symbol.nameLine)) &&
|
|
545
|
+
(!u.dottedScope ||
|
|
546
|
+
(u.dottedScope === 'same-file'
|
|
547
|
+
? u.file === symbol.file
|
|
548
|
+
: u.dottedScope === symbol.relativePath))
|
|
394
549
|
);
|
|
395
550
|
|
|
396
551
|
// For exported symbols in --include-exported mode, also filter out export-site
|
|
@@ -418,6 +573,16 @@ function deadcode(index, options = {}) {
|
|
|
418
573
|
const totalUsages = nonDefUsages.length;
|
|
419
574
|
|
|
420
575
|
if (totalUsages === 0) {
|
|
576
|
+
// External-contract override: the method may be invoked through
|
|
577
|
+
// an out-of-tree base class UCN can't index (deadcode analog of
|
|
578
|
+
// fix #210). A zero usage count is not evidence of deadness.
|
|
579
|
+
// Hidden by default; revealed under --include-exported, since
|
|
580
|
+
// it is external-reachable surface, not internal dead code.
|
|
581
|
+
const isExternalContract = overridesOutOfTreeBase(index, symbol);
|
|
582
|
+
if (isExternalContract && !options.includeExported) {
|
|
583
|
+
excludedExternalContract++;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
421
586
|
// Collect decorators/annotations for hint display
|
|
422
587
|
// Python: symbol.decorators (e.g., ['app.route("/path")', 'login_required'])
|
|
423
588
|
// Java/Rust/Go: symbol.modifiers may contain annotations (e.g., 'bean', 'scheduled')
|
|
@@ -428,6 +593,28 @@ function deadcode(index, options = {}) {
|
|
|
428
593
|
? mods.filter(m => !javaKw.has(m))
|
|
429
594
|
: [];
|
|
430
595
|
|
|
596
|
+
// Interface/trait member declarations are contract surface, not
|
|
597
|
+
// executable code: "unreferenced" is true, but deleting one
|
|
598
|
+
// changes the API contract rather than removing dead logic (Go
|
|
599
|
+
// marker interfaces exist SOLELY as uncallable declarations —
|
|
600
|
+
// grpc-go-measured: its entire default deadcode output was this
|
|
601
|
+
// family). Label so the claim self-explains (fix #211). Only
|
|
602
|
+
// body-less declarations qualify — Java `default` and Rust
|
|
603
|
+
// default-bodied trait methods are executable code, detected
|
|
604
|
+
// generically by a brace in the member's source range.
|
|
605
|
+
const declaredOn = (() => {
|
|
606
|
+
if (!symbol.className) return null;
|
|
607
|
+
const enclosing = (index.symbols.get(symbol.className) || []).find(c =>
|
|
608
|
+
c.file === symbol.file && (c.type === 'interface' || c.type === 'trait'));
|
|
609
|
+
if (!enclosing) return null;
|
|
610
|
+
try {
|
|
611
|
+
const content = index._readFile(symbol.file);
|
|
612
|
+
const range = content.split('\n').slice(symbol.startLine - 1, symbol.endLine).join('\n');
|
|
613
|
+
if (range.includes('{')) return null;
|
|
614
|
+
} catch { return null; }
|
|
615
|
+
return { kind: enclosing.type, name: symbol.className };
|
|
616
|
+
})();
|
|
617
|
+
|
|
431
618
|
results.push({
|
|
432
619
|
name: symbol.name,
|
|
433
620
|
type: symbol.type,
|
|
@@ -437,7 +624,9 @@ function deadcode(index, options = {}) {
|
|
|
437
624
|
isExported,
|
|
438
625
|
usageCount: 0,
|
|
439
626
|
...(decorators.length > 0 && { decorators }),
|
|
440
|
-
...(annotations.length > 0 && { annotations })
|
|
627
|
+
...(annotations.length > 0 && { annotations }),
|
|
628
|
+
...(declaredOn && { declaredOn }),
|
|
629
|
+
...(isExternalContract && { externalContract: true })
|
|
441
630
|
});
|
|
442
631
|
}
|
|
443
632
|
}
|
|
@@ -452,6 +641,7 @@ function deadcode(index, options = {}) {
|
|
|
452
641
|
// Attach exclusion counts as array properties (backwards-compatible)
|
|
453
642
|
results.excludedDecorated = excludedDecorated;
|
|
454
643
|
results.excludedExported = excludedExported;
|
|
644
|
+
results.excludedExternalContract = excludedExternalContract;
|
|
455
645
|
|
|
456
646
|
return results;
|
|
457
647
|
} finally { index._endOp(); }
|
package/core/execute.js
CHANGED
|
@@ -292,6 +292,7 @@ const HANDLERS = {
|
|
|
292
292
|
const result = index.context(p.name, {
|
|
293
293
|
...buildCallerOptions(p),
|
|
294
294
|
unreachableOnly: !!p.unreachableOnly,
|
|
295
|
+
all: !!p.all,
|
|
295
296
|
});
|
|
296
297
|
if (!result) return { ok: false, error: `Symbol "${p.name}" not found.` };
|
|
297
298
|
const tNote = truncationNote(index);
|
|
@@ -337,6 +338,7 @@ const HANDLERS = {
|
|
|
337
338
|
...buildCallerOptions(p),
|
|
338
339
|
depth: depthVal ?? 3,
|
|
339
340
|
all: p.all || depthVal !== undefined,
|
|
341
|
+
expandUnverified: !!p.expandUnverified,
|
|
340
342
|
});
|
|
341
343
|
if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
|
|
342
344
|
const note = treeNote(result);
|
|
@@ -358,6 +360,7 @@ const HANDLERS = {
|
|
|
358
360
|
...buildCallerOptions(p),
|
|
359
361
|
depth: depthVal ?? 5,
|
|
360
362
|
all: p.all || depthVal !== undefined,
|
|
363
|
+
expandUnverified: !!p.expandUnverified,
|
|
361
364
|
});
|
|
362
365
|
if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
|
|
363
366
|
const note = treeNote(result);
|
|
@@ -396,6 +399,7 @@ const HANDLERS = {
|
|
|
396
399
|
...buildCallerOptions(p),
|
|
397
400
|
depth: depthVal ?? 3,
|
|
398
401
|
all: p.all || depthVal !== undefined,
|
|
402
|
+
expandUnverified: !!p.expandUnverified,
|
|
399
403
|
});
|
|
400
404
|
if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
|
|
401
405
|
const note = treeNote(result);
|
|
@@ -747,9 +751,10 @@ const HANDLERS = {
|
|
|
747
751
|
if (limit && limit > 0 && Array.isArray(result) && result.length > limit) {
|
|
748
752
|
note = limitNote(limit, result.length);
|
|
749
753
|
const sliced = result.slice(0, limit);
|
|
750
|
-
// Preserve custom properties (excludedExported, excludedDecorated) from deadcode()
|
|
754
|
+
// Preserve custom properties (excludedExported, excludedDecorated, excludedExternalContract) from deadcode()
|
|
751
755
|
if (result.excludedExported != null) sliced.excludedExported = result.excludedExported;
|
|
752
756
|
if (result.excludedDecorated != null) sliced.excludedDecorated = result.excludedDecorated;
|
|
757
|
+
if (result.excludedExternalContract != null) sliced.excludedExternalContract = result.excludedExternalContract;
|
|
753
758
|
result = sliced;
|
|
754
759
|
}
|
|
755
760
|
const tNote = truncationNote(index);
|
package/core/graph-build.js
CHANGED
|
@@ -105,6 +105,13 @@ function buildImportGraph(index) {
|
|
|
105
105
|
for (const [filePath, fileEntry] of index.files) {
|
|
106
106
|
const importedFiles = new Set();
|
|
107
107
|
const seenModules = new Set();
|
|
108
|
+
// Per-module resolution map (fix #209): module string → resolved
|
|
109
|
+
// project file (ROOT-RELATIVE — fileEntry persists in the cache, so
|
|
110
|
+
// paths must stay portable). Lets query-time code answer "which FILE
|
|
111
|
+
// does the module behind this import binding live in" — file-level
|
|
112
|
+
// importGraph edges can't (a file importing the target for OTHER
|
|
113
|
+
// names is not evidence about THIS name's module).
|
|
114
|
+
const moduleResolved = {};
|
|
108
115
|
|
|
109
116
|
for (const importModule of fileEntry.imports) {
|
|
110
117
|
// Skip null modules (e.g., dynamic include! macros in Rust)
|
|
@@ -128,6 +135,7 @@ function buildImportGraph(index) {
|
|
|
128
135
|
}
|
|
129
136
|
|
|
130
137
|
if (resolved && index.files.has(resolved)) {
|
|
138
|
+
moduleResolved[importModule] = path.relative(index.root, resolved);
|
|
131
139
|
// For Go, a package import means all files in that directory are dependencies
|
|
132
140
|
// (Go packages span multiple files in the same directory)
|
|
133
141
|
const filesToLink = [resolved];
|
|
@@ -154,6 +162,7 @@ function buildImportGraph(index) {
|
|
|
154
162
|
}
|
|
155
163
|
|
|
156
164
|
index.importGraph.set(filePath, importedFiles);
|
|
165
|
+
fileEntry.moduleResolved = moduleResolved;
|
|
157
166
|
}
|
|
158
167
|
}
|
|
159
168
|
|
|
@@ -181,8 +190,15 @@ function buildInheritanceGraph(index) {
|
|
|
181
190
|
}
|
|
182
191
|
|
|
183
192
|
if (symbol.extends) {
|
|
184
|
-
// Parse comma-separated parents (Python MRO: "Flyable, Swimmable")
|
|
185
|
-
|
|
193
|
+
// Parse comma-separated parents (Python MRO: "Flyable, Swimmable").
|
|
194
|
+
// Commas inside type arguments do NOT separate parents:
|
|
195
|
+
// `extends Base<string, object>` is ONE parent `Base`, and
|
|
196
|
+
// `class C(Mapping[str, int], Base)` is `Mapping` + `Base`.
|
|
197
|
+
// The naive split made every generically-extended class
|
|
198
|
+
// parentless (fix #214 — zod's whole ZodType hierarchy had no
|
|
199
|
+
// ancestor edges, measured: 12 true base-class dispatch edges
|
|
200
|
+
// demoted because `Base<string` never equals `Base`).
|
|
201
|
+
const parents = splitParentList(symbol.extends);
|
|
186
202
|
|
|
187
203
|
// Resolve aliased parent names via import aliases
|
|
188
204
|
// e.g., const { BaseHandler: Handler } = require('./base')
|
|
@@ -221,4 +237,30 @@ function buildInheritanceGraph(index) {
|
|
|
221
237
|
}
|
|
222
238
|
}
|
|
223
239
|
|
|
224
|
-
|
|
240
|
+
/**
|
|
241
|
+
* Split an extends/bases clause on TOP-LEVEL commas only and strip each
|
|
242
|
+
* parent's trailing type-argument suffix: `Base<string, object>` → `Base`,
|
|
243
|
+
* `Mapping[str, int], Flyable` → `Mapping`, `Flyable`. Depth-tracks <>, [],
|
|
244
|
+
* and () so argument commas never split (fix #214).
|
|
245
|
+
*/
|
|
246
|
+
function splitParentList(clause) {
|
|
247
|
+
const parts = [];
|
|
248
|
+
let depth = 0;
|
|
249
|
+
let current = '';
|
|
250
|
+
for (const ch of String(clause)) {
|
|
251
|
+
if (ch === '<' || ch === '[' || ch === '(') depth++;
|
|
252
|
+
else if (ch === '>' || ch === ']' || ch === ')') depth = Math.max(0, depth - 1);
|
|
253
|
+
if (ch === ',' && depth === 0) {
|
|
254
|
+
parts.push(current);
|
|
255
|
+
current = '';
|
|
256
|
+
} else {
|
|
257
|
+
current += ch;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
parts.push(current);
|
|
261
|
+
return parts
|
|
262
|
+
.map(s => s.trim().replace(/[<[(].*$/s, '').trim())
|
|
263
|
+
.filter(Boolean);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = { buildDirIndex, buildImportGraph, buildInheritanceGraph, splitParentList, _resolveJavaPackageImport };
|