ucn 5.4.2 → 5.5.1

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 +27 -16
  6. package/cli/index.js +45 -14
  7. package/core/accessors.js +118 -3
  8. package/core/account.js +62 -11
  9. package/core/analysis.js +517 -266
  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 +529 -31
  16. package/core/callers.js +12228 -921
  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 +472 -66
  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 +3026 -0
  62. package/core/rust-modules.js +203 -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 +1185 -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 +2265 -112
  83. package/languages/utils.js +298 -4
  84. package/mcp/server.js +22 -8
  85. package/package.json +1 -1
@@ -147,6 +147,14 @@ function formatAccountLines(account) {
147
147
  `(${account.unparsed.lines} line${account.unparsed.lines === 1 ? '' : 's'}, NOT analyzed): ` +
148
148
  account.unparsed.files.join(', '));
149
149
  }
150
+ if (account.recovered && account.recovered.lines > 0) {
151
+ const rec = account.recovered;
152
+ const shown = rec.sites.slice(0, 5).map(site => `${site.file}:${site.line}`);
153
+ lines.push(`WARNING: ${rec.lines} line${rec.lines === 1 ? '' : 's'} with "${account.symbol}" in ` +
154
+ `${rec.fileCount} file${rec.fileCount === 1 ? '' : 's'} sit${rec.lines === 1 ? 's' : ''} in syntax-error recovery ` +
155
+ `regions (partially parsed; classification there may be wrong): ${shown.join(', ')}` +
156
+ `${rec.lines > shown.length ? `, ... and ${rec.lines - shown.length} more` : ''}`);
157
+ }
150
158
  if (unsupported.lines > 0) {
151
159
  const langParts = Object.entries(unsupported.languages)
152
160
  .map(([lang, count]) => `${lang}: ${count}`).join(', ');
@@ -270,6 +278,7 @@ function formatContextJson(context) {
270
278
  ...(c.resolution && { resolution: c.resolution }),
271
279
  ...(c.provenance && { provenance: c.provenance }),
272
280
  ...(c.siteProvenance && { siteProvenance: c.siteProvenance }),
281
+ ...(c.macroExpansion && { macroExpansion: c.macroExpansion }),
273
282
  ...(c.tier && { tier: c.tier })
274
283
  })),
