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
@@ -1,14 +1,77 @@
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', 'change exposure or call requirements not settled'],
14
+ ['ready', 'READY', 'no unresolved exposure in the modeled checks'],
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
+ for (const site of it.outgoingReview || []) {
54
+ lines.push(` ↳ ${site.file}:${site.line}${site.column != null ? `:${site.column + 1}` : ''} ${site.name}: ${site.reason}`);
55
+ }
56
+ const sites = it.sites || [];
57
+ const definite = it.verdict === 'blocked';
58
+ const shown = definite ? sites : sites.slice(0, NAME_SITE_LIMIT);
59
+ for (const site of shown) {
60
+ lines.push(` ↳ ${site.file}${site.line ? `:${site.line}` : ''}${site.kind ? ` ${site.kind}` : ''}` +
61
+ `${site.unchecked ? ' (not checked by the toolchain)' : ''}`);
62
+ }
63
+ if (sites.length > shown.length) lines.push(` (+${sites.length - shown.length} more occurrence(s))`);
64
+ return lines;
65
+ }
66
+
7
67
  function formatCheck(result) {
8
68
  if (!result) return 'No check result.';
9
69
  if (result.ok === false) {
10
70
  // 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.`;
71
+ const advice = result.status === 'staged-snapshot-unavailable'
72
+ ? '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.'
73
+ : 'This is not a pass. Fix the git context (run inside a git repository with a valid base ref) and rerun.';
74
+ 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
75
  }
13
76
  const pathNotes = [];
14
77
  if (result.nonSourcePaths > 0) pathNotes.push(`Note: ${result.nonSourcePaths} changed path(s) outside supported source files not analyzed.`);
@@ -21,20 +84,26 @@ function formatCheck(result) {
21
84
  lines.push(`Pre-commit Check vs ${result.base}${result.staged ? ' (staged)' : ''}`);
22
85
  lines.push('═'.repeat(60));
23
86
  lines.push(...pathNotes);
87
+ for (const note of result.notes || []) lines.push(`Note: ${note}`);
88
+ const items = result.changed || [];
24
89
  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(' · ')}`);
90
+ const trust = result.trust;
91
+ lines.push(`TRUST: ${trust.status} — UCN evidence is not semantic proof; compiler and tests are required`);
92
+ const counts = [];
93
+ if (trust.blocked) counts.push(`${trust.blocked} blocked`);
94
+ if (trust.review) counts.push(`${trust.review} to review`);
95
+ if (trust.ready) counts.push(`${trust.ready} ready`);
96
+ const details = [];
97
+ if (trust.signatureMismatches) details.push(`${trust.signatureMismatches} signature mismatch(es)`);
98
+ if (trust.unverifiedCallSites) details.push(`${trust.unverifiedCallSites} unverified caller(s)`);
99
+ if (trust.incompleteAccounts) details.push(`${trust.incompleteAccounts} incomplete account(s)`);
100
+ if (trust.filteredEdges) details.push(`${trust.filteredEdges} filtered edge(s)`);
101
+ if (trust.usageReviewSymbols) details.push(`${trust.usageReviewSymbols} symbol(s) need usages review`);
102
+ if (trust.unvalidatedDeclarations) details.push(`${trust.unvalidatedDeclarations} declaration change(s) need toolchain validation`);
103
+ if (trust.strict) details.push('strict: review does not pass');
104
+ if (counts.length > 0 || details.length > 0) lines.push(` ${[...counts, ...details].join(' · ')}`);
34
105
  }
35
106
 
36
- // Changed functions section
37
- const items = result.changed || [];
38
107
  const noun = items.some(it => it.symbolType) ? 'symbol' : 'function';
39
108
  if (result.truncated) {
40
109
  lines.push(`Changed: ${items.length} of ${result.totalChanged} ${noun}s`);
@@ -44,38 +113,28 @@ function formatCheck(result) {
44
113
  if (items.length === 0) {
45
114
  lines.push(' (none — only non-function changes)');
46
115
  } 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
- }
116
+ for (const [verdict, title, meaning] of SECTIONS) {
117
+ const group = items.filter(it => (it.verdict || 'review') === verdict);
118
+ if (group.length === 0) continue;
119
+ lines.push(`${title} (${group.length}) — ${meaning}:`);
120
+ for (const it of group) lines.push(...itemLines(it));
70
121
  }
71
122
  }
72
123
 
73
124
  // Tests
74
125
  lines.push('');
75
126
  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'}`);
