ucn 5.4.2 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.claude/skills/ucn/SKILL.md +58 -236
  2. package/.claude/skills/ucn/references/commands.md +144 -68
  3. package/.claude/skills/ucn/references/resolution-rules.md +171 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +27 -33
  5. package/README.md +18 -12
  6. package/cli/index.js +40 -14
  7. package/core/accessors.js +118 -3
  8. package/core/account.js +30 -0
  9. package/core/analysis.js +508 -264
  10. package/core/ast-analysis.js +76 -117
  11. package/core/async-producers.js +1729 -0
  12. package/core/bridge.js +1877 -268
  13. package/core/brief.js +6 -0
  14. package/core/build-worker.js +78 -14
  15. package/core/cache.js +522 -31
  16. package/core/callers.js +12053 -1047
  17. package/core/check.js +30 -38
  18. package/core/class-identity.js +1028 -0
  19. package/core/command-contracts.js +6 -6
  20. package/core/configuration-items.js +86 -0
  21. package/core/contract-membership.js +2262 -0
  22. package/core/cpp-scope.js +532 -0
  23. package/core/deadcode.js +171 -49
  24. package/core/decorator-binding.js +260 -0
  25. package/core/discovery.js +281 -164
  26. package/core/errors.js +52 -0
  27. package/core/execute.js +21 -9
  28. package/core/external-macros.js +326 -0
  29. package/core/graph-build.js +277 -8
  30. package/core/graph.js +35 -2
  31. package/core/ignore-rules.js +472 -0
  32. package/core/imports.js +504 -42
  33. package/core/index-ir.js +94 -11
  34. package/core/inprocess-clients.js +636 -0
  35. package/core/ir.js +87 -8
  36. package/core/macro-expansion.js +1416 -0
  37. package/core/output/analysis.js +18 -2
  38. package/core/output/brief.js +3 -0
  39. package/core/output/doctor.js +4 -0
  40. package/core/output/endpoints.js +15 -6
  41. package/core/output/find.js +7 -2
  42. package/core/output/refactoring.js +121 -14
  43. package/core/output/reporting.js +31 -3
  44. package/core/output/shared.js +14 -0
  45. package/core/output-budget.js +305 -122
  46. package/core/parallel-build.js +124 -47
  47. package/core/parser.js +3 -2
  48. package/core/project.js +457 -60
  49. package/core/provenance-facts.js +156 -12
  50. package/core/provenance-overload.js +30 -3
  51. package/core/provenance.js +104 -30
  52. package/core/pytest-fixtures.js +434 -0
  53. package/core/python-fixture-flow.js +17 -3
  54. package/core/python-modules.js +173 -0
  55. package/core/reflection.js +877 -0
  56. package/core/registry.js +1 -1
  57. package/core/reporting.js +234 -52
  58. package/core/request-helpers.js +561 -0
  59. package/core/route-graph.js +1922 -0
  60. package/core/route-lists.js +2414 -0
  61. package/core/rust-macro-expansion.js +2113 -0
  62. package/core/rust-modules.js +185 -0
  63. package/core/search.js +121 -29
  64. package/core/shared.js +19 -1
  65. package/core/test-name-bindings.js +256 -0
  66. package/core/type-denotation.js +1161 -0
  67. package/core/type-references.js +3229 -0
  68. package/core/verify.js +3360 -672
  69. package/languages/adapter.js +31 -5
  70. package/languages/c-family.js +4061 -212
  71. package/languages/c-macro-invocations.js +380 -0
  72. package/languages/c-preprocessor.js +254 -0
  73. package/languages/csharp.js +1406 -49
  74. package/languages/go.js +1243 -70
  75. package/languages/index.js +728 -6
  76. package/languages/java.js +473 -61
  77. package/languages/javascript.js +1270 -197
  78. package/languages/lexical-scope.js +1280 -0
  79. package/languages/python.js +1557 -72
  80. package/languages/rust-macro-rules.js +1020 -0
  81. package/languages/rust-value-flow.js +416 -0
  82. package/languages/rust.js +2237 -115
  83. package/languages/utils.js +298 -4
  84. package/mcp/server.js +22 -8
  85. package/package.json +1 -1
