From 81dbb44a5cbd739e26bc2dda4c3b4e3aa6c475be Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Mon, 24 Aug 2026 09:13:42 +0000 Subject: [PATCH] fix(cli): `os build` names the author-time warnings it withheld 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, 52 warning lines both times) while the summary line counted 80 and then 75. The defect is the silence, not the cap: truncated output carrying no notice is indistinguishable from complete output. The cap stays; over it the printer now names the exact remainder and points at `--json`, which already publishes the whole set. At or under the cap the rendering is byte-for-byte unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- .changeset/build-warning-truncation-notice.md | 37 +++++ packages/cli/src/commands/compile.ts | 11 +- packages/cli/src/utils/format.ts | 74 +++++++++ .../build-warning-truncation-notice.test.ts | 140 ++++++++++++++++++ 4 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 .changeset/build-warning-truncation-notice.md create mode 100644 packages/cli/test/build-warning-truncation-notice.test.ts 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(''); + }); +});