ucn 5.5.2 → 5.6.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 (74) hide show
  1. package/.claude/skills/ucn/SKILL.md +35 -12
  2. package/.claude/skills/ucn/references/commands.md +21 -17
  3. package/.claude/skills/ucn/references/resolution-rules.md +4 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +4 -4
  5. package/CHANGELOG.md +474 -0
  6. package/README.md +71 -29
  7. package/cli/flags.js +184 -0
  8. package/cli/index.js +138 -324
  9. package/core/account.js +44 -87
  10. package/core/analysis.js +273 -107
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +29 -65
  13. package/core/cache.js +101 -16
  14. package/core/callers.js +352 -70
  15. package/core/capture.js +331 -0
  16. package/core/change-kinds.js +888 -0
  17. package/core/check.js +1117 -139
  18. package/core/client-receivers.js +189 -0
  19. package/core/command-contracts.js +7 -6
  20. package/core/constructed-class.js +1195 -0
  21. package/core/contract-membership.js +142 -0
  22. package/core/deadcode.js +388 -85
  23. package/core/deletion.js +401 -0
  24. package/core/discovery.js +104 -9
  25. package/core/entrypoints.js +12 -6
  26. package/core/evidence-bands.js +68 -0
  27. package/core/evidence-bands.json +44 -0
  28. package/core/execute.js +172 -161
  29. package/core/hot-summary.js +241 -0
  30. package/core/index-ir.js +3 -1
  31. package/core/inprocess-clients.js +41 -15
  32. package/core/ir.js +5 -1
  33. package/core/member-signatures.js +789 -0
  34. package/core/output/analysis-ext.js +20 -10
  35. package/core/output/analysis.js +98 -60
  36. package/core/output/check.js +98 -37
  37. package/core/output/doctor.js +34 -1
  38. package/core/output/find.js +2 -1
  39. package/core/output/lines.js +63 -3
  40. package/core/output/public.js +30 -11
  41. package/core/output/refactoring.js +32 -0
  42. package/core/output/reporting.js +21 -2
  43. package/core/output/search.js +11 -2
  44. package/core/output/tracing.js +35 -7
  45. package/core/output-budget.js +124 -57
  46. package/core/parallel-build.js +3 -3
  47. package/core/project.js +106 -23
  48. package/core/provenance-type.js +12 -5
  49. package/core/public-command.js +35 -9
  50. package/core/python-modules.js +6 -0
  51. package/core/reflection.js +166 -23
  52. package/core/registry.js +623 -72
  53. package/core/reporting.js +69 -34
  54. package/core/request-helpers.js +63 -7
  55. package/core/review-items.js +75 -0
  56. package/core/search.js +89 -128
  57. package/core/shared.js +0 -45
  58. package/core/snapshot.js +350 -0
  59. package/core/source-universe.js +707 -0
  60. package/core/test-units.js +3624 -0
  61. package/core/tracing.js +150 -96
  62. package/core/verify.js +27 -11
  63. package/languages/adapter.js +8 -3
  64. package/languages/c-family.js +12 -0
  65. package/languages/csharp.js +634 -0
  66. package/languages/go.js +120 -0
  67. package/languages/index.js +422 -4
  68. package/languages/java.js +712 -0
  69. package/languages/javascript.js +1460 -2
  70. package/languages/lexical-scope.js +68 -0
  71. package/languages/python.js +1672 -1
  72. package/languages/rust.js +91 -0
  73. package/mcp/server.js +44 -110
  74. package/package.json +11 -6
package/core/deadcode.js CHANGED
@@ -14,8 +14,11 @@ const { isFrameworkEntrypoint } = require('./entrypoints');
14
14
  const { splitParentList } = require('./graph-build');
15
15
  const { isOverrideMarked, codeUnitCompare, lineInRanges, maskBlockComments, escapeRegExp } = require('./shared');
16
16
  const { projectComputedDispatch, projectReflectionSites } = require('./ast-analysis');
17
- const { reflectionPatternReferences } = require('./reflection');
18
- const { protocolMemberOf, protocolTypeOf, typedLiteralContract, rustProjectTraits } = require('./contract-membership');
17
+ const { reflectionPatternReferences, reflectionLiteralNames } = require('./reflection');
18
+ const { protocolMemberOf, protocolTypeOf, typedLiteralContract, rustProjectTraits,
19
+ hierarchySlotClosure, goContractClosure, structuralContractsRequiring,
20
+ requirementIn, resetContractIndex } = require('./contract-membership');
21
+ const { projectKind, downstreamExposureBlocks } = require('./deletion');
19
22
 
20
23
  const _CLASS_KINDS = ['class', 'struct', 'interface', 'trait', 'record'];
21
24
 
@@ -245,27 +248,170 @@ function typedLiteralExternalMember(index, symbol) {
245
248
  return !!typedLiteralContract(index, symbol)?.external;
246
249
  }
247
250
 
