ucn 4.0.0 → 4.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.
package/cli/index.js CHANGED
@@ -1052,7 +1052,8 @@ function runProjectCommand(rootDir, command, arg) {
1052
1052
  r => output.formatDeadcode(r, {
1053
1053
  top: flags.top,
1054
1054
  decoratedHint: !flags.includeDecorated && result.excludedDecorated > 0 ? `${result.excludedDecorated} decorated/annotated symbol(s) hidden (framework-registered). Use --include-decorated to include them.` : undefined,
1055
- exportedHint: !flags.includeExported && result.excludedExported > 0 ? `${result.excludedExported} exported symbol(s) excluded (all have callers). Use --include-exported to audit them.` : undefined
1055
+ exportedHint: !flags.includeExported && result.excludedExported > 0 ? `${result.excludedExported} exported symbol(s) excluded (all have callers). Use --include-exported to audit them.` : undefined,
1056
+ externalContractHint: !flags.includeExported && result.excludedExternalContract > 0 ? `${result.excludedExternalContract} symbol(s) hidden (override an out-of-tree base class — reachable via external contract, not dead). Use --include-exported to include them.` : undefined
1056
1057
  })
1057
1058
  );
1058
1059
  break;
@@ -1911,7 +1912,8 @@ function executeInteractiveCommand(index, command, arg, iflags = {}, cache = nul
1911
1912
  console.log(output.formatDeadcode(result, {
1912
1913
  top: iflags.top,
1913
1914
  decoratedHint: !iflags.includeDecorated && result.excludedDecorated > 0 ? `${result.excludedDecorated} decorated/annotated symbol(s) hidden (framework-registered). Use --include-decorated to include them.` : undefined,
1914
- exportedHint: !iflags.includeExported && result.excludedExported > 0 ? `${result.excludedExported} exported symbol(s) excluded (all have callers). Use --include-exported to audit them.` : undefined
1915
+ exportedHint: !iflags.includeExported && result.excludedExported > 0 ? `${result.excludedExported} exported symbol(s) excluded (all have callers). Use --include-exported to audit them.` : undefined,
1916
+ externalContractHint: !iflags.includeExported && result.excludedExternalContract > 0 ? `${result.excludedExternalContract} symbol(s) hidden (override an out-of-tree base class — reachable via external contract, not dead). Use --include-exported to include them.` : undefined
1915
1917
  }));
1916
1918
  break;
1917
1919
  }
package/core/analysis.js CHANGED
@@ -12,7 +12,7 @@ const path = require('path');
12
12
  const { execFileSync } = require('child_process');
13
13
  const { parse } = require('./parser');
14
14
  const { detectLanguage, langTraits } = require('../languages');
15
- const { NON_CALLABLE_TYPES, addTestExclusions } = require('./shared');
15
+ const { NON_CALLABLE_TYPES, addTestExclusions, countTextBlindspots } = require('./shared');
16
16
  const { computeReachability, symbolKey } = require('./entrypoints');
17
17
  const { getLanguageModule } = require('../languages');
18
18
 
