ucn 5.5.3 → 5.6.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 (77) hide show
  1. package/.claude/skills/ucn/SKILL.md +37 -12
  2. package/.claude/skills/ucn/references/commands.md +18 -14
  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 +485 -0
  6. package/README.md +66 -29
  7. package/cli/flags.js +184 -0
  8. package/cli/index.js +137 -324
  9. package/core/account.js +44 -87
  10. package/core/analysis.js +274 -108
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +14 -0
  13. package/core/cache.js +103 -16
  14. package/core/call-fit.js +130 -0
  15. package/core/callers.js +353 -71
  16. package/core/capture.js +363 -0
  17. package/core/change-kinds.js +1107 -0
  18. package/core/check.js +1150 -139
  19. package/core/command-contracts.js +8 -7
  20. package/core/constructed-class.js +1195 -0
  21. package/core/contract-membership.js +142 -0
  22. package/core/deadcode.js +455 -106
  23. package/core/deletion.js +421 -0
  24. package/core/discovery.js +104 -9
  25. package/core/entrypoint-toml.js +110 -0
  26. package/core/entrypoints.js +12 -6
  27. package/core/evidence-bands.js +68 -0
  28. package/core/evidence-bands.json +44 -0
  29. package/core/execute.js +172 -161
  30. package/core/hot-summary.js +241 -0
  31. package/core/index-ir.js +5 -3
  32. package/core/ir.js +6 -2
  33. package/core/member-signatures.js +789 -0
  34. package/core/outgoing-check.js +116 -0
  35. package/core/output/analysis-ext.js +20 -10
  36. package/core/output/analysis.js +98 -60
  37. package/core/output/check.js +101 -37
  38. package/core/output/doctor.js +34 -1
  39. package/core/output/find.js +2 -1
  40. package/core/output/lines.js +63 -3
  41. package/core/output/public.js +29 -11
  42. package/core/output/refactoring.js +32 -0
  43. package/core/output/reporting.js +19 -2
  44. package/core/output/search.js +14 -2
  45. package/core/output/tracing.js +36 -8
  46. package/core/output-budget.js +146 -61
  47. package/core/parallel-build.js +3 -3
  48. package/core/project.js +106 -23
  49. package/core/provenance-type.js +12 -5
  50. package/core/public-command.js +35 -9
  51. package/core/python-binding-identity.js +707 -0
  52. package/core/python-modules.js +6 -0
  53. package/core/reflection.js +248 -31
  54. package/core/registry.js +623 -72
  55. package/core/reporting.js +69 -34
  56. package/core/review-items.js +75 -0
  57. package/core/runtime-entrypoints.js +159 -0
  58. package/core/search.js +148 -164
  59. package/core/shared.js +0 -45
  60. package/core/snapshot.js +350 -0
  61. package/core/source-universe.js +707 -0
  62. package/core/test-units.js +3698 -0
  63. package/core/tracing.js +270 -658
  64. package/core/verify.js +63 -22
  65. package/languages/adapter.js +8 -3
  66. package/languages/c-family.js +12 -0
  67. package/languages/csharp.js +634 -0
  68. package/languages/go.js +120 -0
  69. package/languages/index.js +466 -4
  70. package/languages/java.js +712 -0
  71. package/languages/javascript.js +1498 -6
  72. package/languages/lexical-scope.js +68 -0
  73. package/languages/python.js +1708 -3
  74. package/languages/rust.js +209 -104
  75. package/languages/static-js.js +88 -0
  76. package/mcp/server.js +44 -110
  77. package/package.json +11 -5
