ucn 3.8.23 → 3.8.25

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 (44) hide show
  1. package/.claude/skills/ucn/SKILL.md +114 -11
  2. package/README.md +152 -156
  3. package/cli/index.js +363 -37
  4. package/core/analysis.js +936 -32
  5. package/core/bridge.js +1111 -0
  6. package/core/brief.js +408 -0
  7. package/core/cache.js +105 -5
  8. package/core/callers.js +72 -18
  9. package/core/check.js +200 -0
  10. package/core/discovery.js +57 -34
  11. package/core/entrypoints.js +638 -4
  12. package/core/execute.js +304 -5
  13. package/core/git-enrich.js +130 -0
  14. package/core/graph.js +24 -2
  15. package/core/output/analysis.js +157 -25
  16. package/core/output/brief.js +100 -0
  17. package/core/output/check.js +79 -0
  18. package/core/output/doctor.js +85 -0
  19. package/core/output/endpoints.js +239 -0
  20. package/core/output/extraction.js +2 -0
  21. package/core/output/find.js +126 -39
  22. package/core/output/graph.js +48 -15
  23. package/core/output/refactoring.js +103 -5
  24. package/core/output/reporting.js +63 -23
  25. package/core/output/search.js +110 -17
  26. package/core/output/shared.js +56 -2
  27. package/core/output.js +4 -0
  28. package/core/parser.js +8 -2
  29. package/core/project.js +39 -3
  30. package/core/registry.js +30 -14
  31. package/core/reporting.js +465 -2
  32. package/core/search.js +130 -10
  33. package/core/shared.js +101 -5
  34. package/core/tracing.js +16 -6
  35. package/core/verify.js +982 -95
  36. package/languages/go.js +91 -6
  37. package/languages/html.js +10 -0
  38. package/languages/java.js +151 -35
  39. package/languages/javascript.js +290 -33
  40. package/languages/python.js +78 -11
  41. package/languages/rust.js +267 -12
  42. package/languages/utils.js +315 -3
  43. package/mcp/server.js +91 -16
  44. package/package.json +9 -1
package/core/reporting.js CHANGED
@@ -15,7 +15,7 @@ const { isTestFile } = require('./discovery');
15
15
  * Get project statistics: file counts, symbol counts, LOC, language breakdown.
16
16
  *
17
17
  * @param {object} index - ProjectIndex instance
18
- * @param {object} options - { functions }
18
+ * @param {object} options - { functions, hot, top }
19
19
  * @returns {object}
20
20
  */