127
+ const files = result.testFiles.length;
128
+ // A count of tests is printed only when tests were counted: files
129
+ // whose tests are not read have none to report.
130
+ // "at least": a file among them holds tests that are not listed.
131
+ const conditional = result.conditionalTests ? ` (${result.conditionalTests} conditional)` : '';
132
+ lines.push(result.totalTests > 0
133
+ ? `Tests potentially affected: ${result.moreTests ? 'at least ' : ''}${result.totalTests} in ${files} file${files === 1 ? '' : 's'}${conditional}`
134
+ : `Test files potentially affected: ${files}${result.moreTests ? ' (tests not listed)' : ''}`);
77
135
  for (const tf of result.testFiles.slice(0, 8)) {
78
- const cnt = tf.testCount ? ` (${tf.testCount})` : '';
136
+ const cnt = tf.testCount ? ` (${tf.moreTests ? 'at least ' : ''}${tf.testCount})`
137
+ : tf.moreTests ? ' (tests not listed)' : '';
79
138
  lines.push(` ${tf.file}${cnt}`);
80
139
  }
81
140
  if (result.testFiles.length > 8) {
@@ -84,6 +143,11 @@ function formatCheck(result) {
84
143
  } else {
85
144
  lines.push('Tests: none detected for changed functions');
86
145
  }
146
+ if (result.supportFiles && result.supportFiles.length > 0) {
147
+ const names = result.supportFiles.slice(0, 5).map(row => row.file).join(', ');
148
+ const more = result.supportFiles.length > 5 ? `, +${result.supportFiles.length - 5} more` : '';
149
+ lines.push(`Test support code reached (declares no test, runs with the tests that load it): ${names}${more}`);
150
+ }
87
151
 
88
152
  // Action items
89
153
  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.
@@ -15,10 +15,11 @@
15
15
  'use strict';
16
16
 
17
17
  const { CALLABLE_SYMBOL_KINDS } = require('../shared');
18
- const { formatSurfaceMessage } = require('../registry');
18
+ const { CANONICAL_COMMANDS, FLAG_APPLICABILITY, formatSurfaceMessage } = require('../registry');
19
19
  const { formatAccountLines, formatCalleeAccountLine } = require('./analysis');
20
20
 
21
- const LINES_COMMANDS = new Set(['find', 'usages', 'search', 'show', 'impact']);
21
+ // The commands that take `lines`: the registry's applicability.
22
+ const LINES_COMMANDS = new Set(CANONICAL_COMMANDS.filter(command => FLAG_APPLICABILITY[command].includes('lines')));
22
23
 
23
24
  function record(pathLike, line, text, tag = '') {
24
25
  // One record per line is the contract: a multi-line signature or a
@@ -304,4 +305,63 @@ function formatPublicRaw(result, execution = {}) {
304
305
  return code;
305
306
  }
306
307
 
307
- module.exports = { LINES_COMMANDS, formatPublicLines, formatPublicRaw, commentLines };
308
+ /**
309
+ * A line listing cut to `limit` characters: the records that fit, in their
310
+ * order, then every comment line, then one `# truncated` line counting the
311
+ * records left out. A record is never cut and a comment line is never
312
+ * dropped: the accounting still describes the whole answer. Null when the
313
+ * listing has no record to drop or when not one record fits beside the
314
+ * comment lines. `allowNoRows` may return no rows, but still keeps every
315
+ * comment; if that cannot fit, it explicitly withholds the whole answer.
316
+ */
317
+ function budgetLineListing(text, limit, surface = 'cli', { allowNoRows = false } = {}) {
318
+ const lines = String(text).split('\n').filter(line => line !== '');
319
+ const records = lines.filter(line => !line.startsWith('# '));
320
+ const comments = lines.filter(line => line.startsWith('# '));
321
+ if (records.length === 0 && !allowNoRows) return null;
322
+ const advice = surface === 'mcp'
323
+ ? 'raise max_chars or narrow the query'
324
+ : 'raise --max-chars or omit it';
325
+ const trailer = omitted => `# truncated: ${omitted} of ${records.length} rows omitted; ${advice}`;
326
+ // The most records whose text, with every comment line and the trailer
327
+ // that counts the rest, is within the limit.
328
+ const fixed = comments.reduce((sum, line) => sum + line.length + 1, 0);
329
+ const withheld = () => {
330
+ const flag = surface === 'mcp' ? 'max_chars' : '--max-chars';
331
+ const needed = Math.min(String(text).length, fixed + trailer(records.length).length);
332
+ const notices = [
333
+ `# Answer withheld: accounting does not fit; raise ${flag} to at least ${needed}.`,
334
+ `# Answer withheld; ${flag}>=${needed}`,
335
+ `# withheld; >=${needed}`,
336
+ ];
337
+ return {
338
+ text: notices.find(notice => notice.length <= limit) || '',
339
+ rows: records.length, omitted: records.length, contractMetadataComplete: false,
340
+ };
341
+ };
342
+ if (records.length === 0) return withheld();
343
+ const prefix = [0];
344
+ for (const record of records) prefix.push(prefix[prefix.length - 1] + record.length + 1);
345
+ let kept = 0;
346
+ for (let count = records.length - 1; count >= 1; count--) {
347
+ if (prefix[count] + fixed + trailer(records.length - count).length <= limit) { kept = count; break; }
348
+ }
349
+ if (kept === 0) {
350
+ // No row fits. Never present a partial accounting as the complete
351
+ // answer: all comment lines accompany the row count, or the entire
352
+ // answer is withheld explicitly.
353
+ const all = trailer(records.length);
354
+ if (!allowNoRows) return null;
355
+ if (fixed + all.length > limit) return withheld();
356
+ return { text: [...comments, all].join('\n'), rows: records.length, omitted: records.length,
357
+ contractMetadataComplete: true };
358
+ }
359
+ return {
360
+ text: [...records.slice(0, kept), ...comments, trailer(records.length - kept)].join('\n'),
361
+ rows: records.length,
362
+ omitted: records.length - kept,
363
+ contractMetadataComplete: true,
364
+ };
365
+ }
366
+
367
+ module.exports = { LINES_COMMANDS, formatPublicLines, formatPublicRaw, commentLines, budgetLineListing };
@@ -10,10 +10,12 @@
10
10
 
11
11
  const { COMMAND_CONTRACTS } = require('../command-contracts');
12
12
  const { COMMAND_TRUST_MATRIX } = require('../trust-matrix');
13
- const { toCliName, toMcpName, formatSurfaceMessage } = require('../registry');
13
+ const { toCliName, toMcpName, formatSurfaceMessage, paramDefault } = require('../registry');
14
14
  const { LINES_COMMANDS, formatPublicLines, formatPublicRaw } = require('./lines');
15
15
  const { provenanceReplacer } = require('./provenance');
16
16
 
17
+ const { summarizeReasons } = require('../evidence-bands');
18
+
17
19
  const legacy = {
18
20
  ...require('./analysis'),
19
21
  ...require('./analysis-ext'),
@@ -163,11 +165,12 @@ function formatShow(result, params = {}, hints = presentationHints()) {
163
165
  if (result.context) {
164
166
  const context = { ...result.context };
165
167
  const formatted = legacy.formatContext(context, {
166
- showConfidence: params.showConfidence !== false,
167
- compact: params.compact !== false,
168
+ showConfidence: params.showConfidence ?? paramDefault('showConfidence'),
169
+ compact: params.compact ?? paramDefault('compact', 'show'),
168
170
  expandHint: hints.source,
169
171
  allHint: hints.all,
170
172
  usagesHint: hints.usages(context.function || result.target),
173
+ evidenceBands: hints.evidenceBands,
171
174
  });
172
175
  parts.push(block('RELATIONSHIPS', projectContextText(formatted.text, selected)));
173
176
  }
@@ -227,10 +230,14 @@ function formatTrace(result, params = {}, hints = presentationHints()) {
227
230
  });
228
231
  }
229
232
 
230
- function formatImpact(result, params = {}) {
233
+ function formatImpact(result, params = {}, hints = presentationHints()) {
234
+ // `--all` prints every unverified row, `--top` (or `--limit`) that many.
235
+ const top = Number(params.limit) > 0 ? Number(params.limit) : Number(params.top) > 0 ? Number(params.top) : undefined;
236
+ const rows = { all: !!params.all, ...(Number.isInteger(top) && { top }), allHint: hints.all,
237
+ evidenceBands: hints.evidenceBands };
231
238
  return modeOf('impact', result) === 'diff'
232
- ? legacy.formatDiffImpact(result, { all: params.all })
233
- : legacy.formatImpact(result, { compact: params.compact !== false });
239
+ ? legacy.formatDiffImpact(result, rows)
240
+ : legacy.formatImpact(result, { compact: params.compact ?? paramDefault('compact', 'impact'), ...rows });
234
241
  }
235
242
 
236
243
  function formatTests(result, params = {}, hints = presentationHints()) {
@@ -299,11 +306,13 @@ function formatPublicText(command, result, params = {}, execution = {}) {
299
306
  if (shaped != null) return shaped;
300
307
  }
301
308
  const hints = presentationHints(execution.surface);
309
+ // Display only: another band table relabels the reason summaries.
310
+ if (execution.evidenceBands) hints.evidenceBands = execution.evidenceBands;
302
311
  if (result?.scopeWarning?.hint) {
303
- result.scopeWarning = {
312
+ result = { ...result, scopeWarning: {
304
313
  ...result.scopeWarning,
305
314
  hint: formatSurfaceMessage(result.scopeWarning.hint, execution.surface),
306
- };
315
+ } };
307
316
  }
308
317
  let text;
309
318
  switch (command) {
@@ -312,7 +321,7 @@ function formatPublicText(command, result, params = {}, execution = {}) {
312
321
  text = modeOf('find', result) === 'type'
313
322
  ? legacy.formatTypedef(result, params.name)
314
323
  : legacy.formatFindDetailed(result, params.name, {
315
- compact: params.compact,
324
+ compact: params.compact ?? paramDefault('compact', 'find'),
316
325
  withSource: params.withSource,
317
326
  top: params.limit,
318
327
  all: !!params.all,
@@ -320,7 +329,7 @@ function formatPublicText(command, result, params = {}, execution = {}) {
320
329
  });
321
330
  break;
322
331
  case 'usages': text = legacy.formatUsages(result, params.name, {
323
- compact: params.compact,
332
+ compact: params.compact ?? paramDefault('compact', 'usages'),
324
333
  all: params.all,
325
334
  allHint: hints.all,
326
335
  }); break;
@@ -341,7 +350,7 @@ function formatPublicText(command, result, params = {}, execution = {}) {
341
350
  break;
342
351
  case 'source': text = formatSource(result, hints); break;
343
352
  case 'trace': text = formatTrace(result, params, hints); break;
344
- case 'impact': text = formatImpact(result, params); break;
353
+ case 'impact': text = formatImpact(result, params, hints); break;
345
354
  case 'tests': text = formatTests(result, params, hints); break;
346
355
  case 'deps': text = formatDeps(result, params, hints); break;
347
356
  case 'api': text = legacy.formatApi(result, params.file || '.'); break;
@@ -413,6 +422,8 @@ function formatPublicJson(command, result, params = {}, execution = {}) {
413
422
  commandMeta.excludedConstructors = result.excludedConstructors || 0;
414
423
  commandMeta.pythonImplicitExportFiles = result.pythonImplicitExportFiles || 0;
415
424
  commandMeta.excludedDynamicDispatch = result.excludedDynamicDispatch || 0;
425
+ commandMeta.excludedRequiredSlot = result.excludedRequiredSlot || 0;
426
+ commandMeta.projectKind = result.projectKind || 'library';
416
427
  commandMeta.computedDispatch = result.computedDispatch || { count: 0, names: [] };
417
428
  commandMeta.reflection = result.reflection || {
418
429
  count: 0, literalCount: 0, dynamicCount: 0,
@@ -468,6 +479,13 @@ function formatPublicJson(command, result, params = {}, execution = {}) {
468
479
  if (command === 'usages' && result?.analysisGaps) {
469
480
  commandMeta.analysisGaps = result.analysisGaps;
470
481
  }
482
+ // The reasons of the unverified callers with how often each was a real
483
+ // edge on the release board: labels beside the data, never part of it.
484
+ const unverifiedCallers = command === 'impact' ? result?.unverifiedSites
485
+ : command === 'show' ? result?.context?.unverifiedCallers : null;
486
+ if (Array.isArray(unverifiedCallers) && unverifiedCallers.length > 0) {
487
+ commandMeta.unverifiedReasons = summarizeReasons(unverifiedCallers, execution.evidenceBands);
488
+ }
471
489
 
472
490
  const surfaceCommand = execution.surface === 'mcp'
473
491
  ? toMcpName(command)
@@ -71,10 +71,42 @@ function groupedReviewLines(items, cap = 20) {
71
71
  * Format plan command output - text
72
72
  * Shows before/after signatures and all changes needed
73
73
  */
74
+ /**
75
+ * A deletion assessment: the verdict, its basis and where the declaration
76
+ * is. What the verdict never means (a guarantee) is stated once, in the
77
+ * tool description and the usage guide.
78
+ */
79
+ function formatDeletion(assessment) {
80
+ const d = assessment.declaration;
81
+ const owner = d.className ? `${d.className}.` : '';
82
+ const lines = [
83
+ `Deletion assessment: ${owner}${d.name}`,
84
+ '═'.repeat(60),
85
+ `${d.file}:${d.startLine}${d.type ? ` (${d.type})` : ''}`,
86
+ ...(assessment.warnings || []).map(warning => `Note: ${warning.message}`),
87
+ `VERDICT: ${assessment.verdict}`,
88
+ ` basis: ${assessment.basis}`,
89
+ ];
90
+ if (assessment.site) {
91
+ const site = assessment.site;
92
+ lines.push(` first use: ${site.file}${site.line != null ? `:${site.line}` : ''}`);
93
+ }
94
+ if (assessment.projectKind === 'application') {
95
+ lines.push(' project kind: application (.ucn.json): exported symbols are assessed');
96
+ }
97
+ if (assessment.verdict === 'has-static-dependents') {
98
+ lines.push(` next: ucn show ${d.file}:${d.startLine}:${d.name} lists the callers; ucn usages ${d.name} lists every occurrence`);
99
+ } else if (assessment.reason === 'downstream-exposure') {
100
+ lines.push(' next: declare "kind": "application" in .ucn.json if nothing outside this repository consumes it');
101
+ }
102
+ return lines.join('\n');
103
+ }
104
+
74
105
  function formatPlan(plan, options = {}) {
75
106
  if (!plan) {
76
107
  return 'Function not found.';
77
108
  }
109
+ if (plan.operation === 'delete') return formatDeletion(plan);
78
110
  if (!plan.found) {
79
111
  return `Function "${plan.function}" not found.`;
80
112
  }
@@ -215,8 +215,11 @@ function formatDeadcode(results, options = {}) {
215
215
  if (results.length === 0 && !results.excludedDecorated &&
216
216
  !results.excludedExported && !results.excludedExternalContract &&
217
217
  !results.excludedRuntimeContract && !results.excludedConstructors && !results.pythonImplicitExportFiles &&
218
- !results.excludedDynamicDispatch && !results.computedDispatch?.count &&
218
+ !results.excludedDynamicDispatch && !results.excludedRequiredSlot &&
219
+ results.projectKind !== 'application' && !results.computedDispatch?.count &&
219
220
  !results.reflection?.withheld && !results.reflection?.dynamicCount && !results.reflection?.literalCount &&
221
+ !results.reflection?.withheldByTable?.length && !results.macroPaste?.withheld &&
222
+ !results.macroPaste?.blind?.count && !results.macroRulesBlind?.count &&
220
223
  results.coverage?.complete !== false) {
221
224
  return 'No dead code found.';
222
225
  }
@@ -232,6 +235,8 @@ function formatDeadcode(results, options = {}) {
232
235
  } else {
233
236
  lines.push(`Dead code: ${results.length} unused symbol(s)\n`);
234
237
  }
238
+ } else {
239
+ lines.push('Dead code: 0 unused symbol(s)\n');
235
240
  }
236
241
 
237
242
  let currentFile = null;
@@ -301,6 +306,12 @@ function formatDeadcode(results, options = {}) {
301
306
  if (results.excludedDynamicDispatch > 0) {
302
307
  lines.push(`\n${results.excludedDynamicDispatch} registry member(s) hidden because a matching computed dispatch (registry[key]()) may invoke them.`);
303
308
  }
309
+ if (results.excludedRequiredSlot > 0) {
310
+ lines.push(`\n${results.excludedRequiredSlot} member(s) withheld: required by an interface, abstract class or trait of the project, overridden by a descendant that says so, or matching a project interface or protocol by shape. Removing one can break a type.`);
311
+ }
312
+ if (results.projectKind === 'application') {
313
+ lines.push('\nProject kind: application (.ucn.json). Exported symbols are audited; contracts, registrations and reflection still withhold.');
314
+ }
304
315
  if (results.computedDispatch?.count > 0) {
305
316
  lines.push(`\nWARNING: ${results.computedDispatch.count} computed dispatch call(s) in ${results.computedDispatch.fileCount} file(s). Dead-code results are review candidates; runtime-selected members may not have a named static edge.`);
306
317
  }
@@ -379,6 +390,8 @@ function formatDeadcodeJson(results) {
379
390
  ...(results.excludedConstructors > 0 && { excludedConstructors: results.excludedConstructors }),
380
391
  ...(results.pythonImplicitExportFiles > 0 && { pythonImplicitExportFiles: results.pythonImplicitExportFiles }),
381
392
  ...(results.excludedDynamicDispatch > 0 && { excludedDynamicDispatch: results.excludedDynamicDispatch }),
393
+ ...(results.excludedRequiredSlot > 0 && { excludedRequiredSlot: results.excludedRequiredSlot }),
394
+ ...(results.projectKind === 'application' && { projectKind: results.projectKind }),
382
395
  ...(results.computedDispatch?.count > 0 && { computedDispatch: results.computedDispatch }),
383
396
  ...(results.reflection?.count > 0 && { reflection: results.reflection }),
384
397
  ...(results.macroPaste && { macroPaste: results.macroPaste }),
@@ -573,7 +586,11 @@ function formatOrient(result, options = {}) {
573
586
  if (result.skippedSources?.count > 0) {
574
587
  const reasons = Object.entries(result.skippedSources.reasons || {})
575
588
  .map(([reason, count]) => `${reason} ${count}`).join(', ');
576
- lines.push(`SKIPPED SOURCE: ${result.skippedSources.count} path(s) were not indexed${reasons ? ` (${reasons})` : ''} — completeness claims are withdrawn.`);
589
+ const unsearchable = result.skippedSources.unsearchable || 0;
590
+ lines.push(`SKIPPED SOURCE: ${result.skippedSources.count} path(s) not indexed${reasons ? ` (${reasons})` : ''} — ` +
591
+ (unsearchable > 0
592
+ ? `${unsearchable} could not be searched, so no caller account is complete.`
593
+ : 'searched as text for each name; an occurrence there is listed, not analyzed.'));
577
594
  }
578
595
  lines.push('');
579
596
 
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  const { detectDoubleEscaping, advisoryLine } = require('./shared');
6
+ const { selectorText, testCountText } = require('../test-units');
6
7
 
7
8
  /**
8
9
  * Format search command output
@@ -419,15 +420,26 @@ function formatTests(tests, name) {
419
420
  }
420
421
 
421
422
  for (const testFile of tests) {
422
- lines.push(testFile.file);
423
+ const counted = testCountText(testFile.tests, testFile.moreTests, testFile.conditionalTests);
424
+ const facts = testFile.role === 'support' ? ' [support: declares no test]'
425
+ : counted ? ` (${counted})` : '';
426
+ lines.push(`${testFile.file}${facts}`);
423
427
  for (const match of testFile.matches) {
424
428
  const typeLabel = match.matchType === 'test-case' ? '[test]' :
425
429
  match.matchType === 'import' ? '[import]' :
426
430
  match.matchType === 'call' ? '[call]' :
427
431
  match.matchType === 'unverified-call' ? '[unverified-call]' :
428
432
  match.matchType === 'unverified-reference' ? '[unverified-ref]' :
433
+ match.matchType === 'inherited-test' ? '[inherited]' :
429
434
  match.matchType === 'string-ref' ? '[string]' : '[ref]';
430
- lines.push(` ${match.line}: ${typeLabel} ${match.content}`);
435
+ // A test the class inherits: its name, and where its body is.
436
+ const origin = match.matchType === 'inherited-test' ? ` ${match.name} (from ${match.from})` : '';
437
+ lines.push(` ${match.line}: ${typeLabel} ${match.content}${origin}`);
438
+ }
439
+ if (testFile.run) lines.push(` run: ${selectorText(testFile.run)}`);
440
+ else if (testFile.role === 'tests' && testFile.runWithheld) lines.push(` no command: ${testFile.runWithheld}`);
441
+ for (const unit of testFile.units || []) {
442
+ if (unit.selector) lines.push(` run test: ${selectorText(unit.selector)}`);
431
443
  }
432
444
  lines.push('');
433
445
  }