@@ -0,0 +1,116 @@
1
+ /** Validate changed calls against the project declarations they can reach. */
2
+ 'use strict';
3
+
4
+ const { getCachedCalls } = require('./callers');
5
+ const { verifyCallSites } = require('./verify');
6
+ const { langTraits } = require('../languages');
7
+ const { positivelyFits } = require('./call-fit');
8
+
9
+ /** One reader and result cache for a check, including all changed symbols. */
10
+ function outgoingChecker(index, judge, addedLinesByFile) {
11
+ const files = new Map();
12
+ const checked = new Map();
13
+ const read = file => {
14
+ if (!files.has(file)) files.set(file, {
15
+ calls: getCachedCalls(index, file) || [], lines: index._getFileLines(file),
16
+ });
17
+ return files.get(file);
18
+ };
19
+ return (definition, change, added) => {
20
+ const mismatches = [];
21
+ const unsettled = [];
22
+ if (!definition) return { mismatches, unsettled };
23
+ const data = read(definition.file);
24
+ const changedRanges = !added ? change?.changedCalls : null;
25
+ const lines = addedLinesByFile.get(definition.relativePath) || new Set();
26
+ const records = data.calls.filter(call => !call.isFunctionReference &&
27
+ call.line >= definition.startLine && call.line <= definition.endLine &&
28
+ (changedRanges ? changedRanges.some(range =>
29
+ range.start <= (call.callStart ?? call.callSite?.start) &&
30
+ range.end >= (call.callEnd ?? call.callSite?.end ?? call.callStart ?? call.callSite?.start))
31
+ : added || lines.has(call.line)));
32
+ if (records.length === 0) return { mismatches, unsettled };
33
+ const byStart = new Map(records.map(call => [call.callStart ?? call.callSite?.start, call]));
34
+ const callees = index.findCallees(definition, {
35
+ collectAccount: true, includeMethods: true, siteStarts: new Set(byStart.keys()),
36
+ });
37
+ const relative = definition.relativePath;
38
+ const positiveFamilies = new Set();
39
+ const possibleFamilies = new Set();
40
+ const issues = [];
41
+ const familyOf = (callee, site) => {
42
+ // The class's public constructor overloads are the call
43
+ // interface; an initializer edge also projects their body.
44
+ const owner = callee.initializerOf || (callee.type === 'class' ? callee.name : null);
45
+ return JSON.stringify([site.start, callee.file, owner || callee.name,
46
+ owner ? null : callee.className, callee.namespace, callee.enclosingType]);
47
+ };
48
+ const uncertain = (site, name, reason) => unsettled.push({ file: relative, line: site.line, column: site.column, name, reason });
49
+ for (const callee of callees) {
50
+ if (callee.functionReference) continue;
51
+ for (const site of callee.siteProvenance || []) {
52
+ const record = byStart.get(site.start);
53
+ if (!record) continue;
54
+ const family = familyOf(callee, site);
55
+ const key = JSON.stringify([definition.file, site.start, callee.file, callee.startLine,
56
+ callee.sourceSpan?.start, callee.sourceSpan?.end, callee.name, callee.className,
57
+ callee.type, callee.paramsStructured]);
58
+ let result = checked.get(key);
59
+ if (!result) {
60
+ result = verifyCallSites(index, callee, [{
61
+ ...record, file: definition.file, relativePath: relative,
62
+ content: data.lines[site.line - 1] || '', calledAs: record.name,
63
+ provenance: site.provenance,
64
+ callerFile: definition.file, callerStartLine: definition.startLine,
65
+ }]);
66
+ checked.set(key, result);
67
+ }
68
+ for (const mismatch of result.mismatchDetails || []) {
69
+ const counts = String(mismatch.expected || '').match(/\d+/g);
70
+ const suppressed = judge.suppressed(mismatch, {
71
+ kind: 'arity', name: callee.name,
72
+ most: counts ? Number(counts[counts.length - 1]) : undefined,
73
+ span: mismatch.span, problem: mismatch.problem, indirect: mismatch.indirect,
74
+ });
75
+ // JavaScript deliberately permits omitted and extra
76
+ // arguments. A declared count is not runtime proof.
77
+ const flexible = langTraits(index.files.get(definition.file)?.language)?.lenientCallArity === true;
78
+ if ((callee.decorators || []).length) uncertain(site, callee.name, 'decorated-call-interface');
79
+ else if (flexible) uncertain(site, callee.name, 'call-arity-not-enforced');
80
+ else issues.push({ family, site, callee, mismatch: {
81
+ ...mismatch, column: site.column, target: callee.name, ...(suppressed && { unchecked: true }),
82
+ } });
83
+ }
84
+ if (result.valid > 0 || result.uncertain > 0) possibleFamilies.add(family);
85
+ for (const row of result.uncertainDetails || []) issues.push({ family, site, callee, reason: row.reason });
86
+ if (result.valid > 0) {
87
+ const preserved = changedRanges?.some(range => range.stableInvocation &&
88
+ range.start <= site.start && range.end >= (record.callEnd ?? record.callSite?.end ?? site.start));
89
+ if (positivelyFits(index, callee, definition, record, result, preserved)) positiveFamilies.add(family);
90
+ else issues.push({ family, site, callee, reason: 'argument-compatibility-unverified' });
91
+ }
92
+ }
93
+ }
94
+ // Overload declarations describe one call interface. A positively
95
+ // fitting alternative settles it; a merely possible alternative
96
+ // prevents a different signature's arity from claiming a break.
97
+ for (const issue of issues) {
98
+ if (positiveFamilies.has(issue.family)) continue;
99
+ if (issue.mismatch && !possibleFamilies.has(issue.family)) mismatches.push(issue.mismatch);
100
+ else uncertain(issue.site, issue.callee.name, issue.reason || 'overload-compatibility-unverified');
101
+ }
102
+ for (const entry of [...(callees.unverifiedCallees || []), ...(callees.initializerReviews || [])]) {
103
+ // External packages are outside this fit check. A candidate
104
+ // project declaration, however, cannot silently become READY.
105
+ if (!(entry.ownerCount > 0 || entry.initializerOf || index.symbols.has(entry.name))) continue;
106
+ for (const site of entry.siteProvenance || []) {
107
+ if (byStart.has(site.start)) uncertain(site, entry.name, entry.reason || 'outgoing-target-unresolved');
108
+ }
109
+ }
110
+ const unique = rows => [...new Map(rows.map(row => [JSON.stringify(row), row])).values()]
111
+ .sort((a, b) => a.line - b.line || (a.column || 0) - (b.column || 0) || ((a.name || a.target || '') < (b.name || b.target || '') ? -1 : (a.name || a.target || '') > (b.name || b.target || '') ? 1 : 0));
112
+ return { mismatches: unique(mismatches), unsettled: unique(unsettled) };
113
+ };
114
+ }
115
+
116
+ module.exports = { outgoingChecker };
@@ -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,