Skip to content

Commit a34e868

Browse files
committed
test(cli): pin convert-then-refuse on os validate / build / lint --json
A strict defineStack that converts page:header `description` and then refuses `requires: ['no-such-capability']` answers exit 1, STACK_CAPABILITY_UNKNOWN and exactly the one page-header-subtitle-alias notice on each door; the canonical-then-refuse control answers `[]`. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
1 parent dd7f3c8 commit a34e868

1 file changed

Lines changed: 64 additions & 2 deletions

File tree

‎packages/cli/test/stack-conversion-record-door.test.ts‎

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,24 @@
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

140158
const 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

165190
let 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+
268330
describe('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

Comments
 (0)