ucn 4.2.3 → 5.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.claude/skills/ucn/SKILL.md +89 -77
  2. package/.claude/skills/ucn/references/commands.md +62 -68
  3. package/.claude/skills/ucn/references/trust-contract.md +31 -6
  4. package/README.md +438 -305
  5. package/assets/demo.svg +31 -0
  6. package/cli/index.js +430 -1385
  7. package/core/account.js +144 -34
  8. package/core/analysis.js +182 -72
  9. package/core/ast-analysis.js +279 -0
  10. package/core/bridge.js +205 -24
  11. package/core/brief.js +27 -58
  12. package/core/build-worker.js +21 -140
  13. package/core/cache.js +513 -11
  14. package/core/callers.js +4920 -456
  15. package/core/check.js +13 -4
  16. package/core/command-contracts.js +402 -0
  17. package/core/compilation-database.js +276 -0
  18. package/core/confidence.js +4 -1
  19. package/core/deadcode.js +397 -19
  20. package/core/discovery.js +359 -46
  21. package/core/entrypoints.js +195 -41
  22. package/core/execute.js +887 -81
  23. package/core/graph-build.js +162 -7
  24. package/core/graph.js +53 -77
  25. package/core/imports.js +65 -6
  26. package/core/index-ir.js +138 -0
  27. package/core/ir.js +195 -0
  28. package/core/output/analysis.js +212 -22
  29. package/core/output/brief.js +23 -0
  30. package/core/output/check.js +4 -0
  31. package/core/output/doctor.js +37 -6
  32. package/core/output/endpoints.js +5 -2
  33. package/core/output/extraction.js +24 -12
  34. package/core/output/find.js +141 -36
  35. package/core/output/graph.js +11 -5
  36. package/core/output/public.js +462 -0
  37. package/core/output/refactoring.js +42 -10
  38. package/core/output/reporting.js +97 -20
  39. package/core/output/search.js +24 -16
  40. package/core/output/shared.js +22 -1
  41. package/core/output/tracing.js +30 -15
  42. package/core/output-budget.js +295 -0
  43. package/core/output.js +1 -0
  44. package/core/parallel-build.js +44 -11
  45. package/core/parser.js +3 -3
  46. package/core/project.js +384 -187
  47. package/core/public-command.js +47 -0
  48. package/core/registry.js +247 -117
  49. package/core/reporting.js +312 -290
  50. package/core/search.js +317 -185
  51. package/core/semantic-provider.js +110 -0
  52. package/core/stacktrace.js +25 -0
  53. package/core/tracing.js +101 -51
  54. package/core/trust-matrix.js +19 -40
  55. package/core/verify.js +534 -37
  56. package/languages/adapter.js +218 -0
  57. package/languages/c-family.js +2791 -0
  58. package/languages/c.js +3 -0
  59. package/languages/cpp.js +3 -0
  60. package/languages/csharp.js +1402 -0
  61. package/languages/go.js +60 -21
  62. package/languages/html.js +2 -2
  63. package/languages/index.js +85 -7
  64. package/languages/java.js +396 -13
  65. package/languages/javascript.js +199 -19
  66. package/languages/python.js +964 -22
  67. package/languages/rust.js +1317 -152
  68. package/languages/utils.js +40 -3
  69. package/mcp/server.js +254 -636
  70. package/package.json +39 -22
  71. package/eslint.config.js +0 -43
  72. package/jsconfig.json +0 -10
package/core/reporting.js CHANGED
@@ -9,10 +9,25 @@
9
9
 
10
10
  const fs = require('fs');
11
11
  const { codeUnitCompare, CALLABLE_SYMBOL_KINDS } = require('./shared');
12
- const { _declaredFieldType, _projectTopLevelNames } = require('./callers');
13
12
  const path = require('path');
14
13
  const { isTestFile } = require('./discovery');
15
14
  const { summarizeCommandTrust } = require('./trust-matrix');
15
+ const { projectComputedDispatch } = require('./ast-analysis');
16
+ const { langTraits } = require('../languages');
17
+
18
+ function matchesReportingScope(index, relativePath, options = {}) {
19
+ if (!relativePath) return false;
20
+ if (options.file && !relativePath.includes(options.file)) return false;
21
+ const exclude = Array.isArray(options.exclude)
22
+ ? options.exclude
23
+ : (options.exclude
24
+ ? String(options.exclude).split(',').map(s => s.trim()).filter(Boolean)
25
+ : []);
26
+ return index.matchesFilters(relativePath, {
27
+ ...(exclude.length > 0 && { exclude }),
28
+ ...(options.in && { in: options.in }),
29
+ });
30
+ }
16
31
 
17
32
  /**
18
33
  * Get project statistics: file counts, symbol counts, LOC, language breakdown.
@@ -22,15 +37,15 @@ const { summarizeCommandTrust } = require('./trust-matrix');
22
37
  * @returns {object}
23
38
  */
