Skip to content

Commit ab23c67

Browse files
claude[bot]claude
andauthored
fix(cli): make os validate --json --strict exit 1 where --strict already does (#11299)
* fix(cli): make `os validate --json --strict` exit 1 where `--strict` already does (#11174) The `--json` branch emitted its payload and returned above the only `flags.strict` reader, which sat inside the text-rendering block. On one config with one flag the two faces answered differently: text exited 1 with "Strict mode: warnings treated as errors", `--json --strict` exited 0. The flag was accepted, documented for CI in `content/docs/deployment/cli.mdx`, and inert. Assemble the warning list once, above the `if (flags.json)` branch, and have both faces gate on that same list, so the two exit codes cannot drift apart again. The JSON status rides in `emitJson`'s `CliExitCode` slot — the declared channel for pairing a `--json` document with the status the shell reads — so the payload stays exactly one parseable document. The gate reads the text face's list rather than the payload's `warnings` field: the two differ by the ADR-0087 conversion notices, which the payload carries under `conversions`, and gating on the field would have left the same divergence for a conversion-only config. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR * docs(changeset): correct the migration advice — reproducing `--strict` needs `conversions` too (#11174) The changeset told a pipeline to "drop `--strict` and gate on the payload's `warnings` array, which has carried the full advisory set since #10953". That is false in exactly the dimension this PR makes load-bearing: the payload's `warnings` field is the five-way spread WITHOUT the ADR-0087 conversion notices, which ride under `conversions`. A pipeline following that advice would have got a strictly weaker gate than `--strict` — a conversions-only config passes it and fails `--strict` — which is the same silent under-reporting this change exists to remove, published as guidance. Say `warnings.length > 0 || conversions.length > 0`, say plainly that `warnings` alone is narrower and why, and state the resulting payload shape outright: a conversions-only config exits 1 with `"warnings": []` and a populated `conversions`, so predicting the exit code from `warnings.length` is wrong for that config. Changeset prose only — no source, test or bump change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d2619fd commit ab23c67

3 files changed

Lines changed: 336 additions & 20 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
fix(cli): `os validate --json --strict` exits 1 on the configs `--strict` already exits 1 for (#11174)
6+
7+
`commands/validate.ts` emitted the `--json` payload and `return`ed *above* the only
8+
`flags.strict` reader, which sat inside the text-rendering block. So on one config,
9+
one flag, two answers:
10+
11+
```
12+
os validate --strict → exit 1 ("Strict mode: warnings treated as errors")
13+
os validate --json --strict → exit 0
14+
```
15+
16+
`--strict` was accepted, documented — `content/docs/deployment/cli.mdx` spells
17+
`os validate --json --strict` twice in its CI/CD section, once as a GitHub Actions
18+
step — and inert whenever `--json` was also passed. That combination is the one
19+
audience the flag exists for: a pipeline gating on the exit status of the documented
20+
invocation read 0 and concluded the stack was clean.
21+
22+
The `--strict` gate now reads the text face's own warning list, which is assembled
23+
once and consumed by both faces, so the two exit codes cannot drift apart again. The
24+
gate deliberately does **not** read the payload's `warnings` field: the two differ by
25+
the ADR-0087 load-time conversion notices, which the text face folds into its warning
26+
block while the payload carries them under `conversions`. Gating on the field would
27+
have left the same divergence in place for a config whose only advisories are
28+
conversion notices. `specVersionGap` stays outside `--strict` on both faces, as it
29+
always has been on the text one.
30+
31+
`valid: true` beside a non-zero exit is the text face verbatim, not a contradiction:
32+
that path prints "Validation passed" and *then* fails for strict. The stack is
33+
schema-valid; `--strict` is what promotes its advisories to a failure.
34+
35+
**BREAKING** for one caller shape, and the reason this is not a patch: a pipeline
36+
running `os validate --json --strict` over a stack that raises non-blocking
37+
advisories was green and will now be red. Nothing was removed or renamed and no
38+
authored metadata changes — the accept set is identical and the exit status is the
39+
only thing that moves — but a release a CI system can take unattended must not flip
40+
a green build to red, so this does not belong in a patch. It is not a major either:
41+
the new behaviour *restores* what `--strict` declares ("Treat warnings as errors")
42+
and what the docs already advertise, rather than contradicting a contract. Under
43+
this repo's launch-window convention (breaking changes ship as `minor` while the
44+
stack versions in lockstep) `minor` is the honest slot.
45+
46+
## What a pipeline gating on the payload has to read
47+
48+
`--strict` gates on the text face's warning list, and that list is **not** the payload's
49+
`warnings` field. The two differ by the ADR-0087 load-time conversion notices: the text
50+
face folds them into its warning block, while the payload carries them separately under
51+
`conversions`. So a pipeline that wants to reproduce `--strict` from the document must
52+
read **both**:
53+
54+
```
55+
warnings.length > 0 || conversions.length > 0
56+
```
57+
58+
Gating on `warnings` alone is strictly weaker than `--strict` — a config whose only
59+
advisories are conversion notices passes that check and fails `--strict`. That is the
60+
same silent under-reporting this change exists to remove, so do not reach for the
61+
narrower spelling.
62+
63+
The consequence is reachable and worth stating outright, because it is surprising: a
64+
conversions-only config now exits **1** with `"warnings": []` and a populated
65+
`conversions`. Predicting the exit code from `warnings.length` alone will be wrong for
66+
exactly that config. Nothing is missing from the document — both advisory streams are in
67+
it — but they sit in two fields and the exit code answers to both.
68+
69+
If a pipeline genuinely wants the old exit status, the honest fix is to say so rather
70+
than to keep passing a flag that means the opposite: drop `--strict` and read the
71+
payload. If it goes red instead, the advisories were always there — the text face had
72+
been printing them all along.
73+
74+
<!-- adr-0087: not-required (no-migration-prescription) An exit-code parity fix on a CLI flag. No authorable key, export, config field or stored `sys_metadata` shape changes, so there is nothing for `objectstack migrate meta` or the upgrade guide to carry — the remedy is a pipeline-side choice of flag, not a rewrite of anything an author wrote. -->
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #11174 — `os validate --strict` reaches the SAME exit status with `--json` as
5+
* without it, over the real CLI process.
6+
*
7+
* ## The defect this pins shut
8+
*
9+
* `commands/validate.ts` emitted the `--json` payload and `return`ed *above* the
10+
* only `flags.strict` reader, which lived inside the text-rendering block. So on
11+
* a config raising four non-blocking advisories:
12+
*
13+
* os validate --strict → exit 1 ("Strict mode: warnings treated as errors")
14+
* os validate --json --strict → exit 0 (same config, same flag)
15+
*
16+
* `--strict` was accepted, documented — `content/docs/deployment/cli.mdx` spells
17+
* `os validate --json --strict` twice in its CI/CD section, once as a GitHub
18+
* Actions step — and inert. A pipeline gating on the exit status of the exact
19+
* documented invocation read 0 and concluded the stack was clean.
20+
*
21+
* This is the second half of a pair. The first (#10953) made the four structural
22+
* advisories *reachable* in the payload, so a pipeline could at least gate on
23+
* `warnings.length` itself; it did not touch the exit code, and a pipeline
24+
* trusting the exit status still could not.
25+
*
26+
* ## Why the assertions are a PARITY matrix and not `expect(code).toBe(1)`
27+
*
28+
* A hardcoded `1` pins one cell and says nothing about the property the card is
29+
* about: that the two faces of one command agree. Both sides here are read from
30+
* their own production source — the real exit status of two real CLI runs of the
31+
* same config — and compared to each other. Nothing states an expected code.
32+
*
33+
* That comparison alone is satisfiable two dishonest ways, and both are closed:
34+
*
35+
* - **vacuously**, by a config that exits 0 on both faces. The `warns` fixture
36+
* carries a FLOOR — its text run must exit non-zero — so equality is only
37+
* ever asserted over a run that genuinely had something to fail on. Without
38+
* it this file stays green with the fix reverted.
39+
* - **by breaking the other face** — making `--json --strict` and text agree at
40+
* 0. The `clean` fixture pins the zero end, so both faces are held to 1 on
41+
* warnings and to 0 without them; neither can move to meet the other.
42+
*
43+
* And one further run, without `--strict`, separates "gates on `--strict`" from
44+
* "fails whenever `--json` sees a warning" — two fixes that pass the matrix
45+
* above identically, only one of which is the one asked for.
46+
*
47+
* ## Why a real child process
48+
*
49+
* `process.exitCode` set inside a vitest worker is not an exit status: the
50+
* number only exists once Node has exited and the kernel has masked it to
51+
* `& 0xFF`. `test/migrate-exit-code.e2e.test.ts` is the precedent and states
52+
* this in the same words — the audience `--json` exists for reads the SHELL, so
53+
* that is what gets asserted. Spawned through `bin/run-dev.js` + tsx, so the
54+
* suite does not depend on `packages/cli/dist` having been built.
55+
*
56+
* ## Why this file is not beside its siblings in `packages/cli/test/`
57+
*
58+
* That directory was held by another in-flight card while this one was written,
59+
* so it was read-only to this change. `src/` turns out to be the stronger of the
60+
* two homes anyway, and deliberately so for the same reason
61+
* `utils/format.exit-code.test.ts` gives for living here: `packages/cli/
62+
* tsconfig.json` includes `src`, so `pnpm typecheck` compiles this file, while
63+
* no tsc program reads `packages/cli/test/`. `tsconfig.build.json` excludes
64+
* `src/**\/*.test.ts`, so nothing here ships.
65+
*/
66+
67+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
68+
import { execFile } from 'node:child_process';
69+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
70+
import { tmpdir } from 'node:os';
71+
import { join, resolve } from 'node:path';
72+
import { fileURLToPath } from 'node:url';
73+
74+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
75+
const CLI = resolve(HERE, '../../bin/run-dev.js');
76+
const TSX = resolve(HERE, '../../../../node_modules/.bin/tsx');
77+
78+
/**
79+
* Raises all four structural advisories at once and nothing else: no
80+
* `manifest`, no objects, no apps. The card's own measurement used this shape,
81+
* and it still parses — `manifest.id` is schema-REQUIRED once a `manifest` is
82+
* present, so a config that merely omits the id fails the parse and exits long
83+
* before any advisory is computed.
84+
*/
85+
const WARNS_SOURCE = `
86+
export default {
87+
objects: [],
88+
apps: [],
89+
};
90+
`;
91+
92+
/** The zero-warning control — pins the other end of the matrix. */
93+
const CLEAN_SOURCE = `
94+
export default {
95+
manifest: { id: 'com.example.strictexit', name: 'strictexit', version: '1.0.0', type: 'app', namespace: 'strictexit' },
96+
objects: [{
97+
name: 'strictexit_ticket',
98+
label: 'Ticket',
99+
sharingModel: 'private',
100+
fields: { title: { type: 'text', label: 'Title' } },
101+
}],
102+
apps: [{ name: 'strictexit_app', label: 'Strict Exit App' }],
103+
};
104+
`;
105+
106+
interface Run {
107+
code: number;
108+
stdout: string;
109+
stderr: string;
110+
}
111+
112+
function runCli(args: string[], cwd: string): Promise<Run> {
113+
return new Promise((resolvePromise) => {
114+
execFile(
115+
TSX,
116+
[CLI, ...args],
117+
{ cwd, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, NO_COLOR: '1' } },
118+
(err, stdout, stderr) => {
119+
resolvePromise({
120+
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
121+
stdout: String(stdout),
122+
stderr: String(stderr),
123+
});
124+
},
125+
);
126+
});
127+
}
128+
129+
let warnsDir: string;
130+
let cleanDir: string;
131+
132+
beforeAll(() => {
133+
warnsDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-warns-'));
134+
writeFileSync(join(warnsDir, 'objectstack.config.ts'), WARNS_SOURCE);
135+
cleanDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-clean-'));
136+
writeFileSync(join(cleanDir, 'objectstack.config.ts'), CLEAN_SOURCE);
137+
});
138+
139+
afterAll(() => {
140+
rmSync(warnsDir, { recursive: true, force: true });
141+
rmSync(cleanDir, { recursive: true, force: true });
142+
});
143+
144+
describe('#11174 — --strict reaches the same exit status on both faces', () => {
145+
it('a config with advisories: --json --strict exits exactly as --strict does', async () => {
146+
const text = await runCli(['validate', '--strict'], warnsDir);
147+
const json = await runCli(['validate', '--json', '--strict'], warnsDir);
148+
149+
// Anti-vacuity floor: equality below is only meaningful over a run that had
150+
// something to fail on. This is the reference face, so it is also the
151+
// statement that the text side has not moved to meet the JSON one.
152+
expect(
153+
text.code,
154+
`text --strict must fail on this config for the parity below to mean anything:\n${text.stdout}\n${text.stderr}`,
155+
).not.toBe(0);
156+
157+
expect(
158+
json.code,
159+
`--json --strict exited ${json.code} where --strict exited ${text.code}, same config.\n` +
160+
`json stdout:\n${json.stdout}\njson stderr:\n${json.stderr}`,
161+
).toBe(text.code);
162+
}, 120_000);
163+
164+
it('the failing --json run still emits exactly one parseable document, carrying the cause', async () => {
165+
// The exit code must not cost the payload. This file's sibling defect
166+
// (`isExitSignal` in `utils/format.ts`) was a `--json` failure path that
167+
// emitted TWO documents back to back, parseable as neither one document nor
168+
// as JSONL — so a non-zero `--json` run is pinned on the document too.
169+
const json = await runCli(['validate', '--json', '--strict'], warnsDir);
170+
171+
const payload = JSON.parse(json.stdout) as { valid?: unknown; warnings?: unknown };
172+
173+
// `valid: true` beside exit 1 is the text face's contract verbatim: it
174+
// prints "Validation passed" and THEN fails for strict. The config is
175+
// schema-valid; `--strict` is what turns its advisories into a failure.
176+
// Pinned because the pairing is new here and reads like a bug otherwise.
177+
expect(payload.valid).toBe(true);
178+
expect(Array.isArray(payload.warnings) && (payload.warnings as unknown[]).length).toBeGreaterThan(0);
179+
}, 120_000);
180+
181+
it('control: a config with nothing to warn about exits 0 on BOTH faces under --strict', async () => {
182+
const text = await runCli(['validate', '--strict'], cleanDir);
183+
expect(text.code, `text --strict:\n${text.stdout}\n${text.stderr}`).toBe(0);
184+
185+
const json = await runCli(['validate', '--json', '--strict'], cleanDir);
186+
expect(json.code, `json --strict:\n${json.stdout}\n${json.stderr}`).toBe(0);
187+
}, 120_000);
188+
189+
it('control: without --strict, the same advisory-raising config still exits 0 under --json', async () => {
190+
// Separates "gates on --strict" from "fails whenever --json sees a warning".
191+
// Both satisfy the parity matrix above; only the first is the flag's meaning.
192+
const json = await runCli(['validate', '--json'], warnsDir);
193+
expect(json.code, `--json without --strict must stay 0:\n${json.stdout}\n${json.stderr}`).toBe(0);
194+
}, 120_000);
195+
});

packages/cli/src/commands/validate.ts

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -247,24 +247,23 @@ export default class Validate extends Command {
247247
structuralWarnings.push('Missing manifest.namespace — required for multi-app hosting');
248248
}
249249

250-
if (flags.json) {
251-
await emitJson({
252-
valid: true,
253-
manifest: config.manifest,
254-
stats,
255-
// One advisory list for the whole registry. This used to be a
256-
// hand-maintained concatenation of per-gate arrays, and it leaked
257-
// twice: warnings computed and then dropped from `--json` while the
258-
// console printed them. A single list cannot drift from itself.
259-
warnings: [...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, ...capProviderWarnings, ...structuralWarnings],
260-
conversions: conversionNotices,
261-
specVersionGap: specGap,
262-
duration: timer.elapsed(),
263-
});
264-
return;
265-
}
266-
267-
// 5. Warnings (non-blocking)
250+
// 5. Warnings (non-blocking) — assembled HERE, above the `if (flags.json)`
251+
// branch, because this is the list `--strict` gates on and the JSON
252+
// face has to reach the SAME verdict from it. It could not: the payload
253+
// was emitted and `return`ed above the only `flags.strict` reader, so
254+
// `os validate --json --strict` exited 0 on the very configs
255+
// `os validate --strict` exited 1 for. The flag was accepted,
256+
// documented (`content/docs/deployment/cli.mdx` spells the pair twice
257+
// in its CI/CD section, once as a GitHub Actions step) and inert — a
258+
// pipeline gating on the exit status of the documented invocation read
259+
// 0 and called the stack clean.
260+
//
261+
// Hoisting the assembly rather than restating the condition is the same
262+
// move `structuralWarnings` just above and `unknownKeyWarnings` up
263+
// beside `normalized` already made, for the third time in this file:
264+
// ONE list, consumed by both faces, so the two exit codes cannot drift
265+
// from each other by construction. The push ORDER is unchanged, so the
266+
// text face's warning output is byte-for-byte what it was.
268267
const warnings: string[] = [];
269268

270269
// [#3366] Installable-provider hints — a declared capability whose provider
@@ -295,12 +294,56 @@ export default class Validate extends Command {
295294
warnings.push(`${w.path}: ${w.message}`);
296295
}
297296

298-
// The four structural advisories, computed above the `if (flags.json)`
299-
// branch so `--json` carries them too. Appended HERE, in the position the
297+
// The four structural advisories, computed further up so the `--json`
298+
// payload can carry them too. Appended HERE, last, in the position the
300299
// four inline `if` blocks used to occupy, so the text face's warning ORDER
301300
// is byte-for-byte what it was.
302301
warnings.push(...structuralWarnings);
303302

303+
if (flags.json) {
304+
await emitJson(
305+
{
306+
valid: true,
307+
manifest: config.manifest,
308+
stats,
309+
// One advisory list for the whole registry. This used to be a
310+
// hand-maintained concatenation of per-gate arrays, and it leaked
311+
// twice: warnings computed and then dropped from `--json` while the
312+
// console printed them. A single list cannot drift from itself.
313+
warnings: [...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, ...capProviderWarnings, ...structuralWarnings],
314+
conversions: conversionNotices,
315+
specVersionGap: specGap,
316+
duration: timer.elapsed(),
317+
},
318+
// `--strict` means one thing — "treat warnings as errors" — and it now
319+
// means it on both faces. The gate reads `warnings`, the text face's
320+
// OWN list, rather than the payload's `warnings` field: the two differ
321+
// by the ADR-0087 conversion notices, which the text face folds into
322+
// its `⚠` block while the payload carries them under `conversions`.
323+
// Gating on the payload field would have left `--json --strict` at 0
324+
// for a config whose only advisories are conversion notices — the
325+
// same divergence one collection narrower. `specVersionGap` stays out
326+
// on both faces; it is never gated by `--strict` (see below).
327+
//
328+
// `valid: true` beside a 1 is not a contradiction, it is the text
329+
// face verbatim: that path prints "Validation passed" and THEN fails
330+
// for strict. The stack IS schema-valid; `--strict` is what promotes
331+
// its advisories to a failure.
332+
//
333+
// The status rides in `emitJson`'s `CliExitCode` slot rather than a
334+
// following `this.exit(1)`, unlike the failure paths above: those
335+
// must stop a fall-through into the text rendering, while here the
336+
// payload is complete and the `return` is right there. The slot is
337+
// the declared channel for pairing a `--json` document with the
338+
// status the shell reads (`utils/format.ts`; pinned by
339+
// `utils/format.exit-code.test.ts` and `test/migrate-exit-code.e2e.test.ts`),
340+
// and it emits the one document without an ExitError unwinding
341+
// through the catch below.
342+
flags.strict && warnings.length > 0 ? 1 : 0,
343+
);
344+
return;
345+
}
346+
304347
// 6. Display results
305348
console.log('');
306349
printSuccess(`Validation passed ${chalk.dim(`(${timer.display()})`)}`);
@@ -321,6 +364,10 @@ export default class Validate extends Command {
321364
for (const w of warnings) {
322365
console.log(chalk.yellow(` ⚠ ${w}`));
323366
}
367+
// The text face's half of the `--strict` gate. Its JSON counterpart is
368+
// the `CliExitCode` argument at the `emitJson` call above, reading this
369+
// same `warnings` list — change one and change the other, or the two
370+
// faces start disagreeing about the exit status again.
324371
if (flags.strict) {
325372
console.log('');
326373
printError('Strict mode: warnings treated as errors');

0 commit comments

Comments
 (0)