Skip to content

Commit 33e81a5

Browse files
os-trumpclaude
andauthored
feat(cli): carry computed advisory lists on every os validate --json failure exit (#12130)
All five failure exits of `os validate --json` published strictly less than the run had already computed: two carried `ruleAdvisories` alone, three carried no advisory list at all. The text face points at `--json` for the full list, so a tree failing a later gate made that remedy unreachable. The strongest instance is the parse-failure exit, which dropped the undeclared-authoring-key findings computed PRE-parse specifically so they would survive an unrelated schema error. The success payload's five-list spread moves to one `warningsSoFar()` site that every exit reads, so the member order cannot drift between exits. Lists are CARRIED, never recomputed: each stays computed at the step that owns it, and `structuralWarnings` — computed below all five failure exits — therefore rides each of them empty. Ref #12047 Claude-Session: https://claude.ai/code/session_01HbG3rGVLjZStHQxHDtzJdJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent adea66d commit 33e81a5

3 files changed

Lines changed: 716 additions & 9 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
feat(cli): `os validate --json` carries the computed advisory lists on every failure exit, not the success payload alone (#12047)
6+
7+
**Machine-contract widening on the `--json` failure payloads.** A consumer that
8+
today branches on `warnings` being ABSENT from an `os validate --json` failure
9+
payload, or that reads it as "the author-time rule advisories", sees a
10+
different shape after this change.
11+
12+
## What was wrong
13+
14+
The text face prints its advisory blocks ending `— re-run with --json for the
15+
full list`, but `warnings` lived on the TERMINAL SUCCESS payload only — plus
16+
`ruleAdvisories` alone on two of the five failure exits. So the remedy the
17+
notice named returned a payload that did not contain the list, and the author
18+
could not reach the withheld entries by any route until an unrelated later
19+
failure was fixed.
20+
21+
The strongest instance is the parse-failure exit. `validate.ts` computes the
22+
#3786 undeclared-authoring-key findings **before** the schema parse, precisely
23+
so a finding survives an unrelated schema error — the parse is what strips the
24+
key, so it cannot be recovered afterwards. That payload then dropped the list
25+
anyway, defeating the one hoist that existed to prevent exactly this.
26+
27+
## Which exits gain the field
28+
29+
All five failure exits of `os validate --json`. Two already carried a partial
30+
list; three carried none. `warnings` is now present on every one, alongside
31+
each exit's existing keys, which are unchanged:
32+
33+
| exit | existing keys | `warnings` before | after |
34+
| --- | --- | --- | --- |
35+
| protocol parse failure | `errors` | absent | undeclared-key findings |
36+
| author-time rules failed | `errors` | `ruleAdvisories` | rule + key |
37+
| capability provider check | `errors` | absent | rule + key + capability |
38+
| package docs failed | `errors` | `ruleAdvisories` | rule + doc + key + capability |
39+
| thrown / caught | `error` | absent | what the run had computed |
40+
41+
The success payload is unchanged in content: its
42+
`[...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, ...capProviderWarnings, ...structuralWarnings]`
43+
spread moved to a single `warningsSoFar()` site that every exit now reads, so
44+
the member order cannot drift between exits.
45+
46+
`structuralWarnings` is the one member `os validate` has that `os build` does
47+
not, and it is **carried, not hoisted**: it is computed below all five failure
48+
exits, so it rides each of them as an empty list and the success payload stays
49+
the only exit that can ever show it non-empty.
50+
51+
## What a consumer keying off its absence should do instead
52+
53+
`warnings` is no longer a signal of which exit produced the payload. Read
54+
`valid` (and `error` / `errors`) for that; a consumer that inferred "this is a
55+
failure payload" from a missing `warnings` must switch to `valid === false`.
56+
57+
`warnings` on a failure payload is no longer only the author-time rule
58+
advisories. It is the same heterogeneous list the success payload publishes —
59+
rule and doc findings as RECORDS, undeclared-key and structural advisories as
60+
STRINGS — truncated to what the run had computed. A consumer that assumed every
61+
entry was a rule finding must classify by shape.
62+
63+
`warnings: []` on a failure payload does NOT mean "this tree raises no
64+
advisories". It means **this run stopped before those advisories were
65+
computed** — a config that fails to load reports `[]` by construction. A
66+
consumer that needs the full advisory set for a tree must read it from a run
67+
that reaches at least the gate that computes it.
68+
69+
`warnings` is always an array on every `os validate --json` payload, success
70+
or failure, so it can be read unconditionally — that shape constancy is the
71+
point of the change (maintainer ruling 2026-08-25 on #11772, option 1 of three,
72+
inherited here under the same-family rule; option 2, "carry them only where the
73+
text face printed them", was rejected as the hardest contract to declare).
74+
75+
Exit codes are untouched: every failure exit still exits 1, and `--strict`
76+
still reads the text face's own list, so `os validate --json --strict` reaches
77+
the same verdict it did before.
78+
79+
Advisories stay CARRIED, never recomputed: each list is still computed at
80+
exactly the step that owns it, so an exit upstream of a step legitimately
81+
reports that list empty and no failure path pays for a computation it did not
82+
already do.

packages/cli/src/commands/validate.ts

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { loadConfig } from '../utils/config.js';
1616
import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint';
1717
import { resolveSduiManifest } from '../utils/sdui-manifest.js';
1818
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
19-
import { collectAndLintDocs } from '../utils/collect-docs.js';
19+
import { collectAndLintDocs, type DocIssue } from '../utils/collect-docs.js';
2020
import {
2121
printHeader,
2222
printKV,
@@ -57,6 +57,76 @@ export default class Validate extends Command {
5757
printHeader('Validate');
5858
}
5959

60+
// [#12047] THE ADVISORY LISTS THIS RUN HAS COMPUTED SO FAR, hoisted out of
61+
// the `try` so that EVERY `emitJson` exit can read them — not the terminal
62+
// success payload alone.
63+
//
64+
// The defect: all five failure exits published strictly less than the run
65+
// had already computed. Two carried `ruleAdvisories` and nothing else; the
66+
// other three carried no advisory list at all. The text face prints these
67+
// blocks ending `— re-run with --json for the full list`, so an author
68+
// whose tree failed a LATER gate was told to re-run with `--json` and got
69+
// a payload without the withheld entries in it — the "the remedy named is
70+
// unreachable" shape of #11643 and #11391.
71+
//
72+
// The strongest instance is the parse-failure exit. `unknownKeyWarnings`
73+
// is computed PRE-parse (see its own note below) precisely so the finding
74+
// survives an unrelated schema error — and then that exit dropped it
75+
// anyway, defeating the one hoist that existed to prevent exactly this.
76+
//
77+
// Maintainer ruling 2026-08-25 on #11772, inherited here under the
78+
// same-family rule: every failure exit carries the lists the run has
79+
// ALREADY COMPUTED, so `warnings` means the same thing on every exit and a
80+
// machine consumer has exactly one way to read it. Option 2 — carry them
81+
// only where the text face printed them, making the payload's SHAPE depend
82+
// on how far the run got — was rejected as the hardest contract to
83+
// declare. Option 3 (weaken the pointer) was rejected as making the
84+
// product worse.
85+
//
86+
// ⛔ CARRYING, NOT COMPUTING. Every list stays computed at exactly the step
87+
// that owns it; these bindings only make the value visible to the exits
88+
// DOWNSTREAM of that step. An exit that runs before a given step therefore
89+
// still reports that list empty, and that is the honest reading of "what
90+
// the run has already computed". Hoisting a computation earlier so an
91+
// early exit looks fuller would be option 2 wearing option 1's clothes,
92+
// and it would change what the command costs on its failure paths too.
93+
//
94+
// ⛔ `structuralWarnings` is the member that is measured, not assumed. It
95+
// is computed LAST — below every one of the five failure exits — so it
96+
// rides `warningsSoFar()` as an empty list on all of them, and the only
97+
// exit that can ever see it non-empty is the success payload. It is a
98+
// member of the same class as the other four (a non-blocking advisory
99+
// about the stack, gated by `--strict`, already in the success payload's
100+
// `warnings`); it differs only in WHEN it becomes available, which is the
101+
// same axis `docWarnings` and `capProviderWarnings` already differ on. It
102+
// is included here rather than special-cased so the order lives at ONE
103+
// site — ⛔ do not "fix" its emptiness by moving its computation up.
104+
//
105+
// ORDER IS THE SUCCESS PAYLOAD'S, stated ONCE here and read by that
106+
// payload too — the "one list cannot drift from itself" idiom this file
107+
// has already had to apply three times. The spread used to be written out
108+
// at the payload, so a seventh exit could have been added with a different
109+
// member order and nothing would have caught it.
110+
// Typed off `splitBySeverity` rather than by naming `AuthoringFinding`: the
111+
// #4409 import scan (packages/lint/src/authoring-rule-wiring.test.ts) reads
112+
// every symbol this file names from `@objectstack/lint` and strips `type `
113+
// rather than exempting it, and `splitBySeverity` — which produces this
114+
// list — is already ratcheted there. Binding the annotation to the producer
115+
// is also the tighter statement: the list cannot disagree with the function
116+
// that fills it.
117+
let ruleAdvisories: ReturnType<typeof splitBySeverity>['advisories'] = [];
118+
let capProviderWarnings: Array<{ token: string; message: string }> = [];
119+
let unknownKeyWarnings: string[] = [];
120+
let docWarnings: DocIssue[] = [];
121+
let structuralWarnings: string[] = [];
122+
const warningsSoFar = () => [
123+
...ruleAdvisories,
124+
...docWarnings,
125+
...unknownKeyWarnings,
126+
...capProviderWarnings,
127+
...structuralWarnings,
128+
];
129+
60130
try {
61131
// 1. Load configuration
62132
if (!flags.json) printStep('Loading configuration...');
@@ -83,7 +153,7 @@ export default class Validate extends Command {
83153
// carries the key the author actually wrote. Computed here rather than
84154
// down in the warnings section so the `--json` path reports it too — the
85155
// "computed, then discarded" shape this file already had to fix once.
86-
const unknownKeyWarnings = [
156+
unknownKeyWarnings = [
87157
...lintUnknownStackKeys(normalized as Record<string, unknown>, ObjectStackDefinitionSchema),
88158
...lintUnknownAuthoringKeys(normalized as Record<string, unknown>, ObjectStackDefinitionSchema),
89159
].map(formatUnknownAuthoringKey);
@@ -94,6 +164,11 @@ export default class Validate extends Command {
94164
await emitJson({
95165
valid: false,
96166
errors: (result.error as unknown as ZodError).issues,
167+
// [#12047] The list computed at `unknownKeyWarnings` above — six
168+
// lines up, and dropped here until now. This is the exit the card
169+
// called the strongest instance: the hoist exists so the finding
170+
// SURVIVES a schema error, and this payload discarded it anyway.
171+
warnings: warningsSoFar(),
97172
duration: timer.elapsed(),
98173
});
99174
this.exit(1);
@@ -123,7 +198,8 @@ export default class Validate extends Command {
123198
parsed: result.data as Record<string, unknown>,
124199
sduiManifest: resolveSduiManifest(),
125200
});
126-
const { errors: ruleErrors, advisories: ruleAdvisories } = splitBySeverity(findings);
201+
const { errors: ruleErrors, advisories } = splitBySeverity(findings);
202+
ruleAdvisories = advisories;
127203

128204
if (ruleErrors.length > 0) {
129205
// Every failing rule reports at once. The command used to exit at the
@@ -133,7 +209,10 @@ export default class Validate extends Command {
133209
await emitJson({
134210
valid: false,
135211
errors: ruleErrors,
136-
warnings: ruleAdvisories,
212+
// [#12047] Was `ruleAdvisories` alone. Reading the shared site adds
213+
// the pre-parse `unknownKeyWarnings` — computed long before this
214+
// gate — and keeps the member ORDER identical to every other exit.
215+
warnings: warningsSoFar(),
137216
duration: timer.elapsed(),
138217
});
139218
this.exit(1);
@@ -166,7 +245,7 @@ export default class Validate extends Command {
166245
projectDir: dirname(absolutePath),
167246
});
168247
const capProviderErrors = capProviderPreflight.errors;
169-
const capProviderWarnings = capProviderPreflight.warnings.map((c) => ({
248+
capProviderWarnings = capProviderPreflight.warnings.map((c) => ({
170249
token: c.token,
171250
message: renderCapabilityMessage(c),
172251
}));
@@ -175,6 +254,10 @@ export default class Validate extends Command {
175254
await emitJson({
176255
valid: false,
177256
errors: capProviderErrors.map((c) => ({ token: c.token, message: renderCapabilityMessage(c) })),
257+
// [#12047] The FATAL tokens ride `errors`; the advisory ones ride
258+
// `warnings` beside the two lists computed before this gate. The
259+
// two classes being separate is the whole point of the split.
260+
warnings: warningsSoFar(),
178261
duration: timer.elapsed(),
179262
});
180263
this.exit(1);
@@ -200,13 +283,17 @@ export default class Validate extends Command {
200283
if (!flags.json) printStep('Checking package docs (ADR-0046)...');
201284
const docsResult = collectAndLintDocs(absolutePath, result.data as Record<string, unknown>);
202285
const docErrors = docsResult.issues.filter((i) => i.severity === 'error');
203-
const docWarnings = docsResult.issues.filter((i) => i.severity !== 'error');
286+
docWarnings = docsResult.issues.filter((i) => i.severity !== 'error');
204287
if (docErrors.length > 0) {
205288
if (flags.json) {
206289
await emitJson({
207290
valid: false,
208291
errors: docErrors,
209-
warnings: ruleAdvisories,
292+
// [#12047] Was `ruleAdvisories` alone, on the very exit that had
293+
// the most computed: the doc advisories from this same call, the
294+
// capability hints, and the pre-parse key findings were all in
295+
// hand and none of them reached the payload.
296+
warnings: warningsSoFar(),
210297
duration: timer.elapsed(),
211298
});
212299
this.exit(1);
@@ -235,7 +322,7 @@ export default class Validate extends Command {
235322
// conditions were. Computed once and consumed by BOTH faces below, so
236323
// the two cannot disagree by construction — the same "a single list
237324
// cannot drift from itself" move this file already had to make twice.
238-
const structuralWarnings: string[] = [];
325+
structuralWarnings = [];
239326
if (stats.objects === 0) {
240327
structuralWarnings.push('No objects defined — this stack has no data model');
241328
}
@@ -312,7 +399,13 @@ export default class Validate extends Command {
312399
// hand-maintained concatenation of per-gate arrays, and it leaked
313400
// twice: warnings computed and then dropped from `--json` while the
314401
// console printed them. A single list cannot drift from itself.
315-
warnings: [...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, ...capProviderWarnings, ...structuralWarnings],
402+
// [#12047] The spread that used to be written out here now lives
403+
// at `warningsSoFar()` above, which every one of the six exits
404+
// reads. Content is unchanged on this payload — what changed is
405+
// that a seventh exit cannot be added with a different member
406+
// order, and the five failure exits no longer publish less than
407+
// this one.
408+
warnings: warningsSoFar(),
316409
conversions: conversionNotices,
317410
specVersionGap: specGap,
318411
duration: timer.elapsed(),
@@ -391,6 +484,12 @@ export default class Validate extends Command {
391484
await emitJson({
392485
valid: false,
393486
error: error.message,
487+
// [#12047] Whatever the run had reached before the throw. A config
488+
// that dies in `loadConfig` reports `[]` here honestly — nothing was
489+
// computed yet — while a throw from a later step (a `src/docs` that
490+
// is a FILE, say, which makes `readdirSync` raise ENOTDIR) carries
491+
// the three lists already in hand.
492+
warnings: warningsSoFar(),
394493
duration: timer.elapsed(),
395494
});
396495
this.exit(1);

0 commit comments

Comments
 (0)