ucn 4.1.2 → 4.2.1

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/reporting.js CHANGED
@@ -12,6 +12,7 @@ const { codeUnitCompare, CALLABLE_SYMBOL_KINDS } = require('./shared');
12
12
  const { _declaredFieldType, _projectTopLevelNames } = require('./callers');
13
13
  const path = require('path');
14
14
  const { isTestFile } = require('./discovery');
15
+ const { summarizeCommandTrust } = require('./trust-matrix');
15
16
 
16
17
  /**
17
18
  * Get project statistics: file counts, symbol counts, LOC, language breakdown.
@@ -23,7 +24,7 @@ const { isTestFile } = require('./discovery');
23
24
  function getStats(index, options = {}) {
24
25
  // Count total symbols (not just unique names)
25
26
  let totalSymbols = 0;
26
- for (const [name, symbols] of index.symbols) {
27
+ for (const [, symbols] of index.symbols) {
27
28
  totalSymbols += symbols.length;
28
29
  }
29
30
 
@@ -37,7 +38,7 @@ function getStats(index, options = {}) {
37
38
  ...(index.truncated && { truncated: index.truncated })
38
39
  };
39
40
 
40
- for (const [filePath, fileEntry] of index.files) {
41
+ for (const [, fileEntry] of index.files) {
41
42
  const lang = fileEntry.language;
42
43
  if (!stats.byLanguage[lang]) {
43
44
  stats.byLanguage[lang] = { files: 0, lines: 0, symbols: 0 };
@@ -47,7 +48,7 @@ function getStats(index, options = {}) {
47
48
  stats.byLanguage[lang].symbols += fileEntry.symbols.length;
48
49
  }
49
50
 
50
- for (const [name, symbols] of index.symbols) {
51
+ for (const [, symbols] of index.symbols) {
51
52
  for (const sym of symbols) {
52
53
  if (!Object.hasOwn(stats.byType, sym.type)) {
53
54
  stats.byType[sym.type] = 0;
@@ -67,7 +68,7 @@ function getStats(index, options = {}) {
67
68
  // Per-function line counts for complexity audits
68
69
  if (options.functions) {
69
70
  const functions = [];
70
- for (const [name, symbols] of index.symbols) {
71
+ for (const [, symbols] of index.symbols) {
71
72
  for (const sym of symbols) {
72
73
  if (CALLABLE_SYMBOL_KINDS.has(sym.type)) {
73
74
  const lineCount = sym.endLine - sym.startLine + 1;
@@ -298,7 +299,7 @@ function getStats(index, options = {}) {
298
299
  const isMethodRow = ownerClasses.size > 0 &&
299
300
  (!representative || !!representative.className || !!representative.receiver);
300
301
 
301
- let count = 0;
302
+ let count;
302
303
  let approximate = false;
303
304
  if (!isMethodRow) {
304
305
  // Standalone function (or top-level package call): use bare-name calls
@@ -549,7 +550,7 @@ function getToc(index, options = {}) {
549
550
 
550
551
  /**
551
552
  * Project trust report. Tells the caller how much UCN itself trusts the index
552
- * for this project: resolution coverage, blind spots (dynamic imports, eval,
553
+ * for this project: sampled resolution evidence, blind spots (dynamic imports, eval,
553
554
  * reflection), parse failures, and a quick verdict.
554
555
  *
555
556
  * Cheap-by-default: counts + blind-spot scan are O(files). The expensive
@@ -560,9 +561,6 @@ function getToc(index, options = {}) {
560
561
  * @param {object} options - { deep, sampleSize, in, file }
561
562
  */