248
- // Go satisfies interfaces implicitly, so no `implements` edge exists for
249
- // compiler/stdlib callbacks. A zero textual usage for one of these exact
250
- // method shapes is not evidence of deadness (sort.Interface, http.Handler,
251
- // io.Reader, encoding marshalers, database/sql contracts, errors helpers).
252
- const GO_PROTOCOL_METHODS = new Map([
253
- ['Len', 0], ['Less', 2], ['Swap', 2], ['ServeHTTP', 2],
254
- ['Error', 0], ['String', 0], ['Read', 1], ['Write', 1], ['Close', 0],
255
- ['Seek', 2], ['ReadAt', 2], ['WriteAt', 2], ['ReadByte', 0],
256
- ['WriteByte', 1], ['ReadRune', 0], ['UnreadByte', 0], ['UnreadRune', 0],
257
- ['MarshalJSON', 0], ['UnmarshalJSON', 1], ['MarshalText', 0],
258
- ['UnmarshalText', 1], ['MarshalBinary', 0], ['UnmarshalBinary', 1],
259
- ['Scan', 1], ['Value', 0], ['LogValue', 0], ['Unwrap', 0],
260
- ['Is', 1], ['As', 1],
261
- ]);
251
+ /**
252
+ * A member a subtype or a structural satisfier MUST supply. A parser fact
253
+ * (`requiredMember`), read from the declaration's own syntax: an interface
254
+ * or object-type signature that is not optional, an abstract member, a
255
+ * trait method without a body, a Go interface method element, a Python
256
+ * abstract or Protocol member, a C++ pure virtual. A default-bodied
257
+ * interface or trait member is an implementation, not a requirement.
258
+ */
259
+ function isRequiredDeclaration(member) {
260
+ return member.requiredMember === true;
261
+ }
262
+
263
+ /**
264
+ * Is this member held in place by a contract of the PROJECT? Invisible to a
265
+ * name count, because a same-name declaration line is a declaration, never
266
+ * a use. Returns the reason, or null:
267
+ *
268
+ * 'required-slot' it is the implementation a type owes to an
269
+ * interface, abstract class or trait it declares
270
+ * (deleting it leaves the type incomplete); or,
271
+ * where interfaces are satisfied implicitly by
272
+ * method set, the method that makes a type satisfy
273
+ * a project interface; or a descendant declares
274
+ * that it overrides it (deleting it leaves an
275
+ * override of nothing);
276
+ * 'structural-contract' its type has every member a project interface,
277
+ * object type or protocol requires, so the type
278
+ * can be used as that contract without naming it.
279
+ *
280
+ * A plain override of a concrete method is neither: with it gone, calls
281
+ * fall back to the inherited one.
282
+ */
283
+ function requiredByProjectContract(index, symbol) {
284
+ if (!symbol.className) {
285
+ // A member of an object literal declared against a project
286
+ // interface (`const impl: Contract = { slot() {} }`): the literal
287
+ // owes the interface that member unless the interface says it is
288
+ // optional. An out-of-project supertype may require it too.
289
+ const declaredAs = typedLiteralContract(index, symbol)?.projectInterface;
290
+ if (!declaredAs) return null;
291
+ const contracts = (index.symbols.get(declaredAs.name) || []).filter(type =>
292
+ _CLASS_KINDS.includes(type.type) && (!declaredAs.file || type.file === declaredAs.file));
293
+ const requirement = requirementIn(index, contracts, symbol.name);
294
+ return requirement === 'required' || requirement === 'unknown' ? 'required-slot' : null;
295
+ }
296
+ // An implementation written against this contract BY NAME holds the
297
+ // member it implements, required or default-bodied: Rust's
298
+ // `impl Contract for T { fn slot() }` and C#'s `void IContract.Slot()`
299
+ // are errors once the contract has no such member. Matched on the
300
+ // contract's bare name: wider than identity, and only ever withholds.
301
+ const contractNamedBy = member => {
302
+ const named = member.traitName || member.explicitInterface;
303
+ return named ? String(named).replace(/<.*$/s, '').split('::').pop().split('.').pop().trim() : null;
304
+ };
305
+ if ((index.symbols.get(symbol.name) || []).some(member =>
306
+ member !== symbol && contractNamedBy(member) === symbol.className)) return 'required-slot';
307
+ // A requirement is the contract, not an implementation of one: only a
308
+ // descendant that says it overrides it (below) holds it in place.
309
+ const isRequirement = isRequiredDeclaration(symbol) ||
310
+ (index.symbols.get(symbol.className) || []).some(type =>
311
+ (type.type === 'interface' || type.type === 'trait') &&
312
+ path.dirname(type.file) === path.dirname(symbol.file));
313
+ if (!isRequirement) {
314
+ // Interfaces satisfied implicitly by method set: the closure joins
315
+ // a project interface when the type satisfies it or is converted
316
+ // to it.
317
+ const implicit = goContractClosure(index, symbol);
318
+ const joined = implicit && implicit.interfaceNames;
319
+ if (joined && (joined.size ?? joined.length ?? 0) > 0) return 'required-slot';
320
+ }
321
+ const closure = hierarchySlotClosure(index, symbol);
322
+ if (closure) {
323
+ if (!isRequirement && (closure.ancestors || []).some(ancestor =>
324
+ (ancestor.members || []).some(member => isRequiredDeclaration(member)))) return 'required-slot';
325
+ if ((closure.descendants || []).some(descendant =>
326
+ (descendant.members || []).some(member => isOverrideMarked(member)))) return 'required-slot';
327
+ }
328
+ if (!isRequirement && structuralContractsRequiring(index, symbol).length > 0) return 'structural-contract';
329
+ return null;
330
+ }
262
331
 
