4141 * unbuilt default export still lints and still converts through the command's
4242 * own pass (`lint-conversion-notices.e2e.test.ts` pins that half).
4343 *
44+ * ## Convert, then REFUSE — the refusal carries the record, on all three doors (#20583)
45+ *
46+ * A strict `defineStack` that converts the retiring spelling and then refuses
47+ * (`requires: ['no-such-capability']`, `STACK_CAPABILITY_UNKNOWN`) returns no
48+ * stack, so there is no record on a default export to fold. The producer stamps
49+ * the notices it applied on the refusal it throws instead, and each door's
50+ * catch-all folds `stackConversionsOf(error)` into `conversions` beside the
51+ * refusal. Before that fold all three answered `conversions: []` and the notice
52+ * reached stderr alone.
53+ *
54+ * | door (`--json`) | converts, then refuses | canonical, then refuses (control) |
55+ * |:-------------------------|:-----------------------------|:----------------------------------|
56+ * | `os validate` / `build` / `lint` | exit 1, the refusal's `code`, the one notice | exit 1, the refusal's `code`, `[]` |
57+ *
58+ * The control holds the other direction: the same refusal from a source that
59+ * needed no conversion answers `[]`, so an exit that reports the notice without
60+ * reading it off the refusal it caught is red there.
61+ *
4462 * `page-header-subtitle-alias` is the live conversion driven here (`description`
4563 * on a `page:header` component, canonical `subtitle`). The day it retires from
4664 * the load path the non-empty rows go red; re-point the fixture at a live entry
@@ -131,10 +149,10 @@ const page = (ns: string, headerKey: 'description' | 'subtitle') => `{
131149 ] }],
132150 }` ;
133151
134- const stackBody = ( ns : string , headerKey : 'description' | 'subtitle' | null ) => `{
152+ const stackBody = ( ns : string , headerKey : 'description' | 'subtitle' | null , requires ?: string ) => `{
135153 manifest: ${ manifest ( ns ) } ,
136154 objects: [{ name: '${ ns } _thing', label: 'Thing', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }],
137- apps: [{ name: '${ ns } _app', label: 'App' }],${ headerKey ? `\n pages: [${ page ( ns , headerKey ) } ],` : '' }
155+ apps: [{ name: '${ ns } _app', label: 'App' }],${ headerKey ? `\n pages: [${ page ( ns , headerKey ) } ],` : '' } ${ requires ? `\n requires: [' ${ requires } '],` : '' }
138156}` ;
139157
140158const IMPORT = `import { composeStacks, defineStack } from '@objectstack/spec';\n\n` ;
@@ -160,6 +178,13 @@ const FIXTURES: Record<string, string> = {
160178 `export default defineStack(${ stackBody ( 'nep' , null ) } );\n` ,
161179 // The control: the canonical spelling.
162180 canonical : IMPORT + `export default defineStack(${ stackBody ( 'can' , 'subtitle' ) } );\n` ,
181+ // Convert, then refuse: the strict (default) `defineStack` converts the
182+ // retiring spelling, then refuses an unknown capability token.
183+ convertThenRefuse :
184+ IMPORT + `export default defineStack(${ stackBody ( 'ctr' , 'description' , 'no-such-capability' ) } );\n` ,
185+ // Its control: the same refusal from a source that needed no conversion.
186+ canonicalThenRefuse :
187+ IMPORT + `export default defineStack(${ stackBody ( 'ctc' , 'subtitle' , 'no-such-capability' ) } );\n` ,
163188} ;
164189
165190let root = '' ;
@@ -265,6 +290,43 @@ describe("os lint --json — the producer's conversion record reaches `conversio
265290 } , 180_000 ) ;
266291} ) ;
267292
293+ describe ( "convert, then REFUSE — the refusal carries the conversions into each door's `--json` (#20583)" , ( ) => {
294+ // The catch-all exit on every door: the load threw the producer's refusal, so
295+ // the record rides on the error, not on a returned stack.
296+ type Door = 'validate' | 'build' | 'lint' ;
297+ const DOORS : Record < Door , ( label : string ) => string [ ] > = {
298+ validate : ( ) => [ 'validate' , '--json' ] ,
299+ build : ( label ) => [ 'build' , '--json' , '-o' , join ( dirs [ label ] , 'out' , 'objectstack.json' ) ] ,
300+ lint : ( ) => [ 'lint' , '--json' ] ,
301+ } ;
302+ const REFUSAL_CODE = 'STACK_CAPABILITY_UNKNOWN' ;
303+
304+ /** The catch-all payload's own verdict, per door (`os lint`'s is the `error` string alone). */
305+ function expectCatchAll ( command : Door , run : Run , p : Payload & { error ?: unknown ; code ?: unknown } , label : string ) : void {
306+ expect ( run . code , `${ label } : the refusal fails the run\n${ run . stdout } ${ run . stderr } ` ) . toBe ( 1 ) ;
307+ expect ( p . code , `${ label } : the producer's refusal, unwrapped` ) . toBe ( REFUSAL_CODE ) ;
308+ expect ( typeof p . error , `${ label } : the catch-all exit` ) . toBe ( 'string' ) ;
309+ if ( command === 'validate' ) expect ( p . valid ) . toBe ( false ) ;
310+ if ( command === 'build' ) expect ( p . success ) . toBe ( false ) ;
311+ }
312+
313+ for ( const command of Object . keys ( DOORS ) as Door [ ] ) {
314+ it ( `os ${ command } --json: exit 1, the refusal, and the one notice the producer applied before refusing` , async ( ) => {
315+ const run = await runCli ( DOORS [ command ] ( 'convertThenRefuse' ) , dirs . convertThenRefuse ) ;
316+ const p = payloadOf ( run , `${ command } convertThenRefuse` ) ;
317+ expectCatchAll ( command , run , p , `${ command } convertThenRefuse` ) ;
318+ expectExactly ( p , [ THE_NOTICE ] , `${ command } convertThenRefuse` ) ;
319+ } , 180_000 ) ;
320+
321+ it ( `os ${ command } --json control: the same refusal from a source that needed no conversion — \`[]\`` , async ( ) => {
322+ const run = await runCli ( DOORS [ command ] ( 'canonicalThenRefuse' ) , dirs . canonicalThenRefuse ) ;
323+ const p = payloadOf ( run , `${ command } canonicalThenRefuse` ) ;
324+ expectCatchAll ( command , run , p , `${ command } canonicalThenRefuse` ) ;
325+ expectExactly ( p , [ ] , `${ command } canonicalThenRefuse` ) ;
326+ } , 180_000 ) ;
327+ }
328+ } ) ;
329+
268330describe ( 'os validate --strict — a conversion the producer applied fails it, on both faces' , ( ) => {
269331 it ( 'the retiring spelling: exit 1 on the text face and under --json' , async ( ) => {
270332 const text = await runCli ( [ 'validate' , '--strict' ] , dirs . recordAcrossSpread ) ;
0 commit comments