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
@@ -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.
@@ -592,7 +619,7 @@ function formatAffectedTests(result, options = {}) {
592
619
  }
593
620
  const pat = result.possiblyAffectedTests || [];
594
621
  if (pat.length > 0) {
595
- lines.push(` Additional test files (${pat.length}):`);
622
+ lines.push(` Test files with possible links (${pat.length}):`);
596
623
  const MAX_POSSIBLE = options.all ? Infinity : 10;
597
624
  for (const tf of pat.slice(0, MAX_POSSIBLE)) {
598
625
  lines.push(` ${tf.file} (links: ${linkList(tf.linkedFunctions, options)})`);
@@ -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
  }
@@ -3,22 +3,27 @@
3
3
  const {
4
4
  BROAD_COMMANDS: BROAD_CANONICAL,
5
5
  FLAG_APPLICABILITY,
6
+ paramDefault,
7
+ paramRange,
6
8
  resolveCommand,
7
- toMcpName,
8
9
  } = require('./registry');
10
+ const { LINES_COMMANDS, budgetLineListing } = require('./output/lines');
9
11
 
10
- const DEFAULT_OUTPUT_CHARS = 10000;
11
- const BROAD_OUTPUT_CHARS = 3000;
12
- const MAX_OUTPUT_CHARS = 100000;
13
- const BROAD_COMMANDS = new Set([
14
- ...BROAD_CANONICAL,
15
- ...[...BROAD_CANONICAL].map(toMcpName),
16
- ]);
12
+ // The output budget is the parameter table's `maxChars`: its ceiling, its
13
+ // default, and the smaller default of the broad sweep commands.
14
+ const DEFAULT_OUTPUT_CHARS = paramDefault('maxChars');
15
+ const BROAD_OUTPUT_CHARS = paramDefault('maxChars', [...BROAD_CANONICAL][0]);
16
+ const MAX_OUTPUT_CHARS = paramRange('maxChars').max;
17
17
 
18
- const CONTRACT_LINE_RE = /^\s*(?:(?:Summary|ACCOUNT|CONTRACT|WARNING|FILTERED|CALLEE ACCOUNT|TREE ACCOUNT|VALUE ACCOUNT|TYPE ACCOUNT|Note):|\d+ test-file usage\(s\) hidden\b|Found \d+ (?:definitions|fuzzy matches)\b)/;
18
+ const CONTRACT_LINE_RE = /^\s*(?:(?:Summary|ACCOUNT|CONTRACT|WARNING|FILTERED|CALLEE ACCOUNT|TREE ACCOUNT|VALUE ACCOUNT|TYPE ACCOUNT|Note):|TRUST: (?:BLOCKED|REVIEW_REQUIRED|READY_FOR_TOOLCHAIN)\b|\d+ test-file usage\(s\) hidden\b|Found \d+ (?:definitions|fuzzy matches)\b)/;
19
19
  // Trust lines: the accounting and contract lines that qualify a caller
20
20
  // answer. A truncated answer carries every one of them or none of its body.
21
- const TRUST_LINE_RE = /^(?:ACCOUNT|CONTRACT|WARNING|FILTERED|CALLEE ACCOUNT|TREE ACCOUNT|VALUE ACCOUNT|TYPE ACCOUNT):/;
21
+ // The pre-commit check's verdict line is one of them (`repo` prints a
22
+ // TRUST level of its own, which is orientation, not a verdict).
23
+ const TRUST_LINE_RE = /^(?:(?:ACCOUNT|CONTRACT|WARNING|FILTERED|CALLEE ACCOUNT|TREE ACCOUNT|VALUE ACCOUNT|TYPE ACCOUNT):|TRUST: (?:BLOCKED|REVIEW_REQUIRED|READY_FOR_TOOLCHAIN)\b)/;
24
+ // A count or deletion verdict is the answer the warnings qualify. Preserve
25
+ // it with those warnings, or explicitly withhold the answer when both cannot fit.
26
+ const RESULT_LINE_RE = /^(?:Dead code: \d+ unused symbol\(s\)|No dead code found\.|VERDICT: (?:no-static-dependents|has-static-dependents|review)\b)/;
22
27
  const MAX_PRESERVED_CONTRACT_LINES = 24;
23
28
  const MAX_PRESERVED_CONTRACT_CHARS = 8000;
24
29
 
@@ -43,10 +48,10 @@ function contractCandidates(fullText) {
43
48
  // Shell-shaped MCP/interactive results prefix disclosures with '# '.
44
49
  // Match their contents but retain the prefix in the preserved text.
45
50
  const evidenceLine = contractLine.replace(/^\s*# /, '').trim();
46
- if (!CONTRACT_LINE_RE.test(evidenceLine)) continue;
51
+ if (!CONTRACT_LINE_RE.test(evidenceLine) && !RESULT_LINE_RE.test(evidenceLine)) continue;
47
52
  const line = contractLine.trim();
48
53
  if (!line) continue;
49
- const priority = TRUST_LINE_RE.test(evidenceLine)
54
+ const priority = TRUST_LINE_RE.test(evidenceLine) || RESULT_LINE_RE.test(evidenceLine)
50
55
  ? 0
51
56
  : /^\d+ test-file usage\(s\) hidden\b/.test(evidenceLine) ? 1 : 2;
52
57
  candidates.push({ line, priority, sourceIndex });
@@ -139,7 +144,9 @@ function compactNarrowingHint(command, surface, params = {}) {
139
144
  // (`CALLERS — CONFIRMED (12, ...)`, `CALLERS — RUNTIME DISPATCH (332 sites`,
140
145
  // `UNVERIFIED CALL SITES (627) —`, `CALLEES (3):`, `BY FILE:`).
141
146
  const SECTION_HEADER_RE = /^(?:[A-Z][A-Z-]*(?: [A-Z][A-Z-]*)*(?: — [A-Z][A-Z -]*)? \(\d[^)]*\)|[A-Z][A-Z ]+:$)/;
142
- const SECTION_AWARE_COMMANDS = new Set(['show', 'impact']);
147
+ // `check` groups its items by verdict (`BLOCKED (2) — ...`): a long list of
148
+ // breaks must not push what is ready, or what to review, out of the answer.
149
+ const SECTION_AWARE_COMMANDS = new Set(['show', 'impact', 'check']);
143
150
 
144
151
  /**
145
152
  * Cut a relationship answer to `budget` chars keeping a representative head
@@ -152,7 +159,7 @@ const SECTION_AWARE_COMMANDS = new Set(['show', 'impact']);
152
159
  * no sections or its fixed parts alone do not fit (caller falls back to a
153
160
  * head cut).
154
161
  */
155
- function sectionAwareCut(text, budget) {
162
+ function sectionAwareCut(text, budget, lineCost = line => line.length) {
156
163
  const lines = text.split('\n');
157
164
  const blocks = []; // { kind: 'fixed', line } | { kind: 'section', header, body }
158
165
  let current = null;
@@ -177,7 +184,7 @@ function sectionAwareCut(text, budget) {
177
184
  const gapLine = ' ...';
178
185
  let used = 0;
179
186
  for (const block of blocks) {
180
- used += (block.kind === 'fixed' ? block.line.length : block.header.length) + 1;
187
+ used += lineCost(block.kind === 'fixed' ? block.line : block.header) + 1;
181
188
  }
182
189
  // Reserve the worst-case `+N more` line and one gap marker per section.
183
190
  for (const section of sections) {
@@ -212,7 +219,7 @@ function sectionAwareCut(text, budget) {
212
219
  const state = orders.get(section);
213
220
  if (state.next >= state.order.length) continue;
214
221
  const index = state.order[state.next];
215
- const cost = section.body[index].length + 1;
222
+ const cost = lineCost(section.body[index]) + 1;
216
223
  if (used + cost > budget) { state.next = state.order.length; continue; }
217
224
  used += cost;
218
225
  state.taken.add(index);
@@ -249,13 +256,31 @@ function applyOutputBudget(text, {
249
256
  surface = 'cli',
250
257
  params = {},
251
258
  trailingChars = 0,
259
+ peerText,
252
260
  } = {}) {
253
- const defaultLimit = BROAD_COMMANDS.has(command)
254
- ? BROAD_OUTPUT_CHARS
255
- : DEFAULT_OUTPUT_CHARS;
261
+ const defaultLimit = paramDefault('maxChars',
262
+ resolveCommand(command, 'mcp') || resolveCommand(command, 'cli') || command);
256
263
  const requested = maxChars || (all ? MAX_OUTPUT_CHARS : defaultLimit);
257
264
  const hardLimit = Math.min(requested, MAX_OUTPUT_CHARS);
258
- const limit = Math.max(0, hardLimit - trailingChars);
265
+ // Native CLI/MCP guidance has different lengths. Both presentations
266
+ // select the same semantic lines using the larger cost at each source
267
+ // line, including room for the CLI's terminal newline. Rendered text
268
+ // keeps its own native guidance and actual full-character count.
269
+ const paired = typeof peerText === 'string';
270
+ const costs = new Map();
271
+ if (paired) {
272
+ const own = String(text || '').split('\n'), peer = peerText.split('\n');
273
+ for (let i = 0; i < own.length; i++) {
274
+ const cost = Math.max(own[i].length, (peer[i] || '').length);
275
+ costs.set(own[i], Math.max(costs.get(own[i]) || 0, cost));
276
+ costs.set(own[i].trim(), Math.max(costs.get(own[i].trim()) || 0,
277
+ Math.max(own[i].trim().length, (peer[i] || '').trim().length)));
278
+ }
279
+ }
280
+ const lineCost = line => costs.get(line) ?? line.length;
281
+ const textCost = value => value.split('\n').reduce((sum, line) => sum + lineCost(line) + 1, -1);
282
+ const terminalChars = paired ? Math.max(1, trailingChars) : trailingChars;
283
+ const limit = Math.max(0, hardLimit - terminalChars);
259
284
  if (!text) {
260
285
  return {
261
286
  text: '(no output)'.slice(0, limit),
@@ -267,7 +292,7 @@ function applyOutputBudget(text, {
267
292
  };
268
293
  }
269
294
 
270
- if (text.length <= limit) {
295
+ if (textCost(text) <= limit) {
271
296
  return {
272
297
  text,
273
298
  truncated: false,
@@ -280,25 +305,56 @@ function applyOutputBudget(text, {
280
305
 
281
306
  const canonical = resolveCommand(command, surface === 'mcp' ? 'mcp' : 'cli') ||
282
307
  command;
308
+ // A line listing is cut between records: the rows that fit, the
309
+ // accounting, and one line counting the rows left out.
310
+ if (params.lines && LINES_COMMANDS.has(canonical)) {
311
+ const listing = budgetLineListing(text, limit, surface, { allowNoRows: true });
312
+ if (listing) {
313
+ return {
314
+ text: listing.text,
315
+ truncated: true,
316
+ fullChars: text.length,
317
+ requestedLimit: hardLimit,
318
+ contractMetadata: [],
319
+ contractMetadataComplete: listing.contractMetadataComplete,
320
+ };
321
+ }
322
+ }
283
323
  const supportsAll = FLAG_APPLICABILITY[canonical]?.includes('all') &&
284
324
  !(canonical === 'deps' && params.cycles);
285
- const allHint = supportsAll
286
- ? (surface === 'mcp'
287
- ? 'Use all=true or max_chars=<n> (100K maximum).'
288
- : 'Use --all or --max-chars=N (100K maximum).')
289
- : (surface === 'mcp'
290
- ? 'Use max_chars=<n> (100K maximum).'
291
- : 'Use --max-chars=N (100K maximum).');
325
+ const noticesFor = (target, fullChars, budget = hardLimit) => {
326
+ const allHint = supportsAll
327
+ ? (target === 'mcp'
328
+ ? 'Use all=true or max_chars=<n> (100K maximum).'
329
+ : 'Use --all or --max-chars=N (100K maximum).')
330
+ : (target === 'mcp'
331
+ ? 'Use max_chars=<n> (100K maximum).'
332
+ : 'Use --max-chars=N (100K maximum).');
333
+ const raiseHint = target === 'mcp' ? 'max_chars=<n>' : '--max-chars=N';
334
+ const compactScope = compactNarrowingHint(command, target, params);
335
+ const compactGuidance = compactScope.includes(raiseHint)
336
+ ? `Raise ${raiseHint}.`
337
+ : `Narrow with ${compactScope || raiseHint}; raise ${raiseHint}.`;
338
+ return {
339
+ longNotice: `... OUTPUT TRUNCATED: ${fullChars} chars total; hard limit ${budget}. ` +
340
+ `${narrowingHint(command, target, params)} ${allHint}`,
341
+ compactNotice: `... OUTPUT TRUNCATED (${fullChars}→${budget}). ${compactGuidance}`,
342
+ shortNotice: `... OUTPUT TRUNCATED (${fullChars}→${budget}). Raise ${raiseHint}.`,
343
+ // At the tightest budgets, disclosures outrank the repeated
344
+ // raise-budget guidance; retain the full size and limit.
345
+ minimalNotice: `... OUTPUT TRUNCATED (${fullChars}→${budget}).`,
346
+ raiseHint,
347
+ };
348
+ };
349
+ const ownNotices = noticesFor(surface, text.length);
350
+ if (paired) {
351
+ const peerNotices = noticesFor(surface === 'mcp' ? 'cli' : 'mcp', peerText.length);
352
+ for (const name of ['longNotice', 'compactNotice', 'shortNotice', 'minimalNotice']) {
353
+ costs.set(ownNotices[name], Math.max(ownNotices[name].length, peerNotices[name].length));
354
+ }
355
+ }
356
+ const { longNotice, compactNotice, shortNotice, minimalNotice, raiseHint } = ownNotices;
292
357
  const compactBudget = limit < 500;
293
- const raiseHint = surface === 'mcp' ? 'max_chars=<n>' : '--max-chars=N';
294
- const compactScope = compactNarrowingHint(command, surface, params);
295
- const compactGuidance = compactScope.includes(raiseHint)
296
- ? `Raise ${raiseHint}.`
297
- : `Narrow with ${compactScope || raiseHint}; raise ${raiseHint}.`;
298
- const longNotice = `... OUTPUT TRUNCATED: ${text.length} chars total; hard limit ${hardLimit}. ` +
299
- `${narrowingHint(command, surface, params)} ${allHint}`;
300
- const compactNotice = `... OUTPUT TRUNCATED (${text.length}→${hardLimit}). ${compactGuidance}`;
301
- const shortNotice = `... OUTPUT TRUNCATED. Raise ${raiseHint}.`;
302
358
 
303
359
  // Trust lines (ACCOUNT/CONTRACT/WARNING/...) qualify the answer: room for
304
360
  // every one of them is reserved before any body text. Optional
@@ -307,7 +363,7 @@ function applyOutputBudget(text, {
307
363
  const trust = candidates.filter(candidate => candidate.priority === 0);
308
364
  const optional = candidates.filter(candidate => candidate.priority > 0)
309
365
  .sort((a, b) => a.priority - b.priority || a.sourceIndex - b.sourceIndex);
310
- const trustChars = trust.reduce((sum, candidate) => sum + candidate.line.length + 1, 0);
366
+ const trustChars = trust.reduce((sum, candidate) => sum + lineCost(candidate.line) + 1, 0);
311
367
  const result = (rendered, appended, complete) => ({
312
368
  text: rendered,
313
369
  truncated: true,
@@ -321,6 +377,16 @@ function applyOutputBudget(text, {
321
377
  // number of complete source lines it holds.
322
378
  const headCut = (budget) => {
323
379
  if (budget <= 0) return { body: '', keptLines: 0 };
380
+ if (paired) {
381
+ const kept = [];
382
+ let used = -1;
383
+ for (const line of text.split('\n')) {
384
+ if (used + lineCost(line) + 1 > budget) break;
385
+ kept.push(line);
386
+ used += lineCost(line) + 1;
387
+ }
388
+ return { body: kept.join('\n'), keptLines: kept.length };
389
+ }
324
390
  const prefix = text.slice(0, budget);
325
391
  const lastNewline = prefix.lastIndexOf('\n');
326
392
  const body = lastNewline > budget * 0.8 ? prefix.slice(0, lastNewline) : prefix;
@@ -336,18 +402,18 @@ function applyOutputBudget(text, {
336
402
  // to `optionalShare` chars (priority order) before the body; the body
337
403
  // takes the rest. Returns null when the notice and the trust lines alone
338
404
  // exceed the limit.
339
- const costOf = list => list.reduce((sum, candidate) => sum + candidate.line.length + 1, 0);
405
+ const costOf = list => list.reduce((sum, candidate) => sum + lineCost(candidate.line) + 1, 0);
340
406
  const layout = (notice, heading, separator, optionalShare) => {
341
- const reserveFor = metadataChars => notice.length + (metadataChars > 0 ? heading.length + metadataChars : 0);
407
+ const reserveFor = metadataChars => lineCost(notice) + (metadataChars > 0 ? heading.length + metadataChars : 0);
342
408
  if (reserveFor(trustChars) > limit) return null;
343
409
  const reservedOptional = [];
344
410
  let optionalChars = 0;
345
411
  const optionalRoom = Math.min(optionalShare, limit - reserveFor(trustChars) - (trustChars > 0 ? 0 : heading.length));
346
412
  for (const candidate of optional) {
347
413
  if (reservedOptional.length >= MAX_PRESERVED_CONTRACT_LINES) break;
348
- if (optionalChars + candidate.line.length + 1 > optionalRoom) continue;
414
+ if (optionalChars + lineCost(candidate.line) + 1 > optionalRoom) continue;
349
415
  reservedOptional.push(candidate);
350
- optionalChars += candidate.line.length + 1;
416
+ optionalChars += lineCost(candidate.line) + 1;
351
417
  }
352
418
  const reserved = [...trust, ...reservedOptional];
353
419
  const pendingOf = cutState => reserved.filter(candidate => candidate.sourceIndex >= cutState.keptLines);
@@ -361,12 +427,12 @@ function applyOutputBudget(text, {
361
427
  }
362
428
  const metadata = pendingOf(cut);
363
429
  let rendered = cut.body ? `${cut.body}${separator}${notice}` : notice;
364
- let used = rendered.length + (metadata.length > 0 ? heading.length + costOf(metadata) : 0);
430
+ let used = textCost(rendered) + (metadata.length > 0 ? heading.length + costOf(metadata) : 0);
365
431
  let omitted = 0;
366
432
  const taken = new Set(reserved);
367
433
  for (const candidate of optional) {
368
434
  if (taken.has(candidate) || candidate.sourceIndex < cut.keptLines) continue;
369
- const cost = candidate.line.length + 1 + (metadata.length === 0 ? heading.length : 0);
435
+ const cost = lineCost(candidate.line) + 1 + (metadata.length === 0 ? heading.length : 0);
370
436
  if (metadata.length >= trust.length + MAX_PRESERVED_CONTRACT_LINES || used + cost > limit) {
371
437
  omitted++;
372
438
  continue;
@@ -392,28 +458,44 @@ function applyOutputBudget(text, {
392
458
  // so with the budget that would carry them.
393
459
  const withheld = () => {
394
460
  const flag = surface === 'mcp' ? 'max_chars' : '--max-chars';
395
- const noticeFor = (shown, needed) => `... OUTPUT TRUNCATED (${text.length}→${hardLimit}): answer withheld; ` +
396
- `${shown} of ${trust.length} trust line(s) shown. Raise ${flag} to at least ${needed}.`;
461
+ const noticeFor = (shown, needed, target = surface, fullChars = text.length) =>
462
+ `... OUTPUT TRUNCATED (${fullChars}→${hardLimit}): answer withheld; ` +
463
+ `${shown} of ${trust.length} trust line(s) shown. Raise ${target === 'mcp' ? 'max_chars' : '--max-chars'} to at least ${needed}.`;
397
464
  // Smallest budget whose shortest layout carries every trust line.
398
- const needed = trailingChars + shortNotice.length + trustChars;
465
+ let needed = terminalChars + lineCost(minimalNotice) + trustChars;
466
+ // Raising the limit can add a digit to the notice itself. Compute
467
+ // the advertised budget using that budget's notice, for both peers.
468
+ for (;;) {
469
+ const noticeChars = Math.max(noticesFor(surface, text.length, needed).minimalNotice.length,
470
+ paired ? noticesFor(surface === 'mcp' ? 'cli' : 'mcp', peerText.length, needed).minimalNotice.length : 0);
471
+ const required = terminalChars + noticeChars + trustChars;
472
+ if (required <= needed) break;
473
+ needed = required;
474
+ }
399
475
  let shown = 0;
400
- let used = noticeFor(trust.length, needed).length;
476
+ const noticeCost = text => paired ? Math.max(text.length,
477
+ text.replace(surface === 'mcp' ? 'max_chars' : '--max-chars', surface === 'mcp' ? '--max-chars' : 'max_chars').length) : text.length;
478
+ // Native full sizes can have different digit widths. Pair the whole
479
+ // notice before choosing trust lines, just as for ordinary notices.
480
+ const fullNoticeCost = shown => Math.max(noticeFor(shown, needed).length,
481
+ paired ? noticeFor(shown, needed, surface === 'mcp' ? 'cli' : 'mcp', peerText.length).length : 0);
482
+ let used = fullNoticeCost(trust.length);
401
483
  const kept = [];
402
484
  for (const candidate of trust) {
403
- if (used + candidate.line.length + 1 > limit) break;
485
+ if (used + lineCost(candidate.line) + 1 > limit) break;
404
486
  kept.push(candidate.line);
405
- used += candidate.line.length + 1;
487
+ used += lineCost(candidate.line) + 1;
406
488
  shown++;
407
489
  }
408
490
  const notice = noticeFor(shown, needed);
409
- if (notice.length > limit) {
491
+ if (fullNoticeCost(shown) > limit) {
410
492
  // Shorter statements of the same fact; a budget number is never
411
493
  // cut mid-digit.
412
494
  const shorter = [
413
495
  `TRUNCATED: answer withheld; raise ${flag} to at least ${needed}.`,
414
496
  `Answer withheld; ${flag}>=${needed}`,
415
497
  `withheld; >=${needed}`,
416
- ].find(text => text.length <= limit);
498
+ ].find(text => noticeCost(text) <= limit);
417
499
  return result(shorter || '', [], false);
418
500
  }
419
501
  return result([notice, ...kept].join('\n'), kept, false);
@@ -440,7 +522,9 @@ function applyOutputBudget(text, {
440
522
 
441
523
  if (compactBudget) {
442
524
  // Tiny transports: every disclosure outranks body detail.
443
- return pick(() => layout(compactNotice, '', '\n', limit), () => layout(shortNotice, '', '\n', limit)) ||
525
+ return pick(() => layout(compactNotice, '', '\n', limit),
526
+ () => layout(shortNotice, '', '\n', limit),
527
+ () => layout(minimalNotice, '', '\n', limit)) ||
444
528
  (trust.length > 0 ? withheld() : bare());
445
529
  }
446
530
 
@@ -448,26 +532,26 @@ function applyOutputBudget(text, {
448
532
  // Sections keep every non-indented line (trust lines included) in
449
533
  // place; trust lines inside a cut section body are appended, with
450
534
  // their room taken from the sections.
451
- let sectionBudget = limit - longNotice.length - 2;
535
+ let sectionBudget = limit - lineCost(longNotice) - 2;
452
536
  for (let attempt = 0; attempt < 2; attempt++) {
453
- const sectioned = sectionAwareCut(text, sectionBudget);
537
+ const sectioned = sectionAwareCut(text, sectionBudget, lineCost);
454
538
  if (sectioned === null) break;
455
539
  const visible = new Set(sectioned.split('\n').map(line => line.trim()));
456
540
  const missing = trust.filter(candidate => !visible.has(candidate.line));
457
- const missingChars = missing.reduce((sum, candidate) => sum + candidate.line.length + 1, 0);
541
+ const missingChars = missing.reduce((sum, candidate) => sum + lineCost(candidate.line) + 1, 0);
458
542
  let rendered = `${sectioned}\n\n${longNotice}`;
459
- if (rendered.length + missingChars > limit) {
543
+ if (textCost(rendered) + missingChars > limit) {
460
544
  sectionBudget -= missingChars;
461
545
  continue;
462
546
  }
463
547
  const appended = missing.map(candidate => candidate.line);
464
548
  let omitted = 0;
465
- let used = rendered.length + missingChars;
549
+ let used = textCost(rendered) + missingChars;
466
550
  for (const candidate of optional) {
467
551
  if (visible.has(candidate.line)) continue;
468
- if (used + candidate.line.length + 1 > limit) { omitted++; continue; }
552
+ if (used + lineCost(candidate.line) + 1 > limit) { omitted++; continue; }
469
553
  appended.push(candidate.line);
470
- used += candidate.line.length + 1;
554
+ used += lineCost(candidate.line) + 1;
471
555
  }
472
556
  if (appended.length > 0) rendered += '\n' + appended.join('\n');
473
557
  return result(rendered, appended, omitted === 0);
@@ -480,7 +564,8 @@ function applyOutputBudget(text, {
480
564
  const optionalShare = Math.max(0, Math.min(MAX_PRESERVED_CONTRACT_CHARS, Math.floor(limit * 0.5)) - trustChars);
481
565
  return pick(() => layout(longNotice, heading, '\n\n', optionalShare),
482
566
  () => layout(compactNotice, '', '\n', optionalShare),
483
- () => layout(shortNotice, '', '\n', optionalShare)) ||
567
+ () => layout(shortNotice, '', '\n', optionalShare),
568
+ () => layout(minimalNotice, '', '\n', optionalShare)) ||
484
569
  (trust.length > 0 ? withheld() : bare());
485
570
  }
486
571
 
@@ -220,7 +220,7 @@ function parallelBuild(index, files, options = {}) {
220
220
  {
221
221
  for (const result of collected) {
222
222
  if (result.error) {
223
- index.failedFiles.add(result.filePath);
223
+ index._noteFailedFile(result.filePath);
224
224
  if (!options.quiet) {
225
225
  console.error(` Warning: Could not index ${result.filePath}: ${result.error}`);
226
226
  }
@@ -236,7 +236,7 @@ function parallelBuild(index, files, options = {}) {
236
236
  existing.size = result.sizeUpdate;
237
237
  }
238
238
  }
239
- index.failedFiles.delete(result.filePath);
239
+ index._clearFailedFile(result.filePath);
240
240
  continue;
241
241
  }
242
242
 
@@ -267,7 +267,7 @@ function parallelBuild(index, files, options = {}) {
267
267
  index.callsCacheDirty = true;
268
268
  }
269
269
 
270
- index.failedFiles.delete(result.filePath);
270
+ index._clearFailedFile(result.filePath);
271
271
  changed++;
272
272
  }
273
273
  }