package/core/registry.js CHANGED
@@ -136,7 +136,7 @@ const FLAG_APPLICABILITY = {
136
136
  // related. Caller-bearing projections always preserve ACCOUNT/CONTRACT.
137
137
  show: ['name', 'file', 'exclude', 'className', 'line', 'sections', 'includeMethods', 'includeTests', 'top', 'all', 'withTypes', 'minConfidence', 'showConfidence', 'unreachableOnly', 'compact', 'git', 'diverse', 'lines'],
138
138
  find: ['name', 'file', 'exclude', 'className', 'includeTests', 'limit', 'exact', 'in', 'compact', 'type', 'withSource', 'lines'],
139
- usages: ['name', 'file', 'exclude', 'className', 'includeTests', 'limit', 'codeOnly', 'context', 'in', 'compact', 'all', 'lines'],
139
+ usages: ['name', 'file', 'exclude', 'className', 'excludeTests', 'limit', 'codeOnly', 'context', 'in', 'compact', 'all', 'lines'],
140
140
  search: ['term', 'file', 'exclude', 'includeTests', 'top', 'limit', 'codeOnly', 'caseSensitive', 'context', 'regex', 'in', 'type', 'param', 'receiver', 'returns', 'decorator', 'exported', 'unused', 'lines'],
141
141
  source: ['name', 'file', 'className', 'line', 'range', 'all', 'maxLines', 'raw'],
142
142
  trace: ['name', 'file', 'exclude', 'className', 'line', 'direction', 'to', 'includeMethods', 'depth', 'all', 'expandUnverified'],
package/core/reporting.js CHANGED
@@ -220,14 +220,37 @@ function getStats(index, options = {}) {
220
220
  }
221
221
  return ranges.length > 0 && lineInRanges(line, ranges);
222
222
  };