562
563
  function doctor(index, options = {}) {
563
- const { detectLanguage, langTraits } = require('../languages');
564
- const path = require('path');
565
-
566
564
  const inFilter = options.in || options.file || null;
567
565
  const matchInFilter = (rel) => {
568
566
  if (!inFilter) return true;
@@ -583,6 +581,7 @@ function doctor(index, options = {}) {
583
581
  evalCalls: { count: 0, fileCount: 0, files: [] },
584
582
  reflection: { count: 0, fileCount: 0, files: [] },
585
583
  parseFailures: { count: 0, fileCount: 0, files: [] },
584
+ parseRecoveries:{ count: 0, fileCount: 0, files: [] },
586
585
  };
587
586
 
588
587
  // Reflection/eval signals come from the shared text-blind-spot counter
@@ -612,6 +611,7 @@ function doctor(index, options = {}) {
612
611
 
613
612
  if (fe.dynamicImports && fe.dynamicImports > 0) recordBlind(blindSpots.dynamicImports, fe.dynamicImports);
614
613
  if (fe.parseError) recordBlind(blindSpots.parseFailures, 1);
614
+ if (fe.parseRecovery) recordBlind(blindSpots.parseRecoveries, 1);
615
615
 
616
616
  // Read file once for eval/reflection signals (shared counter).
617
617
  if (hasTextBlindspots(lang)) {
@@ -623,12 +623,30 @@ function doctor(index, options = {}) {
623
623
  }
624
624
  }
625
625
 
626
- // Resolution coverage sampled by default to keep doctor fast.
627
- let coverage = null;
626
+ // Files that failed before a fileEntry could be created are absent from
627
+ // index.files, so the loop above can never see them. They are the most
628
+ // important parse failures to surface: otherwise doctor can report a
629
+ // clean index precisely when entire files are missing from it.
630
+ const recordedParseFailureFiles = new Set(blindSpots.parseFailures.files);
631
+ for (const failedPath of index.failedFiles || []) {
632
+ const rel = path.relative(index.root, failedPath);
633
+ if (!matchInFilter(rel) || recordedParseFailureFiles.has(rel)) continue;
634
+ blindSpots.parseFailures.count++;
635
+ blindSpots.parseFailures.fileCount++;
636
+ fileCounts.failed = (fileCounts.failed || 0) + 1;
637
+ if (blindSpots.parseFailures.files.length < BLINDSPOT_FILE_CAP) {
638
+ blindSpots.parseFailures.files.push(rel);
639
+ }
640
+ recordedParseFailureFiles.add(rel);
641
+ }
642
+
643
+ // Evidence profile — sampled only in deep mode. This is deliberately NOT
644
+ // called "accuracy" or "coverage": it describes how UCN classified edges
645
+ // it found. Compiler/LSP oracle evaluation is the accuracy measurement.
646
+ let evidenceProfile = null;
628
647
  if (options.deep || options.sampleSize) {
629
- coverage = computeCoverageSample(index, {
648
+ evidenceProfile = computeEvidenceProfile(index, {
630
649
  sampleSize: options.sampleSize || 200,
631
- inFilter,
632
650
  matchInFilter,
633
651
  });
634
652
  }
@@ -640,86 +658,72 @@ function doctor(index, options = {}) {
640
658
  cache.buildMs = index.buildTime || null;
641
659
  } catch (e) { /* ignore */ }
642
660
 
643
- // Compute trust verdict.
644
- //
645
- // Field-report #1: the old logic dropped the tier by one PER blind-spot
646
- // category present, so any non-trivial Python/TS project (all of which have
647
- // some getattr/eval/dynamic import) was forced to LOW even when --deep
648
- // measured ~99% of edges at confidence ≥ 0.5 — a self-contradicting verdict
649
- // ("99.1% ... LOW") that trains agents to distrust a healthy index. The fix:
650
- // - When --deep coverage exists it drives the tier. Coverage measures the
651
- // CONFIDENCE of edges UCN FOUND, NOT completeness — a reflection-hidden
652
- // edge is absent from the sample, never a low-confidence edge dragging
653
- // the % down — so sparse blind spots are a CAVEAT, while PERVASIVE ones
654
- // (a large share of files) can hide edges the sample can't see and cap
655
- // the verdict at MEDIUM (density, not mere presence; see below).
656
- // - Parse failures are a separate exception: a file UCN couldn't parse is
657
- // not in the sample at all, a genuine uncounted hole → cap at MEDIUM.
658
- // - Without --deep there is no measurement, so blind spots are the only
659
- // signal — but bounded to ONE tier total (not one per category), so a
660
- // handful of getattr doesn't read as untrustworthy.
661
- let trust = 'UNKNOWN';
662
- let trustReason = '';
663
- const tier = ['HIGH', 'MEDIUM', 'LOW'];
664
-
665
661
  const blindSignals = [];
666
662
  if (blindSpots.parseFailures.count > 0) blindSignals.push(`${blindSpots.parseFailures.count} parse failure(s)`);
663
+ if (blindSpots.parseRecoveries.count > 0) blindSignals.push(`${blindSpots.parseRecoveries.count} parse-recovery file(s)`);
667
664
  if (blindSpots.evalCalls.count > 0) blindSignals.push(`${blindSpots.evalCalls.count} eval/exec use(s) in ${blindSpots.evalCalls.fileCount} file(s)`);
668
665
  if (blindSpots.reflection.count > 0) blindSignals.push(`${blindSpots.reflection.count} reflection use(s) in ${blindSpots.reflection.fileCount} file(s)`);
669
666
  if (blindSpots.dynamicImports.count > 0) blindSignals.push(`${blindSpots.dynamicImports.count} dynamic import(s) in ${blindSpots.dynamicImports.fileCount} file(s)`);
670
667
 
671
- if (coverage && coverage.total > 0) {
672
- const safe = coverage.high + coverage.medium;
673
- const safePct = safe / coverage.total;
674
- let idx = safePct >= 0.85 ? 0 : safePct >= 0.6 ? 1 : 2;
675
- // Parse failures: unparsed files aren't in the sample at all.
676
- if (blindSpots.parseFailures.count > 0) idx = Math.max(idx, 1);
677
- // Coverage measures the CONFIDENCE of edges UCN found, NOT completeness:
678
- // a call hidden behind reflection/dynamic dispatch is simply absent from
679
- // findCallers' result, never a low-confidence edge that drags the % down.
680
- // So when blind spots are PERVASIVE — affecting a large share of files —
681
- // they can hide a real fraction of the call graph that the sample can't
682
- // see, and the verdict is capped at MEDIUM. Density, not mere presence:
683
- // a handful of getattr stays a caveat (the old code dropped a tier per
684
- // category, forcing every project to LOW); reflection across half the
685
- // files does cap. Gated on a file-count floor file share is meaningless
686
- // for a handful of files, so small projects ride on coverage alone.
687
- const scanned = fileCounts.scanned || 1;
688
- const share = (fc) => fc / scanned;
689
- const pervasiveBlindSpot = scanned >= 10 && (
690
- share(blindSpots.reflection.fileCount) >= 0.5 ||
691
- share(blindSpots.dynamicImports.fileCount) >= 0.4 ||
692
- share(blindSpots.evalCalls.fileCount) >= 0.15
693
- );
694
- const baseIdx = idx;
695
- if (pervasiveBlindSpot) idx = Math.max(idx, 1);
696
- const capped = idx > baseIdx;
697
- trust = tier[idx];
698
- const reasons = [`${(safePct * 100).toFixed(1)}% of found edges have confidence ≥ 0.5`];
699
- if (blindSignals.length) {
700
- reasons.push(capped
701
- ? `capped at MEDIUM — pervasive blind spots may hide edges the sample can't see: ${blindSignals.join(', ')}`
702
- : `blind spots (caveat — coverage measures found edges, not completeness): ${blindSignals.join(', ')}`);
703
- }
704
- trustReason = reasons.join('; ');
705
- } else if (coverage) {
706
- // Sampled but zero edges — can't say anything about confidence.
707
- trust = 'UNKNOWN';
708
- trustReason = 'no edges sampled (empty scope or filter matched nothing)';
709
- } else if (fileCounts.scanned > 0) {
710
- // Cheap path (no --deep): no measurement, so blind spots are the only
711
- // signal — bounded to one tier total. Run --deep for a measured verdict.
712
- let idx = 0;
713
- if (blindSpots.parseFailures.count > 0) idx = Math.max(idx, 1);
714
- if (blindSpots.evalCalls.count + blindSpots.reflection.count + blindSpots.dynamicImports.count > 0) {
715
- idx = Math.min(2, idx + 1);
668
+ // Trust is task-specific. A healthy index can be excellent for navigation
669
+ // while still requiring review before a breaking refactor or deletion.
670
+ // Never infer semantic accuracy from rule-assigned confidence decimals.
671
+ const indexLevel = fileCounts.scanned === 0 ? 'UNKNOWN'
672
+ : blindSpots.parseFailures.count > 0 || blindSpots.parseRecoveries.count > 0 ||
673
+ cache.fresh === false ? 'MEDIUM' : 'HIGH';
674
+ const indexReason = fileCounts.scanned === 0 ? 'empty scope'
675
+ : blindSpots.parseFailures.count > 0
676
+ ? `${blindSpots.parseFailures.count} file(s) failed to parse`
677
+ : blindSpots.parseRecoveries.count > 0
678
+ ? `${blindSpots.parseRecoveries.count} file(s) required parser recovery; indexed results may be partial`
679
+ : cache.fresh === false ? 'index cache is stale' : 'fresh index; no parse failures';
680
+
681
+ let evidenceLevel = 'UNKNOWN';
682
+ let evidenceReason = 'not sampled; run --deep for a stratified evidence profile';
683
+ if (evidenceProfile) {
684
+ if (evidenceProfile.total === 0) {
685
+ evidenceReason = 'sample contained no caller edges';
686
+ } else {
687
+ const confirmedShare = evidenceProfile.confirmed / evidenceProfile.total;
688
+ evidenceLevel = !evidenceProfile.adequate ? 'UNKNOWN'
689
+ : confirmedShare >= 0.85 ? 'HIGH'
690
+ : confirmedShare >= 0.55 ? 'MEDIUM' : 'LOW';
691
+ evidenceReason = `${(confirmedShare * 100).toFixed(1)}% confirmed-evidence edges across ${evidenceProfile.sampled} pinned definitions`;
692
+ if (!evidenceProfile.adequate) evidenceReason += '; sample too small for a readiness decision';
716
693
  }
717
- trust = tier[idx];
718
- trustReason = blindSignals.length
719
- ? `coverage not deep-checked (run --deep); blind spots: ${blindSignals.join(', ')}`
720
- : 'no parse failures; coverage not deep-checked (run --deep)';
721
694
  }
722
695
 
696
+ const dynamicCount = blindSpots.evalCalls.count + blindSpots.reflection.count + blindSpots.dynamicImports.count;
697
+ const semanticLevel = blindSpots.parseFailures.count > 0 || blindSpots.parseRecoveries.count > 0 ? 'LOW'
698
+ : dynamicCount > 0 ? 'MEDIUM' : 'UNKNOWN';
699
+ const semanticReason = blindSignals.length
700
+ ? `semantic recall may miss runtime-resolved edges: ${blindSignals.join(', ')}`
701
+ : 'no known runtime blind spots detected; alias/dynamic completeness is not compiler-verified locally';
702
+
703
+ const navigationLevel = indexLevel;
704
+ const refactorLevel = indexLevel === 'UNKNOWN' || evidenceLevel === 'UNKNOWN' ? 'UNKNOWN'
705
+ : indexLevel === 'HIGH' && evidenceLevel === 'HIGH' && semanticLevel === 'UNKNOWN' ? 'MEDIUM'
706
+ : indexLevel === 'LOW' || evidenceLevel === 'LOW' || semanticLevel === 'LOW' ? 'LOW' : 'MEDIUM';
707
+ const deletionLevel = refactorLevel === 'LOW' ? 'LOW' : 'REVIEW';
708
+ const dimensions = {
709
+ index: { level: indexLevel, reason: indexReason },
710
+ evidence: { level: evidenceLevel, reason: evidenceReason },
711
+ semanticRecall: { level: semanticLevel, reason: semanticReason },
712
+ navigation: { level: navigationLevel, reason: indexReason },
713
+ refactor: {
714
+ level: refactorLevel,
715
+ reason: refactorLevel === 'UNKNOWN'
716
+ ? 'run --deep; review unverified and non-call occurrences before changing code'
717
+ : 'text-ground accounting is available; aliases, reflection, and unverified edges still require review',
718
+ },
719
+ deletion: {
720
+ level: deletionLevel,
721
+ reason: 'deletion additionally requires usages/deadcode review and tests; caller accounting alone is insufficient',
722
+ },
723
+ };
724
+ const trust = refactorLevel;
725
+ const trustReason = dimensions.refactor.reason;
726
+
723
727
  return {
724
728
  root: index.root,
725
729
  version: require('../package.json').version, // running ucn version — surfaces MCP/CLI drift (field-report #3)
@@ -727,10 +731,16 @@ function doctor(index, options = {}) {
727
731
  symbols: totalSymbols,
728
732
  languages: langs,
729
733
  blindSpots,
730
- coverage,
734
+ evidenceProfile,
735
+ // Backward-compatible field name. `kind` prevents consumers from
736
+ // mistaking this for semantic coverage or measured accuracy.
737
+ coverage: evidenceProfile,
731
738
  cache,
739
+ commandTrust: summarizeCommandTrust(),
732
740
  trust,
733
741
  trustReason,
742
+ trustScope: 'refactor-readiness',
743
+ dimensions,
734
744
  ...(inFilter && { filter: inFilter }),
735
745
  };
736
746
  }
@@ -739,35 +749,83 @@ function doctor(index, options = {}) {
739
749
  * Sample-based coverage: pick up to N symbols, run findCallers, bucket confidence.
740
750
  * Doesn't pretend to be exhaustive — meant for a fast trust signal, not an audit.
741
751
  */
742
- function computeCoverageSample(index, { sampleSize, inFilter, matchInFilter }) {
743
- const buckets = { high: 0, medium: 0, low: 0, total: 0, sampled: 0 };
744
- const symbolNames = [];
745
- for (const [name, arr] of index.symbols) {
752
+ function computeEvidenceProfile(index, { sampleSize, matchInFilter }) {
753
+ const profile = {
754
+ kind: 'evidence-profile-not-accuracy',
755
+ high: 0, medium: 0, low: 0,
756
+ confirmed: 0, unverified: 0, total: 0,
757
+ sampled: 0, candidateSymbols: 0,
758
+ adequate: false, representative: false,
759
+ byLanguage: Object.create(null), byKind: Object.create(null),
760
+ };
761
+ const groups = new Map();
762
+ const seenHandles = new Set();
763
+ for (const [, arr] of index.symbols) {
746
764
  for (const sym of arr) {
747
- if (!sym || !sym.relativePath) continue;
748
- if (!matchInFilter(sym.relativePath)) continue;
749
- if (sym.type === 'method' || sym.type === 'function' || sym.type === 'constructor') {
750
- symbolNames.push(name);
751
- if (symbolNames.length >= sampleSize * 2) break; // cap collection cost
765
+ if (!sym || !sym.relativePath || !matchInFilter(sym.relativePath)) continue;
766
+ if (sym.type !== 'method' && sym.type !== 'function' && sym.type !== 'constructor') continue;
767
+ const handle = `${sym.relativePath}:${sym.startLine}:${sym.name}`;
768
+ if (seenHandles.has(handle)) continue;
769
+ seenHandles.add(handle);
770
+ const lang = index.files.get(sym.file)?.language || 'unknown';
771
+ const key = `${lang}:${sym.type}`;
772
+ if (!groups.has(key)) groups.set(key, []);
773
+ groups.get(key).push({ sym, handle, lang });
774
+ }
775
+ }
776
+ profile.candidateSymbols = seenHandles.size;
777
+ for (const items of groups.values()) {
778
+ items.sort((a, b) => codeUnitCompare(a.handle, b.handle));
779
+ }
780
+
781
+ // Round-robin across language+kind buckets so discovery order, duplicate
782
+ // names, and one large language cannot dominate the verdict.
783
+ const orderedGroups = [...groups.entries()].sort((a, b) => codeUnitCompare(a[0], b[0]));
784
+ const sample = [];
785
+ for (let round = 0; sample.length < sampleSize; round++) {
786
+ let added = false;
787
+ for (const [, items] of orderedGroups) {
788
+ if (items[round]) {
789
+ sample.push(items[round]);
790
+ added = true;
791
+ if (sample.length >= sampleSize) break;
752
792
  }
753
793
  }
754
- if (symbolNames.length >= sampleSize * 2) break;
794
+ if (!added) break;
755
795
  }
756
- // Take a slice (not random — deterministic for tests)
757
- const slice = symbolNames.slice(0, sampleSize);
758
- buckets.sampled = slice.length;
759
-
760
- for (const name of slice) {
761
- const callers = index.findCallers(name, { includeMethods: true, includeUncertain: true });
762
- for (const c of callers) {
763
- const conf = (c.confidence != null) ? c.confidence : 1;
764
- buckets.total++;
765
- if (conf > 0.8) buckets.high++;
766
- else if (conf >= 0.5) buckets.medium++;
767
- else buckets.low++;
796
+ profile.sampled = sample.length;
797
+
798
+ for (const { sym, lang } of sample) {
799
+ profile.byLanguage[lang] = (profile.byLanguage[lang] || 0) + 1;
800
+ profile.byKind[sym.type] = (profile.byKind[sym.type] || 0) + 1;
801
+ const callers = index.findCallers(sym.name, {
802
+ includeMethods: true,
803
+ includeUncertain: true,
804
+ targetDefinitions: [sym],
805
+ collectAccount: true,
806
+ });
807
+ const allEdges = [...callers, ...(callers.unverifiedEntries || [])];
808
+ const seenSites = new Set();
809
+ for (const c of allEdges) {
810
+ const site = `${c.file || c.relativePath}:${c.line}:${c.tier || c.reason || ''}`;
811
+ if (seenSites.has(site)) continue;
812
+ seenSites.add(site);
813
+ const conf = c.confidence != null ? c.confidence : 0;
814
+ profile.total++;
815
+ if (c.tier === 'unverified' || c.reason) profile.unverified++;
816
+ else profile.confirmed++;
817
+ if (conf > 0.8) profile.high++;
818
+ else if (conf >= 0.5) profile.medium++;
819
+ else profile.low++;
768
820
  }
769
821
  }
770
- return buckets;
822
+ const representedGroups = Object.keys(profile.byLanguage).length;
823
+ profile.representative = representedGroups === new Set(
824
+ [...groups.keys()].map(k => k.split(':')[0])
825
+ ).size;
826
+ const minSymbols = Math.min(30, profile.candidateSymbols);
827
+ profile.adequate = profile.sampled >= minSymbols && profile.total >= 20 && profile.representative;
828
+ return profile;
771
829
  }
772
830
 
773
831
  /**