From 3c829c020e7ca24fa111188bef5d374c180d7d64 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 15:12:20 +0000 Subject: [PATCH 1/3] fix(cli): name the remainder at every truncating render in build/validate/init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine diagnostic renders in `os build`, `os validate` and `os init` cut their list at a fixed cap and printed nothing saying so. Truncated output that carries no notice is indistinguishable from complete output, so an author who reads it and sees no further problems has read a list that stopped early — and past the cap each round of fixes reveals a new batch that reads as fresh breakage, which is the round-trip `os validate` removed when it started reporting every failing rule at once. The population was re-derived from the defect (a truncating render with no remainder line) rather than from the `slice(0, 50)` literal the card was scoped by, and the two differ in both directions: the `--strict-body` refusal path caps at 20 and was missing from the ledger, while the `bodyExtractionWarnings` block also caps at 20 and already names its remainder — that one is the in-repo precedent this change copies, and it is untouched. The notice sentence now has one implementation, `printTruncationNotice`, which `printAuthoringAdvisories` (#11529) was refactored onto without changing a byte of its output. Its `remedy` is optional because the pointer has to be verified per site: `--json` publishes each list at the exact exit whose text face carries the notice, but `os init` declares no `--json` flag at all, so both of its notices state the remainder with no pointer rather than naming a remedy that returns the same truncated view. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- packages/cli/src/commands/compile.ts | 51 ++- packages/cli/src/commands/init.ts | 40 +- packages/cli/src/commands/validate.ts | 20 +- packages/cli/src/utils/format.ts | 176 +++++++- .../test/truncation-remainder-notices.test.ts | 422 ++++++++++++++++++ 5 files changed, 667 insertions(+), 42 deletions(-) create mode 100644 packages/cli/test/truncation-remainder-notices.test.ts diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index ba2e308002..ea129bdb01 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -29,6 +29,10 @@ import { printStep, printWarning, printAuthoringAdvisories, + printAuthoringRuleErrors, + printDocIssueErrors, + printBulletList, + JSON_FULL_LIST_REMEDY, createTimer, formatZodErrors, collectMetadataStats, @@ -142,9 +146,17 @@ export default class Compile extends Command { } console.log(''); printError(`--strict-body: ${issues.length} callable(s) lack a metadata body`); - for (const w of issues.slice(0, 20)) { - console.log(` • ${w.origin}: ${w.reason}`); - } + // [#11642] Caps at 20, not 50, which is the only reason a sweep + // anchored on the literal `slice(0, 50)` could not see this one. The + // shape is the defect either way: the header states the true total + // and the body shows 20, with nothing saying the rest exist. The cap + // stays; the silence does not. The pointer is honest here — the + // `--json` branch immediately above this block publishes the whole + // list as `issues`. + printBulletList( + issues.map((w) => `${w.origin}: ${w.reason}`), + { noun: 'callable(s)', limit: 20, remedy: JSON_FULL_LIST_REMEDY }, + ); this.exit(1); } } @@ -233,11 +245,9 @@ export default class Compile extends Command { } console.log(''); printError(`Author-time rules failed (${ruleErrors.length} issue${ruleErrors.length > 1 ? 's' : ''})`); - for (const f of ruleErrors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } + // [#11642] `--json` on this same exit publishes every one of them as + // `issues`, so the pointer resolves to a complete view of THIS list. + printAuthoringRuleErrors(ruleErrors, { remedy: JSON_FULL_LIST_REMEDY }); this.exit(1); } @@ -303,9 +313,15 @@ export default class Compile extends Command { ].map(formatUnknownAuthoringKey); if (unknownKeyWarnings.length > 0 && !flags.json) { printWarning(`Undeclared authoring keys (${unknownKeyWarnings.length}) — dropped at load (#3786)`); - for (const w of unknownKeyWarnings.slice(0, 50)) { - console.log(` • ${w}`); - } + // [#11642] The header already states the true total, so before this + // notice the block printed two numbers that disagreed and explained + // neither. The pointer is honest because #11643 put this exact list + // into the `--json` payload (`warnings`) a few lines below; it would + // have been a dead end before that landed. + printBulletList(unknownKeyWarnings, { + noun: 'undeclared authoring key(s)', + remedy: JSON_FULL_LIST_REMEDY, + }); } // 3e. [ADR-0090 D6] Access-matrix snapshot gate. Opt-in per app: when @@ -343,7 +359,12 @@ export default class Compile extends Command { } console.log(''); printError(`Access matrix drift (${drift.length} change${drift.length > 1 ? 's' : ''}) — capability changes must be reviewed`); - for (const line of drift.slice(0, 50)) console.log(` • ${line}`); + // [#11642] `--json` on this same exit publishes the whole diff + // as `changes`, so the pointer resolves for this list too. + printBulletList(drift, { + noun: 'access-matrix change(s)', + remedy: JSON_FULL_LIST_REMEDY, + }); console.log(chalk.dim(' If intended, re-run with --update-access-matrix and commit the snapshot — its diff IS the review artifact.')); this.exit(1); } @@ -370,10 +391,8 @@ export default class Compile extends Command { } console.log(''); printError(`Package docs validation failed (${docErrors.length} issue${docErrors.length > 1 ? 's' : ''})`); - for (const i of docErrors.slice(0, 50)) { - console.log(` • ${i.path}: ${i.message}`); - console.log(chalk.dim(` rule: ${i.rule}`)); - } + // [#11642] `--json` on this same exit publishes them all as `issues`. + printDocIssueErrors(docErrors, { remedy: JSON_FULL_LIST_REMEDY }); this.exit(1); } if (docWarnings.length > 0 && !flags.json) { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 42ea36f6b8..655f4f13f6 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -6,7 +6,18 @@ import chalk from 'chalk'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; -import { printHeader, printSuccess, printError, printStep, printKV, printInfo, formatZodErrors } from '../utils/format.js'; +import { + printHeader, + printSuccess, + printError, + printStep, + printKV, + printInfo, + formatZodErrors, + printAuthoringAdvisories, + printAuthoringRuleErrors, + AUTHORING_ADVISORY_PRINT_LIMIT, +} from '../utils/format.js'; import { validateScaffold } from '../utils/scaffold-validate.js'; import { summarizeTree, describeEntry } from 'create-objectstack/created-summary'; @@ -890,11 +901,17 @@ export default class Init extends Command { try { const report = await validateScaffold(targetDir); - for (const f of report.advisories.slice(0, 50)) { - printWarning(`${f.where}: ${f.message}`); - if (f.hint) console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } + // [#11642] The SAME printer `os build` renders its advisories with + // — this block was a byte-for-byte copy of it, cap included — so the + // two cannot drift, and the remainder now gets named here too. + // + // ⛔ …but with NO pointer, and that is the point of the third + // argument. `os build`'s notice ends "re-run with --json for the + // full list"; `os init` declares no `--json` flag at all (see + // `static override flags` above), so offering it here would name a + // remedy that does not exist and send the author to a dead end. + // Stating the remainder without a pointer is the honest form. + printAuthoringAdvisories(report.advisories, AUTHORING_ADVISORY_PRINT_LIMIT, null); if (report.schemaError) { printError('Scaffold validation failed: rendered config does not satisfy the protocol schema'); @@ -905,11 +922,12 @@ export default class Init extends Command { printError( `Scaffold validation failed: author-time rules rejected the generated project (${report.errors.length} issue${report.errors.length > 1 ? 's' : ''})`, ); - for (const f of report.errors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - if (f.hint) console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } + // [#11642] Same printer as `os build` / `os validate`, and again + // with no `--json` pointer — this command has no such flag. The + // remedy this site DOES have is the line printed below: a scaffold + // its own generator's rules reject is a CLI bug, so the action is + // to report it, not to read a longer list. + printAuthoringRuleErrors(report.errors, { remedy: null }); scaffoldRejected = true; } else { printSuccess( diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index aa3f641ec2..2d563ff54b 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -23,6 +23,9 @@ import { printSuccess, printError, printStep, + printAuthoringRuleErrors, + printDocIssueErrors, + JSON_FULL_LIST_REMEDY, createTimer, formatZodErrors, collectMetadataStats, @@ -137,11 +140,12 @@ export default class Validate extends Command { } console.log(''); printError(`Author-time rules failed (${ruleErrors.length} issue${ruleErrors.length > 1 ? 's' : ''})`); - for (const f of ruleErrors.slice(0, 50)) { - console.log(` • ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } + // [#11642] The comment above is the reason this render may not be + // silently capped: reporting every failing rule at once is the whole + // point of the block, and a cut with no notice restores a smaller + // version of the round-trip it removed. `--json` on this same exit + // publishes all of them as `errors`, so the pointer resolves. + printAuthoringRuleErrors(ruleErrors, { remedy: JSON_FULL_LIST_REMEDY }); this.exit(1); } @@ -209,10 +213,8 @@ export default class Validate extends Command { } console.log(''); printError(`Package docs validation failed (${docErrors.length} issue${docErrors.length > 1 ? 's' : ''})`); - for (const i of docErrors.slice(0, 50)) { - console.log(` • ${i.path}: ${i.message}`); - console.log(chalk.dim(` rule: ${i.rule}`)); - } + // [#11642] `--json` on this same exit publishes them all as `errors`. + printDocIssueErrors(docErrors, { remedy: JSON_FULL_LIST_REMEDY }); this.exit(1); } diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index e7e02d9810..1dbe354f1f 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -1004,6 +1004,66 @@ export interface AuthoringAdvisory { hint?: string; } +/** + * The pointer a truncation notice offers when — and ONLY when — the command's + * own `--json` payload really does carry the list that was cut. + * + * Spelled once because the honesty of the sentence is per-SITE, not per-word. + * `--json` publishes a different payload at every exit in every command, and + * a notice pointing at one that omits its list is worse than a silent cut: it + * sends the author down a path that returns the same truncated view. So each + * call site below passes this only after its payload has been read, and the + * sites whose list `--json` cannot carry — `os init` declares no `--json` + * flag at all — pass `null` and state the remainder with no pointer. + */ +export const JSON_FULL_LIST_REMEDY = 're-run with --json for the full list'; + +/** + * How many entries a diagnostic list renders in full before it switches to + * the withheld-count line — the value every 50-capped render in the CLI's + * `build` / `validate` / `init` diagnostics has always used. + */ +export const DIAGNOSTIC_PRINT_LIMIT = 50; + +/** + * Say that a rendered list was cut, and by exactly how much. + * + * ONE implementation of that sentence, deliberately. #11529 closed the + * silence at the author-time advisory list; #11642 re-derived the population + * from the DEFECT — a truncating render with no remainder line — and found + * nine more across `compile` / `validate` / `init`. Nine copies of the + * wording would be nine chances for them to drift apart, and the one thing a + * reader must be able to trust is that a report which says nothing about a + * remainder has none. + * + * The defect is the SILENCE, not the cap. Truncated output carrying no notice + * is indistinguishable from complete output, so an author who reads it and + * sees no further problems has read a list that stopped early. Hence the pair + * this function encodes: over the cap the exact remainder is named, at or + * under it NOTHING is printed — the absence is what makes the presence + * informative, so both halves are pinned. + * + * `remedy` is a path to the complete output. It is optional, and it is the + * caller's job to have verified it: see {@link JSON_FULL_LIST_REMEDY}. + */ +export function printTruncationNotice(options: { + /** How many entries the list had. */ + total: number; + /** How many of them the caller actually rendered. */ + shown: number; + /** What the entries are, already plural — e.g. `author-time warning(s)`. */ + noun: string; + /** A complete-output path that WORKS for this list, or `null` for none. */ + remedy?: string | null; +}): void { + const withheld = options.total - options.shown; + if (withheld <= 0) return; + printWarning( + `… and ${withheld} more ${options.noun} not shown (${options.shown} of ${options.total})` + + (options.remedy ? ` — ${options.remedy}` : ''), + ); +} + /** * How many advisories `printAuthoringAdvisories` renders in full before it * switches to the withheld-count line. The cap itself is not the defect it @@ -1042,6 +1102,7 @@ export const AUTHORING_ADVISORY_PRINT_LIMIT = 50; export function printAuthoringAdvisories( advisories: readonly AuthoringAdvisory[], limit: number = AUTHORING_ADVISORY_PRINT_LIMIT, + remedy: string | null = JSON_FULL_LIST_REMEDY, ): void { if (advisories.length === 0) return; @@ -1051,11 +1112,114 @@ export function printAuthoringAdvisories( console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); } - const shown = Math.min(advisories.length, limit); - const withheld = advisories.length - shown; - if (withheld > 0) { - printWarning( - `… and ${withheld} more author-time warning(s) not shown (${shown} of ${advisories.length}) — re-run with --json for the full list`, - ); + // [#11642] The notice sentence now lives in ONE place. Rendering here is + // byte-for-byte what #11529 shipped — `printTruncationNotice` reproduces + // the same wording from the same three numbers — and the third parameter + // exists because `os init` renders this same advisory list with no `--json` + // face to point at. + printTruncationNotice({ + total: advisories.length, + shown: Math.min(advisories.length, limit), + noun: 'author-time warning(s)', + remedy, + }); +} + +/** + * Errors and advisories are the SAME registry shape — `AuthoringFinding` in + * `@objectstack/lint`, split only by `severity` — so both printers take one + * type. The `AuthoringAdvisory` name predates the error printer below; new + * call sites should read this alias. + */ +export type AuthoringRuleFinding = AuthoringAdvisory; + +/** + * Print the GATING author-time rule failures, and name the remainder when the + * list was cut. + * + * Three commands rendered this identical three-line block inline, each behind + * its own `.slice(0, 50)` and none of them saying so (#11642): `os build`, + * `os validate` and `os init`'s scaffold self-test. The comment + * `validate.ts` carries over its own block is the reason the silence matters + * here and not only on the advisory path — "the command used to exit at the + * first failing gate, so an author with three unrelated problems fixed them + * in three round trips and could not see how deep the hole went". A capped + * list with no notice restores a smaller version of exactly that: past the + * cap each round of fixes reveals a new batch that reads as fresh breakage. + * + * The `hint` line is conditional, which is how `printAuthoringAdvisories` and + * `init` already rendered it; `compile`/`validate` printed it unconditionally. + * `AuthoringFinding.hint` is a required non-empty string in every rule the + * registry ships (checked: no rule emits an empty one), so the two forms + * differ on no finding this CLI can actually produce. + */ +export function printAuthoringRuleErrors( + errors: readonly AuthoringRuleFinding[], + options: { limit?: number; remedy?: string | null } = {}, +): void { + const limit = options.limit ?? DIAGNOSTIC_PRINT_LIMIT; + for (const f of errors.slice(0, limit)) { + console.log(` • ${f.where}: ${f.message}`); + if (f.hint) console.log(chalk.dim(` ${f.hint}`)); + console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); + } + printTruncationNotice({ + total: errors.length, + shown: Math.min(errors.length, limit), + noun: 'author-time rule failure(s)', + remedy: options.remedy, + }); +} + +/** One package-doc lint issue, in the shape `collectAndLintDocs` reports. */ +export interface DocIssueRow { + path: string; + message: string; + rule: string; +} + +/** + * Print package-doc (ADR-0046) errors, and name the remainder when the list + * was cut. Shared by `os build` and `os validate`, which ran byte-identical + * capped loops (#11642). + */ +export function printDocIssueErrors( + issues: readonly DocIssueRow[], + options: { limit?: number; remedy?: string | null } = {}, +): void { + const limit = options.limit ?? DIAGNOSTIC_PRINT_LIMIT; + for (const i of issues.slice(0, limit)) { + console.log(` • ${i.path}: ${i.message}`); + console.log(chalk.dim(` rule: ${i.rule}`)); } + printTruncationNotice({ + total: issues.length, + shown: Math.min(issues.length, limit), + noun: 'package-doc error(s)', + remedy: options.remedy, + }); +} + +/** + * Print an already-formatted list as ` • ` bullets, and name the + * remainder when the list was cut. + * + * For the diagnostics whose entries are strings by the time they reach the + * printer: the undeclared-authoring-key findings, the access-matrix drift + * lines, and `--strict-body`'s refusal list (#11642). `noun` is required + * rather than defaulted — a notice that names the wrong thing is the same + * class of unhelpful as one that names nothing. + */ +export function printBulletList( + lines: readonly string[], + options: { noun: string; limit?: number; remedy?: string | null }, +): void { + const limit = options.limit ?? DIAGNOSTIC_PRINT_LIMIT; + for (const line of lines.slice(0, limit)) console.log(` • ${line}`); + printTruncationNotice({ + total: lines.length, + shown: Math.min(lines.length, limit), + noun: options.noun, + remedy: options.remedy, + }); } diff --git a/packages/cli/test/truncation-remainder-notices.test.ts b/packages/cli/test/truncation-remainder-notices.test.ts new file mode 100644 index 0000000000..d61dae338b --- /dev/null +++ b/packages/cli/test/truncation-remainder-notices.test.ts @@ -0,0 +1,422 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #11642 — the truncating renders in `os build` / `os validate` / `os init` + * that cut their list and said nothing about it. + * + * ## The population, and why it had to be re-derived + * + * The card listed eight sites, selected by grepping for `.slice(0, 50)`. That + * literal answers "where does the number 50 appear", NOT "where is output + * truncated in silence", and here the two differ in BOTH directions: + * + * + `compile.ts`'s `--strict-body` refusal path caps at **20**, so no + * 50-anchored sweep could see it — and it is byte-for-byte the shape the + * card already counts as a defect: the header states the true total, the + * body shows 20, nothing says the rest exist. + * - `compile.ts`'s `bodyExtractionWarnings` block also caps at 20 and is + * NOT a defect: it already prints `… and ${n - 20} more` plus a pointer + * at `--strict-body`. It is the in-repo PRECEDENT the rest now follow, + * and it is deliberately untouched — pinned below. + * + * So the defect is defined here as a *truncating render with no remainder + * line*, and the sweep below looks for that rather than for any literal. + * + * ## The remedy is checked per site, not assumed + * + * #11529's notice ends by pointing at `--json` "for the full list", which is + * honest only for a list `--json` actually carries. A notice whose remedy + * does not work is worse than a silent cut: it sends the author down a path + * that returns the same truncated view. Every site below was read against its + * own `--json` payload, and `os init` — which declares no `--json` flag at + * all — states its remainder with NO pointer. The fact that decision rests on + * is pinned, so adding `--json` to `init` fails here rather than quietly + * leaving a dead pointer behind. + * + * ## What the pins assert + * + * Rendered output, from both ends: over the cap the exact remainder is named; + * at or under it NO such line appears. A printer that always printed a notice + * would satisfy only the first half, so the controls carry as much weight as + * the pins. Then, per site, that the command really routes its list through + * such a printer — before the `this.exit(1)` that ends the run. + * + * ALTITUDE: the printers, not a spawned CLI — the precedent set by this + * family's own `build-warning-truncation-notice.test.ts` (#11529) and by + * `print-metadata-stats-zero-row.test.ts`. No child process, so nothing here + * touches `check:cli-test-child-env`. The per-site half is a source read of + * this same package (`../src/commands/*.ts`), which a rendered-output pin on + * a shared printer cannot cover: a call whose output never reaches the + * terminal renders green in isolation. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + AUTHORING_ADVISORY_PRINT_LIMIT, + DIAGNOSTIC_PRINT_LIMIT, + JSON_FULL_LIST_REMEDY, + printAuthoringAdvisories, + printAuthoringRuleErrors, + printBulletList, + printDocIssueErrors, + printTruncationNotice, + type AuthoringRuleFinding, + type DocIssueRow, +} from '../src/utils/format.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const readCommand = (file: string) => readFileSync(resolve(HERE, '..', 'src', 'commands', file), 'utf8'); + +const SOURCES: Record = { + 'compile.ts': readCommand('compile.ts'), + 'validate.ts': readCommand('validate.ts'), + 'init.ts': readCommand('init.ts'), +}; + +/** Drop SGR sequences so an assertion reads the words, not chalk's opinion. */ +const stripAnsi = (s: string) => s.replace(/\u001B\[[0-9;]*m/g, ''); + +/** Run a printer and return everything it printed, as one string. */ +function capture(run: () => void): string { + const lines: string[] = []; + const original = console.log; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + try { + run(); + } finally { + console.log = original; + } + return stripAnsi(lines.join('\n')); +} + +/** The notice, recognised by what makes it honest rather than by its wording. */ +const NOTICE = /and (\d+) more .+ not shown \((\d+) of (\d+)\)/; + +const finding = (i: number): AuthoringRuleFinding => ({ + where: `object "obj_${i}"`, + message: `message ${i}`, + rule: `rule-${i}`, + path: `objects[${i}].sharingModel`, + hint: `hint ${i}`, +}); + +const docIssue = (i: number): DocIssueRow => ({ + path: `src/docs/doc_${i}.md`, + message: `message ${i}`, + rule: `doc-rule-${i}`, +}); + +const many = (n: number, make: (i: number) => T): T[] => Array.from({ length: n }, (_, i) => make(i)); +const bullets = (n: number) => many(n, (i) => `line ${i}`); + +describe('[#11642] the truncation notice itself — one sentence, one implementation', () => { + it('OVER the cap: names the exact remainder and how many of how many were shown', () => { + const out = capture(() => + printTruncationNotice({ total: 80, shown: 50, noun: 'widget(s)', remedy: 'do the thing' }), + ); + expect(out).toMatch(NOTICE); + expect(out).toContain('… and 30 more widget(s) not shown (50 of 80) — do the thing'); + }); + + it('CONTROL — AT the cap: nothing is printed at all', () => { + expect(capture(() => printTruncationNotice({ total: 50, shown: 50, noun: 'widget(s)' }))).toBe(''); + }); + + it('CONTROL — UNDER the cap: nothing is printed at all', () => { + expect(capture(() => printTruncationNotice({ total: 3, shown: 3, noun: 'widget(s)' }))).toBe(''); + }); + + it('no remedy: the remainder is still stated, and no pointer is invented', () => { + const out = capture(() => printTruncationNotice({ total: 80, shown: 50, noun: 'widget(s)', remedy: null })); + expect(out).toContain('… and 30 more widget(s) not shown (50 of 80)'); + expect(out).not.toContain('—'); + expect(out).not.toContain('--json'); + }); + + it('the shared cap and the shared pointer are the values these printers ship with', () => { + // Literals on purpose: moving either has to be a deliberate edit here + // rather than a silently-passing one. + expect(DIAGNOSTIC_PRINT_LIMIT).toBe(50); + expect(JSON_FULL_LIST_REMEDY).toBe('re-run with --json for the full list'); + }); +}); + +describe('[#11642] printAuthoringRuleErrors — the gating rule failures', () => { + it('OVER the cap: names the remainder and offers the remedy the caller supplied', () => { + const out = capture(() => printAuthoringRuleErrors(many(80, finding), { remedy: JSON_FULL_LIST_REMEDY })); + expect(out).toContain('and 30 more author-time rule failure(s) not shown (50 of 80)'); + expect(out).toContain('--json'); + }); + + it('CONTROL — a list shorter than the cap prints no notice at all', () => { + const out = capture(() => printAuthoringRuleErrors(many(3, finding), { remedy: JSON_FULL_LIST_REMEDY })); + expect(out).not.toMatch(NOTICE); + expect(out).not.toContain('not shown'); + }); + + it('CONTROL — exactly AT the cap: every entry, still no notice', () => { + const out = capture(() => printAuthoringRuleErrors(many(DIAGNOSTIC_PRINT_LIMIT, finding))); + expect(out.split('\n').filter((l) => l.includes('rule: ')).length).toBe(50); + expect(out).not.toMatch(NOTICE); + }); + + it('ONE over the cap: the notice reads exactly 1 — the tightest edge', () => { + const out = capture(() => printAuthoringRuleErrors(many(11, finding), { limit: 10 })); + expect(NOTICE.exec(out)?.[1]).toBe('1'); + expect(out).toContain('(10 of 11)'); + }); + + it('CONTROL — the rows are unchanged: the notice adds, it does not replace', () => { + const out = capture(() => printAuthoringRuleErrors(many(80, finding), { remedy: JSON_FULL_LIST_REMEDY })); + expect(out).toContain(' • object "obj_0": message 0'); + expect(out).toContain(' hint 0'); + expect(out).toContain(' rule: rule-0 at objects[0].sharingModel'); + // The cap still caps: the 50th row is there and the 51st is not. + expect(out).toContain('at objects[49].sharingModel'); + expect(out).not.toContain('at objects[50].sharingModel'); + }); + + it('no remedy (the `os init` form): the remainder is named, no pointer is offered', () => { + const out = capture(() => printAuthoringRuleErrors(many(80, finding), { remedy: null })); + expect(out).toContain('and 30 more author-time rule failure(s) not shown (50 of 80)'); + expect(out).not.toContain('--json'); + }); +}); + +describe('[#11642] printDocIssueErrors — the package-doc errors', () => { + it('OVER the cap: names the remainder', () => { + const out = capture(() => printDocIssueErrors(many(64, docIssue), { remedy: JSON_FULL_LIST_REMEDY })); + expect(out).toContain('and 14 more package-doc error(s) not shown (50 of 64)'); + expect(out).toContain('--json'); + }); + + it('CONTROL — a list shorter than the cap prints no notice, and every row', () => { + const out = capture(() => printDocIssueErrors(many(4, docIssue))); + expect(out).not.toMatch(NOTICE); + expect(out).toContain(' • src/docs/doc_3.md: message 3'); + expect(out).toContain(' rule: doc-rule-3'); + }); +}); + +describe('[#11642] printBulletList — the string diagnostics', () => { + it('OVER the cap: names the remainder with the caller-supplied noun', () => { + const out = capture(() => + printBulletList(bullets(75), { noun: 'undeclared authoring key(s)', remedy: JSON_FULL_LIST_REMEDY }), + ); + expect(out).toContain('and 25 more undeclared authoring key(s) not shown (50 of 75)'); + }); + + it('respects a cap other than 50 — the `--strict-body` site caps at 20', () => { + const out = capture(() => printBulletList(bullets(31), { noun: 'callable(s)', limit: 20 })); + expect(out).toContain('and 11 more callable(s) not shown (20 of 31)'); + expect(out).toContain(' • line 19'); + expect(out).not.toContain(' • line 20'); + }); + + it('CONTROL — a list shorter than the cap prints no notice at all', () => { + const out = capture(() => printBulletList(bullets(7), { noun: 'callable(s)', limit: 20 })); + expect(out).not.toMatch(NOTICE); + expect(out.split('\n').length).toBe(7); + }); + + it('CONTROL — an empty list prints nothing, not an empty notice', () => { + expect(capture(() => printBulletList([], { noun: 'callable(s)' }))).toBe(''); + }); +}); + +describe('[#11642] printAuthoringAdvisories keeps #11529 output and gains the no-pointer form', () => { + it('the default is unchanged — remainder plus the --json pointer', () => { + const out = capture(() => printAuthoringAdvisories(many(80, finding))); + expect(out).toContain( + '… and 30 more author-time warning(s) not shown (50 of 80) — re-run with --json for the full list', + ); + }); + + it('remedy `null` (what `os init` passes): remainder named, pointer withheld', () => { + const out = capture(() => printAuthoringAdvisories(many(80, finding), AUTHORING_ADVISORY_PRINT_LIMIT, null)); + expect(out).toContain('… and 30 more author-time warning(s) not shown (50 of 80)'); + expect(out).not.toContain('--json'); + }); +}); + +// ─── Per-site wiring ──────────────────────────────────────────────── +// +// A rendered-output pin on a shared printer stays green against a call whose +// output never reaches the terminal — or against a site that still runs its +// own capped loop beside the printer. These read the commands themselves. + +interface Site { + file: string; + /** Text independently known present at the site — the instrument's positive control. */ + anchor: string; + /** The list expression whose silent `.slice` had to go. */ + list: string; + /** The remainder-naming call the site now consists of. */ + call: string; + /** Does the block end the run? Then the call must precede that exit. */ + exits: boolean; +} + +const SITES: Site[] = [ + { + file: 'compile.ts', + anchor: '`--strict-body: ${issues.length} callable(s) lack a metadata body`', + list: 'issues', + call: "{ noun: 'callable(s)', limit: 20, remedy: JSON_FULL_LIST_REMEDY }", + exits: true, + }, + { + file: 'compile.ts', + anchor: 'printError(`Author-time rules failed (', + list: 'ruleErrors', + call: 'printAuthoringRuleErrors(ruleErrors, { remedy: JSON_FULL_LIST_REMEDY });', + exits: true, + }, + { + file: 'compile.ts', + anchor: 'printWarning(`Undeclared authoring keys (', + list: 'unknownKeyWarnings', + call: 'printBulletList(unknownKeyWarnings, {', + exits: false, + }, + { + file: 'compile.ts', + anchor: 'printError(`Access matrix drift (', + list: 'drift', + call: 'printBulletList(drift, {', + exits: true, + }, + { + file: 'compile.ts', + anchor: 'printError(`Package docs validation failed (', + list: 'docErrors', + call: 'printDocIssueErrors(docErrors, { remedy: JSON_FULL_LIST_REMEDY });', + exits: true, + }, + { + file: 'validate.ts', + anchor: 'printError(`Author-time rules failed (', + list: 'ruleErrors', + call: 'printAuthoringRuleErrors(ruleErrors, { remedy: JSON_FULL_LIST_REMEDY });', + exits: true, + }, + { + file: 'validate.ts', + anchor: 'printError(`Package docs validation failed (', + list: 'docErrors', + call: 'printDocIssueErrors(docErrors, { remedy: JSON_FULL_LIST_REMEDY });', + exits: true, + }, + { + file: 'init.ts', + anchor: "printStep('Validating scaffold...')", + list: 'report.advisories', + call: 'printAuthoringAdvisories(report.advisories, AUTHORING_ADVISORY_PRINT_LIMIT, null);', + exits: false, + }, + { + file: 'init.ts', + anchor: '`Scaffold validation failed: author-time rules rejected the generated project (', + list: 'report.errors', + call: 'printAuthoringRuleErrors(report.errors, { remedy: null });', + exits: false, + }, +]; + +describe('[#11642] every re-derived site routes its list through a remainder-naming printer', () => { + it.each(SITES)('$file — $list', (site) => { + const src = SOURCES[site.file]; + + // Positive control FIRST: the instrument is shown finding something in + // this file before any absence below is read as evidence. Deliberately + // not a substring of the term under test. + expect(src).toContain(site.anchor); + + // The silent cut is gone. + expect(src).not.toContain(`${site.list}.slice(0,`); + + // …and the site consists of a printer that names what it withheld. + expect(src).toContain(site.call); + + const iAnchor = src.indexOf(site.anchor); + const iCall = src.indexOf(site.call); + expect(iCall).toBeGreaterThan(iAnchor); + + if (site.exits) { + // A notice is worthless printed after the process has been told to + // leave: oclif's `this.exit(1)` throws, so anything below it is dead. + const iExit = src.indexOf('this.exit(1)', iCall); + expect(iExit).toBeGreaterThan(iCall); + } + }); +}); + +describe('[#11642] no capped render is left silent in the three printers', () => { + /** `for (const x of .slice(0, N))` — the shape of a truncating render. */ + const CAPPED = /for \(const \w+ of ([\w.[\]]+)\.slice\(0, (\d+)\)\)/g; + /** Any construct that tells the reader a remainder exists. */ + const NAMES_REMAINDER = /… and |not shown|printTruncationNotice/; + + it('the only capped for-of left is the PRECEDENT, and it names its own remainder', () => { + const remaining: string[] = []; + for (const [file, src] of Object.entries(SOURCES)) { + const lines = src.split('\n'); + for (const m of src.matchAll(CAPPED)) { + const line = src.slice(0, m.index).split('\n').length - 1; + const window = lines.slice(line, line + 8).join('\n'); + remaining.push(`${file}:${line + 1} ${m[1]}`); + expect(window, `${file}:${line + 1} truncates ${m[1]} with no remainder line`).toMatch(NAMES_REMAINDER); + } + } + expect(remaining).toEqual([expect.stringContaining('lowering.bodyExtractionWarnings')]); + }); + + it('the precedent is untouched, byte for byte', () => { + // This block was NOT a sibling defect and was explicitly out of scope: it + // already prints a remainder AND points at the complete-output path. It + // is the shape everything above copies, so a change to it is a change to + // the standard. + expect(SOURCES['compile.ts']).toContain( + 'if (n > 20) console.log(chalk.dim(` … and ${n - 20} more`));', + ); + expect(SOURCES['compile.ts']).toContain( + "console.log(chalk.dim(' → run `os build --strict-body` for the full diagnostic, or to make this fatal'));", + ); + }); +}); + +describe('[#11642] a pointer is only offered where it resolves', () => { + it('`os init` has no --json face, so neither of its notices names one', () => { + // Positive control on the same instrument: the two commands that DO + // declare the flag are found by this exact pattern. + const DECLARES_JSON = /json: Flags\.boolean\(/; + expect(SOURCES['compile.ts']).toMatch(DECLARES_JSON); + expect(SOURCES['validate.ts']).toMatch(DECLARES_JSON); + + expect(SOURCES['init.ts']).not.toMatch(DECLARES_JSON); + expect(SOURCES['init.ts']).not.toContain('JSON_FULL_LIST_REMEDY'); + }); + + it('every --json pointer in build/validate sits at an exit whose payload carries that list', () => { + // Read off the payloads once, so the claim is checkable rather than + // asserted in prose: the key each list is published under, on the very + // exit whose text face carries the notice. + const carried: Array<[string, string]> = [ + ['compile.ts', "error: 'strict-body: missing body', issues }"], + ['compile.ts', "error: 'author-time rules failed', issues: ruleErrors"], + ['compile.ts', "error: 'access matrix drift', changes: drift"], + ['compile.ts', "error: 'docs validation failed', issues: docErrors"], + ['compile.ts', 'warnings: [...ruleAdvisories, ...unknownKeyWarnings],'], + ['validate.ts', 'errors: ruleErrors,'], + ['validate.ts', 'errors: docErrors,'], + ]; + for (const [file, payload] of carried) { + expect(SOURCES[file], `${file} no longer publishes ${payload}`).toContain(payload); + } + }); +}); From d087304eaa2ff0445e3affc1f4d536f8ff904f7a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 15:55:09 +0000 Subject: [PATCH 2/3] chore(changeset): CLI diagnostics name the remainder they withheld Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- .../cli-truncation-remainder-notices.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .changeset/cli-truncation-remainder-notices.md diff --git a/.changeset/cli-truncation-remainder-notices.md b/.changeset/cli-truncation-remainder-notices.md new file mode 100644 index 0000000000..6bd1be511b --- /dev/null +++ b/.changeset/cli-truncation-remainder-notices.md @@ -0,0 +1,41 @@ +--- +'@objectstack/cli': patch +--- + +`os build`, `os validate` and `os init` say how many diagnostics they withheld + +Nine more renders across the three authoring commands cut their list at a fixed +cap and printed nothing saying so — the `--strict-body` refusal list, the +author-time rule failures, the undeclared-authoring-key findings, the +access-matrix drift, the package-doc errors, and both halves of `os init`'s +scaffold self-test. The defect is the **silence**, not the cap: truncated +output that carries no notice is indistinguishable from complete output, so an +author who reads it and sees no further problems has read a list that stopped +early. Two of them even stated the true total in their own header and then +showed fewer rows, so the report gave two numbers that disagreed and explained +neither. + +On the gating lists it also undoes the thing `os validate` went out of its way +to provide. Its own comment records why every failing rule reports at once: +"the command used to exit at the first failing gate, so an author with three +unrelated problems fixed them in three round trips and could not see how deep +the hole went". Past the cap that is exactly what came back, one cap-width at a +time, with each round of fixes revealing a new batch that reads as fresh +breakage. + +Every cap stays. Over it the output now names the exact remainder: + +``` + ⚠ … and 30 more author-time rule failure(s) not shown (50 of 80) — re-run with --json for the full list +``` + +**The pointer is verified per site, and two notices deliberately omit it.** +`--json` publishes each of these lists at the very exit whose text face carries +the notice, so re-running really does return the complete set. `os init` +declares no `--json` flag at all, so both of its notices state the remainder +and name no remedy — a notice whose remedy does not work is worse than a silent +cut, because it sends the author down a path that returns the same truncated +view. + +At or under a cap, nothing new is printed and the rendering is byte-for-byte +what it was. From 95ea4cc0bf0ce9bde5ab24c6595eb635aab39841 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 16:15:57 +0000 Subject: [PATCH 3/3] docs(cli): qualify the one truncation pointer whose honesty is conditional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The undeclared-authoring-key notice points at `--json`, and `warnings` lives in `os build`'s TERMINAL payload — so the pointer resolves on the success exit and not when a later gate (access matrix, package docs, the runtime bundle) fails first and emits its own payload. The site's comment stated the honest half without the condition, which is the same shape as the silence this change exists to remove: a claim true in one branch, read as general. Comment only. The caveat and the tracking issue for the payload-shape question are now stated where the pointer is chosen, and the six error-path sites are named as the ones with no such gap because their `--json` branch sits in the same block as the text face. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- packages/cli/src/commands/compile.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index ea129bdb01..efe02fca4a 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -315,9 +315,22 @@ export default class Compile extends Command { printWarning(`Undeclared authoring keys (${unknownKeyWarnings.length}) — dropped at load (#3786)`); // [#11642] The header already states the true total, so before this // notice the block printed two numbers that disagreed and explained - // neither. The pointer is honest because #11643 put this exact list + // neither. The pointer resolves because #11643 put this exact list // into the `--json` payload (`warnings`) a few lines below; it would // have been a dead end before that landed. + // + // ⚠️ …and it resolves ON THE SUCCESS EXIT ONLY — the one conditional + // pointer of the nine. `warnings` lives in the terminal payload, so a + // build that fails at a LATER gate (access matrix 3e, package docs 3f, + // the runtime bundle) emits that gate's failure payload instead, and + // none of those carries this list: the author is told to re-run with + // `--json` and gets a payload without the withheld keys in it. The six + // error-path notices have no such gap — their `--json` branch sits in + // the same block as the text face. Filed as #11772; closing it means + // changing a `--json` payload shape, which is a machine-contract + // decision and not this card's. ⛔ Do not read the line above as + // unconditional — an unqualified claim that holds in one branch is the + // same shape as the silence this whole change is about. printBulletList(unknownKeyWarnings, { noun: 'undeclared authoring key(s)', remedy: JSON_FULL_LIST_REMEDY,