ucn 4.0.1 → 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/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
  }
@@ -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
  }
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
@@ -159,6 +159,49 @@ function isOverrideMarked(def) {
159
159
  return false;
160
160
  }
161
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
+
162
205
  module.exports = {
163
206
  pickBestDefinition,
164
207
  addTestExclusions,
@@ -169,4 +212,6 @@ module.exports = {
169
212
  looksLikeHandle,
170
213
  isTestPath,
171
214
  isOverrideMarked,
215
+ hasTextBlindspots,
216
+ countTextBlindspots,
172
217
  };
package/mcp/server.js CHANGED
@@ -791,7 +791,9 @@ server.registerTool(
791
791
  async function main() {
792
792
  const transport = new StdioServerTransport();
793
793
  await server.connect(transport);
794
- 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`);
795
797
  }
796
798
 
797
799
  main().catch(e => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "4.0.1",
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",