ucn 4.0.1 → 4.1.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 (47) hide show
  1. package/.claude/skills/ucn/SKILL.md +31 -7
  2. package/README.md +89 -50
  3. package/cli/index.js +199 -94
  4. package/core/account.js +3 -1
  5. package/core/analysis.js +334 -316
  6. package/core/bridge.js +13 -8
  7. package/core/cache.js +109 -19
  8. package/core/callers.js +969 -77
  9. package/core/check.js +19 -8
  10. package/core/deadcode.js +368 -40
  11. package/core/discovery.js +31 -11
  12. package/core/entrypoints.js +149 -17
  13. package/core/execute.js +330 -43
  14. package/core/graph-build.js +61 -10
  15. package/core/graph.js +282 -61
  16. package/core/imports.js +72 -3
  17. package/core/output/analysis-ext.js +70 -10
  18. package/core/output/analysis.js +67 -33
  19. package/core/output/check.js +4 -1
  20. package/core/output/doctor.js +13 -3
  21. package/core/output/endpoints.js +8 -3
  22. package/core/output/extraction.js +12 -1
  23. package/core/output/find.js +23 -9
  24. package/core/output/graph.js +32 -9
  25. package/core/output/refactoring.js +147 -49
  26. package/core/output/reporting.js +104 -5
  27. package/core/output/search.js +30 -3
  28. package/core/output/shared.js +31 -4
  29. package/core/output/tracing.js +22 -11
  30. package/core/parser.js +8 -6
  31. package/core/project.js +167 -7
  32. package/core/registry.js +20 -16
  33. package/core/reporting.js +220 -84
  34. package/core/search.js +270 -55
  35. package/core/shared.js +285 -1
  36. package/core/stacktrace.js +23 -1
  37. package/core/tracing.js +278 -36
  38. package/core/verify.js +352 -349
  39. package/languages/go.js +29 -17
  40. package/languages/index.js +56 -0
  41. package/languages/java.js +133 -16
  42. package/languages/javascript.js +215 -27
  43. package/languages/python.js +113 -47
  44. package/languages/rust.js +89 -26
  45. package/languages/utils.js +35 -7
  46. package/mcp/server.js +72 -31
  47. package/package.json +4 -1
package/core/reporting.js CHANGED
@@ -8,6 +8,8 @@
8
8
  'use strict';
9
9
 
10
10
  const fs = require('fs');
11
+ const { codeUnitCompare, CALLABLE_SYMBOL_KINDS } = require('./shared');
12
+ const { _declaredFieldType, _projectTopLevelNames } = require('./callers');
11
13
  const path = require('path');
12
14
  const { isTestFile } = require('./discovery');
13
15
 
