ucn 5.5.2 → 5.6.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 (74) hide show
  1. package/.claude/skills/ucn/SKILL.md +35 -12
  2. package/.claude/skills/ucn/references/commands.md +21 -17
  3. package/.claude/skills/ucn/references/resolution-rules.md +4 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +4 -4
  5. package/CHANGELOG.md +474 -0
  6. package/README.md +71 -29
  7. package/cli/flags.js +184 -0
  8. package/cli/index.js +138 -324
  9. package/core/account.js +44 -87
  10. package/core/analysis.js +273 -107
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +29 -65
  13. package/core/cache.js +101 -16
  14. package/core/callers.js +352 -70
  15. package/core/capture.js +331 -0
  16. package/core/change-kinds.js +888 -0
  17. package/core/check.js +1117 -139
  18. package/core/client-receivers.js +189 -0
  19. package/core/command-contracts.js +7 -6
  20. package/core/constructed-class.js +1195 -0
  21. package/core/contract-membership.js +142 -0
  22. package/core/deadcode.js +388 -85
  23. package/core/deletion.js +401 -0
  24. package/core/discovery.js +104 -9
  25. package/core/entrypoints.js +12 -6
  26. package/core/evidence-bands.js +68 -0
  27. package/core/evidence-bands.json +44 -0
  28. package/core/execute.js +172 -161
  29. package/core/hot-summary.js +241 -0
  30. package/core/index-ir.js +3 -1
  31. package/core/inprocess-clients.js +41 -15
  32. package/core/ir.js +5 -1
  33. package/core/member-signatures.js +789 -0
  34. package/core/output/analysis-ext.js +20 -10
  35. package/core/output/analysis.js +98 -60
  36. package/core/output/check.js +98 -37
  37. package/core/output/doctor.js +34 -1
  38. package/core/output/find.js +2 -1
  39. package/core/output/lines.js +63 -3
  40. package/core/output/public.js +30 -11
  41. package/core/output/refactoring.js +32 -0
  42. package/core/output/reporting.js +21 -2
  43. package/core/output/search.js +11 -2
  44. package/core/output/tracing.js +35 -7
  45. package/core/output-budget.js +124 -57
  46. package/core/parallel-build.js +3 -3
  47. package/core/project.js +106 -23
  48. package/core/provenance-type.js +12 -5
  49. package/core/public-command.js +35 -9
  50. package/core/python-modules.js +6 -0
  51. package/core/reflection.js +166 -23
  52. package/core/registry.js +623 -72
  53. package/core/reporting.js +69 -34
  54. package/core/request-helpers.js +63 -7
  55. package/core/review-items.js +75 -0
  56. package/core/search.js +89 -128
  57. package/core/shared.js +0 -45
  58. package/core/snapshot.js +350 -0
  59. package/core/source-universe.js +707 -0
  60. package/core/test-units.js +3624 -0
  61. package/core/tracing.js +150 -96
  62. package/core/verify.js +27 -11
  63. package/languages/adapter.js +8 -3
  64. package/languages/c-family.js +12 -0
  65. package/languages/csharp.js +634 -0
  66. package/languages/go.js +120 -0
  67. package/languages/index.js +422 -4
  68. package/languages/java.js +712 -0
  69. package/languages/javascript.js +1460 -2
  70. package/languages/lexical-scope.js +68 -0
  71. package/languages/python.js +1672 -1
  72. package/languages/rust.js +91 -0
  73. package/mcp/server.js +44 -110
  74. package/package.json +11 -6
@@ -3,7 +3,8 @@
3
3
  */
4
4
 
5
5
  const { dynamicImportsNote, formatLineRanges, unverifiedReasonLabel, advisoryLine } = require('./shared');
6
- const { formatAccountLines, formatCalleeAccountLine, unverifiedCalleeLines, initializerReviewLines } = require('./analysis');
6
+ const { paramFact } = require('../registry');
7
+ const { formatAccountLines, formatSharedAccountLines, formatCalleeAccountLine, unverifiedCalleeLines, initializerReviewLines } = require('./analysis');
7
8
 
