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 afe10389f2..83223bdc09 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,37 @@ * 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. Those keys — `record:details + * .sections`, `record:highlights.fields` and `record:related_list.add` + * among them — publish their members in prose and are pinned by per-block + * tests next to their renderers, and since objectui#8068 the MEMBER-PIN + * DIRECTION below makes naming that pin MANDATORY rather than voluntary. + * 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,13 +1300,270 @@ 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.', +}; + // ── the MEMBER-PIN direction (objectui#8068) ───────────────────────────────── // -// The three directions above judge a block's DECLARATION — which top-level keys -// it publishes, and with which coarse kinds. None of them can see one layer in, -// and the LIMIT note at the top of this file says so in as many words: an -// `inputs` entry of type `array`/`object` declares no member shape, so a drifted -// key INSIDE an array element or a nested object is invisible here. +// The four directions above judge a block's DECLARATION — which top-level keys +// it publishes, with which coarse kinds, and (since objectui#8067 landed +// alongside this one) with which coarse kind INSIDE a container. None of them +// can see the member's own KEYS, and the LIMIT note at the top of this file says +// so in as many words: `of` "stops at the KIND … `of: 'object'` says the members +// are objects, never WHICH KEYS they have", so a drifted key INSIDE an array +// element or a nested object is still invisible here. +// +// ⚠️ That merge changed a NUMBER in this paragraph and nothing else in this +// direction. `of` is a DECLARATION, not a pin, so it neither satisfies nor +// exempts anything below: the population this direction judges, its ledger and +// its ceiling are the same on the merged tree as they were on the branch that +// measured them. // // That note then names the mitigation — `record:details.sections`, // `record:highlights.fields` and `record:related_list.add` "publish their @@ -2277,6 +2547,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.', ),