ucn 4.0.2 → 4.1.0

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.
Files changed (46) hide show
  1. package/.claude/skills/ucn/SKILL.md +31 -7
  2. package/README.md +89 -50
  3. package/cli/index.js +199 -94
  4. package/core/account.js +3 -1
  5. package/core/analysis.js +322 -304
  6. package/core/bridge.js +13 -8
  7. package/core/cache.js +109 -19
  8. package/core/callers.js +969 -77
  9. package/core/check.js +19 -8
  10. package/core/deadcode.js +368 -40
  11. package/core/discovery.js +31 -11
  12. package/core/entrypoints.js +149 -17
  13. package/core/execute.js +330 -43
  14. package/core/graph-build.js +61 -10
  15. package/core/graph.js +282 -61
  16. package/core/imports.js +72 -3
  17. package/core/output/analysis-ext.js +70 -10
  18. package/core/output/analysis.js +52 -33
  19. package/core/output/check.js +4 -1
  20. package/core/output/endpoints.js +8 -3
  21. package/core/output/extraction.js +12 -1
  22. package/core/output/find.js +23 -9
  23. package/core/output/graph.js +32 -9
  24. package/core/output/refactoring.js +147 -49
  25. package/core/output/reporting.js +104 -5
  26. package/core/output/search.js +30 -3
  27. package/core/output/shared.js +31 -4
  28. package/core/output/tracing.js +22 -11
  29. package/core/parser.js +8 -6
  30. package/core/project.js +167 -7
  31. package/core/registry.js +20 -16
  32. package/core/reporting.js +131 -13
  33. package/core/search.js +270 -55
  34. package/core/shared.js +240 -1
  35. package/core/stacktrace.js +23 -1
  36. package/core/tracing.js +278 -36
  37. package/core/verify.js +352 -349
  38. package/languages/go.js +29 -17
  39. package/languages/index.js +56 -0
  40. package/languages/java.js +133 -16
  41. package/languages/javascript.js +215 -27
  42. package/languages/python.js +113 -47
  43. package/languages/rust.js +89 -26
  44. package/languages/utils.js +35 -7
  45. package/mcp/server.js +69 -30
  46. package/package.json +4 -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 existing diffImpact result has `callers` already
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
- callers = index.findCallers(fn.name, { includeMethods: true, includeUncertain: false }) || [];
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 callers AND not detected as an entry point.
99
- // (A new helper called by another new helper is still NOT orphan; reachability
100
- // is rebuilt as the user iterates, and false positives here cause noise.)
101
- if (item.kind === 'added' && callers.length === 0) {
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 they don't have line/file but still matter
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
- 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;
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
- const callableTypes = ['function', 'method', 'static', 'public', 'abstract', 'constructor'];
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 => callableTypes.includes(s.type))) {
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
- const lines = content.split('\n');
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
- if (isInsideString(line, pos)) continue;
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
- if (!receiver) continue;
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 (!resolved) continue;
446
- dottedScope = resolved;
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 !== ':') continue;
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-function/class types (callableTypes defined above)
472
- if (!callableTypes.includes(symbol.type)) {
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
- if (index.calleeIndex.has(name)) {
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
- : u.dottedScope === symbol.relativePath))
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
- // Check if same-file usage is on an export line
558
- let content;
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
- const isExternalContract = overridesOutOfTreeBase(index, symbol);
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.localeCompare(b.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 };