Skip to content

Commit cd584d5

Browse files
fix(lint): drop #5775's retired displayField / searchFields from the record_picker field-binding entry (#6629) (#6702)
`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. Claude-Session: https://claude.ai/code/session_01AZgRyPVwi1jLb1mNNuUQ9o Co-authored-by: Claude <noreply@anthropic.com>
1 parent 114e727 commit cd584d5

3 files changed

Lines changed: 215 additions & 7 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): the `element:record_picker` field-binding entry drops #5775's retired `displayField` / `searchFields` (#6629)
6+
7+
`COMPONENT_FIELD_SPECS` — the one hand-written table naming which component
8+
props carry FIELD NAMES — still listed `displayField` and `searchFields` for
9+
`element:record_picker`, with a comment ("The schema says `displayField`; real
10+
pages author `labelField`. Accept both.") describing a spec that no longer
11+
exists. #5775 retired both: `displayField` was renamed to `labelField`
12+
(ADR-0087 D2) and `searchFields` was deleted (ADR-0049), and both are
13+
`retiredKey()` tombstones on `ElementRecordPickerPropsSchema`. The entry now
14+
names `labelField` alone.
15+
16+
What changes for an author: a page that writes one of the retired keys no
17+
longer collects a second `page-field-unknown` finding on top of the #5068 props
18+
gate's rename/delete prescription. That finding was the misleading half — it
19+
reported that the field named by a key which no longer exists does not exist
20+
either, while the prescription is what actually moves the page forward. No
21+
spec-conformant page is affected; nothing else in the table moves.
22+
23+
The harder half of the residue is that nothing reconciled this hand-written
24+
table against the spec, which is how a retirement that disposed of the schema,
25+
the tombstone, the ADR-0087 conversion and the generated artifacts still left
26+
dead spelling standing in a live rule — the ADR-0078 reader face, where the
27+
next author infers the spelling is current. `component-field-specs-liveness.test.ts`
28+
closes that class table-wide: every prop the table names must exist on the
29+
corresponding `ComponentPropsMap` schema and must not be a tombstone, so the
30+
next retirement that forgets this table goes red naming the entry instead of
31+
surviving as residue.
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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+
});

packages/lint/src/validate-page-field-bindings.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,14 @@
3232
* authored in the wild. The table below names the field-bearing props
3333
* explicitly; an unknown component type is SKIPPED silently, never flagged.
3434
*
35-
* The table also covers shapes the props schemas do not yet describe but real
36-
* pages authored anyway (they pass only because `properties` is unvalidated):
37-
* `record:details` `sections[].fields[]` and `hideFields[]`, and the record
38-
* picker's `labelField`. Linting the schema's shape alone would find nothing on
39-
* the actual corpus.
35+
* The table once also covered shapes the props schemas did not describe but
36+
* real pages authored anyway (`record:details` `sections[].fields[]` /
37+
* `hideFields[]`, the record picker's `labelField`). #5611 and #5775 settled
38+
* those the other way — the delivered shape got declared — so today every prop
39+
* this table names is a live (non-retired) key on its `ComponentPropsMap`
40+
* schema, and `component-field-specs-liveness.test.ts` pins that: a future
41+
* retirement that forgets this table turns that test red instead of leaving a
42+
* tombstoned key listed here as if it were still-valid spelling (#6629).
4043
*
4144
* ── Shared with the react page surface ──────────────────────────────────
4245
*
@@ -174,8 +177,21 @@ export const COMPONENT_FIELD_SPECS: Readonly<Record<string, ComponentFieldSpec>>
174177
'element:number': { props: ['field'] },
175178
'element:filter': { props: ['fields'] },
176179
'element:form': { props: ['fields'] },
177-
// The schema says `displayField`; real pages author `labelField`. Accept both.
178-
'element:record_picker': { props: ['displayField', 'labelField', 'searchFields'] },
180+
// `labelField` is the one field-bearing prop this element declares. Its former
181+
// companions `displayField` (renamed to `labelField`, ADR-0087 D2) and
182+
// `searchFields` (deleted, ADR-0049) were retired in #5775 and are
183+
// `retiredKey()` tombstones on `ElementRecordPickerPropsSchema` — so no
184+
// spec-conformant page carries either, and this rule's job (resolve a field
185+
// NAME against the object) is not the question a retired key raises (#6629).
186+
//
187+
// A non-conformant page that writes one anyway is not left unattended: the
188+
// #5068 props gate reports the key with its rename/delete prescription. That
189+
// gate is advisory and CLI-only and lives in a different registry
190+
// (`authoring-rules`) from this suite, so it neither precedes nor suppresses
191+
// this rule — what these two entries actually added was a SECOND finding,
192+
// saying a field named by a key that no longer exists does not exist either.
193+
// The prescription is the useful half; this half was noise on top of it.
194+
'element:record_picker': { props: ['labelField'] },
179195
};
180196

181197
/**

0 commit comments

Comments
 (0)