Skip to content

Commit a422843

Browse files
claude[bot]zhuangjianguoclaude
authored
fix(cli): os build names the author-time warnings it withheld (#11645)
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. Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR Co-authored-by: os-zhuang <zhuangjianguo@steedos.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent a5110f5 commit a422843

4 files changed

Lines changed: 257 additions & 5 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`os build` says how many author-time warnings it withheld, instead of stopping
6+
dead at 50
7+
8+
The author-time advisory printer emitted a fixed 50 detailed entries and then
9+
stopped, with nothing in the output saying the list had been cut. Measured on
10+
`objectstack-ai/hotcrm` with the published 17.1.0 CLI: two `objectstack build`
11+
runs over the same tree, before and after a five-warning fix, printed 50
12+
detailed entries each — 184 output lines and 52 warning lines both times —
13+
while the summary line counted 80 and then 75. The two numbers disagreed and
14+
nothing explained why.
15+
16+
The defect is the **silence**, not the cap. Truncated output that carries no
17+
notice is not merely incomplete, it is indistinguishable from complete: an
18+
author who reads the report and sees their file is clean has read a list that
19+
stopped early. Because advisories are ordered by surface (pages, then views,
20+
then flows), a repo whose page warnings alone exceed the cap keeps every `view`
21+
and `flow` advisory permanently invisible — and fixing warnings then makes new
22+
ones *appear*, which reads as a regression caused by the fix.
23+
24+
The cap stays, and over it the output now names the exact remainder:
25+
26+
```
27+
⚠ … and 30 more author-time warning(s) not shown (50 of 80) — re-run with --json for the full list
28+
```
29+
30+
At or under the cap no such line appears, and the detail entries themselves are
31+
byte-for-byte what they were. The pointer is `--json`, which already publishes
32+
the whole set under `warnings` — an existing complete-output path rather than a
33+
new flag. No new verbosity tier, no paging, no configuration surface.
34+
35+
`os validate` was checked at the same time and does **not** truncate its
36+
advisory list: it prints every warning it collected. Only the `build`/`compile`
37+
printer had the cap.

packages/cli/src/commands/compile.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
printError,
2929
printStep,
3030
printWarning,
31+
printAuthoringAdvisories,
3132
createTimer,
3233
formatZodErrors,
3334
collectMetadataStats,
@@ -214,11 +215,11 @@ export default class Compile extends Command {
214215

215216
if (ruleAdvisories.length > 0 && !flags.json) {
216217
console.log('');
217-
for (const f of ruleAdvisories.slice(0, 50)) {
218-
printWarning(`${f.where}: ${f.message}`);
219-
if (f.hint) console.log(chalk.dim(` ${f.hint}`));
220-
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
221-
}
218+
// #11529 — rendered by ONE printer, which also names the remainder when
219+
// the list is cut. The loop used to sit inline here and stop dead at 50
220+
// with no notice, so a truncated report read exactly like a complete
221+
// one. See `printAuthoringAdvisories` for the measurement.
222+
printAuthoringAdvisories(ruleAdvisories);
222223
}
223224
if (ruleErrors.length > 0) {
224225
// Every failing rule reports at once — see the note in `validate.ts`.

packages/cli/src/utils/format.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,3 +985,77 @@ export function printMetadataStats(stats: MetadataStats) {
985985
console.log(` ${chalk.bold(section.label + ':')} ${line}`);
986986
}
987987
}
988+
989+
// ─── Author-time advisories ─────────────────────────────────────────
990+
991+
/**
992+
* One author-time advisory, in the shape the authoring-rule registry reports
993+
* (`@objectstack/lint`'s `splitBySeverity(...).advisories`) and the shape
994+
* `os build --json` / `os validate --json` publish under `warnings`.
995+
*
996+
* Declared structurally rather than re-exported from `@objectstack/lint` so
997+
* this rendering helper stays a pure formatter with no rule-engine import.
998+
*/
999+
export interface AuthoringAdvisory {
1000+
where: string;
1001+
message: string;
1002+
rule: string;
1003+
path: string;
1004+
hint?: string;
1005+
}
1006+
1007+
/**
1008+
* How many advisories `printAuthoringAdvisories` renders in full before it
1009+
* switches to the withheld-count line. The cap itself is not the defect it
1010+
* guards against — see below — so it keeps the value it has always had.
1011+
*/
1012+
export const AUTHORING_ADVISORY_PRINT_LIMIT = 50;
1013+
1014+
/**
1015+
* Print author-time advisories, and — this is the point — say so when the
1016+
* list was cut.
1017+
*
1018+
* #11529: `os build` printed a fixed 50 detailed entries and then stopped,
1019+
* with nothing in the output saying the list had been truncated. Measured on
1020+
* `objectstack-ai/hotcrm` with the published 17.1.0 CLI: two runs, 80 and then
1021+
* 75 advisories, both printing exactly 50 entries and exactly 184 lines. The
1022+
* summary line counted all of them (`⚠ 80 author-time warning(s) — see
1023+
* above`) while only 50 were above, and removing five warnings made five
1024+
* previously-unprinted ones appear — which reads as a regression caused by the
1025+
* fix. Because the advisories are ordered by surface (pages, then views, then
1026+
* flows), a repo whose page warnings alone exceed the cap keeps every `view`
1027+
* and `flow` advisory permanently invisible.
1028+
*
1029+
* The defect is the SILENCE, not the cap. Truncated output that carries no
1030+
* notice is not merely incomplete — it is indistinguishable from complete, so
1031+
* an author who reads it and sees their file is clean has read a list that
1032+
* stopped early. That is the same shape as the dropped summary rows above
1033+
* (#10504, #10952): output that cannot distinguish "none" from "not shown".
1034+
*
1035+
* So the cap stays and the honesty line is added: over the limit, the exact
1036+
* remainder is named; at or under it, no such line appears. The pointer is
1037+
* `--json`, which already carries the whole set (`warnings: ruleAdvisories`)
1038+
* — a complete-output path that exists today, rather than a new flag.
1039+
*
1040+
* Rendering for a set at or under the limit is byte-for-byte what it was.
1041+
*/
1042+
export function printAuthoringAdvisories(
1043+
advisories: readonly AuthoringAdvisory[],
1044+
limit: number = AUTHORING_ADVISORY_PRINT_LIMIT,
1045+
): void {
1046+
if (advisories.length === 0) return;
1047+
1048+
for (const f of advisories.slice(0, limit)) {
1049+
printWarning(`${f.where}: ${f.message}`);
1050+
if (f.hint) console.log(chalk.dim(` ${f.hint}`));
1051+
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
1052+
}
1053+
1054+
const shown = Math.min(advisories.length, limit);
1055+
const withheld = advisories.length - shown;
1056+
if (withheld > 0) {
1057+
printWarning(
1058+
`… and ${withheld} more author-time warning(s) not shown (${shown} of ${advisories.length}) — re-run with --json for the full list`,
1059+
);
1060+
}
1061+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #11529 — `os build` / `os compile` printed a fixed 50 author-time advisories
5+
* and then stopped, with NOTHING in the output saying the list had been cut.
6+
*
7+
* Measured on `objectstack-ai/hotcrm` with the published 17.1.0 CLI: two
8+
* `objectstack build` runs over the same tree, before and after a five-warning
9+
* fix, printed 50 detailed entries each — 184 output lines, 52 warning lines,
10+
* both times — while the summary line counted 80 and then 75. Removing five
11+
* warnings did not shorten the list; it made room, and five advisories that
12+
* had been present all along appeared for the first time.
13+
*
14+
* The defect is the SILENCE, not the cap. A truncated report that carries no
15+
* notice is indistinguishable from a complete one, so an author who reads it
16+
* and sees their file is clean has read a list that stopped early. Same shape
17+
* as the dropped summary rows pinned in `print-metadata-stats-zero-row.test.ts`
18+
* (#10504, #10952): output that cannot distinguish "none" from "not shown".
19+
*
20+
* WHAT THESE PINS ASSERT — the pair, not the cap. A test that only checked
21+
* "50 entries printed" passes on the silent tree and pins nothing. So the
22+
* behaviour is pinned from both ends:
23+
*
24+
* - over the limit -> the output states how many were withheld;
25+
* - at or under it -> no such line appears at all.
26+
*
27+
* ALTITUDE: this pins `printAuthoringAdvisories` — the function `os build`'s
28+
* advisory block now consists of — rather than spawning the CLI, following the
29+
* `printMetadataStats` precedent set by the sibling fixes in this same family
30+
* (`print-metadata-stats-zero-row.test.ts`) and the `formatZodErrors` pattern
31+
* in `format-zod-union.test.ts`. No child process, so nothing here touches
32+
* `check:cli-test-child-env`.
33+
*/
34+
35+
import { describe, expect, it } from 'vitest';
36+
import {
37+
AUTHORING_ADVISORY_PRINT_LIMIT,
38+
printAuthoringAdvisories,
39+
type AuthoringAdvisory,
40+
} from '../src/utils/format.js';
41+
42+
/** Drop SGR sequences so an assertion reads the words, not chalk's opinion. */
43+
const stripAnsi = (s: string) => s.replace(/\u001B\[[0-9;]*m/g, '');
44+
45+
/** Run the printer and return everything it printed, as one string. */
46+
function render(advisories: readonly AuthoringAdvisory[], limit?: number): string {
47+
const captured: string[] = [];
48+
const original = console.log;
49+
console.log = (...args: unknown[]) => {
50+
captured.push(args.map(String).join(' '));
51+
};
52+
try {
53+
if (limit === undefined) printAuthoringAdvisories(advisories);
54+
else printAuthoringAdvisories(advisories, limit);
55+
} finally {
56+
console.log = original;
57+
}
58+
return stripAnsi(captured.join('\n'));
59+
}
60+
61+
/** One advisory in the shape the authoring-rule registry emits. */
62+
const advisory = (i: number): AuthoringAdvisory => ({
63+
where: `view "views[${i}]" form`,
64+
message: `absolute colSpan ${i}`,
65+
rule: 'absolute-colspan-discouraged',
66+
path: `views[${i}].form`,
67+
hint: 'use a fractional colSpan',
68+
});
69+
70+
const many = (n: number): AuthoringAdvisory[] => Array.from({ length: n }, (_, i) => advisory(i));
71+
72+
/** How many detail entries the output carries — one `rule:` line per entry. */
73+
const detailCount = (out: string) => out.split('\n').filter((l) => l.includes('rule: ')).length;
74+
75+
/**
76+
* The notice, recognised by what makes it honest rather than by its full
77+
* wording: it names a remainder and says that remainder was not shown.
78+
*/
79+
const NOTICE = /and (\d+) more author-time warning\(s\) not shown/;
80+
81+
describe('[#11529] the author-time advisory printer names what it withheld', () => {
82+
it('OVER the limit: states how many were withheld, and how many of how many were printed', () => {
83+
// The card's own measured run: 80 advisories against the shipped cap.
84+
// Literal numbers on purpose — this is the reproduction, so changing the
85+
// cap has to be a deliberate edit here rather than a silently-passing one.
86+
expect(AUTHORING_ADVISORY_PRINT_LIMIT).toBe(50);
87+
88+
const out = render(many(80));
89+
90+
// Before the fix the output simply ended after the 50th entry.
91+
expect(out).toMatch(NOTICE);
92+
expect(out).toContain('and 30 more author-time warning(s) not shown (50 of 80)');
93+
// And it points at a path that really does carry the whole set today,
94+
// rather than inventing a flag: `--json` publishes `warnings`.
95+
expect(out).toContain('--json');
96+
});
97+
98+
it('AT the limit: prints every advisory and NO withheld line — the other half of the pair', () => {
99+
const out = render(many(50));
100+
expect(detailCount(out)).toBe(50);
101+
// "50 printed" is true here AND on the truncated run above; only the
102+
// absence of the notice tells the two apart.
103+
expect(out).not.toMatch(NOTICE);
104+
expect(out).not.toContain('not shown');
105+
});
106+
107+
it('UNDER the limit: no withheld line', () => {
108+
const out = render(many(3), 10);
109+
expect(detailCount(out)).toBe(3);
110+
expect(out).not.toMatch(NOTICE);
111+
});
112+
113+
it('ONE over the limit: the notice appears and reads exactly 1 — the tightest edge', () => {
114+
const out = render(many(11), 10);
115+
expect(detailCount(out)).toBe(10);
116+
expect(NOTICE.exec(out)?.[1]).toBe('1');
117+
expect(out).toContain('(10 of 11)');
118+
});
119+
120+
it('the remainder is the EXACT count, not a fixed word', () => {
121+
const out = render(many(8), 5);
122+
expect(NOTICE.exec(out)?.[1]).toBe('3');
123+
expect(out).toContain('(5 of 8)');
124+
});
125+
126+
it('control: the detail entries are unchanged — the notice adds, it does not replace', () => {
127+
const out = render(many(80));
128+
expect(detailCount(out)).toBe(50);
129+
expect(out).toContain('view "views[0]" form: absolute colSpan 0');
130+
expect(out).toContain('use a fractional colSpan');
131+
expect(out).toContain('rule: absolute-colspan-discouraged at views[0].form');
132+
// The 50th entry is present and the 51st is not — the cap still caps.
133+
expect(out).toContain('at views[49].form');
134+
expect(out).not.toContain('at views[50].form');
135+
});
136+
137+
it('control: an empty set prints nothing at all — no notice, no blank advisory block', () => {
138+
expect(render([])).toBe('');
139+
});
140+
});

0 commit comments

Comments
 (0)