263
- function goImplicitExternalContract(symbol) {
264
- if (!symbol.className || !GO_PROTOCOL_METHODS.has(symbol.name)) return false;
332
+ /**
333
+ * What holds a declaration in place whatever names it: facts about the
334
+ * declaration itself, the same for an audit, a deletion and an addition.
335
+ * runtimeContract the language runtime or the compiler invokes it;
336
+ * externalContract it implements or overrides something defined outside
337
+ * the project;
338
+ * projectContract a contract of the project requires it (the reason:
339
+ * 'required-slot' or 'structural-contract'), or null;
340
+ * registered a decorator or a framework registration may invoke
341
+ * it without naming it.
342
+ */
343
+ function contractFactsOf(index, declared, lang) {
344
+ return {
345
+ runtimeContract: !!(protocolMemberOf(index, declared) || protocolTypeOf(index, declared)),
346
+ externalContract: !!(CLASS_AUDIT_KINDS.includes(declared.type)
347
+ ? _heritageReachesExternalBase(index, declared, lang, false)
348
+ : overridesOutOfTreeBase(index, declared) ||
349
+ typedLiteralExternalMember(index, declared) ||
350
+ implicitStandardContract(declared, lang)),
351
+ projectContract: requiredByProjectContract(index, declared),
352
+ registered: !!(isFrameworkEntrypoint(declared, index) || hasUnknownRegistrationDecorator(declared)),
353
+ };
354
+ }
355
+
356
+ /**
357
+ * An absent declaration, placed back in its owner for the questions that
358
+ * depend on where a member stands (which type owns it, what that type
359
+ * implements, what overrides it). Its recorded lines describe a revision
360
+ * the index does not hold; the owner's lines are the index's. Returns the
361
+ * record itself when it has no owner type, a re-anchored copy when its
362
+ * owner is the one type of that name in its file, the record unchanged when
363
+ * the owner is declared elsewhere (a receiver method, an impl block, an
364
+ * out-of-class definition), and null when several types of that name share
365
+ * the file and nothing says which one it stood in.
366
+ */
367
+ function anchoredInOwner(index, symbol) {
368
+ if (!symbol.className) return symbol;
369
+ const owners = (index.symbols.get(symbol.className) || []).filter(type =>
370
+ _CLASS_KINDS.includes(type.type) && type.file === symbol.file);
371
+ if (owners.length > 1) return null;
372
+ if (owners.length === 0) return { ...symbol };
373
+ const line = owners[0].endLine || owners[0].startLine;
374
+ const anchored = { ...symbol, startLine: line, endLine: line };
375
+ delete anchored.nameLine;
376
+ delete anchored.sourceSpan;
377
+ return anchored;
378
+ }
379
+
380
+ /**
381
+ * Run `question` with an absent declaration declared again: the record
382
+ * joins the name's definitions for the duration, and every index derived
383
+ * from them (contract closures, class identity) is rebuilt before and
384
+ * dropped after, so nothing computed with it outlives the call.
385
+ */
386
+ function withDeclared(index, declared, question) {
387
+ const { resetClassIdentity } = require('./class-identity');
388
+ const existing = index.symbols.get(declared.name);
389
+ if (existing) existing.push(declared);
390
+ else index.symbols.set(declared.name, [declared]);
391
+ resetContractIndex(index);
392
+ resetClassIdentity(index);
393
+ try {
394
+ return question(declared);
395
+ } finally {
396
+ if (existing) existing.splice(existing.lastIndexOf(declared), 1);
397
+ else index.symbols.delete(declared.name);
398
+ resetContractIndex(index);
399
+ resetClassIdentity(index);
400
+ }
401
+ }
402
+
403
+ /**
404
+ * A method shaped like a member of a standard contract the language lets a
405
+ * type satisfy without naming it (trait `implicitStandardContracts`: Go's
406
+ * sort.Interface, http.Handler, io.Reader, the encoding marshalers ...).
407
+ */
408
+ function implicitStandardContract(symbol, lang) {
409
+ const methods = langTraits(lang)?.implicitStandardContracts;
410
+ if (!methods || !symbol.className || !methods.has(symbol.name)) return false;
265
411
  const arity = Array.isArray(symbol.paramsStructured)
266
412
  ? symbol.paramsStructured.length
267
413
  : null;
268
- return arity == null || arity === GO_PROTOCOL_METHODS.get(symbol.name);
414
+ return arity == null || arity === methods.get(symbol.name);
269
415
  }
270
416
 
271
417
  // Symbol types whose definition NAME line provably cannot reference a
@@ -364,53 +510,37 @@ function nameOnlySelfRecursive(index, name) {
364
510
  * part of both text and JSON output.
365
511
  */
366
512
  function scanDeadcodeCoverage(index, names) {
367
- const matchedNames = new Set();
368
- const files = new Set();
369
- const unreadableFiles = new Set();
370
513
  const reasons = {};
371
- let unknownCoverage = false;
372
514
  let parseRecoveries = 0;
373
515
 
374
- const recordReason = (reason) => {
375
- const key = reason || 'unknown';
376
- reasons[key] = (reasons[key] || 0) + 1;
377
- };
378
- const scanContent = (content) => {
379
- const identifiers = new Set(String(content).match(/\b[a-zA-Z_]\w*\b/g) || []);
380
- for (const name of names) {
381
- if (/^[a-zA-Z_]\w*$/.test(name)
382
- ? identifiers.has(name)
383
- : String(content).includes(name)) matchedNames.add(name);
384
- }
385
- };
386
- const scanFile = (absPath, rel, reason) => {
387
- files.add(rel);
388
- recordReason(reason);
389
- try {
390
- const stat = fs.statSync(absPath);
391
- if (!stat.isFile()) {
392
- unknownCoverage = true;
393
- return;
394
- }
395
- scanContent(index._readFile(absPath));
396
- } catch (_) {
397
- unreadableFiles.add(rel);
398
- unknownCoverage = true;
399
- }
400
- };
401
-
516
+ // One classification and one scanner for every command
517
+ // (core/source-universe.js): parse failures and text-only source are
518
+ // searched for the candidate names in a single pass per file; a path
519
+ // nothing can be read from withdraws every claim.
520
+ const sourceUniverse = require('./source-universe');
521
+ const failed = [];
402
522
  for (const failedPath of index.failedFiles || []) {
403
523
  if (index.files.has(failedPath)) continue;
404
- scanFile(failedPath, path.relative(index.root, failedPath), 'parse-failure');
524
+ failed.push({
525
+ absolutePath: failedPath,
526
+ relativePath: path.relative(index.root, failedPath),
527
+ reason: 'parse-failure',
528
+ });
405
529
  }
406
- for (const skipped of index.unsupportedFiles || []) {
407
- const rel = skipped.relativePath;
408
- scanFile(path.join(index.root, rel), rel, 'unsupported-language');
409
- }
410
- for (const issue of index.discoveryIssues || []) {
411
- const rel = issue.relativePath || '.';
412
- scanFile(path.join(index.root, rel), rel, issue.reason || 'discovery-gap');
530
+ const scanned = sourceUniverse.scanTextOnlyNames(index, names, failed);
531
+ const matchedNames = scanned.matchedNames;
532
+ const files = new Set(scanned.files);
533
+ const unreadableFiles = new Set(scanned.unreadableFiles);
534
+ for (const [reason, count] of Object.entries(scanned.reasons)) reasons[reason] = count;
535
+ const { opaque } = sourceUniverse.universe(index);
536
+ for (const entry of opaque) {
537
+ files.add(entry.relativePath);
538
+ const key = entry.reason || 'discovery-gap';
539
+ reasons[key] = (reasons[key] || 0) + 1;
540
+ if (entry.reason === 'unreadable-file') unreadableFiles.add(entry.relativePath);
413
541
  }
542
+ const unknownCoverage = opaque.length > 0 || unreadableFiles.size > 0;
543
+
414
544
  for (const [, fileEntry] of index.files) {
415
545
  if (fileEntry.parseRecovery || fileEntry.parseError) parseRecoveries++;
416
546
  }
@@ -748,12 +878,78 @@ function deadcode(index, options = {}) {
748
878
  index._beginOp();
749
879
  try {
750
880
  const results = [];
881
+ // This pipeline IS the deletion eligibility rule. The audit runs it over
882
+ // every name; a deletion assessment (core/deletion.js) runs the same
883
+ // code over one name and asks why each declaration was or was not
884
+ // claimed. `names` restricts the pass, `explain(symbol, reason, detail)`
885
+ // reports every outcome (detail: the audit row of a claim, the sites of
886
+ // a use). Neither changes what is claimed.
887
+ const onlyNames = options.names instanceof Set ? options.names : null;
888
+ const explain = typeof options.explain === 'function' ? options.explain : null;
889
+ const withhold = (symbol, reason, detail) => { if (explain) explain(symbol, reason, detail); };
890
+ // Assessment only: names whose every call site the caller has shown to
891
+ // lie inside the declaration being assessed. Those calls are deleted
892
+ // with it, so the name goes on to the text scan under the recursion
893
+ // carve-out below. The audit never sets this.
894
+ const ownBodyOnly = options.ownBodyOnly instanceof Set ? options.ownBodyOnly : null;
895
+ // Declarations the index does not hold (deleted since, or read from a
896
+ // base revision), assessed from their own record against this index's
897
+ // uses of the name. Their line numbers describe a file that no longer
898
+ // has them, so they never mark a definition site in the current text.
899
+ const absent = new Map(); // symbol record -> file entry of its revision
900
+ const absentByName = new Map();
901
+ for (const extra of Array.isArray(options.declarations) ? options.declarations : []) {
902
+ const symbol = extra.symbol;
903
+ if (!symbol || !symbol.name) continue;
904
+ if (!symbol.relativePath) {
905
+ symbol.relativePath = path.relative(index.root, symbol.file).split(path.sep).join('/');
906
+ }
907
+ // With no file facts from the revision it was declared in, only what
908
+ // the record itself says is known: nothing is exported by a list the
909
+ // file no longer has.
910
+ absent.set(symbol, extra.fileEntry || index.files.get(symbol.file) || {
911
+ language: detectLanguage(symbol.file, index.root),
912
+ relativePath: symbol.relativePath,
913
+ symbols: [], exports: [], exportDetails: [], imports: [], bindings: [],
914
+ });
915
+ if (!absentByName.has(symbol.name)) absentByName.set(symbol.name, []);
916
+ absentByName.get(symbol.name).push(symbol);
917
+ }
918
+ // Where a member stands decides what it implements and what overrides
919
+ // it. An absent declaration answers those questions standing in its
920
+ // owner again (anchoredInOwner); null means it cannot be placed, and
921
+ // its contracts cannot be checked.
922
+ const anchored = new Map();
923
+ for (const symbol of absent.keys()) anchored.set(symbol, anchoredInOwner(index, symbol));
924
+ // The answers that depend on where a member stands. For an absent
925
+ // declaration they are computed together, under one overlay.
926
+ const standingFacts = (declared, lang) => contractFactsOf(index, declared, lang);
927
+ const absentFacts = new Map();
928
+ const standing = (symbol, lang) => {
929
+ if (!absent.has(symbol)) return null;
930
+ if (!absentFacts.has(symbol)) {
931
+ absentFacts.set(symbol, withDeclared(index, anchored.get(symbol),
932
+ declared => standingFacts(declared, lang)));
933
+ }
934
+ return absentFacts.get(symbol);
935
+ };
936
+ const symbolsOf = name => (absentByName.has(name)
937
+ ? [...(index.symbols.get(name) || []), ...absentByName.get(name)]
938
+ : index.symbols.get(name) || []);
939
+ const fileEntryOf = symbol => absent.get(symbol) || index.files.get(symbol.file);
940
+ const namedSymbols = absent.size === 0 ? index.symbols : (function* merged() {
941
+ for (const [name] of index.symbols) yield [name, symbolsOf(name)];
942
+ for (const name of absentByName.keys()) {
943
+ if (!index.symbols.has(name)) yield [name, symbolsOf(name)];
944
+ }
945
+ })();
751
946
  let excludedDecorated = 0;
752
947
  let excludedExported = 0;
753
948
  let excludedExternalContract = 0;
754
949
  let excludedRuntimeContract = 0;
755
950
  let excludedConstructors = 0;
756
951
  let excludedDynamicDispatch = 0;
952
+ let excludedRequiredSlot = 0;
757
953
  const computedDispatchByFile = projectComputedDispatch(index);
758
954
  const computedDispatchReceivers = new Set();
759
955
  const computedDispatchInfo = { count: 0, fileCount: 0, files: [] };
@@ -770,6 +966,8 @@ function deadcode(index, options = {}) {
770
966
  }
771
967
  }
772
968
  const literalReflectionNames = new Set();
969
+ const dynamicReflectionFiles = new Set();
970
+ const reflectionTables = new Map();
773
971
  const reflectionInfo = {
774
972
  count: 0,
775
973
  literalCount: 0,
@@ -781,6 +979,7 @@ function deadcode(index, options = {}) {
781
979
  patterns: [],
782
980
  withheld: 0,
783
981
  withheldByPattern: [],
982
+ withheldByTable: [],
784
983
  };
785
984
  // Reflection is liveness evidence wherever it sits (a test or script
786
985
  // outside --in still reaches the member); the disclosure counts cover the
@@ -790,6 +989,7 @@ function deadcode(index, options = {}) {
790
989
  const fe = index.files.get(reflectionFile);
791
990
  if (!fe || sites.length === 0) continue;
792
991
  for (const site of sites) {
992
+ if (site.dynamic) dynamicReflectionFiles.add(reflectionFile);
793
993
  if (site.name) literalReflectionNames.add(site.name);
794
994
  if (site.patterns && site.patterns.length > 0 && !site.name) {
795
995
  // Exact alternatives of a multi-spelling site are literals.
@@ -839,10 +1039,15 @@ function deadcode(index, options = {}) {
839
1039
  const classAuditSet = new Set(CLASS_AUDIT_KINDS);
840
1040
  const callableNames = new Set();
841
1041
  for (const [symbolName, symbols] of index.symbols) {
1042
+ if (onlyNames && !onlyNames.has(symbolName)) continue;
842
1043
  if (symbols.some(s => auditTypeSet.has(s.type))) {
843
1044
  callableNames.add(symbolName);
844
1045
  }
845
1046
  }
1047
+ for (const [symbolName, symbols] of absentByName) {
1048
+ if (onlyNames && !onlyNames.has(symbolName)) continue;
1049
+ if (symbols.some(s => auditTypeSet.has(s.type))) callableNames.add(symbolName);
1050
+ }
846
1051
 
847
1052
  // Pre-filter: names in the callee index have call sites → definitely used → not dead.
848
1053
  // Exception (fix #253c): a name whose EVERY call site sits inside a
@@ -854,7 +1059,7 @@ function deadcode(index, options = {}) {
854
1059
  for (const name of callableNames) {
855
1060
  if (!index.calleeIndex.has(name)) {
856
1061
  potentiallyDeadNames.add(name);
857
- } else if (nameOnlySelfRecursive(index, name)) {
1062
+ } else if ((ownBodyOnly && ownBodyOnly.has(name)) || nameOnlySelfRecursive(index, name)) {
858
1063
  selfRecursiveNames.add(name);
859
1064
  potentiallyDeadNames.add(name);
860
1065
  }
@@ -863,12 +1068,12 @@ function deadcode(index, options = {}) {
863
1068
  // Pre-filter exported symbols from the scan set when not auditing exports.
864
1069
  // Go exports ~63K capitalized names on K8s — scanning these in Phase 2 only to
865
1070
  // skip them in Phase 3 wastes O(63K × 11K files) = ~700M comparisons.
866
- if (!options.includeExported) {
1071
+ if (downstreamExposureBlocks(index, true, options)) {
867
1072
  const narrowed = new Set();
868
1073
  for (const name of potentiallyDeadNames) {
869
- const syms = index.symbols.get(name) || [];
1074
+ const syms = symbolsOf(name);
870
1075
  // Keep the name only if at least one definition is NOT exported
871
- const allExported = syms.every(s => symbolIsExported(index, s, index.files.get(s.file)));
1076
+ const allExported = syms.every(s => symbolIsExported(index, s, fileEntryOf(s)));
872
1077
  if (!allExported) narrowed.add(name);
873
1078
  }
874
1079
  potentiallyDeadNames = narrowed;
@@ -880,7 +1085,7 @@ function deadcode(index, options = {}) {
880
1085
  if (options.file) {
881
1086
  const filteredNames = new Set();
882
1087
  for (const name of potentiallyDeadNames) {
883
- const syms = index.symbols.get(name) || [];
1088
+ const syms = symbolsOf(name);
884
1089
  if (syms.some(s => s.relativePath && s.relativePath.includes(options.file))) {
885
1090
  filteredNames.add(name);
886
1091
  }
@@ -933,11 +1138,6 @@ function deadcode(index, options = {}) {
933
1138
  }
934
1139
 
935
1140
  const coverage = scanDeadcodeCoverage(index, potentiallyDeadNames);
936
- const coverageSuppressedNames = new Set([
937
- ...coverage.matchedNames,
938
- ...literalReflectionNames,
939
- ...macroSuppressedNames,
940
- ]);
941
1141
  if (coverage.claimsWithdrawn) {
942
1142
  // An unreadable file/directory can contain a use of any candidate.
943
1143
  // Returning no deletion candidates is the only sound verdict.
@@ -1241,8 +1441,16 @@ function deadcode(index, options = {}) {
1241
1441
  }
1242
1442
  }
1243
1443
 
1244
- for (const [name, symbols] of index.symbols) {
1245
- if (coverage.claimsWithdrawn || coverageSuppressedNames.has(name)) continue;
1444
+ for (const [name, symbols] of namedSymbols) {
1445
+ if (onlyNames && !onlyNames.has(name)) continue;
1446
+ // A name-level withdrawal: no declaration of this name is claimed.
1447
+ // The audit skips the name; an assessment still walks the chain so
1448
+ // a more specific reason (the name has callers) is reported first.
1449
+ const withdrawn = coverage.claimsWithdrawn ? 'unsearchable-source'
1450
+ : literalReflectionNames.has(name) ? 'reflection'
1451
+ : macroSuppressedNames.has(name) ? 'macro-reference'
1452
+ : coverage.matchedNames.has(name) ? 'unanalyzed-occurrence' : null;
1453
+ if (withdrawn && !explain) continue;
1246
1454
  // Definition NAME lines of same-name def-kind symbols are
1247
1455
  // declarations, not usages — two never-called same-name methods used
1248
1456
  // to keep each other alive (fix #243: three unreferenced `delete`
@@ -1253,6 +1461,7 @@ function deadcode(index, options = {}) {
1253
1461
  if (sameNameDefLines) return sameNameDefLines;
1254
1462
  sameNameDefLines = new Map();
1255
1463
  for (const other of symbols) {
1464
+ if (absent.has(other)) continue;
1256
1465
  if (!DEF_NAME_LINE_KINDS.has(other.type)) continue;
1257
1466
  const ln = other.nameLine ?? other.startLine;
1258
1467
  if (ln == null) continue;
@@ -1273,6 +1482,7 @@ function deadcode(index, options = {}) {
1273
1482
  if (sameNameDefRanges) return sameNameDefRanges;
1274
1483
  sameNameDefRanges = new Map();
1275
1484
  for (const other of symbols) {
1485
+ if (absent.has(other)) continue;
1276
1486
  if (!DEF_NAME_LINE_KINDS.has(other.type)) continue;
1277
1487
  let arr = sameNameDefRanges.get(other.file);
1278
1488
  if (!arr) { arr = []; sameNameDefRanges.set(other.file, arr); }
@@ -1284,6 +1494,7 @@ function deadcode(index, options = {}) {
1284
1494
  for (const symbol of symbols) {
1285
1495
  // Skip non-audited types (callableTypes defined above)
1286
1496
  if (!auditTypeSet.has(symbol.type)) {
1497
+ withhold(symbol, 'kind-not-audited');
1287
1498
  continue;
1288
1499
  }
1289
1500
 
@@ -1293,19 +1504,22 @@ function deadcode(index, options = {}) {
1293
1504
  // surface (ECMA-262 body-only scope). Auditing them claims
1294
1505
  // callback handlers dead (`app.on('ready', function boot(){})`).
1295
1506
  if (symbol.bodyScopedName) {
1507
+ withhold(symbol, 'expression-scoped-name');
1296
1508
  continue;
1297
1509
  }
1298
1510
  // Generated by a project macro_rules! invocation (fix #374): the
1299
1511
  // definition is the macro's template or the invocation's
1300
1512
  // arguments, never a deletable declaration of its own.
1301
1513
  if (symbol.macroExpansion) {
1514
+ withhold(symbol, 'macro-generated');
1302
1515
  continue;
1303
1516
  }
1304
- const fileEntry = index.files.get(symbol.file);
1517
+ const fileEntry = fileEntryOf(symbol);
1305
1518
  const lang = fileEntry?.language;
1306
1519
 
1307
1520
  // Skip bundled/minified files (webpack bundles, build artifacts)
1308
1521
  if (fileEntry?.isBundled) {
1522
+ withhold(symbol, 'bundled-file');
1309
1523
  continue;
1310
1524
  }
1311
1525
 
@@ -1317,6 +1531,7 @@ function deadcode(index, options = {}) {
1317
1531
  if (/^operator(?:\b|[^a-zA-Z0-9_$])/.test(symbol.name) ||
1318
1532
  symbol.name === 'this[]' ||
1319
1533
  ((lang === 'c' || lang === 'cpp') && symbol.name.startsWith('~'))) {
1534
+ withhold(symbol, 'operator-surface');
1320
1535
  continue;
1321
1536
  }
1322
1537
 
@@ -1326,22 +1541,26 @@ function deadcode(index, options = {}) {
1326
1541
  if (lang === 'rust' && (symbol.modifiers || []).some(modifier =>
1327
1542
  modifier === 'pymethods' || modifier === 'pymodule_init')) {
1328
1543
  excludedRuntimeContract++;
1544
+ withhold(symbol, 'runtime-contract');
1329
1545
  continue;
1330
1546
  }
1331
1547
 
1332
1548
  // Skip test files unless requested
1333
1549
  if (!options.includeTests && isTestFile(symbol.relativePath, lang)) {
1550
+ withhold(symbol, 'test-file');
1334
1551
  continue;
1335
1552
  }
1336
1553
 
1337
1554
  // Apply file filter (scopes deadcode to matching files)
1338
1555
  if (options.file && !symbol.relativePath.includes(options.file)) {
1556
+ withhold(symbol, 'out-of-scope');
1339
1557
  continue;
1340
1558
  }
1341
1559
 
1342
1560
  // Apply exclude and in filters
1343
1561
  if ((options.exclude && options.exclude.length > 0) || options.in) {
1344
1562
  if (!index.matchesFilters(symbol.relativePath, { exclude: options.exclude, in: options.in })) {
1563
+ withhold(symbol, 'out-of-scope');
1345
1564
  continue;
1346
1565
  }
1347
1566
  }
@@ -1354,6 +1573,7 @@ function deadcode(index, options = {}) {
1354
1573
  // compile time and describes external/global shapes — nothing in
1355
1574
  // it is deletable code, so nothing in it is claimable.
1356
1575
  if (symbol.relativePath.endsWith('.d.ts')) {
1576
+ withhold(symbol, 'ambient-declaration');
1357
1577
  continue;
1358
1578
  }
1359
1579
  // The same holds for a `declare global` / `declare module 'x'`
@@ -1361,6 +1581,7 @@ function deadcode(index, options = {}) {
1361
1581
  // with declarations outside the project, whose readers UCN
1362
1582
  // cannot see (`interface Window`, a library's `Register`).
1363
1583
  if (symbol.ambientAugmentation) {
1584
+ withhold(symbol, 'ambient-declaration');
1364
1585
  continue;
1365
1586
  }
1366
1587
 
@@ -1368,6 +1589,7 @@ function deadcode(index, options = {}) {
1368
1589
  // Each language module declares its own isEntryPoint() rules.
1369
1590
  const langModule = getLanguageAdapter(lang);
1370
1591
  if (langModule.isEntryPoint?.(symbol)) {
1592
+ withhold(symbol, 'entry-point');
1371
1593
  continue;
1372
1594
  }
1373
1595
  // A constructor participates in the type's allocation protocol,
@@ -1381,14 +1603,24 @@ function deadcode(index, options = {}) {
1381
1603
  (symbol.className && !classAuditSet.has(symbol.type) &&
1382
1604
  langTraits(lang)?.constructorProtocol?.initializer === name)) {
1383
1605
  excludedConstructors++;
1606
+ withhold(symbol, 'constructor');
1384
1607
  continue;
1385
1608
  }
1386
1609
  // Members the runtime/compiler invokes by name (trait
1387
1610
  // languageProtocolMember): Java serialization callbacks on
1388
1611
  // Serializable types, Python Enum hooks on Enum subclasses, C#
1389
1612
  // pattern-bound members, JS toJSON/then/[Symbol.*] (fix #363).
1390
- if (protocolMemberOf(index, symbol) || protocolTypeOf(index, symbol)) {
1613
+ // An absent member that cannot be placed in one owner type
1614
+ // cannot have its contracts checked: no claim, and no guess.
1615
+ if (absent.has(symbol) && anchored.get(symbol) === null) {
1616
+ withhold(symbol, 'owner-ambiguous');
1617
+ continue;
1618
+ }
1619
+ if (standing(symbol, lang)
1620
+ ? standing(symbol, lang).runtimeContract
1621
+ : protocolMemberOf(index, symbol) || protocolTypeOf(index, symbol)) {
1391
1622
  excludedRuntimeContract++;
1623
+ withhold(symbol, 'runtime-contract');
1392
1624
  continue;
1393
1625
  }
1394
1626
 
@@ -1398,6 +1630,7 @@ function deadcode(index, options = {}) {
1398
1630
  // contract (CS0535); classify it with other external contracts.
1399
1631
  if (lang === 'csharp' && symbol.explicitInterface) {
1400
1632
  excludedExternalContract++;
1633
+ withhold(symbol, 'external-contract');
1401
1634
  continue;
1402
1635
  }
1403
1636
 
@@ -1416,6 +1649,7 @@ function deadcode(index, options = {}) {
1416
1649
  if ((hasFrameworkEntrypoint || hasDecoratorMetadata) &&
1417
1650
  !options.includeDecorated) {
1418
1651
  excludedDecorated++;
1652
+ withhold(symbol, 'registered');
1419
1653
  continue;
1420
1654
  }
1421
1655
 
@@ -1429,18 +1663,25 @@ function deadcode(index, options = {}) {
1429
1663
  // explicitly enables exported-surface review.
1430
1664
  if (symbol.memberAssigned && !symbol.registryMember && !isExported) {
1431
1665
  excludedRuntimeContract++;
1666
+ withhold(symbol, 'runtime-contract');
1432
1667
  continue;
1433
1668
  }
1434
1669
 
1435
1670
  // Skip exported unless requested
1436
- if (isExported && !options.includeExported) {
1671
+ // POLICY, not fact: an exported symbol may have consumers outside
1672
+ // the repository. A library keeps it out of the audit; a project
1673
+ // declared an application, or --include-exported, audits it.
1674
+ // `isExported` stays the fact other shields read (above).
1675
+ if (downstreamExposureBlocks(index, isExported, options)) {
1437
1676
  excludedExported++;
1677
+ withhold(symbol, 'downstream-exposure');
1438
1678
  continue;
1439
1679
  }
1440
1680
 
1441
1681
  // Fast path: name has call sites in callee index → definitely used → not dead
1442
1682
  // (unless every site is the name's own recursion — fix #253c).
1443
1683
  if (index.calleeIndex.has(name) && !selfRecursiveNames.has(name)) {
1684
+ withhold(symbol, 'has-callers');
1444
1685
  continue;
1445
1686
  }
1446
1687
  // Class-kind claims (fix #253a): a class whose member the runtime
@@ -1456,15 +1697,20 @@ function deadcode(index, options = {}) {
1456
1697
  // live. This is the normal invocation form for C# extension
1457
1698
  // containers and other static helper types.
1458
1699
  if (members.some(m => index.calleeIndex.has(m.name))) {
1700
+ withhold(symbol, 'member-in-use', explain && {
1701
+ members: members.filter(m => index.calleeIndex.has(m.name)).map(m => m.name),
1702
+ });
1459
1703
  continue;
1460
1704
  }
1461
1705
  if (members.some(m => langModule.isEntryPoint?.(m))) {
1706
+ withhold(symbol, 'entry-point');
1462
1707
  continue;
1463
1708
  }
1464
1709
  if (members.some(m => isFrameworkEntrypoint(m, index) ||
1465
1710
  hasUnknownRegistrationDecorator(m))) {
1466
1711
  if (!options.includeDecorated) {
1467
1712
  excludedDecorated++;
1713
+ withhold(symbol, 'registered');
1468
1714
  continue;
1469
1715
  }
1470
1716
  }
@@ -1480,7 +1726,9 @@ function deadcode(index, options = {}) {
1480
1726
  // resolves to — `user.load` keeps user.js's load alive, never an
1481
1727
  // unrelated module's same-name symbol.
1482
1728
  let nonDefUsages = allUsages.filter(u =>
1483
- !(u.file === symbol.file && (u.line === symbol.startLine || u.line === symbol.nameLine)) &&
1729
+ // (an absent declaration has no definition site in this text)
1730
+ !(!absent.has(symbol) && u.file === symbol.file &&
1731
+ (u.line === symbol.startLine || u.line === symbol.nameLine)) &&
1484
1732
  !(defNameLines().get(u.file)?.has(u.line)) &&
1485
1733
  // Self-recursion carve-out names (fix #253c): a reference
1486
1734
  // inside a same-name def's own body is the recursion itself.
@@ -1506,7 +1754,7 @@ function deadcode(index, options = {}) {
1506
1754
  // AST ranges, not line prefixes (fix #247): a multi-line
1507
1755
  // `export { a,\n b }` block's continuation lines counted as
1508
1756
  // consumption, silently hiding every symbol exported that way.
1509
- if (isExported && options.includeExported) {
1757
+ if (isExported && !downstreamExposureBlocks(index, isExported, options)) {
1510
1758
  nonDefUsages = nonDefUsages.filter(u => {
1511
1759
  if (u.file !== symbol.file) return true; // cross-file usage always counts
1512
1760
  const ranges = exportSiteRanges(u.file);
@@ -1517,7 +1765,15 @@ function deadcode(index, options = {}) {
1517
1765
  // Total includes all usage types (calls, references, callbacks, re-exports)
1518
1766
  const totalUsages = nonDefUsages.length;
1519
1767
 
1768
+ if (totalUsages > 0) withhold(symbol, 'has-usages', explain && { usages: nonDefUsages });
1520
1769
  if (totalUsages === 0) {
1770
+ // A name-level withdrawal applies to every declaration the
1771
+ // chain did not already explain (assessment only: the audit
1772
+ // skipped this name before the chain).
1773
+ if (withdrawn) {
1774
+ withhold(symbol, withdrawn);
1775
+ continue;
1776
+ }
1521
1777
  // External-contract override: the method may be invoked through
1522
1778
  // an out-of-tree base class UCN can't index (deadcode analog of
1523
1779
  // fix #210). A zero usage count is not evidence of deadness.
@@ -1528,13 +1784,30 @@ function deadcode(index, options = {}) {
1528
1784
  // such subclasses non-textually (django `Command(BaseCommand)`
1529
1785
  // by module path, pytest plugins by registration) — zero
1530
1786
  // in-project references is not evidence of deadness there.
1531
- const isExternalContract = classAuditSet.has(symbol.type)
1532
- ? _heritageReachesExternalBase(index, symbol, lang, false)
1533
- : overridesOutOfTreeBase(index, symbol) ||
1534
- typedLiteralExternalMember(index, symbol) ||
1535
- (lang === 'go' && goImplicitExternalContract(symbol));
1787
+ const isExternalContract = standing(symbol, lang)
1788
+ ? standing(symbol, lang).externalContract
1789
+ : (classAuditSet.has(symbol.type)
1790
+ ? _heritageReachesExternalBase(index, symbol, lang, false)
1791
+ : overridesOutOfTreeBase(index, symbol) ||
1792
+ typedLiteralExternalMember(index, symbol) ||
1793
+ implicitStandardContract(symbol, lang));
1536
1794
  if (isExternalContract && !options.includeExported) {
1537
1795
  excludedExternalContract++;
1796
+ withhold(symbol, 'external-contract');
1797
+ continue;
1798
+ }
1799
+ // Held in place by a contract of the project itself: the
1800
+ // implementation a type owes to an interface, abstract class
1801
+ // or trait, a member a descendant declares it overrides, or
1802
+ // a member that lets its type stand in for a project
1803
+ // interface or protocol by shape. A fact about the
1804
+ // declaration, so no flag reveals it.
1805
+ const projectContract = standing(symbol, lang)
1806
+ ? standing(symbol, lang).projectContract
1807
+ : requiredByProjectContract(index, symbol);
1808
+ if (projectContract) {
1809
+ excludedRequiredSlot++;
1810
+ withhold(symbol, projectContract);
1538
1811
  continue;
1539
1812
  }
1540
1813
  // Collect decorators/annotations for hint display
@@ -1579,6 +1852,7 @@ function deadcode(index, options = {}) {
1579
1852
  computedDispatchReceivers.has(symbol.registryContainer);
1580
1853
  if (dynamicallyDispatched) {
1581
1854
  excludedDynamicDispatch++;
1855
+ withhold(symbol, 'dynamic-dispatch');
1582
1856
  continue;
1583
1857
  }
1584
1858
 
@@ -1595,9 +1869,30 @@ function deadcode(index, options = {}) {
1595
1869
  reflectionWithheldByPattern.set(pattern,
1596
1870
  (reflectionWithheldByPattern.get(pattern) || 0) + 1);
1597
1871
  reflectionInfo.withheld++;
1872
+ withhold(symbol, 'reflection');
1598
1873
  continue;
1599
1874
  }
1600
1875
 
1876
+ // A table may feed a dynamic reflection site through a loop
1877
+ // or helper. Withhold exact same-file literal spellings,
1878
+ // conservatively: this is possible dispatch, not proven flow.
1879
+ if (dynamicReflectionFiles.has(symbol.file)) {
1880
+ if (!reflectionTables.has(symbol.file)) {
1881
+ const source = index._readFile(symbol.file);
1882
+ const tree = index._getParsedTree(symbol.file, source,
1883
+ index.files.get(symbol.file).language);
1884
+ reflectionTables.set(symbol.file, reflectionLiteralNames(tree));
1885
+ }
1886
+ const line = reflectionTables.get(symbol.file).get(symbol.name);
1887
+ if (line) {
1888
+ reflectionInfo.withheldByTable.push({ name: symbol.name,
1889
+ file: symbol.relativePath, line, startLine: symbol.startLine,
1890
+ reason: 'literal-name-near-dynamic-reflection' });
1891
+ withhold(symbol, 'reflection');
1892
+ continue;
1893
+ }
1894
+ }
1895
+
1601
1896
  results.push({
1602
1897
  name: symbol.name,
1603
1898
  type: symbol.type,
@@ -1617,6 +1912,7 @@ function deadcode(index, options = {}) {
1617
1912
  ...(declaredOn && { declaredOn }),
1618
1913
  ...(isExternalContract && { externalContract: true })
1619
1914
  });
1915
+ if (explain) explain(symbol, 'claimed', results[results.length - 1]);
1620
1916
  }
1621
1917
  }
1622
1918
  }
@@ -1634,7 +1930,10 @@ function deadcode(index, options = {}) {
1634
1930
  results.excludedRuntimeContract = excludedRuntimeContract;
1635
1931
  results.excludedConstructors = excludedConstructors;
1636
1932
  results.excludedDynamicDispatch = excludedDynamicDispatch;
1933
+ results.excludedRequiredSlot = excludedRequiredSlot;
1637
1934
  results.computedDispatch = computedDispatchInfo;
1935
+ reflectionInfo.withheldByTable.sort((a, b) => codeUnitCompare(a.file, b.file) ||
1936
+ a.startLine - b.startLine || codeUnitCompare(a.name, b.name));
1638
1937
  reflectionInfo.withheldByPattern = [...reflectionWithheldByPattern]
1639
1938
  .map(([key, count]) => {
1640
1939
  const [pattern, namespaceOf] = key.split('\u0000');
@@ -1660,12 +1959,16 @@ function deadcode(index, options = {}) {
1660
1959
  reasons: coverage.reasons,
1661
1960
  parseRecoveries: coverage.parseRecoveries,
1662
1961
  };
1663
- results.pythonImplicitExportFiles = [...index.files.values()].filter(entry =>
1664
- isPythonPackageFile(index, entry) &&
1665
- !(entry.exportDetails || []).some(exp => exp.type === '__all__')).length;
1962
+ // The Python public-surface rule is an exposure rule: it shields nothing
1963
+ // in a project declared an application.
1964
+ results.projectKind = projectKind(index);
1965
+ results.pythonImplicitExportFiles = results.projectKind === 'application' ? 0
1966
+ : [...index.files.values()].filter(entry =>
1967
+ isPythonPackageFile(index, entry) &&
1968
+ !(entry.exportDetails || []).some(exp => exp.type === '__all__')).length;
1666
1969
 
1667
1970
  return results;
1668
1971
  } finally { index._endOp(); }
1669
1972
  }
1670
1973
 
1671
- module.exports = { buildUsageIndex, deadcode, nameOnlySelfRecursive, DEF_NAME_LINE_KINDS };
1974
+ module.exports = { buildUsageIndex, deadcode, nameOnlySelfRecursive, contractFactsOf, DEF_NAME_LINE_KINDS };