Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/build-warning-truncation-notice.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 6 additions & 5 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import {
printError,
printStep,
printWarning,
printAuthoringAdvisories,
createTimer,
formatZodErrors,
collectMetadataStats,
Expand DownExpand Up@@ -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`.
Expand Down
74 changes: 74 additions & 0 deletions packages/cli/src/utils/format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`,
);
}
}
140 changes: 140 additions & 0 deletions packages/cli/test/build-warning-truncation-notice.test.ts
Original file line numberDiff line numberDiff line change
@@ -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('');
});
});
Loading