From 27e838489ed0b0e52cb96fa0d6a463d6d53758ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 08:02:24 +0000 Subject: [PATCH] test(metadata-admin): pin BLOCK_CONFIG field names to the schemas their blocks are judged by MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `previews/block-config.ts`'s header states the rule — "keep each field `name` aligned with the property name the corresponding renderer reads" — and nothing mechanical checked it. `check-designer-field-key-parity.mjs` judges the field / object / permission payloads and never reads this table; what covered BLOCK_CONFIG was a per-block prose pin in `block-config.test.ts`, each written after a defect was found by hand (objectui#3829, objectui#5212, objectstack#6888, objectui#7772). Two derived oracles, because the table is keyed by designer block type and that is not one vocabulary: `ComponentPropsMap[type]` from `@objectstack/spec/ui` (strict — the face that refuses by name) and the `@object-ui/types/zod` component arms indexed by their `type` literal (passthrough — the face carrying `retirementTombstone()`). Neither table is hand-listed. Both directions are judged: a name the oracle does not declare, and a name it still lists but tombstones. Nested array item controls are judged against the array element shape, and an unreadable element shape is reported rather than skipped. Census over 22 blocks: 18 resolve to a spec props shape, 4 to a node arm, 3 to neither and carry a self-deleting exemption (objectui#8281). Three live violations are ledgered with the card that owns each resolution — `object-kanban.limit` (objectui#8172), `page:tabs.items[].key` (objectui#8278), `object-form.formType` (objectui#6152) — and the ledger ratchets both ways. Zero blocks have a required schema key with no control; that direction is reported on the card, not gated (objectui#7772 records it as a product decision). Part of objectui#8216 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr --- .changeset/block-config-schema-parity-8216.md | 4 + .../block-config-schema-parity-8216.test.ts | 412 ++++++++++++++++++ 2 files changed, 416 insertions(+) create mode 100644 .changeset/block-config-schema-parity-8216.md create mode 100644 packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts diff --git a/.changeset/block-config-schema-parity-8216.md b/.changeset/block-config-schema-parity-8216.md new file mode 100644 index 0000000000..a1fb9131d0 --- /dev/null +++ b/.changeset/block-config-schema-parity-8216.md @@ -0,0 +1,4 @@ +--- +--- + +Test-only: a parity gate pinning every `BLOCK_CONFIG` designer field name to the schema its block is judged by (`@objectstack/spec/ui`'s `ComponentPropsMap`, or the `@object-ui/types/zod` node arm). No published behaviour changes — no runtime source was touched. diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts new file mode 100644 index 0000000000..19cf49fccb --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts @@ -0,0 +1,412 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `BLOCK_CONFIG` field names ↔ the schema each block is judged by (objectui#8216). + * + * `previews/block-config.ts`'s own header states the rule this file mechanises: + * + * > Keep each field `name` aligned with the property name the corresponding + * > renderer reads. + * + * Nothing checked it. `scripts/check-designer-field-key-parity.mjs` judges + * `PAYLOAD_SHAPES` — the field / object / permission payloads — and never reads + * this table; `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` + * judges the registry `inputs` declarations, a different table on a different + * face. What existed here instead was a per-block prose pin in the sibling + * `block-config.test.ts`, each written AFTER a defect was found by hand: + * `page:header.icon` (objectui#3829), `page:accordion.title` and its items + * `value` (objectui#5212), the `global_nav` option (objectstack#6888), and + * `object-kanban` (objectui#7772). That is the voluntary shape objectui#8068 + * ruled insufficient one table over — three keys did it right and nothing + * noticed the fourth. + * + * The measured cost of the absence is objectui#7772: for three months the + * kanban panel's only grouping control wrote `groupField`, which + * `ObjectKanbanSchema` refuses BY NAME, while the REQUIRED `groupBy` had no + * control at all — so that panel could not author a valid board however it was + * filled in, and every derived check stayed green. + * + * ## Two oracles, because `BLOCK_CONFIG` is keyed by DESIGNER block type + * + * That key is not one vocabulary, and the split is measured rather than + * assumed. Both oracle tables are read whole and DERIVED — neither is listed + * here, so a block that gains or loses a schema changes this file's verdict + * without anyone editing it. + * + * SPEC `ComponentPropsMap[type]` from `@objectstack/spec/ui`. STRICT + * objects: an undeclared key draws `unrecognized_keys` naming it. This + * is the face that can REFUSE, and the one the platform's + * component-props lint dispatches on — `PageComponent.properties` is + * `z.record(z.string(), z.unknown())`, so the page parse itself never + * descends into a block's props and `ComponentPropsMap[type]` is + * reachable only by dispatching on the sibling `type` + * (the spec's own note on `lintUnknownKeysAgainstSchema`). + * + * NODE the `@object-ui/types/zod` component arms, indexed by the `type` + * literal each one declares. These extend `BaseSchema`, which is + * `.passthrough()`, so an undeclared key here is not refused — it + * stops being JUDGED and the value is kept (objectui#7664's measured + * mechanism). A violation on this face is therefore a DECLARATION + * gap, not a refusal, and the two are reported under distinct ids for + * that reason. It is also the only face carrying + * `retirementTombstone()`, which is why the retired direction below + * cannot be measured without it. + * + * A block may resolve on both (`object-grid`, `object-form`, `object-kanban`), + * on one (`grid` — node only; every `page:*` / `record:*` / `element:*` spec + * type — spec only), or on neither. Neither is an EXPLICIT exemption with a + * reason and a card, never a silent skip — the idiom is + * `check-designer-field-key-parity.mjs`'s, copied rather than reinvented. + * + * ## Both directions, because "is it declared?" is not the question + * + * MISSING a control writes a name the oracle does not accept. + * RETIRED a control writes a name the oracle still LISTS but tombstones. ADR-0087 + * D2 retirement replaces the member with `z.never()` rather than deleting + * it, so every declared-key check reads green while the parser refuses + * every value by name. `@object-ui/test-support`'s `isShapeKeyTombstoned` + * is the shared judge (objectui#3809 / objectui#4947). + * + * The REQUIRED direction — a schema-required key with no control — is measured + * and reported on objectui#8216, and deliberately NOT gated here: objectui#7772's + * triage records "an inspector need not expose every declared key" as a product + * decision, and a gate over a product decision is a gate that gets waived. + * + * ## Ratchet, not bug report + * + * The first run over `main` surfaced three live offenders. Fixing them is not + * this file's job and was out of scope on the card that built it — the same + * position `check-designer-field-key-parity.mjs`'s header takes, and for the + * same reason: the three take three DIFFERENT correct resolutions (one is + * upstream in `@objectstack/spec`, one is a control rename plus two locale + * tables, one is a `packages/types` mirror). Each is filed as its own card and + * recorded in {@link LEDGER} with that card's number, and the ledger ratchets + * BOTH ways — an unledgered violation is red, and a ledger row that no longer + * applies is equally red, so a resolved key cannot leave an entry behind that + * would silently re-admit the spelling later. + */ + +import { describe, it, expect } from 'vitest'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; +import * as objectUiZod from '@object-ui/types/zod'; +import { + arrayElementSchema, + isShapeKeyTombstoned, + listedShapeKeys, + resolvePropsShape, + shapeMemberTypeName, +} from '@object-ui/test-support'; +import { BLOCK_CONFIG, type BlockPropField } from '../block-config'; + +/* ── oracles ──────────────────────────────────────────────────────────────── */ + +type OracleFace = 'spec' | 'node'; + +/** + * The NODE oracle table, derived: every exported Zod object whose `type` member + * is a literal, indexed by that literal. Written this way rather than as a + * hand-listed `{ 'object-kanban': ObjectKanbanSchema }` because a hand list is + * the thing that goes stale — a new arm would simply never be judged, which is + * the silent-skip failure the exemption table exists to prevent. + */ +function nodeOracles(): Record { + const out: Record = {}; + for (const exported of Object.values(objectUiZod as Record)) { + const shape = resolvePropsShape(exported); + if (!shape) continue; + const typeMember = shape.type as { _def?: { values?: unknown[]; value?: unknown }; value?: unknown } | undefined; + const literal = + (typeMember?._def?.values as unknown[] | undefined)?.[0] ?? + typeMember?._def?.value ?? + typeMember?.value; + if (typeof literal === 'string' && !(literal in out)) out[literal] = exported; + } + return out; +} + +const NODE_ORACLES = nodeOracles(); +const SPEC_ORACLES = ComponentPropsMap as unknown as Record; + +function oraclesFor(blockType: string): Array<{ face: OracleFace; schema: unknown }> { + const found: Array<{ face: OracleFace; schema: unknown }> = []; + if (SPEC_ORACLES[blockType]) found.push({ face: 'spec', schema: SPEC_ORACLES[blockType] }); + if (NODE_ORACLES[blockType]) found.push({ face: 'node', schema: NODE_ORACLES[blockType] }); + return found; +} + +/* ── exemptions ───────────────────────────────────────────────────────────── */ + +/** + * Blocks that resolve to NO runtime-judgeable schema on either face. + * + * SELF-DELETING: each row asserts its block resolves to neither oracle, so an + * exemption cannot outlive its reason — the moment one of these gains a spec + * props shape or a node arm, this file goes red and the row must go. + * + * `card` owns the decision about the absence itself, not about any particular + * key. objectui#8281 censuses all three together and lays out the four routes; + * the two blocks with their own separate defects carry those card numbers in + * the reason so a reader lands on the right one. + */ +const EXEMPT: Readonly> = { + 'element:definition-list': { + reason: + 'objectui-native element block: absent from PageComponentType and ComponentPropsMap, and no @object-ui/types/zod arm declares it. Its declared face is the registry `inputs` list in components/renderers/basic/data-list.tsx, judged by a different gate (objectui#8067/objectui#8068) that reads top-level inputs only. A live item-key mismatch this gate structurally cannot see is objectui#8279.', + card: 'objectui#8281', + }, + 'element:repeater': { + reason: + 'objectui-native element block: absent from PageComponentType and ComponentPropsMap, and no @object-ui/types/zod arm declares it. Same registry-`inputs`-only face as element:definition-list.', + card: 'objectui#8281', + }, + 'ai:input': { + reason: + 'Not a spec PageComponentType, no ComponentPropsMap row, no @object-ui/types/zod arm, and no renderer beyond the opt-in PROTOCOL_COMPONENTS placeholder. That the block has a curated panel at all is objectui#8280.', + card: 'objectui#8281', + }, +}; + +/* ── ledger ───────────────────────────────────────────────────────────────── */ + +/** + * The violations live on `main` today, each with the card that owns its + * resolution. NOT a suppression list — see the header's ratchet note: a row + * that stops applying is as red as a violation that is missing one. + * + * `face` scopes the row to the oracle that reports it, and it is load-bearing + * rather than decoration: `object-form.formType` is a NODE-face declaration gap + * while the SPEC face declares the key perfectly well, and an unscoped row + * would absorb a future spec-face violation on the same name. + */ +const LEDGER: ReadonlyArray<{ block: string; path: string; face: OracleFace; card: string; why: string }> = [ + { + block: 'object-kanban', + path: 'limit', + face: 'spec', + card: 'objectui#8172', + why: + 'The renderer honours it — ObjectKanban.tsx sends `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` — and both @object-ui/types faces declare it, but ComponentPropsMap[object-kanban] does not. The producer side is upstream, so removing the control would delete a working affordance to satisfy a schema that is behind it.', + }, + { + block: 'page:tabs', + path: 'items[].key', + face: 'spec', + card: 'objectui#8278', + why: + 'PageTabsProps items declare `value`, which the tabs renderer reads (containers.tsx `itemsWithValue`, falling back to an index-derived value); `key` is read nowhere and refused by name. The resolution is a control rename, which also moves the field i18n key in both locale tables and needs a read-door decision for stored documents.', + }, + { + block: 'object-form', + path: 'formType', + face: 'node', + card: 'objectui#6152', + why: + 'A declaration gap on the passthrough face only: the TS `ObjectFormSchema` declares `formType`, the spec declares it, PageBlockCanvas reads it, and the zod mirror omits it. objectui#6152 already carries this exact row in its UnmirroredDeclared table (objectql.zod.ts#ObjectFormSchema), so this ledger points there rather than opening a second card over the same debt.', + }, +]; + +const ledgerId = (r: { block: string; path: string; face: OracleFace }) => `${r.block}::${r.path}@${r.face}`; + +/* ── the join ─────────────────────────────────────────────────────────────── */ + +type Violation = { id: string; kind: 'MISSING' | 'RETIRED' | 'UNREADABLE'; detail: string }; + +/** Judge one name against one oracle shape; `undefined` when it is accepted. */ +function judge(schema: unknown, name: string): Omit | undefined { + const keys = listedShapeKeys(schema); + if (!keys.includes(name)) { + return { kind: 'MISSING', detail: `not a declared key (schema declares ${keys.length} keys)` }; + } + if (isShapeKeyTombstoned(schema, name)) { + return { kind: 'RETIRED', detail: 'declared but tombstoned — the parser refuses every value by name' }; + } + return undefined; +} + +/** Every violation on the tree, both directions, both faces, nested included. */ +function census(): Violation[] { + const found: Violation[] = []; + for (const [blockType, fields] of Object.entries(BLOCK_CONFIG)) { + for (const { face, schema } of oraclesFor(blockType)) { + for (const field of fields) { + const top = judge(schema, field.name); + if (top) found.push({ id: `${blockType}::${field.name}@${face}`, ...top }); + + if (field.kind !== 'array') continue; + // The item editors write into the array's ELEMENT shape, verbatim + // (`PageBlockInspector.renderField`: `next[i] = { ...itemObj, [n]: v }`). + const parentShape = resolvePropsShape(schema); + const element = arrayElementSchema(parentShape?.[field.name]); + if (!element) { + // Never a skip: an unreadable element shape would silently exempt + // every item control under it, which is the vacuity this gate exists + // to avoid. Reported unless the parent key is itself already refused. + if (!top) { + found.push({ + id: `${blockType}::${field.name}[]@${face}`, + kind: 'UNREADABLE', + detail: 'the array element shape could not be read, so its item controls are unjudged', + }); + } + continue; + } + for (const item of field.itemFields) { + const nested = judge(element, item.name); + if (nested) found.push({ id: `${blockType}::${field.name}[].${item.name}@${face}`, ...nested }); + } + } + } + } + return found; +} + +/* ── non-vacuity: the instruments, before the verdict ─────────────────────── */ + +describe('BLOCK_CONFIG ↔ node-schema parity — the instruments (objectui#8216)', () => { + it('reads a non-empty SPEC oracle table with the rows this gate depends on', () => { + expect(Object.keys(SPEC_ORACLES).length, 'ComponentPropsMap read empty').toBeGreaterThan(20); + for (const t of ['page:header', 'page:tabs', 'object-kanban', 'record:details']) { + expect(SPEC_ORACLES[t], `ComponentPropsMap lost '${t}'`).toBeTruthy(); + } + }); + + it('derives a non-empty NODE oracle table from the type literals', () => { + // The derivation is a walk over exports; a Zod-internals change that stopped + // yielding literals would empty it and turn every node-face assertion below + // into a no-op that passes. + expect(Object.keys(NODE_ORACLES).length, 'no node arms resolved').toBeGreaterThan(20); + for (const t of ['object-kanban', 'object-form', 'object-grid', 'grid']) { + expect(NODE_ORACLES[t], `no node arm resolved for '${t}'`).toBeTruthy(); + } + }); + + it('the MISSING probe can say no — and yes', () => { + expect(judge(SPEC_ORACLES['page:header'], 'zzzNotAKey')?.kind).toBe('MISSING'); + expect(judge(SPEC_ORACLES['page:header'], 'title')).toBeUndefined(); + }); + + it('the RETIRED probe separates a tombstone from an ordinary member', () => { + // `PageHeaderProps.icon` is the canonical tombstone (objectui#3829): still a + // listed key, refused by name. Without this pair the retired direction could + // be reporting nothing at all and read identically. + expect(listedShapeKeys(SPEC_ORACLES['page:header'])).toContain('icon'); + expect(judge(SPEC_ORACLES['page:header'], 'icon')?.kind).toBe('RETIRED'); + expect(shapeMemberTypeName(SPEC_ORACLES['page:header'], 'title')).toBeTruthy(); + // …and on the node face, where `retirementTombstone()` lives. + expect(judge(NODE_ORACLES['object-kanban'], 'groupField')?.kind).toBe('RETIRED'); + expect(judge(NODE_ORACLES['object-kanban'], 'groupBy')).toBeUndefined(); + }); + + it('the SPEC face really refuses an undeclared key by name, not silently', () => { + // The MISSING verdict above is a key-set read. This is the refusal it stands + // for, measured through the parser — so a schema that quietly became + // passthrough could not keep this gate green on a spelling comparison. + const bogus = SPEC_ORACLES['page:header'] as { safeParse: (v: unknown) => any }; + const refused = bogus.safeParse({ title: 'T', zzzNotAKey: 1 }); + expect(refused.success).toBe(false); + expect(refused.error.issues.flatMap((i: any) => i.keys ?? [])).toContain('zzzNotAKey'); + expect(bogus.safeParse({ title: 'T' }).success, 'the base node must parse clean').toBe(true); + }); +}); + +/* ── coverage: every block is judged or explicitly exempt ─────────────────── */ + +describe('BLOCK_CONFIG ↔ node-schema parity — coverage (objectui#8216)', () => { + it('every block either resolves to an oracle or carries an exemption', () => { + const undecided = Object.keys(BLOCK_CONFIG).filter( + (t) => oraclesFor(t).length === 0 && !(t in EXEMPT), + ); + // If this fails: a BLOCK_CONFIG block is judged by nothing and says nothing + // about why. Give it a schema, or an EXEMPT row with a reason and a card. + expect(undecided, 'blocks judged by nothing, with no exemption').toEqual([]); + }); + + it('every exemption is SELF-DELETING — its block must still resolve to neither oracle', () => { + const resolvable = Object.keys(EXEMPT).filter((t) => oraclesFor(t).length > 0); + // If this fails: the exemption's reason has expired. Delete the row and let + // the block be judged — that is the whole point of writing them this way. + expect(resolvable, 'exempt blocks that now HAVE a schema').toEqual([]); + }); + + it('every exemption names a real block and carries a reason and a card', () => { + for (const [type, entry] of Object.entries(EXEMPT)) { + expect(BLOCK_CONFIG[type], `'${type}' is not a BLOCK_CONFIG block — stale exemption`).toBeDefined(); + expect(entry.reason.length, `'${type}' needs a reason`).toBeGreaterThan(40); + expect(entry.card, `'${type}' needs a card`).toMatch(/^objectui#\d+$/); + } + }); + + it('the judged population is non-trivial — the gate is not vacuously green', () => { + const judged = Object.keys(BLOCK_CONFIG).filter((t) => oraclesFor(t).length > 0); + expect(judged.length).toBeGreaterThan(Object.keys(EXEMPT).length * 3); + const controls = judged.reduce((n, t) => n + BLOCK_CONFIG[t].length, 0); + expect(controls, 'no controls under judgement').toBeGreaterThan(40); + }); +}); + +/* ── the ratchet ──────────────────────────────────────────────────────────── */ + +describe('BLOCK_CONFIG ↔ node-schema parity — the ratchet (objectui#8216)', () => { + const violations = census(); + const byId = new Map(violations.map((v) => [v.id, v])); + const ledgered = new Set(LEDGER.map(ledgerId)); + + it('no control writes a name its oracle refuses or tombstones', () => { + const unledgered = violations + .filter((v) => !ledgered.has(v.id)) + .map((v) => `${v.id} [${v.kind}] ${v.detail}`); + // If this fails: a designer control writes a property key the schema that + // judges that block does not accept — the objectui#7772 class. Resolutions + // differ per key (retire the control, rename it, declare the key upstream), + // so file a card and add a LEDGER row rather than picking one here. + expect(unledgered, 'unledgered BLOCK_CONFIG field names refused by their schema').toEqual([]); + }); + + it('every ledger row still applies — a resolved key may not leave one behind', () => { + const stale = LEDGER.filter((r) => !byId.has(ledgerId(r))).map( + (r) => `${ledgerId(r)} (${r.card}) — no longer a violation; delete this row`, + ); + expect(stale, 'stale LEDGER rows').toEqual([]); + }); + + it('every ledger row names a control that still exists, and carries a card', () => { + for (const row of LEDGER) { + const fields = BLOCK_CONFIG[row.block]; + expect(fields, `LEDGER names '${row.block}', which is not a BLOCK_CONFIG block`).toBeDefined(); + const [head, nested] = row.path.split('[].'); + const top = fields.find((f) => f.name === head); + expect(top, `LEDGER names '${row.block}.${head}', which is not a control`).toBeDefined(); + if (nested !== undefined) { + expect(top!.kind).toBe('array'); + const items = (top as Extract).itemFields; + expect(items.map((f) => f.name), `LEDGER names '${row.path}'`).toContain(nested); + } + expect(row.card).toMatch(/^objectui#\d+$/); + expect(row.why.length, `${ledgerId(row)} needs a reason`).toBeGreaterThan(60); + } + }); + + it('the two SPEC-face ledger rows are real parser refusals, named', () => { + // The ledger's own non-vacuity. A row recorded from a key-set comparison + // would look identical to a row recorded from nothing, so each spec-face + // entry is re-measured through `safeParse` — the verdict the platform's + // component-props lint actually reaches. + const kanban = SPEC_ORACLES['object-kanban'] as { safeParse: (v: unknown) => any }; + const withLimit = kanban.safeParse({ objectName: 'opportunity', groupBy: 'stage', limit: 50 }); + expect(withLimit.success).toBe(false); + expect(withLimit.error.issues.flatMap((i: any) => i.keys ?? [])).toContain('limit'); + expect( + kanban.safeParse({ objectName: 'opportunity', groupBy: 'stage' }).success, + 'the base kanban must parse clean, or the refusal above proves nothing', + ).toBe(true); + + const tabs = SPEC_ORACLES['page:tabs'] as { safeParse: (v: unknown) => any }; + const withKey = tabs.safeParse({ items: [{ key: 'a', label: 'A', children: [] }] }); + expect(withKey.success).toBe(false); + expect(withKey.error.issues.flatMap((i: any) => i.keys ?? [])).toContain('key'); + expect( + tabs.safeParse({ items: [{ value: 'a', label: 'A', children: [] }] }).success, + 'the same item with the DECLARED spelling must parse clean', + ).toBe(true); + }); +});