@@ -600,20 +600,20 @@ function detectCompleteness(index) {
600
600
  const content = index._readFile(filePath);
601
601
 
602
602
  if (langTraits(fileEntry.language)?.hasDynamicImports) {
603
- // Dynamic imports: import(), require(variable), __import__
604
- dynamicImports += (content.match(/import\s*\([^'"]/g) || []).length;
605
- dynamicImports += (content.match(/require\s*\([^'"]/g) || []).length;
606
- dynamicImports += (content.match(/__import__\s*\(/g) || []).length;
607
-
608
- // eval, Function constructor
609
- evalUsage += (content.match(/(^|[^a-zA-Z_])eval\s*\(/gm) || []).length;
610
- evalUsage += (content.match(/new\s+Function\s*\(/g) || []).length;
603
+ // Dynamic imports: use the parser's structural count — the SAME
604
+ // source `doctor` uses — instead of a text regex. The old
605
+ // /import\s*\(/ matched Python grouped imports `from x import
606
+ // (...)`, flashing a false "N dynamic imports" incompleteness
607
+ // warning on essentially every Python project (field-report #2,
608
+ // reviewer-confirmed: doctor and about now agree on one count).
609
+ dynamicImports += fileEntry.dynamicImports || 0;
611
610
  }
612
611
 
613
- // Reflection: getattr, hasattr, Reflect
614
- reflectionUsage += (content.match(/\bgetattr\s*\(/g) || []).length;
615
- reflectionUsage += (content.match(/\bhasattr\s*\(/g) || []).length;
616
- reflectionUsage += (content.match(/\bReflect\./g) || []).length;
612
+ // eval/exec and reflection: the SAME shared counter doctor uses, so
613
+ // the about footer and the trust report never diverge (field-report #2).
614
+ const bs = countTextBlindspots(content, fileEntry.language);
615
+ evalUsage += bs.eval;
616
+ reflectionUsage += bs.reflection;
617
617
  } catch (e) {
618
618
  // Skip unreadable files
619
619
  }
package/core/callers.js CHANGED
@@ -10,7 +10,7 @@ const path = require('path');
10
10
  const crypto = require('crypto');
11
11
  const { detectLanguage, getParser, getLanguageModule, langTraits } = require('../languages');
12
12
  const { isTestFile } = require('./discovery');
13
- const { NON_CALLABLE_TYPES } = require('./shared');
13
+ const { NON_CALLABLE_TYPES, isOverrideMarked } = require('./shared');
14
14
  const { scoreEdge, tierForResolution, TIER } = require('./confidence');
15
15
  const { findGoModule } = require('./imports');
16
16
 
@@ -233,7 +233,7 @@ function findCallers(index, name, options = {}) {
233
233
  // `some`, not `every`: one marked overload proves the NAME exists
234
234
  // on an external contract — receiver identity is then unprovable
235
235
  // for every call shape (external signatures are invisible).
236
- const marked = tDefs.find(d => _externalContractMarker(d));
236
+ const marked = tDefs.find(d => isOverrideMarked(d));
237
237
  if (marked) _extContract = { via: _externalContractVia(index, marked) };
238
238
  }
239
239
  return _extContract;
@@ -3838,7 +3838,7 @@ function _nameBindingReaches(index, startAbs, name, targetFiles, maxDepth = 4) {
3838
3838
  const next = [];
3839
3839
  for (const [abs, attr] of frontier) {
3840
3840
  if (targetFiles.has(abs)) return 'yes';
3841
- const stateKey = `${abs}${attr}`;
3841
+ const stateKey = `${abs}\x00${attr}`;
3842
3842
  if (visited.has(stateKey)) continue;
3843
3843
  visited.add(stateKey);
3844
3844
  const fe = index.files.get(abs);
@@ -4463,22 +4463,8 @@ function _targetAncestryFullyResolved(index, targetDefs) {
4463
4463
  return true;
4464
4464
  }
4465
4465
 
4466
- /**
4467
- * Explicit override marker on a method definition (fix #210). Marker fields
4468
- * are language-disjoint: traitImpl is Rust-only, an 'override' modifier is
4469
- * Java's lowercased @Override annotation, an override-bearing memberType is
4470
- * TS's `override` keyword, and an override decorator is Python's
4471
- * typing.@override. The marker is compiler-checked syntax in all four —
4472
- * never inferred.
4473
- */
4474
- function _externalContractMarker(def) {
4475
- if (def.traitImpl) return true;
4476
- if (def.modifiers && def.modifiers.includes('override')) return true;
4477
- if (def.memberType && /\boverride\b/.test(def.memberType)) return true;
4478
- if (def.decorators && def.decorators.some(d =>
4479
- String(d).replace(/\(.*$/, '').split('.').pop() === 'override')) return true;
4480
- return false;
4481
- }
4466
+ // _externalContractMarker moved to core/shared.js as isOverrideMarked (shared
4467
+ // with deadcode's out-of-tree override suppression one source of truth).
4482
4468
 
4483
4469
  /**
4484
4470
  * Name of the external contract a marked method implements, for dispatch
package/core/deadcode.js CHANGED
@@ -8,6 +8,90 @@
8
8
  const { detectLanguage, getParser, getLanguageModule, safeParse, langTraits } = require('../languages');
9
9
  const { isTestFile } = require('./discovery');
10
10
  const { isFrameworkEntrypoint } = require('./entrypoints');
11
+ const { splitParentList } = require('./graph-build');
12
+ const { isOverrideMarked } = require('./shared');
13
+
14
+ const _CLASS_KINDS = ['class', 'struct', 'interface', 'trait', 'record'];
15
+
16
+ /** Strip a base-type expression to its bare name: `Mapping[str, int]`→Mapping, `java.util.List<Foo>`→List, `a::b::C`→C. */
17
+ function _bareBaseName(raw) {
18
+ return String(raw).replace(/[<[(].*$/s, '').split('.').pop().split('::').pop().trim();
19
+ }
20
+
21
+ // The universal object root (Python `object`, Java/JS `Object`) has a fixed,
22
+ // known method surface — Object/dunder methods, themselves entry points — so it
23
+ // never dispatches an arbitrary subclass method by name or override. It is NOT
24
+ // an external *dispatching* base: `class Foo(object)` must still report its
25
+ // genuinely-dead inherent methods, exactly like `class Foo`, instead of
26
+ // diverging on a purely cosmetic base declaration. (Java `Object` is the
27
+ // universalSupertype trait; `object` is the Python equivalent — a language
28
+ // convention, rule #9.)
29
+ const _UNIVERSAL_ROOTS = new Set(['object', 'Object']);
30
+
31
+ /** True when a base name resolves to NO in-project class/struct/interface/trait/record (an out-of-tree type). */
32
+ function _baseIsExternal(index, bare) {
33
+ if (!bare || _UNIVERSAL_ROOTS.has(bare)) return false;
34
+ const defs = index.symbols.get(bare);
35
+ return !(defs && defs.some(d => _CLASS_KINDS.includes(d.type)));
36
+ }
37
+
38
+ /**
39
+ * Does the method's enclosing class EXTEND at least one base that is NOT in the
40
+ * project index? An out-of-tree base is a framework/library type UCN can't see;
41
+ * via inheritance it may dispatch into a public method of the subclass
42
+ * polymorphically (Starlette → build_middleware_stack) or by name convention
43
+ * (Pydantic → bytes_schema). The class def is matched in the method's own file.
44
+ *
45
+ * `implements` is deliberately NOT consulted: implementing an external
46
+ * interface/trait makes only the INTERFACE'S OWN methods a contract (those
47
+ * carry traitImpl/@Override markers → handled by Rule A), not the class's
48
+ * unrelated inherent methods. Counting it would wrongly shield, e.g., a Rust
49
+ * struct's genuinely-dead inherent method just because the struct also
50
+ * `impl Display for`s.
51
+ */
52
+ function _classHasExternalBase(index, symbol) {
53
+ const classDefs = (index.symbols.get(symbol.className) || []).filter(c =>
54
+ c.file === symbol.file && _CLASS_KINDS.includes(c.type));
55
+ for (const cd of classDefs) {
56
+ if (!cd.extends) continue;
57
+ const supers = Array.isArray(cd.extends) ? cd.extends : splitParentList(cd.extends);
58
+ for (const raw of supers) {
59
+ if (_baseIsExternal(index, _bareBaseName(raw))) return true;
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+
65
+ /**
66
+ * A method that may be reached through an out-of-tree base class — the deadcode
67
+ * analog of fix #210's external-contract methods. A zero in-project usage count
68
+ * is NOT evidence of deadness here; claiming the symbol dead invites deleting a
69
+ * live framework override (e.g. FastAPI.build_middleware_stack overriding
70
+ * Starlette, or GenerateJsonSchema.bytes_schema name-dispatched by Pydantic —
71
+ * the only caller lives in an unindexed dependency).
72
+ * (A) explicit override marker (@Override / `override` / typing.@override /
73
+ * Rust `impl Trait for X`) AND a single project-wide method owner of the
74
+ * name (no in-project supertype defines it → the contract is external —
75
+ * the #210 ownerCount===1 rule).
76
+ * (B) a public-by-shape method whose class EXTENDS an unresolved base.
77
+ * Private/underscore members are never external-contract surface, so a
78
+ * genuinely-dead one stays claimable (the fix #211 shape predicate).
79
+ * Data-driven, not language-keyed: classes without an `extends` clause (Go
80
+ * embedding, Rust structs / inherent impls) never trip (B), and Rust trait
81
+ * impls trip (A) via traitImpl — so new languages inherit correct behavior.
82
+ */
83
+ function overridesOutOfTreeBase(index, symbol) {
84
+ if (!symbol.className) return false; // standalone function can't override
85
+ if (isOverrideMarked(symbol)) {
86
+ const owners = (index.symbols.get(symbol.name) || []).filter(s => s.className);
87
+ if (owners.length <= 1) return true;
88
+ }
89
+ const mods = symbol.modifiers || [];
90
+ const publicByShape = !mods.includes('private') &&
91
+ !symbol.name.startsWith('#') && !symbol.name.startsWith('_');
92
+ if (publicByShape && _classHasExternalBase(index, symbol)) return true;
93
+ return false;
94
+ }
11
95
 
12
96
  /** Check if a position in a line is inside a string literal (quotes/backticks) */
13
97
  function isInsideString(line, pos) {
@@ -228,6 +312,7 @@ function deadcode(index, options = {}) {
228
312
  const results = [];
229
313
  let excludedDecorated = 0;
230
314
  let excludedExported = 0;
315
+ let excludedExternalContract = 0;
231
316
 
232
317
  // Ensure callee index is built (lazy, reused across operations)
233
318
  if (!index.calleeIndex) {
@@ -488,6 +573,16 @@ function deadcode(index, options = {}) {
488
573
  const totalUsages = nonDefUsages.length;
489
574
 
490
575
  if (totalUsages === 0) {
576
+ // External-contract override: the method may be invoked through
577
+ // an out-of-tree base class UCN can't index (deadcode analog of
578
+ // fix #210). A zero usage count is not evidence of deadness.
579
+ // Hidden by default; revealed under --include-exported, since
580
+ // it is external-reachable surface, not internal dead code.
581
+ const isExternalContract = overridesOutOfTreeBase(index, symbol);
582
+ if (isExternalContract && !options.includeExported) {
583
+ excludedExternalContract++;
584
+ continue;
585
+ }
491
586
  // Collect decorators/annotations for hint display
492
587
  // Python: symbol.decorators (e.g., ['app.route("/path")', 'login_required'])
493
588
  // Java/Rust/Go: symbol.modifiers may contain annotations (e.g., 'bean', 'scheduled')
@@ -530,7 +625,8 @@ function deadcode(index, options = {}) {
530
625
  usageCount: 0,
531
626
  ...(decorators.length > 0 && { decorators }),
532
627
  ...(annotations.length > 0 && { annotations }),
533
- ...(declaredOn && { declaredOn })
628
+ ...(declaredOn && { declaredOn }),
629
+ ...(isExternalContract && { externalContract: true })
534
630
  });
535
631
  }
536
632
  }
@@ -545,6 +641,7 @@ function deadcode(index, options = {}) {
545
641
  // Attach exclusion counts as array properties (backwards-compatible)
546
642
  results.excludedDecorated = excludedDecorated;
547
643
  results.excludedExported = excludedExported;
644
+ results.excludedExternalContract = excludedExternalContract;
548
645
 
549
646
  return results;
550
647
  } finally { index._endOp(); }
package/core/execute.js CHANGED
@@ -751,9 +751,10 @@ const HANDLERS = {
751
751
  if (limit && limit > 0 && Array.isArray(result) && result.length > limit) {
752
752
  note = limitNote(limit, result.length);
753
753
  const sliced = result.slice(0, limit);
754
- // Preserve custom properties (excludedExported, excludedDecorated) from deadcode()
754
+ // Preserve custom properties (excludedExported, excludedDecorated, excludedExternalContract) from deadcode()
755
755
  if (result.excludedExported != null) sliced.excludedExported = result.excludedExported;
756
756
  if (result.excludedDecorated != null) sliced.excludedDecorated = result.excludedDecorated;
757
+ if (result.excludedExternalContract != null) sliced.excludedExternalContract = result.excludedExternalContract;
757
758
  result = sliced;
758
759
  }
759
760
  const tNote = truncationNote(index);
@@ -263,4 +263,4 @@ function splitParentList(clause) {
263
263
  .filter(Boolean);
264
264
  }
265
265
 
266
- module.exports = { buildDirIndex, buildImportGraph, buildInheritanceGraph, _resolveJavaPackageImport };
266
+ module.exports = { buildDirIndex, buildImportGraph, buildInheritanceGraph, splitParentList, _resolveJavaPackageImport };
@@ -732,6 +732,21 @@ function formatAbout(about, options = {}) {
732
732
  for (const c of testTop) renderAboutCaller(c);
733
733
  }
734
734
  if (aboutCallerReach.note) lines.push(aboutCallerReach.note);
735
+
736
+ // Field-report #5: when every CONFIRMED caller is a test and the
737
+ // production call sites are method-style (landed in UNVERIFIED as
738
+ // method-ambiguous — e.g. a module function sharing a name with a
739
+ // method), the bare "0 prod" count reads like dead code. Flag it so the
740
+ // empty prod count isn't misread; the real calls are listed below.
741
+ if (prodTop.length === 0 && testTop.length > 0) {
742
+ const uv = about.callers.unverified;
743
+ const methodStyle = uv && uv.top
744
+ ? uv.top.some(u => u.reason === 'method-ambiguous' || u.reason === 'possible-dispatch')
745
+ : false;
746
+ if (uv && uv.total > 0 && methodStyle) {
747
+ lines.push(` Note: 0 production callers CONFIRMED — the ${uv.total} call site(s) under UNVERIFIED below include method-style calls that may bind to this or a same-name method, so this is not dead code.`);
748
+ }
749
+ }
735
750
  }
736
751
 
737
752
  // Callers — UNVERIFIED tier (always visible; the contract forbids hiding)
@@ -13,6 +13,7 @@ function formatDoctor(result) {
13
13
  const lines = [];
14
14
  lines.push(`UCN Trust Report — ${result.root}`);
15
15
  lines.push('═'.repeat(60));
16
+ if (result.version) lines.push(`Version: ucn ${result.version}`);
16
17
  lines.push(`Index: ${result.files.scanned} file${result.files.scanned === 1 ? '' : 's'}, ${result.symbols} symbol${result.symbols === 1 ? '' : 's'}`);
17
18
 
18
19
  if (result.filter) lines.push(`Filter: ${result.filter}`);
@@ -60,13 +61,22 @@ function formatDoctor(result) {
60
61
  ['Reflection', bs.reflection],
61
62
  ['Parse failures', bs.parseFailures],
62
63
  ];
64
+ const unitFor = { 'Dynamic imports': 'import', 'Eval/exec calls': 'use', 'Reflection': 'use', 'Parse failures': 'failure' };
63
65
  let anyBlindSpot = false;
64
66
  for (const [label, info] of bsLines) {
65
67
  if (info && info.count > 0) {
66
68
  anyBlindSpot = true;
67
- const sample = info.files.slice(0, 3).map(f => ` - ${f}`).join('\n');
68
- const more = info.files.length > 3 ? `\n ... and ${info.files.length - 3} more` : '';
69
- lines.push(` ${label}: ${info.count} in ${info.files.length} file${info.files.length === 1 ? '' : 's'}`);
69
+ // fileCount is the TRUE (uncapped) number of files; info.files is a
70
+ // capped display sample. Show "N use(s) in M file(s)" and, when the
71
+ // sample is truncated, "... and K more file(s)" against the true M
72
+ // never present the display cap as the population (field-report #2).
73
+ const fileCount = info.fileCount != null ? info.fileCount : info.files.length;
74
+ const unit = unitFor[label] || 'use';
75
+ lines.push(` ${label}: ${info.count} ${unit}${info.count === 1 ? '' : 's'} in ${fileCount} file${fileCount === 1 ? '' : 's'}`);
76
+ const shownFiles = info.files.slice(0, 3);
77
+ const sample = shownFiles.map(f => ` - ${f}`).join('\n');
78
+ const moreFiles = fileCount - shownFiles.length;
79
+ const more = moreFiles > 0 ? `\n ... and ${moreFiles} more file${moreFiles === 1 ? '' : 's'}` : '';
70
80
  if (sample) lines.push(sample + more);
71
81
  }
72
82
  }
@@ -194,7 +194,7 @@ function formatStatsJson(stats) {
194
194
  * @param {string} [options.exportedHint] - Hint about exported symbols exclusion
195
195
  */
196
196
  function formatDeadcode(results, options = {}) {
197
- if (results.length === 0 && !results.excludedDecorated && !results.excludedExported) {
197
+ if (results.length === 0 && !results.excludedDecorated && !results.excludedExported && !results.excludedExternalContract) {
198
198
  return 'No dead code found.';
199
199
  }
200
200
 
@@ -232,7 +232,11 @@ function formatDeadcode(results, options = {}) {
232
232
  const declStr = item.declaredOn
233
233
  ? ` [declared on ${item.declaredOn.kind} ${item.declaredOn.name} — contract surface, not executable code]`
234
234
  : '';
235
- lines.push(` ${lineRange(item.startLine, item.endLine)} ${item.name} (${item.type})${exported}${hintStr}${declStr}`);
235
+ // Revealed under --include-exported: mark as external-reachable, not dead.
236
+ const extStr = item.externalContract
237
+ ? ' [reachable via out-of-tree base — external contract, not dead]'
238
+ : '';
239
+ lines.push(` ${lineRange(item.startLine, item.endLine)} ${item.name} (${item.type})${exported}${hintStr}${declStr}${extStr}`);
236
240
  }
237
241
 
238
242
  if (hidden > 0) {
@@ -251,6 +255,10 @@ function formatDeadcode(results, options = {}) {
251
255
  const exportedHint = options.exportedHint || `${results.excludedExported} exported symbol(s) excluded (all have callers). Use --include-exported to audit them.`;
252
256
  lines.push(`\n${exportedHint}`);
253
257
  }
258
+ if (results.excludedExternalContract > 0) {
259
+ const extHint = options.externalContractHint || `${results.excludedExternalContract} symbol(s) hidden (override an out-of-tree base class — reachable via external contract, not dead). Use --include-exported to include them.`;
260
+ lines.push(`\n${extHint}`);
261
+ }
254
262
 
255
263
  if (lines.length === 0) {
256
264
  return 'No dead code found.';
@@ -270,6 +278,7 @@ function formatDeadcodeJson(results) {
270
278
  count: results.length,
271
279
  ...(results.excludedExported > 0 && { excludedExported: results.excludedExported }),
272
280
  ...(results.excludedDecorated > 0 && { excludedDecorated: results.excludedDecorated }),
281
+ ...(results.excludedExternalContract > 0 && { excludedExternalContract: results.excludedExternalContract }),
273
282
  symbols: results.map(item => {
274
283
  const handleSym = { ...item, relativePath: item.relativePath || item.file };
275
284
  const handle = formatSymbolHandle(handleSym);
@@ -283,7 +292,8 @@ function formatDeadcodeJson(results) {
283
292
  ...(item.isExported && { isExported: true }),
284
293
  ...(item.decorators && item.decorators.length > 0 && { decorators: item.decorators }),
285
294
  ...(item.annotations && item.annotations.length > 0 && { annotations: item.annotations }),
286
- ...(item.declaredOn && { declaredOn: item.declaredOn })
295
+ ...(item.declaredOn && { declaredOn: item.declaredOn }),
296
+ ...(item.externalContract && { externalContract: true })
287
297
  };
288
298
  }),
289
299
  },
package/core/reporting.js CHANGED
@@ -538,28 +538,23 @@ function doctor(index, options = {}) {
538
538
  const fileCounts = { total: 0, scanned: 0 };
539
539
  const langs = {};
540
540
  let totalSymbols = 0; // counted post-filter for accuracy when --in is set
541
+ // Each category tracks: count = total OCCURRENCES (uses), fileCount = TRUE
542
+ // number of files affected (uncapped), files = a capped sample for display.
543
+ // Keeping count and fileCount distinct is what lets the formatter say
544
+ // "481 uses in 121 files" instead of mislabeling a file count as uses or
545
+ // presenting the 10-file display cap as the population (field-report #2).
546
+ const BLINDSPOT_FILE_CAP = 10;
541
547
  const blindSpots = {
542
- dynamicImports: { count: 0, files: [] },
543
- evalCalls: { count: 0, files: [] },
544
- reflection: { count: 0, files: [] },
545
- parseFailures: { count: 0, files: [] },
548
+ dynamicImports: { count: 0, fileCount: 0, files: [] },
549
+ evalCalls: { count: 0, fileCount: 0, files: [] },
550
+ reflection: { count: 0, fileCount: 0, files: [] },
551
+ parseFailures: { count: 0, fileCount: 0, files: [] },
546
552
  };
547
553
 
548
- // Reflection signals per language. These run textually over the source fast,
549
- // and acceptable since UCN already records dynamic-import counts at parse time.
550
- const REFLECTION_PATTERNS = {
551
- python: /\b(getattr|hasattr|setattr|__import__|importlib\.import_module)\s*\(/,
552
- javascript: /\bnew Function\s*\(|\bReflect\.\w+\s*\(/,
553
- typescript: /\bnew Function\s*\(|\bReflect\.\w+\s*\(/,
554
- go: /"reflect"|reflect\.\w+\s*\(/,
555
- java: /\.getDeclaredMethod\b|\.getMethod\b|\.getDeclaredField\b|Class\.forName\b/,
556
- rust: /\bAny::downcast/,
557
- };
558
- const EVAL_PATTERNS = {
559
- python: /\b(eval|exec)\s*\(/,
560
- javascript: /\beval\s*\(/,
561
- typescript: /\beval\s*\(/,
562
- };
554
+ // Reflection/eval signals come from the shared text-blind-spot counter
555
+ // (core/shared.js) the SAME routine detectCompleteness uses for the about
556
+ // footer, so the two never drift (field-report #2). Occurrence counts.
557
+ const { hasTextBlindspots, countTextBlindspots } = require('./shared');
563
558
 
564
559
  for (const [filePath, fe] of index.files) {
565
560
  fileCounts.total++;
@@ -574,29 +569,22 @@ function doctor(index, options = {}) {
574
569
  langs[lang].lines += fe.lines || 0;
575
570
  totalSymbols += (fe.symbols || []).length;
576
571
 
577
- if (fe.dynamicImports && fe.dynamicImports > 0) {
578
- blindSpots.dynamicImports.count += fe.dynamicImports;
579
- if (blindSpots.dynamicImports.files.length < 10) blindSpots.dynamicImports.files.push(rel);
580
- }
581
- if (fe.parseError) {
582
- blindSpots.parseFailures.count++;
583
- if (blindSpots.parseFailures.files.length < 10) blindSpots.parseFailures.files.push(rel);
584
- }
572
+ const recordBlind = (cat, occurrences) => {
573
+ if (occurrences <= 0) return;
574
+ cat.count += occurrences;
575
+ cat.fileCount++;
576
+ if (cat.files.length < BLINDSPOT_FILE_CAP) cat.files.push(rel);
577
+ };
578
+
579
+ if (fe.dynamicImports && fe.dynamicImports > 0) recordBlind(blindSpots.dynamicImports, fe.dynamicImports);
580
+ if (fe.parseError) recordBlind(blindSpots.parseFailures, 1);
585
581
 
586
- // Read file once for eval/reflection signals
587
- const evalRe = EVAL_PATTERNS[lang];
588
- const reflRe = REFLECTION_PATTERNS[lang];
589
- if (evalRe || reflRe) {
582
+ // Read file once for eval/reflection signals (shared counter).
583
+ if (hasTextBlindspots(lang)) {
590
584
  try {
591
- const content = fs.readFileSync(filePath, 'utf-8');
592
- if (evalRe && evalRe.test(content)) {
593
- blindSpots.evalCalls.count++;
594
- if (blindSpots.evalCalls.files.length < 10) blindSpots.evalCalls.files.push(rel);
595
- }
596
- if (reflRe && reflRe.test(content)) {
597
- blindSpots.reflection.count++;
598
- if (blindSpots.reflection.files.length < 10) blindSpots.reflection.files.push(rel);
599
- }
585
+ const bs = countTextBlindspots(fs.readFileSync(filePath, 'utf-8'), lang);
586
+ recordBlind(blindSpots.evalCalls, bs.eval);
587
+ recordBlind(blindSpots.reflection, bs.reflection);
600
588
  } catch (e) { /* ignore read errors */ }
601
589
  }
602
590
  }
@@ -620,57 +608,87 @@ function doctor(index, options = {}) {
620
608
 
621
609
  // Compute trust verdict.
622
610
  //
623
- // 1. If a deep sample produced no edges (empty project, --in matches nothing),
624
- // don't pretend that's "0% confident" return UNKNOWN.
625
- // 2. Coverage gives the headline %, but blind spots (eval/reflection/dynamic
626
- // imports) downgrade the verdict by one tier each — a project that resolves
627
- // 99% of edges but is full of `getattr` is not actually "HIGH" trust.
628
- // 3. Parse failures always cap at MEDIUM regardless of coverage.
611
+ // Field-report #1: the old logic dropped the tier by one PER blind-spot
612
+ // category present, so any non-trivial Python/TS project (all of which have
613
+ // some getattr/eval/dynamic import) was forced to LOW even when --deep
614
+ // measured ~99% of edges at confidence 0.5 — a self-contradicting verdict
615
+ // ("99.1% ... LOW") that trains agents to distrust a healthy index. The fix:
616
+ // - When --deep coverage exists it drives the tier. Coverage measures the
617
+ // CONFIDENCE of edges UCN FOUND, NOT completeness — a reflection-hidden
618
+ // edge is absent from the sample, never a low-confidence edge dragging
619
+ // the % down — so sparse blind spots are a CAVEAT, while PERVASIVE ones
620
+ // (a large share of files) can hide edges the sample can't see and cap
621
+ // the verdict at MEDIUM (density, not mere presence; see below).
622
+ // - Parse failures are a separate exception: a file UCN couldn't parse is
623
+ // not in the sample at all, a genuine uncounted hole → cap at MEDIUM.
624
+ // - Without --deep there is no measurement, so blind spots are the only
625
+ // signal — but bounded to ONE tier total (not one per category), so a
626
+ // handful of getattr doesn't read as untrustworthy.
629
627
  let trust = 'UNKNOWN';
630
628
  let trustReason = '';
631
- const reasons = [];
629
+ const tier = ['HIGH', 'MEDIUM', 'LOW'];
630
+
631
+ const blindSignals = [];
632
+ if (blindSpots.parseFailures.count > 0) blindSignals.push(`${blindSpots.parseFailures.count} parse failure(s)`);
633
+ if (blindSpots.evalCalls.count > 0) blindSignals.push(`${blindSpots.evalCalls.count} eval/exec use(s) in ${blindSpots.evalCalls.fileCount} file(s)`);
634
+ if (blindSpots.reflection.count > 0) blindSignals.push(`${blindSpots.reflection.count} reflection use(s) in ${blindSpots.reflection.fileCount} file(s)`);
635
+ if (blindSpots.dynamicImports.count > 0) blindSignals.push(`${blindSpots.dynamicImports.count} dynamic import(s) in ${blindSpots.dynamicImports.fileCount} file(s)`);
632
636
 
633
637
  if (coverage && coverage.total > 0) {
634
638
  const safe = coverage.high + coverage.medium;
635
639
  const safePct = safe / coverage.total;
636
- let baseLevel;
637
- if (safePct >= 0.85) baseLevel = 'HIGH';
638
- else if (safePct >= 0.6) baseLevel = 'MEDIUM';
639
- else baseLevel = 'LOW';
640
- reasons.push(`${(safePct * 100).toFixed(1)}% of edges have confidence 0.5`);
641
-
642
- // Blind-spot downgradeseach kind drops one tier.
643
- const tier = ['HIGH', 'MEDIUM', 'LOW'];
644
- let idx = tier.indexOf(baseLevel);
645
- const blindSignals = [];
646
- if (blindSpots.parseFailures.count > 0) { idx = Math.max(idx, 1); blindSignals.push(`${blindSpots.parseFailures.count} parse failure(s)`); }
647
- if (blindSpots.evalCalls.count > 0) { idx = Math.min(2, idx + 1); blindSignals.push(`${blindSpots.evalCalls.count} eval call(s)`); }
648
- if (blindSpots.reflection.count > 0) { idx = Math.min(2, idx + 1); blindSignals.push(`${blindSpots.reflection.count} reflection use(s)`); }
649
- if (blindSpots.dynamicImports.count > 0) { idx = Math.min(2, idx + 1); blindSignals.push(`${blindSpots.dynamicImports.count} dynamic import(s)`); }
640
+ let idx = safePct >= 0.85 ? 0 : safePct >= 0.6 ? 1 : 2;
641
+ // Parse failures: unparsed files aren't in the sample at all.
642
+ if (blindSpots.parseFailures.count > 0) idx = Math.max(idx, 1);
643
+ // Coverage measures the CONFIDENCE of edges UCN found, NOT completeness:
644
+ // a call hidden behind reflection/dynamic dispatch is simply absent from
645
+ // findCallers' result, never a low-confidence edge that drags the % down.
646
+ // So when blind spots are PERVASIVE affecting a large share of files —
647
+ // they can hide a real fraction of the call graph that the sample can't
648
+ // see, and the verdict is capped at MEDIUM. Density, not mere presence:
649
+ // a handful of getattr stays a caveat (the old code dropped a tier per
650
+ // category, forcing every project to LOW); reflection across half the
651
+ // files does cap. Gated on a file-count floor file share is meaningless
652
+ // for a handful of files, so small projects ride on coverage alone.
653
+ const scanned = fileCounts.scanned || 1;
654
+ const share = (fc) => fc / scanned;
655
+ const pervasiveBlindSpot = scanned >= 10 && (
656
+ share(blindSpots.reflection.fileCount) >= 0.5 ||
657
+ share(blindSpots.dynamicImports.fileCount) >= 0.4 ||
658
+ share(blindSpots.evalCalls.fileCount) >= 0.15
659
+ );
660
+ const baseIdx = idx;
661
+ if (pervasiveBlindSpot) idx = Math.max(idx, 1);
662
+ const capped = idx > baseIdx;
650
663
  trust = tier[idx];
651
- if (blindSignals.length) reasons.push(`blind spots: ${blindSignals.join(', ')}`);
664
+ const reasons = [`${(safePct * 100).toFixed(1)}% of found edges have confidence ≥ 0.5`];
665
+ if (blindSignals.length) {
666
+ reasons.push(capped
667
+ ? `capped at MEDIUM — pervasive blind spots may hide edges the sample can't see: ${blindSignals.join(', ')}`
668
+ : `blind spots (caveat — coverage measures found edges, not completeness): ${blindSignals.join(', ')}`);
669
+ }
652
670
  trustReason = reasons.join('; ');
653
671
  } else if (coverage) {
654
672
  // Sampled but zero edges — can't say anything about confidence.
655
673
  trust = 'UNKNOWN';
656
674
  trustReason = 'no edges sampled (empty scope or filter matched nothing)';
657
675
  } else if (fileCounts.scanned > 0) {
658
- // Cheap path (no --deep): use blind-spot signals.
659
- const tier = ['HIGH', 'MEDIUM', 'LOW'];
676
+ // Cheap path (no --deep): no measurement, so blind spots are the only
677
+ // signal — bounded to one tier total. Run --deep for a measured verdict.
660
678
  let idx = 0;
661
- const blindSignals = [];
662
- if (blindSpots.parseFailures.count > 0) { idx = Math.max(idx, 1); blindSignals.push(`${blindSpots.parseFailures.count} parse failure(s)`); }
663
- if (blindSpots.evalCalls.count > 0) { idx = Math.min(2, idx + 1); blindSignals.push(`${blindSpots.evalCalls.count} eval call(s)`); }
664
- if (blindSpots.reflection.count > 0) { idx = Math.min(2, idx + 1); blindSignals.push(`${blindSpots.reflection.count} reflection use(s)`); }
665
- if (blindSpots.dynamicImports.count > 0) { idx = Math.min(2, idx + 1); blindSignals.push(`${blindSpots.dynamicImports.count} dynamic import(s)`); }
679
+ if (blindSpots.parseFailures.count > 0) idx = Math.max(idx, 1);
680
+ if (blindSpots.evalCalls.count + blindSpots.reflection.count + blindSpots.dynamicImports.count > 0) {
681
+ idx = Math.min(2, idx + 1);
682
+ }
666
683
  trust = tier[idx];
667
684
  trustReason = blindSignals.length
668
- ? `coverage not deep-checked; blind spots: ${blindSignals.join(', ')}`
669
- : 'no parse failures; coverage not deep-checked';
685
+ ? `coverage not deep-checked (run --deep); blind spots: ${blindSignals.join(', ')}`
686
+ : 'no parse failures; coverage not deep-checked (run --deep)';
670
687
  }
671
688
 
672
689
  return {
673
690
  root: index.root,
691
+ version: require('../package.json').version, // running ucn version — surfaces MCP/CLI drift (field-report #3)
674
692
  files: fileCounts,
675
693
  symbols: totalSymbols,
676
694
  languages: langs,
package/core/shared.js CHANGED
@@ -139,6 +139,69 @@ function looksLikeHandle(input) {
139
139
  return /^.+:\d+(?::.+)?$/.test(input);
140
140
  }
141
141
 
142
+ /**
143
+ * Explicit override marker on a method definition (fix #210). Marker fields are
144
+ * language-disjoint and compiler-checked syntax (never inferred): traitImpl is
145
+ * Rust's `impl Trait`, an 'override' modifier is Java's lowercased @Override, an
146
+ * override-bearing memberType is TS's `override` keyword, and an override
147
+ * decorator is Python's typing.@override. Shared by the external-contract
148
+ * reasoning in both the caller dispatch gate and deadcode (out-of-tree override
149
+ * suppression) — one source of truth so a new marker is added once, not in two
150
+ * drifting copies.
151
+ */
152
+ function isOverrideMarked(def) {
153
+ if (def.traitImpl) return true;
154
+ const mods = def.modifiers || [];
155
+ if (mods.includes('override')) return true;
156
+ if (def.memberType && /\boverride\b/.test(def.memberType)) return true;
157
+ if (def.decorators && def.decorators.some(d =>
158
+ String(d).replace(/\(.*$/, '').split('.').pop() === 'override')) return true;
159
+ return false;
160
+ }
161
+
162
+ // Per-language text patterns for the "blind spots" UCN's AST can't follow:
163
+ // eval/exec-style code execution and reflection (dynamic attribute access /
164
+ // dynamic dispatch). ONE source of truth so doctor's trust scan and
165
+ // detectCompleteness's about-footer warning count identically (field-report #2:
166
+ // they used to diverge — doctor 497 reflection vs footer 194, eval 3 vs 2 —
167
+ // because each kept its own regex set). Dynamic imports are NOT here: those are
168
+ // structural (fileEntry.dynamicImports), the AST-accurate count both paths share.
169
+ // `new Function(...)` is categorized as eval (code execution), not reflection.
170
+ const BLINDSPOT_TEXT_PATTERNS = {
171
+ reflection: {
172
+ python: /\b(getattr|hasattr|setattr|__import__|importlib\.import_module)\s*\(/g,
173
+ javascript: /\bReflect\.\w+\s*\(/g,
174
+ typescript: /\bReflect\.\w+\s*\(/g,
175
+ go: /\breflect\.\w+\s*\(/g,
176
+ java: /\.getDeclaredMethod\b|\.getMethod\b|\.getDeclaredField\b|Class\.forName\b/g,
177
+ rust: /\bAny::downcast/g,
178
+ },
179
+ eval: {
180
+ python: /\b(eval|exec)\s*\(/g,
181
+ javascript: /\beval\s*\(|\bnew\s+Function\s*\(/g,
182
+ typescript: /\beval\s*\(|\bnew\s+Function\s*\(/g,
183
+ },
184
+ };
185
+
186
+ /** True when a language has any text-blind-spot pattern (so callers can skip the file read otherwise). */
187
+ function hasTextBlindspots(language) {
188
+ return !!(BLINDSPOT_TEXT_PATTERNS.reflection[language] || BLINDSPOT_TEXT_PATTERNS.eval[language]);
189
+ }
190
+
191
+ /**
192
+ * Count text-detected blind spots (eval/exec, reflection) in one file's source.
193
+ * Returns { eval, reflection } OCCURRENCE counts (global match). Shared by doctor
194
+ * and detectCompleteness so both report the same numbers (field-report #2).
195
+ */
196
+ function countTextBlindspots(content, language) {
197
+ const reRe = BLINDSPOT_TEXT_PATTERNS.reflection[language];
198
+ const evRe = BLINDSPOT_TEXT_PATTERNS.eval[language];
199
+ return {
200
+ eval: evRe ? (content.match(evRe) || []).length : 0,
201
+ reflection: reRe ? (content.match(reRe) || []).length : 0,
202
+ };
203
+ }
204
+
142
205
  module.exports = {
143
206
  pickBestDefinition,
144
207
  addTestExclusions,
@@ -148,4 +211,7 @@ module.exports = {
148
211
  parseSymbolHandle,
149
212
  looksLikeHandle,
150
213
  isTestPath,
214
+ isOverrideMarked,
215
+ hasTextBlindspots,
216
+ countTextBlindspots,
151
217
  };
package/mcp/server.js CHANGED
@@ -576,7 +576,8 @@ server.registerTool(
576
576
  let dcText = output.formatDeadcode(result, {
577
577
  top: ep.top || 0,
578
578
  decoratedHint: !ep.includeDecorated && result.excludedDecorated > 0 ? `${result.excludedDecorated} decorated/annotated symbol(s) hidden (framework-registered). Use include_decorated=true to include them.` : undefined,
579
- exportedHint: !ep.includeExported && result.excludedExported > 0 ? `${result.excludedExported} exported symbol(s) excluded (all have callers). Use include_exported=true to audit them.` : undefined
579
+ exportedHint: !ep.includeExported && result.excludedExported > 0 ? `${result.excludedExported} exported symbol(s) excluded (all have callers). Use include_exported=true to audit them.` : undefined,
580
+ externalContractHint: !ep.includeExported && result.excludedExternalContract > 0 ? `${result.excludedExternalContract} symbol(s) hidden (override an out-of-tree base class — reachable via external contract, not dead). Use include_exported=true to include them.` : undefined
580
581
  });
581
582
  if (dcNote) dcText += '\n\n' + dcNote;
582
583
  return tr(dcText);
@@ -790,7 +791,9 @@ server.registerTool(
790
791
  async function main() {
791
792
  const transport = new StdioServerTransport();
792
793
  await server.connect(transport);
793
- console.error('UCN MCP server running on stdio');
794
+ // Print the running version so MCP-vs-CLI drift is visible (field-report #3:
795
+ // a stale `npx -y ucn` cache can silently run an older engine than the CLI).
796
+ console.error(`UCN MCP server v${require('../package.json').version} running on stdio`);
794
797
  }
795
798
 
796
799
  main().catch(e => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
4
4
  "mcpName": "io.github.mleoca/ucn",
5
5
  "description": "Code intelligence toolkit for AI agents — extract functions, trace call chains, find callers, detect dead code without reading entire files. Works as MCP server, CLI, or agent skill. Supports JS/TS, Python, Go, Rust, Java.",
6
6
  "main": "index.js",