diff --git a/.changeset/8499-node-slot-registered-arms.md b/.changeset/8499-node-slot-registered-arms.md new file mode 100644 index 0000000000..c055695d1c --- /dev/null +++ b/.changeset/8499-node-slot-registered-arms.md @@ -0,0 +1,94 @@ +--- +'@object-ui/types': minor +--- + +Give `AnyComponentSchema` arms for seven registered, live renderers that resolved in +no arm at a declared node slot (objectui#8499). + +**The defect, and the direction it ran.** Nine `type` spellings sat at DECLARED node +slots in this repository's own corpora and resolved in no arm of the component union. +Eight were registered renderers with fixtures proving they draw; the ninth +(`my-component`) is the reader's own plugin component and carries a written exemption +in `scripts/check-doc-component-types.mjs`. A reader following +`content/docs/utilities/runner.mdx`'s own instruction — "copy one, wrap it in a page +document … and save it as `src/app-data/pages/index.json`" — got a document that +**renders correctly in the browser and is refused by `objectui check`**. That is the +expensive direction: the likely reaction is to stop trusting the validator. + +It was invisible because `check:doc-types` judges a `type` literal against the +RENDERER REGISTRY (656 keys) and not against `AnyComponentSchema` (107 arm literals). +The two faces disagreed by construction and nothing compared them at a node slot. + +**What is now authorable.** Four new arms, 47 new `type` literals, taking the union +from 107 to 154: + +- `SemanticElementSchema` (`zod/layout.zod.ts`) — the seven HTML sectioning tags + `renderers/layout/semantic.tsx` registers: `aside` `main` `header` `nav` `footer` + `section` `article`. +- `HtmlElementSchema` (`zod/layout.zod.ts`) — the 37 safe flow/inline tags + `renderers/basic/html-elements.tsx` registers (`h1`…`h6`, `p`, `a`, `ul`, `img`, …), + plus the per-tag keys that module forwards to the DOM (`href`, `target`, `rel`, + `title`, `src`, `alt`, `width`, `height`, `dateTime`, `cite`). +- `InputShorthandSchema` (`zod/form.zod.ts`) — `email` / `password`, the two aliases + `renderers/form/input.tsx` registers onto the `input` renderer with `inputType` + pinned. `inputType` is deliberately NOT declared on this arm: the wrapper spreads + its own value last, so an authored one is overwritten. +- `UiCalendarSchema` (`zod/form.zod.ts`) — `ui:calendar`, the date-picker primitive + `renderers/form/calendar.tsx` registers under exactly that key (`skipFallback`, + because bare `calendar` belongs to the plugin-calendar view). + +Every arm this change AUTHORS declares only keys its renderer demonstrably reads — +the `BarChartSchema` discipline objectui#6318 established for the same class of gap. +⚠️ One arm INHERITS more than that, and it is stated rather than glossed: +`UiCalendarSchema` extends `CalendarSchema`, so it carries `minDate` and `maxDate`, +which `renderers/form/calendar.tsx` reads zero times (controls, same file: `mode` 3, +`className` 4 — it reads `mode`, `value`, `defaultValue` and `className`). That is +pre-existing debt on `CalendarSchema`, not something this change introduces, and +narrowing it here would be a different card's accept-set movement; the extend is what +keeps the two spellings one schema. + +**A repo-tracked metric moves, declared knowingly.** +`scripts/measure-strict-authoring-face.mjs` reports `unexportedNodeSchemas` — node +types reachable only through the union because no schema for them is exported by name +from `@object-ui/types/zod`, "so no consumer can validate one alone". The four new +arms are not in that barrel's explicit export lists, so the metric moves from +`[breadcrumb, object-tree]` to **49 node types** (the same 2, plus this change's 47 +new literals). Measured at this branch's head, not estimated. + +Why 49 is acceptable here where 2 was a defect: the 2 were data blocks a consumer had +a standing reason to validate on their own, and objectui#7917 (PR #8777, open at the +time of writing, and the holder of `zod/index.zod.ts`) exists to export exactly those. +The 47 added here are HTML primitives and two input aliases — they have no per-tag +consumer to serve, and exporting a `SemanticElementSchema` / `HtmlElementSchema` pair +would publish a NAMED authoring surface (`z.enum` families, not per-tag schemas) that +this card's ruling does not cover: the ruling is "arm the registered renderers", not +"add public exports to `@object-ui/types`". So the metric is left to move and said out +loud instead. ⇒ Whoever next runs that measurement should expect 49, and whoever +wants the number back down should treat naming these families as its own decision. +If PR #8777 lands first, the same movement reads `0` to `47`. + +**Accept-set movement is widening only.** Nothing that parsed green parses red. Each +arm still judges VALUES: `{ type: 'img', width: true }`, `{ type: 'password', +required: 'yes' }` and `{ type: 'ui:calendar', mode: 'agenda' }` are all refused, and +a `type` nothing registers (`stat-card`, `metric-card`, `h1ZZ`) is refused at the root +and at every node slot exactly as before. + +**Every one of these types becomes legal at EVERY node slot** — measured: 249 +arm-level node slots before this change and 257 after, plus 36 on nested schemas, +all spelled with the one `SchemaNodeSchema`. A slot-constrained shape is not expressible by adding arms: a +discriminated union selects its arm from the authored literal alone, so a slot has no +say. That is a per-slot vocabulary programme, not a variant of this change. + +**`line-chart` is deliberately NOT armed.** The card lists it among the eight as a +live renderer; measured here it is not. `apps/console/src/register-plugins.ts` +registers it as a lazy stub pointing at `@object-ui/plugin-charts`, and that package +never registers the key, so it resolves to nothing at render time. Arming it would +invent a capability rather than name one. `__tests__/node-slot-registered-arms-8499.test.ts` +pins the absence together with its reason, so registering the key for real turns red. + +**Downstream.** `objectui check` stops reporting these documents. Its +`check-validity-recogniser` suite measured 658 registered types against 102 arm +literals when it was written; re-measured here it is 656 against 154, so the +"registered but not modelled" bucket goes from 558 to 505 and its fixture moved off +the HTML primitives (they are modelled now) onto `metric-card`, whose absence from the +union is ruled rather than pending. diff --git a/apps/console/src/__tests__/public-contract.test.ts b/apps/console/src/__tests__/public-contract.test.ts index 520a2e9b13..36a3c83bbd 100644 --- a/apps/console/src/__tests__/public-contract.test.ts +++ b/apps/console/src/__tests__/public-contract.test.ts @@ -397,9 +397,11 @@ describe('PUBLIC_BLOCKS ↔ console coverage (reverse direction)', () => { * * Scope, stated plainly: this covers the CONTAINER half of the layout * vocabulary. The non-container arms (`span`, `separator`, `scroll-area`, - * `resizable`, `page`, and the deprecated `div`) are outside it because - * curating any of them is an unruled question of its own, and a ledger is a - * forcing function, not a place to park four of those at once. + * `resizable`, `page`, the deprecated `div`, and — since objectui#8499 armed + * them — the 37 flow/inline HTML tags of `HtmlElementSchema`, not one of which + * declares containment) are outside it because curating any of them is an + * unruled question of its own, and a ledger is a forcing function, not a place + * to park four of those at once. * * ── THE MEASUREMENT THIS CARD WAS DISPATCHED TO MAKE, RECORDED ────────────── * @@ -443,16 +445,39 @@ describe('PUBLIC_BLOCKS ↔ console coverage (reverse direction)', () => { /** * The JSON layout vocabulary, read from the zod union's own arms. * - * A discriminated union member declares its `type` literal to Zod, so the arm - * list IS the vocabulary. Filtering to real strings is deliberate: a zod release - * that moved this accessor would otherwise yield a population of `undefined` and - * make every assertion below vacuously true, which is why the first case - * compares the resolved count against the raw arm count. + * A discriminated union member declares its `type` to Zod, so the arm list IS + * the vocabulary — but an arm declares it in one of TWO shapes, and reading only + * the first is what objectui#8499 broke here. A `z.literal` arm carries one + * spelling on `.value`; a `z.enum` arm carries a whole registered family on + * `.options` — `SemanticElementSchema`'s seven sectioning tags, and + * `HtmlElementSchema`'s 37 flow/inline tags. A `.value`-only read resolved + * neither, and the anti-vacuity case below reported it as 19 arms yielding 17 + * literals, which is precisely the job that case exists to do. + * + * The switch is on zod's own `def.type` discriminator, NOT on which accessor + * happens to be present. A fallback chain (`.value`, else `.options`, else …) + * would paper over exactly the accessor drift the first case is meant to + * report: an arm whose declaration is neither shape resolves NOTHING here, and + * the count comparison below turns that silence into a failure. */ const LAYOUT_UNION_ARMS = (LayoutSchema as unknown as { options: unknown[] }).options; -const LAYOUT_VOCABULARY: string[] = LAYOUT_UNION_ARMS.map( - (arm) => (arm as { shape?: { type?: { value?: unknown } } }).shape?.type?.value, -).filter((value): value is string => typeof value === 'string' && value.length > 0); + +type TypeDeclaration = { def?: { type?: string }; value?: unknown; options?: unknown[] }; + +/** Every `type` spelling ONE arm can take: a literal's single value, or an enum's whole set. */ +const armLiterals = (arm: unknown): string[] => { + const declared = (arm as { shape?: { type?: TypeDeclaration } }).shape?.type; + const isSpelling = (value: unknown): value is string => + typeof value === 'string' && value.length > 0; + if (declared?.def?.type === 'literal') { + return isSpelling(declared.value) ? [declared.value] : []; + } + if (declared?.def?.type === 'enum') return (declared.options ?? []).filter(isSpelling); + return []; +}; + +const LAYOUT_ARM_LITERALS: string[][] = LAYOUT_UNION_ARMS.map(armLiterals); +const LAYOUT_VOCABULARY: string[] = LAYOUT_ARM_LITERALS.flat(); /** …of those, the ones the registry DECLARES it renders a child list for. */ const DECLARED_LAYOUT_CONTAINERS = LAYOUT_VOCABULARY.filter( @@ -480,7 +505,60 @@ const specCarried = (type: string): boolean => * below by ABSENCE — which is the property `box` needed and did not have. An * entry here is the deliberate, reviewable alternative to curating, never a * silent one, and it must name an issue that resolves it. + * + * ⭐ The seven sectioning tags arrived here BY ABSENCE — the mechanism working, + * not failing. They have declared `isContainer: true` since objectui#6764; + * objectui#8499 armed them as `SemanticElementSchema`, and becoming arms of the + * layout union is what first pulled already-declared containers into the + * intersection this file derives. ⛔ The alternative on offer — housing that arm + * outside `LayoutSchema` — was refused deliberately: it would have removed seven + * genuine layout containers from this population without changing anything about + * what they are, which is a gate that stops looking rather than a gate that + * passes. Ledgering them is the reviewable option; objectui#8775 holds the + * decision itself, which is NOT this card's to take. + * + * ⚠️ What an entry here COSTS, stated once so no entry has to re-argue it, and + * corrected because objectui#8499 first shipped it wrong. Curating a ledgered + * container widens the AI-authoring vocabulary, `sdui.manifest.json` and the + * generated intrinsics, and turns the census pin in + * `renderers/__tests__/container-declaration-census.test.tsx` red BY DESIGN — a + * deliberate re-opening, which is the whole point of pinning it. It does ⛔ NOT + * remove the tag from any `kind:'react'` page. `renderers/layout/react-page.tsx` + * builds that scope with `if (!tag || cfg.isContainer) continue;`, so it skips + * EVERY container config; a ledgered container already carries the flag, so + * promotion changes which list the config comes from and nothing about whether + * the loop keeps it. Measured on `main`: 46 injected identifiers today, 46 with + * `main` promoted, `Main` absent from both. The deletion reading is real but + * runs the OTHER way — objectui#6764's direction, where declaring containment on + * an already-public block removes an identifier that existed — and reading that + * docblock forwards is how it got here. */ + +/** + * The seven are ONE registration — a single loop factory in + * `renderers/layout/semantic.tsx` over one `tags` array — so they share one + * reason rather than seven paraphrases of it. + */ +const SECTIONING_TAG_UNRULED = + 'NOT YET RULED, either way — and promoting it is not a roster edit. One of the seven HTML ' + + 'sectioning tags the single loop factory in `renderers/layout/semantic.tsx` registers with ' + + '`category: layout` and `isContainer: true` (objectui#6764). objectui#8499 armed them as ' + + '`SemanticElementSchema`, which is what first brought already-declared containers into the ' + + 'population this file derives — none of them is newly a container, and none is newly ' + + 'authorable. What curating one WOULD move, measured rather than reasoned: it widens the ' + + 'AI-authoring vocabulary, `sdui.manifest.json` and the generated intrinsics, and it turns the ' + + '"none of the eight is in the curated public contract" pin in ' + + '`container-declaration-census.test.tsx` RED BY DESIGN — which is precisely what ' + + '`semantic.tsx` means by re-opening the question THERE. ⛔ It does NOT delete the tag from ' + + 'any react page, and an earlier revision of this entry said it did: `react-page.tsx` skips ' + + 'EVERY container config (`if (!tag || cfg.isContainer) continue;`) and these seven already ' + + 'carry `isContainer: true`, so a promoted config is skipped on exactly the same line an ' + + 'unlisted one never reaches — measured at 46 injected identifiers before and 46 after ' + + 'simulating the promotion of `main`, with `Main` absent from both. A lowercase `main` in a ' + + 'react page is a DOM intrinsic either way, because `react-runtime` never reads the registry. ' + + 'objectui#8775 measured the population and holds the decision: curate the family or a named ' + + 'subset and carry that consequence, or refuse on stated merits and replace this text with them.'; + const UNCURATED_LAYOUT_CONTAINERS: Record = { 'aspect-ratio': 'NOT YET RULED, either way — and that is the entry, stated honestly rather than dressed as merits. ' + @@ -490,18 +568,56 @@ const UNCURATED_LAYOUT_CONTAINERS: Record = { '`PUBLIC_BLOCKS`, so the react-page scope builder never saw this tag"), never a reason. objectui#6879 ' + 'measured the population and filed the decision as objectui#8628 — curate it, or refuse it on stated ' + 'merits and replace this text with them. Until then it stays visible here instead of invisible in a gap.', + article: SECTIONING_TAG_UNRULED, + aside: SECTIONING_TAG_UNRULED, + footer: SECTIONING_TAG_UNRULED, + header: SECTIONING_TAG_UNRULED, + main: SECTIONING_TAG_UNRULED, + nav: SECTIONING_TAG_UNRULED, + section: SECTIONING_TAG_UNRULED, }; describe('PUBLIC_BLOCKS ↔ the declared layout containers (derived, objectui#6879)', () => { it('reads the vocabulary off the union rather than restating it', () => { - // The anti-vacuity case, and it comes first. Every arm must resolve a real - // `type` literal: a population of `undefined`s would make the ledger pin - // below pass while asserting nothing at all. + // The anti-vacuity case, and it comes first. EVERY arm must resolve at least + // one real `type` spelling: an arm that resolved none would make the ledger + // pin below pass while asserting nothing at all about that arm. + // + // ⚠️ Counting literals against arms — what this line did before + // objectui#8499 — is NOT the same assertion and cannot be restored: a single + // enum arm contributes 37 spellings, so the two numbers are no longer meant + // to match. What still holds one-for-one is that no arm contributes ZERO. expect(LAYOUT_UNION_ARMS.length).toBeGreaterThan(0); - expect(LAYOUT_VOCABULARY).toHaveLength(LAYOUT_UNION_ARMS.length); - // And it must be the vocabulary we think it is — the minted type this card - // is about, plus two of its long-standing siblings. - expect(LAYOUT_VOCABULARY).toEqual(expect.arrayContaining(['box', 'flex', 'container'])); + expect(LAYOUT_ARM_LITERALS.filter((literals) => literals.length > 0)).toHaveLength( + LAYOUT_UNION_ARMS.length, + ); + // And it must be the vocabulary we think it is: the type this card was built + // around, two of its long-standing siblings, and one spelling out of EACH + // enum arm — so a reader that silently stopped resolving enums, which is the + // regression this file caught, cannot pass this case either. + expect(LAYOUT_VOCABULARY).toEqual( + expect.arrayContaining(['box', 'flex', 'container', 'main', 'h1']), + ); + // ⭐ The SECOND ACCESSOR PATH, and the reason the sample above is not the + // only guard. `propValues` is zod's OWN discriminator index, built when the + // union was constructed; `armLiterals` is this file's switch over `def`. + // Two readers, one schema — so they must agree as sets, and the day someone + // "simplifies" `armLiterals` back to a `.value`-only read (exactly the + // objectui#8499 regression) the two stop agreeing here rather than five + // sampled spellings later. + // + // ⚠️ Scope, stated so nobody over-reads it: this pins the READER, not the + // schema. Both paths read the same union, so a spelling genuinely deleted + // from an enum arm (`q`, say) leaves both sides equal and this case green; + // that direction is pinned against the REGISTRATION SOURCE elsewhere, which + // is where it belongs. + const zodPropValues = ( + LayoutSchema as unknown as { _zod?: { propValues?: { type?: Set } } } + )._zod?.propValues?.type; + expect(zodPropValues, "zod exposes no `propValues.type` index for this union").toBeDefined(); + expect([...(zodPropValues ?? [])].filter((v) => typeof v === 'string').sort()).toEqual( + [...LAYOUT_VOCABULARY].sort(), + ); }); it('derives a non-empty container population holding the objectui#6764 control set', () => { diff --git a/packages/cli/src/__tests__/check-validity-recogniser.test.ts b/packages/cli/src/__tests__/check-validity-recogniser.test.ts index af25fb49bf..15163d559d 100644 --- a/packages/cli/src/__tests__/check-validity-recogniser.test.ts +++ b/packages/cli/src/__tests__/check-validity-recogniser.test.ts @@ -182,27 +182,40 @@ describe('objectui check — a broken ObjectUI schema is never filed as a foreig it('reports a registered component type the bundled schemas do not model', async () => { // The other half of the bucket, and why its wording says "off-spec OR not - // modelled". `abbr` is a real registered type — `packages/components/src/ - // renderers/basic/html-elements.tsx` registers the raw HTML elements in - // bulk — and `AnyComponentSchema` is a union of COMPONENT schemas that has - // no member for it. So the well-formed document below fails the validity - // arm. Reporting it is right (a registered type the shipped validator - // cannot validate is itself a finding) but calling it INVALID would - // overclaim: nothing about this document is wrong. + // modelled". `metric-card` is a real registered type — objectui's dashboard + // widget-slot component, registered by `apps/console/src/register-plugins.ts` + // and `@object-ui/plugin-dashboard` — and `AnyComponentSchema` is a union of + // COMPONENT schemas that has no member for it. So the well-formed document + // below fails the validity arm. Reporting it is right (a registered type the + // shipped validator cannot validate is itself a finding) but calling it + // INVALID would overclaim: nothing about this document is wrong. // // ## Pick this type by measurement, not memory (objectui#6939) // - // Measured on this tree: `KNOWN_SCHEMA_TYPES` carries 658 registered types - // and `AnyComponentSchema` declares 102 distinct literal `type` values, so - // 558 registered types have no member. The probe was controlled in both - // directions — `kanban`, `text` and `tree-view` all read as MODELLED (so it - // is not blind to real members) and a nonsense type reads as unmodelled. + // Re-measured on this tree (objectui#8499): `KNOWN_SCHEMA_TYPES` carries 656 + // registered types and `AnyComponentSchema` declares 154 distinct literal + // `type` values, so 505 registered types have no member. The probe was + // controlled in both directions — `kanban`, `text` and `tree-view` all read + // as MODELLED (so it is not blind to real members) and a nonsense type reads + // as unmodelled. // - // Not every one of those 558 is a safe fixture. The type used here must be - // one nothing is about to model, and the raw HTML primitives are the stable - // inhabitants of this bucket: they are registered as a bulk passthrough - // list, not as authorable component schemas. A plugin-ish type such as - // `dashboard-grid` also lands here today and would be the wrong choice. + // ## Why it is no longer `abbr` + // + // This sample was `abbr` on the reasoning that "the raw HTML primitives are + // the stable inhabitants of this bucket". They were not: objectui#8499 armed + // the whole registered HTML passthrough set (`h1`…`h6`, `p`, `a`, `abbr`, …) + // together with the seven semantic sectioning tags, precisely because a + // registered renderer the validator cannot name is a defect rather than a + // stable state of affairs. The 47 literals that card added are what took 102 + // to 154 above, and they took this fixture with them. + // + // The replacement is chosen for a stronger property than "nobody has got to + // it yet": `metric-card`'s absence from this union is RULED. It is objectui's + // CLOSED dashboard-widget-slot extension, admitted by the 2026-08-14 ruling + // (objectstack#8593), and `packages/types/src/zod/complex.zod.ts` records in + // as many words that it is "DELIBERATELY not an arm of `AnyComponentSchema`". + // So this fixture cannot rot the way `abbr` did without a maintainer + // reversing that ruling — which is exactly when this test SHOULD be re-read. // // ## Why it is no longer `kanban` // @@ -215,24 +228,24 @@ describe('objectui check — a broken ObjectUI schema is never filed as a foreig // fork, the board started validating, and this case measured 0 candidates. // The defect this file's own subject matter exists to catch had been frozen // into an assertion of expected behaviour. - writeSchema('abbr.json', { - type: 'abbr', - content: 'HTML', - title: 'HyperText Markup Language', + writeSchema('metric-card.json', { + type: 'metric-card', + title: 'Total Revenue', + value: '$123,456', }); // The precondition, asserted rather than assumed, so that the day someone - // models `abbr` this file says WHY it went red instead of reporting a bare - // `expected +0 to be 1`. If this fires: pick another registered type with - // no member in `AnyComponentSchema` (a raw HTML primitive) and update the - // counts above. + // models `metric-card` this file says WHY it went red instead of reporting a + // bare `expected +0 to be 1`. If this fires: the objectstack#8593 ruling has + // been revisited — re-read this case, then pick another registered type with + // no member in `AnyComponentSchema` and update the counts above. expect( - safeValidateSchema({ type: 'abbr', content: 'HTML', title: 'HyperText Markup Language' }).success, - '`abbr` is now modelled by AnyComponentSchema — this fixture needs a type that still is not; see the comment above', + safeValidateSchema({ type: 'metric-card', title: 'Total Revenue', value: '$123,456' }).success, + '`metric-card` is now modelled by AnyComponentSchema — this fixture needs a type that still is not; see the comment above', ).toBe(false); await check(cwd); expect(candidateCount()).toBe(1); expect(candidateLines()).toEqual([ - expect.stringContaining('abbr.json (type "abbr")'), + expect.stringContaining('metric-card.json (type "metric-card")'), ]); expect(skippedCount()).toBe(0); }); diff --git a/packages/types/src/__tests__/node-recursion-point-8344.test.ts b/packages/types/src/__tests__/node-recursion-point-8344.test.ts index 38f185f5da..fd5021e0e9 100644 --- a/packages/types/src/__tests__/node-recursion-point-8344.test.ts +++ b/packages/types/src/__tests__/node-recursion-point-8344.test.ts @@ -100,14 +100,27 @@ describe('objectui#7869 — the off-spec node gets the same verdict at both dept describe('the arm IS the component union, and not the base shape', () => { /** - * `h1` is a REGISTERED RENDERER (`components/src/renderers/basic/html-elements.tsx`) - * with no mirror in `AnyComponentSchema` — so it is the input the two candidate - * recursion points answer differently: `BaseSchemaCore` takes any object with a - * string `type`, the component union takes none it does not declare. ⛔ Do not - * "fix" this by adding an `h1` arm to make some other test green: that is the - * public-surface widening objectui#8344 routes into its own card. + * A REGISTERED RENDERER with no mirror in `AnyComponentSchema` — the input the + * two candidate recursion points answer differently: `BaseSchemaCore` takes any + * object with a string `type`, the component union takes none it does not + * declare. + * + * ⚠️ This was `h1` until objectui#8499. That card was the one objectui#8344 + * routed the widening into, and it LANDED: `h1` is now an arm + * (`zod/layout.zod.ts#HtmlElementSchema`), so the old example stopped + * discriminating and this leg went green for the wrong reason. ⛔ The remedy was + * NOT to weaken the leg — it is re-pointed at an input whose absence from the + * union is RULED rather than merely pending: `metric-card` is objectui's closed + * dashboard-widget-slot component extension, admitted by the 2026-08-14 ruling + * (objectstack#8593) and, in `zod/complex.zod.ts`'s own words, "DELIBERATELY not + * an arm of `AnyComponentSchema`". So this example cannot rot the way `h1` did + * without a maintainer reversing that ruling. + * + * ⛔ Do not "fix" a future failure here by adding an arm to make some other test + * green: an arm is a public-surface widening and belongs to its own card, which + * is what objectui#8344 said and what objectui#8499 then did properly. */ - const UNMIRRORED = { type: 'h1', children: 'Sales Dashboard' } as const; + const UNMIRRORED = { type: 'metric-card', title: 'Sales Dashboard' } as const; it('refuses an unmirrored node nested in a declared child slot', () => { expect(AnyComponentSchema.safeParse(nested(UNMIRRORED)).success).toBe(false); @@ -139,7 +152,7 @@ describe('the late-binding wiring, read by IDENTITY on the exported wrapper', () // module imports the barrel and nothing else, so a break in `index.zod.ts`'s // `defineNodeComponentUnion(...)` initializer lands here rather than in whichever suite // happened to run second. - expect(AnyComponentSchema.safeParse(nested({ type: 'h1' })).success).toBe(false); + expect(AnyComponentSchema.safeParse(nested({ type: 'metric-card' })).success).toBe(false); expect(AnyComponentSchema.safeParse(nested(LEGAL_ICON)).success).toBe(true); }); diff --git a/packages/types/src/__tests__/node-slot-registered-arms-8499.test.ts b/packages/types/src/__tests__/node-slot-registered-arms-8499.test.ts new file mode 100644 index 0000000000..f4bb0b1361 --- /dev/null +++ b/packages/types/src/__tests__/node-slot-registered-arms-8499.test.ts @@ -0,0 +1,259 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Registered, live renderers reached through a declared node slot resolve in an + * arm of `AnyComponentSchema` (objectui#8499). + * + * ## The defect these pin + * + * Nine `type` spellings sat at DECLARED node slots in this repository's own + * corpora and resolved in no arm of the union. Eight of them were registered + * renderers; the ninth (`my-component`) is the reader's own plugin component and + * carries a written exemption at `scripts/check-doc-component-types.mjs`. + * + * The damage ran the expensive direction: a reader following + * `content/docs/utilities/runner.mdx`'s own instruction — "copy one, wrap it in + * a page document … and save it as `src/app-data/pages/index.json`" — got a + * document that RENDERS CORRECTLY in the browser and is REFUSED by `objectui + * check`. The likely reaction to that is to stop trusting `check`, not to fix + * the document. + * + * ## Why nothing caught it + * + * `check:doc-types` judges a `type` literal against the RENDERER REGISTRY (656 + * keys, measured at the time of writing); `AnyComponentSchema` declares 107 arm + * literals. The two faces disagree BY CONSTRUCTION and nothing compared them at + * a node slot. So the repair is paired with the comparison this file's third + * `describe` performs — not the full `registry ⊆ arms` containment (552 of the + * 656 registered keys have no arm, so that instrument needs a ledger this card + * is not authorised to mint), but the two FAMILY arms compared against the very + * arrays their registration sites loop over. A tag added to either array without + * an arm here goes red instead of diverging silently. + * + * ## `line-chart` is deliberately NOT armed — the card's premise fails for it + * + * The card lists `line-chart` among the eight as a "REGISTERED, LIVE renderer". + * Measured here, it is not. `apps/console/src/register-plugins.ts` registers it + * as a LAZY STUB pointing at `@object-ui/plugin-charts`, and that package never + * registers the key — `Registry.loadLazy`'s own docblock says the loader + * "resolves once the loader completes (whether or not the loaded module actually + * registered the expected type)". So the key is known to `check:doc-types` and + * resolves to nothing at render time; `scripts/check-doc-component-types.mjs` + * records the same reading, calling it "the `line-chart` widget objectui#7896 + * recorded in `packages/plugin-dashboard/README.md`". objectui#8499's triage + * admits arms only for things that "运行时已经正确渲染" — already render + * correctly at runtime — so an arm for `line-chart` would invent a capability + * rather than name one. The fourth `describe` pins the absence WITH its reason, + * so registering the key for real turns this red instead of leaving the gap. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { AnyComponentSchema } from '../zod/index.zod.js'; +import { SemanticElementSchema, HtmlElementSchema } from '../zod/layout.zod.js'; +import { InputShorthandSchema, UiCalendarSchema } from '../zod/form.zod.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); +const read = (relative: string): string => readFileSync(join(REPO_ROOT, relative), 'utf8'); + +const SEMANTIC_RENDERER = 'packages/components/src/renderers/layout/semantic.tsx'; +const HTML_RENDERER = 'packages/components/src/renderers/basic/html-elements.tsx'; +const CHARTS_PLUGIN = 'packages/plugin-charts/src/index.tsx'; +const CONSOLE_PLUGINS = 'apps/console/src/register-plugins.ts'; + +/** The seven spellings this card armed, and where each renders from. */ +const ARMED = [ + { type: 'footer', renderer: SEMANTIC_RENDERER }, + { type: 'header', renderer: SEMANTIC_RENDERER }, + { type: 'main', renderer: SEMANTIC_RENDERER }, + { type: 'nav', renderer: SEMANTIC_RENDERER }, + { type: 'h1', renderer: HTML_RENDERER }, + { type: 'password', renderer: 'packages/components/src/renderers/form/input.tsx' }, + { type: 'ui:calendar', renderer: 'packages/components/src/renderers/form/calendar.tsx' }, +] as const; + +/** + * The firing controls. Each is a `type` NOTHING registers, so each must stay + * refused — an assertion set that only ever says ACCEPT would pass against a + * union that accepted everything, which is the failure mode a widening card is + * most exposed to. + */ +const UNREGISTERED = [ + 'h1ZZ', + 'stat-card', + 'my-component', + 'area-chart', +] as const; + +/** The `type` literals a schema declares to Zod's discriminator dispatch. */ +function literalsOf(schema: unknown): string[] { + const values = (schema as { _zod?: { propValues?: { type?: Set } } })._zod?.propValues + ?.type; + return values === undefined ? [] : [...values]; +} + +/** The string array a `const NAME = [ … ] as const;` declaration holds. */ +function sourceArray(source: string, declaration: string): string[] { + const start = source.indexOf(declaration); + if (start === -1) throw new Error(`declaration not found: ${declaration}`); + const open = source.indexOf('[', start); + const close = source.indexOf(']', open); + if (open === -1 || close === -1) throw new Error(`array literal not found: ${declaration}`); + return [...source.slice(open, close).matchAll(/'([^']+)'/g)].map((m) => m[1]); +} + +describe('objectui#8499 — the seven armed spellings resolve, at the root and at a node slot', () => { + it.each(ARMED)('accepts `$type` as a document root', ({ type }) => { + const result = AnyComponentSchema.safeParse({ type }); + expect(result.success, JSON.stringify(result.success ? {} : result.error.issues)).toBe(true); + }); + + it.each(ARMED)('accepts `$type` nested at a declared node slot', ({ type }) => { + // The slot that matters: objectui#8344 pointed the node recursion point at + // this union, so a nested node is judged by its own component schema. Before + // this card every one of these was refused HERE as well as at the root. + const result = AnyComponentSchema.safeParse({ type: 'div', children: [{ type }] }); + expect(result.success, JSON.stringify(result.success ? {} : result.error.issues)).toBe(true); + }); + + it('accepts the catalog fixtures the card names as its live evidence', () => { + // These render — `examples/schema-catalog/test/catalog-gallery-render.test.tsx` + // fails if any catalog entry paints the registry's OBJUI-001 "Unknown + // component type" panel — and every one of them was refused by this union. + const fixtures = [ + ...['article', 'aside', 'blog-article', 'complete-layout', 'footer', 'header', + 'main-element', 'navigation', 'section'] + .map((n) => `components-layout-semantic/${n}`), + ...['custom-style', 'date-range', 'form-integration', 'multiple-dates', + 'simple-calendar', 'single-date'] + .map((n) => `components-form-calendar/${n}`), + ]; + expect(fixtures).toHaveLength(15); + const refused = fixtures.filter((name) => { + const doc: unknown = JSON.parse( + read(`examples/schema-catalog/src/schemas/${name}.json`), + ); + return !AnyComponentSchema.safeParse(doc).success; + }); + expect(refused, 'a fixture the renderer draws is still refused by the validator').toEqual([]); + }); +}); + +describe('objectui#8499 — the controls that keep the acceptances honest', () => { + it.each(UNREGISTERED)('still refuses `%s`, which nothing registers', (type) => { + expect(AnyComponentSchema.safeParse({ type }).success).toBe(false); + expect(AnyComponentSchema.safeParse({ type: 'div', children: [{ type }] }).success).toBe(false); + }); + + it('the new arms judge VALUES, not just the discriminator', () => { + // An arm that accepted its literal and nothing else would satisfy every + // assertion above while validating nothing. Each pair is one green reading + // and one red reading on the SAME key of the SAME arm. + expect(AnyComponentSchema.safeParse({ type: 'img', src: 'a.png', width: 100 }).success).toBe(true); + expect(AnyComponentSchema.safeParse({ type: 'img', src: 'a.png', width: true }).success).toBe(false); + expect(AnyComponentSchema.safeParse({ type: 'password', required: true }).success).toBe(true); + expect(AnyComponentSchema.safeParse({ type: 'password', required: 'yes' }).success).toBe(false); + expect(AnyComponentSchema.safeParse({ type: 'ui:calendar', mode: 'single' }).success).toBe(true); + expect(AnyComponentSchema.safeParse({ type: 'ui:calendar', mode: 'agenda' }).success).toBe(false); + expect(AnyComponentSchema.safeParse({ type: 'main', hidden: true }).success).toBe(true); + expect(AnyComponentSchema.safeParse({ type: 'main', hidden: 42 }).success).toBe(false); + }); + + it('adds no discriminator collision', () => { + // `AnyComponentSchema` can only stay a discriminated union while every + // literal is claimed once — `any-component-union-fanout.test.ts` states the + // invariant; this leg says these four arms are not where it breaks. + const added = [ + ...literalsOf(SemanticElementSchema), + ...literalsOf(HtmlElementSchema), + ...literalsOf(InputShorthandSchema), + ...literalsOf(UiCalendarSchema), + ]; + expect(added.length).toBe(7 + 37 + 2 + 1); + expect(new Set(added).size).toBe(added.length); + const all = literalsOf(AnyComponentSchema); + expect(new Set(all).size).toBe(all.length); + for (const literal of added) expect(all).toContain(literal); + }); +}); + +describe('objectui#8499 — the family arms are compared against their registration sites', () => { + // ⭐ THE COMPARISON INSTRUMENT. The two faces diverged by construction because + // nothing compared them. This does, for the two families this card armed: the + // arm's literal set must EQUAL the array the registration site loops over. + // + // ⚠️ WHAT THESE THREE LEGS ARE NOT, stated so the count is not overclaimed: + // they are NOT controls for union membership. They read `SemanticElementSchema` + // and `HtmlElementSchema` directly, so removing those arms from + // `AnyComponentSchema` leaves them green — they survive that ablation by + // design, because the property they hold is declaration-vs-registration parity, + // a different one from "the union accepts this spelling". The union's own + // firing controls are the `UNREGISTERED` refusals above. + it('`SemanticElementSchema` names exactly the tags `semantic.tsx` registers', () => { + const tags = sourceArray(read(SEMANTIC_RENDERER), 'const tags = '); + expect(tags.length, 'the source read went vacuous — check the declaration name').toBe(7); + expect([...literalsOf(SemanticElementSchema)].sort()).toEqual([...tags].sort()); + }); + + it('`HtmlElementSchema` names exactly the tags `html-elements.tsx` registers', () => { + const tags = sourceArray(read(HTML_RENDERER), 'const TAGS = '); + expect(tags.length, 'the source read went vacuous — check the declaration name').toBe(37); + expect([...literalsOf(HtmlElementSchema)].sort()).toEqual([...tags].sort()); + }); + + it('detects a registered-but-unarmed tag, and fails closed on an unreadable source', () => { + // ⚠️ What this control must NOT be, and was: comparing `[...armed]` against + // `[...armed, 'hgroup']`. That is set algebra — an array differs from itself + // plus an element whatever the schema says, so it passed without ever + // touching a registration. Both halves below run the REAL instrument. + // + // A real mismatch, from a real registration site: `html-elements.tsx` + // registers 37 tags and `SemanticElementSchema` arms none of them, so the + // equality the two legs above perform must come out false here. + const htmlTags = sourceArray(read(HTML_RENDERER), 'const TAGS = '); + const semanticArmed = [...literalsOf(SemanticElementSchema)].sort(); + expect(htmlTags.length, 'the registration read went vacuous').toBe(37); + expect(semanticArmed.length, 'the arm read went vacuous').toBe(7); + expect(semanticArmed).not.toEqual([...htmlTags].sort()); + + // And the reader must be able to come back EMPTY rather than fabricate a + // pass — which is what makes the `toBe(7)` / `toBe(37)` guards above real + // guards. A declaration name that is not in the file throws rather than + // quietly yielding [], so the non-vacuity checks cannot be satisfied by a + // reader that has stopped reading. + expect(() => sourceArray(read(SEMANTIC_RENDERER), 'const notADeclaration = ')).toThrow( + /declaration not found/, + ); + }); +}); + +describe('objectui#8499 — `line-chart` stays unarmed, and the reason stays checked', () => { + it('resolves in no arm', () => { + expect(AnyComponentSchema.safeParse({ type: 'line-chart' }).success).toBe(false); + }); + + it('is a lazy stub in the console that `@object-ui/plugin-charts` never fulfils', () => { + // The premise the card asserts for all eight and that fails for this one. + // Both halves are read from source so the day someone registers the key for + // real, this goes red and the arm becomes owed. + const consoleSource = read(CONSOLE_PLUGINS); + expect(consoleSource, 'the console stub moved — re-derive the premise').toContain("'line-chart'"); + + const pluginSource = read(CHARTS_PLUGIN); + const registered = [...pluginSource.matchAll(/register\(\s*\n?\s*'([^']+)'/g)].map((m) => m[1]); + // Non-vacuity: the reader must actually find this module's registrations. + expect(registered.length, 'the registration read went vacuous').toBeGreaterThanOrEqual(6); + expect(registered).toContain('pie-chart'); + expect(registered).not.toContain('line-chart'); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 13d1a064b9..9427e7c150 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -397,8 +397,8 @@ import { ActionSchema, CRUDDialogSchema, DetailSchema } from '../zod/crud.zod.js import { AlertSchema, AvatarSchema, BadgeSchema, BarChartSchema, ChartDataSeriesSchema, ChartSchema, DataTableSchema, DrillDownConfigSchema, HtmlSchema, KbdSchema, ListItemSchema, ListSchema, MarkdownSchema, StaticTableColumnSchema, StatisticSchema, TableColumnSchema, TableSchema, TimelineEventSchema, TimelineSchema, TreeNodeSchema, TreeViewSchema } from '../zod/data-display.zod.js'; import { AccordionItemSchema, AccordionSchema, CollapsibleSchema, ToggleGroupItemSchema, ToggleGroupSchema } from '../zod/disclosure.zod.js'; import { EmptySchema, LoadingSchema, ProgressSchema, SkeletonSchema, SonnerSchema, SpinnerSchema, ToasterSchema, ToastSchema } from '../zod/feedback.zod.js'; -import { ButtonSchema, CalendarSchema, CheckboxSchema, CodeEditorSchema, ComboboxOptionSchema, ComboboxSchema, CommandGroupSchema, CommandItemSchema, CommandSchema, DatePickerSchema, FieldConditionSchema, FieldConstraintsSchema, FileUploadSchema, FormFieldSchema, FormSchema, InputOTPSchema, InputSchema, LabelSchema, RadioGroupSchema, RadioOptionSchema, SelectOptionSchema, SelectSchema, SliderSchema, SwitchSchema, TextareaSchema, ToggleSchema } from '../zod/form.zod.js'; -import { AspectRatioSchema, BoxSchema, CardSchema, ContainerSchema, DivSchema, FlexSchema, GridSchema, IconSchema, ImageSchema, PageNodeRegionSchema, PageNodeSchema, ResizablePanelSchema, ResizableSchema, ScrollAreaSchema, SeparatorSchema, StackSchema, TabItemSchema, TabsSchema, TextSchema, TextSpanSchema } from '../zod/layout.zod.js'; +import { ButtonSchema, CalendarSchema, CheckboxSchema, CodeEditorSchema, ComboboxOptionSchema, ComboboxSchema, CommandGroupSchema, CommandItemSchema, CommandSchema, DatePickerSchema, FieldConditionSchema, FieldConstraintsSchema, FileUploadSchema, FormFieldSchema, FormSchema, InputOTPSchema, InputSchema, InputShorthandSchema, LabelSchema, RadioGroupSchema, RadioOptionSchema, SelectOptionSchema, SelectSchema, SliderSchema, SwitchSchema, TextareaSchema, ToggleSchema, UiCalendarSchema } from '../zod/form.zod.js'; +import { AspectRatioSchema, BoxSchema, CardSchema, ContainerSchema, DivSchema, FlexSchema, GridSchema, HtmlElementSchema, IconSchema, ImageSchema, PageNodeRegionSchema, SemanticElementSchema, PageNodeSchema, ResizablePanelSchema, ResizableSchema, ScrollAreaSchema, SeparatorSchema, StackSchema, TabItemSchema, TabsSchema, TextSchema, TextSpanSchema } from '../zod/layout.zod.js'; import { BreadcrumbItemSchema, BreadcrumbSchema, ButtonGroupButtonSchema, ButtonGroupSchema, HeaderBarSchema, NavigationMenuItemSchema, NavigationMenuSchema, NavLinkSchema, PaginationSchema, SidebarSchema } from '../zod/navigation.zod.js'; import { ObjectCalendarSchema, ObjectChartSchema, ObjectDataTableSchema, ObjectFormSchema, ObjectGallerySchema, ObjectGanttSchema, ObjectGridSchema, ObjectKanbanSchema, ObjectMapConfigSchema, ObjectMapSchema, ObjectTreeSchema, ObjectViewSchema, SortConfigSchema } from '../zod/objectql.zod.js'; import { AlertDialogSchema, ContextMenuSchema, DialogSchema, DrawerSchema, DropdownMenuSchema, HoverCardSchema, MenubarMenuSchema, MenubarSchema, MenuItemSchema as OverlayMenuItemSchema, PopoverSchema, SheetSchema, TooltipSchema } from '../zod/overlay.zod.js'; @@ -413,8 +413,8 @@ import type { CRUDDialogSchema as Ts_CRUDDialogSchema, DetailSchema as Ts_Detail import type { AlertSchema as Ts_AlertSchema, AvatarSchema as Ts_AvatarSchema, BadgeSchema as Ts_BadgeSchema, BarChartSchema as Ts_BarChartSchema, ChartDataSeries as Ts_ChartDataSeries, ChartSchema as Ts_ChartSchema, DataTableSchema as Ts_DataTableSchema, DrillDownConfig as Ts_DrillDownConfig, HtmlSchema as Ts_HtmlSchema, KbdSchema as Ts_KbdSchema, ListItem as Ts_ListItem, ListSchema as Ts_ListSchema, MarkdownSchema as Ts_MarkdownSchema, StaticTableColumn as Ts_StaticTableColumn, StatisticSchema as Ts_StatisticSchema, TableColumn as Ts_TableColumn, TableSchema as Ts_TableSchema, TimelineEvent as Ts_TimelineEvent, TimelineSchema as Ts_TimelineSchema, TreeViewSchema as Ts_TreeViewSchema, BreadcrumbItem as Ts_BreadcrumbItem, BreadcrumbSchema as Ts_BreadcrumbSchema } from '../data-display'; import type { AccordionItem as Ts_AccordionItem, AccordionSchema as Ts_AccordionSchema, CollapsibleSchema as Ts_CollapsibleSchema, ToggleGroupItem as Ts_ToggleGroupItem, ToggleGroupSchema as Ts_ToggleGroupSchema } from '../disclosure'; import type { EmptySchema as Ts_EmptySchema, LoadingSchema as Ts_LoadingSchema, ProgressSchema as Ts_ProgressSchema, SkeletonSchema as Ts_SkeletonSchema, SonnerSchema as Ts_SonnerSchema, SpinnerSchema as Ts_SpinnerSchema, ToasterSchema as Ts_ToasterSchema, ToastSchema as Ts_ToastSchema } from '../feedback'; -import type { ButtonSchema as Ts_ButtonSchema, CalendarSchema as Ts_CalendarSchema, CheckboxSchema as Ts_CheckboxSchema, CodeEditorSchema as Ts_CodeEditorSchema, ComboboxOption as Ts_ComboboxOption, ComboboxSchema as Ts_ComboboxSchema, CommandGroup as Ts_CommandGroup, CommandItem as Ts_CommandItem, CommandSchema as Ts_CommandSchema, DatePickerSchema as Ts_DatePickerSchema, FieldCondition as Ts_FieldCondition, FieldValidationRules as Ts_FieldValidationRules, FileUploadSchema as Ts_FileUploadSchema, FormField as Ts_FormField, FormSchema as Ts_FormSchema, InputOTPSchema as Ts_InputOTPSchema, InputSchema as Ts_InputSchema, LabelSchema as Ts_LabelSchema, RadioGroupSchema as Ts_RadioGroupSchema, RadioOption as Ts_RadioOption, SelectOption as Ts_SelectOption, SelectSchema as Ts_SelectSchema, SliderSchema as Ts_SliderSchema, SwitchSchema as Ts_SwitchSchema, TextareaSchema as Ts_TextareaSchema, ToggleSchema as Ts_ToggleSchema } from '../form'; -import type { AspectRatioSchema as Ts_AspectRatioSchema, BoxSchema as Ts_BoxSchema, CardSchema as Ts_CardSchema, ContainerSchema as Ts_ContainerSchema, DivSchema as Ts_DivSchema, FlexSchema as Ts_FlexSchema, GridSchema as Ts_GridSchema, IconSchema as Ts_IconSchema, ImageSchema as Ts_ImageSchema, PageNodeRegion as Ts_PageNodeRegion, PageNodeSchema as Ts_PageNodeSchema, ResizablePanel as Ts_ResizablePanel, ResizableSchema as Ts_ResizableSchema, ScrollAreaSchema as Ts_ScrollAreaSchema, SeparatorSchema as Ts_SeparatorSchema, StackSchema as Ts_StackSchema, TabItem as Ts_TabItem, TabsSchema as Ts_TabsSchema, TextSchema as Ts_TextSchema, TextSpanSchema as Ts_TextSpanSchema } from '../layout'; +import type { ButtonSchema as Ts_ButtonSchema, CalendarSchema as Ts_CalendarSchema, CheckboxSchema as Ts_CheckboxSchema, CodeEditorSchema as Ts_CodeEditorSchema, ComboboxOption as Ts_ComboboxOption, ComboboxSchema as Ts_ComboboxSchema, CommandGroup as Ts_CommandGroup, CommandItem as Ts_CommandItem, CommandSchema as Ts_CommandSchema, DatePickerSchema as Ts_DatePickerSchema, FieldCondition as Ts_FieldCondition, FieldValidationRules as Ts_FieldValidationRules, FileUploadSchema as Ts_FileUploadSchema, FormField as Ts_FormField, FormSchema as Ts_FormSchema, InputOTPSchema as Ts_InputOTPSchema, InputSchema as Ts_InputSchema, InputShorthandSchema as Ts_InputShorthandSchema, UiCalendarSchema as Ts_UiCalendarSchema, LabelSchema as Ts_LabelSchema, RadioGroupSchema as Ts_RadioGroupSchema, RadioOption as Ts_RadioOption, SelectOption as Ts_SelectOption, SelectSchema as Ts_SelectSchema, SliderSchema as Ts_SliderSchema, SwitchSchema as Ts_SwitchSchema, TextareaSchema as Ts_TextareaSchema, ToggleSchema as Ts_ToggleSchema } from '../form'; +import type { AspectRatioSchema as Ts_AspectRatioSchema, BoxSchema as Ts_BoxSchema, CardSchema as Ts_CardSchema, ContainerSchema as Ts_ContainerSchema, DivSchema as Ts_DivSchema, FlexSchema as Ts_FlexSchema, GridSchema as Ts_GridSchema, HtmlElementSchema as Ts_HtmlElementSchema, IconSchema as Ts_IconSchema, ImageSchema as Ts_ImageSchema, SemanticElementSchema as Ts_SemanticElementSchema, PageNodeRegion as Ts_PageNodeRegion, PageNodeSchema as Ts_PageNodeSchema, ResizablePanel as Ts_ResizablePanel, ResizableSchema as Ts_ResizableSchema, ScrollAreaSchema as Ts_ScrollAreaSchema, SeparatorSchema as Ts_SeparatorSchema, StackSchema as Ts_StackSchema, TabItem as Ts_TabItem, TabsSchema as Ts_TabsSchema, TextSchema as Ts_TextSchema, TextSpanSchema as Ts_TextSpanSchema } from '../layout'; import type { ButtonGroupButton as Ts_ButtonGroupButton, ButtonGroupSchema as Ts_ButtonGroupSchema, HeaderBarSchema as Ts_HeaderBarSchema, NavigationMenuSchema as Ts_NavigationMenuSchema, PaginationSchema as Ts_PaginationSchema, SidebarSchema as Ts_SidebarSchema } from '../navigation'; import type { ObjectCalendarSchema as Ts_ObjectCalendarSchema, ObjectChartSchema as Ts_ObjectChartSchema, ObjectDataTableSchema as Ts_ObjectDataTableSchema, ObjectFormSchema as Ts_ObjectFormSchema, ObjectGallerySchema as Ts_ObjectGallerySchema, ObjectGanttSchema as Ts_ObjectGanttSchema, ObjectGridSchema as Ts_ObjectGridSchema, ObjectKanbanSchema as Ts_ObjectKanbanSchema, ObjectMapConfig as Ts_ObjectMapConfig, ObjectMapSchema as Ts_ObjectMapSchema, ObjectTreeSchema as Ts_ObjectTreeSchema, ObjectViewSchema as Ts_ObjectViewSchema, SortConfig as Ts_SortConfig } from '../objectql'; import type { AlertDialogSchema as Ts_AlertDialogSchema, ContextMenuSchema as Ts_ContextMenuSchema, DialogSchema as Ts_DialogSchema, DrawerSchema as Ts_DrawerSchema, DropdownMenuSchema as Ts_DropdownMenuSchema, HoverCardSchema as Ts_HoverCardSchema, MenubarMenu as Ts_MenubarMenu, MenubarSchema as Ts_MenubarSchema, PopoverSchema as Ts_PopoverSchema, SheetSchema as Ts_SheetSchema, TooltipSchema as Ts_TooltipSchema } from '../overlay'; @@ -1141,6 +1141,8 @@ const MIRRORS = { 'form.zod.ts#FormSchema': FormSchema, 'form.zod.ts#InputOTPSchema': InputOTPSchema, 'form.zod.ts#InputSchema': InputSchema, + 'form.zod.ts#InputShorthandSchema': InputShorthandSchema, + 'form.zod.ts#UiCalendarSchema': UiCalendarSchema, 'form.zod.ts#LabelSchema': LabelSchema, 'form.zod.ts#RadioGroupSchema': RadioGroupSchema, 'form.zod.ts#RadioOptionSchema': RadioOptionSchema, @@ -1155,6 +1157,8 @@ const MIRRORS = { 'layout.zod.ts#CardSchema': CardSchema, 'layout.zod.ts#ContainerSchema': ContainerSchema, 'layout.zod.ts#DivSchema': DivSchema, + 'layout.zod.ts#HtmlElementSchema': HtmlElementSchema, + 'layout.zod.ts#SemanticElementSchema': SemanticElementSchema, 'layout.zod.ts#FlexSchema': FlexSchema, 'layout.zod.ts#GridSchema': GridSchema, 'layout.zod.ts#IconSchema': IconSchema, @@ -1303,6 +1307,8 @@ interface Declared { 'form.zod.ts#FormSchema': Ts_FormSchema; 'form.zod.ts#InputOTPSchema': Ts_InputOTPSchema; 'form.zod.ts#InputSchema': Ts_InputSchema; + 'form.zod.ts#InputShorthandSchema': Ts_InputShorthandSchema; + 'form.zod.ts#UiCalendarSchema': Ts_UiCalendarSchema; 'form.zod.ts#LabelSchema': Ts_LabelSchema; 'form.zod.ts#RadioGroupSchema': Ts_RadioGroupSchema; 'form.zod.ts#RadioOptionSchema': Ts_RadioOption; @@ -1317,6 +1323,8 @@ interface Declared { 'layout.zod.ts#CardSchema': Ts_CardSchema; 'layout.zod.ts#ContainerSchema': Ts_ContainerSchema; 'layout.zod.ts#DivSchema': Ts_DivSchema; + 'layout.zod.ts#HtmlElementSchema': Ts_HtmlElementSchema; + 'layout.zod.ts#SemanticElementSchema': Ts_SemanticElementSchema; 'layout.zod.ts#FlexSchema': Ts_FlexSchema; 'layout.zod.ts#GridSchema': Ts_GridSchema; 'layout.zod.ts#IconSchema': Ts_IconSchema; @@ -3119,7 +3127,7 @@ const ZOD_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'zod'); * MINUEND under it had moved. Nothing failed on any of those days, because nothing * compared the registry to a number. objectui#7433 is that absence, not the digits. */ -const EXPECTED_MIRROR_PAIRS = 158; +const EXPECTED_MIRROR_PAIRS = 162; /** * A ledger this file can size from its own AST. `WiderThanDeclared` joined at diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index a9491e92af..ac33618b12 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -1637,6 +1637,50 @@ export interface CodeEditorSchema extends BaseSchema { onChange?: (value: string | undefined) => void; } +/** + * The `email` / `password` input shorthands + * `packages/components/src/renderers/form/input.tsx` registers (objectui#8499). + * + * Both are the SAME renderer as `input`, wrapped so `inputType` is pinned: + * ``. + * + * ⛔ `inputType` is therefore absent here, and that absence is the whole + * difference from {@link InputSchema}. The wrapper spreads its own value LAST, + * so an authored `inputType` is silently overwritten; declaring it would publish + * a key the runtime discards. Write `{ type: 'input', inputType: 'email' }` when + * the input type is the choice. + * + * ⚠️ {@link BaseSchema} carries an index signature and its mirror passes unknown + * keys through, so omitting the key states the contract — it does not refuse the + * value. Refusing it by name is an accept-set narrowing left to its own ruling. + * + * Mirror: `zod/form.zod.ts#InputShorthandSchema`. + */ +export interface InputShorthandSchema extends Omit { + type: 'email' | 'password'; +} + +/** + * The date-picker primitive `packages/components/src/renderers/form/calendar.tsx` + * registers, reachable at `ui:calendar` ONLY (objectui#8499). + * + * ⚠️ `ui:calendar` and `calendar` are DIFFERENT components. That registration + * carries `skipFallback: true` because "`calendar` collides with the + * plugin-calendar full CRUD calendar VIEW, which owns the bare `type: 'calendar'` + * schema keyword; this date-picker primitive is reached via `ui:calendar` only." + * So the namespaced spelling is not a stylistic variant of {@link CalendarSchema} + * — it is the only spelling that resolves to this renderer. + * + * Five catalog fixtures under + * `examples/schema-catalog/src/schemas/components-form-calendar/` author it; every + * one rendered and every one was refused by `AnyComponentSchema` until this arm. + * + * Mirror: `zod/form.zod.ts#UiCalendarSchema`. + */ +export interface UiCalendarSchema extends Omit { + type: 'ui:calendar'; +} + /** * Union type of all form schemas */ @@ -1658,5 +1702,7 @@ export type FormComponentSchema = | LabelSchema | ComboboxSchema | CommandSchema - | CodeEditorSchema; + | CodeEditorSchema + | InputShorthandSchema + | UiCalendarSchema; diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index 75d9e5c804..c069db8259 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -923,6 +923,82 @@ export interface PageSlotMap { discussion?: SchemaNode | SchemaNode[]; } +/** + * The seven HTML sectioning tags `packages/components/src/renderers/layout/semantic.tsx` + * registers (objectui#8499). + * + * They are registered, live renderers carrying nine catalog fixtures under + * `examples/schema-catalog/src/schemas/components-layout-semantic/`, and until + * objectui#8499 no arm of `AnyComponentSchema` named any of them — so a document + * that rendered correctly in the browser was refused by `objectui check`. + * + * The renderer is one factory over all seven tags: it renders + * `renderChildren(schema.children || schema.body)` inside the tag and declares + * exactly one authoring input, `className` (a {@link BaseSchema} member). The + * mirror is `zod/layout.zod.ts#SemanticElementSchema`, and + * `__tests__/node-slot-registered-arms-8499.test.ts` compares the tag list below + * against `semantic.tsx`'s own `tags` array. + */ +export interface SemanticElementSchema extends BaseSchema { + type: 'aside' | 'main' | 'header' | 'nav' | 'footer' | 'section' | 'article'; + /** + * Child components — read as `schema.children || schema.body`. + */ + children?: SchemaNode | SchemaNode[]; +} + +/** + * The safe flow/inline HTML passthrough set + * `packages/components/src/renderers/basic/html-elements.tsx` registers + * (objectui#8499). + * + * A `kind:'html'` page is PARSED (never executed) into the SDUI tree, so — in + * that module's own words — "the everyday HTML tags an author reaches for … + * must each resolve to a renderer". They resolved in the renderer registry and + * in no arm of `AnyComponentSchema`; `content/docs/utilities/runner.mdx` teaches + * a document carrying `h1`, and that document rendered and was refused. + * + * ⚠️ The per-tag keys below are declared on the WHOLE set, not per tag, so + * `{ type: 'p', href: '…' }` type-checks. {@link BaseSchema} carries an index + * signature, so it type-checked before this declaration existed too — this + * narrows nothing and gains the author a named surface. `width` / `height` are + * `string | number` rather than the registration's `number`, because the + * renderer forwards them verbatim to the DOM attribute, which takes both. + */ +export interface HtmlElementSchema extends BaseSchema { + type: + | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' + | 'p' | 'a' | 'blockquote' | 'pre' + | 'strong' | 'em' | 'b' | 'i' | 'u' | 'small' | 'mark' | 'sub' | 'sup' | 'del' | 'ins' | 'abbr' + | 'ul' | 'ol' | 'li' | 'dl' | 'dt' | 'dd' + | 'figure' | 'figcaption' | 'img' | 'hr' | 'br' | 'time' | 'address' | 'cite' | 'q'; + /** + * Child components — read as `schema.children ?? schema.body`; ignored for the + * void tags `img` / `hr` / `br`. + */ + children?: SchemaNode | SchemaNode[]; + /** `a` link target; scheme-sanitised (`javascript:` / `data:` / `vbscript:` are dropped). */ + href?: string; + /** `a` browsing context. */ + target?: string; + /** `a` link relationship. */ + rel?: string; + /** Advisory title — declared for `a`, `img` and `abbr`. */ + title?: string; + /** `img` source URL. */ + src?: string; + /** `img` alternative text. */ + alt?: string; + /** `img` width — forwarded verbatim to the DOM attribute. */ + width?: string | number; + /** `img` height — forwarded verbatim to the DOM attribute. */ + height?: string | number; + /** `time` machine-readable datetime. */ + dateTime?: string; + /** `q` / `blockquote` source URL. */ + cite?: string; +} + /** * Union type of all layout schemas */ @@ -943,5 +1019,7 @@ export type LayoutSchema = | ScrollAreaSchema | ResizableSchema | AspectRatioSchema - | PageNodeSchema; + | PageNodeSchema + | SemanticElementSchema + | HtmlElementSchema; diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index 0196ec8a14..a5fc0eb5b0 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -686,6 +686,75 @@ export const FormSchema = BaseSchema.extend({ showActions: z.boolean().optional().describe('Show action buttons'), }); +/** + * Input Shorthand Schema — the `email` / `password` aliases + * `packages/components/src/renderers/form/input.tsx` registers (objectui#8499). + * + * Both are the SAME renderer as `input`, wrapped so that `inputType` is pinned: + * + * ```tsx + * ComponentRegistry.register('password', + * (props) => , …) + * ``` + * + * ⛔ `inputType` is therefore ABSENT from this arm, deliberately, and that + * absence is the whole difference from {@link InputSchema}. The wrapper spreads + * its own value LAST, so an authored `inputType` is silently overwritten; + * declaring it here would publish a key the runtime discards. Write + * `{ type: 'input', inputType: 'email' }` when the input type is the choice. + * + * ⚠️ MEASURED, so the omission is not over-read: `BaseSchema` passes unknown + * keys through, so `{ type: 'password', inputType: 'text' }` still PARSES — + * omitting the key states the contract on the declared face, it does not refuse + * the value. Refusing it by name (the `./tombstone.zod.ts` mechanism) would be + * an accept-set NARROWING in the opposite direction from this card and is + * deliberately left to its own ruling; objectui#8499's report records it. + * + * Every other key is {@link InputSchema}'s, because it is literally the same + * renderer reading the same schema. + */ +export const InputShorthandSchema = InputSchema.omit({ type: true, inputType: true }).extend({ + type: z.enum(['email', 'password']) + .describe('Input shorthand — `renderers/form/input.tsx` pins `inputType` to match'), + // Declared here and not (yet) on {@link InputSchema}, which is the pair + // `__tests__/zod-mirror-parity.test.ts` records as unmirrored for this very key. + // The renderer both arms share reads it — `renderers/form/input.tsx:42`, + // `cn('grid w-full items-center gap-1.5', schema.wrapperClass)` — so declaring it + // is the read site talking. ⛔ Copying the gap into a NEW pair would have minted a + // second ledger row for a key that is demonstrably read; shrinking the existing + // row is `InputSchema`'s own repair (objectui#7722's family) and not this card's. + wrapperClass: z.string().optional() + .describe("Classes on the wrapper div around the input and its label, read at renderers/form/input.tsx:42 — `cn('grid w-full items-center gap-1.5', schema.wrapperClass)`"), +}); + +/** + * Ui Calendar Schema — the date-picker primitive + * `packages/components/src/renderers/form/calendar.tsx` registers, reachable at + * `ui:calendar` ONLY (objectui#8499). + * + * ⚠️ `ui:calendar`, not `calendar`, and the two are different components. That + * registration carries `skipFallback: true` with its reason written beside it: + * + * > `calendar` collides with the plugin-calendar full CRUD calendar VIEW, which + * > owns the bare `type: 'calendar'` schema keyword; this date-picker primitive + * > is reached via `ui:calendar` only. + * + * So the namespaced spelling is not a stylistic variant of {@link CalendarSchema} + * — it is the only spelling that resolves to this renderer. Five catalog + * fixtures under `examples/schema-catalog/src/schemas/components-form-calendar/` + * author it, every one of them rendered and every one of them refused by + * `AnyComponentSchema` until this arm. + * + * The key set is {@link CalendarSchema}'s: `calendar.tsx` reads `schema.mode`, + * `schema.value`, `schema.defaultValue` and `className`, which is what that + * schema already declares — it mirrors this primitive's shape while its own + * literal resolves to the plugin view. + */ +export const UiCalendarSchema = CalendarSchema.extend({ + type: z.literal('ui:calendar') + .describe('The `ui`-namespaced date-picker primitive — `calendar` alone names the plugin-calendar view'), +}); + /** * Form Component Schema Union - All form component schemas */ @@ -733,4 +802,6 @@ export const FormComponentSchema = z.discriminatedUnion('type', [ CommandSchema, FormSchema, CodeEditorSchema, + InputShorthandSchema, + UiCalendarSchema, ]); diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 80c19b1a5d..cc5b6a21db 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -459,6 +459,111 @@ export const PageNodeSchema = BaseSchema.extend(SpecPageFields.shape).extend({ assignedProfiles: z.array(z.string()).optional().describe('Profiles that can access this page'), }); +/** + * Semantic Element Schema — the seven HTML sectioning tags + * `packages/components/src/renderers/layout/semantic.tsx` registers + * (objectui#8499). + * + * ## The defect this closes + * + * These seven are REGISTERED, LIVE renderers with nine catalog fixtures of + * their own under `examples/schema-catalog/src/schemas/components-layout-semantic/`, + * and `AnyComponentSchema` had no arm for any of them. So a document that + * renders correctly in the browser was REFUSED by `objectui check` — the + * expensive direction, because the author's likely reaction is to stop trusting + * the validator rather than to fix the document (objectui#8499 triage). + * + * The two faces disagreed BY CONSTRUCTION: `check:doc-types` judges a `type` + * literal against the RENDERER registry (656 keys at the time of writing), not + * against this union (107 arms), and nothing compared them at a node slot. + * `../__tests__/node-slot-registered-arms-8499.test.ts` compares the literal set + * below against `semantic.tsx`'s own `tags` array, so a tag added there without + * an arm here goes red instead of diverging silently. + * + * ## Why one arm and not seven + * + * The factory in `semantic.tsx` builds ONE component over all seven tags: it + * renders `renderChildren(schema.children || schema.body)` inside the tag and + * declares exactly one authoring input, `className`. Seven arms would restate + * the same shape seven times with no key to tell them apart. + * + * ## What is NOT declared here, and why + * + * Nothing beyond `type` and the child slot. Every other key the factory touches + * — `data-obj-id`, `data-obj-type`, `style` — is a DESIGNER prop injected by the + * renderer host, never authored metadata, and `className` is already a + * {@link BaseSchema} member. Following the `BarChartSchema` discipline (`./data-display.zod.ts`): + * only keys the renderer demonstrably reads, nothing on the strength of what a + * sectioning tag "should" accept. + */ +export const SemanticElementSchema = BaseSchema.extend({ + type: z.enum(['aside', 'main', 'header', 'nav', 'footer', 'section', 'article']) + .describe('HTML sectioning tag — the seven `renderers/layout/semantic.tsx` registers'), + children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional() + .describe('Child components — read as `schema.children || schema.body` by the factory'), +}); + +/** + * Html Element Schema — the safe flow/inline HTML passthrough set + * `packages/components/src/renderers/basic/html-elements.tsx` registers + * (objectui#8499). + * + * ## Why these must resolve + * + * That module's own docblock states the purpose: a `kind:'html'` page is PARSED + * (never executed) into the SDUI tree, so "the everyday HTML tags an author + * reaches for — headings, paragraphs, lists, links, images, emphasis — must each + * resolve to a renderer (otherwise the parser flags them `unknown-component`)". + * They resolved in the RENDERER registry and in no arm of `AnyComponentSchema`, + * which is objectui#8499: `content/docs/utilities/runner.mdx` teaches the reader + * to save a document carrying `h1`, and the document it teaches renders and is + * refused. + * + * ⚠️ One arm over the whole set, deliberately, and it is a real cost: `h1` + * becomes legal at EVERY node slot, including slots where it is absurd (a + * `header-bar`'s `logo`, a `dialog`'s `trigger`). That is not new laxity + * — every one of the 107 pre-existing arms is already legal at every one of the + * 249 declared node slots, because there is exactly ONE node-slot vocabulary in + * this face ({@link SchemaNodeSchema}) and a discriminated union selects its arm + * from the authored literal alone, with no way for a slot to narrow it. A + * per-slot vocabulary is a different programme, not a variant of this arm. + * + * ## The per-tag keys, and why they are typed wider than the registration + * + * `PER_TAG_INPUTS` in that module declares `img`'s `width`/`height` as + * `number`. The renderer FORWARDS them verbatim onto the DOM element, which + * accepts `"100"` as readily as `100`, so a number-only declaration here would + * refuse a document the renderer draws — this card's own defect, rebuilt one + * key down. The declaration follows the read site, not the authoring hint. + * + * ⚠️ The keys are declared on the whole set rather than per tag, so + * `{ type: 'p', href: '…' }` parses. {@link BaseSchema} passes unknown keys + * through, so it parsed before this arm existed too — declaring them narrows + * nothing and gains the author a typed surface. + */ +export const HtmlElementSchema = BaseSchema.extend({ + type: z.enum([ + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'p', 'a', 'blockquote', 'pre', + 'strong', 'em', 'b', 'i', 'u', 'small', 'mark', 'sub', 'sup', 'del', 'ins', 'abbr', + 'ul', 'ol', 'li', 'dl', 'dt', 'dd', + 'figure', 'figcaption', 'img', 'hr', 'br', 'time', 'address', 'cite', 'q', + ]).describe('Safe HTML tag — the set `renderers/basic/html-elements.tsx` registers'), + children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional() + .describe('Child components — read as `schema.children ?? schema.body`; ignored for the void tags `img` / `hr` / `br`'), + href: z.string().optional() + .describe('`a` link target; scheme-sanitised at html-elements.tsx:74 (`javascript:` / `data:` / `vbscript:` are dropped)'), + target: z.string().optional().describe('`a` browsing context — an internal link navigates through the SPA router unless this names another target'), + rel: z.string().optional().describe('`a` link relationship'), + title: z.string().optional().describe('Advisory title — declared for `a`, `img` and `abbr`'), + src: z.string().optional().describe('`img` source URL'), + alt: z.string().optional().describe('`img` alternative text'), + width: z.union([z.string(), z.number()]).optional().describe('`img` width — forwarded verbatim to the DOM attribute'), + height: z.union([z.string(), z.number()]).optional().describe('`img` height — forwarded verbatim to the DOM attribute'), + dateTime: z.string().optional().describe('`time` machine-readable datetime'), + cite: z.string().optional().describe('`q` / `blockquote` source URL'), +}); + /** * Layout Schema Union - All layout component schemas */ @@ -480,4 +585,6 @@ export const LayoutSchema = z.discriminatedUnion('type', [ ResizableSchema, AspectRatioSchema, PageNodeSchema, + SemanticElementSchema, + HtmlElementSchema, ]);