diff --git a/.changeset/build-warning-truncation-notice.md b/.changeset/build-warning-truncation-notice.md new file mode 100644 index 0000000000..cce705900e --- /dev/null +++ b/.changeset/build-warning-truncation-notice.md @@ -0,0 +1,37 @@ +--- +'@objectstack/cli': patch +--- + +`os build` says how many author-time warnings it withheld, instead of stopping +dead at 50 + +The author-time advisory printer emitted a fixed 50 detailed entries and then +stopped, with nothing in the output saying the list had been cut. Measured on +`objectstack-ai/hotcrm` with the published 17.1.0 CLI: two `objectstack build` +runs over the same tree, before and after a five-warning fix, printed 50 +detailed entries each — 184 output lines and 52 warning lines both times — +while the summary line counted 80 and then 75. The two numbers disagreed and +nothing explained why. + +The defect is the **silence**, not the cap. Truncated output that carries no +notice is not merely incomplete, it is indistinguishable from complete: an +author who reads the report and sees their file is clean has read a list that +stopped early. Because advisories are ordered by surface (pages, then views, +then flows), a repo whose page warnings alone exceed the cap keeps every `view` +and `flow` advisory permanently invisible — and fixing warnings then makes new +ones *appear*, which reads as a regression caused by the fix. + +The cap stays, and over it the output now names the exact remainder: + +``` + ⚠ … and 30 more author-time warning(s) not shown (50 of 80) — re-run with --json for the full list +``` + +At or under the cap no such line appears, and the detail entries themselves are +byte-for-byte what they were. The pointer is `--json`, which already publishes +the whole set under `warnings` — an existing complete-output path rather than a +new flag. No new verbosity tier, no paging, no configuration surface. + +`os validate` was checked at the same time and does **not** truncate its +advisory list: it prints every warning it collected. Only the `build`/`compile` +printer had the cap. diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 8273b8211e..250e10c005 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -28,6 +28,7 @@ import { printError, printStep, printWarning, + printAuthoringAdvisories, createTimer, formatZodErrors, collectMetadataStats, @@ -214,11 +215,11 @@ export default class Compile extends Command { if (ruleAdvisories.length > 0 && !flags.json) { console.log(''); - for (const f of ruleAdvisories.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}`)); - } + // #11529 — rendered by ONE printer, which also names the remainder when + // the list is cut. The loop used to sit inline here and stop dead at 50 + // with no notice, so a truncated report read exactly like a complete + // one. See `printAuthoringAdvisories` for the measurement. + printAuthoringAdvisories(ruleAdvisories); } if (ruleErrors.length > 0) { // Every failing rule reports at once — see the note in `validate.ts`. diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index c9589cd40a..e7e02d9810 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -985,3 +985,77 @@ export function printMetadataStats(stats: MetadataStats) { console.log(` ${chalk.bold(section.label + ':')} ${line}`); } } + +// ─── Author-time advisories ───────────────────────────────────────── + +/** + * One author-time advisory, in the shape the authoring-rule registry reports + * (`@objectstack/lint`'s `splitBySeverity(...).advisories`) and the shape + * `os build --json` / `os validate --json` publish under `warnings`. + * + * Declared structurally rather than re-exported from `@objectstack/lint` so + * this rendering helper stays a pure formatter with no rule-engine import. + */ +export interface AuthoringAdvisory { + where: string; + message: string; + rule: string; + path: string; + hint?: string; +} + +/** + * How many advisories `printAuthoringAdvisories` renders in full before it + * switches to the withheld-count line. The cap itself is not the defect it + * guards against — see below — so it keeps the value it has always had. + */ +export const AUTHORING_ADVISORY_PRINT_LIMIT = 50; + +/** + * Print author-time advisories, and — this is the point — say so when the + * list was cut. + * + * #11529: `os build` printed a fixed 50 detailed entries and then stopped, + * with nothing in the output saying the list had been truncated. Measured on + * `objectstack-ai/hotcrm` with the published 17.1.0 CLI: two runs, 80 and then + * 75 advisories, both printing exactly 50 entries and exactly 184 lines. The + * summary line counted all of them (`⚠ 80 author-time warning(s) — see + * above`) while only 50 were above, and removing five warnings made five + * previously-unprinted ones appear — which reads as a regression caused by the + * fix. Because the advisories are ordered by surface (pages, then views, then + * flows), a repo whose page warnings alone exceed the cap keeps every `view` + * and `flow` advisory permanently invisible. + * + * The defect is the SILENCE, not the cap. Truncated output that carries no + * notice is not merely incomplete — it is indistinguishable from complete, so + * an author who reads it and sees their file is clean has read a list that + * stopped early. That is the same shape as the dropped summary rows above + * (#10504, #10952): output that cannot distinguish "none" from "not shown". + * + * So the cap stays and the honesty line is added: over the limit, the exact + * remainder is named; at or under it, no such line appears. The pointer is + * `--json`, which already carries the whole set (`warnings: ruleAdvisories`) + * — a complete-output path that exists today, rather than a new flag. + * + * Rendering for a set at or under the limit is byte-for-byte what it was. + */ +export function printAuthoringAdvisories( + advisories: readonly AuthoringAdvisory[], + limit: number = AUTHORING_ADVISORY_PRINT_LIMIT, +): void { + if (advisories.length === 0) return; + + for (const f of advisories.slice(0, limit)) { + printWarning(`${f.where}: ${f.message}`); + if (f.hint) console.log(chalk.dim(` ${f.hint}`)); + 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`, + ); + } +} diff --git a/packages/cli/test/build-warning-truncation-notice.test.ts b/packages/cli/test/build-warning-truncation-notice.test.ts new file mode 100644 index 0000000000..862f424ea8 --- /dev/null +++ b/packages/cli/test/build-warning-truncation-notice.test.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #11529 — `os build` / `os compile` printed a fixed 50 author-time advisories + * and then stopped, with NOTHING in the output saying the list had been cut. + * + * Measured on `objectstack-ai/hotcrm` with the published 17.1.0 CLI: two + * `objectstack build` runs over the same tree, before and after a five-warning + * fix, printed 50 detailed entries each — 184 output lines, 52 warning lines, + * both times — while the summary line counted 80 and then 75. Removing five + * warnings did not shorten the list; it made room, and five advisories that + * had been present all along appeared for the first time. + * + * The defect is the SILENCE, not the cap. A truncated report that carries no + * notice is indistinguishable from a complete one, so an author who reads it + * and sees their file is clean has read a list that stopped early. Same shape + * as the dropped summary rows pinned in `print-metadata-stats-zero-row.test.ts` + * (#10504, #10952): output that cannot distinguish "none" from "not shown". + * + * WHAT THESE PINS ASSERT — the pair, not the cap. A test that only checked + * "50 entries printed" passes on the silent tree and pins nothing. So the + * behaviour is pinned from both ends: + * + * - over the limit -> the output states how many were withheld; + * - at or under it -> no such line appears at all. + * + * ALTITUDE: this pins `printAuthoringAdvisories` — the function `os build`'s + * advisory block now consists of — rather than spawning the CLI, following the + * `printMetadataStats` precedent set by the sibling fixes in this same family + * (`print-metadata-stats-zero-row.test.ts`) and the `formatZodErrors` pattern + * in `format-zod-union.test.ts`. No child process, so nothing here touches + * `check:cli-test-child-env`. + */ + +import { describe, expect, it } from 'vitest'; +import { + AUTHORING_ADVISORY_PRINT_LIMIT, + printAuthoringAdvisories, + type AuthoringAdvisory, +} from '../src/utils/format.js'; + +/** 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 the printer and return everything it printed, as one string. */ +function render(advisories: readonly AuthoringAdvisory[], limit?: number): string { + const captured: string[] = []; + const original = console.log; + console.log = (...args: unknown[]) => { + captured.push(args.map(String).join(' ')); + }; + try { + if (limit === undefined) printAuthoringAdvisories(advisories); + else printAuthoringAdvisories(advisories, limit); + } finally { + console.log = original; + } + return stripAnsi(captured.join('\n')); +} + +/** One advisory in the shape the authoring-rule registry emits. */ +const advisory = (i: number): AuthoringAdvisory => ({ + where: `view "views[${i}]" form`, + message: `absolute colSpan ${i}`, + rule: 'absolute-colspan-discouraged', + path: `views[${i}].form`, + hint: 'use a fractional colSpan', +}); + +const many = (n: number): AuthoringAdvisory[] => Array.from({ length: n }, (_, i) => advisory(i)); + +/** How many detail entries the output carries — one `rule:` line per entry. */ +const detailCount = (out: string) => out.split('\n').filter((l) => l.includes('rule: ')).length; + +/** + * The notice, recognised by what makes it honest rather than by its full + * wording: it names a remainder and says that remainder was not shown. + */ +const NOTICE = /and (\d+) more author-time warning\(s\) not shown/; + +describe('[#11529] the author-time advisory printer names what it withheld', () => { + it('OVER the limit: states how many were withheld, and how many of how many were printed', () => { + // The card's own measured run: 80 advisories against the shipped cap. + // Literal numbers on purpose — this is the reproduction, so changing the + // cap has to be a deliberate edit here rather than a silently-passing one. + expect(AUTHORING_ADVISORY_PRINT_LIMIT).toBe(50); + + const out = render(many(80)); + + // Before the fix the output simply ended after the 50th entry. + expect(out).toMatch(NOTICE); + expect(out).toContain('and 30 more author-time warning(s) not shown (50 of 80)'); + // And it points at a path that really does carry the whole set today, + // rather than inventing a flag: `--json` publishes `warnings`. + expect(out).toContain('--json'); + }); + + it('AT the limit: prints every advisory and NO withheld line — the other half of the pair', () => { + const out = render(many(50)); + expect(detailCount(out)).toBe(50); + // "50 printed" is true here AND on the truncated run above; only the + // absence of the notice tells the two apart. + expect(out).not.toMatch(NOTICE); + expect(out).not.toContain('not shown'); + }); + + it('UNDER the limit: no withheld line', () => { + const out = render(many(3), 10); + expect(detailCount(out)).toBe(3); + expect(out).not.toMatch(NOTICE); + }); + + it('ONE over the limit: the notice appears and reads exactly 1 — the tightest edge', () => { + const out = render(many(11), 10); + expect(detailCount(out)).toBe(10); + expect(NOTICE.exec(out)?.[1]).toBe('1'); + expect(out).toContain('(10 of 11)'); + }); + + it('the remainder is the EXACT count, not a fixed word', () => { + const out = render(many(8), 5); + expect(NOTICE.exec(out)?.[1]).toBe('3'); + expect(out).toContain('(5 of 8)'); + }); + + it('control: the detail entries are unchanged — the notice adds, it does not replace', () => { + const out = render(many(80)); + expect(detailCount(out)).toBe(50); + expect(out).toContain('view "views[0]" form: absolute colSpan 0'); + expect(out).toContain('use a fractional colSpan'); + expect(out).toContain('rule: absolute-colspan-discouraged at views[0].form'); + // The 50th entry is present and the 51st is not — the cap still caps. + expect(out).toContain('at views[49].form'); + expect(out).not.toContain('at views[50].form'); + }); + + it('control: an empty set prints nothing at all — no notice, no blank advisory block', () => { + expect(render([])).toBe(''); + }); +});