24
39
  function getStats(index, options = {}) {
25
- // Count total symbols (not just unique names)
26
- let totalSymbols = 0;
27
- for (const [, symbols] of index.symbols) {
28
- totalSymbols += symbols.length;
29
- }
40
+ const scopedFiles = [...index.files].filter(([, fileEntry]) =>
41
+ matchesReportingScope(index, fileEntry.relativePath, options));
42
+ const scopedPaths = new Set(scopedFiles.map(([filePath]) => filePath));
43
+ const totalSymbols = scopedFiles.reduce(
44
+ (sum, [, fileEntry]) => sum + (fileEntry.symbols || []).length, 0);
30
45
 
31
46
  const stats = {
32
47
  root: index.root,
33
- files: index.files.size,
48
+ files: scopedFiles.length,
34
49
  symbols: totalSymbols, // Total symbol count, not unique names
35
50
  buildTime: index.buildTime,
36
51
  byLanguage: {},
@@ -38,7 +53,7 @@ function getStats(index, options = {}) {
38
53
  ...(index.truncated && { truncated: index.truncated })
39
54
  };
40
55
 
41
- for (const [, fileEntry] of index.files) {
56
+ for (const [, fileEntry] of scopedFiles) {
42
57
  const lang = fileEntry.language;
43
58
  if (!stats.byLanguage[lang]) {
44
59
  stats.byLanguage[lang] = { files: 0, lines: 0, symbols: 0 };
@@ -48,8 +63,8 @@ function getStats(index, options = {}) {
48
63
  stats.byLanguage[lang].symbols += fileEntry.symbols.length;
49
64
  }
50
65
 
51
- for (const [, symbols] of index.symbols) {
52
- for (const sym of symbols) {
66
+ for (const [, fileEntry] of scopedFiles) {
67
+ for (const sym of fileEntry.symbols || []) {
53
68
  if (!Object.hasOwn(stats.byType, sym.type)) {
54
69
  stats.byType[sym.type] = 0;
55
70
  }
@@ -59,17 +74,22 @@ function getStats(index, options = {}) {
59
74
 
60
75
  // Surface build warnings (parse failures, skipped files)
61
76
  if (index.failedFiles && index.failedFiles.size > 0) {
62
- stats.warnings = {
63
- failedFiles: [...index.failedFiles].map(f => path.relative(index.root, f)),
64
- count: index.failedFiles.size
65
- };
77
+ const failedFiles = [...index.failedFiles]
78
+ .map(f => path.relative(index.root, f))
79
+ .filter(rel => matchesReportingScope(index, rel, options));
80
+ if (failedFiles.length > 0) {
81
+ stats.warnings = {
82
+ failedFiles,
83
+ count: failedFiles.length,
84
+ };
85
+ }
66
86
  }
67
87
 
68
88
  // Per-function line counts for complexity audits
69
89
  if (options.functions) {
70
90
  const functions = [];
71
- for (const [, symbols] of index.symbols) {
72
- for (const sym of symbols) {
91
+ for (const [, fileEntry] of scopedFiles) {
92
+ for (const sym of fileEntry.symbols || []) {
73
93
  if (CALLABLE_SYMBOL_KINDS.has(sym.type)) {
74
94
  const lineCount = sym.endLine - sym.startLine + 1;
75
95
  const relativePath = sym.relativePath || (sym.file ? path.relative(index.root, sym.file) : '');
@@ -107,258 +127,112 @@ function getStats(index, options = {}) {
107
127
  index.buildCalleeIndex();
108
128
  }
109
129
 
110
- // BUG-H2: aggregate calls by *resolution kind* so a method call like
111
- // `dict.get()` doesn't get attributed to a standalone `function get()`.
112
- //
113
- // Buckets per name:
114
- // bareNameCounts[name] — calls with !isMethod (e.g. `get()`)
115
- // methodByReceiverType[t][name] — calls with isMethod and inferred receiverType
116
- // methodByName[name] — all isMethod calls (fallback denominator)
117
- // importedReceiverCounts[name] — method calls whose receiver is an imported
118
- // module alias in the calling file (e.g.
119
- // `mod.foo()` where `mod` is a require alias).
120
- // These resolve like top-level function calls.
121
- //
122
- // self/this/cls/super counted under bareNameCounts since they always resolve
123
- // to the enclosing class's method (handled in attribution below).
124
- // We dedupe per file by (name, line) so multi-record call sites count once.
125
- const SELF_RECEIVERS = new Set(['self', 'this', 'cls', 'super']);
126
- const bareNameCounts = new Map(); // name -> count
127
- const methodByReceiverType = new Map(); // receiverType -> Map(name -> count)
128
- const methodByName = new Map(); // name -> count of all method calls
129
- const selfMethodByName = new Map(); // name -> count of self/this.name() calls
130
- const importedReceiverCounts = new Map(); // name -> count of `mod.name()` calls
131
- // where mod is an import alias
132
-
133
- // Pre-compute import-alias sets per file. Used to distinguish `mod.foo()`
134
- // (resolves to top-level foo) from `obj.foo()` on a local variable.
135
- const fileImportAliases = new Map(); // filePath -> Set<string> of alias names
136
- const fieldHopCache = new Map(); // rootType\0field -> declared type|null
137
- // Names import-bound to an EXTERNAL module, per file (fix #256,
138
- // dogfood-measured: 895 node:test `describe(...)` calls in test
139
- // files were attributed to a project closure named `describe` —
140
- // the #215 name discipline says an externally-bound bare name
141
- // cannot reach a project def, so it never counts toward the hot
142
- // leaderboard). Relative modules, resolved modules, and resolver
143
- // gaps (first segment names a project path) all stay countable.
144
- const fileExternalNames = new Map(); // filePath -> Set<string>
145
- for (const [filePath, fileEntry] of index.files) {
146
- const aliases = new Set();
147
- // importNames are the named imports/exports brought into this file.
148
- // importAliases (when present) carry namespace import aliases (e.g.
149
- // `import * as mod from "..."` → 'mod').
150
- for (const n of (fileEntry.importNames || [])) aliases.add(n);
151
- if (Array.isArray(fileEntry.importAliases)) {
152
- for (const a of fileEntry.importAliases) {
153
- if (a && a.local) aliases.add(a.local);
154
- }
155
- }
156
- fileImportAliases.set(filePath, aliases);
157
- let ext = null;
158
- for (const b of (fileEntry.importBindings || [])) {
159
- const mod = String(b.module || '');
160
- if (!b.name || !mod || mod.startsWith('.') || mod.startsWith('/')) continue;
161
- if (fileEntry.moduleResolved && fileEntry.moduleResolved[mod]) continue;
162
- const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
163
- if (firstSeg && _projectTopLevelNames(index).has(firstSeg)) continue;
164
- (ext || (ext = new Set())).add(b.name);
165
- }
166
- if (ext) fileExternalNames.set(filePath, ext);
167
- }
168
-
130
+ // Build a cheap, provenance-independent upper bound per spelling.
131
+ // Every confirmed caller must first exist as a call record bearing the
132
+ // target name (or resolved import alias), so this count can safely
133
+ // decide which definitions are capable of entering the requested top
134
+ // N. Exact pinned caller resolution is then run only until no unseen
135
+ // candidate can beat the current Nth result.
136
+ const rawUpperByName = new Map();
169
137
  for (const [filePath, entry] of index.callsCache) {
138
+ if (!scopedPaths.has(filePath)) continue;
139
+ if (index.files.get(filePath)?.isBundled) continue;
170
140
  if (!entry || !Array.isArray(entry.calls)) continue;
171
141
  const seenInFile = new Set();
172
- const aliasesForFile = fileImportAliases.get(filePath) || new Set();
173
142
  for (const c of entry.calls) {
174
143
  if (!c || !c.name) continue;
175
144
  const key = `${c.name}::${c.line || 0}`;
176
- if (seenInFile.has(key)) continue;
177
- seenInFile.add(key);
178
-
179
- const isSelfMethod = c.isMethod && SELF_RECEIVERS.has(c.receiver);
180
- if (!c.isMethod) {
181
- // Bare-name call: foo() or pkg.Foo() (Go package call has receiver
182
- // but isMethod:false — keep counting under bareName since they
183
- // resolve like top-level functions in their package).
184
- // Externally-bound names are the external library's calls,
185
- // never a project def's (fix #256).
186
- if (fileExternalNames.get(filePath)?.has(c.name)) continue;
187
- bareNameCounts.set(c.name, (bareNameCounts.get(c.name) || 0) + 1);
188
- } else if (isSelfMethod) {
189
- // self/this.foo() — attributed to the enclosing class's foo
190
- selfMethodByName.set(c.name, (selfMethodByName.get(c.name) || 0) + 1);
191
- methodByName.set(c.name, (methodByName.get(c.name) || 0) + 1);
192
- } else {
193
- methodByName.set(c.name, (methodByName.get(c.name) || 0) + 1);
194
- // Module-alias receiver? `mod.foo()` where `mod` was imported here.
195
- // Treat the call as resolving to a top-level `foo` (the standalone
196
- // function exported from `mod`).
197
- if (c.receiver && aliasesForFile.has(c.receiver)) {
198
- importedReceiverCounts.set(c.name,
199
- (importedReceiverCounts.get(c.name) || 0) + 1);
200
- }
201
- // Field-access receivers (fix #251): `tm.service.Save()`
202
- // carries receiverRootType, not receiverType — the same
203
- // #202/#231 declared-field hop the caller/callee engine
204
- // uses. Without it, edges `context` confirms were
205
- // invisible to the hot leaderboard.
206
- let recvType = c.receiverType;
207
- if (!recvType && c.receiverField && c.receiverRootType) {
208
- const hopKey = `${c.receiverRootType}\u0000${c.receiverField}`;
209
- if (!fieldHopCache.has(hopKey)) {
210
- const lang = index.files.get(filePath)?.language;
211
- fieldHopCache.set(hopKey,
212
- lang ? _declaredFieldType(index, c.receiverRootType, c.receiverField, lang) : null);
213
- }
214
- recvType = fieldHopCache.get(hopKey);
215
- }
216
- if (recvType) {
217
- let inner = methodByReceiverType.get(recvType);
218
- if (!inner) {
219
- inner = new Map();
220
- methodByReceiverType.set(recvType, inner);
221
- }
222
- inner.set(c.name, (inner.get(c.name) || 0) + 1);
223
- }
145
+ if (!seenInFile.has(key)) {
146
+ seenInFile.add(key);
147
+ rawUpperByName.set(c.name, (rawUpperByName.get(c.name) || 0) + 1);
224
148
  }
225
- // Also account for resolvedName aliases (e.g. `import {foo as bar}; bar()`
226
- // resolves to `foo`). Treat the resolved form the same way as the original.
227
149
  if (c.resolvedName && c.resolvedName !== c.name) {
228
150
  const rkey = `${c.resolvedName}::${c.line || 0}`;
229
151
  if (!seenInFile.has(rkey)) {
230
152
  seenInFile.add(rkey);
231
- if (!c.isMethod) {
232
- bareNameCounts.set(c.resolvedName,
233
- (bareNameCounts.get(c.resolvedName) || 0) + 1);
234
- }
153
+ rawUpperByName.set(c.resolvedName,
154
+ (rawUpperByName.get(c.resolvedName) || 0) + 1);
235
155
  }
236
156
  }
237
157
  }
238
158
  }
239
159
 
240
- // For each name, count how many distinct classes/types own a method with
241
- // that name (used to split method-call counts when receiverType is unknown).
242
- const classOwnersByName = new Map(); // name -> Set<className>
160
+ const candidates = [];
161
+ const seenDefinitions = new Set();
243
162
  for (const [name, symbols] of index.symbols) {
244
- for (const sym of symbols) {
245
- if (!FUNCTION_TYPES.has(sym.type)) continue;
246
- const owner = sym.className || (sym.receiver && sym.receiver.replace(/^\*/, ''));
247
- if (owner) {
248
- let s = classOwnersByName.get(name);
249
- if (!s) { s = new Set(); classOwnersByName.set(name, s); }
250
- s.add(owner);
163
+ const upper = rawUpperByName.get(name) || 0;
164
+ if (upper === 0) continue;
165
+ const callable = symbols.filter(symbol =>
166
+ FUNCTION_TYPES.has(symbol.type) &&
167
+ matchesReportingScope(index, symbol.relativePath, options));
168
+ for (const symbol of callable) {
169
+ if (index.files.get(symbol.file)?.isBundled) continue;
170
+ if (options.productionCallsOnly && require('./shared').isTestPath(symbol.relativePath)) continue;
171
+ const identity = `${symbol.file}:${symbol.startLine}:${name}:` +
172
+ `${symbol.className || symbol.receiver || ''}:${symbol.params || ''}`;
173
+ if (seenDefinitions.has(identity)) continue;
174
+ seenDefinitions.add(identity);
175
+
176
+ // A linked C/C++ prototype and implementation close to the
177
+ // same compiler identity. Show the implementation once.
178
+ if (symbol.isSignature && callable.some(candidate =>
179
+ !candidate.isSignature &&
180
+ (candidate.className || candidate.receiver || null) ===
181
+ (symbol.className || symbol.receiver || null) &&
182
+ (index.importGraph.get(candidate.file)?.has(symbol.file) ||
183
+ index.importGraph.get(symbol.file)?.has(candidate.file)))) {
184
+ continue;
251
185
  }
186
+ candidates.push({ name, symbol, upper });
252
187
  }
253
188
  }
189
+ candidates.sort((a, b) =>
190
+ (b.upper - a.upper) ||
191
+ codeUnitCompare(a.symbol.relativePath, b.symbol.relativePath) ||
192
+ (a.symbol.startLine || 0) - (b.symbol.startLine || 0));
254
193
 
255
- // MEDIUM-6: aggregate by name. Multiple definitions of the same name
256
- // in different files (e.g. `tmp` in test/helpers/index.js AND
257
- // test/accuracy.test.js) previously each got the GLOBAL call count,
258
- // duplicating the row and inflating the leaderboard. We now emit
259
- // one row per name with a `locations` list, so the user sees both
260
- // definitions but the count appears exactly once.
261
- //
262
- // BUG-H2: with the buckets above, attribute counts per (name, ownerClass):
263
- // - standalone function: bareNameCounts[name]
264
- // - class method (Foo.bar): methodByReceiverType[Foo][bar]
265
- // + selfMethodByName[bar] / numOwnerClasses
266
- // + (residual unresolved method calls split evenly)
267
- // - falls back to methodByName[name] when no receiverType evidence exists.
268
194
  const hotList = [];
269
- let usedHeuristicSplit = false; // whether any row's count was approximated
270
- for (const [name, symbols] of index.symbols) {
271
- // Filter to function-shaped definitions, dedup by file:line.
272
- const seenLoc = new Set();
273
- const locations = [];
274
- let representative = null;
275
- const ownerClasses = new Set(); // classes/receivers that own this name
276
- for (const sym of symbols) {
277
- if (!FUNCTION_TYPES.has(sym.type)) continue;
278
- const relativePath = sym.relativePath ||
279
- (sym.file ? path.relative(index.root, sym.file) : '');
280
- const locKey = `${relativePath}:${sym.startLine}`;
281
- if (seenLoc.has(locKey)) continue;
282
- seenLoc.add(locKey);
283
- locations.push({
284
- file: relativePath,
285
- startLine: sym.startLine,
286
- endLine: sym.endLine,
287
- ...(sym.className && { className: sym.className }),
195
+ const { findCallers } = require('./callers');
196
+ const scopedCallerQuery = !!(options.file || options.in ||
197
+ (options.exclude && options.exclude.length > 0));
198
+ let refined = 0;
199
+ if (top > 0) {
200
+ for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) {
201
+ const { name, symbol } = candidates[candidateIndex];
202
+ const exact = findCallers(index, name, {
203
+ targetDefinitions: [symbol],
204
+ includeTests: true,
205
+ collectAccount: true,
288
206
  });
289
- const owner = sym.className || (sym.receiver && sym.receiver.replace(/^\*/, ''));
290
- if (owner) ownerClasses.add(owner);
291
- if (!representative) representative = sym;
292
- }
293
- if (locations.length === 0) continue;
294
-
295
- // Decide if this row represents a standalone function or a method.
296
- // Mixed-type defs (e.g. "tmp" defined as both a function and a class method
297
- // somewhere) are rare; for them we use the representative's flavor and
298
- // accept that the count may be approximate.
299
- const isMethodRow = ownerClasses.size > 0 &&
300
- (!representative || !!representative.className || !!representative.receiver);
301
-
302
- let count;
303
- let approximate = false;
304
- if (!isMethodRow) {
305
- // Standalone function (or top-level package call): use bare-name calls
306
- // plus method-style calls where the receiver was an imported module
307
- // alias (e.g. `lib.foo()` where `lib` is a require/import alias).
308
- // We deliberately do NOT include arbitrary `obj.foo()` calls — those
309
- // would inflate the count with unrelated method calls (the H2 bug).
310
- count = (bareNameCounts.get(name) || 0) +
311
- (importedReceiverCounts.get(name) || 0);
312
- } else {
313
- // Method definition. Count only calls we can resolve to this owner:
314
- // - typed hits (receiverType matches one of this row's owner classes)
315
- // - self-method calls inside this owner class (counted via callerSymbol)
316
- // Calls like `dict.get()` (no receiverType) are NOT attributed — they
317
- // would inflate the count with builtin/unrelated method calls.
318
- const selfShare = selfMethodByName.get(name) || 0;
319
- const totalOwners = (classOwnersByName.get(name) || new Set()).size || 1;
320
-
321
- let typedHits = 0;
322
- for (const cls of ownerClasses) {
323
- const inner = methodByReceiverType.get(cls);
324
- if (inner) typedHits += (inner.get(name) || 0);
207
+ refined++;
208
+ const count = exact.filter(caller =>
209
+ caller.tier !== 'unverified' &&
210
+ (!scopedCallerQuery || scopedPaths.has(caller.file)) &&
211
+ !index.files.get(caller.file)?.isBundled &&
212
+ (!options.productionCallsOnly ||
213
+ !require('./shared').isTestPath(caller.relativePath || caller.file))).length;
214
+ if (count > 0) {
215
+ const owner = symbol.className ||
216
+ (symbol.receiver || '').replace(/^\*/, '');
217
+ hotList.push({
218
+ name: owner ? `${owner}.${name}` : name,
219
+ file: symbol.relativePath,
220
+ startLine: symbol.startLine,
221
+ endLine: symbol.endLine,
222
+ callCount: count,
223
+ evidence: 'confirmed-callers',
224
+ });
225
+ }
226
+ hotList.sort((a, b) =>
227
+ (b.callCount - a.callCount) ||
228
+ codeUnitCompare(a.file, b.file) ||
229
+ (a.startLine || 0) - (b.startLine || 0));
230
+ if (hotList.length >= top) {
231
+ const threshold = hotList[top - 1].callCount;
232
+ const nextUpper = candidates[candidateIndex + 1]?.upper ?? -1;
233
+ if (nextUpper < threshold) break;
325
234
  }
326
-
327
- // Self-method calls: split evenly across owner classes (each class's own
328
- // self.method() resolves to itself). When this row covers all owners
329
- // (locations cover the only class that has this method), give the full
330
- // self-share to this row.
331
- const selfShareForRow = selfShare * (ownerClasses.size / totalOwners);
332
-
333
- count = typedHits + Math.round(selfShareForRow);
334
- // If we used the self-method heuristic across multiple classes, mark approximate.
335
- if (selfShare > 0 && totalOwners > 1) approximate = true;
336
235
  }
337
- if (count === 0) continue; // skip dead symbols
338
-
339
- if (approximate) usedHeuristicSplit = true;
340
- // Sort locations by (file, startLine) for stable display.
341
- locations.sort((a, b) =>
342
- codeUnitCompare(a.file, b.file) ||
343
- (a.startLine || 0) - (b.startLine || 0)
344
- );
345
- const primary = locations[0];
346
- hotList.push({
347
- // Use the representative symbol's className for display name
348
- // (so "Foo.bar" is preserved when applicable). When defs
349
- // disagree on className, just show the bare name.
350
- name: representative && representative.className
351
- ? `${representative.className}.${name}`
352
- : name,
353
- // Primary location remains for backward-compat with consumers
354
- // that read `file`/`startLine`/`endLine` directly.
355
- file: primary.file,
356
- startLine: primary.startLine,
357
- endLine: primary.endLine,
358
- callCount: count,
359
- ...(approximate && { approximate: true }),
360
- ...(locations.length > 1 && { locations }),
361
- });
362
236
  }
363
237
 
364
238
  // Stable order: callCount desc, then (relativePath, startLine) asc.
@@ -370,11 +244,13 @@ function getStats(index, options = {}) {
370
244
 
371
245
  stats.hot = {
372
246
  top,
373
- total: hotList.length,
247
+ total: refined === candidates.length ? hotList.length : candidates.length,
248
+ totalKind: refined === candidates.length ? 'confirmed' : 'raw-call-candidates',
249
+ refined,
374
250
  items: hotList.slice(0, top),
375
- ...(usedHeuristicSplit && {
376
- note: 'Method-call counts approximated when receiver type was unknown — values within those rows may include unresolved calls split across owner classes.'
377
- }),
251
+ note: refined === candidates.length
252
+ ? 'Counts are confirmed caller-engine edges pinned to each displayed definition; unverified dispatch is excluded.'
253
+ : `Displayed counts are exact confirmed caller-engine edges; ${candidates.length} raw candidates were bounded and ${refined} required exact refinement.`,
378
254
  };
379
255
  }
380
256
 
@@ -435,11 +311,10 @@ function getToc(index, options = {}) {
435
311
  if (options.in) {
436
312
  if (!index.matchesFilters(fileEntry.relativePath, { in: options.in })) continue;
437
313
  }
438
- let functions = fileEntry.symbols.filter(s =>
439
- s.type === 'function' || s.type === 'method' || s.type === 'static' ||
440
- s.type === 'constructor' || s.type === 'public' || s.type === 'abstract' ||
441
- s.type === 'classmethod'
442
- );
314
+ // Every callable kind, incl. accessors/properties/indexers — a
315
+ // hand-rolled subset here silently dropped C# `this[]` (kind
316
+ // 'property') and Python accessor kinds from the detailed listing.
317
+ let functions = fileEntry.symbols.filter(s => CALLABLE_SYMBOL_KINDS.has(s.type));
443
318
  const classes = fileEntry.symbols.filter(s =>
444
319
  ['class', 'interface', 'type', 'enum', 'struct', 'trait', 'impl', 'record', 'namespace'].includes(s.type)
445
320
  );
@@ -521,7 +396,10 @@ function getToc(index, options = {}) {
521
396
 
522
397
  return {
523
398
  meta: {
524
- complete: totalDynamic === 0,
399
+ // `complete` describes this returned list, while the narrower
400
+ // dynamic-import fact has an unambiguous name of its own.
401
+ complete: hiddenFiles === 0 && totalDynamic === 0,
402
+ noDynamicImports: totalDynamic === 0,
525
403
  skipped: 0,
526
404
  dynamicImports: totalDynamic,
527
405
  uncertain: 0,
@@ -561,11 +439,19 @@ function getToc(index, options = {}) {
561
439
  * @param {object} options - { deep, sampleSize, in, file }
562
440
  */
563
441
  function doctor(index, options = {}) {
564
- const inFilter = options.in || options.file || null;
565
- const matchInFilter = (rel) => {
566
- if (!inFilter) return true;
567
- return rel.includes(inFilter);
568
- };
442
+ const projectLanguage = index._getPredominantLanguage();
443
+ const staticSpecialImports = projectLanguage &&
444
+ !langTraits(projectLanguage)?.hasDynamicImports;
445
+ const importBlindspotLabel = staticSpecialImports
446
+ ? (projectLanguage === 'rust' ? 'glob import(s)' : 'blank/dot import(s)')
447
+ : 'dynamic import(s)';
448
+ const normalizedExclude = Array.isArray(options.exclude)
449
+ ? options.exclude.filter(Boolean)
450
+ : (options.exclude
451
+ ? String(options.exclude).split(',').map(s => s.trim()).filter(Boolean)
452
+ : []);
453
+ const hasFilter = !!(options.in || options.file || normalizedExclude.length > 0);
454
+ const matchInFilter = (rel) => matchesReportingScope(index, rel, options);
569
455
 
570
456
  const fileCounts = { total: 0, scanned: 0 };
571
457
  const langs = {};
@@ -580,8 +466,13 @@ function doctor(index, options = {}) {
580
466
  dynamicImports: { count: 0, fileCount: 0, files: [] },
581
467
  evalCalls: { count: 0, fileCount: 0, files: [] },
582
468
  reflection: { count: 0, fileCount: 0, files: [] },
469
+ computedDispatch:{ count: 0, fileCount: 0, files: [] },
583
470
  parseFailures: { count: 0, fileCount: 0, files: [] },
584
471
  parseRecoveries:{ count: 0, fileCount: 0, files: [] },
472
+ unsupportedSources: {
473
+ count: 0, fileCount: 0, files: [], languages: {}, extensions: {},
474
+ },
475
+ skippedSources: { count: 0, fileCount: 0, files: [], reasons: {} },
585
476
  };
586
477
 
587
478
  // Reflection/eval signals come from the shared text-blind-spot counter
@@ -589,6 +480,7 @@ function doctor(index, options = {}) {
589
480
  // footer, so the two never drift (field-report #2). Occurrence counts.
590
481
  const { hasTextBlindspots, countTextBlindspots } = require('./shared');
591
482
 
483
+ const computedDispatch = projectComputedDispatch(index);
592
484
  for (const [filePath, fe] of index.files) {
593
485
  fileCounts.total++;
594
486
  const rel = fe.relativePath || filePath;
@@ -610,6 +502,10 @@ function doctor(index, options = {}) {
610
502
  };
611
503
 
612
504
  if (fe.dynamicImports && fe.dynamicImports > 0) recordBlind(blindSpots.dynamicImports, fe.dynamicImports);
505
+ if (computedDispatch.has(filePath)) {
506
+ recordBlind(blindSpots.computedDispatch,
507
+ computedDispatch.get(filePath).length);
508
+ }
613
509
  if (fe.parseError) recordBlind(blindSpots.parseFailures, 1);
614
510
  if (fe.parseRecovery) recordBlind(blindSpots.parseRecoveries, 1);
615
511
 
@@ -640,6 +536,35 @@ function doctor(index, options = {}) {
640
536
  recordedParseFailureFiles.add(rel);
641
537
  }
642
538
 
539
+ // Project-wide discovery also records common source languages for which
540
+ // UCN has no parser. These are not parse failures: they are an explicit
541
+ // engine boundary and a required grep/ripgrep handoff.
542
+ for (const skipped of index.unsupportedFiles || []) {
543
+ const rel = skipped.relativePath;
544
+ if (!matchInFilter(rel)) continue;
545
+ const unsupported = blindSpots.unsupportedSources;
546
+ unsupported.count++;
547
+ unsupported.fileCount++;
548
+ unsupported.languages[skipped.language] =
549
+ (unsupported.languages[skipped.language] || 0) + 1;
550
+ unsupported.extensions[skipped.extension] =
551
+ (unsupported.extensions[skipped.extension] || 0) + 1;
552
+ if (unsupported.files.length < BLINDSPOT_FILE_CAP) unsupported.files.push(rel);
553
+ }
554
+ fileCounts.unsupported = blindSpots.unsupportedSources.fileCount;
555
+
556
+ for (const issue of index.discoveryIssues || []) {
557
+ const rel = issue.relativePath || '.';
558
+ if (!matchInFilter(rel)) continue;
559
+ const skipped = blindSpots.skippedSources;
560
+ skipped.count++;
561
+ skipped.fileCount++;
562
+ skipped.reasons[issue.reason || 'unknown'] =
563
+ (skipped.reasons[issue.reason || 'unknown'] || 0) + 1;
564
+ if (skipped.files.length < BLINDSPOT_FILE_CAP) skipped.files.push(rel);
565
+ }
566
+ fileCounts.skipped = blindSpots.skippedSources.fileCount;
567
+
643
568
  // Evidence profile — sampled only in deep mode. This is deliberately NOT
644
569
  // called "accuracy" or "coverage": it describes how UCN classified edges
645
570
  // it found. Compiler/LSP oracle evaluation is the accuracy measurement.
@@ -663,47 +588,80 @@ function doctor(index, options = {}) {
663
588
  if (blindSpots.parseRecoveries.count > 0) blindSignals.push(`${blindSpots.parseRecoveries.count} parse-recovery file(s)`);
664
589
  if (blindSpots.evalCalls.count > 0) blindSignals.push(`${blindSpots.evalCalls.count} eval/exec use(s) in ${blindSpots.evalCalls.fileCount} file(s)`);
665
590
  if (blindSpots.reflection.count > 0) blindSignals.push(`${blindSpots.reflection.count} reflection use(s) in ${blindSpots.reflection.fileCount} file(s)`);
666
- if (blindSpots.dynamicImports.count > 0) blindSignals.push(`${blindSpots.dynamicImports.count} dynamic import(s) in ${blindSpots.dynamicImports.fileCount} file(s)`);
591
+ if (blindSpots.computedDispatch.count > 0) {
592
+ blindSignals.push(`${blindSpots.computedDispatch.count} computed dispatch call(s) in ${blindSpots.computedDispatch.fileCount} file(s)`);
593
+ }
594
+ if (blindSpots.dynamicImports.count > 0) blindSignals.push(`${blindSpots.dynamicImports.count} ${importBlindspotLabel} in ${blindSpots.dynamicImports.fileCount} file(s)`);
595
+ if (blindSpots.unsupportedSources.count > 0) {
596
+ const mix = Object.entries(blindSpots.unsupportedSources.languages)
597
+ .map(([language, count]) => `${language} ${count}`)
598
+ .join(', ');
599
+ blindSignals.push(`${blindSpots.unsupportedSources.count} unsupported source file(s): ${mix}`);
600
+ }
601
+ if (blindSpots.skippedSources.count > 0) {
602
+ const reasons = Object.entries(blindSpots.skippedSources.reasons)
603
+ .map(([reason, count]) => `${reason} ${count}`).join(', ');
604
+ blindSignals.push(`${blindSpots.skippedSources.count} source discovery gap(s): ${reasons}`);
605
+ }
667
606
 
668
607
  // Trust is task-specific. A healthy index can be excellent for navigation
669
608
  // while still requiring review before a breaking refactor or deletion.
670
609
  // 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'
610
+ const unsupportedCount = blindSpots.unsupportedSources.count;
611
+ const incompleteIndex = blindSpots.parseFailures.count > 0 ||
612
+ blindSpots.parseRecoveries.count > 0 || cache.fresh === false ||
613
+ !!index.truncated || unsupportedCount > 0 || blindSpots.skippedSources.count > 0;
614
+ const indexLevel = fileCounts.scanned === 0 && unsupportedCount > 0 ? 'UNSUPPORTED'
615
+ : fileCounts.scanned === 0 ? 'UNKNOWN'
616
+ : incompleteIndex ? 'PARTIAL' : 'HIGH';
617
+ const indexReason = fileCounts.scanned === 0 && unsupportedCount > 0
618
+ ? `0 supported files indexed; ${unsupportedCount} unsupported source file(s) require grep/ripgrep`
619
+ : fileCounts.scanned === 0 ? 'empty scope'
675
620
  : blindSpots.parseFailures.count > 0
676
621
  ? `${blindSpots.parseFailures.count} file(s) failed to parse`
677
622
  : blindSpots.parseRecoveries.count > 0
678
623
  ? `${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';
624
+ : cache.fresh === false ? 'index cache is stale'
625
+ : index.truncated ? `file discovery stopped at maxFiles=${index.truncated.maxFiles}`
626
+ : blindSpots.skippedSources.count > 0
627
+ ? `${blindSpots.skippedSources.count} source path(s) were not indexed; inspect discovery reasons`
628
+ : unsupportedCount > 0
629
+ ? `${fileCounts.scanned} supported file(s) indexed; ${unsupportedCount} unsupported source file(s) require grep/ripgrep`
630
+ : 'fresh index; no parse failures';
680
631
 
681
632
  let evidenceLevel = 'UNKNOWN';
682
633
  let evidenceReason = 'not sampled; run --deep for a stratified evidence profile';
683
634
  if (evidenceProfile) {
684
635
  if (evidenceProfile.total === 0) {
636
+ evidenceLevel = 'NONE';
685
637
  evidenceReason = 'sample contained no caller edges';
686
638
  } else {
687
639
  const confirmedShare = evidenceProfile.confirmed / evidenceProfile.total;
688
- evidenceLevel = !evidenceProfile.adequate ? 'UNKNOWN'
689
- : confirmedShare >= 0.85 ? 'HIGH'
690
- : confirmedShare >= 0.55 ? 'MEDIUM' : 'LOW';
640
+ // Confirmed share is a classification mix, not accuracy and not a
641
+ // readiness score. A conservative engine can be excellent while
642
+ // intentionally routing many edges to visible unverified bands.
643
+ evidenceLevel = evidenceProfile.adequate ? 'PROFILED' : 'LIMITED';
691
644
  evidenceReason = `${(confirmedShare * 100).toFixed(1)}% confirmed-evidence edges across ${evidenceProfile.sampled} pinned definitions`;
692
645
  if (!evidenceProfile.adequate) evidenceReason += '; sample too small for a readiness decision';
693
646
  }
694
647
  }
695
648
 
696
- const dynamicCount = blindSpots.evalCalls.count + blindSpots.reflection.count + blindSpots.dynamicImports.count;
649
+ const dynamicCount = blindSpots.evalCalls.count + blindSpots.reflection.count +
650
+ blindSpots.dynamicImports.count + blindSpots.computedDispatch.count;
697
651
  const semanticLevel = blindSpots.parseFailures.count > 0 || blindSpots.parseRecoveries.count > 0 ? 'LOW'
698
- : dynamicCount > 0 ? 'MEDIUM' : 'UNKNOWN';
652
+ : unsupportedCount > 0 ? 'PARTIAL'
653
+ : dynamicCount > 0 ? 'REVIEW' : 'UNKNOWN';
699
654
  const semanticReason = blindSignals.length
700
655
  ? `semantic recall may miss runtime-resolved edges: ${blindSignals.join(', ')}`
701
656
  : 'no known runtime blind spots detected; alias/dynamic completeness is not compiler-verified locally';
702
657
 
703
658
  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';
659
+ const hardIndexRisk = blindSpots.parseFailures.count > 0 ||
660
+ blindSpots.parseRecoveries.count > 0 || cache.fresh === false || !!index.truncated;
661
+ const refactorLevel = indexLevel === 'UNKNOWN' ? 'UNKNOWN'
662
+ : indexLevel === 'UNSUPPORTED' ? 'UNSUPPORTED'
663
+ : hardIndexRisk ? 'LOW'
664
+ : unsupportedCount > 0 ? 'PARTIAL' : 'REVIEW';
707
665
  const deletionLevel = refactorLevel === 'LOW' ? 'LOW' : 'REVIEW';
708
666
  const dimensions = {
709
667
  index: { level: indexLevel, reason: indexReason },
@@ -712,36 +670,46 @@ function doctor(index, options = {}) {
712
670
  navigation: { level: navigationLevel, reason: indexReason },
713
671
  refactor: {
714
672
  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',
673
+ reason: indexLevel === 'UNKNOWN'
674
+ ? 'no supported source scope was indexed'
675
+ : indexLevel === 'UNSUPPORTED'
676
+ ? 'UCN cannot analyze this source scope; use grep/ripgrep and a language-native tool'
677
+ : unsupportedCount > 0
678
+ ? 'supported files are analyzable, but skipped language files require grep/ripgrep before cross-language changes'
679
+ : 'text-ground accounting is available; aliases, reflection, and unverified edges still require review',
718
680
  },
719
681
  deletion: {
720
682
  level: deletionLevel,
721
683
  reason: 'deletion additionally requires usages/deadcode review and tests; caller accounting alone is insufficient',
722
684
  },
723
685
  };
724
- const trust = refactorLevel;
725
- const trustReason = dimensions.refactor.reason;
686
+ // `repo` is an orientation/navigation command. Its headline must answer
687
+ // that task, while refactor/deletion remain separately visible dimensions.
688
+ const trust = navigationLevel;
689
+ const trustReason = dimensions.navigation.reason;
726
690
 
727
691
  return {
728
692
  root: index.root,
693
+ projectLanguage,
729
694
  version: require('../package.json').version, // running ucn version — surfaces MCP/CLI drift (field-report #3)
730
695
  files: fileCounts,
731
696
  symbols: totalSymbols,
732
697
  languages: langs,
733
698
  blindSpots,
734
699
  evidenceProfile,
735
- // Backward-compatible field name. `kind` prevents consumers from
736
- // mistaking this for semantic coverage or measured accuracy.
737
- coverage: evidenceProfile,
738
700
  cache,
739
701
  commandTrust: summarizeCommandTrust(),
740
702
  trust,
741
703
  trustReason,
742
- trustScope: 'refactor-readiness',
704
+ trustScope: 'navigation-readiness',
743
705
  dimensions,
744
- ...(inFilter && { filter: inFilter }),
706
+ ...(hasFilter && {
707
+ filter: {
708
+ ...(options.file && { file: options.file }),
709
+ ...(options.in && { in: options.in }),
710
+ ...(normalizedExclude.length > 0 && { exclude: normalizedExclude }),
711
+ },
712
+ }),
745
713
  };
746
714
  }
747
715
 
@@ -807,6 +775,9 @@ function computeEvidenceProfile(index, { sampleSize, matchInFilter }) {
807
775
  const allEdges = [...callers, ...(callers.unverifiedEntries || [])];
808
776
  const seenSites = new Set();
809
777
  for (const c of allEdges) {
778
+ const rel = c.relativePath ||
779
+ (c.file && path.isAbsolute(c.file) ? path.relative(index.root, c.file) : c.file);
780
+ if (!matchInFilter(rel)) continue;
810
781
  const site = `${c.file || c.relativePath}:${c.line}:${c.tier || c.reason || ''}`;
811
782
  if (seenSites.has(site)) continue;
812
783
  seenSites.add(site);
@@ -836,16 +807,34 @@ function computeEvidenceProfile(index, { sampleSize, matchInFilter }) {
836
807
  */
837
808
  function orient(index, options = {}) {
838
809
  const top = options.top || 8;
839
- // Fetch a deeper hot list so production functions survive the filter
840
- // below even when test helpers dominate raw call counts.
841
- const stats = getStats(index, { hot: true, top: Math.min(top * 5, 200) });
842
- const health = doctor(index, {});
810
+ const scope = {
811
+ file: options.file,
812
+ in: options.in,
813
+ exclude: options.exclude,
814
+ };
815
+ const { isTestPath } = require('./shared');
816
+ const hasProductionFiles = [...index.files.values()].some(fileEntry => {
817
+ const relativePath = fileEntry.relativePath;
818
+ return relativePath &&
819
+ matchesReportingScope(index, relativePath, scope) &&
820
+ !isTestPath(relativePath);
821
+ });
822
+ const stats = getStats(index, {
823
+ ...scope,
824
+ hot: true,
825
+ top,
826
+ // A production-only call count is useful only when the selected scope
827
+ // actually contains production files. In an all-test repository it
828
+ // would erase the raw ranking that orient promises as its fallback.
829
+ productionCallsOnly: options.includeTests !== true && hasProductionFiles,
830
+ });
831
+ const health = doctor(index, scope);
843
832
 
844
833
  // Densest directories (leaf dirname rollup — "where does the code live")
845
834
  const dirMap = new Map();
846
835
  for (const [, fe] of index.files) {
847
836
  const rp = fe.relativePath;
848
- if (!rp) continue;
837
+ if (!rp || !matchesReportingScope(index, rp, scope)) continue;
849
838
  const slash = rp.lastIndexOf('/');
850
839
  const dir = slash === -1 ? '.' : rp.slice(0, slash);
851
840
  const e = dirMap.get(dir) || { dir, files: 0, symbols: 0 };
@@ -861,7 +850,24 @@ function orient(index, options = {}) {
861
850
  let entrypoints = null;
862
851
  try {
863
852
  const { detectEntrypoints } = require('./entrypoints');
864
- const eps = detectEntrypoints(index, {});
853
+ const { addTestExclusions } = require('./shared');
854
+ const orientExclude = Array.isArray(options.exclude)
855
+ ? options.exclude
856
+ : (options.exclude
857
+ ? String(options.exclude).split(',').map(s => s.trim()).filter(Boolean)
858
+ : []);
859
+ // Same default as the entrypoints command: the orientation count
860
+ // describes the project's own entry surface, not test fixtures.
861
+ const detected = detectEntrypoints(index, {
862
+ file: options.file,
863
+ exclude: options.includeTests === true
864
+ ? orientExclude
865
+ : addTestExclusions(orientExclude),
866
+ });
867
+ const eps = Array.isArray(detected)
868
+ ? detected.filter(entry => matchesReportingScope(
869
+ index, entry.relativePath || entry.file, scope))
870
+ : detected;
865
871
  if (Array.isArray(eps)) {
866
872
  const byType = new Map();
867
873
  for (const e of eps) byType.set(e.type, (byType.get(e.type) || 0) + 1);
@@ -877,7 +883,6 @@ function orient(index, options = {}) {
877
883
  // Orientation wants the ENGINE's hot functions, not fixture helpers —
878
884
  // prefer production-path entries (labeled as such by the formatter);
879
885
  // an all-test project falls back to the raw ranking.
880
- const { isTestPath } = require('./shared');
881
886
  const allHot = (stats.hot?.items || []).map(i => ({
882
887
  name: i.name,
883
888
  file: i.file,
@@ -892,22 +897,39 @@ function orient(index, options = {}) {
892
897
 
893
898
  return {
894
899
  root: stats.root,
900
+ ...(options.in && { scope: options.in }),
901
+ ...(options.file && !options.in && { scope: options.file }),
895
902
  files: stats.files,
896
903
  symbols: stats.symbols,
897
904
  buildTime: stats.buildTime,
898
905
  byLanguage: stats.byLanguage,
899
906
  dirs,
900
- hot: { total: stats.hot?.total ?? 0, top, production, items: hotItems },
907
+ hot: {
908
+ total: stats.hot?.total ?? 0,
909
+ totalKind: stats.hot?.totalKind || 'confirmed',
910
+ refined: stats.hot?.refined ?? 0,
911
+ top,
912
+ production,
913
+ items: hotItems,
914
+ },
901
915
  entrypoints,
902
916
  trust: {
903
917
  level: health.trust,
918
+ projectLanguage: health.projectLanguage,
904
919
  blindSpots: {
905
920
  dynamicImports: health.blindSpots?.dynamicImports?.count ?? 0,
906
921
  evalCalls: health.blindSpots?.evalCalls?.count ?? 0,
907
922
  reflection: health.blindSpots?.reflection?.count ?? 0,
923
+ computedDispatch: health.blindSpots?.computedDispatch?.count ?? 0,
908
924
  parseFailures: health.blindSpots?.parseFailures?.count ?? 0,
925
+ parseRecoveries: health.blindSpots?.parseRecoveries?.count ?? 0,
926
+ unsupportedSources: health.blindSpots?.unsupportedSources?.count ?? 0,
927
+ skippedSources: health.blindSpots?.skippedSources?.count ?? 0,
909
928
  },
910
929
  },
930
+ unsupportedSources: health.blindSpots?.unsupportedSources ?? null,
931
+ skippedSources: health.blindSpots?.skippedSources ?? null,
932
+ projectLanguage: health.projectLanguage,
911
933
  suggest: hottestProd ? hottestProd.name : null,
912
934
  };
913
935
  }