From 60ba53df046475d62287847732092e4b39902ba3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 15:18:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(types):=20ComponentInput.of=20=E2=80=94=20?= =?UTF-8?q?the=20coarse=20member=20kind,=20with=20readers=20on=20day=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `type: 'array'` said a value was a list and stopped there, so a member that drifted from `@objectstack/spec` was invisible to every layer that reads a declaration. `page:header.actions` is the measured cost: the contract declares `z.array(z.string())` ("Action IDs"), the renderer read the members as `ActionDef` objects, and the repo-wide parity gate stayed green for the whole life of the drift because both sides carried the key and neither could say what was inside it. `ComponentInput` gains an optional `of`, carrying `type`'s coarse-kind vocabulary one level down: the elements of an `array`, or the values of an `object` used as a map. Three readers ship in this change, which is the bar objectui#5905 set for a new key on this interface: - the repo-wide parity gate compares every declared `of` against the member kind `ComponentPropsMap[type]` accepts, one-directionally, with the same exemption discipline as its three existing directions; - `sdui-parser`'s `validateTree` reports a member that fits no declared kind (`member-type-mismatch`, naming the offending positions); - the codegen narrows the generated `sdui-intrinsics.d.ts` element type. Fifteen keys across ten blocks declare one, each derived by probing the member position with one value of each coarse kind and declaring only where exactly one was accepted. `of` is a KIND and never a value domain, so the 2026-08-17 expression-ceiling ruling on `ComponentInput.type` is untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj --- .../8067-component-input-member-kind.md | 54 +++ .../registry-inputs-spec-parity.test.ts | 457 +++++++++++++++++- content/docs/guide/plugin-development.md | 8 +- .../src/renderers/basic/record-picker.tsx | 7 + .../src/renderers/layout/containers.tsx | 6 +- packages/plugin-detail/src/index.tsx | 48 +- packages/plugin-grid/src/index.tsx | 2 +- .../src/__tests__/member-kind-8067.test.ts | 203 ++++++++ packages/sdui-parser/src/codegen.ts | 27 +- packages/sdui-parser/src/index.ts | 16 + packages/sdui-parser/src/types.ts | 16 + packages/sdui-parser/src/validate.ts | 67 ++- ...nent-input-retired-constraint-keys.test.ts | 2 +- .../component-input-retired-keys-7493.test.ts | 38 +- packages/types/src/base.ts | 97 +++- packages/types/src/zod/base.zod.ts | 34 +- 16 files changed, 1005 insertions(+), 77 deletions(-) create mode 100644 .changeset/8067-component-input-member-kind.md create mode 100644 packages/sdui-parser/src/__tests__/member-kind-8067.test.ts diff --git a/.changeset/8067-component-input-member-kind.md b/.changeset/8067-component-input-member-kind.md new file mode 100644 index 0000000000..ddd9c49df8 --- /dev/null +++ b/.changeset/8067-component-input-member-kind.md @@ -0,0 +1,54 @@ +--- +'@object-ui/types': minor +'@object-ui/sdui-parser': minor +'@object-ui/components': minor +'@object-ui/plugin-detail': minor +'@object-ui/plugin-grid': minor +--- + +`ComponentInput.of` — the coarse kind of an input's MEMBERS, with readers on day one +(objectui#8067). + +A registration's `type: 'array'` said a value was a list and stopped there, so a member +that drifted from `@objectstack/spec` was invisible to every layer that reads a +declaration. `page:header.actions` is the measured cost: the contract declares +`z.array(z.string())` ("Action IDs"), the renderer read the members as `ActionDef` +objects, and the repo-wide parity gate in +`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` stayed green for the +whole life of the drift because both sides carried the key and neither could say what +was inside it. What settled it was a maintainer ruling, not a test — and even after the +fix, "these are ids" survived only as English in the registration's `description`. + +**What is new.** `ComponentInput` gains an optional `of`, carrying the same coarse-kind +vocabulary as `type` one level down: the ELEMENTS of an `array`, or the VALUES of an +`object` used as a map. One kind, or an array of them for a member contract that is a +union, with `type`'s semantics — a member passes when any declared arm accepts it. The +manifest serializer forwards it, so `sdui.manifest.json` now carries seven keys per +input instead of six. + +**Three readers ship with it**, which was the bar this slot had to clear (objectui#5905 +is the precedent: five `ComponentInput` keys declared and read by nothing). The +repo-wide parity gate compares every declared `of` against the member kind +`ComponentPropsMap[type]` actually accepts and fails on one the contract refuses; +`sdui-parser`'s `validateTree` reports a member that fits no declared kind, as a new +`member-type-mismatch` diagnostic naming the offending positions; and the generated +`sdui-intrinsics.d.ts` narrows the authoring type — `page:header`'s `actions` is +`string[]` where it used to be `unknown[]`. + +**Fifteen keys now declare one**, across ten blocks, each DERIVED rather than chosen: +every container key's member position was probed with one value of each coarse kind and +a declaration written only where exactly one kind was accepted. A member contract that +admits several kinds — `record:highlights.fields` takes a field name or an inline field +object — is deliberately left undeclared and pinned with its reason, because picking one +arm there is a narrowing this repo leaves un-gated and picking all of them would +advertise shapes only a per-block pin can vouch for. + +**The ceiling is unchanged.** `of` is a KIND and never a value domain, so the maintainer +ruling of 2026-08-17 quoted on `ComponentInput.type` — the coarse arm plus `description` +is the publication face's expression ceiling, and spec is the sole judge of values — +stands exactly as written. `of: 'object'` says the members are objects; which keys they +carry is still `description`'s job and `os validate`'s. + +**Nothing published before this changes.** An input that declares no `of` validates, +serializes and types byte-identically: `validateTree` checks no member, the serializer +emits no key, and the codegen emits the same `unknown[]`. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index e3a3617696..9e644c4c30 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -97,24 +97,34 @@ * DELETED rather than kept — the last test in this file turns a no-longer-needed * exemption red, so the list cannot rot into a permanent allowlist. * - * LIMIT — worth knowing before trusting a pass. This gate compares TOP-LEVEL - * KEY NAMES and nothing else. Two things it therefore cannot see, both real and - * both filed: - * - * - member shapes. An `inputs` entry of type `array`/`object` declares no - * member shape (`ComponentInput` has no slot for one), so a drifted key - * INSIDE an array element or nested object is invisible here — which is why - * `record:details.sections`, `record:highlights.fields` and - * `record:related_list.add` publish their members in prose and are pinned by - * per-block tests next to their renderers. PR #3795's open question; + * LIMIT — worth knowing before trusting a pass. Of the two things this gate + * used to be unable to see, ONE IS NOW GATED and one is still deliberately not: + * + * - member KINDS — CLOSED, objectui#8067. This gate used to compare top-level + * key names and nothing else, because an `inputs` entry of type + * `array`/`object` declared no member shape at all: `ComponentInput` had no + * slot for one, so a member that drifted from the contract was invisible + * here. That is what `page:header.actions` cost — spec `z.array(z.string())` + * ("Action IDs"), a renderer reading the members as `ActionDef` OBJECTS, and + * this gate green for the whole life of the drift because both sides had the + * key. `ComponentInput.of` now carries the member kind, and the MEMBER + * DIRECTION section below compares every declared one against what + * `ComponentPropsMap[type]` accepts at the member position. It stops at the + * KIND, exactly as the arm direction does: `of: 'object'` says the members + * are objects, never WHICH KEYS they have, so the nested field shapes of + * `record:details.sections` and `record:related_list.add` are still per-block + * pins next to their renderers. PR #3795's open question, half of it closed; * - types, NARROWER than the contract. A key can be in perfect name parity * while declaring fewer arms than the spec accepts, and this gate does not * look. That half is deliberately left to per-block discipline, for the * reason the ARM DIRECTION section below sets out: narrowing is NOISY, and - * noise is at least audible. + * noise is at least audible. It applies to `of` unchanged — a member union + * declared with one arm is not gated either, and the keys left undeclared + * for that reason are pinned in `MULTI_KIND_MEMBER_CONTRACTS`. * - * A pass means the top-level key names are in parity, and that no declared arm - * is one the contract refuses outright — nothing more. + * A pass means the top-level key names are in parity, that no declared arm is + * one the contract refuses outright, and that no declared MEMBER kind is — + * nothing more. * * ── THE ARM DIRECTION, AND WHY ONLY ONE OF ITS TWO HALVES IS GATED ────────── * (objectui#4971) @@ -1287,6 +1297,256 @@ const OFF_SPEC_ARM_EXEMPTIONS: Record = { 'Two spec authorities disagree about the KIND, so no declaration can satisfy both: ObjectGridSchema.data resolves to ViewDataSchema (an object discriminated on `provider`) while ComponentPropsMap[object-grid].data is `z.array(z.unknown())` ("Static inline rows"). The `object` arm is the DELIBERATE one — objectui#5090 / PR objectui#5108 changed it from `array` against ViewDataSchema, and plugin-grid/src/__tests__/gridDataInputContract.test.ts pins it there; flipping it back re-opens #5090 and fails `tsc` (TS2322, measured on that card). Convergence is upstream, filed as objectui#6207.', }; +// ── the MEMBER direction (objectui#8067) ───────────────────────────────────── +// +// The first of the two LIMITs in this file's header, closed. `ComponentInput` +// now carries `of` — the coarse KIND of an input's members, array elements or +// the values of an object used as a map — so the `inputs` side can finally say +// what a container holds, and this section compares it to what the contract +// holds. Same `covered` set, same derived-not-restated expectations, same +// exemption discipline as the three directions above, and the same ONE +// DIRECTION for the same reason: a member kind the contract REFUSES is silent +// (the manifest, the generated `.d.ts` and `validateTree` all publish it as +// legal), while declaring FEWER member kinds than the contract accepts is +// merely noisy, and noise is audible. +// +// WHAT THIS COST BEFORE IT EXISTED. `page:header.actions` — spec +// `z.array(z.string())`, "Action IDs"; the renderer read the members as +// `ActionDef` OBJECTS; this gate saw `actions` on both sides and stayed green +// for the whole life of the drift. What settled it was a maintainer ruling, not +// a test, and even after the fix the fact "these are ids" lived only in the +// registration's `description` PROSE. It is now `of: 'string'` at that +// registration, and the calibration pin below asserts, by name, that this gate +// reds on `of: 'object'` there — the exact declaration the drift would have +// made. + +/** + * The container arms a member declaration can describe. + * + * `of` means the same thing on each: the ELEMENTS of the array, and the VALUES + * of an object used as a MAP. An input declaring `of` and neither of these has + * no member position at all, which `judgeMembers` reports rather than skips. + */ +const CONTAINER_ARMS = new Set(['array', 'object']); + +/** + * The key a member probe occupies when the container arm is `object`. + * + * Deliberately a name no contract declares, because the two answers it can draw + * are exactly the two cases that need telling apart: a MAP contract + * (`z.record(...)`) judges the value under whatever key it is given, while a + * NAMED-SHAPE contract (`z.object({ ... })`) refuses the key itself at the + * container node — which is not a verdict about the member kind, it is the + * absence of a uniform member position. + */ +const OBJECT_MEMBER_PROBE_KEY = '__objectui_member_probe__'; + +/** The value that puts one member probe in the member position of a container arm. */ +function containerProbe(containerArm: string, member: unknown): { value: unknown; position: unknown } { + return containerArm === 'array' + ? { value: [member], position: 0 } + : { value: { [OBJECT_MEMBER_PROBE_KEY]: member }, position: OBJECT_MEMBER_PROBE_KEY }; +} + +type MemberVerdict = ArmVerdict | 'no-member-position'; + +/** + * What the contract says about ONE member value, in ONE container arm, on ONE + * key. + * + * The same scoping discipline `specArmVerdict` is built on, one level deeper: + * only issues about this key count, and of those only the ones at the MEMBER's + * own position, so a sibling key's missing requirement cannot speak for a + * member and a complaint about the container cannot either. Once the issues are + * rebased onto the member node, the judgement is `refusesKind` — the same + * function, unchanged, because "is this a KIND refusal or a CONTENT refusal" is + * the same question at any depth. + * + * `no-member-position` is the verdict this level adds, and it is not a shrug: + * it means the contract refused the CONTAINER — either its kind outright, or, + * for an `object` arm, the probe key itself, which is how a named-shape + * `z.object({ ... })` says it is not a map. A declaration claiming uniform + * members of a contract that has no uniform member position is wrong in a way + * worth naming, so the gate below treats it as a refusal rather than skipping + * it. + */ +function specMemberVerdict( + type: string, + key: string, + containerArm: string, + member: unknown, +): MemberVerdict { + const parser = specParser(type); + if (!parser) return 'no-schema'; + const { value, position } = containerProbe(containerArm, member); + const result = parser.safeParse({ [key]: value }); + if (result.success) return 'accepts'; + const issues = result.error?.issues ?? []; + // The key refused BY NAME at the top — the forward direction's subject, and + // `judgeMembers` only judges keys the accepted set already carries. + if (issues.some((issue) => issue.code === 'unrecognized_keys' && (issue.keys ?? []).includes(key))) + return 'no-member-position'; + const mine = issues.filter((issue) => (issue.path ?? [])[0] === key); + if (mine.length === 0) return 'accepts'; + // Anything AT the container node is about the container, not the member: an + // `invalid_type` refusing the container kind, or the `unrecognized_keys` a + // named-shape object raises for the probe key. + if (mine.some((issue) => (issue.path ?? []).length === 1)) return 'no-member-position'; + const atMember = mine + .filter((issue) => (issue.path ?? [])[1] === position) + .map((issue) => ({ ...issue, path: (issue.path ?? []).slice(2) })); + if (atMember.length === 0) return 'accepts'; + return refusesKind(atMember, member) ? 'refuses-kind' : 'refuses-content'; +} + +interface MemberJudgement { + /** `BLOCK.INPUT:of=ARM` — the exemption key format. */ + id: string; + type: string; + input: string; + arm: string; + verdict: 'witnessed' | 'refused' | 'exempt-slot' | 'exempt-empty-enum' | 'no-container-arm'; + /** What the contract actually answered, for the failure message. */ + evidence: string; +} + +/** + * Judge every declared member arm of every declared input on one block. + * + * An input that declares no `of` produces no judgement — this direction asks + * what a DECLARATION claims, and there is nothing to compare against a key that + * claims nothing. (What that silence costs is the LIMIT this section closes; + * which keys deserve a declaration and which are left to per-block discipline + * is recorded on `ComponentInput.of` and pinned by + * `member declarations are derived from single-kind member contracts` below.) + * + * Off-spec input names are skipped for the same reason `judgeArms` skips them: + * asking what members a contract accepts inside a key it does not declare has + * no answer worth reporting. + */ +function judgeMembers(type: string): MemberJudgement[] { + const accepted = new Set(specTopLevelKeys(type)); + const judgements: MemberJudgement[] = []; + for (const input of declaredInputEntries(type) ?? []) { + if (!accepted.has(input.name)) continue; + const memberArms = inputTypeArms(input.of as never); + if (memberArms.length === 0) continue; + const containerArms = inputTypeArms(input.type).filter((arm) => CONTAINER_ARMS.has(arm)); + for (const arm of memberArms) { + const id = `${type}.${input.name}:of=${arm}`; + if (containerArms.length === 0) { + judgements.push({ + id, type, input: input.name, arm, + verdict: 'no-container-arm', + evidence: `declares members but its type is ${JSON.stringify(input.type)}, which holds none`, + }); + continue; + } + if (ARM_KINDS_WITHOUT_A_VALUE_CLAIM.has(arm)) { + judgements.push({ + id, type, input: input.name, arm, + verdict: 'exempt-slot', + evidence: 'describes a child position, not a value', + }); + continue; + } + if (arm === 'enum') { + // EXACT, not coarse — the same rule the `enum` ARM is judged by, since + // an enum's admitted set is finite and written down. Every declared + // member must be a value the contract accepts SOMEWHERE in the member + // position of some declared container arm. + const members = declaredEnumValues(input); + if (members.length === 0) { + judgements.push({ + id, type, input: input.name, arm, + verdict: 'exempt-empty-enum', + evidence: 'declares no members, so it admits nothing', + }); + continue; + } + const refused = members.filter((member) => + !containerArms.some( + (containerArm) => specMemberVerdict(type, input.name, containerArm, member) === 'accepts', + ), + ); + judgements.push({ + id, type, input: input.name, arm, + verdict: refused.length === 0 ? 'witnessed' : 'refused', + evidence: + refused.length === 0 + ? `all ${members.length} declared members accepted` + : `the contract refuses the declared member(s) ${JSON.stringify(refused)}`, + }); + continue; + } + const probes = COARSE_ARM_PROBES[arm] ?? []; + const verdicts = containerArms.flatMap((containerArm) => + probes.map((probe) => specMemberVerdict(type, input.name, containerArm, probe)), + ); + const witnessed = verdicts.some( + (verdict) => verdict === 'accepts' || verdict === 'refuses-content', + ); + judgements.push({ + id, type, input: input.name, arm, + verdict: witnessed ? 'witnessed' : 'refused', + evidence: witnessed + ? `member probe verdicts ${JSON.stringify(verdicts)}` + : `the contract admits no member of this KIND — member probe verdicts ${JSON.stringify(verdicts)}`, + }); + } + } + return judgements; +} + +/** Every member judgement this gate makes, computed once. */ +const MEMBER_JUDGEMENTS: MemberJudgement[] = covered.flatMap(judgeMembers); + +/** The verdicts that mean "the contract will not have this member declaration". */ +const MEMBER_REFUSALS = new Set(['refused', 'no-container-arm']); + +/** Member arms of `type` the contract refuses, as `BLOCK.INPUT:of=ARM`. */ +const refusedMembers = (type: string): string[] => + MEMBER_JUDGEMENTS.filter( + (judgement) => judgement.type === type && MEMBER_REFUSALS.has(judgement.verdict), + ).map((judgement) => judgement.id); + +/** + * Declared MEMBER arms the contract refuses, ACCEPTED for now, each with the + * reason. Key format: `BLOCK.INPUT:of=ARM`. + * + * Fourth instance of this file's one exemption discipline, and the bar is + * unchanged: the divergence has to be owned by a named, open piece of work, + * because neither `@objectstack/spec` nor a declaration is edited to make a + * gate green (AGENTS.md #0 / #0.1). + * + * EMPTY ON ARRIVAL, and that is a measurement rather than an accident. Every + * `of` this repository declares was DERIVED from the contract — each container + * key's member position was probed with one value of each coarse kind, and a + * declaration was written only where exactly ONE kind was accepted — so a + * refusal here would mean the derivation and the contract disagree, which is a + * finding, not an exemption. + */ +const OFF_SPEC_MEMBER_EXEMPTIONS: Record = {}; + +/** + * Container keys whose member contract accepts MORE THAN ONE coarse kind, and + * are therefore deliberately left without an `of`. + * + * Pinned rather than merely absent, because "no declaration" and "no + * declaration for a reason" are the same byte in the registration and opposite + * facts about this gate. Declaring one arm of a genuine member union is the + * NARROWING this repo leaves un-gated as noise (#4971), and declaring all of + * them would advertise member shapes the renderer may not resolve — the rule + * `ComponentInput.type` states for arms, one level down. Either way it is + * per-block knowledge, not a repo-wide derivation, so it stays out of this + * change. + */ +const MULTI_KIND_MEMBER_CONTRACTS: Record = { + 'record:highlights.fields': + 'The member contract is a union — a field NAME or an inline field object — so no single coarse arm describes it and declaring both would advertise a member shape only the per-block pin next to the renderer can vouch for (packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts). objectui#8067 leaves it undeclared on purpose.', +}; + + describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide)', () => { it('judges every spec-carried block that declares an authoring surface', () => { // Non-vacuity guard. Every per-block assertion below is generated from @@ -1827,6 +2087,177 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) expect(Object.keys(OFF_SPEC_ARM_EXEMPTIONS).filter((id) => !refused.has(id))).toEqual([]); }); + // ── the MEMBER direction (objectui#8067) ─────────────────────────────────── + + it.each(covered)('%s declares no member kind the spec refuses outright', (type) => { + const unregistered = refusedMembers(type).filter((id) => !(id in OFF_SPEC_MEMBER_EXEMPTIONS)); + const evidence = MEMBER_JUDGEMENTS.filter((judgement) => unregistered.includes(judgement.id)) + .map((judgement) => `${judgement.id} — ${judgement.evidence}`) + .join('; '); + expect(unregistered, evidence).toEqual([]); + }); + + it('judges a non-vacuous member census — real declarations, on real blocks', () => { + // THE NON-VACUITY GUARD for this direction, and it has to be stricter than + // the arm direction's. `of` is OPTIONAL: a walk that resolved nothing — + // `inputTypeArms(input.of)` returning `[]` because the key stopped reaching + // the registry, a `specTopLevelKeys` that skipped every key as off-spec, a + // serializer that dropped the field — produces an EMPTY judgement list, and + // an empty list is INDISTINGUISHABLE from "no block declares members yet". + // That is precisely the failure mode objectui#5905 recorded: a key written + // everywhere and read by nothing, with every gate green. So the counts are + // pinned as lower bounds rather than merely asserted non-zero. + const keysJudged = new Set( + MEMBER_JUDGEMENTS.map((judgement) => `${judgement.type}.${judgement.input}`), + ); + const blocksJudged = new Set(MEMBER_JUDGEMENTS.map((judgement) => judgement.type)); + const census = + `blocks with member declarations ${blocksJudged.size} · keys judged ${keysJudged.size} ` + + `· member arms judged ${MEMBER_JUDGEMENTS.length} · witnessed ` + + `${MEMBER_JUDGEMENTS.filter((j) => j.verdict === 'witnessed').length} · refused ` + + `${MEMBER_JUDGEMENTS.filter((j) => MEMBER_REFUSALS.has(j.verdict)).length} ` + + `· registered exemptions ${Object.keys(OFF_SPEC_MEMBER_EXEMPTIONS).length}`; + + // The fifteen keys objectui#8067 derived from single-kind member contracts, + // across ten blocks. A LOWER bound, not an equality: a new declaration that + // this gate then judges is the direction this section exists to encourage, + // and it should not have to edit a number to land. A declaration + // DISAPPEARING is the regression, and that is what the bound catches. + expect(keysJudged.size, census).toBeGreaterThanOrEqual(15); + expect(blocksJudged.size, census).toBeGreaterThanOrEqual(10); + expect(MEMBER_JUDGEMENTS.length, census).toBeGreaterThanOrEqual(keysJudged.size); + // Every judgement must be a real verdict about the contract, not a skip. + expect( + MEMBER_JUDGEMENTS.filter((j) => j.verdict === 'witnessed').length, + census, + ).toBeGreaterThanOrEqual(15); + }); + + it('the member judge reds on the drift that started this — page:header.actions, by name', () => { + // CALIBRATION, and the reason this section is not just three more green + // assertions. Every other member assertion here is satisfied by a judge that + // never refutes anything; this one asserts the refutation, on the key whose + // drift the card was filed for. + // + // `ComponentPropsMap['page:header'].actions` is `z.array(z.string())` — + // "Action IDs". The declaration says `of: 'string'`, and that must be + // witnessed… + expect(specMemberVerdict('page:header', 'actions', 'array', 'Account')).toBe('accepts'); + // …while `of: 'object'` — the members the renderer actually read for the + // whole life of the drift, and the exact mutation the ablation for this card + // applies — must read as a KIND refusal at the MEMBER position. Before this + // section existed there was no declaration to make and nothing to compare + // it with, which is why the gate stayed green. + expect(specMemberVerdict('page:header', 'actions', 'array', { id: 'clone' })).toBe( + 'refuses-kind', + ); + expect(specMemberVerdict('page:header', 'actions', 'array', 42)).toBe('refuses-kind'); + + // The container-level control, which is what tells a member refusal from the + // top-level refusal the ARM direction already covered: the ARRAY itself is + // perfectly acceptable on this key. A judge that simply refused everything + // would pass the two assertions above and fail this one. + expect(specArmVerdict('page:header', 'actions', [])).toBe('accepts'); + + // …and the second half of the calibration: a CONTENT refusal at the member + // position is NOT a kind refusal, so the coarse-kind ceiling survives one + // level down exactly as it does at the top. `record:activity.types` is a + // spec enum of strings — a string member is refused as a VALUE, and reading + // that as "the string member declaration is invented" would condemn a + // declaration derived from the contract itself. + expect(specMemberVerdict('record:activity', 'types', 'array', 'Account')).toBe( + 'refuses-content', + ); + expect(specMemberVerdict('record:activity', 'types', 'array', 42)).toBe('refuses-kind'); + }); + + it('a contract with no uniform member position is named, not silently skipped', () => { + // The third verdict this level adds, calibrated by name because nothing in + // the repository declares it today and an unexercised branch is a branch + // that can be wrong for free. + // + // `record:related_list.add` is a named-shape `z.object({ ... })`, not a map: + // it has no position where "every value is of kind K" is even a statement, + // so a probe key it never declared is refused AT THE CONTAINER. That is not + // a verdict about the member kind — it is the absence of a member position, + // and `judgeMembers` reports a declaration resting on one rather than + // passing it. + expect(specMemberVerdict('record:related_list', 'add', 'object', 'Account')).toBe( + 'no-member-position', + ); + // …and the control: the same block's `columns` IS a container with a member + // position, so the verdict above is about the contract's shape and not about + // the probe machinery. + expect(specMemberVerdict('record:related_list', 'columns', 'array', 'name')).toBe('accepts'); + // The other half of the same fact: a MAP contract judges the value under + // whatever key it is given, so the probe key is not refused there. + expect(specMemberVerdict('object-form', 'initialValues', 'object', 'Account')).toBe('accepts'); + }); + + it('member declarations are derived from single-kind member contracts', () => { + // The rule `ComponentInput.of` states, asserted rather than trusted: a key + // is declared when the contract accepts exactly ONE coarse member kind, and + // left alone when it accepts several. Both halves matter — the first is what + // makes a declaration underivable-by-hand and therefore checkable, and the + // second is what keeps this change from advertising member shapes only a + // per-block pin can vouch for. + const KINDS = ['string', 'number', 'boolean', 'array', 'object'] as const; + const acceptedMemberKinds = (type: string, key: string): string[] => + KINDS.filter((kind) => + (COARSE_ARM_PROBES[kind] ?? []).some((probe) => { + const verdict = specMemberVerdict(type, key, 'array', probe); + return verdict === 'accepts' || verdict === 'refuses-content'; + }), + ); + + // Every declared member arm is the contract's single accepted kind… + const declaredAgainstContract = MEMBER_JUDGEMENTS.filter( + (judgement) => judgement.verdict === 'witnessed', + ).map((judgement) => { + const accepted = acceptedMemberKinds(judgement.type, judgement.input); + return `${judgement.id} → contract accepts {${accepted.join(',')}}`; + }); + expect( + declaredAgainstContract.filter((row) => !/→ contract accepts \{[a-z]+\}$/.test(row)), + declaredAgainstContract.join('; '), + ).toEqual([]); + + // …and the keys left undeclared for a member UNION are pinned with their + // reason, so "no declaration" and "no declaration for a reason" stay + // different facts. A stale entry fails: once the contract collapses to one + // kind, the key becomes derivable and the entry must be deleted. + for (const [id, reason] of Object.entries(MULTI_KIND_MEMBER_CONTRACTS)) { + const [type, key] = splitExemptionKey(id); + expect(reason.length, id).toBeGreaterThan(40); + expect(reason, id).toMatch(/objectui#\d+/); + expect(declaredInputs(type) ?? [], id).toContain(key); + expect(acceptedMemberKinds(type, key).length, `${id} — ${reason}`).toBeGreaterThan(1); + expect( + MEMBER_JUDGEMENTS.some((judgement) => judgement.type === type && judgement.input === key), + `${id} is pinned as a member union yet declares an \`of\` — delete the entry`, + ).toBe(false); + } + }); + + it('every member exemption names a member arm a covered block really declares', () => { + const declaredIds = new Set(MEMBER_JUDGEMENTS.map((judgement) => judgement.id)); + expect(Object.keys(OFF_SPEC_MEMBER_EXEMPTIONS).filter((id) => !declaredIds.has(id))).toEqual([]); + }); + + it('every member exemption states a reason and references a tracking issue', () => { + for (const [id, reason] of Object.entries(OFF_SPEC_MEMBER_EXEMPTIONS)) { + expect(reason.length, id).toBeGreaterThan(40); + expect(reason, id).toMatch(/objectui#\d+|objectstack#\d+/); + } + }); + + it('carries no stale member exemption — a member kind the contract accepts must lose its entry', () => { + const refused = new Set( + MEMBER_JUDGEMENTS.filter((j) => MEMBER_REFUSALS.has(j.verdict)).map((j) => j.id), + ); + expect(Object.keys(OFF_SPEC_MEMBER_EXEMPTIONS).filter((id) => !refused.has(id))).toEqual([]); + }); + it('the five A-class keys objectui#3808 / #3830 declared are discoverable, block by block', () => { // Named, not just covered by the derived loop above. The derived assertion // would also pass if these five were added to `UNPUBLISHED_EXEMPTIONS` diff --git a/content/docs/guide/plugin-development.md b/content/docs/guide/plugin-development.md index b7825ba0c3..dad028c3f3 100644 --- a/content/docs/guide/plugin-development.md +++ b/content/docs/guide/plugin-development.md @@ -366,14 +366,16 @@ export interface BoardSchema extends BaseSchema { } ``` -Declare `ComponentInput` entries when registering: they are what the published manifest (`sdui.manifest.json`) and the JSX-page compiler's diagnostics read. Each entry carries the six keys the manifest forwards — `name`, `type`, `required`, `enum`, `binding`, `description`; a default belongs in the renderer's own fallback read and, for the author, in `description` (`label`, `defaultValue` and `advanced` are retired keys — nothing ever read them): +Declare `ComponentInput` entries when registering: they are what the published manifest (`sdui.manifest.json`) and the JSX-page compiler's diagnostics read. Each entry carries the seven keys the manifest forwards — `name`, `type`, `of`, `required`, `enum`, `binding`, `description`; a default belongs in the renderer's own fallback read and, for the author, in `description` (`label`, `defaultValue` and `advanced` are retired keys — nothing ever read them). + +`of` is the coarse kind of an `array`'s elements, or of the values of an `object` used as a map. Declare it whenever the contract admits exactly one kind there: `type: 'array'` alone says a value is a list and stops, so a member of the wrong shape reaches the renderer with nothing anywhere reporting it. With `of` declared, the parser reports a member no declared kind accepts, and the generated `sdui-intrinsics.d.ts` types the elements (`string[]` rather than `unknown[]`). Like `type`, it names a KIND and never a domain: `of: 'object'` says the elements are objects, not which keys they carry — spell that out in `description`, and let `os validate` judge the values. A member contract that genuinely accepts several kinds takes the array form (`of: ['string', 'object']`), or no `of` at all: ```tsx ComponentRegistry.register('board', BoardRenderer, { inputs: [ - { name: 'columns', type: 'array', required: true }, - { name: 'items', type: 'array', required: true }, + { name: 'columns', type: 'array', of: 'object', required: true }, + { name: 'items', type: 'array', of: 'object', required: true }, { name: 'layout', type: 'enum', diff --git a/packages/components/src/renderers/basic/record-picker.tsx b/packages/components/src/renderers/basic/record-picker.tsx index ca16cc4bff..c44925d5a0 100644 --- a/packages/components/src/renderers/basic/record-picker.tsx +++ b/packages/components/src/renderers/basic/record-picker.tsx @@ -417,6 +417,13 @@ ComponentRegistry.register('record_picker', elementDataSourceBlock(ElementRecord // does NOT, which is worth saying in the description because it is the // form an author is most likely to reach for. type: 'array', + // The MEMBER kind, machine-readable rather than only described + // (objectui#8067). `z.array(z.object({ … }))` accepts exactly one coarse + // kind at its member position — an object — so the fact the paragraph + // below spends a sentence on ("the terse string form is not accepted") is + // now a claim the repo-wide parity gate compares against the contract, + // `validateTree` reports on, and `sdui-intrinsics.d.ts` types. + of: 'object', description: 'Row order, as an array of `{ field, order }` entries — `[{ field: "name", order: "asc" }]`. It becomes the `$orderby` of the picker\'s own query, so it decides the order records are offered in. `order` is `asc` or `desc`; the terse string form (`"name asc"`) is not accepted by the contract. PRECEDENCE: identical to `filter` above and for the same reason — the renderer reads `dataSource.sort ?? sort`, so a node-level `dataSource` binding (or the saved view its `view` names) REPLACES this key outright rather than merging with it; it applies only when the node carries no `dataSource`, or that `dataSource` and its view both leave `sort` unset.', }, diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index 246c8e65df..7b70bd7087 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -785,7 +785,7 @@ ComponentRegistry.register('tabs', PageTabsRenderer, { // `unknown-prop` on an author who wrote it anyway, and the renderer honoured // it regardless. Same defect as `record:details.hideFields` in objectui#3808. inputs: [ - { name: 'items', type: 'array', required: true, description: 'Tab definitions [{ label, value?, icon?, count?, visibleWhen?, children }] — value is the stable ?tab= URL token, count auto-derives from record:related_list descendants when omitted' }, + { name: 'items', type: 'array', of: 'object', required: true, description: 'Tab definitions [{ label, value?, icon?, count?, visibleWhen?, children }] — value is the stable ?tab= URL token, count auto-derives from record:related_list descendants when omitted' }, { name: 'tabStyle', type: 'enum', enum: ['line', 'card', 'pill'] }, { name: 'position', type: 'enum', enum: ['top', 'left'] }, { name: 'alwaysShowStrip', type: 'boolean', description: 'Keep the tab strip visible when only one tab survives. Default false: a lone pill is clutter rather than an affordance, so a one-tab strip is hidden and its panel renders bare. Count the tabs AFTER each item visibleWhen predicate has been evaluated — a page authored with four tabs of which three are conditional reaches this rule whenever the other three are false.' }, @@ -962,7 +962,7 @@ ComponentRegistry.register('accordion', PageAccordionRenderer, { category: 'layout', isContainer: true, inputs: [ - { name: 'items', type: 'array', required: true, description: 'Panel definitions [{ label, icon?, collapsed?, children }] — collapsed: false opens a panel by default' }, + { name: 'items', type: 'array', of: 'object', required: true, description: 'Panel definitions [{ label, icon?, collapsed?, children }] — collapsed: false opens a panel by default' }, { name: 'allowMultiple', type: 'boolean' }, { name: 'variant', type: 'enum', enum: ['flush', 'card'] }, ], @@ -2029,7 +2029,7 @@ ComponentRegistry.register('header', PageHeaderRenderer, { // map form this description tells the author to write. { name: 'title', type: ['string', 'object'], description: 'Supports {field} interpolation and inline translation maps; falls back to the record title' }, { name: 'subtitle', type: ['string', 'object'], description: 'Same interpolation as Title' }, - { name: 'actions', type: 'array', description: "Action IDS — the names of actions declared on the object's own metadata — rendered in the header before any host-injected system actions. An id whose action declares neither record_header nor record_more in its locations renders nowhere." }, + { name: 'actions', type: 'array', of: 'string', description: "Action IDS — the names of actions declared on the object's own metadata — rendered in the header before any host-injected system actions. An id whose action declares neither record_header nor record_more in its locations renders nowhere." }, { name: 'breadcrumb', type: 'boolean' }, { name: 'recordChrome', type: 'boolean', description: 'Set false for the bare h1 header on non-record pages' }, { name: 'showStar', type: 'boolean' }, diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index d681fd7236..1f7afd8d69 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -395,9 +395,11 @@ ComponentRegistry.register('details', RecordDetailsRenderer, { // Designer inputs mirror @objectstack/spec RecordDetailsProps (component.zod). // // `sections` publishes its ENTRY shape in prose, derived from the spec's own - // `.describe()` on each member key — `ComponentInput` is flat by design and - // has no slot for a member shape, so an array-of-objects input can only - // document its elements here (same as `record:highlights.fields`, + // `.describe()` on each member key. `ComponentInput.of` now carries the + // member KIND (`of: 'object'` below, objectui#8067) and the repo-wide parity + // gate compares it against the contract, so "these are objects" is no longer + // prose-only — but `of` stops at the kind and names no member KEYS, so the + // entry's own fields stay described here (same as `record:highlights.fields`, // `record:path.stages`, `record:alert.action`). It says "object, not string" // out loud because the string spelling is exactly what this text used to // teach: until 17.x the spec declared `sections: z.array(z.string())` and @@ -446,8 +448,8 @@ ComponentRegistry.register('details', RecordDetailsRenderer, { // above already said `rejects`.) inputs: [ { name: 'columns', type: 'enum', enum: ['1', '2', '3', '4'], description: 'Number of columns for field layout (1-4)' }, - { name: 'sections', type: 'array', description: 'Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` (required) are the field names shown in this section, in order. `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.._sections..label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section\'s field-grid width; omit it and the renderer derives the width. Authoring `sections` at all makes it the only source of the detail body; omit it and the body falls back to the object\'s highlightFields.' }, - { name: 'fields', type: 'array', description: 'Explicit field list (overrides highlightFields)' }, + { name: 'sections', type: 'array', of: 'object', description: 'Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` (required) are the field names shown in this section, in order. `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.._sections..label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section\'s field-grid width; omit it and the renderer derives the width. Authoring `sections` at all makes it the only source of the detail body; omit it and the body falls back to the object\'s highlightFields.' }, + { name: 'fields', type: 'array', of: 'string', description: 'Explicit field list (overrides highlightFields)' }, // `hideFields` is DECLARED, not merely honoured (objectui#3808). The spec // declares it (objectstack#5611) and `RecordDetailsRenderer` has read it // since the highlight-dedup phase (`renderers/record-details.tsx:147`), but @@ -466,7 +468,7 @@ ComponentRegistry.register('details', RecordDetailsRenderer, { // The "hiding every field drops the section" sentence is read off // `DetailSection.tsx:439` (`visibleFields.length === 0 && // emptyCount === section.fields.length` returns null), not assumed. - { name: 'hideFields', type: 'array', description: 'Field names to omit from the body — applied to the top-level `fields` list AND to every section\'s `fields`. Bare field names only. Authors rarely need it: the synth pipeline fills it with the fields already shown in `record:highlights`, and hand-authored pages get the same dedup live from HighlightFieldsContext, so its purpose is suppressing a field you do not want repeated (the page H1 title field is dropped for you too). Hiding every field of a section leaves that section out entirely.' }, + { name: 'hideFields', type: 'array', of: 'string', description: 'Field names to omit from the body — applied to the top-level `fields` list AND to every section\'s `fields`. Bare field names only. Authors rarely need it: the synth pipeline fills it with the fields already shown in `record:highlights`, and hand-authored pages get the same dedup live from HighlightFieldsContext, so its purpose is suppressing a field you do not want repeated (the page H1 title field is dropped for you too). Hiding every field of a section leaves that section out entirely.' }, // `inlineEdit` and `showHeader` are DECLARED, not merely honoured // (objectui#4668) — the same reverse-direction defect `hideFields` above // records, on the two keys @objectstack/spec 17.0.0 GA added to this block. @@ -516,7 +518,7 @@ ComponentRegistry.register('related_list', RecordRelatedListRenderer, { { name: 'objectName', type: 'string', required: true, description: 'Related object name (e.g. "task")' }, { name: 'relationshipField', type: 'string', required: true, description: 'Field on the related object pointing back to this record' }, { name: 'relationshipValueField', type: 'string', description: 'Which field OF THIS PARENT record `relationshipField` stores. Defaults to "id"; set it to the field a name-keyed junction points at (e.g. "name" when sys_user_position.position holds sys_position.name). The resolved value drives three things at once — the list filter, the Add-picker link value, and the pre-filled create form — so they cannot drift apart. While the parent record is still loading, a non-"id" field resolves to null and the list holds its fetch rather than querying on an empty value.' }, - { name: 'columns', type: 'array', required: true, description: 'Fields to display in the related list' }, + { name: 'columns', type: 'array', of: 'string', required: true, description: 'Fields to display in the related list' }, { name: 'sort', type: 'array' }, { name: 'limit', type: 'number', description: 'Records to display initially' }, // `type: 'array'` matches the spec (`RecordRelatedListProps.filter` is @@ -527,15 +529,17 @@ ComponentRegistry.register('related_list', RecordRelatedListRenderer, { // which is the part a wrong guess makes dangerous rather than merely broken: // an author who reads "filter" as "the list's whole filter" would expect it // to be able to widen past the parent record, and it cannot (objectstack#7118). - { name: 'filter', type: 'array', description: 'Additional filter criteria, as spec `ViewFilterRule` entries (`[{ field, operator, value }]`). AND-combined with the parent relationship condition, never a replacement for it: it can only narrow this record\'s children. Also the key a per-element `dataSource` binding\'s composed filter lands on.' }, + { name: 'filter', type: 'array', of: 'object', description: 'Additional filter criteria, as spec `ViewFilterRule` entries (`[{ field, operator, value }]`). AND-combined with the parent relationship condition, never a replacement for it: it can only narrow this record\'s children. Also the key a per-element `dataSource` binding\'s composed filter lands on.' }, { name: 'title', type: 'string' }, { name: 'showViewAll', type: 'boolean' }, - { name: 'actions', type: 'array', description: 'Action IDs available for related records' }, + { name: 'actions', type: 'array', of: 'string', description: 'Action IDs available for related records' }, // `add` publishes its MEMBER shape in prose for the reason the sibling // array-of-objects inputs do (`record:details.sections`, - // `record:highlights.fields`, `record:path.stages`): `ComponentInput` is flat - // by design and has no slot for a member shape, so an `object` input can - // only document its members here. + // `record:highlights.fields`, `record:path.stages`): `ComponentInput.of` + // carries a member KIND and nothing finer (objectui#8067), and this key has + // no uniform member kind to carry — its contract is a named-shape + // `z.object({ … })`, not a map — so its members can only be documented + // here. // // Documented members are exactly the spec's — `picker.object`, // `picker.valueField`, `picker.labelField`, `linkField`, `label` — with each @@ -584,10 +588,14 @@ ComponentRegistry.register('highlights', RecordHighlightsRenderer, { // hand-editable and their page refused by the contract wherever it is // parsed. (This said "strips the unknown key on parse without error" until // objectui#7127: the pre-#4001-batch-A behaviour, the same stale claim the - // `record:details` block above carried.) `ComponentInput` is flat by design - // (`name` = "must match schema property"), so an array-of-objects input - // publishes its member keys in prose, the same way `record:path.stages` and - // `record:alert.action` do. objectui#3407 / objectstack#5176. + // `record:details` block above carried.) `ComponentInput.of` carries a member + // KIND and nothing finer (objectui#8067), so an array input publishes its + // member KEYS in prose, the same way `record:path.stages` and + // `record:alert.action` do — and this key does not declare `of` at all, + // because its member contract accepts a bare field NAME or an inline field + // object and picking one arm of that union is the narrowing this repo leaves + // un-gated (pinned as `MULTI_KIND_MEMBER_CONTRACTS` in the repo-wide parity + // gate). objectui#3407 / objectstack#5176. inputs: [ { name: 'fields', type: 'array', required: true, description: 'Key fields to highlight (1-7), bare names or {name,label?,icon?,type?,readonly?}. Set readonly: true on an entry to render that chip read-only — it suppresses the inline-edit affordance and the HeaderHighlight editability gate enforces it. Use it for hook/automation-maintained columns that must not be hand-edited from the record header; marking the OBJECT field readonly instead would also strip the hook\'s own write-back.' }, { name: 'layout', type: 'enum', enum: ['horizontal', 'vertical'], description: 'Layout orientation for highlight fields' }, @@ -621,7 +629,7 @@ ComponentRegistry.register('activity', RecordActivityRenderer, { // DiscussionContext (record detail pages do); `showSubscriptionToggle` is a // declared-but-inert GAP and says so here rather than looking configurable. inputs: [ - { name: 'types', type: 'array', description: 'Allow-list of feed item types to show (comment, field_change, task, event, email, call, note, file, record_create, record_delete, approval, sharing, system). Omit for all; unrecognised entries are ignored.' }, + { name: 'types', type: 'array', of: 'string', description: 'Allow-list of feed item types to show (comment, field_change, task, event, email, call, note, file, record_create, record_delete, approval, sharing, system). Omit for all; unrecognised entries are ignored.' }, { name: 'filterMode', type: 'enum', @@ -695,7 +703,7 @@ ComponentRegistry.register('path', RecordPathRenderer, { // Mirrors @objectstack/spec RecordPathProps. inputs: [ { name: 'statusField', type: 'string', required: true, description: 'Field representing the current status/stage' }, - { name: 'stages', type: 'array', description: 'Explicit stage definitions [{ value, label }] (else derived from field metadata)' }, + { name: 'stages', type: 'array', of: 'object', description: 'Explicit stage definitions [{ value, label }] (else derived from field metadata)' }, ], }); @@ -716,8 +724,8 @@ ComponentRegistry.register('quick_actions', RecordQuickActionsRenderer, { // taught to authors and to tooling — objectstack#8744 quoted it verbatim. // Implementing the fallback would be a behaviour expansion and needs its own // card; pinned by `recordQuickActionsInputs.actionNamesFallback.test.tsx`. - { name: 'actionNames', type: 'array', description: 'Action names to expose, in order — resolved from the actions declared on the object. With no names (and no host-supplied actions) nothing is looked up and the bar renders its empty placeholder' }, - { name: 'requiredPermissions', type: 'array', description: 'Hide the whole bar unless the user holds these permissions' }, + { name: 'actionNames', type: 'array', of: 'string', description: 'Action names to expose, in order — resolved from the actions declared on the object. With no names (and no host-supplied actions) nothing is looked up and the bar renders its empty placeholder' }, + { name: 'requiredPermissions', type: 'array', of: 'string', description: 'Hide the whole bar unless the user holds these permissions' }, // Derived from the spec's own vocabulary rather than restated — #3019. { name: 'location', type: 'enum', enum: [...ACTION_LOCATIONS], description: 'Which declared action location this bar renders' }, { name: 'align', type: 'enum', enum: ['start', 'center', 'end'] }, diff --git a/packages/plugin-grid/src/index.tsx b/packages/plugin-grid/src/index.tsx index 05f00069fa..4cd633a482 100644 --- a/packages/plugin-grid/src/index.tsx +++ b/packages/plugin-grid/src/index.tsx @@ -221,7 +221,7 @@ const GRID_QUERY_INPUTS: ComponentInput[] = [ // ── query shaping ───────────────────────────────────────────────────────── { name: 'sort', type: 'array', description: 'Initial sort order, `[{ field, order }]`. The canonical spelling — the deprecated single-sort `defaultSort` is only read when this is absent.' }, { name: 'pagination', type: 'object', description: 'Pagination config, `{ pageSize, pageSizeOptions, … }`. Its presence is what enables paging; prefer it over the deprecated flat `pageSize` / `showPagination` pair.' }, - { name: 'searchableFields', type: 'array', description: 'Fields the toolbar search box queries. A non-empty list is what enables search — prefer it over the deprecated boolean `showSearch`, which cannot say WHICH fields to search.' }, + { name: 'searchableFields', type: 'array', of: 'string', description: 'Fields the toolbar search box queries. A non-empty list is what enables search — prefer it over the deprecated boolean `showSearch`, which cannot say WHICH fields to search.' }, { name: 'data', type: 'object', description: 'Data source configuration — a `ViewData` object discriminated by `provider`: `{ provider: "object", object }` (what an omitted `data` falls back to, using `objectName`), `{ provider: "api", read, write }`, `{ provider: "value", items: [...] }` for inline rows that bypass the object query, or `{ provider: "schema", schemaId }`. The canonical spelling — the deprecated `staticData` is the array-only shortcut for the `value` provider, so inline rows go under `items` here rather than in a bare array.' }, // ── presentation ────────────────────────────────────────────────────────── { name: 'rowHeight', type: 'enum', enum: ['compact', 'short', 'medium', 'tall', 'extra_tall'], description: 'Row density. An unrecognised value falls back to `compact` rather than erroring.' }, diff --git a/packages/sdui-parser/src/__tests__/member-kind-8067.test.ts b/packages/sdui-parser/src/__tests__/member-kind-8067.test.ts new file mode 100644 index 0000000000..6471d36923 --- /dev/null +++ b/packages/sdui-parser/src/__tests__/member-kind-8067.test.ts @@ -0,0 +1,203 @@ +/** + * ObjectUI — `ManifestInput.of`, the coarse MEMBER kind (objectui#8067) + * + * The mechanism half of the card. The contract half — every declared `of` in + * the repository compared against what `ComponentPropsMap[type]` accepts at the + * member position — lives in + * `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`. + * + * WHY THE KEY EXISTS. `type: 'array'` said a value was a list and stopped + * there, so a member that drifted from the contract was invisible to every + * layer that reads a declaration. `page:header.actions` is the measured cost: + * spec `z.array(z.string())` ("Action IDs"), a renderer reading the members as + * `ActionDef` OBJECTS, and the repo-wide parity gate green for the whole life of + * the drift because both sides had the key and neither could say what was + * inside it. + * + * WHAT IS PINNED HERE, and they are different facts: + * + * 1. THE READER EXISTS — a member no declared arm accepts is REPORTED. This is + * the fact objectui#5905 is the cautionary precedent for: five + * `ComponentInput` keys were declared and read by nothing, and every gate + * stayed green. A `of` that nothing reads is that defect with a new name. + * 2. BACKWARD COMPATIBILITY — an input that declares no `of` is validated, + * serialized and typed byte-identically to before the key existed. That is + * what makes this an extension of `sdui.manifest.json` rather than a new + * version of it. + * 3. THE COARSE CEILING HOLDS ONE LEVEL DOWN — `of` names a KIND, never a + * domain and never a member's KEYS, so `of: 'object'` clears every object + * whatever it contains. The maintainer ruling of 2026-08-17 quoted on + * `ComponentInput.type` ("SPEC IS THE SOLE JUDGE OF VALUES") is untouched. + * 4. THE OTHER CONSUMERS — the serializer canonicalizes `of` exactly as it + * canonicalizes `type`, and the JSX codegen narrows the emitted element + * type from it. A member kind the validator honours but the `.d.ts` + * contradicts would just move an author's false error one layer over. + */ +import { describe, expect, it } from 'vitest'; +import { generateDts, manifestFromConfigs, validateTree } from '../index.js'; +import type { Manifest, SchemaElement } from '../types.js'; + +const one = (inputs: Parameters[0][number]['inputs']): Manifest => + manifestFromConfigs([{ type: 'probe', namespace: 'ui', inputs }]); + +const diags = (manifest: Manifest, node: Record) => + validateTree({ type: 'probe', ...node } as SchemaElement, manifest).diagnostics; + +const codes = (manifest: Manifest, node: Record): string[] => + diags(manifest, node).map((d) => d.code); + +describe('a declared member kind is READ — the objectui#5905 standard', () => { + const manifest = one([{ name: 'actions', type: 'array', of: 'string' }]); + + it('clears an array whose members are all of the declared kind', () => { + expect(codes(manifest, { actions: ['clone', 'convert'] })).toEqual([]); + }); + + it('reports a member of the wrong kind — the drift this key exists to catch', () => { + // Verbatim the `page:header.actions` drift: spec says action IDs, the value + // carries `ActionDef` objects. Before `of` this node was clean. + expect(codes(manifest, { actions: [{ name: 'clone' }] })).toEqual(['member-type-mismatch']); + }); + + it('names every offending position in ONE diagnostic, not one per member', () => { + const [diagnostic] = diags(manifest, { actions: ['clone', 42, {}, 'convert'] }); + expect(diagnostic.code).toBe('member-type-mismatch'); + expect(diagnostic.severity).toBe('warning'); + expect(diagnostic.message).toBe( + ' prop "actions" expected every member to be a string — [1], [2] are not', + ); + }); + + it('an empty container conforms — there is no member to refuse', () => { + expect(codes(manifest, { actions: [] })).toEqual([]); + }); + + it('reads the ARRAY form of `of` the same way it reads the array form of `type`', () => { + // The rot this pins: a reader that forgot `Array.isArray` would fall through + // and report NOTHING, which looks exactly like a clean value. + const union = one([{ name: 'items', type: 'array', of: ['string', 'object'] }]); + expect(codes(union, { items: ['a', { b: 1 }] })).toEqual([]); + expect(codes(union, { items: [42] })).toEqual(['member-type-mismatch']); + }); + + it('judges an OBJECT container by its VALUES — the map half of the key', () => { + const map = one([{ name: 'labels', type: 'object', of: 'string' }]); + expect(codes(map, { labels: { en: 'Account', 'zh-CN': '客户' } })).toEqual([]); + const [diagnostic] = diags(map, { labels: { en: 'Account', count: 3 } }); + expect(diagnostic.message).toBe( + ' prop "labels" expected every member to be a string — [count] is not', + ); + }); +}); + +describe('the container verdict comes first', () => { + const manifest = one([{ name: 'actions', type: 'array', of: 'string' }]); + + it('a wrong CONTAINER draws one diagnostic, not two', () => { + // One mistake, one report. A member walk over a value that is not even the + // declared container would name positions of a shape the author never wrote. + expect(codes(manifest, { actions: 'clone' })).toEqual(['type-mismatch']); + }); + + it('a value that satisfied a NON-container arm of a union is not member-judged', () => { + const union = one([{ name: 'actions', type: ['string', 'array'], of: 'string' }]); + expect(codes(union, { actions: 'clone' })).toEqual([]); + expect(codes(union, { actions: [42] })).toEqual(['member-type-mismatch']); + }); +}); + +describe('the coarse ceiling holds one level down', () => { + it('`of: \'object\'` clears every object, whatever keys it carries', () => { + // `of` is a KIND, never a member's KEYS. Which keys an element must have is + // spec's question and a per-block pin's, exactly as the value DOMAIN of a + // `number` arm is (`ComponentInput.type`, maintainer ruling 2026-08-17). + const manifest = one([{ name: 'sections', type: 'array', of: 'object' }]); + expect(codes(manifest, { sections: [{ anything: 'at all' }, {}] })).toEqual([]); + expect(codes(manifest, { sections: ['sales_info'] })).toEqual(['member-type-mismatch']); + }); + + it('an `enum` member arm raises its severity to error, as it does one level up', () => { + const manifest = one([ + { name: 'types', type: 'array', of: 'enum', enum: ['comment', 'task'] }, + ]); + expect(codes(manifest, { types: ['comment', 'task'] })).toEqual([]); + const [diagnostic] = diags(manifest, { types: ['comment', 'email'] }); + expect(diagnostic.code).toBe('member-type-mismatch'); + expect(diagnostic.severity).toBe('error'); + }); +}); + +describe('an input that declares no member kind is unchanged', () => { + // The backward-compatibility half. Every input published before this key + // existed says exactly this, so anything that moves here moves for all of + // them. + const manifest = one([{ name: 'actions', type: 'array' }]); + + it('draws no member diagnostic on any member', () => { + expect(codes(manifest, { actions: [42, {}, 'clone', null] })).toEqual([]); + }); + + it('publishes no `of` at all — the serialized entry is byte-identical', () => { + expect(JSON.stringify(manifest.components.probe.inputs[0])).toBe( + '{"name":"actions","type":"array"}', + ); + }); + + it('emits the unnarrowed element type', () => { + expect(generateDts(manifest)).toContain('actions?: unknown[];'); + }); +}); + +describe('the serializer canonicalizes `of` exactly as it canonicalizes `type`', () => { + it('collapses a one-element array to the bare kind', () => { + expect(one([{ name: 'a', type: 'array', of: ['string'] }]).components.probe.inputs[0].of).toBe( + 'string', + ); + }); + + it('drops an off-vocabulary arm rather than inventing `string` for it', () => { + expect( + one([{ name: 'a', type: 'array', of: ['string', 'nonsense'] }]).components.probe.inputs[0].of, + ).toBe('string'); + }); + + it('dedupes a repeated arm', () => { + expect( + one([{ name: 'a', type: 'array', of: ['string', 'object', 'string'] }]).components.probe + .inputs[0].of, + ).toEqual(['string', 'object']); + }); + + it('does NOT invent a member kind for an undeclared `of`', () => { + // `canonicalizeInputType`'s no-arms fallback is `'string'`, so routing an + // undefined through it would make every array in every manifest claim + // string members it was never told it had. + expect(one([{ name: 'a', type: 'array' }]).components.probe.inputs[0].of).toBeUndefined(); + }); +}); + +describe('the JSX authoring surface narrows with it', () => { + it('types the elements rather than emitting `unknown[]`', () => { + const dts = generateDts(one([{ name: 'actions', type: 'array', of: 'string' }])); + expect(dts).toContain('actions?: string[];'); + expect(dts).not.toContain('actions?: unknown[];'); + }); + + it('types an object map\'s values', () => { + expect(generateDts(one([{ name: 'labels', type: 'object', of: 'string' }]))).toContain( + 'labels?: Record;', + ); + }); + + it('emits a parenthesised union for a multi-arm member declaration', () => { + expect(generateDts(one([{ name: 'items', type: 'array', of: ['string', 'object'] }]))).toContain( + 'items?: (string | Record)[];', + ); + }); + + it('a `slot`-only member declaration types no member — never a silent `string`', () => { + expect(generateDts(one([{ name: 'items', type: 'array', of: 'slot' }]))).toContain( + 'items?: unknown[];', + ); + }); +}); diff --git a/packages/sdui-parser/src/codegen.ts b/packages/sdui-parser/src/codegen.ts index 19bf92694b..d8f7df0ea8 100644 --- a/packages/sdui-parser/src/codegen.ts +++ b/packages/sdui-parser/src/codegen.ts @@ -102,9 +102,9 @@ function armTsType(arm: ManifestInputType, input: ManifestInput): string { case 'boolean': return 'boolean'; case 'array': - return 'unknown[]'; + return `${memberTsType(input)}[]`; case 'object': - return 'Record'; + return `Record`; case 'enum': { const vals = (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e)); return vals.length ? vals.map((v) => JSON.stringify(v)).join(' | ') : 'string'; @@ -114,6 +114,29 @@ function armTsType(arm: ManifestInputType, input: ManifestInput): string { } } +/** + * The TypeScript type of a container's MEMBERS, from `ManifestInput.of` + * (objectui#8067). + * + * `unknown` when nothing is declared, which is what every input published + * before `of` existed says — so an undeclared array still emits `unknown[]` and + * an undeclared object still emits `Record`, byte for byte. + * A declared member kind narrows it: `of: 'string'` on `page:header.actions` + * turns `unknown[]` into `string[]`, and the JSX surface finally types the + * action IDs the contract has always required. + * + * `'slot'` is dropped for the same reason {@link valueArms} drops it one level + * up: it names a child position, not a value, so it contributes no member type. + * If that leaves nothing, the result is `unknown` — a declaration that types no + * member must not silently type them all as strings. + */ +function memberTsType(input: ManifestInput): string { + const arms = inputTypeArms(input.of).filter((arm) => arm !== 'slot'); + if (arms.length === 0) return 'unknown'; + const emitted = [...new Set(arms.map((arm) => armTsType(arm, { ...input, of: undefined })))]; + return emitted.length === 1 ? emitted[0] : `(${emitted.join(' | ')})`; +} + /** * A union declaration emits a TypeScript union, so the `.d.ts` an author * type-checks their page against accepts exactly the arms the manifest gate diff --git a/packages/sdui-parser/src/index.ts b/packages/sdui-parser/src/index.ts index 24013eae95..4cf5b5c371 100644 --- a/packages/sdui-parser/src/index.ts +++ b/packages/sdui-parser/src/index.ts @@ -77,6 +77,14 @@ export interface RegistryConfigLike { * `canonicalizeInputType` on the way in. */ type: string | string[]; + /** + * The declared member kind(s) — array elements, or an object map's values + * (objectui#8067). Typed as loosely as `type` above and for the same + * reason: this interface is the STRUCTURAL boundary that keeps the package + * free of a registry dependency, so an off-vocabulary value has to be + * representable here and is normalized on the way in. + */ + of?: string | string[]; required?: boolean; enum?: Array; binding?: 'object' | 'field'; @@ -153,6 +161,14 @@ export function manifestFromConfigs( inputs: (c.inputs ?? []).map((i) => ({ name: i.name, type: canonicalizeInputType(i.type), + // Undefined stays undefined rather than going through + // `canonicalizeInputType`, whose no-arms fallback is `'string'`: an + // input that declares no member kind must publish NO `of`, or every + // array in every manifest would suddenly claim string members it was + // never told it had. `JSON.stringify` drops the undefined key, so the + // published artifact is byte-identical for every input that does not + // declare one (objectui#8067). + of: i.of === undefined ? undefined : canonicalizeInputType(i.of), required: i.required, enum: i.enum, binding: i.binding, diff --git a/packages/sdui-parser/src/types.ts b/packages/sdui-parser/src/types.ts index 78c9bac5d4..c9a41a536e 100644 --- a/packages/sdui-parser/src/types.ts +++ b/packages/sdui-parser/src/types.ts @@ -77,6 +77,22 @@ export interface ManifestInput { * declared and every already-published entry serializes byte-identically. */ type: ManifestInputType | ManifestInputType[]; + /** + * The coarse kind of the input's MEMBERS — array elements, or the values of + * an object used as a map — as ONE kind or an ARRAY of kinds for a member + * contract that is a union (objectui#8067). + * + * Absent means "not declared", which is what every input published before + * this key existed says: {@link validateTree} checks no member and the + * codegen emits the unnarrowed element type, exactly as before. So a + * manifest gains this key only where a member kind was really declared, and + * every already-published entry serializes byte-identically. + * + * Read the arms through `inputTypeArms(input.of)` — the same accessor + * `type`'s arms go through, since the two fields carry the same shape and a + * reader that forgets the array form is silently inert on it. + */ + of?: ManifestInputType | ManifestInputType[]; required?: boolean; /** allowed values for `enum` inputs */ enum?: Array; diff --git a/packages/sdui-parser/src/validate.ts b/packages/sdui-parser/src/validate.ts index 1efa6d2847..c507be8b68 100644 --- a/packages/sdui-parser/src/validate.ts +++ b/packages/sdui-parser/src/validate.ts @@ -122,7 +122,16 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Ma }); } else { const typeDiag = checkType(node.type, input, value); - if (typeDiag) diagnostics.push(typeDiag); + if (typeDiag) { + diagnostics.push(typeDiag); + } else { + // Members only once the CONTAINER kind was accepted. Reporting a + // member of a value that is not even the declared container is two + // diagnostics for one mistake, and the second one names positions + // of a shape the author did not write (objectui#8067). + const memberDiag = checkMemberTypes(node.type, input, value); + if (memberDiag) diagnostics.push(memberDiag); + } } } @@ -205,6 +214,62 @@ function armExpectation(arm: ManifestInputType, input: ManifestInput): string { } } +/** + * The member positions of a container value, as `[position, member]` pairs, or + * `null` when the value has no member position to speak of. + * + * Arrays index by position and objects by key, which is exactly the pair + * `ComponentInput.of` describes: array ELEMENTS, and the VALUES of an object + * used as a map. A scalar returns `null` rather than an empty list, so a value + * that only satisfied a non-container arm of a union declaration + * (`type: ['string', 'array'], of: 'string'`) is not reported as an empty + * container that trivially conforms — it is simply not the arm `of` describes. + */ +function memberEntries(value: unknown): Array<[string, unknown]> | null { + if (Array.isArray(value)) return value.map((member, index) => [String(index), member]); + if (typeof value === 'object' && value !== null) return Object.entries(value); + return null; +} + +/** + * Coarse MEMBER check, over the arms `of` declares (objectui#8067). + * + * The same question `checkType` asks, one level down and with the same answer + * shape: ANY declared arm accepting a member clears it, a member no arm accepts + * is reported, and an input that declares no `of` is checked exactly as it was + * before the key existed — this function returns immediately on an empty arm + * list, so nothing published today changes severity or gains a diagnostic. + * + * ONE diagnostic per prop, naming every offending position, rather than one per + * member: a page that passes an array of the wrong member kind is one mistake + * made once, and N copies of it is the noise this repo treats as the thing that + * trains authors to dismiss real reports. + * + * Severity mirrors `checkType`'s rule for the same reason — `error` when an + * `enum` arm is present, because a closed list is the one fact this layer can + * be certain about; `warning` otherwise, since the coarse kind is a KIND claim + * and `os validate` / `os build` remain the judge of values. + */ +function checkMemberTypes(tag: string, input: ManifestInput, value: unknown): Diagnostic | null { + const arms = inputTypeArms(input.of); + if (arms.length === 0) return null; + const entries = memberEntries(value); + if (entries === null) return null; + const offenders = entries.filter( + ([, member]) => !arms.some((arm) => armAccepts(arm, input, member)), + ); + if (offenders.length === 0) return null; + const expectation = arms.map((arm) => armExpectation(arm, input)).join(' or '); + return { + severity: arms.includes('enum') ? 'error' : 'warning', + code: 'member-type-mismatch', + message: `<${tag}> prop "${input.name}" expected every member to be ${expectation}` + + ` — ${offenders.map(([position]) => `[${position}]`).join(', ')} ` + + `${offenders.length === 1 ? 'is' : 'are'} not`, + tag, + }; +} + /** * Coarse type check, over the arms an input declares (objectui#3832). * diff --git a/packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts b/packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts index 6bc0e3357a..6a8c327d49 100644 --- a/packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts +++ b/packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts @@ -215,7 +215,7 @@ describe('the zod tombstones REFUSE, loudly (objectui#5905)', () => { if (!result.success) { expect(result.error.issues[0]?.message).toBe( 'RETIRED (objectui#5905) — `ComponentInput.placeholder` was never read, and never published: ' - + 'the manifest serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and ' + + 'the manifest serializer forwards `name`/`type`/`of`/`required`/`enum`/`binding`/`description` and ' + 'this is not one of them, so an authored value was silently dropped. Delete the key; put the ' + 'hint in `description`, which IS published. `BaseSchema.placeholder`, the node-level prop, is ' + 'a DIFFERENT key and is unaffected.', diff --git a/packages/types/src/__tests__/component-input-retired-keys-7493.test.ts b/packages/types/src/__tests__/component-input-retired-keys-7493.test.ts index 98acbc6fc7..7bd071bc48 100644 --- a/packages/types/src/__tests__/component-input-retired-keys-7493.test.ts +++ b/packages/types/src/__tests__/component-input-retired-keys-7493.test.ts @@ -16,9 +16,11 @@ * * The three are the keys the manifest serializer does not forward. Every * non-test consumer of `ComponentMeta.inputs` was enumerated and none reads - * any of them: `sdui-parser`'s serializer forwards exactly six keys per input - * (`name`, `type`, `required`, `enum`, `binding`, `description`) and its - * boundary type has no slot for these; the registry's data-source seam reads + * any of them: `sdui-parser`'s serializer forwarded exactly six keys per input + * at the time of the retirement (`name`, `type`, `required`, `enum`, `binding`, + * `description`) and its boundary type had no slot for these — objectui#8067 + * has since added a SEVENTH, `of`, which is why the pins below read the + * serializer's CURRENT list against a named expectation rather than a count; the registry's data-source seam reads * `name` only; neither the designer nor the app-shell inspectors consult * registry `inputs` at all. The one non-test touch was a WRITE — the * `WidgetRegistry` seam copying widget-manifest values across — and it fed @@ -204,7 +206,7 @@ describe('the zod tombstones REFUSE, loudly and by name (objectui#7493)', () => if (!result.success) { expect(result.error.issues[0]?.message).toBe( 'RETIRED (objectui#7493) — `ComponentInput.defaultValue` was never read, and never published: the manifest ' - + 'serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + + 'serializer forwards `name`/`type`/`of`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + 'so an authored value was silently dropped. Delete the key; the renderer\'s own fallback read is the ' + 'default, and `description`, which IS published, is where to state it.', ); @@ -265,30 +267,42 @@ describe('the `ComponentInput` census after the retirement', () => { expect(tombstones).toEqual([...ALL_TOMBSTONES].sort()); }); - it('declares exactly the five forwarded keys as writable on the TypeScript face', () => { + it('declares exactly the six live keys as writable on the TypeScript face', () => { + // `of` is the sixth, added by objectui#8067 — the member KIND of an + // `array`/`object` input. It is here rather than among the tombstones for + // the one reason this pin exists to enforce: it SHIPPED WITH READERS. The + // repo-wide parity gate compares every declared `of` against the contract's + // member position, `validateTree` reports a member no declared arm accepts, + // and the codegen types the generated `.d.ts` surface from it. A key added + // here with no reader is the objectui#5905 defect this whole file records. const writable = [...block.matchAll(/^\s{2}(\w+)\??: (?!never;)/gm)].map((m) => m[1]).sort(); - expect(writable).toEqual(['description', 'enum', 'name', 'required', 'type']); + expect(writable).toEqual(['description', 'enum', 'name', 'of', 'required', 'type']); }); - it('the mirror agrees member for member: eight RETIRED describes, five live keys', () => { + it('the mirror agrees member for member: eight RETIRED describes, six live keys', () => { const shape = shapeOf(ComponentInputSchema); const retired = Object.keys(shape).filter((k) => describeOf(ComponentInputSchema, k)?.startsWith('RETIRED (')).sort(); const live = Object.keys(shape).filter((k) => !describeOf(ComponentInputSchema, k)?.startsWith('RETIRED (')).sort(); expect(retired).toEqual([...ALL_TOMBSTONES].sort()); - expect(live).toEqual(['description', 'enum', 'name', 'required', 'type']); + expect(live).toEqual(['description', 'enum', 'name', 'of', 'required', 'type']); }); }); -/* ── the serializer still forwards exactly six ───────────────────────────── */ +/* ── the serializer forwards the live keys, and none of the retired ──────── */ -describe('the publication path is unchanged: `manifestFromConfigs` forwards exactly six keys', () => { - it('reads the serializer source and finds the six, and none of the three', () => { +describe('the publication path: `manifestFromConfigs` forwards every live key', () => { + it('reads the serializer source and finds the seven, and none of the three', () => { const src = readFileSync(resolve(ROOT, 'packages/sdui-parser/src/index.ts'), 'utf8'); const fn = src.slice(src.indexOf('export function manifestFromConfigs(')); const forwarded = /inputs: \(c\.inputs \?\? \[\]\)\.map\(\(i\) => \(\{([\s\S]*?)\}\)\)/.exec(fn)?.[1] ?? ''; expect(forwarded.length, 'the serializer no longer maps inputs where this pin reads it').toBeGreaterThan(0); + // Seven since objectui#8067 added `of`. The load-bearing half of this pin + // is unchanged and is the reason it is an EQUALITY: the three retired keys + // must not reappear here, and a serializer that quietly started forwarding + // one would be caught by the same assertion that lets a genuinely-read new + // key through in a diff someone reviews. const keys = [...forwarded.matchAll(/^\s*(\w+):/gm)].map((m) => m[1]).sort(); - expect(keys).toEqual(['binding', 'description', 'enum', 'name', 'required', 'type']); + expect(keys).toEqual(['binding', 'description', 'enum', 'name', 'of', 'required', 'type']); }); }); diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts index f6a382f979..6f0b8825e0 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -597,6 +597,76 @@ export interface ComponentInput { */ type: ComponentInputControlType | ComponentInputControlType[]; + /** + * The coarse kind(s) of this input's MEMBERS — one level down from `type` + * (objectui#8067). + * + * Meaningful on an input whose `type` declares `array` or `object`, and it + * means the same thing on each: the ELEMENTS of the array, and the VALUES of + * an object used as a MAP. One kind, or an array of them for a member + * contract that is a union, with exactly `type`'s semantics — a member passes + * when ANY declared arm accepts it, and a member matching none is reported. + * + * ## Why the slot exists + * + * `type: 'array'` says a value is a list and stops there, so a member key + * that drifts from the contract is invisible to every layer that reads a + * declaration. `page:header.actions` is the measured cost: `@objectstack/spec` + * declares `z.array(z.string())` (action IDs), the renderer read the members + * as `ActionDef` OBJECTS, and the repo-wide parity gate + * (`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`) stayed + * green for the whole life of the drift because it could only compare + * top-level key names — the `inputs` side had nothing to compare with. What + * finally settled it was a maintainer ruling, and even after the fix the + * fact "these are ids" lived only in `description` PROSE. `of: 'string'` is + * that same fact, in a form a machine reads. + * + * ## What it is NOT — and why the 2026-08-17 ceiling is untouched + * + * `of` is a KIND, exactly like `type`, and it stops one level down. It is NOT + * a nested schema: it names no object keys, no element field types, no + * per-member `required`. It is deliberately not called `items` / `element` / + * `shape`, all of which promise a sub-schema this does not carry. + * + * It therefore adds NO value-domain slot, so the maintainer ruling quoted on + * `type` above — "the coarse arm plus `description` IS the publication face's + * expression ceiling today, and SPEC IS THE SOLE JUDGE OF VALUES" — stands + * unchanged. `of` extends the one axis that ruling already blessed (a coarse + * KIND) to the one position that had no way to state it; it does not reopen + * the constraint slots (`min` / `max` / `step`) that ruling deferred, and it + * is not the `integer`-style narrowing that ruling declined. `of: 'string'` + * says the members are strings; which strings is still spec's question alone. + * + * ## It has readers on day one — the standard this slot had to meet + * + * objectui#5905 is the cautionary precedent: five `ComponentInput` keys were + * declared and read by NOTHING, and the manifest serializer forwarded six + * keys of which it read none. So `of` ships with three readers, all in the + * same change that adds it: + * + * 1. the repo-wide parity gate compares every declared `of` arm against the + * member kind `ComponentPropsMap[type]` actually accepts, and reds on an + * arm the contract refuses — the same one-directional widening question + * the ARM DIRECTION asks of `type` (objectui#4971); + * 2. `sdui-parser`'s `validateTree` reports a member whose kind fits no + * declared arm, at the member's own position; + * 3. `sdui-parser`'s codegen types the generated `sdui-intrinsics.d.ts` + * surface from it — `string[]` rather than `unknown[]`. + * + * DECLARE IT ONLY WHERE THE CONTRACT FORCES ONE ANSWER. Every `of` in this + * repository was derived by probing `ComponentPropsMap`'s member position + * with one value of each coarse kind and taking the result only where exactly + * ONE kind was accepted. A member contract that accepts several kinds (spec's + * `record:highlights.fields` takes a string OR an object) is left undeclared + * on purpose: picking one arm there would be the NARROWING this repo treats + * as noise, and picking all of them would advertise shapes the renderer may + * not resolve — which is per-block discipline, not a repo-wide derivation. + * + * @example { name: 'actions', type: 'array', of: 'string' } + * @example { name: 'sections', type: 'array', of: 'object' } + */ + of?: ComponentInputControlType | ComponentInputControlType[]; + /** * ADR-0049 RETIREMENT TOMBSTONES — `label` / `defaultValue` / `advanced` * (objectui#7493 item ①, objectui#7781; maintainer ruling A of 2026-09-06). @@ -613,9 +683,9 @@ export interface ComponentInput { * What was measured (re-measured on the retiring PR's merge-base, not * inherited from the cards): every non-test consumer of * `ComponentMeta.inputs` was enumerated and none reads any of the three — - * the serializer (`packages/sdui-parser/src/index.ts`) forwards exactly six - * keys per input (`name`, `type`, `required`, `enum`, `binding`, - * `description`), its boundary type has no slot for these, the registry's + * the serializer (`packages/sdui-parser/src/index.ts`) forwards a fixed key + * list per input, `of` included since objectui#8067 (`name`, `type`, `of`, + * `required`, `enum`, `binding`, `description`), its boundary type has no slot for these, the registry's * data-source seam reads `name` only, and neither the designer nor the * app-shell inspectors consult registry `inputs` at all. The one * non-test touch was a WRITE (`WidgetRegistry` copying the widget-manifest @@ -646,7 +716,7 @@ export interface ComponentInput { /** * RETIRED (objectui#7493, ADR-0049) — never read, and never published: the - * manifest serializer forwards six keys and this is not one of them, and no + * manifest serializer forwards a fixed key list and this is not one of them, and no * renderer, registry or designer surface ever read a declared default. Delete * the key; the renderer's own fallback read is the default, and `description` * — which IS published — is where to state it for an author. The 245 values @@ -673,8 +743,8 @@ export interface ComponentInput { /** * RETIRED (objectui#7493 / objectui#7781, ADR-0049) — never read, and never - * published: the manifest serializer forwards six keys and this is not one of - * them, and no designer surface ever hid an "advanced" input (nine + * published: the manifest serializer forwards a fixed key list and this is + * not one of them, and no designer surface ever hid an "advanced" input (nine * registrations wrote it; nothing consumed it). Delete the key; there is * nothing to write instead. * @deprecated Not part of `ComponentInput`'s contract — the value was inert. @@ -683,7 +753,7 @@ export interface ComponentInput { /** * RETIRED (objectui#5905, ADR-0049) — never read, and never published: the - * manifest serializer forwards six keys and this is not one of them. Put the + * manifest serializer forwards a fixed key list and this is not one of them. Put the * control hint in `description`, which IS published. * * The LAST of the five to be retired, and by its own ruling, because its @@ -723,8 +793,9 @@ export interface ComponentInput { * * What was measured (objectui#5905, re-measured on the merge-base of the * retiring PR): no consumer reads any of the four, and the manifest - * serializer (`packages/sdui-parser/src/index.ts`) forwards exactly six keys - * per input — `name`, `type`, `required`, `enum`, `binding`, `description` — + * serializer (`packages/sdui-parser/src/index.ts`) forwards a fixed key list + * per input — `name`, `type`, `of`, `required`, `enum`, `binding`, + * `description`, the last of them added by objectui#8067 — * so a value authored here could not reach the published * `sdui.manifest.json` even in principle. A structural census over every * `inputs:` array in the repository found ZERO authoring sites for the four @@ -745,28 +816,28 @@ export interface ComponentInput { * silence let through) is still the route back. * * RETIRED (objectui#5905, ADR-0049) — never read, and never published: the - * manifest serializer forwards six keys and this is not one of them. Spell + * manifest serializer forwards a fixed key list and this is not one of them. Spell * the numeric domain out in `description`, which IS published. * @deprecated Not part of `ComponentInput`'s contract — the value was inert. */ min?: never; /** * RETIRED (objectui#5905, ADR-0049) — never read, and never published: the - * manifest serializer forwards six keys and this is not one of them. Spell + * manifest serializer forwards a fixed key list and this is not one of them. Spell * the numeric domain out in `description`, which IS published. * @deprecated Not part of `ComponentInput`'s contract — the value was inert. */ max?: never; /** * RETIRED (objectui#5905, ADR-0049) — never read, and never published: the - * manifest serializer forwards six keys and this is not one of them. Spell + * manifest serializer forwards a fixed key list and this is not one of them. Spell * the numeric domain out in `description`, which IS published. * @deprecated Not part of `ComponentInput`'s contract — the value was inert. */ step?: never; /** * RETIRED (objectui#5905, ADR-0049) — never read, and never published: the - * manifest serializer forwards six keys and this is not one of them. Put the + * manifest serializer forwards a fixed key list and this is not one of them. Put the * hint in `description`, which IS published. `BaseSchema.placeholder` — the * node-level prop a renderer does read — is a DIFFERENT key and is * unaffected. diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts index 651b1037df..1bc156ee2e 100644 --- a/packages/types/src/zod/base.zod.ts +++ b/packages/types/src/zod/base.zod.ts @@ -317,6 +317,24 @@ export const ComponentInputSchema = z.object({ message: 'Input control type arms must be distinct', }), ]).describe('Input control type, or the arms of a union type'), + /** + * The coarse kind(s) of the input's MEMBERS — array elements, or the values + * of an object used as a map (objectui#8067). Same shape and same two bounds + * as `type` one level up, for the same reason: an empty array declares a + * member contract nothing can satisfy, and a repeated arm means the author + * believes they said something they did not. Optional — an input that + * declares no member kind is judged exactly as it was before this key + * existed. See `ComponentInput.of` in `../base.ts` for the ruling boundary it + * stays inside (it is a KIND, never a value domain) and for its readers. + */ + of: z.union([ + ComponentInputControlTypeSchema, + z.array(ComponentInputControlTypeSchema) + .min(1) + .refine((arms) => new Set(arms).size === arms.length, { + message: 'Input member kind arms must be distinct', + }), + ]).optional().describe('Coarse kind of the input\'s members, or the arms of a union'), /** * ADR-0049 RETIREMENT TOMBSTONES (objectui#7493 item ① / objectui#7781, * maintainer ruling A of 2026-09-06) — `label` / `defaultValue` / @@ -332,13 +350,13 @@ export const ComponentInputSchema = z.object({ */ label: retirementTombstone( 'RETIRED (objectui#7493) — `ComponentInput.label` was never read, and never published: the manifest ' - + 'serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + + 'serializer forwards `name`/`type`/`of`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + 'so an authored value was silently dropped. Delete the key; an input is identified by its `name` on ' + 'every path that reaches it, and nothing ever rendered a label for it.', ), defaultValue: retirementTombstone( 'RETIRED (objectui#7493) — `ComponentInput.defaultValue` was never read, and never published: the manifest ' - + 'serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + + 'serializer forwards `name`/`type`/`of`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + 'so an authored value was silently dropped. Delete the key; the renderer\'s own fallback read is the ' + 'default, and `description`, which IS published, is where to state it.', ), @@ -353,7 +371,7 @@ export const ComponentInputSchema = z.object({ description: z.string().optional().describe('Help text'), advanced: retirementTombstone( 'RETIRED (objectui#7493) — `ComponentInput.advanced` was never read, and never published: the manifest ' - + 'serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + + 'serializer forwards `name`/`type`/`of`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + 'so an authored value was silently dropped. Delete the key; no designer surface ever hid an "advanced" ' + 'input, so there is nothing to write instead.', ), @@ -369,7 +387,7 @@ export const ComponentInputSchema = z.object({ */ inputType: retirementTombstone( 'RETIRED (objectui#5905) — `ComponentInput.inputType` was never read, and never published: the manifest ' - + 'serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + + 'serializer forwards `name`/`type`/`of`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + 'so an authored value was silently dropped. Delete the key; put the control hint in `description`, ' + 'which IS published.', ), @@ -395,25 +413,25 @@ export const ComponentInputSchema = z.object({ */ min: retirementTombstone( 'RETIRED (objectui#5905) — `ComponentInput.min` was never read, and never published: the manifest ' - + 'serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + + 'serializer forwards `name`/`type`/`of`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + 'so an authored value was silently dropped. Delete the key; spell the numeric domain out in `description`, ' + 'which IS published.', ), max: retirementTombstone( 'RETIRED (objectui#5905) — `ComponentInput.max` was never read, and never published: the manifest ' - + 'serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + + 'serializer forwards `name`/`type`/`of`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + 'so an authored value was silently dropped. Delete the key; spell the numeric domain out in `description`, ' + 'which IS published.', ), step: retirementTombstone( 'RETIRED (objectui#5905) — `ComponentInput.step` was never read, and never published: the manifest ' - + 'serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + + 'serializer forwards `name`/`type`/`of`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + 'so an authored value was silently dropped. Delete the key; spell the numeric domain out in `description`, ' + 'which IS published.', ), placeholder: retirementTombstone( 'RETIRED (objectui#5905) — `ComponentInput.placeholder` was never read, and never published: the manifest ' - + 'serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + + 'serializer forwards `name`/`type`/`of`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + 'so an authored value was silently dropped. Delete the key; put the hint in `description`, which IS ' + 'published. `BaseSchema.placeholder`, the node-level prop, is a DIFFERENT key and is unaffected.', ),