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. diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index ba2e308002..efe02fca4a 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,28 @@ 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 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, + }); } // 3e. [ADR-0090 D6] Access-matrix snapshot gate. Opt-in per app: when @@ -343,7 +372,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 +404,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); + } + }); +});