@@ -16,7 +16,7 @@ import { loadConfig } from '../utils/config.js';
1616import { runAuthoringRules , splitBySeverity , authoringRulesFor } from '@objectstack/lint' ;
1717import { resolveSduiManifest } from '../utils/sdui-manifest.js' ;
1818import { 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' ;
2020import {
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