diff --git a/.changeset/strict-mode-parity-probes-4910.md b/.changeset/strict-mode-parity-probes-4910.md new file mode 100644 index 0000000000..145356df8e --- /dev/null +++ b/.changeset/strict-mode-parity-probes-4910.md @@ -0,0 +1,27 @@ +--- +--- + +Test-only (objectui#4910). The two `element:*` spec-parity tests in +`packages/components/src/__tests__/` — `text-input-inputs-spec-parity.test.ts` and +`record-picker-inputs-spec-parity.test.ts` — re-express their undeclared-key contrast probe +so it holds on both `@objectstack/spec` pins. + +`@objectstack/spec@17.0.0` GA flipped the `element:*` props schemas from strip mode to +strict (objectstack#4001 batch A): an undeclared prop used to parse green and vanish from +`data`, and now fails with `unrecognized_keys` naming the key. Each probe existed to keep +the surrounding key-reachability claim non-vacuous — it shows what an *unpublished* key +looks like, so "the declared key survives the parse" means something — and it asserted the +strip-mode half as its control, which GA falsifies. + +Both probes now read the installed spec's refusal mode **behaviourally** (parse a payload +carrying a key no spec declares) and assert the same verdict under either mode: a named +`unrecognized_keys` refusal under GA, a silent drop under the pinned `17.0.0-rc.6`. Neither +arm is vacuous, and the probe never reads a version string, so it cannot go stale against a +pin it does not observe. The undeclared key is now carried alongside the declared one, which +is what makes either arm attributable to the undeclared key rather than to the declared key +having gone bad. This is the disposition already taken by +`packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts` +(objectui#4648 / PR #4671). + +No published behaviour changes: no runtime source, no registration, and no gate was touched +— only the two test files. diff --git a/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts b/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts index 115affd955..980c113b67 100644 --- a/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts +++ b/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts @@ -69,6 +69,31 @@ const filterDescription = () => input('filter')?.description ?? ''; */ const withFilter = (filter: unknown) => ({ object: 'account', filter }); +/** + * Does the installed spec REFUSE an undeclared top-level key, or drop it in + * silence? (objectui#4910, measured on both pins.) + * + * `@objectstack/spec` 17.0.0 GA flipped the `element:*` props schemas from + * strip mode to strict under objectstack#4001 batch A, so an undeclared prop + * now raises `unrecognized_keys` with a named message; the pinned + * `17.0.0-rc.6` still drops it in silence. The VERDICT under test is identical + * either way — an undeclared key is not an authoring surface, which is the + * whole reason "the declared key survives the parse" says anything — but the + * EVIDENCE differs, and asserting the wrong one turns this file red for a + * reason that has nothing to do with what it guards. + * + * Probed behaviourally rather than off a version string: the strictness IS the + * fact this file cares about, a probe cannot go stale against a pin it never + * reads, and the probe key is a name no spec would ever declare. `object` is + * carried because it is required, so a refusal here can only be the probe key. + * Same shape as `recordHighlightsInputs.spec-parity.test.ts`, which took this + * disposition first (objectui#4648 / PR #4671). + */ +const specRefusesUnknownTopLevelKeys = !ElementRecordPickerPropsSchema.safeParse({ + object: 'account', + __objectui_4910_probe__: true, +} as never).success; + describe('element:record_picker — registry inputs vs @objectstack/spec', () => { it('is registered with a non-empty `inputs` surface', () => { expect(config()).toBeDefined(); @@ -83,23 +108,50 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => it('publishes `filter`, which the renderer has read all along', () => { // A KEY-reachability claim, so the criterion is that the key SURVIVES the - // parse — not that the parse succeeds. This props schema is a strip-mode - // `z.object`, so an UNDECLARED key parses green too and is simply absent - // from `data` afterwards; asserting `success` alone would prove nothing. + // parse — not that the parse succeeds. Neither refusal mode makes + // `success === true` proof on its own: under rc.6's strip mode an + // UNDECLARED key parses green as well, and under GA's strict mode a green + // parse only reports that no undeclared key was present. Survival is the + // claim on both pins. expect(specTopLevelKeys()).toContain('filter'); const parsed = ElementRecordPickerPropsSchema.safeParse(withFilter({ status: 'open' })); expect(parsed.success).toBe(true); expect(parsed.data?.filter).toEqual({ status: 'open' }); - // The contrast that makes the criterion meaningful: same green parse, key - // gone, no diagnostic. That is what `filter` looked like to every manifest - // consumer before it was declared here. + // The contrast that makes the criterion meaningful: the SAME payload plus a + // key the spec does not declare. Two contract spellings, one verdict — the + // undeclared key never becomes authoring surface (see + // `specRefusesUnknownTopLevelKeys`). Carrying `filter` alongside is + // load-bearing rather than tidy: it is what makes either arm attributable + // to `notASpecKey` instead of to the declared key having gone bad, and the + // green parse asserted just above is what proves the base is valid. const undeclared = ElementRecordPickerPropsSchema.safeParse({ - object: 'account', + ...withFilter({ status: 'open' }), notASpecKey: 1, } as never); - expect(undeclared.success).toBe(true); - expect(Object.keys(undeclared.data ?? {})).not.toContain('notASpecKey'); + + if (specRefusesUnknownTopLevelKeys) { + // 17.0.0 GA: a loud refusal, and the STRONGER guarantee — the author now + // gets told. Asserted as an envelope (the code AND the key it names) + // because a bare "it failed" would be satisfied just as well by a + // rejection of `filter` or `object`, the halves that have to stay valid. + expect(undeclared.success).toBe(false); + expect(undeclared.error?.issues.map((i) => i.code)).toContain('unrecognized_keys'); + const refused = undeclared.error?.issues.flatMap( + (i) => (i as unknown as { keys?: string[] }).keys ?? [], + ); + expect(refused).toContain('notASpecKey'); + expect(refused).not.toContain('filter'); + expect(refused).not.toContain('object'); + } else { + // The pinned rc.6: same green parse, key gone, no diagnostic. That is + // what `filter` looked like to every manifest consumer before it was + // declared here — and the silent-drop harm objectstack#4001 batch A + // retired. + expect(undeclared.success).toBe(true); + expect(Object.keys(undeclared.data ?? {})).not.toContain('notASpecKey'); + expect(undeclared.data?.filter).toEqual({ status: 'open' }); + } expect(inputNames()).toContain('filter'); expect(filterDescription()).not.toBe(''); diff --git a/packages/components/src/__tests__/text-input-inputs-spec-parity.test.ts b/packages/components/src/__tests__/text-input-inputs-spec-parity.test.ts index c2fb891a12..66b3c0bd19 100644 --- a/packages/components/src/__tests__/text-input-inputs-spec-parity.test.ts +++ b/packages/components/src/__tests__/text-input-inputs-spec-parity.test.ts @@ -49,6 +49,29 @@ const inputNames = () => inputs().map((i) => i.name); const input = (name: string) => inputs().find((i) => i.name === name); const defaultValueDescription = () => input('defaultValue')?.description ?? ''; +/** + * Does the installed spec REFUSE an undeclared top-level key, or drop it in + * silence? (objectui#4910, measured on both pins.) + * + * `@objectstack/spec` 17.0.0 GA flipped the `element:*` props schemas from + * strip mode to strict under objectstack#4001 batch A, so an undeclared prop + * now raises `unrecognized_keys` with a named message; the pinned + * `17.0.0-rc.6` still drops it in silence. The VERDICT under test is identical + * either way — an undeclared key is not an authoring surface, which is the + * whole reason "the declared key survives the parse" says anything — but the + * EVIDENCE differs, and asserting the wrong one turns this file red for a + * reason that has nothing to do with what it guards. + * + * Probed behaviourally rather than off a version string: the strictness IS the + * fact this file cares about, a probe cannot go stale against a pin it never + * reads, and the probe key is a name no spec would ever declare. Same shape as + * `recordHighlightsInputs.spec-parity.test.ts`, which took this disposition + * first (objectui#4648 / PR #4671). + */ +const specRefusesUnknownTopLevelKeys = !ElementTextInputPropsSchema.safeParse({ + __objectui_4910_probe__: true, +} as never).success; + describe('element:text_input — registry inputs vs @objectstack/spec', () => { it('is registered with a non-empty `inputs` surface', () => { expect(config()).toBeDefined(); @@ -68,20 +91,49 @@ describe('element:text_input — registry inputs vs @objectstack/spec', () => { it('publishes `defaultValue`, which the renderer has read all along', () => { // A KEY-reachability claim, so the criterion is that the key SURVIVES the - // parse — not that the parse succeeds. This props schema is a strip-mode - // `z.object`, so an UNDECLARED key parses green too and is simply absent from - // `data` afterwards; asserting `success` alone would prove nothing at all. + // parse — not that the parse succeeds. Neither refusal mode makes + // `success === true` proof on its own: under rc.6's strip mode an + // UNDECLARED key parses green as well, and under GA's strict mode a green + // parse only reports that no undeclared key was present. Survival is the + // claim on both pins. expect(specTopLevelKeys()).toContain('defaultValue'); const parsed = ElementTextInputPropsSchema.safeParse({ defaultValue: 'acme' }); expect(parsed.success).toBe(true); expect(parsed.data?.defaultValue).toBe('acme'); - // The contrast that makes the criterion meaningful: same green parse, key - // gone, no diagnostic. That is what `defaultValue` looked like to every - // manifest consumer before it was declared here. - const undeclared = ElementTextInputPropsSchema.safeParse({ notASpecKey: 1 } as never); - expect(undeclared.success).toBe(true); - expect(Object.keys(undeclared.data ?? {})).not.toContain('notASpecKey'); + // The contrast that makes the criterion meaningful: the SAME payload plus a + // key the spec does not declare. Two contract spellings, one verdict — the + // undeclared key never becomes authoring surface (see + // `specRefusesUnknownTopLevelKeys`). Carrying `defaultValue` alongside is + // load-bearing rather than tidy: it is what makes either arm attributable + // to `notASpecKey` instead of to the declared key having gone bad, and the + // green parse asserted just above is what proves the base is valid. + const undeclared = ElementTextInputPropsSchema.safeParse({ + defaultValue: 'acme', + notASpecKey: 1, + } as never); + + if (specRefusesUnknownTopLevelKeys) { + // 17.0.0 GA: a loud refusal, and the STRONGER guarantee — the author now + // gets told. Asserted as an envelope (the code AND the key it names) + // because a bare "it failed" would be satisfied just as well by a + // rejection of `defaultValue`, the half that has to stay valid. + expect(undeclared.success).toBe(false); + expect(undeclared.error?.issues.map((i) => i.code)).toContain('unrecognized_keys'); + const refused = undeclared.error?.issues.flatMap( + (i) => (i as unknown as { keys?: string[] }).keys ?? [], + ); + expect(refused).toContain('notASpecKey'); + expect(refused).not.toContain('defaultValue'); + } else { + // The pinned rc.6: same green parse, key gone, no diagnostic. That is + // what `defaultValue` looked like to every manifest consumer before it + // was declared here — and the silent-drop harm objectstack#4001 batch A + // retired. + expect(undeclared.success).toBe(true); + expect(Object.keys(undeclared.data ?? {})).not.toContain('notASpecKey'); + expect(undeclared.data?.defaultValue).toBe('acme'); + } expect(inputNames()).toContain('defaultValue'); expect(defaultValueDescription()).not.toBe('');