2222 * because they fail independently:
2323 *
2424 * 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;
25+ * same verdict from both gates, for every registered metadata type. Three
26+ * carriers are structurally different and are asserted at their real
27+ * positions; one type (`api`) has a schema #4001 has not closed yet, so
28+ * there the claim is AGREEMENT plus "the author is still told", with the
29+ * gap filed rather than papered over (#5384);
2730 * B. the commands GATE on that parse — the issue's undeclared-key repro and
2831 * the #4001 batch-13 `responsiveStyles.large` → `.lg` negative control,
2932 * run through the real binary: non-zero exit, prescription in the output,
@@ -42,7 +45,7 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4245import { tmpdir } from 'node:os' ;
4346import { join } from 'node:path' ;
4447import { fileURLToPath } from 'node:url' ;
45- import { ObjectStackDefinitionSchema } from '@objectstack/spec' ;
48+ import { ObjectStackDefinitionSchema , lintUnknownAuthoringKeys , formatUnknownAuthoringKey } from '@objectstack/spec' ;
4649import { getMetadataTypeSchema , listMetadataTypeSchemaTypes } from '@objectstack/spec/kernel' ;
4750
4851const cliBin = join ( fileURLToPath ( new URL ( '.' , import . meta. url ) ) , '..' , 'bin' , 'run-dev.js' ) ;
@@ -100,6 +103,27 @@ const STRUCTURAL_EXCEPTIONS: Readonly<Record<string, string>> = {
100103 + 'member, `ViewSchema`.' ,
101104} ;
102105
106+ /**
107+ * Registered types whose SCHEMA is not closed yet, so "both gates reject" is
108+ * not the claim to make — "both gates agree, and the author is still told" is.
109+ *
110+ * `api` is the live one. #5312 registered the type and the stack authors it at
111+ * `apis:` (ADR-0121; note the neighbouring singular `api:` block, which is
112+ * server-facing REST config, not metadata), but `ApiEndpointSchema` is still a
113+ * plain `z.object` — the #4001 campaign has not reached it, and the strictness
114+ * ledger still files all of `api/` as wire. An undeclared key on an endpoint
115+ * is therefore DROPPED on both the write path and here, identically. Filed as
116+ * #5384 (sub-issue of #4001) rather than papered over.
117+ *
118+ * What is asserted instead: the CLI is no looser than the write path, and the
119+ * #3786 pre-parse layer still names the key, so the author is not left with
120+ * silence. When #5384 closes the shape, the agreement assertions below go red
121+ * — that is the ratchet working; move the row into `GATED_AT` then.
122+ */
123+ const NOT_YET_CLOSED : Readonly < Record < string , { collection : string ; tracking : string } > > = {
124+ api : { collection : 'apis' , tracking : '#5384 (sub-issue of #4001)' } ,
125+ } ;
126+
103127/** Every `unrecognized_keys` issue naming `INJECTED_KEY`, with its path. */
104128function undeclaredKeyRejections ( result : { success : boolean ; error ?: any } ) : string [ ] {
105129 if ( result . success ) return [ ] ;
@@ -171,13 +195,18 @@ describe('the CLI parses metadata through the registry schemas (#5000)', () => {
171195 // A newly registered type with no classification fails here rather than
172196 // quietly acquiring no CLI-side gate — the generalized form of #5000's
173197 // worry, which was about exactly one type nobody had checked.
174- const classified = new Set ( [ ...Object . keys ( GATED_AT ) , ...Object . keys ( STRUCTURAL_EXCEPTIONS ) ] ) ;
198+ const classified = new Set ( [
199+ ...Object . keys ( GATED_AT ) ,
200+ ...Object . keys ( STRUCTURAL_EXCEPTIONS ) ,
201+ ...Object . keys ( NOT_YET_CLOSED ) ,
202+ ] ) ;
175203 const registered = listMetadataTypeSchemaTypes ( ) ;
176204 const unclassified = registered . filter ( ( t ) => ! classified . has ( t ) ) ;
177205 expect (
178206 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' ,
207+ 'a registered metadata type is in none of the three tables — decide which it is (gated, structurally '
208+ + 'different, or a schema #4001 has not closed yet), so `os validate` cannot silently stop gating it. '
209+ + 'This is the row `api` needed when #5312 registered it mid-flight.' ,
181210 ) . toEqual ( [ ] ) ;
182211 // And the reverse: a table row for a type nobody registers any more is a
183212 // guard describing a surface that no longer exists.
@@ -258,6 +287,37 @@ describe('the CLI parses metadata through the registry schemas (#5000)', () => {
258287 ) ,
259288 ) . toContain ( 'views.0' ) ;
260289 } ) ;
290+
291+ it ( 'is no looser than the write path on a type #4001 has not closed, and still names the key' , ( ) => {
292+ for ( const [ type , { collection, tracking } ] of Object . entries ( NOT_YET_CLOSED ) ) {
293+ const registry = getMetadataTypeSchema ( type ) ;
294+ expect ( registry , `no registered schema for '${ type } '` ) . toBeDefined ( ) ;
295+
296+ // Agreement, both directions. If the registry schema closes (that is
297+ // what `tracking` is for), the first expectation flips and this row
298+ // moves into GATED_AT — a deliberate step, not a surprise.
299+ expect (
300+ undeclaredKeyRejections ( registry ! . safeParse ( { [ INJECTED_KEY ] : 1 } ) ) ,
301+ `${ type } 's schema now rejects undeclared keys (${ tracking } closed it?) — move it into GATED_AT` ,
302+ ) . toEqual ( [ ] ) ;
303+ expect (
304+ undeclaredKeyRejections (
305+ ObjectStackDefinitionSchema . safeParse ( { manifest : MANIFEST , [ collection ] : [ { [ INJECTED_KEY ] : 1 } ] } ) ,
306+ ) ,
307+ `the CLI rejects on '${ collection } ' while the write path accepts — a divergence in the other direction` ,
308+ ) . toEqual ( [ ] ) ;
309+
310+ // Not rejected is not the same as not reported: the #3786 pre-parse diff
311+ // is what stands between the author and silence while the shape is open.
312+ const reported = lintUnknownAuthoringKeys ( { manifest : MANIFEST , [ collection ] : [
313+ { name : 'gate_endpoint' , path : '/api/v1/apps/gate_probe/things' , method : 'GET' , type : 'proxy' , target : 'https://example.test' , [ INJECTED_KEY ] : 1 } ,
314+ ] } as Record < string , unknown > ) . map ( formatUnknownAuthoringKey ) ;
315+ expect (
316+ reported . join ( '\n' ) ,
317+ `an undeclared key on a '${ type } ' item is neither rejected nor reported — that is silent metadata loss` ,
318+ ) . toContain ( INJECTED_KEY ) ;
319+ }
320+ } ) ;
261321} ) ;
262322
263323describe ( 'the authoring commands gate on that parse (#5000)' , ( ) => {
0 commit comments