21
21
  function getStats(index, options = {}) {
@@ -85,6 +85,264 @@ function getStats(index, options = {}) {
85
85
  stats.functions = functions;
86
86
  }
87
87
 
88
+ // Hot list: top N functions by inbound call-site count.
89
+ // "callCount" = number of distinct call-site lines that resolve to this name
90
+ // across the project. Multiple definitions of the same name are listed
91
+ // separately (per file:line) since callers may differ. The count is
92
+ // name-keyed (not per-definition) — same trade-off as `usages` and matches
93
+ // the rest of the codebase's call-graph approximation.
94
+ if (options.hot) {
95
+ // MEDIUM-7: caller (execute.js) validates and passes either a
96
+ // positive integer, 0 (show nothing), or undefined (default 10).
97
+ const top = options.top === 0
98
+ ? 0
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
+ ]);
104
+
105
+ // Ensure the calls cache is fully populated before counting.
106
+ // First-time stats --hot may need to parse files to extract calls;
107
+ // subsequent runs use the persisted calls cache.
108
+ if (typeof index.buildCalleeIndex === 'function' && !index.calleeIndex) {
109
+ index.buildCalleeIndex();
110
+ }
111
+
112
+ // BUG-H2: aggregate calls by *resolution kind* so a method call like
113
+ // `dict.get()` doesn't get attributed to a standalone `function get()`.
114
+ //
115
+ // Buckets per name:
116
+ // bareNameCounts[name] — calls with !isMethod (e.g. `get()`)
117
+ // methodByReceiverType[t][name] — calls with isMethod and inferred receiverType
118
+ // methodByName[name] — all isMethod calls (fallback denominator)
119
+ // importedReceiverCounts[name] — method calls whose receiver is an imported
120
+ // module alias in the calling file (e.g.
121
+ // `mod.foo()` where `mod` is a require alias).
122
+ // These resolve like top-level function calls.
123
+ //
124
+ // self/this/cls/super counted under bareNameCounts since they always resolve
125
+ // to the enclosing class's method (handled in attribution below).
126
+ // We dedupe per file by (name, line) so multi-record call sites count once.
127
+ const SELF_RECEIVERS = new Set(['self', 'this', 'cls', 'super']);
128
+ const bareNameCounts = new Map(); // name -> count
129
+ const methodByReceiverType = new Map(); // receiverType -> Map(name -> count)
130
+ const methodByName = new Map(); // name -> count of all method calls
131
+ const selfMethodByName = new Map(); // name -> count of self/this.name() calls
132
+ const importedReceiverCounts = new Map(); // name -> count of `mod.name()` calls
133
+ // where mod is an import alias
134
+
135
+ // Pre-compute import-alias sets per file. Used to distinguish `mod.foo()`
136
+ // (resolves to top-level foo) from `obj.foo()` on a local variable.
137
+ const fileImportAliases = new Map(); // filePath -> Set<string> of alias names
138
+ for (const [filePath, fileEntry] of index.files) {
139
+ const aliases = new Set();
140
+ // importNames are the named imports/exports brought into this file.
141
+ // importAliases (when present) carry namespace import aliases (e.g.
142
+ // `import * as mod from "..."` → 'mod').
143
+ for (const n of (fileEntry.importNames || [])) aliases.add(n);
144
+ if (Array.isArray(fileEntry.importAliases)) {
145
+ for (const a of fileEntry.importAliases) {
146
+ if (a && a.local) aliases.add(a.local);
147
+ }
148
+ }
149
+ fileImportAliases.set(filePath, aliases);
150
+ }
151
+
152
+ for (const [filePath, entry] of index.callsCache) {
153
+ if (!entry || !Array.isArray(entry.calls)) continue;
154
+ const seenInFile = new Set();
155
+ const aliasesForFile = fileImportAliases.get(filePath) || new Set();
156
+ for (const c of entry.calls) {
157
+ if (!c || !c.name) continue;
158
+ const key = `${c.name}::${c.line || 0}`;
159
+ if (seenInFile.has(key)) continue;
160
+ seenInFile.add(key);
161
+
162
+ const isSelfMethod = c.isMethod && SELF_RECEIVERS.has(c.receiver);
163
+ if (!c.isMethod) {
164
+ // Bare-name call: foo() or pkg.Foo() (Go package call has receiver
165
+ // but isMethod:false — keep counting under bareName since they
166
+ // resolve like top-level functions in their package).
167
+ bareNameCounts.set(c.name, (bareNameCounts.get(c.name) || 0) + 1);
168
+ } else if (isSelfMethod) {
169
+ // self/this.foo() — attributed to the enclosing class's foo
170
+ selfMethodByName.set(c.name, (selfMethodByName.get(c.name) || 0) + 1);
171
+ methodByName.set(c.name, (methodByName.get(c.name) || 0) + 1);
172
+ } else {
173
+ methodByName.set(c.name, (methodByName.get(c.name) || 0) + 1);
174
+ // Module-alias receiver? `mod.foo()` where `mod` was imported here.
175
+ // Treat the call as resolving to a top-level `foo` (the standalone
176
+ // function exported from `mod`).
177
+ if (c.receiver && aliasesForFile.has(c.receiver)) {
178
+ importedReceiverCounts.set(c.name,
179
+ (importedReceiverCounts.get(c.name) || 0) + 1);
180
+ }
181
+ if (c.receiverType) {
182
+ let inner = methodByReceiverType.get(c.receiverType);
183
+ if (!inner) {
184
+ inner = new Map();
185
+ methodByReceiverType.set(c.receiverType, inner);
186
+ }
187
+ inner.set(c.name, (inner.get(c.name) || 0) + 1);
188
+ }
189
+ }
190
+ // Also account for resolvedName aliases (e.g. `import {foo as bar}; bar()`
191
+ // resolves to `foo`). Treat the resolved form the same way as the original.
192
+ if (c.resolvedName && c.resolvedName !== c.name) {
193
+ const rkey = `${c.resolvedName}::${c.line || 0}`;
194
+ if (!seenInFile.has(rkey)) {
195
+ seenInFile.add(rkey);
196
+ if (!c.isMethod) {
197
+ bareNameCounts.set(c.resolvedName,
198
+ (bareNameCounts.get(c.resolvedName) || 0) + 1);
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ // For each name, count how many distinct classes/types own a method with
206
+ // that name (used to split method-call counts when receiverType is unknown).
207
+ const classOwnersByName = new Map(); // name -> Set<className>
208
+ for (const [name, symbols] of index.symbols) {
209
+ for (const sym of symbols) {
210
+ if (!FUNCTION_TYPES.has(sym.type)) continue;
211
+ const owner = sym.className || (sym.receiver && sym.receiver.replace(/^\*/, ''));
212
+ if (owner) {
213
+ let s = classOwnersByName.get(name);
214
+ if (!s) { s = new Set(); classOwnersByName.set(name, s); }
215
+ s.add(owner);
216
+ }
217
+ }
218
+ }
219
+
220
+ // MEDIUM-6: aggregate by name. Multiple definitions of the same name
221
+ // in different files (e.g. `tmp` in test/helpers/index.js AND
222
+ // test/accuracy.test.js) previously each got the GLOBAL call count,
223
+ // duplicating the row and inflating the leaderboard. We now emit
224
+ // one row per name with a `locations` list, so the user sees both
225
+ // definitions but the count appears exactly once.
226
+ //
227
+ // BUG-H2: with the buckets above, attribute counts per (name, ownerClass):
228
+ // - standalone function: bareNameCounts[name]
229
+ // - class method (Foo.bar): methodByReceiverType[Foo][bar]
230
+ // + selfMethodByName[bar] / numOwnerClasses
231
+ // + (residual unresolved method calls split evenly)
232
+ // - falls back to methodByName[name] when no receiverType evidence exists.
233
+ const hotList = [];
234
+ let usedHeuristicSplit = false; // whether any row's count was approximated
235
+ for (const [name, symbols] of index.symbols) {
236
+ // Filter to function-shaped definitions, dedup by file:line.
237
+ const seenLoc = new Set();
238
+ const locations = [];
239
+ let representative = null;
240
+ const ownerClasses = new Set(); // classes/receivers that own this name
241
+ for (const sym of symbols) {
242
+ if (!FUNCTION_TYPES.has(sym.type)) continue;
243
+ const relativePath = sym.relativePath ||
244
+ (sym.file ? path.relative(index.root, sym.file) : '');
245
+ const locKey = `${relativePath}:${sym.startLine}`;
246
+ if (seenLoc.has(locKey)) continue;
247
+ seenLoc.add(locKey);
248
+ locations.push({
249
+ file: relativePath,
250
+ startLine: sym.startLine,
251
+ endLine: sym.endLine,
252
+ ...(sym.className && { className: sym.className }),
253
+ });
254
+ const owner = sym.className || (sym.receiver && sym.receiver.replace(/^\*/, ''));
255
+ if (owner) ownerClasses.add(owner);
256
+ if (!representative) representative = sym;
257
+ }
258
+ if (locations.length === 0) continue;
259
+
260
+ // Decide if this row represents a standalone function or a method.
261
+ // Mixed-type defs (e.g. "tmp" defined as both a function and a class method
262
+ // somewhere) are rare; for them we use the representative's flavor and
263
+ // accept that the count may be approximate.
264
+ const isMethodRow = ownerClasses.size > 0 &&
265
+ (!representative || !!representative.className || !!representative.receiver);
266
+
267
+ let count = 0;
268
+ let approximate = false;
269
+ if (!isMethodRow) {
270
+ // Standalone function (or top-level package call): use bare-name calls
271
+ // plus method-style calls where the receiver was an imported module
272
+ // alias (e.g. `lib.foo()` where `lib` is a require/import alias).
273
+ // We deliberately do NOT include arbitrary `obj.foo()` calls — those
274
+ // would inflate the count with unrelated method calls (the H2 bug).
275
+ count = (bareNameCounts.get(name) || 0) +
276
+ (importedReceiverCounts.get(name) || 0);
277
+ } else {
278
+ // Method definition. Count only calls we can resolve to this owner:
279
+ // - typed hits (receiverType matches one of this row's owner classes)
280
+ // - self-method calls inside this owner class (counted via callerSymbol)
281
+ // Calls like `dict.get()` (no receiverType) are NOT attributed — they
282
+ // would inflate the count with builtin/unrelated method calls.
283
+ const selfShare = selfMethodByName.get(name) || 0;
284
+ const totalOwners = (classOwnersByName.get(name) || new Set()).size || 1;
285
+
286
+ let typedHits = 0;
287
+ for (const cls of ownerClasses) {
288
+ const inner = methodByReceiverType.get(cls);
289
+ if (inner) typedHits += (inner.get(name) || 0);
290
+ }
291
+
292
+ // Self-method calls: split evenly across owner classes (each class's own
293
+ // self.method() resolves to itself). When this row covers all owners
294
+ // (locations cover the only class that has this method), give the full
295
+ // self-share to this row.
296
+ const selfShareForRow = selfShare * (ownerClasses.size / totalOwners);
297
+
298
+ count = typedHits + Math.round(selfShareForRow);
299
+ // If we used the self-method heuristic across multiple classes, mark approximate.
300
+ if (selfShare > 0 && totalOwners > 1) approximate = true;
301
+ }
302
+ if (count === 0) continue; // skip dead symbols
303
+
304
+ if (approximate) usedHeuristicSplit = true;
305
+ // Sort locations by (file, startLine) for stable display.
306
+ locations.sort((a, b) =>
307
+ a.file.localeCompare(b.file) ||
308
+ (a.startLine || 0) - (b.startLine || 0)
309
+ );
310
+ const primary = locations[0];
311
+ hotList.push({
312
+ // Use the representative symbol's className for display name
313
+ // (so "Foo.bar" is preserved when applicable). When defs
314
+ // disagree on className, just show the bare name.
315
+ name: representative && representative.className
316
+ ? `${representative.className}.${name}`
317
+ : name,
318
+ // Primary location remains for backward-compat with consumers
319
+ // that read `file`/`startLine`/`endLine` directly.
320
+ file: primary.file,
321
+ startLine: primary.startLine,
322
+ endLine: primary.endLine,
323
+ callCount: count,
324
+ ...(approximate && { approximate: true }),
325
+ ...(locations.length > 1 && { locations }),
326
+ });
327
+ }
328
+
329
+ // Stable order: callCount desc, then (relativePath, startLine) asc.
330
+ hotList.sort((a, b) =>
331
+ (b.callCount - a.callCount) ||
332
+ a.file.localeCompare(b.file) ||
333
+ (a.startLine || 0) - (b.startLine || 0)
334
+ );
335
+
336
+ stats.hot = {
337
+ top,
338
+ total: hotList.length,
339
+ items: hotList.slice(0, top),
340
+ ...(usedHeuristicSplit && {
341
+ note: 'Method-call counts approximated when receiver type was unknown — values within those rows may include unresolved calls split across owner classes.'
342
+ }),
343
+ };
344
+ }
345
+
88
346
  return stats;
89
347
  }
90
348
 
@@ -255,4 +513,209 @@ function getToc(index, options = {}) {
255
513
  };
256
514
  }
257
515
 
258
- module.exports = { getStats, getToc };
516
+ /**
517
+ * Project trust report. Tells the caller how much UCN itself trusts the index
518
+ * for this project: resolution coverage, blind spots (dynamic imports, eval,
519
+ * reflection), parse failures, and a quick verdict.
520
+ *
521
+ * Cheap-by-default: counts + blind-spot scan are O(files). The expensive
522
+ * confidence-coverage computation is deferred unless options.deep is set
523
+ * (then samples a slice of symbols).
524
+ *
525
+ * @param {object} index - ProjectIndex
526
+ * @param {object} options - { deep, sampleSize, in, file }
527
+ */
528
+ function doctor(index, options = {}) {
529
+ const { detectLanguage, langTraits } = require('../languages');
530
+ const path = require('path');
531
+
532
+ const inFilter = options.in || options.file || null;
533
+ const matchInFilter = (rel) => {
534
+ if (!inFilter) return true;
535
+ return rel.includes(inFilter);
536
+ };
537
+
538
+ const fileCounts = { total: 0, scanned: 0 };
539
+ const langs = {};
540
+ let totalSymbols = 0; // counted post-filter for accuracy when --in is set
541
+ const blindSpots = {
542
+ dynamicImports: { count: 0, files: [] },
543
+ evalCalls: { count: 0, files: [] },
544
+ reflection: { count: 0, files: [] },
545
+ parseFailures: { count: 0, files: [] },
546
+ };
547
+
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
+ };
563
+
564
+ for (const [filePath, fe] of index.files) {
565
+ fileCounts.total++;
566
+ const rel = fe.relativePath || filePath;
567
+ if (!matchInFilter(rel)) continue;
568
+ fileCounts.scanned++;
569
+
570
+ const lang = fe.language || 'unknown';
571
+ if (!langs[lang]) langs[lang] = { files: 0, symbols: 0, lines: 0 };
572
+ langs[lang].files++;
573
+ langs[lang].symbols += (fe.symbols || []).length;
574
+ langs[lang].lines += fe.lines || 0;
575
+ totalSymbols += (fe.symbols || []).length;
576
+
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
+ }
585
+
586
+ // Read file once for eval/reflection signals
587
+ const evalRe = EVAL_PATTERNS[lang];
588
+ const reflRe = REFLECTION_PATTERNS[lang];
589
+ if (evalRe || reflRe) {
590
+ 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
+ }
600
+ } catch (e) { /* ignore read errors */ }
601
+ }
602
+ }
603
+
604
+ // Resolution coverage — sampled by default to keep doctor fast.
605
+ let coverage = null;
606
+ if (options.deep || options.sampleSize) {
607
+ coverage = computeCoverageSample(index, {
608
+ sampleSize: options.sampleSize || 200,
609
+ inFilter,
610
+ matchInFilter,
611
+ });
612
+ }
613
+
614
+ // Cache info
615
+ let cache = { fresh: null };
616
+ try {
617
+ cache.fresh = !index.isCacheStale();
618
+ cache.buildMs = index.buildTime || null;
619
+ } catch (e) { /* ignore */ }
620
+
621
+ // Compute trust verdict.
622
+ //
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.
629
+ let trust = 'UNKNOWN';
630
+ let trustReason = '';
631
+ const reasons = [];
632
+
633
+ if (coverage && coverage.total > 0) {
634
+ const safe = coverage.high + coverage.medium;
635
+ 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 downgrades — each 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)`); }
650
+ trust = tier[idx];
651
+ if (blindSignals.length) reasons.push(`blind spots: ${blindSignals.join(', ')}`);
652
+ trustReason = reasons.join('; ');
653
+ } else if (coverage) {
654
+ // Sampled but zero edges — can't say anything about confidence.
655
+ trust = 'UNKNOWN';
656
+ trustReason = 'no edges sampled (empty scope or filter matched nothing)';
657
+ } else if (fileCounts.scanned > 0) {
658
+ // Cheap path (no --deep): use blind-spot signals.
659
+ const tier = ['HIGH', 'MEDIUM', 'LOW'];
660
+ 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)`); }
666
+ trust = tier[idx];
667
+ trustReason = blindSignals.length
668
+ ? `coverage not deep-checked; blind spots: ${blindSignals.join(', ')}`
669
+ : 'no parse failures; coverage not deep-checked';
670
+ }
671
+
672
+ return {
673
+ root: index.root,
674
+ files: fileCounts,
675
+ symbols: totalSymbols,
676
+ languages: langs,
677
+ blindSpots,
678
+ coverage,
679
+ cache,
680
+ trust,
681
+ trustReason,
682
+ ...(inFilter && { filter: inFilter }),
683
+ };
684
+ }
685
+
686
+ /**
687
+ * Sample-based coverage: pick up to N symbols, run findCallers, bucket confidence.
688
+ * Doesn't pretend to be exhaustive — meant for a fast trust signal, not an audit.
689
+ */
690
+ function computeCoverageSample(index, { sampleSize, inFilter, matchInFilter }) {
691
+ const buckets = { high: 0, medium: 0, low: 0, total: 0, sampled: 0 };
692
+ const symbolNames = [];
693
+ for (const [name, arr] of index.symbols) {
694
+ for (const sym of arr) {
695
+ if (!sym || !sym.relativePath) continue;
696
+ if (!matchInFilter(sym.relativePath)) continue;
697
+ if (sym.type === 'method' || sym.type === 'function' || sym.type === 'constructor') {
698
+ symbolNames.push(name);
699
+ if (symbolNames.length >= sampleSize * 2) break; // cap collection cost
700
+ }
701
+ }
702
+ if (symbolNames.length >= sampleSize * 2) break;
703
+ }
704
+ // Take a slice (not random — deterministic for tests)
705
+ const slice = symbolNames.slice(0, sampleSize);
706
+ buckets.sampled = slice.length;
707
+
708
+ for (const name of slice) {
709
+ const callers = index.findCallers(name, { includeMethods: true, includeUncertain: true });
710
+ for (const c of callers) {
711
+ const conf = (c.confidence != null) ? c.confidence : 1;
712
+ buckets.total++;
713
+ if (conf > 0.8) buckets.high++;
714
+ else if (conf >= 0.5) buckets.medium++;
715
+ else buckets.low++;
716
+ }
717
+ }
718
+ return buckets;
719
+ }
720
+
721
+ module.exports = { getStats, getToc, doctor };