8
9
  /**
9
10
  * Format related command output - text
@@ -200,6 +201,8 @@ function formatDiffImpact(result, options = {}) {
200
201
 
201
202
  const lines = [];
202
203
  const MAX_CALLERS_PER_FN = options.all ? Infinity : 30;
204
+ const unverifiedLimit = options.all ? Infinity
205
+ : Number.isInteger(options.top) && options.top > 0 ? options.top : paramFact('top', 'unverifiedRows');
203
206
 
204
207
  lines.push(`Diff Impact Analysis (vs ${result.base})`);
205
208
  lines.push('═'.repeat(60));
@@ -262,7 +265,7 @@ function formatDiffImpact(result, options = {}) {
262
265
  // Unverified tier: visible one-liners with reasons (never silently dropped)
263
266
  const unverified = fn.unverifiedCallers || [];
264
267
  if (unverified.length > 0) {
265
- const cap = options.all ? Infinity : 10;
268
+ const cap = unverifiedLimit;
266
269
  lines.push(` Unverified call sites (${unverified.length}) — call syntax, no binding/receiver evidence:`);
267
270
  for (const u of unverified.slice(0, cap)) {
268
271
  const caller = u.callerName ? ` [${u.callerName}]` : '';
@@ -275,11 +278,15 @@ function formatDiffImpact(result, options = {}) {
275
278
  }
276
279
  }
277
280
 
278
- // Conservation contract lines, indented inside the function block
279
- for (const al of formatAccountLines(fn.account)) {
281
+ // Conservation contract lines, indented inside the function block.
282
+ // What holds for every symbol of the repository is said once,
283
+ // after the last function.
284
+ for (const al of formatAccountLines(fn.account, { shared: false })) {
280
285
  lines.push(` ${al}`);
281
286
  }
282
287
  }
288
+ const withAccount = result.functions.find(fn => fn.account);
289
+ if (withAccount) lines.push(...formatSharedAccountLines(withAccount.account));
283
290
  }
284
291
 
285
292
  // New functions
@@ -300,11 +307,12 @@ function formatDiffImpact(result, options = {}) {
300
307
  const remaining = fn.remainingCallSites || [];
301
308
  if (remaining.length > 0) {
302
309
  lines.push(` ⚠ still called from ${remaining.length} site(s) — name-level matches:`);
303
- for (const site of remaining.slice(0, 10)) {
310
+ const cap = unverifiedLimit;
311
+ for (const site of remaining.slice(0, cap)) {
304
312
  lines.push(` ${site.relativePath}:${site.line} ${site.content}`);
305
313
  }
306
- if (remaining.length > 10) {
307
- lines.push(` ... and ${remaining.length - 10} more`);
314
+ if (remaining.length > cap) {
315
+ lines.push(` ... and ${remaining.length - cap} more`);
308
316
  }
309
317
  }
310
318
  }
@@ -316,14 +324,16 @@ function formatDiffImpact(result, options = {}) {
316
324
  for (const symbol of result[key]) {
317
325
  lines.push(` ${symbol.type} ${symbol.name} — ${symbol.relativePath}:${symbol.startLine}`);
318
326
  if (symbol.impact) {
319
- lines.push(require('./analysis').formatImpact(symbol.impact, { compact: true }));
327
+ lines.push(require('./analysis').formatImpact(symbol.impact, { compact: true,
328
+ all: options.all, top: options.top, allHint: options.allHint,
329
+ evidenceBands: options.evidenceBands }));
320
330
  } else {
321
331
  const refs = symbol.remainingReferences || [];
322
332
  lines.push(` Remaining name references: ${refs.length} unverified (deleted target)`);
323
- for (const site of refs.slice(0, MAX_CALLERS_PER_FN)) {
333
+ for (const site of refs.slice(0, unverifiedLimit)) {
324
334
  lines.push(` ${site.file}:${site.line}: ${site.expression}`);
325
335
  }
326
- if (refs.length > MAX_CALLERS_PER_FN) lines.push(` (+${refs.length - MAX_CALLERS_PER_FN} more)`);
336
+ if (refs.length > unverifiedLimit) lines.push(` (+${refs.length - unverifiedLimit} more)`);
327
337
  }
328
338
  }
329
339
  }
@@ -5,7 +5,11 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { langTraits } = require('../../languages');
7
7
  const { dynamicImportsNote, formatGitLine, unverifiedReasonLabel } = require('./shared');
8
+ const { paramFact } = require('../registry');
8
9
  const { provenanceReplacer } = require('./provenance');
10
+ const { matchLabelCounts, reasonList } = require('../source-universe');
11
+ const { uncertaintyClassOf, partitionUnverified, runtimeFamilyKey } = require('../review-items');
12
+ const { reasonSummaryLine } = require('../evidence-bands');
9
13
 
10
14
  /**
11
15
  * One short sentence (~80 chars) of a docstring, suitable for inline display
@@ -93,12 +97,43 @@ function formatAmbiguityCandidates(lines, ambiguity) {
93
97
  }
94
98
  }
95
99
 
100
+ /**
101
+ * The account lines that hold for every symbol of the repository: paths
102
+ * nothing could be read from, so no name can be cleared against them. One
103
+ * line, with the reasons; the paths are listed in `repo` health.
104
+ */
105
+ function formatSharedAccountLines(account) {
106
+ const opaque = account && account.opaque;
107
+ if (!opaque || opaque.count === 0) return [];
108
+ const sample = (opaque.sample || []).map(entry => `${entry.path} (${entry.reason})`);
109
+ const more = opaque.count - sample.length;
110
+ return [`WARNING: ${opaque.count} path${opaque.count === 1 ? '' : 's'} could not be searched ` +
111
+ `(${reasonList(opaque.reasons)}), so no name is cleared against ${opaque.count === 1 ? 'it' : 'them'}: ` +
112
+ `${sample.join(', ')}${more > 0 ? `, +${more} more` : ''}. See repo --sections=health.`];
113
+ }
114
+
96
115
  /**
97
116
  * Render the conservation contract lines: ACCOUNT and CONTRACT (always),
98
- * WARNING (unparsed files containing the symbol), FILTERED (display-filter
117
+ * WARNING (what weakens the claim for THIS symbol), FILTERED (display-filter
99
118
  * hides). Returns [] when no account is present (e.g. class-type context).
119
+ *
120
+ * CONTRACT is a state, not a sentence. What the states never mean is the
121
+ * same for every answer and is stated where it is read once (the tool
122
+ * description, the usage guide, `repo` health): a complete text partition
123
+ * is not semantic completeness, and a zero is not deletion proof.
124
+ * text-complete every literal-name line of project
125
+ * source was classified;
126
+ * text-complete, zero observed ... and none is a use;
127
+ * text-complete over indexed source ... except lines in source UCN reads
128
+ * but does not analyze, listed below;
129
+ * degraded some line or path could not be
130
+ * classified at all; see WARNING.
131
+ *
132
+ * `shared: false` leaves out the lines that hold for every symbol of the
133
+ * repository (paths nothing could be read from): a caller that prints many
134
+ * accounts prints those once, through formatSharedAccountLines.
100
135
  */
