ucn 5.5.3 → 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 (71) hide show
  1. package/.claude/skills/ucn/SKILL.md +35 -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 +474 -0
  6. package/README.md +69 -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 +273 -107
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +14 -0
  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/command-contracts.js +7 -6
  19. package/core/constructed-class.js +1195 -0
  20. package/core/contract-membership.js +142 -0
  21. package/core/deadcode.js +362 -84
  22. package/core/deletion.js +401 -0
  23. package/core/discovery.js +104 -9
  24. package/core/entrypoints.js +12 -6
  25. package/core/evidence-bands.js +68 -0
  26. package/core/evidence-bands.json +44 -0
  27. package/core/execute.js +172 -161
  28. package/core/hot-summary.js +241 -0
  29. package/core/index-ir.js +3 -1
  30. package/core/ir.js +5 -1
  31. package/core/member-signatures.js +789 -0
  32. package/core/output/analysis-ext.js +20 -10
  33. package/core/output/analysis.js +98 -60
  34. package/core/output/check.js +98 -37
  35. package/core/output/doctor.js +34 -1
  36. package/core/output/find.js +2 -1
  37. package/core/output/lines.js +63 -3
  38. package/core/output/public.js +29 -11
  39. package/core/output/refactoring.js +32 -0
  40. package/core/output/reporting.js +15 -2
  41. package/core/output/search.js +11 -2
  42. package/core/output/tracing.js +35 -7
  43. package/core/output-budget.js +124 -57
  44. package/core/parallel-build.js +3 -3
  45. package/core/project.js +106 -23
  46. package/core/provenance-type.js +12 -5
  47. package/core/public-command.js +35 -9
  48. package/core/python-modules.js +6 -0
  49. package/core/reflection.js +151 -23
  50. package/core/registry.js +623 -72
  51. package/core/reporting.js +69 -34
  52. package/core/review-items.js +75 -0
  53. package/core/search.js +89 -128
  54. package/core/shared.js +0 -45
  55. package/core/snapshot.js +350 -0
  56. package/core/source-universe.js +707 -0
  57. package/core/test-units.js +3624 -0
  58. package/core/tracing.js +150 -96
  59. package/core/verify.js +27 -11
  60. package/languages/adapter.js +8 -3
  61. package/languages/c-family.js +12 -0
  62. package/languages/csharp.js +634 -0
  63. package/languages/go.js +120 -0
  64. package/languages/index.js +422 -4
  65. package/languages/java.js +712 -0
  66. package/languages/javascript.js +1460 -2
  67. package/languages/lexical-scope.js +68 -0
  68. package/languages/python.js +1672 -1
  69. package/languages/rust.js +91 -0
  70. package/mcp/server.js +44 -110
  71. package/package.json +10 -5
@@ -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,7 +215,8 @@ 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 &&
220
221
  results.coverage?.complete !== false) {
221
222
  return 'No dead code found.';
@@ -301,6 +302,12 @@ function formatDeadcode(results, options = {}) {
301
302
  if (results.excludedDynamicDispatch > 0) {
302
303
  lines.push(`\n${results.excludedDynamicDispatch} registry member(s) hidden because a matching computed dispatch (registry[key]()) may invoke them.`);
303
304
  }
305
+ if (results.excludedRequiredSlot > 0) {
306
+ 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.`);
307
+ }
308
+ if (results.projectKind === 'application') {
309
+ lines.push('\nProject kind: application (.ucn.json). Exported symbols are audited; contracts, registrations and reflection still withhold.');
310
+ }
304
311
  if (results.computedDispatch?.count > 0) {
305
312
  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
313
  }
@@ -379,6 +386,8 @@ function formatDeadcodeJson(results) {
379
386
  ...(results.excludedConstructors > 0 && { excludedConstructors: results.excludedConstructors }),
380
387
  ...(results.pythonImplicitExportFiles > 0 && { pythonImplicitExportFiles: results.pythonImplicitExportFiles }),
381
388
  ...(results.excludedDynamicDispatch > 0 && { excludedDynamicDispatch: results.excludedDynamicDispatch }),
389
+ ...(results.excludedRequiredSlot > 0 && { excludedRequiredSlot: results.excludedRequiredSlot }),
390
+ ...(results.projectKind === 'application' && { projectKind: results.projectKind }),
382
391
  ...(results.computedDispatch?.count > 0 && { computedDispatch: results.computedDispatch }),
383
392
  ...(results.reflection?.count > 0 && { reflection: results.reflection }),
384
393
  ...(results.macroPaste && { macroPaste: results.macroPaste }),
@@ -573,7 +582,11 @@ function formatOrient(result, options = {}) {
573
582
  if (result.skippedSources?.count > 0) {
574
583
  const reasons = Object.entries(result.skippedSources.reasons || {})
575
584
  .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.`);
585
+ const unsearchable = result.skippedSources.unsearchable || 0;
586
+ lines.push(`SKIPPED SOURCE: ${result.skippedSources.count} path(s) not indexed${reasons ? ` (${reasons})` : ''} — ` +
587
+ (unsearchable > 0
588
+ ? `${unsearchable} could not be searched, so no caller account is complete.`
589
+ : 'searched as text for each name; an occurrence there is listed, not analyzed.'));
577
590
  }
578
591
  lines.push('');
579
592
 
@@ -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,16 +420,24 @@ 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}`);
431
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}`);
432
441
  lines.push('');
