ucn 4.2.3 → 5.0.2

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