101
- function formatAccountLines(account) {
136
+ function formatAccountLines(account, { shared = true } = {}) {
102
137
  if (!account) return [];
103
138
  const lines = [];
104
139
  const nc = account.nonCall || { imports: 0, definitions: 0, references: 0, unclassifiedText: 0, total: 0 };
@@ -107,7 +142,7 @@ function formatAccountLines(account) {
107
142
  `${account.confirmed} confirmed, ${account.unverified} unverified, ` +
108
143
  `${nc.total} non-call (${nc.imports} import, ${nc.definitions} definition, ${nc.references} reference, ${nc.unclassifiedText} other-text), ` +
109
144
  `${account.excluded ? account.excluded.total : 0} other-target, ` +
110
- `${account.unsupported && account.unsupported.lines > 0 ? `${account.unsupported.lines} unsupported-language, ` : ''}` +
145
+ `${account.unsupported && account.unsupported.lines > 0 ? `${account.unsupported.lines} unanalyzed, ` : ''}` +
111
146
  `${account.unaccounted} unaccounted`;
112
147
  if (account.beyondText && account.beyondText.count > 0) {
113
148
  line += ` (+${account.beyondText.count} beyond-text caller${account.beyondText.count === 1 ? '' : 's'})`;
@@ -115,31 +150,31 @@ function formatAccountLines(account) {
115
150
  lines.push(line);
116
151
  const contract = account.contract || {};
117
152
  const unsupported = account.unsupported ||
118
- { fileCount: 0, lines: 0, files: [], languages: {}, sites: [], sitesTruncated: false };
153
+ { fileCount: 0, lines: 0, files: [], languages: {}, reasons: {}, sites: [], sitesTruncated: false };
154
+ const opaqueCount = account.opaque ? account.opaque.count : 0;
119
155
  const textComplete = contract.textComplete !== undefined
120
156
  ? contract.textComplete
121
157
  : Boolean(account.conserved) && !(account.unparsed && account.unparsed.fileCount > 0) &&
122
158
  unsupported.lines === 0 &&
123
- !(account.unreadableFiles && account.unreadableFiles.length > 0);
124
- // When the ONLY gap is unsupported-language occurrences, say exactly that
125
- // instead of the generic DEGRADED sentence: the partition over supported
126
- // languages is still conserved, and the gap is enumerated below.
127
- const unsupportedOnly = !textComplete && unsupported.lines > 0 &&
159
+ !(account.unreadableFiles && account.unreadableFiles.length > 0) &&
160
+ opaqueCount === 0;
161
+ // When the ONLY gap is occurrences in unanalyzed source, say exactly
162
+ // that: the partition over indexed source is still conserved, and the
163
+ // gap is enumerated below.
164
+ const unanalyzedOnly = !textComplete && unsupported.lines > 0 &&
128
165
  Boolean(account.conserved) &&
129
166
  !(account.unparsed && account.unparsed.fileCount > 0) &&
130
- !(account.unreadableFiles && account.unreadableFiles.length > 0);
131
- if (unsupportedOnly) {
132
- const langs = Object.keys(unsupported.languages).join(', ');
133
- lines.push(`CONTRACT: literal-name text partition complete over SUPPORTED languages only — ` +
134
- `${unsupported.lines} line${unsupported.lines === 1 ? '' : 's'} in unsupported-language file${unsupported.fileCount === 1 ? '' : 's'}` +
135
- `${langs ? ` (${langs})` : ''} NOT analyzed (see WARNING; verify with grep/ripgrep). ` +
136
- 'Semantic completeness is not claimed.');
167
+ !(account.unreadableFiles && account.unreadableFiles.length > 0) &&
168
+ opaqueCount === 0;
169
+ if (unanalyzedOnly) {
170
+ lines.push('CONTRACT: text-complete over indexed source; ' +
171
+ `${unsupported.lines} line${unsupported.lines === 1 ? '' : 's'} in unanalyzed source (see WARNING)`);
137
172
  } else if (!textComplete) {
138
- lines.push('CONTRACT: literal-name text partition is DEGRADED; review WARNING and unaccounted lines before acting. Semantic completeness is not claimed.');
173
+ lines.push('CONTRACT: degraded; see WARNING');
139
174
  } else if (contract.observedTextZero) {
140
- lines.push('CONTRACT: observed-text zero only; every literal-name line was classified, but aliases, indirect calls, generated code, and runtime dispatch may exist. Not safe-delete proof.');
175
+ lines.push('CONTRACT: text-complete, zero observed; not deletion proof');
141
176
  } else {
142
- lines.push('CONTRACT: literal-name text partition complete; semantic completeness is not claimed (aliases, indirect calls, generated code, and runtime dispatch may exist).');
177
+ lines.push('CONTRACT: text-complete');
143
178
  }
144
179
  if (account.unparsed && account.unparsed.fileCount > 0) {
145
180
  lines.push(`WARNING: ${account.unparsed.fileCount} unparsed file${account.unparsed.fileCount === 1 ? '' : 's'} ` +
@@ -156,12 +191,11 @@ function formatAccountLines(account) {
156
191
  `${rec.lines > shown.length ? `, ... and ${rec.lines - shown.length} more` : ''}`);
157
192
  }
158
193
  if (unsupported.lines > 0) {
159
- const langParts = Object.entries(unsupported.languages)
160
- .map(([lang, count]) => `${lang}: ${count}`).join(', ');
194
+ const kinds = matchLabelCounts(unsupported);
161
195
  lines.push(`WARNING: ${unsupported.lines} line${unsupported.lines === 1 ? '' : 's'} in ` +
162
- `${unsupported.fileCount} unsupported-language file${unsupported.fileCount === 1 ? '' : 's'} ` +
196
+ `${unsupported.fileCount} unanalyzed file${unsupported.fileCount === 1 ? '' : 's'} ` +
163
197
  `contain${unsupported.lines === 1 ? 's' : ''} "${account.symbol}"` +
164
- `${langParts ? ` (${langParts})` : ''} — NOT analyzed; verify with grep/ripgrep:`);
198
+ `${kinds ? ` (${kinds})` : ''} — NOT analyzed; verify with grep/ripgrep:`);
165
199
  const shown = unsupported.sites.slice(0, 5);
166
200
  for (const site of shown) {
167
201
  lines.push(` ${site.file}:${site.line} ${site.text}`);
@@ -173,14 +207,7 @@ function formatAccountLines(account) {
173
207
  if (account.unreadableFiles && account.unreadableFiles.length > 0) {
174
208
  lines.push(`WARNING: ${account.unreadableFiles.length} unreadable file(s) skipped: ${account.unreadableFiles.join(', ')}`);
175
209
  }
176
- if (account.skippedSources && account.skippedSources.length > 0) {
177
- const shown = account.skippedSources.slice(0, 5)
178
- .map(issue => `${issue.relativePath} (${issue.reason})`);
179
- lines.push(`WARNING: ${account.skippedSources.length} source discovery gap(s) make this index partial: ${shown.join(', ')}`);
180
- if (account.skippedSources.length > shown.length) {
181
- lines.push(` ... and ${account.skippedSources.length - shown.length} more; run repo --sections=health for the full reason set.`);
182
- }
183
- }
210
+ if (shared) lines.push(...formatSharedAccountLines(account));
184
211
  if (account.filtered && account.filtered.total > 0) {
185
212
  const parts = [];
186
213
  const f = account.filtered.byFlag || {};
@@ -192,6 +219,15 @@ function formatAccountLines(account) {
192
219
  return lines;
193
220
  }
194
221
 
222
+ /**
223
+ * Rows an unverified list prints: every one with `all`, `top` when it was
224
+ * given, ten otherwise. The header always states the full count.
225
+ */
226
+ function unverifiedRowCap(options) {
227
+ if (options?.all) return Infinity;
228
+ return Number.isInteger(options?.top) && options.top > 0 ? options.top : paramFact('top', 'unverifiedRows');
229
+ }
230
+
195
231
  /**
196
232
  * Single-node CALLEE ACCOUNT line (context/smart — the per-def callee
197
233
  * contract from #223). Matches the tree rollup's arithmetic phrasing.
@@ -328,9 +364,7 @@ function formatContextJson(context) {
328
364
  ...(c.dispatchVia && { dispatchVia: c.dispatchVia }),
329
365
  ...(c.dispatchCandidates != null && { dispatchCandidates: c.dispatchCandidates }),
330
366
  ...(c.externalContract && { externalContract: true }),
331
- uncertaintyClass: c.uncertaintyClass ||
332
- (c.reason === 'possible-dispatch' && c.dispatchVia
333
- ? 'runtime-dispatch' : 'actionable-ambiguity'),
367
+ uncertaintyClass: uncertaintyClassOf(c),
334
368
  ...(c.dispatchFamily && { dispatchFamily: c.dispatchFamily }),
335
369
  })),
336
370
  ...(context.ambiguityCandidates && {
@@ -391,9 +425,7 @@ function formatContextJson(context) {
391
425
  ...(c.dispatchVia && { dispatchVia: c.dispatchVia }),
392
426
  ...(c.dispatchCandidates != null && { dispatchCandidates: c.dispatchCandidates }),
393
427
  ...(c.externalContract && { externalContract: true }),
394
- uncertaintyClass: c.uncertaintyClass ||
395
- (c.reason === 'possible-dispatch' && c.dispatchVia
396
- ? 'runtime-dispatch' : 'actionable-ambiguity'),
428
+ uncertaintyClass: uncertaintyClassOf(c),
397
429
  ...(c.dispatchFamily && { dispatchFamily: c.dispatchFamily }),
398
430
  })),
399
431
  ...(context.ambiguityCandidates && {
@@ -506,7 +538,9 @@ function formatContext(ctx, options = {}) {
506
538
  if (typeUnverified.length > 0) {
507
539
  lines.push(`\nCALLERS — UNVERIFIED (${typeUnverified.length}) — call or callable-reference syntax, no binding/receiver evidence:`);
508
540
  formatAmbiguityCandidates(lines, ctx.ambiguityCandidates);
509
- const cap = 10;
541
+ const typeReasons = reasonSummaryLine(typeUnverified, options.evidenceBands);
542
+ if (typeReasons) lines.push(typeReasons);
543
+ const cap = unverifiedRowCap(ctx.meta);
510
544
  let shown = 0;
511
545
  for (const u of typeUnverified) {
512
546
  if (shown >= cap) break;
@@ -628,20 +662,15 @@ function formatContext(ctx, options = {}) {
628
662
  // This separates unavoidable language semantics from ambiguity the agent
629
663
  // can investigate.
630
664
  const unverified = ctx.unverifiedCallers || [];
631
- const runtimeDispatch = unverified.filter(u =>
632
- u.uncertaintyClass === 'runtime-dispatch' ||
633
- (u.reason === 'possible-dispatch' && u.dispatchVia));
634
- const compileTimeDispatch = unverified.filter(u =>
635
- u.uncertaintyClass === 'compile-time-dispatch');
636
- const actionableUnverified = unverified.filter(u =>
637
- u.uncertaintyClass !== 'compile-time-dispatch' &&
638
- !(u.uncertaintyClass === 'runtime-dispatch' ||
639
- (u.reason === 'possible-dispatch' && u.dispatchVia)));
665
+ // One classification for the text, the JSON and the review counts
666
+ // (core/review-items.js).
667
+ const {
668
+ runtime: runtimeDispatch, compileTime: compileTimeDispatch, actionable: actionableUnverified,
669
+ } = partitionUnverified(unverified);
640
670
  if (runtimeDispatch.length > 0) {
641
671
  const groups = new Map();
642
672
  for (const site of runtimeDispatch) {
643
- const key = `${site.dispatchVia}\0${site.dispatchCandidates ?? ''}\0` +
644
- `${site.externalContract ? 'external' : 'project'}`;
673
+ const key = runtimeFamilyKey(site);
645
674
  if (!groups.has(key)) groups.set(key, []);
646
675
  groups.get(key).push(site);
647
676
  }
@@ -732,11 +761,13 @@ function formatContext(ctx, options = {}) {
732
761
  }
733
762
 
734
763
  // Actionable ambiguity: call syntax without enough identity evidence.
735
- // Always visible and capped at 10 one-liners unless --all.
764
+ // Always visible; ten one-liners unless --top or --all says otherwise.
736
765
  if (actionableUnverified.length > 0) {
737
766
  lines.push(`${compact ? '' : '\n'}CALLERS — UNVERIFIED (${actionableUnverified.length}) — call or callable-reference syntax, no binding/receiver evidence:`);
738
767
  formatAmbiguityCandidates(lines, ctx.ambiguityCandidates);
739
- const cap = (ctx.meta && ctx.meta.all) ? Infinity : 10;
768
+ const reasons = reasonSummaryLine(actionableUnverified, options.evidenceBands);
769
+ if (reasons) lines.push(reasons);
770
+ const cap = unverifiedRowCap(ctx.meta);
740
771
  let shown = 0;
741
772
  for (const u of actionableUnverified) {
742
773
  if (shown >= cap) break;
@@ -862,6 +893,8 @@ function formatImpact(impact, options = {}) {
862
893
  }
863
894
 
864
895
  const compact = !!options.compact;
896
+ const allHint = options.allHint || 'use --all';
897
+ const unverifiedCap = unverifiedRowCap(options);
865
898
  const lines = [];
866
899
 
867
900
  // Header
@@ -970,14 +1003,14 @@ function formatImpact(impact, options = {}) {
970
1003
  const reason = impact.propertyAccesses ? 'matching attribute syntax, receiver identity unresolved'
971
1004
  : 'matching value syntax, binding identity unresolved';
972
1005
  lines.push(`${compact ? '' : '\n'}UNVERIFIED ${label} ACCESS CANDIDATES (${access.unverifiedSites.length}) — ${reason}:`);
973
- for (const site of access.unverifiedSites.slice(0, 10)) {
1006
+ for (const site of access.unverifiedSites.slice(0, unverifiedCap)) {
974
1007
  const caller = site.callerName ? ` [${site.callerName}]` : '';
975
1008
  const expr = site.expression ? `: ${site.expression.replace(/\s+/g, ' ').slice(0, 100)}` : '';
976
1009
  const why = impact.valueAccesses && site.reason ? ` (${site.reason})` : '';
977
1010
  lines.push(` ${site.file}:${site.line}${caller} [${site.accessKind || 'access'}${Number.isInteger(site.column) ? `, column ${site.column + 1}` : ''}]${expr}${why}`);
978
1011
  }
979
- if (access.unverifiedSites.length > 10) {
980
- lines.push(` (+${access.unverifiedSites.length - 10} more unverified)`);
1012
+ if (access.unverifiedSites.length > unverifiedCap) {
1013
+ lines.push(` (+${access.unverifiedSites.length - unverifiedCap} more unverified — ${allHint})`);
981
1014
  }
982
1015
  }
983
1016
  const valueAccount = impact.valueAccesses?.account;
@@ -999,21 +1032,23 @@ function formatImpact(impact, options = {}) {
999
1032
  }
1000
1033
  if (refs.unverifiedSites.length > 0) {
1001
1034
  lines.push(`${compact ? '' : '\n'}UNVERIFIED TYPE REFERENCE CANDIDATES (${refs.unverifiedSites.length}) — the name, the type lookup cannot decide what it names:`);
1002
- for (const site of refs.unverifiedSites.slice(0, 10)) {
1035
+ for (const site of refs.unverifiedSites.slice(0, unverifiedCap)) {
1003
1036
  const expr = site.expression ? `: ${site.expression.replace(/\s+/g, ' ').slice(0, 100)}` : '';
1004
1037
  lines.push(` ${site.file}:${site.line} [${position(site)}]${expr} (${site.reason})`);
1005
1038
  }
1006
- if (refs.unverifiedSites.length > 10) {
1007
- lines.push(` (+${refs.unverifiedSites.length - 10} more unverified)`);
1039
+ if (refs.unverifiedSites.length > unverifiedCap) {
1040
+ lines.push(` (+${refs.unverifiedSites.length - unverifiedCap} more unverified — ${allHint})`);
1008
1041
  }
1009
1042
  }
1010
1043
  if (refs.account) lines.push(formatTypeAccount(impact.function, refs));
1011
1044
  }
1012
1045
 
1013
- // Unverified tier: visible, capped at 10 one-liners
1046
+ // Unverified tier: visible; ten one-liners unless --top or --all says otherwise
1014
1047
  if (impactUnverified.length > 0) {
1015
1048
  lines.push(`${compact ? '' : '\n'}UNVERIFIED CALL SITES (${impactUnverified.length}) — call or callable-reference syntax, no binding/receiver evidence:`);
1016
- const cap = 10;
1049
+ const reasons = reasonSummaryLine(impactUnverified, options.evidenceBands);
1050
+ if (reasons) lines.push(reasons);
1051
+ const cap = unverifiedCap;
1017
1052
  for (const site of impactUnverified.slice(0, cap)) {
1018
1053
  const caller = site.callerName ? ` [${site.callerName}]` : '';
1019
1054
  const reason = site.reason ? ` (${unverifiedReasonLabel(site)})` : '';
@@ -1021,7 +1056,7 @@ function formatImpact(impact, options = {}) {
1021
1056
  lines.push(` ${site.file}:${site.line}${caller}${expr}${reason}`);
1022
1057
  }
1023
1058
  if (impactUnverified.length > cap) {
1024
- lines.push(` (+${impactUnverified.length - cap} more unverified)`);
1059
+ lines.push(` (+${impactUnverified.length - cap} more unverified — ${allHint})`);
1025
1060
  }
1026
1061
  }
1027
1062
 
@@ -1171,6 +1206,8 @@ function formatAbout(about, options = {}) {
1171
1206
  if (aboutUnverified && aboutUnverified.total > 0) {
1172
1207
  lines.push('');
1173
1208
  lines.push(`CALLERS — UNVERIFIED (${aboutUnverified.total}) — call or callable-reference syntax, no binding/receiver evidence:`);
1209
+ const reasons = reasonSummaryLine(aboutUnverified.reasons || aboutUnverified.top, options.evidenceBands);
1210
+ if (reasons) lines.push(reasons);
1174
1211
  for (const u of aboutUnverified.top) {
1175
1212
  const caller = u.callerName ? ` [${u.callerName}]` : '';
1176
1213
  const reason = u.reason ? ` (${unverifiedReasonLabel(u)})` : '';
@@ -1325,6 +1362,7 @@ module.exports = {
1325
1362
  // Shared with output/tracing.js: the tree commands render the same
1326
1363
  // root-hop ACCOUNT/WARNING/FILTERED lines as context/impact.
1327
1364
  formatAccountLines,
1365
+ formatSharedAccountLines,
1328
1366
  // Shared with output/analysis-ext.js (smart): single-node callee contract.
1329
1367
  formatCalleeAccountLine,
1330
1368
  unverifiedCalleeLines,
@@ -1,14 +1,74 @@
1
1
  /**
2
2
  * core/output/check.js — Pre-commit check formatter.
3
+ *
4
+ * Items are grouped by verdict, worst first: what broke, what to look at,
5
+ * what nothing depends on. Every site of a break is listed: a reader who
6
+ * has to fix thirteen call sites needs thirteen lines.
3
7
  */
4
8
 
5
9
  'use strict';
6
10
 
11
+ const SECTIONS = [
12
+ ['blocked', 'BLOCKED', 'a break was observed'],
13
+ ['review', 'REVIEW', 'can reach existing code; not settled by the engine'],
14
+ ['ready', 'READY', 'nothing that existed depends on the change'],
15
+ ];
16
+ // Occurrences of a name are context, not a worklist: a head is enough.
17
+ const NAME_SITE_LIMIT = 10;
18
+
19
+ function callersText(it) {
20
+ if (it.kind === 'module') return '';
21
+ let callers = it.callerCount != null ? `${it.callerCount} caller${it.callerCount === 1 ? '' : 's'}` : '';
22
+ if (it.unverifiedCallerCount > 0) callers += ` (+${it.unverifiedCallerCount} unverified)`;
23
+ return callers;
24
+ }
25
+
26
+ function itemLines(it) {
27
+ const tags = [];
28
+ if (it.kind && it.kind !== 'changed') tags.push(it.kind.toUpperCase());
29
+ if (it.verdict === 'ready' && it.kind === 'modified') {
30
+ if (it.change === 'inert') tags.push('contract unchanged');
31
+ else if (it.noDependents) tags.push('no static dependents');
32
+ }
33
+ if (it.verdict !== 'ready') {
34
+ for (const reason of it.reasons || []) if (reason !== 'signature-mismatch') tags.push(reason);
35
+ }
36
+ if (it.signatureMismatches > 0) tags.push(`SIG-DRIFT(${it.signatureMismatches})`);
37
+ if (it.orphan) tags.push('ORPHAN');
38
+ if (it.symbolType) tags.push(it.symbolType);
39
+ // Caller evidence matters where the callers do.
40
+ if (it.verdict !== 'ready' && it.kind !== 'deleted' && it.account && !it.account.textComplete) {
41
+ tags.push('ACCOUNT-INCOMPLETE');
42
+ }
43
+ const lines = [` ${it.name} (${it.file}:${it.line})${tags.length ? ` [${tags.join(', ')}]` : ''} ${callersText(it)}`.trimEnd()];
44
+ if (it.symbolType) {
45
+ lines.push(` Dependencies: ${it.dependencyCount || 0} confirmed, ${it.unverifiedDependencyCount || 0} unverified`);
46
+ }
47
+ for (const m of it.mismatches || []) {
48
+ const problem = m.expected ? `expected ${m.expected}, got ${m.actual}` : (m.reason || 'arity mismatch');
49
+ const where = m.file ? `${m.file}:${m.line}` : '';
50
+ const expression = m.expression ? ` ${String(m.expression).trim()}` : '';
51
+ lines.push(` ↳ ${where}${expression} ${problem}${m.unchecked ? ' (not checked by the toolchain)' : ''}`);
52
+ }
53
+ const sites = it.sites || [];
54
+ const definite = it.verdict === 'blocked';
55
+ const shown = definite ? sites : sites.slice(0, NAME_SITE_LIMIT);
56
+ for (const site of shown) {
57
+ lines.push(` ↳ ${site.file}${site.line ? `:${site.line}` : ''}${site.kind ? ` ${site.kind}` : ''}` +
58
+ `${site.unchecked ? ' (not checked by the toolchain)' : ''}`);
59
+ }
60
+ if (sites.length > shown.length) lines.push(` (+${sites.length - shown.length} more occurrence(s))`);
61
+ return lines;
62
+ }
63
+
7
64
  function formatCheck(result) {
8
65
  if (!result) return 'No check result.';
9
66
  if (result.ok === false) {
10
67
  // The gate could not run — never render this like a clean tree.
11
- return `Pre-commit Check (${result.base}${result.staged ? ', staged' : ''})\n${'═'.repeat(60)}\nCHECK DID NOT RUN [${result.status || 'diff-failed'}] — ${result.error || 'git diff failed'}\nThis is not a pass. Fix the git context (run inside a git repository with a valid base ref) and rerun.`;
68
+ const advice = result.status === 'staged-snapshot-unavailable'
69
+ ? 'This is not a pass, and says nothing about the worktree. Resolve what keeps the git index from being exported (unmerged paths, a missing object) and rerun.'
70
+ : 'This is not a pass. Fix the git context (run inside a git repository with a valid base ref) and rerun.';
71
+ return `Pre-commit Check (${result.base}${result.staged ? ', staged' : ''})\n${'═'.repeat(60)}\nCHECK DID NOT RUN [${result.status || 'diff-failed'}] — ${result.error || 'git diff failed'}\n${advice}`;
12
72
  }
13
73
  const pathNotes = [];
14
74
  if (result.nonSourcePaths > 0) pathNotes.push(`Note: ${result.nonSourcePaths} changed path(s) outside supported source files not analyzed.`);
@@ -21,20 +81,26 @@ function formatCheck(result) {
21
81
  lines.push(`Pre-commit Check vs ${result.base}${result.staged ? ' (staged)' : ''}`);
22
82
  lines.push('═'.repeat(60));
23
83
  lines.push(...pathNotes);
84
+ for (const note of result.notes || []) lines.push(`Note: ${note}`);
85
+ const items = result.changed || [];
24
86
  if (result.trust) {
25
- lines.push(`TRUST: ${result.trust.status} — UCN evidence is not semantic proof; compiler and tests are required`);
26
- const trustDetails = [];
27
- if (result.trust.incompleteAccounts) trustDetails.push(`${result.trust.incompleteAccounts} incomplete account(s)`);
28
- if (result.trust.unverifiedCallSites) trustDetails.push(`${result.trust.unverifiedCallSites} unverified caller(s)`);
29
- if (result.trust.signatureMismatches) trustDetails.push(`${result.trust.signatureMismatches} signature mismatch(es)`);
30
- if (result.trust.filteredEdges) trustDetails.push(`${result.trust.filteredEdges} filtered edge(s)`);
31
- if (result.trust.usageReviewSymbols) trustDetails.push(`${result.trust.usageReviewSymbols} symbol(s) need usages review`);
32
- if (result.trust.unvalidatedDeclarations) trustDetails.push(`${result.trust.unvalidatedDeclarations} declaration change(s) need toolchain validation`);
33
- if (trustDetails.length > 0) lines.push(` ${trustDetails.join(' · ')}`);
87
+ const trust = result.trust;
88
+ lines.push(`TRUST: ${trust.status} — UCN evidence is not semantic proof; compiler and tests are required`);
89
+ const counts = [];
90
+ if (trust.blocked) counts.push(`${trust.blocked} blocked`);
91
+ if (trust.review) counts.push(`${trust.review} to review`);
92
+ if (trust.ready) counts.push(`${trust.ready} ready`);
93
+ const details = [];
94
+ if (trust.signatureMismatches) details.push(`${trust.signatureMismatches} signature mismatch(es)`);
95
+ if (trust.unverifiedCallSites) details.push(`${trust.unverifiedCallSites} unverified caller(s)`);
96
+ if (trust.incompleteAccounts) details.push(`${trust.incompleteAccounts} incomplete account(s)`);
97
+ if (trust.filteredEdges) details.push(`${trust.filteredEdges} filtered edge(s)`);
98
+ if (trust.usageReviewSymbols) details.push(`${trust.usageReviewSymbols} symbol(s) need usages review`);
99
+ if (trust.unvalidatedDeclarations) details.push(`${trust.unvalidatedDeclarations} declaration change(s) need toolchain validation`);
100
+ if (trust.strict) details.push('strict: review does not pass');
101
+ if (counts.length > 0 || details.length > 0) lines.push(` ${[...counts, ...details].join(' · ')}`);
34
102
  }
35
103
 
36
- // Changed functions section
37
- const items = result.changed || [];
38
104
  const noun = items.some(it => it.symbolType) ? 'symbol' : 'function';
39
105
  if (result.truncated) {
40
106
  lines.push(`Changed: ${items.length} of ${result.totalChanged} ${noun}s`);
@@ -44,38 +110,28 @@ function formatCheck(result) {
44
110
  if (items.length === 0) {
45
111
  lines.push(' (none — only non-function changes)');
46
112
  } else {
47
- for (const it of items) {
48
- const tags = [];
49
- if (it.kind && it.kind !== 'changed') tags.push(it.kind.toUpperCase());
50
- if (it.signatureMismatches > 0) tags.push(`SIG-DRIFT(${it.signatureMismatches})`);
51
- if (it.orphan) tags.push('ORPHAN');
52
- if (it.symbolType) tags.push(it.symbolType);
53
- if (it.account && !it.account.textComplete) tags.push('ACCOUNT-INCOMPLETE');
54
- const tagStr = tags.length ? ' [' + tags.join(', ') + ']' : '';
55
- let callers = it.callerCount != null ? `${it.callerCount} caller${it.callerCount === 1 ? '' : 's'}` : '';
56
- if (it.unverifiedCallerCount > 0) {
57
- callers += ` (+${it.unverifiedCallerCount} unverified)`;
58
- }
59
- lines.push(` ${it.name} (${it.file}:${it.line})${tagStr} ${callers}`);
60
- if (it.symbolType) lines.push(` Dependencies: ${it.dependencyCount || 0} confirmed, ${it.unverifiedDependencyCount || 0} unverified`);
61
- if (it.mismatches && it.mismatches.length > 0) {
62
- for (const m of it.mismatches.slice(0, 3)) {
63
- const where = m.file ? ` at ${m.file}:${m.line}` : '';
64
- const reason = m.expected
65
- ? `expected ${m.expected}, got ${m.actual}`
66
- : (m.reason || 'arity mismatch');
67
- lines.push(` ↳ ${reason}${where}`);
68
- }
69
- }
113
+ for (const [verdict, title, meaning] of SECTIONS) {
114
+ const group = items.filter(it => (it.verdict || 'review') === verdict);
115
+ if (group.length === 0) continue;
116
+ lines.push(`${title} (${group.length}) — ${meaning}:`);
117
+ for (const it of group) lines.push(...itemLines(it));
70
118
  }
71
119
  }
72
120
 
73
121
  // Tests
74
122
  lines.push('');
75
123
  if (result.testFiles && result.testFiles.length > 0) {
76
- lines.push(`Tests potentially affected: ${result.totalTests || result.testFiles.length} in ${result.testFiles.length} file${result.testFiles.length === 1 ? '' : 's'}`);
124
+ const files = result.testFiles.length;
125
+ // A count of tests is printed only when tests were counted: files
126
+ // whose tests are not read have none to report.
127
+ // "at least": a file among them holds tests that are not listed.
128
+ const conditional = result.conditionalTests ? ` (${result.conditionalTests} conditional)` : '';
129
+ lines.push(result.totalTests > 0
130
+ ? `Tests potentially affected: ${result.moreTests ? 'at least ' : ''}${result.totalTests} in ${files} file${files === 1 ? '' : 's'}${conditional}`
131
+ : `Test files potentially affected: ${files}${result.moreTests ? ' (tests not listed)' : ''}`);
77
132
  for (const tf of result.testFiles.slice(0, 8)) {
78
- const cnt = tf.testCount ? ` (${tf.testCount})` : '';
133
+ const cnt = tf.testCount ? ` (${tf.moreTests ? 'at least ' : ''}${tf.testCount})`
134
+ : tf.moreTests ? ' (tests not listed)' : '';
79
135
  lines.push(` ${tf.file}${cnt}`);
80
136
  }
81
137
  if (result.testFiles.length > 8) {
@@ -84,6 +140,11 @@ function formatCheck(result) {
84
140
  } else {
85
141
  lines.push('Tests: none detected for changed functions');
86
142
  }
143
+ if (result.supportFiles && result.supportFiles.length > 0) {
144
+ const names = result.supportFiles.slice(0, 5).map(row => row.file).join(', ');
145
+ const more = result.supportFiles.length > 5 ? `, +${result.supportFiles.length - 5} more` : '';
146
+ lines.push(`Test support code reached (declares no test, runs with the tests that load it): ${names}${more}`);
147
+ }
87
148
 
88
149
  // Action items
89
150
  if (result.actions && result.actions.length > 0) {
@@ -116,7 +116,15 @@ function formatDoctor(result, options = {}) {
116
116
  const reasons = Object.entries(info.reasons || {})
117
117
  .map(([reason, count]) => `${reason} ${count}`).join(', ');
118
118
  lines.push(` ${label}: ${fileCount} path${fileCount === 1 ? '' : 's'} not indexed${reasons ? ` (${reasons})` : ''}`);
119
- lines.push(' The index is partial; adjust .ucn.json or discovery limits before relying on completeness-sensitive commands.');
119
+ const unsearchable = info.unsearchable || 0;
120
+ if (unsearchable > 0) {
121
+ lines.push(` ${unsearchable} could not be searched: no caller account is complete until ` +
122
+ 'they can be read (fix the path, raise the limit, or declare it in .ucn.json outsideProject).');
123
+ }
124
+ if (fileCount > unsearchable) {
125
+ lines.push(` ${unsearchable > 0 ? 'The rest is searched' : 'Searched'} as text for each name: ` +
126
+ 'an occurrence there is listed, not analyzed.');
127
+ }
120
128
  } else {
121
129
  lines.push(` ${label}: ${info.count} ${unit}${info.count === 1 ? '' : 's'} in ${fileCount} file${fileCount === 1 ? '' : 's'}`);
122
130
  }
@@ -129,6 +137,31 @@ function formatDoctor(result, options = {}) {
129
137
  }
130
138
  if (!anyBlindSpot) lines.push(' (none detected)');
131
139
 
140
+ // What a completeness claim ranges over and what it never means: said
141
+ // here once, so an answer only carries its own state.
142
+ lines.push('');
143
+ lines.push('Contract scope:');
144
+ const scope = result.scope;
145
+ if (scope) {
146
+ const list = reasons => Object.entries(reasons || {})
147
+ .map(([reason, count]) => `${reason} ${count}`).join(', ');
148
+ lines.push(` Indexed source, plus ${scope.textOnly.count} file${scope.textOnly.count === 1 ? '' : 's'} ` +
149
+ `searched as text for each name${scope.textOnly.count > 0 ? ` (${list(scope.textOnly.reasons)})` : ''}.`);
150
+ if (scope.opaque.count > 0) {
151
+ lines.push(` Not searchable: ${scope.opaque.count} path${scope.opaque.count === 1 ? '' : 's'} ` +
152
+ `(${list(scope.opaque.reasons)}); every caller account is degraded until they are.`);
153
+ }
154
+ if (scope.outside.count > 0) {
155
+ const sample = scope.outside.sample.slice(0, 3).map(entry => entry.path).join(', ');
156
+ lines.push(` Outside the project: ${scope.outside.count} path${scope.outside.count === 1 ? '' : 's'} ` +
157
+ `(${list(scope.outside.reasons)}), no part of any claim: ${sample}` +
158
+ `${scope.outside.count > 3 ? ', ...' : ''}`);
159
+ }
160
+ }
161
+ lines.push(' CONTRACT text-complete: every literal-name line in that scope was classified.');
162
+ lines.push(' It is never semantic completeness (aliases, indirect calls, generated code and');
163
+ lines.push(' runtime dispatch may exist), and a zero is never deletion proof.');
164
+
132
165
  // Task-specific readiness. One scalar cannot honestly describe navigation,
133
166
  // refactoring, and deletion risk.
134
167
  lines.push('');
@@ -10,9 +10,10 @@ const {
10
10
  } = require('./shared');
11
11
 
12
12
  const { formatSymbolHandle } = require('../shared');
13
+ const { paramFact } = require('../registry');
13
14
 
14
15
  // Detailed text rows shown by default; the rest are counted.
15
- const DEFAULT_DISPLAY_LIMIT = 5;
16
+ const DEFAULT_DISPLAY_LIMIT = paramFact('limit', 'findDetail');
16
17
 
17
18
  /**
18
19
  * Trim a docstring to a single short sentence (max 80 chars) for inline display.