223
+ // Files whose callers can COUNT (fix #365). The exact count below
224
+ // keeps only callers in scope, outside bundles, and - for a
225
+ // production-only ranking - outside test files, so records elsewhere
226
+ // can never raise a definition's count: they leave the bound, and
227
+ // the exact passes never scan them (every per-site verdict is
228
+ // file-local). Orientation on django spent most of its time
229
+ // resolving the test suite's calls, which it then discarded.
230
+ const { isTestPath } = require('./shared');
231
+ const countableFile = (filePath) => {
232
+ if (!scopedPaths.has(filePath)) return false;
233
+ const fileEntry = index.files.get(filePath);
234
+ if (!fileEntry || fileEntry.isBundled) return false;
235
+ return !options.productionCallsOnly || !isTestPath(fileEntry.relativePath || filePath);
236
+ };
237
+ const countableFiles = new Set([...index.files.keys()].filter(countableFile));
238
+ // Call records per name in the countable files: what one exact
239
+ // refinement of that name scans (the HOT work unit, fix #382).
240
+ const recordsByName = new Map();
241
+ // The bound must count calls macro expansions generate (fix #374).
242
+ require('./rust-macro-expansion').materializeRustMacroCalls(index);
223
243
  for (const [filePath, entry] of index.callsCache) {
224
- if (!scopedPaths.has(filePath)) continue;
244
+ if (!countableFiles.has(filePath)) continue;
225
245
  const fileEntry = index.files.get(filePath);
226
- if (fileEntry?.isBundled) continue;
227
246
  if (!entry || !Array.isArray(entry.calls)) continue;
228
247
  const seenInFile = new Set();
229
248
  for (const c of entry.calls) {
230
249
  if (!c || !c.name) continue;
250
+ recordsByName.set(c.name, (recordsByName.get(c.name) || 0) + 1);
251
+ if (c.resolvedName && c.resolvedName !== c.name) {
252
+ recordsByName.set(c.resolvedName, (recordsByName.get(c.resolvedName) || 0) + 1);
253
+ }
231
254
  if (receiverProvablyExternal(filePath, fileEntry, c)) continue;
232
255
  if (options.productionCallsOnly && inInlineTest(filePath, c.line)) continue;
233
256
  // Distinct receiver types on one line can confirm DIFFERENT
@@ -252,6 +275,16 @@ function getStats(index, options = {}) {
252
275
  }
253
276
  const ownerOf = (symbol) => normalizeTypeName(symbol.className || symbol.receiver || '');
254
277
 
278
+ // One HOT entry per callable IDENTITY (fix #382). The caller engine
279
+ // closes a pinned definition over its identity group (overload
280
+ // signatures with their implementation, C/C++ prototypes with their
281
+ // definitions, C++ specializations with their primary template), so
282
+ // definitions whose closed groups are the same set answer the same
283
+ // query: HOT listed a Python @overload stub pair beside the
284
+ // implementation and each TS overload signature of `h` with one
285
+ // count. Members of one closed set collapse into one entry, shown at
286
+ // its first implementation (else its first declaration).
287
+ const { _closeCallableIdentityGroup } = require('./callers');
255
288
  const candidates = [];
256
289
  const seenDefinitions = new Set();
257
290
  for (const [name, symbols] of index.symbols) {
@@ -262,6 +295,7 @@ function getStats(index, options = {}) {
262
295
  FUNCTION_TYPES.has(symbol.type) &&
263
296
  matchesReportingScope(index, symbol.relativePath, options));
264
297
  const definers = new Set(callable.map(ownerOf).filter(Boolean));
298
+ const eligible = [];
265
299
  for (const symbol of callable) {
266
300
  let upper = untyped;
267
301
  if (typedByClass) {
@@ -271,14 +305,6 @@ function getStats(index, options = {}) {
271
305
  }
272
306
  }
273
307
  if (upper === 0) continue;
274
- // Fair share of the name's ceiling: a name shared by 278
275
- // methods cannot make all 278 hot, so a definition's likely
276
- // count is nearer upper/definers than upper. Ordering by the
277
- // share puts the genuinely hot definitions first; the early
278
- // stop below still uses the exact remaining ceiling, so the
279
- // exact answer is unchanged and only a bounded refinement
280
- // (`maxRefine`) benefits from the order.
281
- const share = upper / Math.max(1, callable.length);
282
308
  if (index.files.get(symbol.file)?.isBundled) continue;
283
309
  if (options.productionCallsOnly &&
284
310
  (require('./shared').isTestPath(symbol.relativePath) ||
@@ -287,63 +313,159 @@ function getStats(index, options = {}) {
287
313
  `${symbol.className || symbol.receiver || ''}:${symbol.params || ''}`;
288
314
  if (seenDefinitions.has(identity)) continue;
289
315
  seenDefinitions.add(identity);
290
-
291
- // A linked C/C++ prototype and implementation close to the
292
- // same compiler identity. Show the implementation once.
293
- if (symbol.isSignature && callable.some(candidate =>
294
- !candidate.isSignature &&
295
- (candidate.className || candidate.receiver || null) ===
296
- (symbol.className || symbol.receiver || null) &&
297
- (index.importGraph.get(candidate.file)?.has(symbol.file) ||
298
- index.importGraph.get(symbol.file)?.has(candidate.file)))) {
299
- continue;
316
+ eligible.push({ name, symbol, upper });
317
+ }
318
+ // Groups close only over a signature or a C++ template
319
+ // specialization; names with neither keep one entry per
320
+ // definition without asking.
321
+ const grouped = eligible.length > 1 && symbols.some(definition =>
322
+ definition.isSignature || definition.isSpecialization || definition.templateDependent);
323
+ if (!grouped) {
324
+ candidates.push(...eligible);
325
+ continue;
326
+ }
327
+ // Entries whose closures reach each other are one entity (a C
328
+ // prototype closes over every definition of its linkage, each
329
+ // definition over the prototypes). The entity is counted through
330
+ // a member whose closure covers all of it (the implementation
331
+ // when it does, else the declaration), so the count is the
332
+ // entity's; without such a member, each distinct closure keeps
333
+ // its own entry.
334
+ const closures = eligible.map(entry =>
335
+ new Set(_closeCallableIdentityGroup(index, [entry.symbol], symbols)));
336
+ const parent = eligible.map((entry, i) => i);
337
+ const find = i => (parent[i] === i ? i : (parent[i] = find(parent[i])));
338
+ for (let i = 0; i < eligible.length; i++) {
339
+ for (let j = i + 1; j < eligible.length; j++) {
340
+ if (closures[i].has(eligible[j].symbol) || closures[j].has(eligible[i].symbol)) {
341
+ parent[find(j)] = find(i);
342
+ }
343
+ }
344
+ }
345
+ const components = new Map(); // root -> entry indexes in symbol order
346
+ for (let i = 0; i < eligible.length; i++) {
347
+ const root = find(i);
348
+ if (!components.has(root)) components.set(root, []);
349
+ components.get(root).push(i);
350
+ }
351
+ const position = new Map(symbols.map((definition, i) => [definition, i]));
352
+ for (const members of components.values()) {
353
+ const covering = members.filter(i =>
354
+ members.every(j => closures[i].has(eligible[j].symbol)));
355
+ const groups = covering.length > 0 ? [covering] : [...members.reduce((byKey, i) => {
356
+ const key = [...closures[i]].map(definition => position.get(definition))
357
+ .sort((a, b) => a - b).join(',');
358
+ if (!byKey.has(key)) byKey.set(key, []);
359
+ byKey.get(key).push(i);
360
+ return byKey;
361
+ }, new Map()).values()];
362
+ for (const group of groups) {
363
+ const shown = group.find(i => !eligible[i].symbol.isSignature) ?? group[0];
364
+ candidates.push({
365
+ ...eligible[shown],
366
+ upper: Math.max(...(covering.length > 0 ? members : group).map(i => eligible[i].upper)),
367
+ });
300
368
  }
301
- candidates.push({ name, symbol, upper, share });
302
369
  }
303
370
  }
304
371
  const maxRefine = Number.isInteger(options.maxRefine) && options.maxRefine > 0
305
372
  ? options.maxRefine : Infinity;
306
- // Exact mode walks the ceilings in descending order so the early stop
307
- // fires as soon as possible; a bounded refinement walks fair shares so
308
- // the budget lands on the definitions most likely to be hot.
373
+ // Refinement work is bounded by call records, not wall time, so the
374
+ // answer is the same on every run and machine (fix #382). One
375
+ // refinement scans the call records of its name in the countable
376
+ // files; `workBudget` caps their sum. Exact answers on real projects
377
+ // stay far below it; a very large repository (kubernetes: ~68M
378
+ // records under its candidate ceilings) stops and says so.
379
+ const workBudget = Number.isFinite(options.workBudget) && options.workBudget > 0
380
+ ? options.workBudget : STATS_HOT_WORK_BUDGET;
381
+ const workOf = candidate => recordsByName.get(candidate.name) || 0;
382
+ // Ceilings in descending order: the early stop fires as soon as no
383
+ // unseen ceiling can reach the current Nth count, so exactly the
384
+ // candidates able to enter the top N are refined.
309
385
  candidates.sort((a, b) =>
310
- (maxRefine !== Infinity ? (b.share - a.share) : 0) ||
311
386
  (b.upper - a.upper) ||
312
387
  codeUnitCompare(a.symbol.relativePath, b.symbol.relativePath) ||
313
388
  (a.symbol.startLine || 0) - (b.symbol.startLine || 0));
314
- // Suffix maximum of the exact ceilings: once no unseen candidate can
315
- // beat the current Nth result, the answer is exact regardless of order.
316
- const remainingUpper = new Array(candidates.length + 1).fill(-1);
317
- for (let i = candidates.length - 1; i >= 0; i--) {
318
- remainingUpper[i] = Math.max(candidates[i].upper, remainingUpper[i + 1]);
319
- }
389
+ // Ceiling order is the cheapest exact walk. When the budget binds
390
+ // first, the ceiling walk has spent it on the definitions of a few
391
+ // names shared by hundreds of types (every `String`, `Name`,
392
+ // generated `DeepCopyInto` carries its whole name's ceiling), so a
393
+ // second budget walks the rest in fair-share order (ceiling per
394
+ // same-name candidate) and the approximate list spans many names.
395
+ // Exactness is proven the same way in both walks: no unrefined
396
+ // ceiling can reach the current Nth count.
397
+ const fairShareOrder = () => {
398
+ const definitionsOf = new Map();
399
+ for (const candidate of candidates) {
400
+ definitionsOf.set(candidate.name, (definitionsOf.get(candidate.name) || 0) + 1);
401
+ }
402
+ return candidates.map((candidate, i) => i).sort((a, b) =>
403
+ (candidates[b].upper / definitionsOf.get(candidates[b].name)) -
404
+ (candidates[a].upper / definitionsOf.get(candidates[a].name)) || a - b);
405
+ };
406
+ const completionBudget = Number.isFinite(workBudget)
407
+ ? Math.floor(workBudget * HOT_COMPLETION_BUDGET_SHARE) : 0;
320
408
 
321
409
  const hotList = [];
322
410
  const { findCallers } = require('./callers');
323
411
  const scopedCallerQuery = !!(options.file || options.in ||
324
412
  (options.exclude && options.exclude.length > 0));
325
413
  let refined = 0;
414
+ let work = 0;
415
+ // One test-path verdict per caller file (fix #382: asked per caller
416
+ // of every refinement).
417
+ const testPathMemo = new Map();
418
+ const testPath = (file) => {
419
+ let verdict = testPathMemo.get(file);
420
+ if (verdict === undefined) {
421
+ verdict = require('./shared').isTestPath(file);
422
+ testPathMemo.set(file, verdict);
423
+ }
424
+ return verdict;
425
+ };
326
426
  let budgetExhausted = false;
427
+ const topCounts = []; // the `top` largest counts so far, descending
428
+ const done = new Uint8Array(candidates.length);
429
+ let ceilingHead = 0; // first candidate in ceiling order not refined
327
430
  // One operation scope for the whole refinement loop (fix #340): the
328
431
  // per-file derivations findCallers builds are shared across candidates.
329
432
  if (top > 0) index._beginOp();
330
433
  try {
331
434
  if (top > 0) {
332
- for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) {
435
+ let order = null; // null = ceiling order
436
+ let limit = workBudget;
437
+ let exact = false;
438
+ for (let step = 0; step < candidates.length && !exact;) {
439
+ const candidateIndex = order ? order[step] : step;
440
+ step++;
441
+ if (done[candidateIndex]) continue;
333
442
  if (refined >= maxRefine) { budgetExhausted = true; break; }
443
+ if (work >= limit) {
444
+ budgetExhausted = true;
445
+ if (order || completionBudget === 0) break;
446
+ // Ceiling budget spent: complete in fair-share order.
447
+ order = fairShareOrder();
448
+ limit = work + completionBudget;
449
+ step = 0;
450
+ continue;
451
+ }
334
452
  const { name, symbol } = candidates[candidateIndex];
335
- const exact = findCallers(index, name, {
453
+ const callers = findCallers(index, name, {
336
454
  targetDefinitions: [symbol],
337
455
  includeTests: true,
338
456
  collectAccount: true,
457
+ ...(countableFiles.size < index.files.size && { candidateFiles: countableFiles }),
339
458
  });
340
459
  refined++;
341
- const count = exact.filter(caller =>
460
+ work += workOf(candidates[candidateIndex]);
461
+ done[candidateIndex] = 1;
462
+ while (ceilingHead < candidates.length && done[ceilingHead]) ceilingHead++;
463
+ const count = callers.filter(caller =>
342
464
  caller.tier !== 'unverified' &&
343
465
  (!scopedCallerQuery || scopedPaths.has(caller.file)) &&
344
466
  !index.files.get(caller.file)?.isBundled &&
345
467
  (!options.productionCallsOnly ||
346
- (!require('./shared').isTestPath(caller.relativePath || caller.file) &&
468
+ (!testPath(caller.relativePath || caller.file) &&
347
469
  !inInlineTest(caller.file, caller.line)))).length;
348
470
  if (count > 0) {
349
471
  const owner = symbol.className ||
@@ -356,16 +478,22 @@ function getStats(index, options = {}) {
356
478
  callCount: count,
357
479
  evidence: 'confirmed-callers',
358
480
  });
481
+ // The stop test needs only the top-th largest count;
482
+ // the full list is sorted once below (fix #365: sorting
483
+ // it per refinement was quadratic in refinements).
484
+ let at = topCounts.length;
485
+ while (at > 0 && topCounts[at - 1] < count) at--;
486
+ if (at < top) {
487
+ topCounts.splice(at, 0, count);
488
+ if (topCounts.length > top) topCounts.pop();
489
+ }
359
490
  }
360
- hotList.sort((a, b) =>
361
- (b.callCount - a.callCount) ||
362
- codeUnitCompare(a.file, b.file) ||
363
- (a.startLine || 0) - (b.startLine || 0));
364
- if (hotList.length >= top) {
365
- const threshold = hotList[top - 1].callCount;
366
- if (remainingUpper[candidateIndex + 1] < threshold) break;
367
- }
491
+ // Exact once no unrefined ceiling can reach the Nth count
492
+ // (the highest unrefined ceiling is the ceiling-order head).
493
+ if (topCounts.length >= top && (ceilingHead >= candidates.length ||
494
+ candidates[ceilingHead].upper < topCounts[top - 1])) exact = true;
368
495
  }
496
+ if (exact) budgetExhausted = false;
369
497
  }
370
498
  } finally { if (top > 0) index._endOp(); }
371
499
 
@@ -382,7 +510,10 @@ function getStats(index, options = {}) {
382
510
  totalKind: refined === candidates.length ? 'confirmed' : 'raw-call-candidates',
383
511
  refined,
384
512
  items: hotList.slice(0, top),
385
- ...(budgetExhausted && { budgetExhausted: true, maxRefine }),
513
+ ...(budgetExhausted && {
514
+ budgetExhausted: true,
515
+ ...(refined >= maxRefine ? { maxRefine } : { workBudget, work }),
516
+ }),
386
517
  note: refined === candidates.length
387
518
  ? 'Counts are confirmed caller-engine edges pinned to each displayed definition; unverified dispatch is excluded.'
388
519
  : `Displayed counts are exact confirmed caller-engine edges; ${candidates.length} raw candidates were bounded and ${refined} required exact refinement.`,
@@ -700,6 +831,37 @@ function doctor(index, options = {}) {
700
831
  }
701
832
  fileCounts.skipped = blindSpots.skippedSources.fileCount;
702
833
 
834
+ // C/C++ token-pasting macro invocations whose expansion cannot be
835
+ // computed (fix #362). Deep mode only: it reads the calls of every
836
+ // C/C++ file.
837
+ if (options.deep && [...index.files.values()].some(fe => langTraits(fe.language)?.textualIncludes)) {
838
+ const { macroExpansionSummary } = require('./macro-expansion');
839
+ const summary = macroExpansionSummary(index);
840
+ blindSpots.macroPasteDispatch = {
841
+ count: summary.blind.count,
842
+ fileCount: summary.blind.fileCount,
843
+ files: summary.blind.files,
844
+ expandedSites: summary.sites,
845
+ unexpandedPatterns: summary.patterns.length,
846
+ };
847
+ }
848
+
849
+ // Rust macro_rules! invocations of project macros that could not be
850
+ // expanded (fix #374): read from the persisted per-file records.
851
+ if ([...index.files.values()].some(fe => fe.rustMacroExpansion)) {
852
+ const { rustMacroExpansionSummary } = require('./rust-macro-expansion');
853
+ const summary = rustMacroExpansionSummary(index);
854
+ if (summary.sites > 0 || summary.blind.count > 0) {
855
+ blindSpots.macroRulesExpansion = {
856
+ count: summary.blind.count,
857
+ fileCount: summary.blind.fileCount,
858
+ files: summary.blind.files,
859
+ expandedSites: summary.sites,
860
+ ...(summary.blind.reasons && { reasons: summary.blind.reasons }),
861
+ };
862
+ }
863
+ }
864
+
703
865
  // Evidence profile — sampled only in deep mode. This is deliberately NOT
704
866
  // called "accuracy" or "coverage": it describes how UCN classified edges
705
867
  // it found. Compiler/LSP oracle evaluation is the accuracy measurement.
@@ -726,6 +888,12 @@ function doctor(index, options = {}) {
726
888
  if (blindSpots.computedDispatch.count > 0) {
727
889
  blindSignals.push(`${blindSpots.computedDispatch.count} computed dispatch call(s) in ${blindSpots.computedDispatch.fileCount} file(s)`);
728
890
  }
891
+ if (blindSpots.macroPasteDispatch?.count > 0) {
892
+ blindSignals.push(`${blindSpots.macroPasteDispatch.count} token-pasting macro dispatch invocation(s) not expanded in ${blindSpots.macroPasteDispatch.fileCount} file(s)`);
893
+ }
894
+ if (blindSpots.macroRulesExpansion?.count > 0) {
895
+ blindSignals.push(`${blindSpots.macroRulesExpansion.count} macro_rules! invocation(s) not expanded in ${blindSpots.macroRulesExpansion.fileCount} file(s)`);
896
+ }
729
897
  if (blindSpots.dynamicImports.count > 0) blindSignals.push(`${blindSpots.dynamicImports.count} ${importBlindspotLabel} in ${blindSpots.dynamicImports.fileCount} file(s)`);
730
898
  if (blindSpots.unsupportedSources.count > 0) {
731
899
  const mix = Object.entries(blindSpots.unsupportedSources.languages)
@@ -940,11 +1108,20 @@ function computeEvidenceProfile(index, { sampleSize, matchInFilter }) {
940
1108
  * trust verdict. Composes existing engine reads; counts and pointers only
941
1109
  * (no caller claims, so no account — the toc/stats category).
942
1110
  */
943
- // Orientation refines at most this many HOT candidates exactly (fix #340).
944
- // grpc-go (1037 files): exact refinement walks 1089 candidates in ~20s; 400
945
- // in fair-share order reproduces the exact top 8 in under 5s. When the budget
946
- // binds, the header says so and points at the exact command.
947
- const ORIENT_HOT_REFINE_BUDGET = 400;
1111
+ // Orientation's HOT list is exact unless exact refinement would examine more
1112
+ // call records than this (fix #382; a count of 10000 refinements before, fix
1113
+ // #340/#365). A production ranking charges its bound only with production
1114
+ // call records and scans only production files, so exact lists on real
1115
+ // projects examine well under a million records (django ~450k, tokio ~500k).
1116
+ // The budget binds only on very large repositories (kubernetes), where the
1117
+ // header says the ranking is approximate.
1118
+ const ORIENT_HOT_WORK_BUDGET = 1500000;
1119
+ // `repo --sections=stats --hot` ranks by all callers and is the explicit
1120
+ // request for an exact list: a larger budget, disclosed the same way.
1121
+ const STATS_HOT_WORK_BUDGET = 8000000;
1122
+ // When a ceiling-order walk exhausts its budget, this share of it again is
1123
+ // spent completing the ranking in fair-share order.
1124
+ const HOT_COMPLETION_BUDGET_SHARE = 0.5;
948
1125
 
949
1126
  function orient(index, options = {}) {
950
1127
  const top = options.top || 8;
@@ -968,8 +1145,10 @@ function orient(index, options = {}) {
968
1145
  // actually contains production files. In an all-test repository it
969
1146
  // would erase the raw ranking that orient promises as its fallback.
970
1147
  productionCallsOnly: options.includeTests !== true && hasProductionFiles,
971
- maxRefine: Number.isInteger(options.hotRefineBudget) && options.hotRefineBudget > 0
972
- ? options.hotRefineBudget : ORIENT_HOT_REFINE_BUDGET,
1148
+ ...(Number.isInteger(options.hotRefineBudget) && options.hotRefineBudget > 0 &&
1149
+ { maxRefine: options.hotRefineBudget }),
1150
+ workBudget: Number.isFinite(options.hotWorkBudget) && options.hotWorkBudget > 0
1151
+ ? options.hotWorkBudget : ORIENT_HOT_WORK_BUDGET,
973
1152
  });
974
1153
  const health = doctor(index, scope);
975
1154
 
@@ -1053,7 +1232,10 @@ function orient(index, options = {}) {
1053
1232
  totalKind: stats.hot?.totalKind || 'confirmed',
1054
1233
  refined: stats.hot?.refined ?? 0,
1055
1234
  ...(stats.hot?.budgetExhausted && {
1056
- budgetExhausted: true, maxRefine: stats.hot.maxRefine,
1235
+ budgetExhausted: true,
1236
+ ...(stats.hot.maxRefine !== undefined
1237
+ ? { maxRefine: stats.hot.maxRefine }
1238
+ : { workBudget: stats.hot.workBudget, work: stats.hot.work }),
1057
1239
  }),
1058
1240
  top,
1059
1241
  production,