433
442
  }
434
443
  }
@@ -8,6 +8,7 @@
8
8
  * conservation) lines close the arithmetic.
9
9
  */
10
10
 
11
+ const { testCountText } = require('../test-units');
11
12
  const { unverifiedReasonLabel } = require('./shared');
12
13
  const { codeUnitCompare } = require('../shared');
13
14
  const { formatAccountLines } = require('./analysis');
@@ -562,23 +563,49 @@ function formatAffectedTests(result, options = {}) {
562
563
  lines.push('No confirmed static test links found for any affected function.');
563
564
  } else {
564
565
  const MAX_TEST_FILES = options.all ? Infinity : 30;
565
- const displayFiles = result.testFiles.slice(0, MAX_TEST_FILES);
566
- const truncatedFiles = result.testFiles.length - displayFiles.length;
567
- lines.push(`Test files to run (${summary.totalTestFiles}):`);
566
+ const runnable = result.testFiles.filter(tf => tf.role !== 'support');
567
+ const support = result.testFiles.filter(tf => tf.role === 'support');
568
+ const displayFiles = runnable.slice(0, MAX_TEST_FILES);
569
+ const truncatedFiles = runnable.length - displayFiles.length;
570
+ // A count is exact only where every test of the files is listed:
571
+ // "at least" says a file holds tests that are not.
572
+ const total = testCountText(summary.totalTests, summary.moreTests, summary.conditionalTests);
573
+ lines.push(`Test files to run (${runnable.length})${total ? `, ${total}` : ''}:`);
568
574
  lines.push('');
569
575
  for (const tf of displayFiles) {
570
- lines.push(` ${tf.file} (links: ${linkList(tf.linkedFunctions, options)})`);
576
+ const counted = testCountText(tf.testCount, tf.moreTests, tf.conditionalTests);
577
+ const count = counted ? ` · ${counted}` : '';
578
+ const refusal = tf.runWithheld ? ` · no command: ${tf.runWithheld}` : '';
579
+ lines.push(` ${tf.file} (links: ${linkList(tf.linkedFunctions, options)})${count}${refusal}`);
571
580
  // Show up to 5 key matches per file
572
581
  const keyMatches = tf.matches
573
- .filter(m => m.matchType === 'call' || m.matchType === 'test-case')
582
+ .filter(m => m.matchType === 'call' || m.matchType === 'test-case' || m.matchType === 'inherited-test')
574
583
  .slice(0, 5);
575
584
  for (const m of keyMatches) {
576
- lines.push(` L${m.line}: ${m.content} [${m.matchType}]`);
585
+ const origin = m.matchType === 'inherited-test' ? ` ${m.name} (from ${m.from})` : '';
586
+ lines.push(` L${m.line}: ${m.content}${origin} [${m.matchType}]`);
577
587
  }
578
588
  }
579
589
  if (truncatedFiles > 0) {
580
590
  lines.push(`\n ... ${truncatedFiles} more test files (use file= and exclude= to narrow scope)`);
581
591
  }
592
+ if (support.length > 0) {
593
+ lines.push('');
594
+ lines.push(`Test support code reached (${support.length}) — declares no test, runs with the tests that load it:`);
595
+ for (const tf of support.slice(0, options.all ? Infinity : 10)) {
596
+ lines.push(` ${tf.file} (links: ${linkList(tf.linkedFunctions, options)})`);
597
+ }
598
+ }
599
+ const commands = result.commands || [];
600
+ if (commands.length > 0) {
601
+ lines.push('');
602
+ lines.push('Run:');
603
+ const MAX_COMMANDS = options.all ? Infinity : 5;
604
+ for (const line of commands.slice(0, MAX_COMMANDS)) lines.push(` ${line}`);
605
+ if (commands.length > MAX_COMMANDS) {
606
+ lines.push(` ... ${commands.length - MAX_COMMANDS} more (${options.allHint || 'use --all'})`);
607
+ }
608
+ }
582
609
  }
583
610
 
584
611
  // Possible band: functions reachable only through unverified chains.
@@ -614,7 +641,8 @@ function formatAffectedTests(result, options = {}) {
614
641
  const pct = summary.totalAffected > 0
615
642
  ? Math.round(summary.staticallyLinkedFunctions / summary.totalAffected * 100)
616
643
  : 0;
617
- let summaryLine = `Summary: ${summary.totalAffected} affected → ${summary.totalTestFiles} statically linked test files, ${summary.staticallyLinkedFunctions}/${summary.totalAffected} functions linked (${pct}%)`;
644
+ const totalTests = testCountText(summary.totalTests, summary.moreTests, summary.conditionalTests);
645
+ let summaryLine = `Summary: ${summary.totalAffected} affected → ${summary.totalTestFiles} statically linked test files${totalTests ? ` (${totalTests})` : ''}, ${summary.staticallyLinkedFunctions}/${summary.totalAffected} functions linked (${pct}%)`;
618
646
  if (summary.possiblyAffected > 0) {
619
647
  summaryLine += ` · ${summary.possiblyAffected} possibly affected (unverified chains)`;
620
648
  }