@@ -67,9 +69,7 @@ function getStats(index, options = {}) {
67
69
  const functions = [];
68
70
  for (const [name, symbols] of index.symbols) {
69
71
  for (const sym of symbols) {
70
- if (sym.type === 'function' || sym.type === 'method' || sym.type === 'static' ||
71
- sym.type === 'constructor' || sym.type === 'public' || sym.type === 'abstract' ||
72
- sym.type === 'classmethod') {
72
+ if (CALLABLE_SYMBOL_KINDS.has(sym.type)) {
73
73
  const lineCount = sym.endLine - sym.startLine + 1;
74
74
  const relativePath = sym.relativePath || (sym.file ? path.relative(index.root, sym.file) : '');
75
75
  functions.push({
@@ -97,10 +97,7 @@ function getStats(index, options = {}) {
97
97
  const top = options.top === 0
98
98
  ? 0
99
99
  : ((options.top != null && Number(options.top) > 0) ? Number(options.top) : 10);
100
- const FUNCTION_TYPES = new Set([
101
- 'function', 'method', 'static', 'constructor',
102
- 'public', 'abstract', 'classmethod'
103
- ]);
100
+ const FUNCTION_TYPES = CALLABLE_SYMBOL_KINDS;
104
101
 
105
102
  // Ensure the calls cache is fully populated before counting.
106
103
  // First-time stats --hot may need to parse files to extract calls;
@@ -135,6 +132,15 @@ function getStats(index, options = {}) {
135
132
  // Pre-compute import-alias sets per file. Used to distinguish `mod.foo()`
136
133
  // (resolves to top-level foo) from `obj.foo()` on a local variable.
137
134
  const fileImportAliases = new Map(); // filePath -> Set<string> of alias names
135
+ const fieldHopCache = new Map(); // rootType\0field -> declared type|null
136
+ // Names import-bound to an EXTERNAL module, per file (fix #256,
137
+ // dogfood-measured: 895 node:test `describe(...)` calls in test
138
+ // files were attributed to a project closure named `describe` —
139
+ // the #215 name discipline says an externally-bound bare name
140
+ // cannot reach a project def, so it never counts toward the hot
141
+ // leaderboard). Relative modules, resolved modules, and resolver
142
+ // gaps (first segment names a project path) all stay countable.
143
+ const fileExternalNames = new Map(); // filePath -> Set<string>
138
144
  for (const [filePath, fileEntry] of index.files) {
139
145
  const aliases = new Set();
140
146
  // importNames are the named imports/exports brought into this file.
@@ -147,6 +153,16 @@ function getStats(index, options = {}) {
147
153
  }
148
154
  }
149
155
  fileImportAliases.set(filePath, aliases);
156
+ let ext = null;
157
+ for (const b of (fileEntry.importBindings || [])) {
158
+ const mod = String(b.module || '');
159
+ if (!b.name || !mod || mod.startsWith('.') || mod.startsWith('/')) continue;
160
+ if (fileEntry.moduleResolved && fileEntry.moduleResolved[mod]) continue;
161
+ const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
162
+ if (firstSeg && _projectTopLevelNames(index).has(firstSeg)) continue;
163
+ (ext || (ext = new Set())).add(b.name);
164
+ }
165
+ if (ext) fileExternalNames.set(filePath, ext);
150
166
  }
151
167
 
152
168
  for (const [filePath, entry] of index.callsCache) {
@@ -164,6 +180,9 @@ function getStats(index, options = {}) {
164
180
  // Bare-name call: foo() or pkg.Foo() (Go package call has receiver
165
181
  // but isMethod:false — keep counting under bareName since they
166
182
  // resolve like top-level functions in their package).
183
+ // Externally-bound names are the external library's calls,
184
+ // never a project def's (fix #256).
185
+ if (fileExternalNames.get(filePath)?.has(c.name)) continue;
167
186
  bareNameCounts.set(c.name, (bareNameCounts.get(c.name) || 0) + 1);
168
187
  } else if (isSelfMethod) {
169
188
  // self/this.foo() — attributed to the enclosing class's foo
@@ -178,11 +197,26 @@ function getStats(index, options = {}) {
178
197
  importedReceiverCounts.set(c.name,
179
198
  (importedReceiverCounts.get(c.name) || 0) + 1);
180
199
  }
181
- if (c.receiverType) {
182
- let inner = methodByReceiverType.get(c.receiverType);
200
+ // Field-access receivers (fix #251): `tm.service.Save()`
201
+ // carries receiverRootType, not receiverType — the same
202
+ // #202/#231 declared-field hop the caller/callee engine
203
+ // uses. Without it, edges `context` confirms were
204
+ // invisible to the hot leaderboard.
205
+ let recvType = c.receiverType;
206
+ if (!recvType && c.receiverField && c.receiverRootType) {
207
+ const hopKey = `${c.receiverRootType}\u0000${c.receiverField}`;
208
+ if (!fieldHopCache.has(hopKey)) {
209
+ const lang = index.files.get(filePath)?.language;
210
+ fieldHopCache.set(hopKey,
211
+ lang ? _declaredFieldType(index, c.receiverRootType, c.receiverField, lang) : null);
212
+ }
213
+ recvType = fieldHopCache.get(hopKey);
214
+ }
215
+ if (recvType) {
216
+ let inner = methodByReceiverType.get(recvType);
183
217
  if (!inner) {
184
218
  inner = new Map();
185
- methodByReceiverType.set(c.receiverType, inner);
219
+ methodByReceiverType.set(recvType, inner);
186
220
  }
187
221
  inner.set(c.name, (inner.get(c.name) || 0) + 1);
188
222
  }
@@ -304,7 +338,7 @@ function getStats(index, options = {}) {
304
338
  if (approximate) usedHeuristicSplit = true;
305
339
  // Sort locations by (file, startLine) for stable display.
306
340
  locations.sort((a, b) =>
307
- a.file.localeCompare(b.file) ||
341
+ codeUnitCompare(a.file, b.file) ||
308
342
  (a.startLine || 0) - (b.startLine || 0)
309
343
  );
310
344
  const primary = locations[0];
@@ -329,7 +363,7 @@ function getStats(index, options = {}) {
329
363
  // Stable order: callCount desc, then (relativePath, startLine) asc.
330
364
  hotList.sort((a, b) =>
331
365
  (b.callCount - a.callCount) ||
332
- a.file.localeCompare(b.file) ||
366
+ codeUnitCompare(a.file, b.file) ||
333
367
  (a.startLine || 0) - (b.startLine || 0)
334
368
  );
335
369
 
@@ -538,28 +572,23 @@ function doctor(index, options = {}) {
538
572
  const fileCounts = { total: 0, scanned: 0 };
539
573
  const langs = {};
540
574
  let totalSymbols = 0; // counted post-filter for accuracy when --in is set
575
+ // Each category tracks: count = total OCCURRENCES (uses), fileCount = TRUE
576
+ // number of files affected (uncapped), files = a capped sample for display.
577
+ // Keeping count and fileCount distinct is what lets the formatter say
578
+ // "481 uses in 121 files" instead of mislabeling a file count as uses or
579
+ // presenting the 10-file display cap as the population (field-report #2).
580
+ const BLINDSPOT_FILE_CAP = 10;
541
581
  const blindSpots = {
542
- dynamicImports: { count: 0, files: [] },
543
- evalCalls: { count: 0, files: [] },
544
- reflection: { count: 0, files: [] },
545
- parseFailures: { count: 0, files: [] },
582
+ dynamicImports: { count: 0, fileCount: 0, files: [] },
583
+ evalCalls: { count: 0, fileCount: 0, files: [] },
584
+ reflection: { count: 0, fileCount: 0, files: [] },
585
+ parseFailures: { count: 0, fileCount: 0, files: [] },
546
586
  };
547
587
 
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
- };
588
+ // Reflection/eval signals come from the shared text-blind-spot counter
589
+ // (core/shared.js) the SAME routine detectCompleteness uses for the about
590
+ // footer, so the two never drift (field-report #2). Occurrence counts.
591
+ const { hasTextBlindspots, countTextBlindspots } = require('./shared');
563
592
 
564
593
  for (const [filePath, fe] of index.files) {
565
594
  fileCounts.total++;
@@ -574,29 +603,22 @@ function doctor(index, options = {}) {
574
603
  langs[lang].lines += fe.lines || 0;
575
604
  totalSymbols += (fe.symbols || []).length;
576
605
 
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
- }
606
+ const recordBlind = (cat, occurrences) => {
607
+ if (occurrences <= 0) return;
608
+ cat.count += occurrences;
609
+ cat.fileCount++;
610
+ if (cat.files.length < BLINDSPOT_FILE_CAP) cat.files.push(rel);
611
+ };
612
+
613
+ if (fe.dynamicImports && fe.dynamicImports > 0) recordBlind(blindSpots.dynamicImports, fe.dynamicImports);
614
+ if (fe.parseError) recordBlind(blindSpots.parseFailures, 1);
585
615
 
586
- // Read file once for eval/reflection signals
587
- const evalRe = EVAL_PATTERNS[lang];
588
- const reflRe = REFLECTION_PATTERNS[lang];
589
- if (evalRe || reflRe) {
616
+ // Read file once for eval/reflection signals (shared counter).
617
+ if (hasTextBlindspots(lang)) {
590
618
  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
- }
619
+ const bs = countTextBlindspots(fs.readFileSync(filePath, 'utf-8'), lang);
620
+ recordBlind(blindSpots.evalCalls, bs.eval);
621
+ recordBlind(blindSpots.reflection, bs.reflection);
600
622
  } catch (e) { /* ignore read errors */ }
601
623
  }
602
624
  }
@@ -620,57 +642,87 @@ function doctor(index, options = {}) {
620
642
 
621
643
  // Compute trust verdict.
622
644
  //
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.
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.
629
661
  let trust = 'UNKNOWN';
630
662
  let trustReason = '';
631
- const reasons = [];
663
+ const tier = ['HIGH', 'MEDIUM', 'LOW'];
664
+
665
+ const blindSignals = [];
666
+ if (blindSpots.parseFailures.count > 0) blindSignals.push(`${blindSpots.parseFailures.count} parse failure(s)`);
667
+ if (blindSpots.evalCalls.count > 0) blindSignals.push(`${blindSpots.evalCalls.count} eval/exec use(s) in ${blindSpots.evalCalls.fileCount} file(s)`);
668
+ if (blindSpots.reflection.count > 0) blindSignals.push(`${blindSpots.reflection.count} reflection use(s) in ${blindSpots.reflection.fileCount} file(s)`);
669
+ if (blindSpots.dynamicImports.count > 0) blindSignals.push(`${blindSpots.dynamicImports.count} dynamic import(s) in ${blindSpots.dynamicImports.fileCount} file(s)`);
632
670
 
633
671
  if (coverage && coverage.total > 0) {
634
672
  const safe = coverage.high + coverage.medium;
635
673
  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)`); }
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;
650
697
  trust = tier[idx];
651
- if (blindSignals.length) reasons.push(`blind spots: ${blindSignals.join(', ')}`);
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
+ }
652
704
  trustReason = reasons.join('; ');
653
705
  } else if (coverage) {
654
706
  // Sampled but zero edges — can't say anything about confidence.
655
707
  trust = 'UNKNOWN';
656
708
  trustReason = 'no edges sampled (empty scope or filter matched nothing)';
657
709
  } else if (fileCounts.scanned > 0) {
658
- // Cheap path (no --deep): use blind-spot signals.
659
- const tier = ['HIGH', 'MEDIUM', 'LOW'];
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.
660
712
  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)`); }
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);
716
+ }
666
717
  trust = tier[idx];
667
718
  trustReason = blindSignals.length
668
- ? `coverage not deep-checked; blind spots: ${blindSignals.join(', ')}`
669
- : 'no parse failures; coverage not deep-checked';
719
+ ? `coverage not deep-checked (run --deep); blind spots: ${blindSignals.join(', ')}`
720
+ : 'no parse failures; coverage not deep-checked (run --deep)';
670
721
  }
671
722
 
672
723
  return {
673
724
  root: index.root,
725
+ version: require('../package.json').version, // running ucn version — surfaces MCP/CLI drift (field-report #3)
674
726
  files: fileCounts,
675
727
  symbols: totalSymbols,
676
728
  languages: langs,
@@ -718,4 +770,88 @@ function computeCoverageSample(index, { sampleSize, inFilter, matchInFilter }) {
718
770
  return buckets;
719
771
  }
720
772
 
721
- module.exports = { getStats, getToc, doctor };
773
+ /**
774
+ * orient — one-call cold-repo orientation: size + language mix, densest
775
+ * directories, most-called functions, entry-point counts, and the doctor
776
+ * trust verdict. Composes existing engine reads; counts and pointers only
777
+ * (no caller claims, so no account — the toc/stats category).
778
+ */
779
+ function orient(index, options = {}) {
780
+ const top = options.top || 8;
781
+ // Fetch a deeper hot list so production functions survive the filter
782
+ // below even when test helpers dominate raw call counts.
783
+ const stats = getStats(index, { hot: true, top: Math.min(top * 5, 200) });
784
+ const health = doctor(index, {});
785
+
786
+ // Densest directories (leaf dirname rollup — "where does the code live")
787
+ const dirMap = new Map();
788
+ for (const [, fe] of index.files) {
789
+ const rp = fe.relativePath;
790
+ if (!rp) continue;
791
+ const slash = rp.lastIndexOf('/');
792
+ const dir = slash === -1 ? '.' : rp.slice(0, slash);
793
+ const e = dirMap.get(dir) || { dir, files: 0, symbols: 0 };
794
+ e.files += 1;
795
+ e.symbols += (fe.symbols || []).length;
796
+ dirMap.set(dir, e);
797
+ }
798
+ const dirs = [...dirMap.values()]
799
+ .sort((a, b) => b.symbols - a.symbols || codeUnitCompare(a.dir, b.dir))
800
+ .slice(0, top);
801
+
802
+ // Entry-point counts by type — orientation must not fail on detection
803
+ let entrypoints = null;
804
+ try {
805
+ const { detectEntrypoints } = require('./entrypoints');
806
+ const eps = detectEntrypoints(index, {});
807
+ if (Array.isArray(eps)) {
808
+ const byType = new Map();
809
+ for (const e of eps) byType.set(e.type, (byType.get(e.type) || 0) + 1);
810
+ entrypoints = {
811
+ total: eps.length,
812
+ byType: [...byType.entries()]
813
+ .map(([type, count]) => ({ type, count }))
814
+ .sort((a, b) => b.count - a.count || codeUnitCompare(a.type, b.type)),
815
+ };
816
+ }
817
+ } catch { /* detection error → entrypoints stays null, rendered as unavailable */ }
818
+
819
+ // Orientation wants the ENGINE's hot functions, not fixture helpers —
820
+ // prefer production-path entries (labeled as such by the formatter);
821
+ // an all-test project falls back to the raw ranking.
822
+ const { isTestPath } = require('./shared');
823
+ const allHot = (stats.hot?.items || []).map(i => ({
824
+ name: i.name,
825
+ file: i.file,
826
+ line: i.startLine,
827
+ callCount: i.callCount,
828
+ ...(i.className ? { className: i.className } : {}),
829
+ }));
830
+ const prodHot = allHot.filter(i => i.file && !isTestPath(i.file));
831
+ const production = prodHot.length > 0;
832
+ const hotItems = (production ? prodHot : allHot).slice(0, top);
833
+ const hottestProd = hotItems[0] || null;
834
+
835
+ return {
836
+ root: stats.root,
837
+ files: stats.files,
838
+ symbols: stats.symbols,
839
+ buildTime: stats.buildTime,
840
+ byLanguage: stats.byLanguage,
841
+ dirs,
842
+ hot: { total: stats.hot?.total ?? 0, top, production, items: hotItems },
843
+ entrypoints,
844
+ trust: {
845
+ level: health.trust,
846
+ blindSpots: {
847
+ dynamicImports: health.blindSpots?.dynamicImports?.count ?? 0,
848
+ evalCalls: health.blindSpots?.evalCalls?.count ?? 0,
849
+ reflection: health.blindSpots?.reflection?.count ?? 0,
850
+ parseFailures: health.blindSpots?.parseFailures?.count ?? 0,
851
+ },
852
+ },
853
+ suggest: hottestProd ? hottestProd.name : null,
854
+ };
855
+ }
856
+
857
+ module.exports = { getStats, getToc, doctor, orient };