Skip to content

Commit 6ce53c7

Browse files
committed
test(cli): classify the newly registered api type in the metadata gate ledger (#5000)
`api` landed in DEFAULT_METADATA_TYPE_REGISTRY / BUILTIN_METADATA_TYPE_SCHEMAS on main (#5271 -> #5312) after this branch was cut, and the reconciliation case did its job: an unclassified registered type fails until someone decides how the CLI reaches it. Measured rather than assumed. The stack DOES carry it (`apis:`, ADR-0121, still declared on main), and the CLI and the write path reach the same schema — but `ApiEndpointSchema` is a plain `z.object`, so an undeclared key on an endpoint is dropped on BOTH paths. That is not a CLI divergence, it is a #4001 shape the campaign has not reached; the strictness ledger still files all of `api/` as "wire, tolerant by design", which stopped being true when the type was registered. Filed as #5384, a sub-issue of #4001. So `api` gets its own ledger row with the honest claim: the CLI is no looser than the write path, and the #3786 pre-parse layer still names the key so the author is not left with silence. When #5384 closes the shape, the agreement assertion goes red and the row moves into GATED_AT — the ratchet working. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VkPSGsX9o17MsGv3Lbxu2w
1 parent 1750de1 commit 6ce53c7

1 file changed

Lines changed: 66 additions & 6 deletions

File tree

packages/cli/test/metadata-type-schema-gate.test.ts

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@
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';
4245
import { tmpdir } from 'node:os';
4346
import { join } from 'node:path';
4447
import { fileURLToPath } from 'node:url';
45-
import { ObjectStackDefinitionSchema } from '@objectstack/spec';
48+
import { ObjectStackDefinitionSchema, lintUnknownAuthoringKeys, formatUnknownAuthoringKey } from '@objectstack/spec';
4649
import { getMetadataTypeSchema, listMetadataTypeSchemaTypes } from '@objectstack/spec/kernel';
4750

4851
const 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. */
104128
function 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

263323
describe('the authoring commands gate on that parse (#5000)', () => {

0 commit comments

Comments
 (0)