ucn 3.8.23 → 3.8.26
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/.claude/skills/ucn/SKILL.md +127 -12
- package/README.md +152 -156
- package/cli/index.js +363 -37
- package/core/analysis.js +936 -32
- package/core/bridge.js +1095 -0
- package/core/brief.js +408 -0
- package/core/cache.js +105 -5
- package/core/callers.js +72 -18
- package/core/check.js +200 -0
- package/core/discovery.js +57 -34
- package/core/entrypoints.js +638 -4
- package/core/execute.js +304 -5
- package/core/git-enrich.js +130 -0
- package/core/graph.js +24 -2
- package/core/output/analysis.js +157 -25
- package/core/output/brief.js +100 -0
- package/core/output/check.js +79 -0
- package/core/output/doctor.js +85 -0
- package/core/output/endpoints.js +239 -0
- package/core/output/extraction.js +2 -0
- package/core/output/find.js +126 -39
- package/core/output/graph.js +48 -15
- package/core/output/refactoring.js +103 -5
- package/core/output/reporting.js +63 -23
- package/core/output/search.js +110 -17
- package/core/output/shared.js +56 -2
- package/core/output.js +4 -0
- package/core/parser.js +8 -2
- package/core/project.js +39 -3
- package/core/registry.js +30 -14
- package/core/reporting.js +465 -2
- package/core/search.js +130 -52
- package/core/shared.js +101 -5
- package/core/tracing.js +16 -6
- package/core/verify.js +982 -95
- package/languages/go.js +91 -6
- package/languages/html.js +10 -0
- package/languages/java.js +151 -35
- package/languages/javascript.js +290 -33
- package/languages/python.js +78 -11
- package/languages/rust.js +267 -12
- package/languages/utils.js +315 -3
- package/mcp/server.js +91 -16
- 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
|
-
|
|
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 };
|