Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/record-picker-retired-field-binding-entries.md
Original file line numberDiff line numberDiff line change
@@ -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.
161 changes: 161 additions & 0 deletions packages/lint/src/component-field-specs-liveness.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>;
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<string, unknown> | 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<string, unknown>;

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'] });
});
});
30 changes: 23 additions & 7 deletions packages/lint/src/validate-page-field-bindings.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 ──────────────────────────────────
*
Expand DownExpand Up@@ -174,8 +177,21 @@ export const COMPONENT_FIELD_SPECS: Readonly<Record<string, ComponentFieldSpec>>
'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'] },
};

/**
Expand Down
Loading