From 85941a42358fb5f949beae15152f078c9a12b9fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 13:06:49 +0000 Subject: [PATCH] fix(lint): drop #5775's retired `displayField` / `searchFields` from the record_picker field-binding entry (#6629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `COMPONENT_FIELD_SPECS` still listed both keys for `element:record_picker`, with a comment describing a spec that no longer exists ("The schema says `displayField`; real pages author `labelField`. Accept both."). #5775 retired both — `displayField` renamed to `labelField` (ADR-0087 D2), `searchFields` deleted (ADR-0049) — and both are `retiredKey()` tombstones on `ElementRecordPickerPropsSchema`. The entry now names `labelField` alone. Adds `component-field-specs-liveness.test.ts`, which reconciles the whole hand-written table against `ComponentPropsMap`: every prop it names must exist on the corresponding schema and must not be a tombstone. Nothing checked this before, which is how a retirement that disposed of schema, tombstone, ADR-0087 conversion and generated artifacts still left dead spelling in a live rule. The tombstone detector is self-tested against the retired and live keys of the schema this issue is about, so a zod-internals change cannot disarm the gate into permanent green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AZgRyPVwi1jLb1mNNuUQ9o --- ...rd-picker-retired-field-binding-entries.md | 31 ++++ .../component-field-specs-liveness.test.ts | 161 ++++++++++++++++++ .../lint/src/validate-page-field-bindings.ts | 30 +++- 3 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 .changeset/record-picker-retired-field-binding-entries.md create mode 100644 packages/lint/src/component-field-specs-liveness.test.ts diff --git a/.changeset/record-picker-retired-field-binding-entries.md b/.changeset/record-picker-retired-field-binding-entries.md new file mode 100644 index 0000000000..f8bdb4307e --- /dev/null +++ b/.changeset/record-picker-retired-field-binding-entries.md @@ -0,0 +1,31 @@ +--- +"@objectstack/lint": patch +--- + +fix(lint): the `element:record_picker` field-binding entry drops #5775's retired `displayField` / `searchFields` (#6629) + +`COMPONENT_FIELD_SPECS` — the one hand-written table naming which component +props carry FIELD NAMES — still listed `displayField` and `searchFields` for +`element:record_picker`, with a comment ("The schema says `displayField`; real +pages author `labelField`. Accept both.") describing a spec that no longer +exists. #5775 retired both: `displayField` was renamed to `labelField` +(ADR-0087 D2) and `searchFields` was deleted (ADR-0049), and both are +`retiredKey()` tombstones on `ElementRecordPickerPropsSchema`. The entry now +names `labelField` alone. + +What changes for an author: a page that writes one of the retired keys no +longer collects a second `page-field-unknown` finding on top of the #5068 props +gate's rename/delete prescription. That finding was the misleading half — it +reported that the field named by a key which no longer exists does not exist +either, while the prescription is what actually moves the page forward. No +spec-conformant page is affected; nothing else in the table moves. + +The harder half of the residue is that nothing reconciled this hand-written +table against the spec, which is how a retirement that disposed of the schema, +the tombstone, the ADR-0087 conversion and the generated artifacts still left +dead spelling standing in a live rule — the ADR-0078 reader face, where the +next author infers the spelling is current. `component-field-specs-liveness.test.ts` +closes that class table-wide: every prop the table names must exist on the +corresponding `ComponentPropsMap` schema and must not be a tombstone, so the +next retirement that forgets this table goes red naming the entry instead of +surviving as residue. diff --git a/packages/lint/src/component-field-specs-liveness.test.ts b/packages/lint/src/component-field-specs-liveness.test.ts new file mode 100644 index 0000000000..9051ab60ef --- /dev/null +++ b/packages/lint/src/component-field-specs-liveness.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#6629] `COMPONENT_FIELD_SPECS` ↔ `ComponentPropsMap` liveness. + * + * `COMPONENT_FIELD_SPECS` is the one hand-written, centralized declaration of + * "which component props carry FIELD NAMES", and until this test nothing + * reconciled it against the spec: a spec-side retirement disposes of the schema + * (tombstone, ADR-0087 conversion, generated artifacts) but no gate reached into + * this table — which is exactly how #5775's `displayField` / `searchFields` + * stayed listed here as apparently-valid spelling (#6629). A retired key listed + * in a live rule is the ADR-0078 reader face: the next author infers the + * spelling is current. + * + * This closes that residue class table-wide rather than per-incident: every prop + * name the table declares must exist on the corresponding `ComponentPropsMap` + * schema and must not be a `retiredKey()` tombstone (`never`-typed once the + * optional wrapper is unwrapped — the shape + * `packages/spec/src/shared/retired-key.ts` constructs, and the same + * introspection `packages/spec/src/ui/theme.test.ts` uses to assert the + * tombstone route). The next retirement that forgets this table goes red HERE, + * naming the entry, instead of surviving as dead spelling. + * + * ── Why the detector is self-tested ───────────────────────────────────── + * + * The reconciliation below is a "no violations" assertion, so it passes both + * when the table is clean AND when {@link isRetiredTombstone} has stopped + * recognising a tombstone at all — a zod internals change is enough to disarm it + * into permanent green with nothing to show for it. So the detector is pinned + * against a KNOWN tombstone and a KNOWN live key from the very schema this issue + * is about, which is what keeps the gate falsifiable rather than decorative. + * + * Deliberately NOT covered: + * - `record:related_list` — special-cased off the table (`RELATED_LIST_TYPE`); + * its props are read structurally by `relatedListFieldRefs`, not declared as a + * name list this test could reconcile. + * - The `fields[]` shape INSIDE a `nestedSections` entry — that is the walker's + * business. This test pins that the section prop itself (`sections`) is a live + * key. + */ + +import { describe, it, expect } from 'vitest'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; +import { COMPONENT_FIELD_SPECS } from './validate-page-field-bindings.js'; + +/** As much of a zod (v4) node as this file reads. */ +interface ZodDef { + type?: string; + shape?: Record; + in?: unknown; + getter?: () => unknown; + innerType?: unknown; +} + +function defOf(node: unknown): ZodDef | undefined { + return (node as { _zod?: { def?: ZodDef } } | undefined)?._zod?.def; +} + +/** + * Resolve a props schema to its object shape. Tolerates the two wrappers the + * spec composes over plain objects — `.transform()` pipes (`def.in`) and + * `z.lazy` (`def.getter`); `lazySchema` proxies resolve transparently on the + * `_zod` read itself. + */ +function shapeOf(schema: unknown): Record | undefined { + let def = defOf(schema); + for (let hops = 0; def && !def.shape && hops < 4; hops++) { + if (def.type === 'pipe') def = defOf(def.in); + else if (def.type === 'lazy' && def.getter) def = defOf(def.getter()); + else break; + } + return def?.shape; +} + +/** + * A `retiredKey()` tombstone is `z.never().optional().describe(…)`: unwrap the + * optional/default wrapper chain and look for `never` at the core. + */ +function isRetiredTombstone(propSchema: unknown): boolean { + let node: unknown = propSchema; + for (let hops = 0; defOf(node)?.innerType && hops < 8; hops++) node = defOf(node)?.innerType; + return defOf(node)?.type === 'never'; +} + +const map = ComponentPropsMap as unknown as Record; + +describe('COMPONENT_FIELD_SPECS liveness against ComponentPropsMap (#6629)', () => { + // ── 0. the detector, before anything is asserted THROUGH it ───────────── + describe('the tombstone detector actually detects (anti-vacuity)', () => { + // `element:record_picker` is the incident's own schema, and it carries both + // halves of the discrimination at once: `labelField` live, `displayField` + // and `searchFields` tombstoned by #5775. If any of these four assertions + // goes red the reconciliation below is reporting nothing, whatever colour + // it shows. + const shape = shapeOf(map['element:record_picker']); + + it('reaches a real object shape through the lazySchema proxy', () => { + expect(shape, 'ElementRecordPickerPropsSchema resolved to no object shape').toBeDefined(); + // Both spellings are PRESENT in the shape — that is the tombstone route + // (declared-but-unwritable), and it is why "absent from the shape" and + // "retired" have to be two separate verdicts below. + expect(Object.keys(shape!)).toEqual(expect.arrayContaining(['labelField', 'displayField', 'searchFields'])); + }); + + it.each(['displayField', 'searchFields'])('recognises the #5775 `%s` tombstone', (key) => { + expect(isRetiredTombstone(shape![key])).toBe(true); + }); + + it('does not mistake the live `labelField` for one', () => { + expect(isRetiredTombstone(shape!.labelField)).toBe(false); + }); + }); + + // ── 1. the reconciliation ─────────────────────────────────────────────── + it('names only types that have a registered props schema', () => { + // The component-type universe is open (`z.union([PageComponentType, + // z.string()])`), so an unregistered type in the table would not be wrong + // per se — but every entry today is registered, and an unregistered one + // could never be reconciled below. If a future entry must name an + // unregistered type, exempt it here explicitly with the reasoning. + const unregistered = Object.keys(COMPONENT_FIELD_SPECS).filter((type) => !map[type]); + expect(unregistered).toEqual([]); + }); + + it('every prop the table names is a live, non-retired key on its schema', () => { + const violations: string[] = []; + for (const [type, spec] of Object.entries(COMPONENT_FIELD_SPECS)) { + const schema = map[type]; + if (!schema) continue; // reported by the registration pin above + const shape = shapeOf(schema); + if (!shape) { + violations.push(`${type}: props schema has no resolvable object shape`); + continue; + } + for (const prop of [...(spec.props ?? []), ...(spec.nestedSections ?? [])]) { + if (!(prop in shape)) { + violations.push( + `${type}.${prop}: not declared on its ComponentPropsMap schema — ` + + 'either a typo in COMPONENT_FIELD_SPECS or a schema key that was removed outright', + ); + } else if (isRetiredTombstone(shape[prop])) { + violations.push( + `${type}.${prop}: RETIRED on its ComponentPropsMap schema (retiredKey tombstone) — ` + + 'no spec-conformant page can carry it, and the #5068 props gate already reports it ' + + 'by name with its rename/delete prescription, so this entry only adds a second ' + + 'finding about a key that no longer exists; drop it (the #5775/#6629 residue class)', + ); + } + } + } + expect(violations).toEqual([]); + }); + + it('the record_picker entry is exactly the #5775 survivor set', () => { + // The incident pin under the general rule above, and NOT redundant with it: + // the reconciliation is silent about a prop that is simply gone, so dropping + // `labelField` would leave it green with nothing left to check. This is the + // half that notices. + expect(COMPONENT_FIELD_SPECS['element:record_picker']).toEqual({ props: ['labelField'] }); + }); +}); diff --git a/packages/lint/src/validate-page-field-bindings.ts b/packages/lint/src/validate-page-field-bindings.ts index 30d6e9e4fb..72a9704f0d 100644 --- a/packages/lint/src/validate-page-field-bindings.ts +++ b/packages/lint/src/validate-page-field-bindings.ts @@ -32,11 +32,14 @@ * authored in the wild. The table below names the field-bearing props * explicitly; an unknown component type is SKIPPED silently, never flagged. * - * The table also covers shapes the props schemas do not yet describe but real - * pages authored anyway (they pass only because `properties` is unvalidated): - * `record:details` `sections[].fields[]` and `hideFields[]`, and the record - * picker's `labelField`. Linting the schema's shape alone would find nothing on - * the actual corpus. + * The table once also covered shapes the props schemas did not describe but + * real pages authored anyway (`record:details` `sections[].fields[]` / + * `hideFields[]`, the record picker's `labelField`). #5611 and #5775 settled + * those the other way — the delivered shape got declared — so today every prop + * this table names is a live (non-retired) key on its `ComponentPropsMap` + * schema, and `component-field-specs-liveness.test.ts` pins that: a future + * retirement that forgets this table turns that test red instead of leaving a + * tombstoned key listed here as if it were still-valid spelling (#6629). * * ── Shared with the react page surface ────────────────────────────────── * @@ -174,8 +177,21 @@ export const COMPONENT_FIELD_SPECS: Readonly> 'element:number': { props: ['field'] }, 'element:filter': { props: ['fields'] }, 'element:form': { props: ['fields'] }, - // The schema says `displayField`; real pages author `labelField`. Accept both. - 'element:record_picker': { props: ['displayField', 'labelField', 'searchFields'] }, + // `labelField` is the one field-bearing prop this element declares. Its former + // companions `displayField` (renamed to `labelField`, ADR-0087 D2) and + // `searchFields` (deleted, ADR-0049) were retired in #5775 and are + // `retiredKey()` tombstones on `ElementRecordPickerPropsSchema` — so no + // spec-conformant page carries either, and this rule's job (resolve a field + // NAME against the object) is not the question a retired key raises (#6629). + // + // A non-conformant page that writes one anyway is not left unattended: the + // #5068 props gate reports the key with its rename/delete prescription. That + // gate is advisory and CLI-only and lives in a different registry + // (`authoring-rules`) from this suite, so it neither precedes nor suppresses + // this rule — what these two entries actually added was a SECOND finding, + // saying a field named by a key that no longer exists does not exist either. + // The prescription is the useful half; this half was noise on top of it. + 'element:record_picker': { props: ['labelField'] }, }; /**