From 3e7c64b03acff660d8eab682136813f92f8b8c00 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 06:49:13 +0000 Subject: [PATCH 1/3] fix(plugin-detail,types): stop erasing the record renderers' props annotation, and align the two mirror keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `record-details.tsx`, `record-highlights.tsx` and `record-related-list.tsx` annotated `schema` correctly and then destructured it as `schema = {} as any`. A destructuring default's type joins the annotated property type at the binding, so `any` erased the annotation for every read site in each file — declared keys and undeclared ones alike read `any`, indistinguishably. That is why a checker census could not classify eleven of the twelve reads objectui#8649 lists. Repairing it moves no published surface; the exported annotations were always correct. Spelled through the annotation so it cannot drift back. With the annotation restored the compiler named a latent contract violation the `any` had hidden: `RecordRelatedListBody` passed a possibly-unbound `objectName` into `ResolveRelatedRecordActionsInput.objectName`, which is `string`. The call is now gated on the key being bound — output-identical, because `resolve` is pure and returns `{}` for an unknown object and the result is discarded on that path by the `if (!objectName)` placeholder return. Two keys are then alignments the contract had already made: - `RecordRelatedListComponentProps` gains `relationshipValueField?: string`. The spec declares it, the renderer reads it and the registry publishes it as an input; only this published TypeScript face refused the document (TS2353). - `record:reference_rail` declares the node-level `properties` envelope it reads (`PageComponentSchema.properties`), which it had been reaching through `[k: string]: any`. That narrows an accept already granted. `enforceFieldSecurity`, `redactFields` and `requiredPermissions` are NOT declared and NOT retired here. Measured over every object schema the installed contract exports, with controls: the first two are declared on none of them, and `requiredPermissions` is declared — including on the sibling block `RecordQuickActionsProps` — but on none of the three schemas these renderers map to. Declaring them here would make this repo accept what the platform refuses; retiring the reads would delete a redaction that works today. No runtime permission or masking behaviour changes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .../8649-detail-renderer-undeclared-keys.md | 76 ++++ .../detailRendererUndeclaredKeys-8649.test.ts | 374 ++++++++++++++++++ .../src/renderers/record-details.tsx | 13 +- .../src/renderers/record-highlights.tsx | 5 +- .../src/renderers/record-reference-rail.tsx | 22 ++ .../src/renderers/record-related-list.tsx | 32 +- packages/types/src/record-components.ts | 15 + 7 files changed, 529 insertions(+), 8 deletions(-) create mode 100644 .changeset/8649-detail-renderer-undeclared-keys.md create mode 100644 packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts diff --git a/.changeset/8649-detail-renderer-undeclared-keys.md b/.changeset/8649-detail-renderer-undeclared-keys.md new file mode 100644 index 0000000000..d8ffd02240 --- /dev/null +++ b/.changeset/8649-detail-renderer-undeclared-keys.md @@ -0,0 +1,76 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-detail': patch +--- + +`record:related_list` accepts `relationshipValueField`, and three record +renderers stop erasing their own props annotation (objectui#8649). + +**`@object-ui/types` — `RecordRelatedListComponentProps` gains +`relationshipValueField?: string`.** Nothing that worked stops working; a +document that was already valid everywhere else stops being refused here. +`@objectstack/spec` declares the key (`RecordRelatedListProps.relationshipValueField`, +`z.string().default('id')`), `RecordRelatedListRenderer` has always read it, and +`@object-ui/plugin-detail`'s registry has published it as an input since +objectui#3808 — every layer declared it except this published TypeScript face: + +```ts +// before — TS2353, while the platform accepted the same document +const props: RecordRelatedListComponentProps = { + objectName: 'task', relationshipField: 'account', relationshipValueField: 'name', +}; +// after — accepted, with the contract's own type +``` + +This is an **alignment, not a widening**: the accept set of this face moves to +the contract's and never past it. The pin that says so re-derives the contract +side on every run rather than restating it +(`packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts`). + +**`@object-ui/plugin-detail` — the annotation-erasing destructure default is +gone from three renderers.** `record-details.tsx`, `record-highlights.tsx` and +`record-related-list.tsx` each annotated `schema` correctly and then wrote +`schema = {} as any`. A destructuring default's type joins the annotated +property type at the binding, so `any` erased the annotation for *every* read +site in the file — declared keys and undeclared ones alike read `any`. No +published surface moves: the exported annotations were always correct. + +The repair made the compiler name a latent contract violation the `any` had +hidden, and it is fixed here: `RecordRelatedListBody` passed a possibly-unbound +`objectName` into `ResolveRelatedRecordActionsInput.objectName`, which is +`string`. The call is now gated on the key being bound. **Output-identical**, +both halves measured rather than assumed — `resolve` is pure and its only use of +the key (`objects.find((o) => o?.name === objectName)`) finds nothing for +`undefined` and returns `{}`, and the result is discarded on that path by the +`if (!objectName)` placeholder return. + +**`record:reference_rail` declares the node-level `properties` envelope it +reads.** The renderer accepts a node either flattened (`schema.entries`) or +enveloped (`schema.properties.entries`); the enveloped read compiled only +through the schema type's `[k: string]: any`, so `entries` arrived as `any` on +that path. `properties` is `@objectstack/spec`'s own node-level key +(`PageComponentSchema.properties`, "Component props passed to the widget") with +the standing `dataSource` and `className` have. Declaring it **narrows** an +accept this face already granted — it widens nothing, and `properties` itself +stays open because the contract declares it as a record. + +⚠️ **Three keys are deliberately NOT declared, and no runtime behaviour +changes.** `enforceFieldSecurity`, `redactFields` and `requiredPermissions` are +read by all three renderers and are **routed to the producer**, not declared +here and not retired here. Measured on the installed contract (17.4.0) over +every object schema `@objectstack/spec/ui` exports, with controls in the same +pass: + +``` +enforceFieldSecurity · redactFields declared on 0 exported schemas +requiredPermissions declared — including on the sibling block + RecordQuickActionsProps — but on none of + RecordDetails/Highlights/RelatedListProps +aria · fields · columns declared on many <- CONTROL +zzqx_no_such_key declared on 0 <- CONTROL +``` + +Declaring them here would make this repo accept what the platform refuses; +retiring the reads would delete a redaction that works today on the raw-node +path. Both keys stay honoured exactly as before, and the census above is a test, +so it goes red the day the platform declares one of them. diff --git a/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts b/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts new file mode 100644 index 0000000000..ce09815d7c --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts @@ -0,0 +1,374 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#8649 — the twelve undeclared reads across four record renderers do + * NOT share one cause and do NOT share one exit. This file is the instrument + * for every claim the ruling rests on, so each is re-derived on every run + * instead of being written down once (AGENTS.md #9). + * + * ## The two causes, measured with the checker (objectui#8410: never a grep) + * + * - `record-details.tsx`, `record-highlights.tsx` and `record-related-list.tsx` + * annotate `schema` correctly and then destructure it as `schema = {} as any`. + * A destructuring default's type joins the annotation at the binding, so + * `any` ERASED the annotation for every read site in the file — declared + * keys (`hideFields`, `add`, `columns`, `sort`, …) and undeclared ones + * (`enforceFieldSecurity`, …) alike read `any`, indistinguishably. That is a + * local type defect with no published-surface consequence, and repairing it + * is the prerequisite to classifying anything: before it, the checker cannot + * tell an author's key from a host's at any of those sites. + * - `record-reference-rail.tsx` has the OTHER cause. Its `schema` type is not + * erased; `properties` is simply not a declared member of it and compiles + * through the `[k: string]: any` index signature. + * + * ## The exits, one per key — determined by the contract, not by preference + * + * `@object-ui/types` is a MIRROR of `@objectstack/spec`, not an authority, so + * declaring a key the platform does not declare would make this repo accept what + * the platform refuses. The question per key is therefore: does the contract + * declare it, and on WHICH schema? Both halves matter — a token that exists + * somewhere under the UI contract is not a declaration on the schema a given + * node maps to. {@link declaringSchemasOf} answers exactly that, over the + * installed published artifact, every run. + * + * - `hideFields` (`record-details`) — DECLARED, on `RecordDetailsProps` and + * already on this repo's mirror since objectui#9040. The card listed it only + * because the erasure hid it. ⇒ nothing to rule; the erasure repair alone + * makes the checker see it. + * - `relationshipValueField` (`record-related-list`) — DECLARED on + * `RecordRelatedListProps` and published by this block's registry `inputs` + * (`recordRelatedListInputs.spec-parity.test.ts`), while the mirror + * interface omitted it. That is objectui#9040's Direction 1 one interface + * over: a spec-valid, renderer-honoured, registry-published document refused + * by `tsc` with `TS2353`. ⇒ ALIGN THE MIRROR. + * - `properties` (`record-reference-rail`) — DECLARED by the contract at NODE + * level on `PageComponentSchema` ("Component props passed to the widget"), + * which is the same standing `dataSource` and `className` have in + * `recordRelatedListInputs.spec-parity.test.ts`. ⇒ ALIGN THE MIRROR, for the + * one member this renderer reads off the envelope. + * - `enforceFieldSecurity` and `redactFields` (three renderers each) — declared + * on NO object schema the UI contract exports. ⇒ "declare" is off the table + * outright. ROUTED TO THE PRODUCER, ⛔ not retired here: the renderers honour + * both keys today on the raw-node path, so deleting the reads would remove a + * redaction that is working, and changing runtime masking behaviour is the + * maintainer floor this card must not cross. + * - `requiredPermissions` (three renderers each) — the sharpest of the twelve. + * The contract DOES declare it, including on the sibling page-component + * props schema `RecordQuickActionsProps`, but NOT on the three this card + * covers. A word-frequency screen over the contract reads "present" and is + * wrong about exactly this; the per-schema census below is what separates + * them. ⇒ ROUTED TO THE PRODUCER, same floor. + * + * ## What each leg can and cannot prove + * + * - The `Equal` legs are compiled by `tsc -p tsconfig.test.json` and by nothing + * else — vitest strips types. They are the ONLY half that discriminates the + * two mirror alignments from their defect, because that defect was a + * TypeScript-only refusal. + * - The census legs read the INSTALLED `@objectstack/spec` artifact. They are + * the PREMISE of the routing decision, never its evidence: they were green + * before this card and are green after. They earn their place by going RED + * the day the platform declares one of the routed keys — which is the signal + * that the routed card landed and this repo owes the mirror an update. + * - The source-text legs read the three renderers through the shared comment + * mask, so a re-introduced `{} as any` is caught by a run that never + * type-checks. Each carries a control that varies only the claim. + * - {@link ROUTED_KEYS} is a LEDGER, and a stale exception is a hole: every + * entry is asserted to be STILL READ by the file it is ledgered against. If + * someone retires one of these reads, this file goes red and the routing + * claim has to be re-derived rather than quietly outliving its subject. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import * as specUi from '@objectstack/spec/ui'; +import { + ComponentPropsMap, + PageComponentSchema, + RecordRelatedListProps, + type ComponentPropsInput, + type ReferenceRailEntry, +} from '@objectstack/spec/ui'; +import type { RecordRelatedListComponentProps } from '@object-ui/types'; +import type { RecordReferenceRailRendererProps } from '../record-reference-rail'; +// @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) +import { maskComments } from '../../../../../scripts/js-comment-mask.mjs'; + +/** Local annotation, since the import above is untyped — the call site stays checked. */ +const mask: (source: string) => string = maskComments; + +/** Rooted at THIS file, never at `process.cwd()` — the two differ per invocation. */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const RENDERERS = join(HERE, '..'); + +/* ── Type-level pins (compiled by `tsc -p tsconfig.test.json`) ─────────────── */ + +/** + * What an author writes for the spec's `record:related_list` props bag, reached + * through the contract's own block-tag map rather than through a named schema + * export — so the pin is bound to the tag this renderer registers. `zod` is not + * a dependency of this package and must not become one for a type alias; the + * contract already publishes this derivation. + * + * The map entry and the named export being the SAME schema is the premise of + * that indirection, and it is asserted at runtime below rather than assumed. + */ +type SpecRelatedListProps = ComponentPropsInput<'record:related_list'>; + +/** Invariant type equality. `A extends B` is NOT this: `never` and `any` pass that. */ +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +/** The only assertion form used here — its constraint is what refuses `false`. */ +type Expect = T; + +/* Direction proofs: a broken instrument makes THIS file red. */ + +// @ts-expect-error objectui#8649 — `Expect` must refuse `false`. Widen its constraint and this directive goes unused (TS2578). +type _ExpectRefusesFalse = Expect; + +// @ts-expect-error objectui#8649 — `never` must NOT read as equal to `true`. An `extends`-shaped comparison would let it through. +type _EqualRefusesNever = Expect>; + +// @ts-expect-error objectui#8649 — `any` must NOT read as equal to `true`. This is the exact shape the erasure produced. +type _EqualRefusesAny = Expect>; + +/* Exit "align the mirror", key 1: `relationshipValueField`. */ + +/** RED before objectui#8649 (`TS2339` — the member did not exist here). */ +export type _RelationshipValueFieldMirrorsSpec = Expect< + Equal< + RecordRelatedListComponentProps['relationshipValueField'], + SpecRelatedListProps['relationshipValueField'] + > +>; + +/** + * Spelled out as well as derived. `Equal` against the spec alone would also be + * satisfied if BOTH faces drifted to the same wrong type. + */ +export type _RelationshipValueFieldIsOptionalString = Expect< + Equal +>; + +/** + * The card's repro as a literal: spec-valid, renderer-honoured, registry-published, + * and refused by `tsc` with `TS2353` before this card. + */ +const relationshipValueFieldAccepted: RecordRelatedListComponentProps = { + objectName: 'task', + relationshipField: 'account', + relationshipValueField: 'name', +}; + +/* Exit "align the mirror", key 2: the reference rail's node-level `properties`. */ + +type RailSchema = NonNullable; + +/** + * RED before objectui#8649: `properties` was admitted only by the schema type's + * `[k: string]: any` index signature, so it read `any` — which `Equal` refuses + * (see `_EqualRefusesAny` above, the same shape). + */ +export type _RailPropertiesIsDeclared = Expect< + Equal) | undefined> +>; + +/** + * And the member the renderer actually reads off the envelope carries the + * contract's own entry type, not `any`. + */ +export type _RailPropertiesEntriesIsSpecEntry = Expect< + Equal['entries'], ReferenceRailEntry[] | undefined> +>; + +/* ── Runtime legs ─────────────────────────────────────────────────────────── */ + +/** + * Every key name declared on `schema`, unwrapping the wrappers a published zod + * artifact uses (`.pipe()` — which `PageComponentSchema` is — plus + * `.optional()` / `.default()`), or `null` when `schema` is not an object + * schema at all. + */ +function objectShapeKeys(schema: unknown): string[] | null { + let node = schema as + | { shape?: unknown; _def?: { shape?: unknown; in?: unknown; innerType?: unknown; type?: unknown } } + | undefined; + for (let hop = 0; hop < 8; hop += 1) { + // ⚠️ zod 4 schemas are CALLABLE, so a `typeof !== 'object'` guard here is not + // a type check — it is a silent census cut. Measured: it dropped 96 of the + // 115 object schemas the contract exports, and every "declared nowhere" + // reading taken through it would have been vacuous. The population leg in + // the first block is what caught it, and is why it is written as a floor. + if (!node || (typeof node !== 'object' && typeof node !== 'function')) return null; + let shape: unknown; + try { + shape = node.shape ?? node._def?.shape; + } catch { + return null; + } + const resolved = typeof shape === 'function' ? (shape as () => object)() : shape; + if (resolved && typeof resolved === 'object') return Object.keys(resolved); + node = (node._def?.in ?? node._def?.innerType ?? node._def?.type) as typeof node; + } + return null; +} + +/** + * The census this card's routing rests on: which EXPORTED object schemas of the + * installed UI contract declare `key`. Derived from the artifact on every run — + * never a list written down here. + */ +function declaringSchemasOf(key: string): string[] { + const found: string[] = []; + for (const [name, value] of Object.entries(specUi as Record)) { + const keys = objectShapeKeys(value); + if (keys?.includes(key)) found.push(name); + } + return found.sort(); +} + +/** How many exported object schemas the census actually walked. */ +const censusPopulation = (): number => + Object.values(specUi as Record).filter((v) => objectShapeKeys(v) !== null).length; + +/** The three props schemas this card's renderers map to. */ +const CARD_PROPS_SCHEMAS = ['RecordDetailsProps', 'RecordHighlightsProps', 'RecordRelatedListProps'] as const; + +/** + * The keys routed to the producer, and the renderer files each is ledgered + * against. Every entry is asserted STILL READ below — a ledger entry whose + * subject has gone is a hole, not a pass. + */ +const ROUTED_KEYS = { + enforceFieldSecurity: ['record-details.tsx', 'record-highlights.tsx', 'record-related-list.tsx'], + redactFields: ['record-details.tsx', 'record-highlights.tsx', 'record-related-list.tsx'], + requiredPermissions: ['record-details.tsx', 'record-highlights.tsx', 'record-related-list.tsx'], +} as const; + +/** The three files whose `schema` annotation the erasure used to destroy. */ +const ERASURE_REPAIRED = [ + 'record-details.tsx', + 'record-highlights.tsx', + 'record-related-list.tsx', +] as const; + +const maskedSource = (file: string): string => mask(readFileSync(join(RENDERERS, file), 'utf8')); + +/** The erasing spelling, as a matcher — applied to real sources AND to a control. */ +const ERASING_DEFAULT = /schema\s*=\s*\{\}\s*as\s+any/; + +describe('objectui#8649 — the census the routing rests on (PREMISE, re-derived every run)', () => { + it('walks a non-empty population and discriminates', () => { + // Calibration in both directions: an empty walk would make every "declared + // nowhere" reading below vacuous, and an everything-set would make them + // unfalsifiable. + expect(censusPopulation()).toBeGreaterThan(50); + expect(declaringSchemasOf('zzqx_no_such_key')).toEqual([]); + expect(declaringSchemasOf('aria').length).toBeGreaterThan(0); + expect(declaringSchemasOf('fields').length).toBeGreaterThan(0); + }); + + it('`enforceFieldSecurity` and `redactFields` are declared on NO exported UI-contract schema', () => { + // The reading that takes "declare" off the table for these two. It goes RED + // the day the platform declares either — which is the signal that the routed + // producer-side card landed. + expect(declaringSchemasOf('enforceFieldSecurity')).toEqual([]); + expect(declaringSchemasOf('redactFields')).toEqual([]); + }); + + it('`requiredPermissions` IS declared by the contract — just never on these three props schemas', () => { + // Why a word-frequency screen gets this key wrong, stated as an assertion + // rather than as prose: the token is present AND the declaration is absent + // where these renderers read it. + const declaring = declaringSchemasOf('requiredPermissions'); + expect(declaring.length).toBeGreaterThan(0); + // The sibling page-component props schema that DOES carry it — the precedent + // the producer-side card would cite. + expect(declaring).toContain('RecordQuickActionsProps'); + for (const schema of CARD_PROPS_SCHEMAS) expect(declaring).not.toContain(schema); + }); + + it('the block-tag map entry IS the named props schema (premise of the type pins)', () => { + // The `ComponentPropsInput<'record:related_list'>` alias above is only a + // reading of `RecordRelatedListProps` while this holds. + expect(ComponentPropsMap['record:related_list']).toBe(RecordRelatedListProps); + }); + + it('the two mirror alignments are alignments — the contract declares both keys', () => { + expect(declaringSchemasOf('relationshipValueField')).toContain('RecordRelatedListProps'); + expect(declaringSchemasOf('hideFields')).toContain('RecordDetailsProps'); + // `properties` is a NODE-level key, so it is declared on the page-component + // node rather than on any block's props bag. + expect(declaringSchemasOf('properties')).toContain('PageComponentSchema'); + expect(objectShapeKeys(PageComponentSchema)).toContain('dataSource'); + expect(objectShapeKeys(PageComponentSchema)).not.toContain('relationshipField'); + }); +}); + +describe('objectui#8649 — the erasure is repaired and stays repaired', () => { + it('the matcher can fire, so a zero reading below is a reading', () => { + // The control varies ONLY the claim: same matcher, same shape of source, the + // erasing spelling restored. + expect(ERASING_DEFAULT.test('const C = ({ schema = {} as any, className }) => null;')).toBe(true); + }); + + for (const file of ERASURE_REPAIRED) { + it(`${file} destructures \`schema\` without erasing its annotation`, () => { + const source = maskedSource(file); + // Proof the file was read and masked, so the absence below is about the + // spelling and not about an empty string. + expect(source).toMatch(/RecordDetailsRendererProps|RecordHighlightsRendererProps|RecordRelatedListRendererProps/); + expect(source).not.toMatch(ERASING_DEFAULT); + // And the repaired spelling is the annotation-tracking one, so a future + // change to the annotation cannot silently re-erase it. + expect(source).toMatch(/schema\s*=\s*\{\}\s*as\s+NonNullable<\s*Record\w+RendererProps\['schema'\]\s*>/); + }); + } +}); + +describe('objectui#8649 — the routed-key ledger is not stale', () => { + for (const [key, files] of Object.entries(ROUTED_KEYS)) { + for (const file of files) { + it(`${file} still reads \`${key}\` (ledger entry stays live)`, () => { + const source = maskedSource(file); + expect(source).toContain(`.${key}`); + }); + } + } + + it('the rail still reads the node-level `properties` envelope it now declares', () => { + expect(maskedSource('record-reference-rail.tsx')).toMatch(/properties\??\.entries/); + }); + + it('the ledger matcher can fire negative, so the legs above are readings', () => { + expect(mask('const x = 1;')).not.toContain('.enforceFieldSecurity'); + }); +}); + +describe('objectui#8649 — the aligned mirror keys reach the renderers', () => { + it('`relationshipValueField` is accepted by the mirror at the type level', () => { + // The compile-time legs are the discriminating half; this keeps the literal + // reachable from a vitest run so the fixture cannot rot unnoticed. + expect(relationshipValueFieldAccepted.relationshipValueField).toBe('name'); + }); + + it('the contract accepts the same document, so mirror and contract agree', () => { + const parsed = RecordRelatedListProps.safeParse({ + objectName: 'task', + relationshipField: 'account', + columns: ['name'], + relationshipValueField: 'name', + }); + expect(parsed.success).toBe(true); + expect(parsed.data?.relationshipValueField).toBe('name'); + }); +}); diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx index badbb75bdd..9842f5f130 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -121,7 +121,18 @@ export interface RecordDetailsRendererProps { } export const RecordDetailsRenderer: React.FC = ({ - schema = {} as any, + // ⛔ NOT `{} as any` (objectui#8649). A destructuring default's type joins + // the annotated property type at the binding, so `any` here ERASED + // `RecordDetailsRendererProps` for every read site in this file: declared + // keys (`hideFields`, `sections`, `columns`, …) and undeclared ones + // (`enforceFieldSecurity`, …) all read `any`, indistinguishably. That is what + // made objectui#8327's checker census unable to classify eleven of this + // card's twelve reads, and it is a LOCAL type defect — the exported + // annotation above was always correct, so repairing it moves no published + // surface. Spelled THROUGH the annotation rather than restating it, so a + // later change to `RecordDetailsRendererProps` cannot silently re-erase it. + // Pinned by `__tests__/detailRendererUndeclaredKeys-8649.test.ts`. + schema = {} as NonNullable, className, ...props }) => { diff --git a/packages/plugin-detail/src/renderers/record-highlights.tsx b/packages/plugin-detail/src/renderers/record-highlights.tsx index 5f48ac03b3..077481b2b6 100644 --- a/packages/plugin-detail/src/renderers/record-highlights.tsx +++ b/packages/plugin-detail/src/renderers/record-highlights.tsx @@ -28,7 +28,10 @@ export interface RecordHighlightsRendererProps { } export const RecordHighlightsRenderer: React.FC = ({ - schema = {} as any, + // ⛔ NOT `{} as any` — the annotation-erasing default objectui#8649 repaired. + // The mechanism and why the spelling tracks the annotation are written once, + // at the same site in `record-details.tsx`. + schema = {} as NonNullable, className, ...props }) => { diff --git a/packages/plugin-detail/src/renderers/record-reference-rail.tsx b/packages/plugin-detail/src/renderers/record-reference-rail.tsx index ee42cb9c02..f18c7fda35 100644 --- a/packages/plugin-detail/src/renderers/record-reference-rail.tsx +++ b/packages/plugin-detail/src/renderers/record-reference-rail.tsx @@ -102,6 +102,28 @@ export interface RecordReferenceRailRendererProps { * behavior. */ hideEmpty?: boolean; + /** + * The contract's NODE-level props envelope + * (`@objectstack/spec` `PageComponentSchema.properties` — "Component props + * passed to the widget"), declared here by objectui#8649 for the ONE member + * this renderer reads off it. + * + * The rail accepts a node either flattened (`schema.entries`) or enveloped + * (`schema.properties.entries`), and the enveloped read compiled only + * through the `[k: string]: any` below — so `entries` arrived as `any` on + * that path while the flattened path had the contract's own entry type. + * Declaring it NARROWS an accept this face already granted; it widens + * nothing, and `properties` itself stays open because the contract declares + * it as a record. + * + * ⚠️ This is the node's envelope, NOT a `record:reference_rail` prop: + * `RecordReferenceRailProps` declares `entries` and `hideEmpty` and nothing + * else. `properties` has the standing `dataSource` and `className` have — + * accepted on every page component, as + * `recordRelatedListInputs.spec-parity.test.ts` derives for the sibling + * block. + */ + properties?: { entries?: ReferenceRailEntry[] } & Record; [k: string]: any; }; className?: string; diff --git a/packages/plugin-detail/src/renderers/record-related-list.tsx b/packages/plugin-detail/src/renderers/record-related-list.tsx index 613623aa03..219d09ca28 100644 --- a/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -79,7 +79,10 @@ export interface RecordRelatedListRendererProps { } const RecordRelatedListBody: React.FC = ({ - schema = {} as any, + // ⛔ NOT `{} as any` — the annotation-erasing default objectui#8649 repaired. + // The mechanism and why the spelling tracks the annotation are written once, + // at the same site in `record-details.tsx`. + schema = {} as NonNullable, className, ...props }) => { @@ -128,11 +131,28 @@ const RecordRelatedListBody: React.FC = ({ const relatedActions = useRelatedRecordActions(); const handlers = React.useMemo( () => - relatedActions?.resolve({ - objectName, - relationshipField: schema.relationshipField, - parentId: parentLinkValue, - }) ?? null, + // The `objectName &&` gate is the objectui#8649 erasure repair surfacing a + // latent contract violation, not a behaviour change. `schema.objectName` + // is OPTIONAL on this component by declaration (see the annotation above: + // the gate binds it from `dataSource`, so it can arrive unbound), while + // `ResolveRelatedRecordActionsInput.objectName` is `string`. Until the + // default stopped erasing the annotation both read `any` and the mismatch + // was invisible; `tsc` names it as TS2322 now. + // + // Output-identical, and both halves are measured rather than assumed: + // `resolve` is pure and its only use of the key is + // `objects.find((o) => o?.name === objectName)`, which finds nothing for + // `undefined` and returns `{}`; and `handlers` is never read on this path + // — the `if (!objectName)` placeholder return below (kept AFTER the hooks + // for hook-order stability) discards it. So the gate replaces a discarded + // `{}` with a discarded `null` and skips a lookup that could never hit. + objectName + ? (relatedActions?.resolve({ + objectName, + relationshipField: schema.relationshipField, + parentId: parentLinkValue, + }) ?? null) + : null, [relatedActions, objectName, schema.relationshipField, parentLinkValue], ); diff --git a/packages/types/src/record-components.ts b/packages/types/src/record-components.ts index 52abc6f3ce..fd17b28cda 100644 --- a/packages/types/src/record-components.ts +++ b/packages/types/src/record-components.ts @@ -292,6 +292,21 @@ export interface RecordRelatedListComponentProps { objectName: string; /** Field on the related object that links back to this record */ relationshipField: string; + /** + * Parent-record field whose value `relationshipField` stores — the spec's own + * wording. Defaults to `'id'`; `'name'` for a name-keyed junction. + * + * Declared here by objectui#8649. The contract has always declared it + * (`@objectstack/spec` `RecordRelatedListProps.relationshipValueField`, + * `z.string().default('id')`), `RecordRelatedListRenderer` has always read it, + * and `@object-ui/plugin-detail`'s registry has published it as an input since + * objectui#3808 — every layer declared it except this published TypeScript + * face, so a spec-valid, renderer-honoured, registry-published document was + * refused here with `TS2353`. That is objectui#9040's Direction 1, one + * interface over. Declaring it ALIGNS THE MIRROR rather than widening it: the + * accept set of this face moves to the contract's, never past it. + */ + relationshipValueField?: string; /** Columns to display in the related list */ columns?: string[]; /** Sort configuration — `'field'` / `'-field'` string or explicit array (spec union) */ From 9b00449c47f4ed938a854c84f1b1c96cda989bc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 07:17:12 +0000 Subject: [PATCH 2/3] fix(plugin-detail): census the block-tag map, not the spec module namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `import * as specUi from '@objectstack/spec/ui'` pulled the restricted form-VIEW vocabulary (`FormField` / `FormFieldSchema`) in with everything else, and the repo's `no-restricted-imports` rule refuses it by name: that type erases to `any`, so importing it silently deletes type safety (objectui#3090). The replacement is not a narrower import of the same idea — it is a better population. `ComponentPropsMap` is the contract's own block-tag map, which is the authoring surface an author writes into; "whatever the module exports" also contains action and nav-item schemas that no page author can write a block prop on. That is exactly the trap `requiredPermissions` sets for a word-frequency screen, and the census now reads the surface the question is about: every block the contract maps, plus the node envelope every block shares. The verdicts are unchanged and sharper. `enforceFieldSecurity` and `redactFields` are declared by no block and are not node-level keys; `requiredPermissions` is declared by exactly one block, `record:quick_actions`, and by none of the three this card covers. The walk now also NAMES the blocks whose props schema it cannot open, because such a block is a hole in every absence reading rather than something to skip in silence. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .../8649-detail-renderer-undeclared-keys.md | 18 +-- .../detailRendererUndeclaredKeys-8649.test.ts | 122 +++++++++++------- 2 files changed, 87 insertions(+), 53 deletions(-) diff --git a/.changeset/8649-detail-renderer-undeclared-keys.md b/.changeset/8649-detail-renderer-undeclared-keys.md index d8ffd02240..4cd2015f48 100644 --- a/.changeset/8649-detail-renderer-undeclared-keys.md +++ b/.changeset/8649-detail-renderer-undeclared-keys.md @@ -57,17 +57,17 @@ stays open because the contract declares it as a record. ⚠️ **Three keys are deliberately NOT declared, and no runtime behaviour changes.** `enforceFieldSecurity`, `redactFields` and `requiredPermissions` are read by all three renderers and are **routed to the producer**, not declared -here and not retired here. Measured on the installed contract (17.4.0) over -every object schema `@objectstack/spec/ui` exports, with controls in the same -pass: +here and not retired here. Measured on the installed contract over the block-tag +map `ComponentPropsMap` — the authoring surface an author writes into — plus the +node envelope every block shares, with controls in the same pass: ``` -enforceFieldSecurity · redactFields declared on 0 exported schemas -requiredPermissions declared — including on the sibling block - RecordQuickActionsProps — but on none of - RecordDetails/Highlights/RelatedListProps -aria · fields · columns declared on many <- CONTROL -zzqx_no_such_key declared on 0 <- CONTROL +enforceFieldSecurity · redactFields declared by no block, and not on the node +requiredPermissions declared by exactly one block, + `record:quick_actions`, and by none of + record:details / :highlights / :related_list +aria · fields declared by many blocks <- CONTROL +zzqx_no_such_key declared by none <- CONTROL ``` Declaring them here would make this repo accept what the platform refuses; diff --git a/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts b/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts index ce09815d7c..bcc02b7535 100644 --- a/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts +++ b/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts @@ -34,7 +34,7 @@ * the platform refuses. The question per key is therefore: does the contract * declare it, and on WHICH schema? Both halves matter — a token that exists * somewhere under the UI contract is not a declaration on the schema a given - * node maps to. {@link declaringSchemasOf} answers exactly that, over the + * node maps to. {@link declaringBlocksOf} answers exactly that, over the * installed published artifact, every run. * * - `hideFields` (`record-details`) — DECLARED, on `RecordDetailsProps` and @@ -53,15 +53,15 @@ * `recordRelatedListInputs.spec-parity.test.ts`. ⇒ ALIGN THE MIRROR, for the * one member this renderer reads off the envelope. * - `enforceFieldSecurity` and `redactFields` (three renderers each) — declared - * on NO object schema the UI contract exports. ⇒ "declare" is off the table + * by NO block the UI contract maps, and not on the shared node envelope + * either. ⇒ "declare" is off the table * outright. ROUTED TO THE PRODUCER, ⛔ not retired here: the renderers honour * both keys today on the raw-node path, so deleting the reads would remove a * redaction that is working, and changing runtime masking behaviour is the * maintainer floor this card must not cross. * - `requiredPermissions` (three renderers each) — the sharpest of the twelve. - * The contract DOES declare it, including on the sibling page-component - * props schema `RecordQuickActionsProps`, but NOT on the three this card - * covers. A word-frequency screen over the contract reads "present" and is + * The contract DOES declare it, on the sibling block `record:quick_actions`, + * but NOT on the three this card covers. A word-frequency screen over the contract reads "present" and is * wrong about exactly this; the per-schema census below is what separates * them. ⇒ ROUTED TO THE PRODUCER, same floor. * @@ -71,7 +71,9 @@ * else — vitest strips types. They are the ONLY half that discriminates the * two mirror alignments from their defect, because that defect was a * TypeScript-only refusal. - * - The census legs read the INSTALLED `@objectstack/spec` artifact. They are + * - The census legs read the INSTALLED `@objectstack/spec` artifact, over the + * contract's own block-tag map plus the node envelope — the surface an + * author actually writes into. They are * the PREMISE of the routing decision, never its evidence: they were green * before this card and are green after. They earn their place by going RED * the day the platform declares one of the routed keys — which is the signal @@ -89,7 +91,12 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; -import * as specUi from '@objectstack/spec/ui'; +// ⛔ NOT `import * as` from '@objectstack/spec/ui'. A namespace import pulls in +// the restricted form-VIEW vocabulary (`FormField` / `FormFieldSchema`, +// objectui#3090) whose type erases to `any`, and the repo's `no-restricted-imports` +// rule refuses it by name. Named imports are also the better instrument here: +// they make the census population a DECLARED set rather than "whatever the module +// happens to export". import { ComponentPropsMap, PageComponentSchema, @@ -223,25 +230,41 @@ function objectShapeKeys(schema: unknown): string[] | null { } /** - * The census this card's routing rests on: which EXPORTED object schemas of the - * installed UI contract declare `key`. Derived from the artifact on every run — - * never a list written down here. + * The census this card's routing rests on: which BLOCKS of the installed UI + * contract declare `key` on their props. Derived from the artifact on every run + * — never a list written down here. + * + * The population is `ComponentPropsMap`, the contract's own block-tag map, which + * is the authoring surface an author writes into. That is narrower and more + * meaningful than every export of the module: a token can appear on + * `ActionSchema` or a nav item and still be no part of any block's props, which + * is exactly the trap `requiredPermissions` sets for a word-frequency screen. */ -function declaringSchemasOf(key: string): string[] { +function declaringBlocksOf(key: string): string[] { const found: string[] = []; - for (const [name, value] of Object.entries(specUi as Record)) { - const keys = objectShapeKeys(value); - if (keys?.includes(key)) found.push(name); + for (const [tag, schema] of Object.entries(ComponentPropsMap as Record)) { + const keys = objectShapeKeys(schema); + if (keys?.includes(key)) found.push(tag); } return found.sort(); } -/** How many exported object schemas the census actually walked. */ +/** The block tags whose props schema the census could not walk. */ +const unwalkableBlocks = (): string[] => + Object.entries(ComponentPropsMap as Record) + .filter(([, schema]) => objectShapeKeys(schema) === null) + .map(([tag]) => tag) + .sort(); + +/** How many block props schemas the census actually walked. */ const censusPopulation = (): number => - Object.values(specUi as Record).filter((v) => objectShapeKeys(v) !== null).length; + Object.keys(ComponentPropsMap as Record).length - unwalkableBlocks().length; + +/** Keys the contract accepts on the page-component NODE, on every block. */ +const nodeLevelKeys = (): string[] => objectShapeKeys(PageComponentSchema) ?? []; -/** The three props schemas this card's renderers map to. */ -const CARD_PROPS_SCHEMAS = ['RecordDetailsProps', 'RecordHighlightsProps', 'RecordRelatedListProps'] as const; +/** The three blocks this card's erasure-repaired renderers implement. */ +const CARD_BLOCKS = ['record:details', 'record:highlights', 'record:related_list'] as const; /** * The keys routed to the producer, and the renderer files each is ledgered @@ -267,34 +290,42 @@ const maskedSource = (file: string): string => mask(readFileSync(join(RENDERERS, const ERASING_DEFAULT = /schema\s*=\s*\{\}\s*as\s+any/; describe('objectui#8649 — the census the routing rests on (PREMISE, re-derived every run)', () => { - it('walks a non-empty population and discriminates', () => { + it('walks a non-empty population, names what it cannot walk, and discriminates', () => { // Calibration in both directions: an empty walk would make every "declared // nowhere" reading below vacuous, and an everything-set would make them // unfalsifiable. - expect(censusPopulation()).toBeGreaterThan(50); - expect(declaringSchemasOf('zzqx_no_such_key')).toEqual([]); - expect(declaringSchemasOf('aria').length).toBeGreaterThan(0); - expect(declaringSchemasOf('fields').length).toBeGreaterThan(0); + expect(censusPopulation()).toBeGreaterThan(30); + // ⚠️ A block whose props schema this walk cannot open is a HOLE in every + // absence reading below, so it is surfaced rather than silently skipped. The + // three blocks this card rules on must never be in it. + for (const block of CARD_BLOCKS) expect(unwalkableBlocks()).not.toContain(block); + expect(declaringBlocksOf('zzqx_no_such_key')).toEqual([]); + expect(declaringBlocksOf('aria').length).toBeGreaterThan(0); + expect(declaringBlocksOf('fields').length).toBeGreaterThan(0); }); - it('`enforceFieldSecurity` and `redactFields` are declared on NO exported UI-contract schema', () => { - // The reading that takes "declare" off the table for these two. It goes RED - // the day the platform declares either — which is the signal that the routed - // producer-side card landed. - expect(declaringSchemasOf('enforceFieldSecurity')).toEqual([]); - expect(declaringSchemasOf('redactFields')).toEqual([]); + it('`enforceFieldSecurity` and `redactFields` are declared by NO block, and not on the node', () => { + // The reading that takes "declare" off the table for these two: they are no + // part of any block's authoring surface, nor of the envelope every block + // shares. It goes RED the day the platform declares either — which is the + // signal that the routed producer-side card landed. + expect(declaringBlocksOf('enforceFieldSecurity')).toEqual([]); + expect(declaringBlocksOf('redactFields')).toEqual([]); + expect(nodeLevelKeys()).not.toContain('enforceFieldSecurity'); + expect(nodeLevelKeys()).not.toContain('redactFields'); }); - it('`requiredPermissions` IS declared by the contract — just never on these three props schemas', () => { + it('`requiredPermissions` IS declared by the contract — just never on these three blocks', () => { // Why a word-frequency screen gets this key wrong, stated as an assertion - // rather than as prose: the token is present AND the declaration is absent - // where these renderers read it. - const declaring = declaringSchemasOf('requiredPermissions'); + // rather than as prose: the token is present on the authoring surface AND + // absent from the three blocks that read it. + const declaring = declaringBlocksOf('requiredPermissions'); expect(declaring.length).toBeGreaterThan(0); - // The sibling page-component props schema that DOES carry it — the precedent - // the producer-side card would cite. - expect(declaring).toContain('RecordQuickActionsProps'); - for (const schema of CARD_PROPS_SCHEMAS) expect(declaring).not.toContain(schema); + // The sibling block that DOES carry it — the precedent the producer-side + // card would cite. + expect(declaring).toContain('record:quick_actions'); + for (const block of CARD_BLOCKS) expect(declaring).not.toContain(block); + expect(nodeLevelKeys()).not.toContain('requiredPermissions'); }); it('the block-tag map entry IS the named props schema (premise of the type pins)', () => { @@ -304,13 +335,16 @@ describe('objectui#8649 — the census the routing rests on (PREMISE, re-derived }); it('the two mirror alignments are alignments — the contract declares both keys', () => { - expect(declaringSchemasOf('relationshipValueField')).toContain('RecordRelatedListProps'); - expect(declaringSchemasOf('hideFields')).toContain('RecordDetailsProps'); - // `properties` is a NODE-level key, so it is declared on the page-component - // node rather than on any block's props bag. - expect(declaringSchemasOf('properties')).toContain('PageComponentSchema'); - expect(objectShapeKeys(PageComponentSchema)).toContain('dataSource'); - expect(objectShapeKeys(PageComponentSchema)).not.toContain('relationshipField'); + expect(declaringBlocksOf('relationshipValueField')).toContain('record:related_list'); + expect(declaringBlocksOf('hideFields')).toContain('record:details'); + // `properties` is a NODE-level key: declared on the page-component envelope + // every block shares, and on no block's own props bag. Both halves asserted, + // because the rail's declaration is only an alignment if BOTH are true. + expect(nodeLevelKeys()).toContain('properties'); + expect(declaringBlocksOf('properties')).toEqual([]); + // Calibration of the node reading itself, in both directions. + expect(nodeLevelKeys()).toContain('dataSource'); + expect(nodeLevelKeys()).not.toContain('relationshipField'); }); }); From 69cd07ed84e3f7f4191d009f3685023f95c7120b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 08:07:41 +0000 Subject: [PATCH 3/3] fix(plugin-detail): make the rail's enveloped read use the declaration it named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract review D1 on objectui#9469: the previous commit declared `properties` on the reference rail's schema type and never touched the read the declaration was for. That read goes through an explicit `(schema as any)` cast which predates this branch, so the checker saw `.properties : any` and `.entries : any` and the declaration was inert at the one site its own doc-comment named. Measured at the merge-base and at the previous head: the read site's bytes were identical, and deleting the declaration produced errors only inside the test file and none inside the renderer. The cast is gone. `schema.properties?.entries` now carries `ReferenceRailEntry[] | undefined` from the declaration, and the trailing `as ReferenceRailEntry[]` assertion went with it because the declared type supplies it. The two sibling renderers reading the same envelope (`record-history.tsx`, `record-quick-actions.tsx`) already read it un-cast; this file was the outlier. Type assertions erase at compile time, so no runtime behaviour changes. The pin could not have caught this: `toMatch(/properties\??\.entries/)` matches the cast form as happily as the un-cast one. It is replaced by three assertions whose load-bearing one is NEGATIVE — no cast may stand between `schema` and `.properties` — with a control proving that matcher fires. The false mechanism sentence is corrected in both places it was published: the renderer doc-comment and the matching changeset paragraph. The read never compiled through the `[k: string]: any` index signature; it compiled through an explicit cast the index signature had nothing to do with. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .../8649-detail-renderer-undeclared-keys.md | 25 +++++++++---- .../detailRendererUndeclaredKeys-8649.test.ts | 37 ++++++++++++++++++- .../src/renderers/record-reference-rail.tsx | 32 ++++++++++++---- 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/.changeset/8649-detail-renderer-undeclared-keys.md b/.changeset/8649-detail-renderer-undeclared-keys.md index 4cd2015f48..d7d8532d5a 100644 --- a/.changeset/8649-detail-renderer-undeclared-keys.md +++ b/.changeset/8649-detail-renderer-undeclared-keys.md @@ -45,14 +45,23 @@ the key (`objects.find((o) => o?.name === objectName)`) finds nothing for `if (!objectName)` placeholder return. **`record:reference_rail` declares the node-level `properties` envelope it -reads.** The renderer accepts a node either flattened (`schema.entries`) or -enveloped (`schema.properties.entries`); the enveloped read compiled only -through the schema type's `[k: string]: any`, so `entries` arrived as `any` on -that path. `properties` is `@objectstack/spec`'s own node-level key -(`PageComponentSchema.properties`, "Component props passed to the widget") with -the standing `dataSource` and `className` have. Declaring it **narrows** an -accept this face already granted — it widens nothing, and `properties` itself -stays open because the contract declares it as a record. +reads, and the read now uses the declaration.** The renderer accepts a node +either flattened (`schema.entries`) or enveloped (`schema.properties.entries`). +The enveloped read went through an explicit `(schema as any)` cast — **not** +through the schema type's `[k: string]: any`, which had nothing to do with it — +so `entries` arrived as `any` on that path. Both halves are fixed here: the +member is declared **and** the cast is removed, so the checker types the read +`ReferenceRailEntry[]` (the trailing `as ReferenceRailEntry[]` assertion went +with it — the declared type supplies it). `properties` is `@objectstack/spec`'s +own node-level key (`PageComponentSchema.properties`, "Component props passed to +the widget") with the standing `dataSource` and `className` have. Declaring it +**narrows** an accept this face already granted through its index signature — it +widens nothing, and `properties` itself stays open because the contract declares +it as a record. + +⚠️ Declaring a member is not enough on its own when the read site casts: a cast +defeats the declaration while a membership instrument still reports the member +as present. The pin now fails if the cast returns. ⚠️ **Three keys are deliberately NOT declared, and no runtime behaviour changes.** `enforceFieldSecurity`, `redactFields` and `requiredPermissions` are diff --git a/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts b/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts index bcc02b7535..e28534735f 100644 --- a/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts +++ b/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts @@ -289,6 +289,13 @@ const maskedSource = (file: string): string => mask(readFileSync(join(RENDERERS, /** The erasing spelling, as a matcher — applied to real sources AND to a control. */ const ERASING_DEFAULT = /schema\s*=\s*\{\}\s*as\s+any/; +/** + * A cast standing between `schema` and `.properties` — the shape that made this + * card's `properties` declaration inert at its own read site (objectui#8649 + * contract review D1). Applied to the real source AND to a control below. + */ +const CAST_BEFORE_PROPERTIES = /\(\s*schema\s+as\s+\w+\s*\)\s*\.\s*properties/; + describe('objectui#8649 — the census the routing rests on (PREMISE, re-derived every run)', () => { it('walks a non-empty population, names what it cannot walk, and discriminates', () => { // Calibration in both directions: an empty walk would make every "declared @@ -379,8 +386,34 @@ describe('objectui#8649 — the routed-key ledger is not stale', () => { } } - it('the rail still reads the node-level `properties` envelope it now declares', () => { - expect(maskedSource('record-reference-rail.tsx')).toMatch(/properties\??\.entries/); + /** + * ⭐ The assertion this block used to carry was + * `toMatch(/properties\??\.entries/)`, and it was WORTHLESS for the thing it + * was there to guard: it matches `(schema as any).properties.entries` exactly + * as happily as the un-cast form. The declaration this card added was inert at + * this very site for that reason, and this pin reported green throughout — + * measured by the objectui#8649 contract review, not by this file. + * + * A cast at the read site defeats a declaration that a MEMBERSHIP instrument + * (`getPropertyOfType` on the binding, which unwraps the cast) still reports + * as present. So the liveness leg is now three assertions, and the + * load-bearing one is the NEGATIVE: the enveloped read must not be re-cast. + */ + it('the rail reads the node-level `properties` envelope UN-CAST, so the declaration reaches it', () => { + const source = maskedSource('record-reference-rail.tsx'); + // Liveness: the read is still here at all. + expect(source).toMatch(/Array\.isArray\(schema\.properties\?\.entries\)/); + expect(source).toMatch(/\?\s*schema\.properties\.entries/); + // The guard: no cast may stand between `schema` and `.properties`. Comments + // are masked before this runs, so the spelling quoted in this file's own + // prose cannot satisfy or defeat it. + expect(source).not.toMatch(CAST_BEFORE_PROPERTIES); + }); + + it('the un-cast guard can fire, so the negative leg above is a reading', () => { + // The control varies ONLY the claim: the same read, re-cast. + expect(CAST_BEFORE_PROPERTIES.test('Array.isArray((schema as any).properties?.entries)')).toBe(true); + expect(CAST_BEFORE_PROPERTIES.test('Array.isArray(schema.properties?.entries)')).toBe(false); }); it('the ledger matcher can fire negative, so the legs above are readings', () => { diff --git a/packages/plugin-detail/src/renderers/record-reference-rail.tsx b/packages/plugin-detail/src/renderers/record-reference-rail.tsx index f18c7fda35..6d3e1bab08 100644 --- a/packages/plugin-detail/src/renderers/record-reference-rail.tsx +++ b/packages/plugin-detail/src/renderers/record-reference-rail.tsx @@ -109,12 +109,19 @@ export interface RecordReferenceRailRendererProps { * this renderer reads off it. * * The rail accepts a node either flattened (`schema.entries`) or enveloped - * (`schema.properties.entries`), and the enveloped read compiled only - * through the `[k: string]: any` below — so `entries` arrived as `any` on - * that path while the flattened path had the contract's own entry type. - * Declaring it NARROWS an accept this face already granted; it widens - * nothing, and `properties` itself stays open because the contract declares - * it as a record. + * (`schema.properties.entries`). The enveloped read went through an explicit + * `(schema as any)` cast — ⛔ NOT through the `[k: string]: any` below, + * which had nothing to do with it — so `entries` arrived as `any` on that + * path while the flattened path had the contract's own entry type. + * + * ⚠️ An earlier revision of this card declared the member and left that cast + * in place, which made the declaration INERT at the only site this comment + * names: a cast at the read site defeats a declaration that a membership + * instrument still reports as present. The cast is now gone (see the read + * itself), and `__tests__/detailRendererUndeclaredKeys-8649.test.ts` fails + * if it returns. Declaring the member NARROWS an accept this face already + * granted through its index signature; it widens nothing, and `properties` + * itself stays open because the contract declares it as a record. * * ⚠️ This is the node's envelope, NOT a `record:reference_rail` prop: * `RecordReferenceRailProps` declares `entries` and `hideEmpty` and nothing @@ -178,8 +185,17 @@ export const RecordReferenceRailRenderer: React.FC