|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#6629] `COMPONENT_FIELD_SPECS` ↔ `ComponentPropsMap` liveness. |
| 5 | + * |
| 6 | + * `COMPONENT_FIELD_SPECS` is the one hand-written, centralized declaration of |
| 7 | + * "which component props carry FIELD NAMES", and until this test nothing |
| 8 | + * reconciled it against the spec: a spec-side retirement disposes of the schema |
| 9 | + * (tombstone, ADR-0087 conversion, generated artifacts) but no gate reached into |
| 10 | + * this table — which is exactly how #5775's `displayField` / `searchFields` |
| 11 | + * stayed listed here as apparently-valid spelling (#6629). A retired key listed |
| 12 | + * in a live rule is the ADR-0078 reader face: the next author infers the |
| 13 | + * spelling is current. |
| 14 | + * |
| 15 | + * This closes that residue class table-wide rather than per-incident: every prop |
| 16 | + * name the table declares must exist on the corresponding `ComponentPropsMap` |
| 17 | + * schema and must not be a `retiredKey()` tombstone (`never`-typed once the |
| 18 | + * optional wrapper is unwrapped — the shape |
| 19 | + * `packages/spec/src/shared/retired-key.ts` constructs, and the same |
| 20 | + * introspection `packages/spec/src/ui/theme.test.ts` uses to assert the |
| 21 | + * tombstone route). The next retirement that forgets this table goes red HERE, |
| 22 | + * naming the entry, instead of surviving as dead spelling. |
| 23 | + * |
| 24 | + * ── Why the detector is self-tested ───────────────────────────────────── |
| 25 | + * |
| 26 | + * The reconciliation below is a "no violations" assertion, so it passes both |
| 27 | + * when the table is clean AND when {@link isRetiredTombstone} has stopped |
| 28 | + * recognising a tombstone at all — a zod internals change is enough to disarm it |
| 29 | + * into permanent green with nothing to show for it. So the detector is pinned |
| 30 | + * against a KNOWN tombstone and a KNOWN live key from the very schema this issue |
| 31 | + * is about, which is what keeps the gate falsifiable rather than decorative. |
| 32 | + * |
| 33 | + * Deliberately NOT covered: |
| 34 | + * - `record:related_list` — special-cased off the table (`RELATED_LIST_TYPE`); |
| 35 | + * its props are read structurally by `relatedListFieldRefs`, not declared as a |
| 36 | + * name list this test could reconcile. |
| 37 | + * - The `fields[]` shape INSIDE a `nestedSections` entry — that is the walker's |
| 38 | + * business. This test pins that the section prop itself (`sections`) is a live |
| 39 | + * key. |
| 40 | + */ |
| 41 | + |
| 42 | +import { describe, it, expect } from 'vitest'; |
| 43 | +import { ComponentPropsMap } from '@objectstack/spec/ui'; |
| 44 | +import { COMPONENT_FIELD_SPECS } from './validate-page-field-bindings.js'; |
| 45 | + |
| 46 | +/** As much of a zod (v4) node as this file reads. */ |
| 47 | +interface ZodDef { |
| 48 | + type?: string; |
| 49 | + shape?: Record<string, unknown>; |
| 50 | + in?: unknown; |
| 51 | + getter?: () => unknown; |
| 52 | + innerType?: unknown; |
| 53 | +} |
| 54 | + |
| 55 | +function defOf(node: unknown): ZodDef | undefined { |
| 56 | + return (node as { _zod?: { def?: ZodDef } } | undefined)?._zod?.def; |
| 57 | +} |
| 58 | + |
| 59 | +/** |
| 60 | + * Resolve a props schema to its object shape. Tolerates the two wrappers the |
| 61 | + * spec composes over plain objects — `.transform()` pipes (`def.in`) and |
| 62 | + * `z.lazy` (`def.getter`); `lazySchema` proxies resolve transparently on the |
| 63 | + * `_zod` read itself. |
| 64 | + */ |
| 65 | +function shapeOf(schema: unknown): Record<string, unknown> | undefined { |
| 66 | + let def = defOf(schema); |
| 67 | + for (let hops = 0; def && !def.shape && hops < 4; hops++) { |
| 68 | + if (def.type === 'pipe') def = defOf(def.in); |
| 69 | + else if (def.type === 'lazy' && def.getter) def = defOf(def.getter()); |
| 70 | + else break; |
| 71 | + } |
| 72 | + return def?.shape; |
| 73 | +} |
| 74 | + |
| 75 | +/** |
| 76 | + * A `retiredKey()` tombstone is `z.never().optional().describe(…)`: unwrap the |
| 77 | + * optional/default wrapper chain and look for `never` at the core. |
| 78 | + */ |
| 79 | +function isRetiredTombstone(propSchema: unknown): boolean { |
| 80 | + let node: unknown = propSchema; |
| 81 | + for (let hops = 0; defOf(node)?.innerType && hops < 8; hops++) node = defOf(node)?.innerType; |
| 82 | + return defOf(node)?.type === 'never'; |
| 83 | +} |
| 84 | + |
| 85 | +const map = ComponentPropsMap as unknown as Record<string, unknown>; |
| 86 | + |
| 87 | +describe('COMPONENT_FIELD_SPECS liveness against ComponentPropsMap (#6629)', () => { |
| 88 | + // ── 0. the detector, before anything is asserted THROUGH it ───────────── |
| 89 | + describe('the tombstone detector actually detects (anti-vacuity)', () => { |
| 90 | + // `element:record_picker` is the incident's own schema, and it carries both |
| 91 | + // halves of the discrimination at once: `labelField` live, `displayField` |
| 92 | + // and `searchFields` tombstoned by #5775. If any of these four assertions |
| 93 | + // goes red the reconciliation below is reporting nothing, whatever colour |
| 94 | + // it shows. |
| 95 | + const shape = shapeOf(map['element:record_picker']); |
| 96 | + |
| 97 | + it('reaches a real object shape through the lazySchema proxy', () => { |
| 98 | + expect(shape, 'ElementRecordPickerPropsSchema resolved to no object shape').toBeDefined(); |
| 99 | + // Both spellings are PRESENT in the shape — that is the tombstone route |
| 100 | + // (declared-but-unwritable), and it is why "absent from the shape" and |
| 101 | + // "retired" have to be two separate verdicts below. |
| 102 | + expect(Object.keys(shape!)).toEqual(expect.arrayContaining(['labelField', 'displayField', 'searchFields'])); |
| 103 | + }); |
| 104 | + |
| 105 | + it.each(['displayField', 'searchFields'])('recognises the #5775 `%s` tombstone', (key) => { |
| 106 | + expect(isRetiredTombstone(shape![key])).toBe(true); |
| 107 | + }); |
| 108 | + |
| 109 | + it('does not mistake the live `labelField` for one', () => { |
| 110 | + expect(isRetiredTombstone(shape!.labelField)).toBe(false); |
| 111 | + }); |
| 112 | + }); |
| 113 | + |
| 114 | + // ── 1. the reconciliation ─────────────────────────────────────────────── |
| 115 | + it('names only types that have a registered props schema', () => { |
| 116 | + // The component-type universe is open (`z.union([PageComponentType, |
| 117 | + // z.string()])`), so an unregistered type in the table would not be wrong |
| 118 | + // per se — but every entry today is registered, and an unregistered one |
| 119 | + // could never be reconciled below. If a future entry must name an |
| 120 | + // unregistered type, exempt it here explicitly with the reasoning. |
| 121 | + const unregistered = Object.keys(COMPONENT_FIELD_SPECS).filter((type) => !map[type]); |
| 122 | + expect(unregistered).toEqual([]); |
| 123 | + }); |
| 124 | + |
| 125 | + it('every prop the table names is a live, non-retired key on its schema', () => { |
| 126 | + const violations: string[] = []; |
| 127 | + for (const [type, spec] of Object.entries(COMPONENT_FIELD_SPECS)) { |
| 128 | + const schema = map[type]; |
| 129 | + if (!schema) continue; // reported by the registration pin above |
| 130 | + const shape = shapeOf(schema); |
| 131 | + if (!shape) { |
| 132 | + violations.push(`${type}: props schema has no resolvable object shape`); |
| 133 | + continue; |
| 134 | + } |
| 135 | + for (const prop of [...(spec.props ?? []), ...(spec.nestedSections ?? [])]) { |
| 136 | + if (!(prop in shape)) { |
| 137 | + violations.push( |
| 138 | + `${type}.${prop}: not declared on its ComponentPropsMap schema — ` + |
| 139 | + 'either a typo in COMPONENT_FIELD_SPECS or a schema key that was removed outright', |
| 140 | + ); |
| 141 | + } else if (isRetiredTombstone(shape[prop])) { |
| 142 | + violations.push( |
| 143 | + `${type}.${prop}: RETIRED on its ComponentPropsMap schema (retiredKey tombstone) — ` + |
| 144 | + 'no spec-conformant page can carry it, and the #5068 props gate already reports it ' + |
| 145 | + 'by name with its rename/delete prescription, so this entry only adds a second ' + |
| 146 | + 'finding about a key that no longer exists; drop it (the #5775/#6629 residue class)', |
| 147 | + ); |
| 148 | + } |
| 149 | + } |
| 150 | + } |
| 151 | + expect(violations).toEqual([]); |
| 152 | + }); |
| 153 | + |
| 154 | + it('the record_picker entry is exactly the #5775 survivor set', () => { |
| 155 | + // The incident pin under the general rule above, and NOT redundant with it: |
| 156 | + // the reconciliation is silent about a prop that is simply gone, so dropping |
| 157 | + // `labelField` would leave it green with nothing left to check. This is the |
| 158 | + // half that notices. |
| 159 | + expect(COMPONENT_FIELD_SPECS['element:record_picker']).toEqual({ props: ['labelField'] }); |
| 160 | + }); |
| 161 | +}); |
0 commit comments