4444 * "fails whenever `--json` sees a warning" — two fixes that pass the matrix
4545 * above identically, only one of which is the one asked for.
4646 *
47+ * ## The conversions-only cell (#11301)
48+ *
49+ * `--strict` gates on the TEXT face's warning list, and that list folds in the
50+ * ADR-0087 D2 load-time conversion notices. The payload carries those notices
51+ * separately, under `conversions`; its own `warnings` field is the five-way
52+ * spread WITHOUT them. So a config whose only advisories are conversion notices
53+ * exits 1 carrying `{ valid: true, warnings: [], conversions: [...] }` — the one
54+ * cell where the exit code is decided by a collection absent from the payload
55+ * field a reader reaches for first. Every fixture above raises zero conversions,
56+ * so a regression narrowing the gate back to `payload.warnings` would restore
57+ * the original divergence for exactly these configs with all of them green.
58+ *
59+ * The two fixtures below are a MINIMAL PAIR from one template, differing in a
60+ * single key on one page-header component: `description`, the alias
61+ * `page-header-subtitle-alias` rewrites at load, against `subtitle`, the
62+ * canonical spelling that converts nothing. So the pin discriminates on the
63+ * CONVERSION and not on "a page is present" — measured on the pair before it
64+ * was written: `description` → text `--strict` 1, `--json --strict` 1, `--json`
65+ * 0, `warnings: []`, one notice; `subtitle` → 0 on every face, no notices.
66+ *
67+ * The non-empty `conversions` assertion is the anti-vacuity guard, and it is
68+ * load-bearing rather than decorative. `page-header-subtitle-alias` is a LIVE
69+ * window that retires from the load path at protocol 18; the day it retires,
70+ * this fixture raises nothing and, without that assertion, the file would keep
71+ * passing while pinning an empty cell — precisely the failure this test exists
72+ * to remove. It goes red instead, and whoever retires the entry re-points the
73+ * fixture at another live conversion. (The obvious candidate for this fixture,
74+ * `object-compactLayout-to-highlightFields`, is already `retiredFromLoadPath`:
75+ * the schema tombstones the key, so it raises a validation ERROR, not a notice.)
76+ *
4777 * ## Why a real child process
4878 *
4979 * `process.exitCode` set inside a vitest worker is not an exit status: the
@@ -103,6 +133,35 @@ export default {
103133};
104134` ;
105135
136+ /**
137+ * The conversions-only minimal pair (#11301) — `CLEAN_SOURCE`'s shape plus one
138+ * `page:header` component, whose second line is authored under the key named.
139+ *
140+ * Nothing else here raises an advisory: the unknown-key lints run on the
141+ * POST-conversion `normalized`, so what they see is the canonical `subtitle`
142+ * either way, and both members are pinned to `warnings: []` below rather than
143+ * assumed to be.
144+ */
145+ const headerPageSource = ( headerTextKey : 'description' | 'subtitle' ) : string => `
146+ export default {
147+ manifest: { id: 'com.example.strictexit', name: 'strictexit', version: '1.0.0', type: 'app', namespace: 'strictexit' },
148+ objects: [{
149+ name: 'strictexit_ticket',
150+ label: 'Ticket',
151+ sharingModel: 'private',
152+ fields: { title: { type: 'text', label: 'Title' } },
153+ }],
154+ apps: [{ name: 'strictexit_app', label: 'Strict Exit App' }],
155+ pages: [{
156+ name: 'strictexit_home',
157+ label: 'Home',
158+ regions: [{ name: 'main', components: [
159+ { type: 'page:header', properties: { title: 'Tickets', ${ headerTextKey } : 'All open tickets' } },
160+ ] }],
161+ }],
162+ };
163+ ` ;
164+
106165interface Run {
107166 code : number ;
108167 stdout : string ;
@@ -128,17 +187,25 @@ function runCli(args: string[], cwd: string): Promise<Run> {
128187
129188let warnsDir : string ;
130189let cleanDir : string ;
190+ let conversionsDir : string ;
191+ let conversionsCanonDir : string ;
131192
132193beforeAll ( ( ) => {
133194 warnsDir = mkdtempSync ( join ( tmpdir ( ) , 'os-validate-strict-exit-warns-' ) ) ;
134195 writeFileSync ( join ( warnsDir , 'objectstack.config.ts' ) , WARNS_SOURCE ) ;
135196 cleanDir = mkdtempSync ( join ( tmpdir ( ) , 'os-validate-strict-exit-clean-' ) ) ;
136197 writeFileSync ( join ( cleanDir , 'objectstack.config.ts' ) , CLEAN_SOURCE ) ;
198+ conversionsDir = mkdtempSync ( join ( tmpdir ( ) , 'os-validate-strict-exit-conversions-' ) ) ;
199+ writeFileSync ( join ( conversionsDir , 'objectstack.config.ts' ) , headerPageSource ( 'description' ) ) ;
200+ conversionsCanonDir = mkdtempSync ( join ( tmpdir ( ) , 'os-validate-strict-exit-conversions-canon-' ) ) ;
201+ writeFileSync ( join ( conversionsCanonDir , 'objectstack.config.ts' ) , headerPageSource ( 'subtitle' ) ) ;
137202} ) ;
138203
139204afterAll ( ( ) => {
140205 rmSync ( warnsDir , { recursive : true , force : true } ) ;
141206 rmSync ( cleanDir , { recursive : true , force : true } ) ;
207+ rmSync ( conversionsDir , { recursive : true , force : true } ) ;
208+ rmSync ( conversionsCanonDir , { recursive : true , force : true } ) ;
142209} ) ;
143210
144211describe ( '#11174 — --strict reaches the same exit status on both faces' , ( ) => {
@@ -186,6 +253,62 @@ describe('#11174 — --strict reaches the same exit status on both faces', () =>
186253 expect ( json . code , `json --strict:\n${ json . stdout } \n${ json . stderr } ` ) . toBe ( 0 ) ;
187254 } , 120_000 ) ;
188255
256+ it ( 'conversions-only: the cell where --strict is decided by a collection the payload keeps OUT of `warnings`' , async ( ) => {
257+ const text = await runCli ( [ 'validate' , '--strict' ] , conversionsDir ) ;
258+ const json = await runCli ( [ 'validate' , '--json' , '--strict' ] , conversionsDir ) ;
259+
260+ // Same floor as the first case: equality is only worth asserting over a run
261+ // that genuinely had something to fail on.
262+ expect (
263+ text . code ,
264+ `text --strict must fail on the conversions-only config:\n${ text . stdout } \n${ text . stderr } ` ,
265+ ) . not . toBe ( 0 ) ;
266+
267+ expect (
268+ json . code ,
269+ `--json --strict exited ${ json . code } where --strict exited ${ text . code } , same config.\n` +
270+ `json stdout:\n${ json . stdout } \njson stderr:\n${ json . stderr } ` ,
271+ ) . toBe ( text . code ) ;
272+
273+ const payload = JSON . parse ( json . stdout ) as {
274+ valid ?: unknown ;
275+ warnings ?: unknown ;
276+ conversions ?: unknown ;
277+ } ;
278+
279+ // The cell spelled out. `warnings: []` is asserted, not tolerated: it is the
280+ // whole point — narrow the gate to this field and the run above drops to 0
281+ // while the text face stays at 1.
282+ expect ( payload . valid ) . toBe ( true ) ;
283+ expect ( payload . warnings ) . toEqual ( [ ] ) ;
284+ expect (
285+ Array . isArray ( payload . conversions ) && ( payload . conversions as unknown [ ] ) . length ,
286+ 'the fixture raised NO conversion — the alias has most likely retired from ' +
287+ 'the load path; re-point `headerPageSource` at a live entry in ' +
288+ '`packages/spec/src/conversions/registry.ts` rather than deleting this line' ,
289+ ) . toBeGreaterThan ( 0 ) ;
290+
291+ // Separates "gates on --strict" from "fails whenever a conversion is seen".
292+ // The warnings fixture's own without-strict control cannot cover this: it
293+ // raises no conversions, so it passes under either behaviour.
294+ const loose = await runCli ( [ 'validate' , '--json' ] , conversionsDir ) ;
295+ expect ( loose . code , `--json without --strict must stay 0:\n${ loose . stdout } \n${ loose . stderr } ` ) . toBe ( 0 ) ;
296+ } , 120_000 ) ;
297+
298+ it ( 'control: the same page under the CANONICAL key converts nothing and exits 0 on both faces' , async ( ) => {
299+ // The discriminator. Byte-identical to the fixture above but for one key,
300+ // so a pin that passed here too would be pinning the presence of a page.
301+ const text = await runCli ( [ 'validate' , '--strict' ] , conversionsCanonDir ) ;
302+ expect ( text . code , `text --strict:\n${ text . stdout } \n${ text . stderr } ` ) . toBe ( 0 ) ;
303+
304+ const json = await runCli ( [ 'validate' , '--json' , '--strict' ] , conversionsCanonDir ) ;
305+ expect ( json . code , `json --strict:\n${ json . stdout } \n${ json . stderr } ` ) . toBe ( 0 ) ;
306+
307+ const payload = JSON . parse ( json . stdout ) as { warnings ?: unknown ; conversions ?: unknown } ;
308+ expect ( payload . warnings ) . toEqual ( [ ] ) ;
309+ expect ( payload . conversions ) . toEqual ( [ ] ) ;
310+ } , 120_000 ) ;
311+
189312 it ( 'control: without --strict, the same advisory-raising config still exits 0 under --json' , async ( ) => {
190313 // Separates "gates on --strict" from "fails whenever --json sees a warning".
191314 // Both satisfy the parity matrix above; only the first is the flag's meaning.
0 commit comments