275
284
  unverifiedCallers: (context.unverifiedCallers || []).map(c => ({
@@ -287,6 +296,7 @@ function formatContextJson(context) {
287
296
  ...(c.resolution && { resolution: c.resolution }),
288
297
  ...(c.provenance && { provenance: c.provenance }),
289
298
  ...(c.siteProvenance && { siteProvenance: c.siteProvenance }),
299
+ ...(c.macroExpansion && { macroExpansion: c.macroExpansion }),
290
300
  ...(c.reason && { reason: c.reason }),
291
301
  ...(c.dispatchVia && { dispatchVia: c.dispatchVia }),
292
302
  ...(c.dispatchCandidates != null && { dispatchCandidates: c.dispatchCandidates }),
@@ -329,6 +339,7 @@ function formatContextJson(context) {
329
339
  ...(c.confidence != null && { confidence: c.confidence, resolution: c.resolution }),
330
340
  ...(c.provenance && { provenance: c.provenance }),
331
341
  ...(c.siteProvenance && { siteProvenance: c.siteProvenance }),
342
+ ...(c.macroExpansion && { macroExpansion: c.macroExpansion }),
332
343
  ...(c.evidenceScore != null && { evidenceScore: c.evidenceScore }),
333
344
  ...(c.scoreKind && { scoreKind: c.scoreKind }),
334
345
  ...(c.tier && { tier: c.tier }),
@@ -345,6 +356,7 @@ function formatContextJson(context) {
345
356
  ...(c.confidence != null && { confidence: c.confidence, resolution: c.resolution }),
346
357
  ...(c.provenance && { provenance: c.provenance }),
347
358
  ...(c.siteProvenance && { siteProvenance: c.siteProvenance }),
359
+ ...(c.macroExpansion && { macroExpansion: c.macroExpansion }),
348
360
  ...(c.evidenceScore != null && { evidenceScore: c.evidenceScore }),
349
361
  ...(c.scoreKind && { scoreKind: c.scoreKind }),
350
362
  tier: 'unverified',
@@ -370,6 +382,7 @@ function formatContextJson(context) {
370
382
  ...(c.confidence != null && { confidence: c.confidence, resolution: c.resolution }),
371
383
  ...(c.provenance && { provenance: c.provenance }),
372
384
  ...(c.siteProvenance && { siteProvenance: c.siteProvenance }),
385
+ ...(c.macroExpansion && { macroExpansion: c.macroExpansion }),
373
386
  ...(c.evidenceScore != null && { evidenceScore: c.evidenceScore }),
374
387
  ...(c.scoreKind && { scoreKind: c.scoreKind }),
375
388
  ...(c.tier && { tier: c.tier }),
@@ -545,12 +558,15 @@ function formatContext(ctx, options = {}) {
545
558
  const renderCaller = (c) => {
546
559
  const callerName = c.callerName ? ` [${c.callerName}]` : '';
547
560
  const unreachableMark = (callerReach.perLine && c.reachable === false) ? ' [unreachable]' : '';
561
+ // The call target is produced by expanding a macro invocation on
562
+ // this line; the line itself does not spell the name (fix #362).
563
+ const viaMacro = c.macroExpansion ? ` [via macro ${c.macroExpansion.macro}]` : '';
548
564
  if (compact) {
549
565
  // One line per caller: "[N] file:line [callerName]: expression"
550
566
  const expr = c.content ? c.content.trim().replace(/\s+/g, ' ').slice(0, 100) : '';
551
- lines.push(` [${itemNum}] ${c.relativePath}:${c.line}${callerName}${unreachableMark}: ${expr}`);
567
+ lines.push(` [${itemNum}] ${c.relativePath}:${c.line}${callerName}${unreachableMark}${viaMacro}: ${expr}`);
552
568
  } else {
553
- lines.push(` [${itemNum}] ${c.relativePath}:${c.line}${callerName}${unreachableMark}`);
569
+ lines.push(` [${itemNum}] ${c.relativePath}:${c.line}${callerName}${unreachableMark}${viaMacro}`);
554
570
  lines.push(` ${c.content.trim()}`);
555
571
  }
556
572
  expandable.push({
@@ -87,6 +87,9 @@ function formatBrief(result) {
87
87
  if (sym.handle) lines.push(` handle: ${sym.handle}`);
88
88
  if (sym.docstring) lines.push(` "${sym.docstring}"`);
89
89
  if (sym.className) lines.push(` in class ${sym.className}`);
90
+ if (sym.macroExpansion) {
91
+ lines.push(` generated by macro ${sym.macroExpansion.macro} (${sym.macroExpansion.definition})`);
92
+ }
90
93
  const gitLineFn = formatGitLine(result.git);
91
94
  if (gitLineFn) lines.push(` ${gitLineFn}`);
92
95
 
@@ -77,6 +77,8 @@ function formatDoctor(result, options = {}) {
77
77
  ['Eval/exec calls', bs.evalCalls],
78
78
  ['Reflection', bs.reflection],
79
79
  ['Computed dispatch', bs.computedDispatch],
80
+ ['Token-pasting macro dispatch', bs.macroPasteDispatch],
81
+ ['Unexpanded macro_rules! invocations', bs.macroRulesExpansion],
80
82
  ['Parse failures', bs.parseFailures],
81
83
  ['Parser recovery', bs.parseRecoveries],
82
84
  ['Unsupported source', bs.unsupportedSources],
@@ -86,6 +88,8 @@ function formatDoctor(result, options = {}) {
86
88
  'Dynamic imports': 'import', 'Blank/dot imports': 'import',
87
89
  'Glob imports': 'import', 'Eval/exec calls': 'use', Reflection: 'use',
88
90
  'Computed dispatch': 'call',
91
+ 'Token-pasting macro dispatch': 'invocation',
92
+ 'Unexpanded macro_rules! invocations': 'invocation',
89
93
  'Parse failures': 'failure',
90
94
  'Unsupported source': 'file',
91
95
  'Skipped source': 'path',
@@ -52,10 +52,13 @@ function formatUncertainRequests(result, options) {
52
52
  const list = result?.uncertainRequests || [];
53
53
  if (list.length === 0) return [];
54
54
  const lines = [];
55
- lines.push(`Possible client requests (${list.length}) — request-shaped call with a path literal, receiver not recognized as an HTTP client:`);
55
+ lines.push(`Possible client requests (${list.length}) — request-shaped call with a path literal, receiver or helper not proven to be an HTTP client:`);
56
56
  const cap = options.all ? Infinity : 10;
57
57
  for (const r of list.slice(0, cap)) {
58
- lines.push(` ${r.file}:${r.line} ${r.receiver}.${r.method}(${JSON.stringify(r.path)}) in ${r.callerName}${r.isTest ? ' [test]' : ''}`);
58
+ const callee = r.receiver ? `${r.receiver}.${r.method}` : r.method;
59
+ const why = String(r.reason || '').startsWith('request-helper-unproven')
60
+ ? ` [${r.reason.replace(/^request-helper-unproven:?/, 'helper not proven HTTP: ')}]` : '';
61
+ lines.push(` ${r.file}:${r.line} ${callee}(${JSON.stringify(r.path)}) in ${r.callerName}${r.isTest ? ' [test]' : ''}${why}`);
59
62
  }
60
63
  if (list.length > cap) lines.push(` (+${list.length - cap} more — use --all)`);
61
64
  return lines;
@@ -88,7 +91,8 @@ function formatRoutesAndRequests(routes, requests, meta, options, advisory = nul
88
91
  for (const r of list) {
89
92
  const handler = r.handler || '<anonymous>';
90
93
  const fw = r.framework ? `[${r.framework}]` : '';
91
- lines.push(` ${pad(r.method, 7)} ${pad(r.path, 40)} → ${handler} ${fw}${r.isTest ? ' [test]' : ''} :${r.line}`);
94
+ const derived = r.derived ? ` [${r.derived}]` : '';
95
+ lines.push(` ${pad(r.method, 7)} ${pad(r.path, 40)} → ${handler} ${fw}${derived}${r.isTest ? ' [test]' : ''} :${r.line}`);
92
96
  }
93
97
  lines.push('');
94
98
  }
@@ -166,12 +170,15 @@ function formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, option
166
170
 
167
171
  lines.push(`Matched (${sorted.length} routes):`);
168
172
  for (const { route, clients } of sorted) {
169
- lines.push(` ${pad(route.method, 7)} ${route.path} [${route.framework}]${route.isTest ? ' [test]' : ''} ${route.file}:${route.line}`);
173
+ lines.push(` ${pad(route.method, 7)} ${route.path} [${route.framework}]${route.derived ? ` [${route.derived}]` : ''}${route.isTest ? ' [test]' : ''} ${route.file}:${route.line}`);
170
174
  for (const b of clients) {
171
175
  const conf = b.confidence.toFixed(2);
172
176
  const tier = b.matchType.toUpperCase();
173
177
  const inf = b.methodInferred ? ' method?' : '';
174
- lines.push(` ↔ ${pad(b.request.method + inf, 9)} ${pad(b.request.path, 30)} ${tier} (${conf}) from ${b.request.callerName}${b.request.isTest ? ' [test]' : ''} ${b.request.file}:${b.request.line}`);
178
+ // An in-process client whose app did not resolve matches by path
179
+ // across every app (fix #392).
180
+ const unscoped = b.unscoped ? ' [unscoped]' : '';
181
+ lines.push(` ↔ ${pad(b.request.method + inf, 9)} ${pad(b.request.path, 30)} ${tier} (${conf})${unscoped} from ${b.request.callerName}${b.request.isTest ? ' [test]' : ''} ${b.request.file}:${b.request.line}`);
175
182
  }
176
183
  lines.push('');
177
184
  }
@@ -180,7 +187,7 @@ function formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, option
180
187
  if (unmatchedRoutes.length > 0) {
181
188
  lines.push(`Unmatched server routes (${unmatchedRoutes.length}):`);
182
189
  for (const r of unmatchedRoutes) {
183
- lines.push(` ${pad(r.method, 7)} ${pad(r.path, 40)} → ${r.handler} [${r.framework}]${r.isTest ? ' [test]' : ''} ${r.file}:${r.line}`);
190
+ lines.push(` ${pad(r.method, 7)} ${pad(r.path, 40)} → ${r.handler} [${r.framework}]${r.derived ? ` [${r.derived}]` : ''}${r.isTest ? ' [test]' : ''} ${r.file}:${r.line}`);
184
191
  }
185
192
  lines.push('');
186
193
  }
@@ -215,6 +222,7 @@ function formatEndpointsJson(result, options = {}) {
215
222
  framework: r.framework,
216
223
  isTest: !!r.isTest,
217
224
  ...(r.classPrefix && { classPrefix: r.classPrefix }),
225
+ ...(r.derived && { derived: r.derived }),
218
226
  });
219
227
  const trimReq = (r) => ({
220
228
  method: r.method,
@@ -235,6 +243,7 @@ function formatEndpointsJson(result, options = {}) {
235
243
  matchType: b.matchType,
236
244
  confidence: b.confidence,
237
245
  ...(b.methodInferred && { methodInferred: true }),
246
+ ...(b.unscoped && { unscoped: true }),
238
247
  });
239
248
 
240
249
  return JSON.stringify({
@@ -11,6 +11,9 @@ const {
11
11
 
12
12
  const { formatSymbolHandle } = require('../shared');
13
13
 
14
+ // Detailed text rows shown by default; the rest are counted.
15
+ const DEFAULT_DISPLAY_LIMIT = 5;
16
+
14
17
  /**
15
18
  * Trim a docstring to a single short sentence (max 80 chars) for inline display.
16
19
  */
@@ -112,7 +115,6 @@ function formatFindDetailed(symbols, query, options = {}) {
112
115
  // Surfaces pass validated NUMBERS; the string comparisons below made
113
116
  // --depth 0/2 dead code everywhere (fix #250). Normalize once.
114
117
  const depth = options.depth != null ? String(options.depth) : undefined;
115
- const DEFAULT_LIMIT = 5;
116
118
 
117
119
  if (symbols.length === 0) {
118
120
  return `No symbols found for "${query}"`;
@@ -129,7 +131,10 @@ function formatFindDetailed(symbols, query, options = {}) {
129
131
  for (const [name, count] of Object.entries(symbols.findInfo?.nameWideDefinitionCounts || {})) {
130
132
  sameNameDefinitionCounts.set(name, count);
131
133
  }
132
- const limit = all ? symbols.length : (top > 0 ? top : DEFAULT_LIMIT);
134
+ // Text shows the top N detailed results (default 5; --limit=N raises
135
+ // both the returned set and the display, --all lifts both) and counts
136
+ // the rest; JSON carries every returned result (up to 500 by default).
137
+ const limit = all ? symbols.length : (top > 0 ? top : DEFAULT_DISPLAY_LIMIT);
133
138
  const showing = Math.min(limit, symbols.length);
134
139
  const total = symbols.findInfo?.total ?? symbols.length;
135
140
  const hidden = Math.max(0, total - showing);
@@ -27,6 +27,46 @@ function _unverifiedBandLines(sites, cap = 10) {
27
27
  return lines;
28
28
  }
29
29
 
30
+ /**
31
+ * Review items grouped by finding: one finding (same reason, same
32
+ * consequence) often holds at many sites, e.g. every place a type is used as
33
+ * an interface, or every comment naming the symbol. Each finding is stated
34
+ * once with its sites; at most `cap` sites are listed in total and every
35
+ * group keeps its count.
36
+ */
37
+ function groupedReviewLines(items, cap = 20) {
38
+ const groups = new Map();
39
+ for (const item of items) {
40
+ const key = `${item.reviewReason || ''}\0${item.suggestion}`;
41
+ if (!groups.has(key)) groups.set(key, { suggestion: item.suggestion, items: [] });
42
+ groups.get(key).items.push(item);
43
+ }
44
+ const siteLine = item => `${item.file}:${item.line}: ${item.expression.replace(/\s+/g, ' ').slice(0, 120)}`;
45
+ const lines = [];
46
+ let budget = cap;
47
+ for (const group of groups.values()) {
48
+ if (group.items.length === 1) {
49
+ const [item] = group.items;
50
+ if (budget > 0) {
51
+ lines.push(` ${siteLine(item)}`);
52
+ lines.push(` → ${group.suggestion}`);
53
+ budget--;
54
+ } else {
55
+ lines.push(` ${item.file}:${item.line}: ${group.suggestion}`);
56
+ }
57
+ continue;
58
+ }
59
+ lines.push(` ${group.suggestion} (${group.items.length} sites):`);
60
+ const shown = group.items.slice(0, Math.max(1, budget));
61
+ for (const item of shown) lines.push(` ${siteLine(item)}`);
62
+ budget -= shown.length;
63
+ if (group.items.length > shown.length) {
64
+ lines.push(` (+${group.items.length - shown.length} more sites)`);
65
+ }
66
+ }
67
+ return lines;
68
+ }
69
+
30
70
  /**
31
71
  * Format plan command output - text
32
72
  * Shows before/after signatures and all changes needed
@@ -70,7 +110,7 @@ function formatPlan(plan, options = {}) {
70
110
  lines.push(` Files affected: ${plan.filesAffected}`);
71
111
  if (plan.changeSummary) {
72
112
  const summary = plan.changeSummary;
73
- lines.push(` Definition ${summary.definitions}, calls ${summary.calls}, references ${summary.references || 0}, text dependencies ${summary.textReferences || 0}, imports ${summary.imports}, exports ${summary.exports}; manual review items ${summary.reviewRequired}`);
113
+ lines.push(` Definition ${summary.definitions}, calls ${summary.calls}, references ${summary.references || 0}, ${summary.examples ? `examples ${summary.examples}, ` : ''}text dependencies ${summary.textReferences || 0}, ${summary.contracts ? `contract sites ${summary.contracts}, ` : ''}imports ${summary.imports}, exports ${summary.exports}; manual review items ${summary.reviewRequired}`);
74
114
  }
75
115
  if (plan.unchangedSites > 0) {
76
116
  lines.push(` ${plan.unchangedSites} existing call site${plan.unchangedSites === 1 ? '' : 's'} require no edit because the new parameter has a default.`);
@@ -81,6 +121,9 @@ function formatPlan(plan, options = {}) {
81
121
  if (plan.outsideIndexedSource) {
82
122
  lines.push(` Source boundary: ${plan.outsideIndexedSource.action}`);
83
123
  }
124
+ for (const rename of plan.fileRenames || []) {
125
+ lines.push(` Rename file: ${rename.from} -> ${rename.to}`);
126
+ }
84
127
  lines.push('');
85
128
 
86
129
  // Group by file
@@ -104,15 +147,33 @@ function formatPlan(plan, options = {}) {
104
147
  }
105
148
  }
106
149
 
107
- if (plan.reviewItems?.length > 0) {
108
- lines.push(`\nREVIEW ITEMS (${plan.reviewItems.length}) — source text dependencies are never rewritten automatically:`);
109
- for (const item of plan.reviewItems.slice(0, 20)) {
110
- lines.push(` ${item.file}:${item.line}: ${item.expression.replace(/\s+/g, ' ').slice(0, 120)}`);
111
- lines.push(` → ${item.suggestion}`);
112
- }
113
- if (plan.reviewItems.length > 20) {
114
- lines.push(` (+${plan.reviewItems.length - 20} more review items)`);
115
- }
150
+ const contractItems = (plan.reviewItems || []).filter(item => item.contractDependency);
151
+ const templateItems = (plan.reviewItems || []).filter(item => item.templateDependency);
152
+ const bindingItems = (plan.reviewItems || []).filter(item => item.testBinding);
153
+ const textItems = (plan.reviewItems || []).filter(item =>
154
+ !item.contractDependency && !item.templateDependency && !item.testBinding);
155
+ if (plan.contract?.blocked || contractItems.length > 0) {
156
+ const generatedName = (plan.contract?.external || []).some(entry =>
157
+ entry.certainty === 'definite' && entry.reason === 'macro-generated-name');
158
+ const heading = plan.contract?.blocked
159
+ ? (generatedName
160
+ ? 'CONTRACT: rename blocked, a macro invocation generates the name; no edits are proposed'
161
+ : 'CONTRACT: rename blocked, the member fills a slot of a contract outside the project; no edits are proposed')
162
+ : 'CONTRACT REVIEW — the member takes part in a contract this plan cannot complete by editing project declarations';
163
+ lines.push(`\n${heading}:`);
164
+ lines.push(...groupedReviewLines(contractItems));
165
+ }
166
+ if (templateItems.length > 0) {
167
+ lines.push(`\nMACRO TEMPLATE SITES (${templateItems.length}) — tokens that bind at each macro expansion; review before renaming:`);
168
+ lines.push(...groupedReviewLines(templateItems));
169
+ }
170
+ if (bindingItems.length > 0) {
171
+ lines.push(`\nTEST FUNCTIONS BOUND BY NAME (${bindingItems.length}) — review before renaming:`);
172
+ lines.push(...groupedReviewLines(bindingItems));
173
+ }
174
+ if (textItems.length > 0) {
175
+ lines.push(`\nREVIEW ITEMS (${textItems.length}) — source text dependencies are never rewritten automatically:`);
176
+ lines.push(...groupedReviewLines(textItems));
116
177
  }
117
178
 
118
179
  // v4 tiered contract: candidates without evidence are not planned but
@@ -175,6 +236,7 @@ function formatPlanJson(plan) {
175
236
  outsideIndexedSource: plan.outsideIndexedSource,
176
237
  }),
177
238
  ...(plan.unchangedSites > 0 && { unchangedSites: plan.unchangedSites }),
239
+ ...(plan.fileRenames?.length > 0 && { fileRenames: plan.fileRenames }),
178
240
  changes: plan.changes.map(c => ({
179
241
  file: c.file,
180
242
  line: c.line,
@@ -186,6 +248,7 @@ function formatPlanJson(plan) {
186
248
  ...(c.isImport && { isImport: true }),
187
249
  ...(c.isExport && { isExport: true }),
188
250
  ...(c.needsReview && { needsReview: true }),
251
+ ...(c.reviewReason && { reviewReason: c.reviewReason }),
189
252
  ...(c.textDependency && { textDependency: true }),
190
253
  })),
191
254
  reviewItems: (plan.reviewItems || []).map(item => ({
@@ -195,8 +258,13 @@ function formatPlanJson(plan) {
195
258
  suggestion: item.suggestion,
196
259
  editKind: item.editKind,
197
260
  needsReview: true,
198
- textDependency: true,
261
+ ...(item.contractDependency
262
+ ? { contractDependency: true, reviewReason: item.reviewReason }
263
+ : item.templateDependency
264
+ ? { templateDependency: true, reviewReason: item.reviewReason }
265
+ : { textDependency: true }),
199
266
  })),
267
+ ...(plan.contract && { contract: plan.contract }),
200
268
  // v4 tiered contract passthrough
201
269
  unverifiedCount: plan.unverifiedCount,
202
270
  unverifiedSites: plan.unverifiedSites,
@@ -239,6 +307,10 @@ function formatVerify(result, options = {}) {
239
307
  lines.push('═'.repeat(60));
240
308
  lines.push(`${result.file}:${result.startLine}`);
241
309
  lines.push(result.signature);
310
+ if (result.changedSince) {
311
+ lines.push(`Changed since ${result.changedSince.base}: was ${result.changedSince.signature}; ` +
312
+ `${result.changedSince.callSites} call site(s) of the old declaration checked against the new one`);
313
+ }
242
314
  for (const warning of result.warnings || []) {
243
315
  lines.push(`Note: ${warning.message}`);
244
316
  }
@@ -504,8 +576,18 @@ function formatStackTraceJson(result) {
504
576
  function formatAuditAsync(result) {
505
577
  if (!result) return 'No async audit data.';
506
578
  const issues = Array.isArray(result.issues) ? result.issues : [];
579
+ const skippedLines = [];
580
+ if (result.skippedUnknown > 0) {
581
+ skippedLines.push(`${result.skippedUnknown} unawaited call(s) to async functions whose call result is not known (unrecognized decorator or disagreeing same-name definitions) were not audited.`);
582
+ }
583
+ if (result.skippedFutures > 0) {
584
+ skippedLines.push(`${result.skippedFutures} unpolled Rust future(s) not known to be lazy (a type implementing Future can be a handle to started work; a non-builtin attribute can rewrite an async fn) were not audited.`);
585
+ }
586
+ const skipped = skippedLines.length > 0 ? skippedLines.join('\n') : null;
507
587
  if (issues.length === 0) {
508
- return 'Async audit: no missing-await issues found.';
588
+ return skipped
589
+ ? `Async audit: no missing-await issues found.\n${skipped}`
590
+ : 'Async audit: no missing-await issues found.';
509
591
  }
510
592
  const lines = [];
511
593
  lines.push(`Async audit: ${result.totalIssues} likely missing-await call site(s) across ${result.filesAffected} file(s)`);
@@ -522,12 +604,32 @@ function formatAuditAsync(result) {
522
604
  lines.push(`${file} (${fileIssues.length})`);
523
605
  for (const issue of fileIssues) {
524
606
  const caller = issue.callerName ? ` [${issue.callerName}]` : '';
525
- const detail = issue.reason === 'stored-promise-used-as-value'
607
+ const detail = issue.reason === 'future-discarded'
608
+ ? `${issue.calleeName}() — future created and never polled (discarded)`
609
+ : issue.reason === 'future-dropped'
610
+ ? `${issue.calleeName}() — future dropped by \`let _ =\` without being polled`
611
+ : issue.reason === 'future-unused'
612
+ ? `${issue.calleeName}() — future bound to ${issue.variable} and never used`
613
+ : issue.reason === 'stored-future-used-as-value'
614
+ ? `${issue.variable} used as its Output; future from ${issue.calleeName}() at line ${issue.originLine} is not awaited`
615
+ : issue.reason === 'stored-promise-used-as-value'
526
616
  ? `${issue.variable} used as a resolved value; promise from ${issue.calleeName}() at line ${issue.originLine}`
527
- : `${issue.calleeName}() — async, not awaited`;
617
+ : issue.reason === 'stored-coroutine-used-as-value'
618
+ ? `${issue.variable} used as a resolved value; coroutine from ${issue.calleeName}() at line ${issue.originLine} is not awaited`
619
+ : issue.reason === 'async-result-used-as-value'
620
+ ? `${issue.calleeName}() — result used as a resolved value, not awaited`
621
+ : issue.reason === 'async-iterator-discarded'
622
+ ? `${issue.calleeName}() — async iterator created and never iterated`
623
+ : issue.reason === 'async-context-manager-discarded'
624
+ ? `${issue.calleeName}() — async context manager created and never entered`
625
+ : `${issue.calleeName}() — async, not awaited`;
528
626
  lines.push(` :${issue.line}${caller} ${detail}`);
529
627
  }
530
628
  }
629
+ if (skipped) {
630
+ lines.push('');
631
+ lines.push(skipped);
632
+ }
531
633
  return lines.join('\n');
532
634
  }
533
635
 
@@ -544,7 +646,12 @@ function formatAuditAsyncJson(result) {
544
646
  line: i.line,
545
647
  callerName: i.callerName,
546
648
  calleeName: i.calleeName,
649
+ ...(i.reason && { reason: i.reason }),
650
+ ...(i.reason && i.reason.startsWith('stored-') && { variable: i.variable, originLine: i.originLine }),
651
+ ...(i.reason === 'future-unused' && { variable: i.variable }),
547
652
  })),
653
+ ...(result.skippedUnknown > 0 && { skippedUnknown: result.skippedUnknown }),
654
+ ...(result.skippedFutures > 0 && { skippedFutures: result.skippedFutures }),
548
655
  }, null, 2);
549
656
  }
550
657
 
@@ -167,6 +167,11 @@ function formatStats(stats, options = {}) {
167
167
  ? `${total} raw candidates`
168
168
  : `${total} called`;
169
169
  lines.push(`\nHottest functions (top ${items.length} of ${population}):`);
170
+ if (stats.hot.budgetExhausted) {
171
+ lines.push(` Refinement budget reached after ${stats.hot.refined} of ${total} candidates ` +
172
+ `(${stats.hot.work ?? stats.hot.refined} ${stats.hot.work !== undefined ? 'call records' : 'refinements'}): ` +
173
+ 'ranking approximate, counts exact; narrow with --in=<dir> for an exact list.');
174
+ }
170
175
  if (items.length === 0) {
171
176
  lines.push(' (no inbound calls detected)');
172
177
  } else {
@@ -298,9 +303,29 @@ function formatDeadcode(results, options = {}) {
298
303
  if (results.reflection?.literalCount > 0) {
299
304
  lines.push(`\n${results.reflection.literalCount} literal reflection use(s) name ${results.reflection.names.length} member spelling(s); matching symbols were withheld from deletion candidates.`);
300
305
  }
306
+ if (results.reflection?.withheld > 0) {
307
+ const byPattern = results.reflection.withheldByPattern || [];
308
+ const sample = byPattern.slice(0, 5).map(w => `\`${w.pattern}\`${w.namespaceOf ? ` in the module namespace of ${w.namespaceOf}` : ''} (${w.count})`).join(', ');
309
+ lines.push(`\n${results.reflection.withheld} candidate(s) withheld by reflection pattern: ${sample}${byPattern.length > 5 ? ', ...' : ''}. A reflective access builds these member names at runtime.`);
310
+ }
301
311
  if (results.reflection?.dynamicCount > 0) {
302
312
  lines.push(`\nWARNING: ${results.reflection.dynamicCount} dynamic reflection use(s) have no static member spelling. Dead-code results remain review candidates because runtime-selected members cannot be attributed.`);
303
313
  }
314
+ if (results.macroPaste) {
315
+ const m = results.macroPaste;
316
+ if (m.withheld > 0) {
317
+ const sample = m.withheldNames.slice(0, 3)
318
+ .map(w => `${w.name} (${w.macro} at ${w.file}:${w.line})`).join(', ');
319
+ lines.push(`\n${m.withheld} candidate(s) withheld: a token-pasting macro whose expansions are not all visible could produce the name - ${sample}${m.withheld > 3 ? ', ...' : ''}.`);
320
+ }
321
+ if (m.blind?.count > 0) {
322
+ lines.push(`\nWARNING: ${m.blind.count} token-pasting macro dispatch invocation(s) in ${m.blind.fileCount} file(s) could not be expanded; names they produce are unattributed (${m.blind.files.slice(0, 3).join(', ')}).`);
323
+ }
324
+ }
325
+ if (results.macroRulesBlind?.count > 0) {
326
+ const b = results.macroRulesBlind;
327
+ lines.push(`\nWARNING: ${b.count} macro_rules! invocation(s) in ${b.fileCount} file(s) could not be expanded; calls and definitions they generate are unattributed (${b.files.slice(0, 3).join(', ')}).`);
328
+ }
304
329
  if (results.coverage?.complete === false) {
305
330
  const c = results.coverage;
306
331
  const reasonText = Object.entries(c.reasons || {})
@@ -345,6 +370,8 @@ function formatDeadcodeJson(results) {
345
370
  ...(results.excludedDynamicDispatch > 0 && { excludedDynamicDispatch: results.excludedDynamicDispatch }),
346
371
  ...(results.computedDispatch?.count > 0 && { computedDispatch: results.computedDispatch }),
347
372
  ...(results.reflection?.count > 0 && { reflection: results.reflection }),
373
+ ...(results.macroPaste && { macroPaste: results.macroPaste }),
374
+ ...(results.macroRulesBlind && { macroRulesBlind: results.macroRulesBlind }),
348
375
  ...(results.coverage?.complete === false && { coverage: results.coverage }),
349
376
  symbols: results.map(item => {
350
377
  const handleSym = { ...item, relativePath: item.relativePath || item.file };
@@ -490,9 +517,10 @@ function formatOrient(result, options = {}) {
490
517
  const population = result.hot.totalKind === 'raw-call-candidates'
491
518
  ? `${result.hot.total} raw candidates`
492
519
  : result.hot.total;
493
- const budgetNote = result.hot.budgetExhausted
494
- ? `; refinement budget ${result.hot.maxRefine} reached — ranking approximate, exact list: ucn repo --sections=stats --hot`
495
- : '';
520
+ const budgetNote = !result.hot.budgetExhausted ? ''
521
+ : result.hot.maxRefine !== undefined
522
+ ? `; refinement budget ${result.hot.maxRefine} reached — ranking approximate, exact list: ucn repo --sections=stats --hot`
523
+ : `; refinement budget reached after ${result.hot.refined} of ${result.hot.total} candidates (${result.hot.work} call records) — ranking approximate, narrow with --in=<dir> for an exact list`;
496
524
  lines.push(`HOT (most-called ${scope}, top ${result.hot.items.length} of ${population}${budgetNote}):`);
497
525
  for (const h of result.hot.items) {
498
526
  const label = h.className ? `${h.className}.${h.name}` : h.name;
@@ -358,12 +358,26 @@ function unverifiedReasonLabel(entry) {
358
358
  ? `possible-dispatch via ${entry.dispatchVia} — 1 of ${n} implementations`
359
359
  : `possible-dispatch via ${entry.dispatchVia}`;
360
360
  }
361
+ if (entry.reason === 'macro-generated-scope' && entry.dispatchVia) {
362
+ // A macro-wrapped body whose argument names a class (fix #396).
363
+ return `macro-generated-scope via ${entry.dispatchVia}`;
364
+ }
361
365
  if (entry.reason === 'method-ambiguous' && entry.dispatchCandidates > 1) {
362
366
  return `method-ambiguous — ${entry.dispatchCandidates} dispatch owners`;
363
367
  }
364
368
  if (entry.reason === 'overload-ambiguous' && entry.dispatchCandidates > 1) {
365
369
  return `overload-ambiguous — 1 of ${entry.dispatchCandidates} applicable overloads`;
366
370
  }
371
+ if (entry.reason === 'reflection-pattern' && entry.reflectionPattern) {
372
+ // Member name built at runtime from literal fragments (fix #363).
373
+ return entry.reflectionScope === 'module-namespace'
374
+ ? `reflection-pattern \`${entry.reflectionPattern}\` in this module's namespace`
375
+ : `reflection-pattern \`${entry.reflectionPattern}\``;
376
+ }
377
+ if (entry.macroExpansion) {
378
+ // Call target produced by a macro invocation on this line (fix #362).
379
+ return `${entry.reason}, via macro ${entry.macroExpansion.macro}`;
380
+ }
367
381
  return entry.reason;
368
382
  }
369
383