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.
@@ -9,24 +9,56 @@
9
9
 
10
10
  // Resolution types ordered from most to least confident
11
11
  const RESOLUTION = {
12
- EXACT_BINDING: 'exact-binding',
13
- SAME_CLASS: 'same-class',
14
- RECEIVER_HINT: 'receiver-hint',
15
- SCOPE_MATCH: 'scope-match',
16
- NAME_ONLY: 'name-only',
17
- UNCERTAIN: 'uncertain',
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]: 0.98,
23
- [RESOLUTION.SAME_CLASS]: 0.92,
24
- [RESOLUTION.RECEIVER_HINT]: 0.80,
25
- [RESOLUTION.SCOPE_MATCH]: 0.65,
26
- [RESOLUTION.NAME_ONLY]: 0.40,
27
- [RESOLUTION.UNCERTAIN]: 0.25,
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
- return { confidence: SCORES[RESOLUTION.SCOPE_MATCH], resolution: RESOLUTION.SCOPE_MATCH, evidence: reasons };
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
- if (fileIdentifiers.has(name)) namesInFile.push(name);
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
- // Skip property/field access: preceded by '.' unless followed by '(' (method call)
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] !== '(')) continue;
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 = fileEntry && (
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);
@@ -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
- const parents = symbol.extends.split(',').map(s => s.trim()).filter(Boolean);
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
- module.exports = { buildDirIndex, buildImportGraph, buildInheritanceGraph, _resolveJavaPackageImport };
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 };