Skip to content

Commit 6f727e4

Browse files
committed
test(cli): pin that build/validate parse metadata through the registry schemas (#5000)
#5000 measured that `os build` / `os validate` never parse `page` metadata through `PageSchema`, so an undeclared page-component key shipped into the artifact and #4001's "all three example apps validate clean" was empty evidence on the page surface. Re-measured on origin/main the claim does not hold: both commands parse the whole stack through `ObjectStackDefinitionSchema`, whose `pages` element is the schema `getMetadataTypeSchema('page')` returns. The issue's repro and its own negative control (`responsiveStyles.large` -> `.lg`) both exit non-zero today, and `os build` writes no artifact for a rejected stack. Nothing pinned either half of that, so this adds the evidence the issue found missing: - per registered metadata type, one undeclared key gets the same verdict from the write-path gate and from the schema the CLI parses through (the three structurally-different carriers -- field, translation, view -- are named and asserted at their real positions), plus a reconciliation that fails when a newly registered type is classified by nobody; - the two commands act on that parse, run through the real binary, with a control case (same stack, no planted key) that must exit 0. Behavioural rather than schema-instance identity on purpose: spec ships one bundle per entry point, so the root entry's embedded `PageSchema` and `@objectstack/spec/kernel`'s registry copy are never `===` (filed as #5379). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VkPSGsX9o17MsGv3Lbxu2w
1 parent 2f6516e commit 6f727e4

1 file changed

Lines changed: 314 additions & 0 deletions

File tree

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Does the CLI hold metadata to the SAME schema the write path does? (#5000)
5+
*
6+
* #5000 measured that `os build` / `os validate` "never parse page metadata by
7+
* `PageSchema`": an undeclared key on a page component was said to pass both
8+
* commands and land in `dist/objectstack.json`, which would make the #4001
9+
* acceptance line — "all three example apps `validate` clean" — empty evidence
10+
* on the page surface, because nothing on that path ever parsed a page.
11+
*
12+
* Re-measured against `origin/main`, the claim does not hold. Both commands
13+
* parse the WHOLE stack through `ObjectStackDefinitionSchema`, and its `pages`
14+
* element is the very object `getMetadataTypeSchema('page')` returns — the same
15+
* gate `MetadataManager.validate` and `GET /api/v1/meta` use. The issue's own
16+
* repro and its own negative control both exit non-zero today.
17+
*
18+
* So this file is not the gate the issue asked for; it is the evidence the
19+
* issue found missing. Nothing pinned either half of the claim, which is why a
20+
* stale `packages/spec/dist` (AGENTS.md §9) or a refactor onto a lenient
21+
* publish shape could reopen it without a single test turning red. Two claims,
22+
* because they fail independently:
23+
*
24+
* A. the CLI parses through the registry's schemas — one undeclared key, the
25+
* same verdict from both gates, for every registered metadata type, with
26+
* the three structurally-different carriers named and placed;
27+
* B. the commands GATE on that parse — the issue's undeclared-key repro and
28+
* the #4001 batch-13 `responsiveStyles.large` → `.lg` negative control,
29+
* run through the real binary: non-zero exit, prescription in the output,
30+
* and `os build` writes no artifact.
31+
*
32+
* (A) without (B) is a strict schema whose verdict a command swallows — the
33+
* #3782 shape. (B) without (A) is a command that gates on a schema nobody
34+
* checked is the canonical one. Both have happened here before: #3782 wired
35+
* four lints into `os build` alone, and #4409 found 23 of 26 rules running on
36+
* a strict subset of the three authoring commands.
37+
*/
38+
39+
import { describe, expect, it } from 'vitest';
40+
import { execFileSync } from 'node:child_process';
41+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
42+
import { tmpdir } from 'node:os';
43+
import { join } from 'node:path';
44+
import { fileURLToPath } from 'node:url';
45+
import { ObjectStackDefinitionSchema } from '@objectstack/spec';
46+
import { getMetadataTypeSchema, listMetadataTypeSchemaTypes } from '@objectstack/spec/kernel';
47+
48+
const cliBin = join(fileURLToPath(new URL('.', import.meta.url)), '..', 'bin', 'run-dev.js');
49+
50+
/** The key #5000 injected. Kept verbatim so the repro reads as the issue wrote it. */
51+
const INJECTED_KEY = 'aKeyPageComponentHasRejectedSinceADR0089';
52+
53+
/**
54+
* Registered metadata type → the stack-root collection the CLI parses it in.
55+
*
56+
* Asserted behaviourally (same undeclared key, same verdict on both gates)
57+
* rather than by schema-instance identity: `@objectstack/spec` ships one bundle
58+
* per entry point, so `@objectstack/spec/kernel`'s `getMetadataTypeSchema('page')`
59+
* and the `PageSchema` embedded in the root entry's `ObjectStackDefinitionSchema`
60+
* are equal-by-source copies that are never `===`. Identity is unobservable
61+
* across that boundary; the verdict is what the author actually meets.
62+
*/
63+
const GATED_AT: Readonly<Record<string, string>> = {
64+
object: 'objects',
65+
hook: 'hooks',
66+
seed: 'data',
67+
mapping: 'mappings',
68+
page: 'pages',
69+
dashboard: 'dashboards',
70+
app: 'apps',
71+
action: 'actions',
72+
report: 'reports',
73+
dataset: 'datasets',
74+
flow: 'flows',
75+
job: 'jobs',
76+
datasource: 'datasources',
77+
email_template: 'emailTemplates',
78+
doc: 'docs',
79+
book: 'books',
80+
permission: 'permissions',
81+
position: 'positions',
82+
agent: 'agents',
83+
tool: 'tools',
84+
skill: 'skills',
85+
};
86+
87+
/**
88+
* The three types the stack does NOT carry as a flat collection of the
89+
* registry's own shape. Each is a structural difference between "how an app
90+
* authors it" and "what a stored row looks like" — not a hole — so each gets
91+
* its own placement in the assertion below rather than the generic one.
92+
*/
93+
const STRUCTURAL_EXCEPTIONS: Readonly<Record<string, string>> = {
94+
field: 'authored INSIDE its object (`objects[].fields`), never as a stack-root collection; '
95+
+ 'ObjectSchema carries FieldSchema for it.',
96+
translation: 'the stack authors locale → data BUNDLES (`TranslationBundleSchema`, a record); '
97+
+ 'the registry carries `TranslationItemSchema`, the per-row shape the runtime metadata API stores.',
98+
view: 'the registry schema is the #3095 union over all three persisted view shapes (wire ViewItem, '
99+
+ 'defineView container, flattened personalization overlay); the stack authors the container '
100+
+ 'member, `ViewSchema`.',
101+
};
102+
103+
/** Every `unrecognized_keys` issue naming `INJECTED_KEY`, with its path. */
104+
function undeclaredKeyRejections(result: { success: boolean; error?: any }): string[] {
105+
if (result.success) return [];
106+
return (result.error.issues as any[])
107+
.filter((i) => i.code === 'unrecognized_keys' && Array.isArray(i.keys) && i.keys.includes(INJECTED_KEY))
108+
.map((i) => i.path.join('.'));
109+
}
110+
111+
const MANIFEST = {
112+
id: 'gate_probe',
113+
name: 'Gate Probe',
114+
namespace: 'gate_probe',
115+
version: '1.0.0',
116+
type: 'app',
117+
} as const;
118+
119+
/** The issue's page, with `injected` deciding whether the defect is planted. */
120+
const pageWith = (component: Record<string, unknown>) => ({
121+
name: 'gate_probe_page',
122+
label: 'Gate Probe',
123+
type: 'app',
124+
template: 'default',
125+
kind: 'full',
126+
regions: [{ name: 'main', components: [component] }],
127+
});
128+
129+
const CLEAN_COMPONENT = {
130+
id: 'styling_root',
131+
type: 'flex',
132+
responsiveStyles: { large: { display: 'flex' } },
133+
properties: { children: [] },
134+
};
135+
136+
/** Run a CLI command in `dir`; returns its exit code and combined output. */
137+
function runCli(command: string, dir: string, args: string[] = []): { exitCode: number; output: string } {
138+
try {
139+
const output = execFileSync(process.execPath, [cliBin, command, ...args], {
140+
cwd: dir,
141+
encoding: 'utf8',
142+
stdio: 'pipe',
143+
});
144+
return { exitCode: 0, output };
145+
} catch (error: any) {
146+
return { exitCode: error.status ?? 1, output: `${error.stdout ?? ''}${error.stderr ?? ''}` };
147+
}
148+
}
149+
150+
/**
151+
* A config written as a plain literal — no `defineStack` / `definePage`.
152+
*
153+
* Load-bearing: those factories parse eagerly, so a config authored through
154+
* them is rejected before the command's own gate is ever consulted. #5000's
155+
* repro edited `examples/app-showcase`, where every page goes through
156+
* `definePage`, so its exit code could not distinguish "the CLI gates" from
157+
* "the factory threw". A literal isolates the command's own parse.
158+
*/
159+
function withConfig<T>(stack: Record<string, unknown>, body: (dir: string) => T): T {
160+
const dir = mkdtempSync(join(tmpdir(), 'os-metadata-gate-'));
161+
try {
162+
writeFileSync(join(dir, 'objectstack.config.mjs'), `export default ${JSON.stringify(stack, null, 2)};\n`);
163+
return body(dir);
164+
} finally {
165+
rmSync(dir, { recursive: true, force: true });
166+
}
167+
}
168+
169+
describe('the CLI parses metadata through the registry schemas (#5000)', () => {
170+
it('classifies every registered metadata type', () => {
171+
// A newly registered type with no classification fails here rather than
172+
// quietly acquiring no CLI-side gate — the generalized form of #5000's
173+
// worry, which was about exactly one type nobody had checked.
174+
const classified = new Set([...Object.keys(GATED_AT), ...Object.keys(STRUCTURAL_EXCEPTIONS)]);
175+
const registered = listMetadataTypeSchemaTypes();
176+
const unclassified = registered.filter((t) => !classified.has(t));
177+
expect(
178+
unclassified,
179+
'a registered metadata type is neither carried at the stack root by its own schema nor listed as a '
180+
+ 'structural exception — decide which it is, so `os validate` cannot silently stop gating it',
181+
).toEqual([]);
182+
// And the reverse: a table row for a type nobody registers any more is a
183+
// guard describing a surface that no longer exists.
184+
const stale = [...classified].filter((t) => !registered.includes(t));
185+
expect(stale, 'the table names metadata types that are no longer registered').toEqual([]);
186+
});
187+
188+
it('reaches the same verdict as the write path on an undeclared key, per type', () => {
189+
// Left: the gate `MetadataManager.validate` / `GET /api/v1/meta` / the
190+
// Studio form use. Right: the schema `os validate` and `os build` parse
191+
// the whole stack through. #5000's claim was that the right-hand column
192+
// is blank for `page`; it is blank for nothing.
193+
const writePathAccepts: string[] = [];
194+
const cliAccepts: string[] = [];
195+
196+
for (const [type, collectionKey] of Object.entries(GATED_AT)) {
197+
const registry = getMetadataTypeSchema(type);
198+
expect(registry, `no registered schema for '${type}'`).toBeDefined();
199+
if (undeclaredKeyRejections(registry!.safeParse({ [INJECTED_KEY]: 1 })).length === 0) {
200+
writePathAccepts.push(type);
201+
}
202+
const viaCli = undeclaredKeyRejections(
203+
ObjectStackDefinitionSchema.safeParse({ manifest: MANIFEST, [collectionKey]: [{ [INJECTED_KEY]: 1 }] }),
204+
);
205+
if (!viaCli.includes(`${collectionKey}.0`)) cliAccepts.push(`${type} (stack '${collectionKey}')`);
206+
}
207+
208+
// Guard the guard: the detector must be able to say NO. A collection the
209+
// stack does not declare is silently DROPPED (the root object is not
210+
// strict — that is what the #3786 warning layer exists for), so an
211+
// ungated type produces zero rejections here. If this ever came back
212+
// non-empty, every row above would be passing on a detector that always
213+
// says yes.
214+
expect(
215+
undeclaredKeyRejections(
216+
ObjectStackDefinitionSchema.safeParse({ manifest: MANIFEST, notAStackCollection: [{ [INJECTED_KEY]: 1 }] }),
217+
),
218+
'the undeclared-key detector reported a rejection for a collection the stack never declares',
219+
).toEqual([]);
220+
221+
expect(writePathAccepts, 'the metadata-type registry stopped rejecting undeclared keys for these types').toEqual([]);
222+
expect(
223+
cliAccepts,
224+
'the stack schema `os validate` / `os build` parse through no longer rejects an undeclared key for these '
225+
+ 'types — either the collection is gone from the stack root (silently dropped, since the root is not '
226+
+ 'strict) or it now parses through a looser shape than the write path. That divergence is #5000.',
227+
).toEqual([]);
228+
});
229+
230+
it('rejects an undeclared key on each structurally-different carrier', () => {
231+
// `field` — inside its object.
232+
expect(
233+
undeclaredKeyRejections(
234+
ObjectStackDefinitionSchema.safeParse({
235+
manifest: MANIFEST,
236+
objects: [{ name: 'gate_obj', label: 'Gate', fields: { title: { type: 'text', label: 'T', [INJECTED_KEY]: 1 } } }],
237+
}),
238+
),
239+
).toContain('objects.0.fields.title');
240+
241+
// `translation` — inside a locale of the bundle.
242+
expect(
243+
undeclaredKeyRejections(
244+
ObjectStackDefinitionSchema.safeParse({
245+
manifest: MANIFEST,
246+
translations: [{ 'en-US': { [INJECTED_KEY]: 1 } }],
247+
}),
248+
),
249+
).toContain('translations.0.en-US');
250+
251+
// `view` — the container member of the registry union.
252+
expect(
253+
undeclaredKeyRejections(
254+
ObjectStackDefinitionSchema.safeParse({
255+
manifest: MANIFEST,
256+
views: [{ name: 'gate_view', label: 'Gate', object: 'gate_obj', [INJECTED_KEY]: 1 }],
257+
}),
258+
),
259+
).toContain('views.0');
260+
});
261+
});
262+
263+
describe('the authoring commands gate on that parse (#5000)', () => {
264+
// Reverse verification, direction declared up front: the SAME stack without
265+
// the planted key must exit 0. Without this control the three cases below
266+
// would also pass if the stack failed for some unrelated reason — a green
267+
// that proves nothing, which is the failure mode #5000 itself ran into.
268+
it('accepts the control stack (no planted key)', () => {
269+
const { exitCode, output } = withConfig({ manifest: MANIFEST, pages: [pageWith(CLEAN_COMPONENT)] }, (dir) =>
270+
runCli('validate', dir),
271+
);
272+
expect(exitCode, `os validate rejected the CONTROL stack:\n${output}`).toBe(0);
273+
}, 120_000);
274+
275+
it('os validate rejects an undeclared key on a page component, with the prescription', () => {
276+
const { exitCode, output } = withConfig(
277+
{ manifest: MANIFEST, pages: [pageWith({ ...CLEAN_COMPONENT, [INJECTED_KEY]: 1 })] },
278+
(dir) => runCli('validate', dir),
279+
);
280+
expect(exitCode, `os validate exited 0 on #5000's repro:\n${output}`).not.toBe(0);
281+
expect(output).toContain(INJECTED_KEY);
282+
// A rejection that does not say WHICH schema refused, and what changed,
283+
// sends the author to the wrong file. ADR-0089 D3a is the decision.
284+
expect(output).toContain('ADR-0089 D3a');
285+
}, 120_000);
286+
287+
it('os build rejects the same stack and writes no artifact', () => {
288+
const { exitCode, output, wroteArtifact } = withConfig(
289+
{ manifest: MANIFEST, pages: [pageWith({ ...CLEAN_COMPONENT, [INJECTED_KEY]: 1 })] },
290+
(dir) => ({ ...runCli('build', dir), wroteArtifact: existsSync(join(dir, 'dist', 'objectstack.json')) }),
291+
);
292+
expect(exitCode, `os build exited 0 on #5000's repro:\n${output}`).not.toBe(0);
293+
expect(output).toContain(INJECTED_KEY);
294+
// #5000's second complaint: the artifact carried the bad value onward.
295+
// The build emits from `result.data`, so a rejected parse emits nothing.
296+
expect(wroteArtifact, 'os build wrote an artifact for a stack it rejected').toBe(false);
297+
}, 120_000);
298+
299+
it("os validate turns red on #4001 batch 13's own negative control (`large` → `lg`)", () => {
300+
// The control that did NOT turn red when #5000 was filed, which is what
301+
// made the batch reach for a slot-tracing probe instead. It is red now:
302+
// `ResponsiveStylesSchema` closed, and the CLI parses through it.
303+
const { exitCode, output } = withConfig(
304+
{
305+
manifest: MANIFEST,
306+
pages: [pageWith({ ...CLEAN_COMPONENT, responsiveStyles: { lg: { display: 'flex' } } })],
307+
},
308+
(dir) => runCli('validate', dir),
309+
);
310+
expect(exitCode, `os validate exited 0 on a page styled under the wrong breakpoint vocabulary:\n${output}`).not.toBe(0);
311+
expect(output).toContain('responsiveStyles');
312+
expect(output).toContain('large');
313+
}, 120_000);
314+
});

0 commit comments

Comments
 (0)