diff --git a/.changeset/11564-flex-bag-children-list.md b/.changeset/11564-flex-bag-children-list.md index e2a9fb6ef9..a509c73404 100644 --- a/.changeset/11564-flex-bag-children-list.md +++ b/.changeset/11564-flex-bag-children-list.md @@ -16,3 +16,5 @@ The type is the flat `FlexSchema` mirror's own `children` member, by reference, What does NOT move: every zod face and every runtime path. `FlexBlockSchema` keeps `z.array(z.unknown())` for the list, because `@objectstack/spec`'s page walk already judges each entry there, once, at its real path, on the tolerant and the strict face (objectui#11223). The one entry kind where the faces now differ is a nested list, which the walk passes through unvisited and the TypeScript face refuses; `@object-ui/types`' mirror-parity ledger records the divergence. **Migration.** Give each list entry its declared node type (or `DeclaredNode`), declare a custom type in `CustomNodeRegistry`, and flatten a nested list into the one list. + +⚠️ **Dated note, 2026-10-04 — `BaseSchema` loses its index signature — objectui#8347.** At this change, "Node types that extend `BaseSchema` keep its index signature until objectui#8347 removes it, so a misspelled key on one of them still compiles" held. Later in this same release objectui#8347 removed that signature, so a misspelled key on a node type that extends `BaseSchema` no longer compiles either, in the list exactly as in a single child. `.changeset/8347-baseschema-closed-face.md` states what ships. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/8347-baseschema-closed-face.md b/.changeset/8347-baseschema-closed-face.md new file mode 100644 index 0000000000..79bb52a605 --- /dev/null +++ b/.changeset/8347-baseschema-closed-face.md @@ -0,0 +1,18 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-kanban': patch +'@object-ui/plugin-timeline': minor +--- + +**`BaseSchema` no longer declares `[key: string]: any`** (objectui#8347, executing the objectui#7927 ruling: the TypeScript face is a contract). Every node type extends `BaseSchema`, so a node literal annotated with its node type now refuses a key that no declaration names, a misspelled key included, where it used to type it `any`. The correct spelling compiles as before. + +**Clause-②: yes (narrowing)**, shipped as `minor` per this repository's version policy. The removal narrows the TypeScript authoring face of every node type; the `visibleWhen` change below widens both faces to the envelope the spec's own parse writes. + +- **What does not move.** The zod faces keep their accept sets for every key but `visibleWhen`: the tolerant mirror is still `.passthrough()`, so `safeValidateSchema` keeps an undeclared key, and the derived strict face refuses it as before. `ComponentRendererProps`, the renderer props type, keeps its own index signature. Nothing a renderer draws changes. +- **The bound.** TypeScript runs its excess-property check only on a fresh object literal. A value that reached its annotation through a variable of a wider type is not re-checked. +- **`PartialSchema` works as written.** With the signature gone, `keyof T` is the literal member union again, so the alias keeps `T`'s declared members, optional, with `type` required. While the signature stood it declared `type` alone (objectui#6397). +- **`BaseSchema.visibleWhen` is the spec's `EvaluatedExpressionInput`**, by reference: a predicate string, or the `{ dialect, source }` envelope. The zod twin takes `EvaluatedExpressionInputSchema`'s verdict without its transform, so a string parses to itself. A dialect-less envelope, an unknown dialect and a blank predicate are refused, as the spec refuses them. Both faces read `string` before, which refused the envelope a spec parse writes into this key. +- **`@object-ui/plugin-kanban`.** `ObjectKanban` reads the `sort` the element data-source gate writes through a read type private to the package. `ObjectKanbanSchema` still declares no `sort` (objectui#8174). Nothing drawn changes. +- **`@object-ui/plugin-timeline`.** `TimelineRenderSchema`, the `schema` prop type of the exported `TimelineRenderer`, gains one optional member: the `onItemClick` slot `ObjectTimeline` composes. That is a one-member optional widening of an exported prop type. `TimelineSchema`, the authoring face, still declares no `onItemClick`. Nothing drawn changes. + +**Migration.** Where a literal stops compiling, the key is misspelled (fix it) or not declared on that node type (declare it on the type that reads it, by reference to the `@objectstack/spec` row, or remove it). Do not cast past the error. `props`, the legacy alias of `properties`, is not declared on the TypeScript face; the renderer still reads it, so write `properties`. diff --git a/AGENTS.md b/AGENTS.md index 57af347790..02bb43170f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,12 +74,12 @@ Every node in the UI tree follows this shape (`@object-ui/types`): interface BaseSchema { type: string; // registry key: 'input', 'grid', 'card' id?: string; // DOM accessibility / event targeting - props?: Record; // element:* config envelope, not a general bag bind?: string; // data binding path: 'user.address.city' className?: string; // Tailwind overrides hidden?: boolean | ExpressionWire; // expression or boolean: "${data.role != 'admin'}" disabled?: boolean | ExpressionWire; // same wire as hidden children?: SchemaNode | SchemaNode[]; // layout slots; primitives admitted too + // no index signature: a key no member declares is a compile error (#0.1) } ``` @@ -90,6 +90,7 @@ interface BaseSchema { - **#-1 — English-only codebase.** This is an international OSS project. All user-facing text (component labels, buttons, titles, errors), code comments, docs (`README.md`, `docs/*.md`), and console/log messages MUST be English. No Chinese or other non-English in those. *(This rule governs the **codebase**; this instruction file may use Chinese in operational sections.)* - **#0 — Strict adherence to `@objectstack/spec`.** All schemas/JSON structures/types MUST follow `@objectstack/spec`. Don't invent schema properties — if the spec says `columns`, don't use `fields`. Check the spec before writing any `interface`/`type`. - **#0.1 — Fix the metadata, not the renderer (contract-first).** Corollary to #0. This is a metadata-driven system: `@objectstack/spec` is the contract between producers and this renderer. When a piece of metadata "doesn't render," ask **first**: *is it spec-compliant? is this the long-term-correct direction?* If the metadata is off-spec, fix it at the **producer** (and have it rejected at authoring/publish) — do **not** add a lenient fallback/alias in the renderer (reading both `columns` and `fields`, coercing a malformed shape, `??`-defaulting around bad input) to make non-compliant metadata "work." A tolerant fallback fossilizes the wrong convention into a second de-facto contract, dilutes the spec, and hides the producer's bug — one strict contract beats N dialects. We own both ends, so Postel's "be liberal in what you accept" does **not** apply (that's for untrusted boundaries). Change the **spec** only when it is genuinely wrong — deliberately, in `@objectstack/spec`, never by accreting renderer-side fallbacks. + - **The TypeScript authoring face is part of the contract (objectui#7927 ruling, executed by objectui#8347).** `BaseSchema` declares no index signature, so a node literal typed with its node type refuses, at the authoring site, a key no declaration names — a misspelling included. When a key you need is refused, this rule applies unchanged: declare it on the node type that reads it, by reference to the spec row, or fix the producer; a key only the host writes at runtime is typed on a package-local hand-off type where its producer and consumer live, never on the authoring face (objectui#6356). ⛔ Never make it compile with `as any`, a cast to `Record`, a re-added index signature or an open `type: string` arm. Tolerance stays where it is deliberate: renderer props (`ComponentRendererProps` keeps its signature) and the tolerant zod face (`.passthrough()`); the derived strict zod face, `StrictAnyComponentSchema`, refuses an unknown key in metadata that arrives as data. ⚠️ The bound, said per #9: TypeScript excess-checks only a FRESH object literal, so a value that reached its annotation through a wider variable is not re-checked. `packages/types/src/__tests__/base-schema-closed-face-8347.test.ts` pins both directions and that bound. - **#1 — Protocol-agnostic.** Never hardcode `objectql.find()`. Use the DataSource interface; inject `dataSource` via ``. - **#2 — Docs-driven.** For every feature/refactor, update package `README.md` **and** `content/docs/guide/*.md`. Not done until docs reflect the code. - **#3 — "Shadcn-native" aesthetics.** We are "serializable Shadcn". Follow Shadcn's DOM structure (`CardHeader`/`CardTitle`/`CardContent`). Always expose `className` in schema props so users can override via JSON. @@ -97,7 +98,7 @@ interface BaseSchema { ```json { "type": "action:button", "properties": { "label": "Open details", "actionType": "url", "target": "/users/ada" } } ``` - ⛔ Never author an `events` bag: `BaseSchema` declares no `events` member and no renderer reads `schema.events` — the node is `.passthrough()`, so one authored there is kept, judged by nothing and run by nothing. `ButtonSchema.onClick` is a runtime slot for a host-supplied function and is refused by name for the same reason. + ⛔ Never author an `events` bag: `BaseSchema` declares no `events` member and no renderer reads `schema.events` — a typed literal refuses it (#0.1), but the tolerant zod face is `.passthrough()`, so one that arrives as data is kept, judged by nothing and run by nothing. `ButtonSchema.onClick` is a runtime slot for a host-supplied function and is refused by name for the same reason. - **#5 — Layout as components.** Treat `Grid`/`Stack`/`Container` as first-class. Layout schemas declare responsive columns on the node as `columns` — a number, or a breakpoint object (`columns: { xs: 1, md: 2, lg: 4 }`); never `cols`, which nothing reads (objectui#4001). - **#6 — Type safety over magic.** No `any` — use strict generics. Map `"type": "button"` → React component via a central `ComponentRegistry`. **No `eval()` / runtime dynamic imports** to load components (security). - **#7 — No-Touch zones (Shadcn purity).** `packages/components/src/ui/**/*.tsx` are upstream 3rd-party files overwritten by sync scripts — **never edit their logic/styles**. To change `Button`/`Dialog` behavior: create/edit a wrapper in `packages/components/src/custom/`, import the primitive from `@/ui/...`, and wrap it. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 062962a7f3..a42fe178e1 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -96,7 +96,7 @@ One row per declared member, in declaration order, so the list can be checked ag | `body` | *retired* | ⛔ Refused by name (objectui#6771). `body` was a second child-list spelling `BaseSchema` declared beside `children`; it is now `never` on the TypeScript face and an alias refusal on the Zod mirror, and the refusal names `children`. | | `children` | `SchemaNode \| SchemaNode[]` | Child components rendered inside this component — the child-list key, and since objectui#6771 the only one. Whether a given node type renders a child list at all is still per component; see the note below. | | `visible` | `boolean \| string \| { dialect?: string; source: string }` | Visibility control. Accepts a boolean, a predicate expression string, **or** the CEL envelope object (`{ dialect: 'cel', source }` — what `objectstack build` emits for every authored predicate) — the renderer evaluates this key rather than reading it as a boolean. The string-or-envelope half is `ExpressionWire`, the one wire type `visibleWhen` on form fields already carries. | -| `visibleWhen` | `string` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Evaluated **before** `visible` and `visibleOn`, and outranks both. | +| `visibleWhen` | `string \| { dialect, source }` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Typed as `@objectstack/spec`'s `EvaluatedExpressionInput`: a predicate string, or the envelope the spec's parse writes (`dialect` is `cel`, `cron` or `template`, and `source` is not blank). Evaluated **before** `visible` and `visibleOn`, and outranks both. | | `visibleOn` | `string` | Expression for conditional visibility. **Deprecated** (ADR-0089) — use `visibleWhen`. | | `hidden` | `boolean \| string \| { dialect?: string; source: string }` | Inverse of `visible` — the node is not rendered. Accepts a boolean, a predicate expression string **or** the CEL envelope object (`ExpressionWire`), which the renderer evaluates rather than reading as a boolean; `hiddenOn` remains the sibling spelling. | | `hiddenOn` | `string` | Expression for conditional hiding. | @@ -109,7 +109,7 @@ Two things the table cannot show in a cell: - **A concrete schema may narrow an inherited member, and its own declaration wins.** Many component schemas restate `label`, `description` or `disabled` more narrowly than `BaseSchema` declares them, so the unions above are what a node gets when its own schema does not restate the key. Check the component's own property table before writing a predicate string or a locale map into an inherited slot. - **⚠️ `body` and `children` WERE two channels, not one key with two spellings — and the second one is retired (objectui#6771).** Each renderer read one, the other, both, or neither, and `SchemaRenderer` strips both out of the props bag it spreads — so writing the channel a renderer did not read rendered an EMPTY element, with no error at authoring time, none at validation time and none at render time. That is the defect objectui#8284 named after seven cards had repaired one page of it each, and it was closed per component by measurement: each schema narrows to the channel its renderer actually reads and **tombstones the other as `never`**, refused by name on both published faces (objectui#9254 for the components that read exactly one channel, objectui#9256 for the ones that read neither). objectui#6771 then closed the class at its source: `children` is the one child-list spelling and `body` is refused on `BaseSchema` itself. ⇒ **whether a node type accepts a child list at all is still per component** — check the component's own section, where a node that renders no children tombstones `children` too. -- **This list is exhaustive for *declared* members, not for *accepted* keys.** `BaseSchema` carries an index signature (`[key: string]: any`) and its Zod mirror is `.passthrough()`, so an undeclared key — a misspelling included — is still accepted by both halves. Absence from this table does not mean a key is rejected. +- **This list is exhaustive for *declared* members; the two faces differ on *accepted* keys.** `BaseSchema` carries no index signature (objectui#8347), so on the TypeScript face an undeclared key, a misspelling included, is a compile error in a typed literal. Its tolerant Zod mirror is still `.passthrough()`, so the rendering face (and `safeValidateSchema`) keeps an undeclared key; the derived strict face, `StrictAnyComponentSchema`, refuses it. Absence from this table does not mean the tolerant face rejects a key. --- diff --git a/content/docs/components/data-display/list.mdx b/content/docs/components/data-display/list.mdx index 7ef6fe1b9b..214e9a7db5 100644 --- a/content/docs/components/data-display/list.mdx +++ b/content/docs/components/data-display/list.mdx @@ -42,7 +42,7 @@ required, so the bind-only list above was refused at `items`. The TypeScript fac `wrapperClass` is now typed. The renderer has read it all along — it lands on the wrapper element around the list — but it went undeclared until objectui#7722, - surviving on `BaseSchema`'s index signature and the zod mirror's `.passthrough()`. + surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's `.passthrough()`. Declared as `string` on both faces, a **non-string** `wrapperClass` that used to parse green is now refused by name — on `ListSchema` and through `safeValidateSchema` alike. Pass the Tailwind class string; a number or an object diff --git a/content/docs/components/form/date-picker.mdx b/content/docs/components/form/date-picker.mdx index 327b6e055f..7e8bb7d687 100644 --- a/content/docs/components/form/date-picker.mdx +++ b/content/docs/components/form/date-picker.mdx @@ -49,7 +49,7 @@ interface DatePickerSchema { `wrapperClass` is now typed. The renderer has read it all along — it lands on the wrapper element around the date picker and its label — but it went undeclared until - objectui#7722, surviving on `BaseSchema`'s index signature and the zod mirror's + objectui#7722, surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's `.passthrough()`. Declared as `string` on both faces, a **non-string** `wrapperClass` that used to parse green is now refused by name — on `DatePickerSchema` and through `safeValidateSchema` alike. Pass the Tailwind class diff --git a/content/docs/components/form/select.mdx b/content/docs/components/form/select.mdx index 7edefd689d..366d652062 100644 --- a/content/docs/components/form/select.mdx +++ b/content/docs/components/form/select.mdx @@ -27,7 +27,7 @@ interface SelectSchema { `wrapperClass` is now typed. The renderer has read it all along — it lands on the wrapper element around the select and its label — but it went undeclared until - objectui#7722, surviving on `BaseSchema`'s index signature and the zod mirror's + objectui#7722, surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's `.passthrough()`. Declared as `string` on both faces, a **non-string** `wrapperClass` that used to parse green is now refused by name — on `SelectSchema` and through `safeValidateSchema` alike. Pass the Tailwind class string; a number or diff --git a/content/docs/components/form/switch.mdx b/content/docs/components/form/switch.mdx index ffb26966f1..ba30663104 100644 --- a/content/docs/components/form/switch.mdx +++ b/content/docs/components/form/switch.mdx @@ -25,7 +25,7 @@ interface SwitchSchema { `wrapperClass` is now typed. The renderer has read it all along — it lands on the wrapper element around the switch and its label — but it went undeclared until - objectui#7722, surviving on `BaseSchema`'s index signature and the zod mirror's + objectui#7722, surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's `.passthrough()`. Declared as `string` on both faces, a **non-string** `wrapperClass` that used to parse green is now refused by name — on `SwitchSchema` and through `safeValidateSchema` alike. Pass the Tailwind class string; a number or diff --git a/content/docs/components/form/textarea.mdx b/content/docs/components/form/textarea.mdx index 4167e2cb2f..48ff61457f 100644 --- a/content/docs/components/form/textarea.mdx +++ b/content/docs/components/form/textarea.mdx @@ -27,7 +27,7 @@ interface TextareaSchema { `wrapperClass` is now typed. The renderer has read it all along — it lands on the wrapper element around the textarea and its label — but it went undeclared until - objectui#7722, surviving on `BaseSchema`'s index signature and the zod mirror's + objectui#7722, surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's `.passthrough()`. Declared as `string` on both faces, a **non-string** `wrapperClass` that used to parse green is now refused by name — on `TextareaSchema` and through `safeValidateSchema` alike. Pass the Tailwind class string; a number or diff --git a/content/docs/core/schema-renderer.mdx b/content/docs/core/schema-renderer.mdx index 8d3d78ea45..cb840b0b8f 100644 --- a/content/docs/core/schema-renderer.mdx +++ b/content/docs/core/schema-renderer.mdx @@ -34,16 +34,20 @@ The SchemaRenderer automatically handles nested schemas: ## Schema Structure ```ts -interface SchemaNode { +// abridged: `BaseSchema` in @object-ui/types carries the full member list +interface BaseSchema { type: string; // Component type (e.g., 'card', 'button', 'text') id?: string; // Unique identifier className?: string; // Tailwind CSS classes - children?: SchemaNode | SchemaNode[]; // Child components - props?: Record; // Component-specific props - [key: string]: any; // Any other component props + children?: BaseSchema | BaseSchema[]; // Child components } ``` +Every node type extends `BaseSchema` with its own keys, and none of them +carries an index signature (objectui#8347): in a typed literal, a key the +node's type does not declare, a misspelling included, is a compile error. +A child in a slot is checked against its own `type`'s declaration. + ## Error Handling When an unknown component type is encountered, SchemaRenderer displays a helpful error message: diff --git a/content/docs/guide/architecture.md b/content/docs/guide/architecture.md index dd78fe4223..19c80b43cc 100644 --- a/content/docs/guide/architecture.md +++ b/content/docs/guide/architecture.md @@ -378,7 +378,7 @@ function handleClick() { const schema: ButtonSchema = { type: 'button', - text: 'Click me', + label: 'Click me', variant: 'default', // ✅ Type-checked onClick: handleClick, // ✅ a handler, not its name — onClick is () => void | Promise } diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 24b48603e9..3db7e70d7b 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -1582,8 +1582,8 @@ trailing comment (which must **not** be reported), an `X as Y` with `X` fabricat in a type import. **Out of scope, deliberately:** compiling the extracted blocks (a separate card — it has pre-existing -reds that need a baseline decision first), and authorable-JSON *key* surfaces, which no type check can -reject while `BaseSchema` carries an index signature and its Zod mirror is `.passthrough()`. +reds that need a baseline decision first), and authorable-JSON *key* surfaces: a JSON block carries no +type annotation, and the Zod mirror `safeValidateSchema` reads is `.passthrough()`. **If it fails:** it names the README, the line of the offending specifier, and which package really exports the name. Run it locally with `pnpm check:readme-exports` after a build, or diff --git a/content/docs/guide/objectos-integration.mdx b/content/docs/guide/objectos-integration.mdx index 169b5c7e36..812cd2a2e3 100644 --- a/content/docs/guide/objectos-integration.mdx +++ b/content/docs/guide/objectos-integration.mdx @@ -95,7 +95,7 @@ objects, views and apps that the server exposes. ```typescript // src/App.tsx -import { SchemaRenderer } from '@object-ui/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; import { ObjectStackAdapter } from '@object-ui/data-objectstack'; // Import required plugins @@ -110,32 +110,42 @@ const dataSource = new ObjectStackAdapter({ }); export function App() { + // The adapter is installed ONCE, by `SchemaRendererProvider` above the + // renderer: every node in the tree, the nested `object-view` included, reads + // it from that context. A `dataSource` prop on `SchemaRenderer` itself would + // only be forwarded to the root component. return ( -
- +
+ -
+ }} + /> +
+
); } ``` @@ -214,12 +224,15 @@ const objectStackAdapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000/api' }); +// The adapter is installed above the renderer by `SchemaRendererProvider` +// (`dataSource={objectStackAdapter}`, from `@object-ui/react`), never written on +// the node: a node's `dataSource` is the spec's per-element binding, not an adapter. const sysUserView: ObjectViewSchema = { type: 'object-view', objectName: 'sys_user', - dataSource: objectStackAdapter, defaultViewType: 'grid', - columns: ['username', 'email', 'role', 'status', 'last_login'] + // The grid's own configuration rides the `table` slot. + table: { columns: ['username', 'email', 'role', 'status', 'last_login'] } }; ``` diff --git a/content/docs/plugins/plugin-calendar.mdx b/content/docs/plugins/plugin-calendar.mdx index fc839f9f32..dcbf6e455c 100644 --- a/content/docs/plugins/plugin-calendar.mdx +++ b/content/docs/plugins/plugin-calendar.mdx @@ -431,10 +431,16 @@ anywhere in the package. ## Event Handling +A click handler is a function, so it is a prop of the `ObjectCalendar` +component a React host renders, never a key on the node: +`ObjectCalendarSchema` declares no `onEventClick` or `onDateClick`, and +authored JSON can never carry a function anyway. + ### Event Click ```tsx -import type { ObjectCalendarSchema } from '@object-ui/types'; +import { ObjectCalendar } from '@object-ui/plugin-calendar'; +import type { DataSource, ObjectCalendarSchema } from '@object-ui/types'; const eventClickCalendar: ObjectCalendarSchema = { type: 'object-calendar', @@ -442,19 +448,28 @@ const eventClickCalendar: ObjectCalendarSchema = { calendar: { startDateField: 'start', titleField: 'title' - }, - onEventClick: (record: any) => { - console.log('Event clicked:', record); - // Open event details - // Navigate to event page } }; + +export function EventClickCalendar({ dataSource }: { dataSource: DataSource }) { + return ( + { + console.log('Event clicked:', record); + // Open event details, or navigate to the event page + }} + /> + ); +} ``` ### Date Click ```tsx -import type { ObjectCalendarSchema } from '@object-ui/types'; +import { ObjectCalendar } from '@object-ui/plugin-calendar'; +import type { DataSource, ObjectCalendarSchema } from '@object-ui/types'; const dateClickCalendar: ObjectCalendarSchema = { type: 'object-calendar', @@ -462,12 +477,21 @@ const dateClickCalendar: ObjectCalendarSchema = { calendar: { startDateField: 'start', titleField: 'title' - }, - onDateClick: (date: Date) => { - console.log('Date clicked:', date); - // Create new event on this date } }; + +export function DateClickCalendar({ dataSource }: { dataSource: DataSource }) { + return ( + { + console.log('Date clicked:', date); + // Create a new event on this date + }} + /> + ); +} ``` ## Examples @@ -485,11 +509,9 @@ const appointmentCalendar: ObjectCalendarSchema = { endDateField: 'appointmentEnd', titleField: 'patientName', colorField: 'appointmentType' - }, - onEventClick: (appointment: any) => { - // Show appointment details } }; +// Show appointment details from the component's `onEventClick` prop (see Event Handling). ``` ### Event Management @@ -505,14 +527,9 @@ const eventCalendar: ObjectCalendarSchema = { endDateField: 'eventEnd', titleField: 'eventTitle', colorField: 'eventCategory' - }, - onEventClick: (event: any) => { - // Navigate to event details - }, - onDateClick: (date: Date) => { - // Create new event } }; +// Navigate from `onEventClick` and create from `onDateClick`, both component props (see Event Handling). ``` ### Task Deadlines @@ -527,11 +544,9 @@ const taskCalendar: ObjectCalendarSchema = { startDateField: 'dueDate', titleField: 'taskTitle', colorField: 'priority' - }, - onEventClick: (task: any) => { - // Open task details } }; +// Open task details from the component's `onEventClick` prop (see Event Handling). ``` ## Direct Component Usage diff --git a/content/docs/plugins/plugin-chatbot.mdx b/content/docs/plugins/plugin-chatbot.mdx index c4bcfaf140..c8ea06e8d6 100644 --- a/content/docs/plugins/plugin-chatbot.mdx +++ b/content/docs/plugins/plugin-chatbot.mdx @@ -147,9 +147,9 @@ Each registration has its own importable authoring-face type in nineteen shared rows below are one declaration the two newer faces pick off `ChatbotSchema` by name, and a scoped row is declared only on the face(s) whose registration reads it. A key a registration ignores is therefore not a declared -member of its type. It still type-checks (`BaseSchema` ends in an index -signature, so an unlisted key is `any` rather than an error) and still parses -(the Zod twins are `.passthrough()`). It is then dropped silently at render +member of its type. In a typed literal it is a compile error (objectui#8347 +removed the `BaseSchema` index signature that typed an unlisted key `any`), but +it still parses (the Zod twins are `.passthrough()`). It is then dropped silently at render time, on all three registrations alike. `chatbot-floating` used to be the exception: its registration ended its panel element with an unfiltered props spread, so three keys its type does not declare (`processVisibility`, `surface`, diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index ab5cd7041b..99b8479bf1 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -445,9 +445,11 @@ The package README has the full rule, under "What an edit save writes". ### Form with Validation One `validation` object per field, keyed by rule name. The annotation is part of -the example: `FormField` and `FormSchema` both carry an index signature, so an -un-annotated `const schema = { … }` type-checks whatever is written in it, while -`const signUpForm: FormSchema` makes a wrong `validation` shape a compile error. +the example: an un-annotated `const schema = { … }` type-checks whatever is +written in it, while `const signUpForm: FormSchema` makes a wrong `validation` +shape a compile error, and a misspelled form-level key too (`FormSchema` +carries no index signature since objectui#8347; a `FormField` entry still +carries its own). ```typescript import type { FormSchema } from '@object-ui/types'; diff --git a/content/docs/plugins/plugin-map.mdx b/content/docs/plugins/plugin-map.mdx index f2202e9e20..be0bf6d72b 100644 --- a/content/docs/plugins/plugin-map.mdx +++ b/content/docs/plugins/plugin-map.mdx @@ -130,8 +130,8 @@ const schema: ObjectMapBlockNode = { navigation?: NavigationConfig, // What a marker click opens (see Marker click navigation) mapStyle?: string // MapLibre style URL; read before `map.style` }, - onMarkerClick?: (record: any) => void, // A host callback: on the node, in code only className?: string + // `onMarkerClick` is no node key: a React host passes it as a prop (see Marker Click) } ``` @@ -354,13 +354,15 @@ A click handler from a parent view (`onRowClick`) outranks the whole key. ### Marker Click `onMarkerClick` is a function, so it is no member of the `properties` bag (the -spec's row declares none): a schema built in code carries it on the node, beside -the bag. +spec's row declares none) and no key of the node either: a React host passes it +as a prop, and `SchemaRenderer` forwards it to the map. `ObjectMapBlockNode` is +the node with its bag closed, so a misspelled key inside it is a compile error. ```tsx -import type { BaseSchema } from '@object-ui/types'; +import { SchemaRenderer } from '@object-ui/react'; +import type { ObjectMapBlockNode } from '@object-ui/types'; -const clickableMap: BaseSchema = { +const clickableMap: ObjectMapBlockNode = { type: 'object-map', properties: { objectName: 'locations', @@ -369,14 +371,20 @@ const clickableMap: BaseSchema = { longitudeField: 'lng', titleField: 'name' } - }, - onMarkerClick: (location: Record) => { - console.log('Marker clicked:', location); - // Show location details - // Navigate to location page - // Open directions } }; + +export function ClickableMap() { + return ( + ) => { + console.log('Marker clicked:', location); + // Show location details, navigate to the location page, or open directions + }} + /> + ); +} ``` ## Examples @@ -384,9 +392,9 @@ const clickableMap: BaseSchema = { ### Store Locator ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectMapBlockNode } from '@object-ui/types'; -const storeLocator: BaseSchema = { +const storeLocator: ObjectMapBlockNode = { type: 'object-map', properties: { objectName: 'retail_locations', @@ -398,20 +406,17 @@ const storeLocator: BaseSchema = { zoom: 10, center: [37.7749, -122.4194] // San Francisco } - }, - onMarkerClick: (store: Record) => { - // Show store details - // Display hours, phone, etc. } } +// Store details (hours, phone) come from `onMarkerClick`, a prop the host passes (see Marker Click). ``` ### Delivery Tracking ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectMapBlockNode } from '@object-ui/types'; -const deliveryMap: BaseSchema = { +const deliveryMap: ObjectMapBlockNode = { type: 'object-map', properties: { objectName: 'active_deliveries', @@ -421,20 +426,17 @@ const deliveryMap: BaseSchema = { titleField: 'driver_name', descriptionField: 'delivery_address' } - }, - onMarkerClick: (delivery: Record) => { - // Show delivery details - // Contact driver } } +// Delivery details and contacting the driver come from `onMarkerClick`, a prop the host passes (see Marker Click). ``` ### Real Estate Listings ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectMapBlockNode } from '@object-ui/types'; -const propertyMap: BaseSchema = { +const propertyMap: ObjectMapBlockNode = { type: 'object-map', properties: { objectName: 'properties', @@ -445,12 +447,9 @@ const propertyMap: BaseSchema = { descriptionField: 'property_details', zoom: 12 } - }, - onMarkerClick: (property: Record) => { - // Show property details - // Display photos, price, etc. } } +// Property details (photos, price) come from `onMarkerClick`, a prop the host passes (see Marker Click). ``` ### Event Venues @@ -489,20 +488,17 @@ const venueMap = { descriptionField: 'details', zoom: 11 } - }, - onMarkerClick: (venue: Record) => { - // Show venue details - // Book venue } } +// Venue details and booking come from `onMarkerClick`, a prop the host passes (see Marker Click). ``` ### Field Service Map ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectMapBlockNode } from '@object-ui/types'; -const serviceMap: BaseSchema = { +const serviceMap: ObjectMapBlockNode = { type: 'object-map', properties: { objectName: 'service_calls', @@ -513,13 +509,9 @@ const serviceMap: BaseSchema = { descriptionField: 'service_type', zoom: 10 } - }, - onMarkerClick: (serviceCall: Record) => { - // Show service call details - // Assign technician - // Get directions } } +// Service-call details, assigning a technician and directions come from `onMarkerClick`, a prop the host passes (see Marker Click). ``` ## Typical Use Cases diff --git a/examples/schema-catalog/test/aspect-ratio-demo-content-6773.test.tsx b/examples/schema-catalog/test/aspect-ratio-demo-content-6773.test.tsx index d74976f330..e0df317e9d 100644 --- a/examples/schema-catalog/test/aspect-ratio-demo-content-6773.test.tsx +++ b/examples/schema-catalog/test/aspect-ratio-demo-content-6773.test.tsx @@ -38,9 +38,9 @@ * and an empty ratio box clears it on the wrapper Radix draws for the ratio * itself. Its stronger control — the entry's own authored strings on screen — * is scoped to `NEWLY_REGISTERED_CATEGORIES`, which this family is not in. - * Nor could a parse have caught it: `BaseSchema` is `.passthrough()` and - * carries `[key: string]: any`, so `content` is accepted by zod and by tsc - * alike (the objectui#6157 class-3 shape). `check-doc-component-types.mjs` + * Nor could a parse have caught it: `BaseSchema` is `.passthrough()`, so + * `content` is accepted by zod (and was by tsc, through `[key: string]: any`, + * until objectui#8347) (the objectui#6157 class-3 shape). `check-doc-component-types.mjs` * rules the question out by name — "NOT in scope, deliberately: whether the * snippet's OTHER keys are read by the renderer the type resolves to". * diff --git a/examples/schema-catalog/test/badge-demo-label-6829.test.tsx b/examples/schema-catalog/test/badge-demo-label-6829.test.tsx index b488220534..517ecc6cb0 100644 --- a/examples/schema-catalog/test/badge-demo-label-6829.test.tsx +++ b/examples/schema-catalog/test/badge-demo-label-6829.test.tsx @@ -93,8 +93,8 @@ * * Arm A restored four demos and did not close the class: `children` was * declared on `BaseSchema`, accepted by every other container renderer, - * refused by neither zod nor tsc (`BaseSchema` is `.passthrough()` with an - * index signature), and consumed by the pipeline before it could leak to the + * refused by neither zod nor tsc (`BaseSchema` was `.passthrough()` with an + * index signature then), and consumed by the pipeline before it could leak to the * DOM — so the next author who wrote `children` on a badge drew an empty pill * again. Arm B (teaching `badge.tsx` to read `children`) widened a published * renderer's read set, which AGENTS.md #0.1 governs, and was left to diff --git a/examples/schema-catalog/test/button-group-retired-keys-7077.test.ts b/examples/schema-catalog/test/button-group-retired-keys-7077.test.ts index 231a2f0f53..9f09eafe5c 100644 --- a/examples/schema-catalog/test/button-group-retired-keys-7077.test.ts +++ b/examples/schema-catalog/test/button-group-retired-keys-7077.test.ts @@ -15,8 +15,8 @@ * All six fixtures in this category authored keys the shipped types do not * declare — `buttons[].value` ×17, `buttons[].icon` ×8, group-level `value` ×2 * and `selectionMode` ×2, 29 occurrences over six files. Nothing went red: - * `BaseSchema` is `.passthrough()` and carries `[key: string]: any`, so all 29 - * PARSED GREEN and type-checked — admitted unexamined, not refused (the reading + * `BaseSchema` is `.passthrough()` and carried `[key: string]: any` until + * objectui#8347, so all 29 PARSED GREEN and type-checked — admitted unexamined, not refused (the reading * `component-fixture-declared-keys.test.ts` and * `undeclared-but-consumed-keys-6150.test.ts` both record), and * `catalog-gallery-render.test.tsx` fails only on an unregistered `type`. So the diff --git a/examples/schema-catalog/test/card-demo-content-6788.test.tsx b/examples/schema-catalog/test/card-demo-content-6788.test.tsx index 9e716f04f9..8c0097be64 100644 --- a/examples/schema-catalog/test/card-demo-content-6788.test.tsx +++ b/examples/schema-catalog/test/card-demo-content-6788.test.tsx @@ -72,8 +72,8 @@ * 1. DECLARED — read off the shipped `CardSchema`'s own zod shape, not a * hand-copied list, so it follows the platform instead of yesterday's * vocabulary. `.success` is NOT the probe here and could not be: - * `BaseSchema` is `.passthrough()` and carries `[key: string]: any`, so - * `content` parses green and type-checks. The structural read is the only + * `BaseSchema` is `.passthrough()`, so `content` parses green (and it + * type-checked through `[key: string]: any` until objectui#8347). The structural read is the only * instrument that sees it. * 2. READ — the keys `card.tsx` reads, copied as literals on purpose: they * are the contract this file is about, and a renderer that starts reading diff --git a/examples/schema-catalog/test/catalog-authored-key-6805-6806.test.tsx b/examples/schema-catalog/test/catalog-authored-key-6805-6806.test.tsx index e86bb0fed8..3afe8018f8 100644 --- a/examples/schema-catalog/test/catalog-authored-key-6805-6806.test.tsx +++ b/examples/schema-catalog/test/catalog-authored-key-6805-6806.test.tsx @@ -104,9 +104,9 @@ * is objectui#6810, an open `needs-user-decision` card; this file deliberately * covers only the two renderers these two cards name. * - * Nor could a parse have caught either: `BaseSchema` is `.passthrough()` and - * carries `[key: string]: any`, so `content` is accepted by zod and by tsc - * alike. `check-doc-component-types.mjs` rules the question out by name. + * Nor could a parse have caught either: `BaseSchema` is `.passthrough()`, so + * `content` is accepted by zod (and was by tsc, through `[key: string]: any`, + * until objectui#8347). `check-doc-component-types.mjs` rules the question out by name. * * Module-scope import of `@object-ui/components`, not `beforeAll` (AGENTS.md * §测试纪律): registering the renderers is an unbounded module load and must diff --git a/examples/schema-catalog/test/component-fixture-declared-keys.test.ts b/examples/schema-catalog/test/component-fixture-declared-keys.test.ts index 931a192052..cdd135d3ee 100644 --- a/examples/schema-catalog/test/component-fixture-declared-keys.test.ts +++ b/examples/schema-catalog/test/component-fixture-declared-keys.test.ts @@ -37,8 +37,9 @@ * is therefore round-trip equality, not `.success`. * 3. **Declared-elsewhere, refused by neither** — `direction` on a * `radio-group`. `BaseSchema` is `.passthrough()` (`zod/base.zod.js:171`) - * AND carries `[key: string]: any` (`base.d.ts`), so zod and tsc both - * ACCEPT the key. The authority is that `RadioGroupSchema` declares + * AND carried `[key: string]: any` (`base.d.ts`) until objectui#8347, so + * zod and tsc both ACCEPTED the key (tsc refuses it in a typed literal + * now). The authority is that `RadioGroupSchema` declares * `orientation` (`form.d.ts:377`, `zod/form.zod.js:263`) and nothing reads * `direction`. The probe must be structural — asserting `.success` here * would assert nothing at all. diff --git a/examples/schema-catalog/test/kanban-column-cards-6939.test.tsx b/examples/schema-catalog/test/kanban-column-cards-6939.test.tsx index f04555e72d..47a9cf2fb4 100644 --- a/examples/schema-catalog/test/kanban-column-cards-6939.test.tsx +++ b/examples/schema-catalog/test/kanban-column-cards-6939.test.tsx @@ -259,9 +259,9 @@ describe('objectui#6939 — the mirror now accepts the spelling every board read // is STILL green, which is the finding that card had to make rather than // the one it was sent to make. Two corrections to the paragraph above: // - // 1. declaring on this face NARROWS, it does not widen — `BaseSchema` - // carries `[key: string]: any` / `.passthrough()`, so a declaration - // can only add validation where there was none; + // 1. declaring on this face NARROWED, it did not widen — `BaseSchema` + // carried `[key: string]: any` / `.passthrough()` then, so a + // declaration could only add validation where there was none; // 2. `columns[].items` was never refused BY NAME even on the retired // arm. `KanbanColumnSchema` is a plain (strip-postured) object, so // `items` was accepted and dropped there too; what refused the diff --git a/packages/components/src/__tests__/action-bar.test.tsx b/packages/components/src/__tests__/action-bar.test.tsx index d8f3f54b8f..43b631be9a 100644 --- a/packages/components/src/__tests__/action-bar.test.tsx +++ b/packages/components/src/__tests__/action-bar.test.tsx @@ -11,8 +11,8 @@ import type { ActionBarSchema } from '@object-ui/types'; /** * Types each literal below as the `action:bar` node it is rather than as the - * `BaseSchema` `renderComponent` accepts (objectui#11347): once `BaseSchema`'s - * index signature is gone (objectui#8347), a literal checked against + * `BaseSchema` `renderComponent` accepts (objectui#11347): since objectui#8347 + * removed `BaseSchema`'s index signature, a literal checked against * `BaseSchema` may author only `BaseSchema`'s own keys. */ const actionBar = (schema: ActionBarSchema): ActionBarSchema => schema; diff --git a/packages/components/src/__tests__/alias-precedence-cross-channel.test.tsx b/packages/components/src/__tests__/alias-precedence-cross-channel.test.tsx index 6a35c361e1..63a2b925b2 100644 --- a/packages/components/src/__tests__/alias-precedence-cross-channel.test.tsx +++ b/packages/components/src/__tests__/alias-precedence-cross-channel.test.tsx @@ -81,6 +81,7 @@ describe('alias precedence across the two renderer families (objectui#5123)', () schema={{ type: 'badge', label: 'b', + // @ts-expect-error — `props` is not on the TS authoring face (objectui#8347, the `props` alias, option A); the runtime alias read is what this test measures props: { title: 'FROM_PROPS' }, properties: { title: 'FROM_PROPERTIES' }, }} @@ -138,7 +139,12 @@ describe('alias precedence across the two renderer families (objectui#5123)', () const badge = render( ); expect( diff --git a/packages/components/src/__tests__/basic-renderers.test.tsx b/packages/components/src/__tests__/basic-renderers.test.tsx index 704553caf3..9893221fdc 100644 --- a/packages/components/src/__tests__/basic-renderers.test.tsx +++ b/packages/components/src/__tests__/basic-renderers.test.tsx @@ -30,7 +30,7 @@ import type { /** * Each literal below is typed as the node it is rather than as the * `BaseSchema` `renderComponent` accepts, children included (objectui#11347): - * once `BaseSchema`'s index signature is gone (objectui#8347), a literal + * since objectui#8347 removed `BaseSchema`'s index signature, a literal * checked against `BaseSchema` or `SchemaNode` may author only `BaseSchema`'s * own keys. */ diff --git a/packages/components/src/__tests__/data-table-row-click.test.tsx b/packages/components/src/__tests__/data-table-row-click.test.tsx index 32cbcc031c..f953fe016d 100644 --- a/packages/components/src/__tests__/data-table-row-click.test.tsx +++ b/packages/components/src/__tests__/data-table-row-click.test.tsx @@ -29,8 +29,8 @@ import type { DataTableSchema } from '@object-ui/types'; /** * Types a literal below as the node it is rather than as the `BaseSchema` - * `renderComponent` accepts (objectui#11347): once `BaseSchema`'s index - * signature is gone (objectui#8347), a literal checked against `BaseSchema` + * `renderComponent` accepts (objectui#11347): since objectui#8347 removed + * `BaseSchema`'s index signature, a literal checked against `BaseSchema` * may author only `BaseSchema`'s own keys. */ const dataTableNode = (schema: DataTableSchema): DataTableSchema => schema; diff --git a/packages/components/src/__tests__/empty-action-declared-7105.test.tsx b/packages/components/src/__tests__/empty-action-declared-7105.test.tsx index be9d2a61de..2f3960ce3d 100644 --- a/packages/components/src/__tests__/empty-action-declared-7105.test.tsx +++ b/packages/components/src/__tests__/empty-action-declared-7105.test.tsx @@ -22,7 +22,7 @@ * `className`, so the designer could not offer the key either; * * only the docs row said the capability existed. * - * The read compiled because `BaseSchema` ends in `[key: string]: any`, and the + * The read compiled because `BaseSchema` ended in `[key: string]: any`, and the * renderer spelled it `(schema as any).action` — which is also why * objectui#6150's census, scanning for `schema.KEY`, could not see this reader. * @@ -107,7 +107,7 @@ const DEMO_ACTION: ButtonSchema = { variant: 'default', }; // `DEMO_ACTION` is typed as the `button` node it is (objectui#11347): the slot is -// `SchemaNode`, and once `BaseSchema`'s index signature is gone (objectui#8347) +// `SchemaNode`, and since objectui#8347 removed `BaseSchema`'s index signature // a literal checked against it may author only `BaseSchema`'s own keys. const DEMO = { type: 'empty', diff --git a/packages/components/src/__tests__/form-renderers.test.tsx b/packages/components/src/__tests__/form-renderers.test.tsx index 5371a8e0c1..3f4e37fc7d 100644 --- a/packages/components/src/__tests__/form-renderers.test.tsx +++ b/packages/components/src/__tests__/form-renderers.test.tsx @@ -30,8 +30,8 @@ import type { /** * Types a literal below as the node it is rather than as the `BaseSchema` - * `renderComponent` accepts, children included (objectui#11347): once - * `BaseSchema`'s index signature is gone (objectui#8347), a literal checked + * `renderComponent` accepts, children included (objectui#11347): since + * objectui#8347 removed `BaseSchema`'s index signature, a literal checked * against `BaseSchema` or `SchemaNode` may author only `BaseSchema`'s own keys. * The literals that deliberately author a retired or undeclared key stay * untyped, on purpose. @@ -397,6 +397,7 @@ describe('Form Renderers - Display Issue Detection', () => { // `@object-ui/types` retire it, so a node carrying only `content` renders no text. const { container } = renderComponent({ type: 'label', + // @ts-expect-error — retired on `label` (objectui#6152), and `BaseSchema` declares no `content` and no index signature (objectui#8347) content: 'Retired Spelling', }); diff --git a/packages/components/src/__tests__/list-title-declared-11347.test.tsx b/packages/components/src/__tests__/list-title-declared-11347.test.tsx index 5ebd3b09f2..ac9591118f 100644 --- a/packages/components/src/__tests__/list-title-declared-11347.test.tsx +++ b/packages/components/src/__tests__/list-title-declared-11347.test.tsx @@ -12,7 +12,7 @@ * The renderer has read `schema.title` all along, drawing it as a heading above * the list, and the `list` registration publishes it as an input. Until * objectui#11347 no declaration carried it, so it survived only on - * `BaseSchema`'s index signature, which objectui#8347 removes. The installed + * `BaseSchema`'s index signature, which objectui#8347 removed. The installed * `@objectstack/spec` has no row for `list`, and a published producer authors * the key: the `skills/objectui` expressions guide teaches * `{ "type": "list", "title": "Team", "ordered": true, ... }`. So the key is diff --git a/packages/components/src/__tests__/page-body-single-node-8310.test.tsx b/packages/components/src/__tests__/page-body-single-node-8310.test.tsx index 21b5294d76..f8ef211124 100644 --- a/packages/components/src/__tests__/page-body-single-node-8310.test.tsx +++ b/packages/components/src/__tests__/page-body-single-node-8310.test.tsx @@ -32,13 +32,15 @@ * `packages/types/src/__tests__/page-body-arity-8310.test.ts`. * - **objectui#8912 — evidence, and it is RENDER OUTPUT.** The three * `statistic` labels the flagship authors must appear in the rendered tree. - * ⛔ No compile-time check can stand in for this leg: `BaseSchema` is - * `.passthrough()` with an `[key: string]: any` index signature, so the - * defective key validated, type-checked, rode onto the node and drew nothing. + * ⛔ No compile-time check could stand in for this leg when it was written: + * `BaseSchema` was `.passthrough()` with an `[key: string]: any` index + * signature, so the defective key validated, type-checked, rode onto the + * node and drew nothing. * `check:doc-types` only asks whether a fence's `type` literal is registered - * (`grid` is), and `check:doc-examples` compiles against that same index - * signature. Both are green on the defect BY DESIGN (objectui#4823), so a - * green from either is not a reading of this card. + * (`grid` is), and `check:doc-examples` compiled against that same index + * signature (objectui#8347 removed it). Both were green on the defect BY + * DESIGN (objectui#4823), so a green from either is not a reading of this + * card. * * ## ⚠️ Why the negative-space leg renders the OLD spelling * diff --git a/packages/components/src/__tests__/required-marker-markup.test.tsx b/packages/components/src/__tests__/required-marker-markup.test.tsx index c07d582f0d..dbe0ff9a6e 100644 --- a/packages/components/src/__tests__/required-marker-markup.test.tsx +++ b/packages/components/src/__tests__/required-marker-markup.test.tsx @@ -58,8 +58,8 @@ import type { CheckboxSchema, ElementTextInputNode, InputSchema, SelectSchema, T /** * Types a literal below as the node it is rather than as the `BaseSchema` - * `renderComponent` accepts (objectui#11347): once `BaseSchema`'s index - * signature is gone (objectui#8347), a literal checked against `BaseSchema` + * `renderComponent` accepts (objectui#11347): since objectui#8347 removed + * `BaseSchema`'s index signature, a literal checked against `BaseSchema` * may author only `BaseSchema`'s own keys. */ const inputNode = (schema: InputSchema): InputSchema => schema; diff --git a/packages/components/src/__tests__/span-children-rendering.test.tsx b/packages/components/src/__tests__/span-children-rendering.test.tsx index 2345834e4f..33b9e0f921 100644 --- a/packages/components/src/__tests__/span-children-rendering.test.tsx +++ b/packages/components/src/__tests__/span-children-rendering.test.tsx @@ -27,10 +27,12 @@ * fossilize a second de-facto contract for the one type whose declaration never * named it (Commandment #0.1). The third case below pins that. * - * Note on what the type surface can and cannot say: `body` was never a type - * ERROR on a span, because `BaseSchema` declares both child keys and carries an - * index signature. So the read side is the only place this can be stated, which - * is exactly why it is stated here rather than left to review. + * Note on what the type surface could and could not say when this was written: + * `body` was not a type ERROR on a span, because `BaseSchema` declared both + * child keys and carried an index signature (`body` has since become a + * `?: never` tombstone, and objectui#8347 removed the signature). So the read + * side was the only place this could be stated, which is exactly why it is + * stated here rather than left to review. */ import { describe, it, expect } from 'vitest'; @@ -44,8 +46,8 @@ import '../renderers'; /** * Types a child literal below as the `text` node it is (objectui#11347): a - * `children` entry is checked against `SchemaNode`, and once `BaseSchema`'s - * index signature is gone (objectui#8347) a literal checked against it may + * `children` entry is checked against `SchemaNode`, and since objectui#8347 + * removed `BaseSchema`'s index signature a literal checked against it may * author only `BaseSchema`'s own keys. */ const textNode = (schema: TextSchema): TextSchema => schema; diff --git a/packages/components/src/__tests__/span-value-fallback.test.tsx b/packages/components/src/__tests__/span-value-fallback.test.tsx index e621594a81..ef8ecf5a54 100644 --- a/packages/components/src/__tests__/span-value-fallback.test.tsx +++ b/packages/components/src/__tests__/span-value-fallback.test.tsx @@ -34,9 +34,11 @@ * position. `body` stays refused — see `span-children-rendering.test.tsx`; it is * declared nowhere for this type, whereas `value` is declared twice. * - * What the type surface can and cannot say, same as on #5027: `BaseSchema` - * carries an index signature, so no spelling here is ever a TS error. The read - * side is the only place the contract can be stated, which is why it is pinned + * What the type surface could and could not say when this was written, same as + * on #5027: `BaseSchema` carried an index signature, so no spelling here was a + * TS error (objectui#8347 removed it, so an undeclared spelling in a typed + * literal is one now). The read side is the only place the precedence contract + * can be stated, which is why it is pinned * rather than left to review. */ @@ -51,8 +53,8 @@ import '../renderers'; /** * Types a child literal below as the `text` node it is (objectui#11347): a - * `children` entry is checked against `SchemaNode`, and once `BaseSchema`'s - * index signature is gone (objectui#8347) a literal checked against it may + * `children` entry is checked against `SchemaNode`, and since objectui#8347 + * removed `BaseSchema`'s index signature a literal checked against it may * author only `BaseSchema`'s own keys. */ const textNode = (schema: TextSchema): TextSchema => schema; diff --git a/packages/components/src/renderers/complex/dataTableBindDiagnostic.ts b/packages/components/src/renderers/complex/dataTableBindDiagnostic.ts index 2a11e8af7a..9741ff76d2 100644 --- a/packages/components/src/renderers/complex/dataTableBindDiagnostic.ts +++ b/packages/components/src/renderers/complex/dataTableBindDiagnostic.ts @@ -33,8 +33,8 @@ * EMPTY_ROWS` off the node, and the file calls no such hook. * * A `bind` on a `data-table` is nevertheless accepted by every gate: the TS - * side by `BaseSchema`'s `[key: string]: any`, the zod side by `BaseSchema` - * being `.passthrough()` (which `DataTableSchema.extend(…)` inherits). So the + * side because `BaseSchema` declares `bind` for every node (objectui#6357), the + * zod side by `BaseSchema` being `.passthrough()` (which `DataTableSchema.extend(…)` inherits). So the * author gets a table drawing a correct-looking header over the "No results * found" empty state, with no error, no warning and no diagnostic — the * hardest failure shape for a human OR an AI author to self-check, because a diff --git a/packages/components/src/renderers/form/__tests__/select-label-association.test.tsx b/packages/components/src/renderers/form/__tests__/select-label-association.test.tsx index 7e5bbd85d8..b022663af2 100644 --- a/packages/components/src/renderers/form/__tests__/select-label-association.test.tsx +++ b/packages/components/src/renderers/form/__tests__/select-label-association.test.tsx @@ -39,8 +39,8 @@ import type { SelectSchema } from '@object-ui/types'; /** * Types a literal below as the node it is rather than as the `BaseSchema` - * `renderComponent` and `SchemaRenderer` accept (objectui#11347): once `BaseSchema`'s index - * signature is gone (objectui#8347), a literal checked against `BaseSchema` + * `renderComponent` and `SchemaRenderer` accept (objectui#11347): since objectui#8347 removed + * `BaseSchema`'s index signature, a literal checked against `BaseSchema` * may author only `BaseSchema`'s own keys. */ const selectNode = (schema: SelectSchema): SelectSchema => schema; diff --git a/packages/plugin-calendar/README.md b/packages/plugin-calendar/README.md index 58a4e05208..69b645eee4 100644 --- a/packages/plugin-calendar/README.md +++ b/packages/plugin-calendar/README.md @@ -194,10 +194,16 @@ const backwards: CalendarViewNode = shipped; > **What those two assignments do and do not buy.** They check every key's > **type**, in both directions. They do **not** check a key's **spelling**: -> `CalendarViewSchema` extends `BaseSchema`, whose `[key: string]: any` accepts -> any name, so a key invented or misspelled here is `any` rather than an error -> (objectui#7927). Key-level validity is the strict `@objectstack/spec` twin's -> question, not TypeScript's. +> both assign a declared variable, not a fresh object literal, and TypeScript +> runs its excess-property check only on a fresh literal. So a key renamed in +> the listing is an extra optional member on one side and simply absent on the +> other, and both assignments still compile. A fresh literal IS checked: +> `CalendarViewSchema` extends `BaseSchema`, which carries no index signature +> since objectui#8347, so +> `const node: CalendarViewSchema = { type: 'calendar-view', titleFieldd: 'name' }` +> is a compile error. Metadata that arrives as data is a question for the zod +> faces (the strict `StrictAnyComponentSchema` refuses an unknown key), not for +> TypeScript. There is deliberately **no authorable `events` key**: the renderer computes its events from `data` plus the field-name keys, and drops an authored `events` @@ -297,11 +303,11 @@ import type { ObjectCalendarSchema } from '@object-ui/types'; // What this annotation buys, and what it does not - measured, objectui#7925. // It type-checks the VALUES of the declared keys: `defaultView: 'agenda'` and // `calendar: { titleField: 42 }` are both compile errors, and -// `check:doc-snippets` re-runs that check on every commit. It does NOT check -// key NAMES - this interface extends `BaseSchema`, whose `[key: string]: any` -// admits any spelling, and the `calendar` block is open the same way, so a -// misspelt key still compiles clean. Read the block as type-checked values, -// never as a guarded key set. +// `check:doc-snippets` re-runs that check on every commit. Top-level key NAMES +// are checked too since objectui#8347 removed `BaseSchema`'s `[key: string]: any`, +// but NOT inside the `calendar` block, which is open (its type is inferred from +// a `.passthrough()` schema), so a misspelt key there still compiles clean. Read +// the `calendar` block as type-checked values, never as a guarded key set. const schema: ObjectCalendarSchema = { type: 'object-calendar', objectName: 'events', diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index b7f1e6f997..78e6b41efe 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -94,8 +94,8 @@ import { * fifteen. * * ⚠️ But the cast count is NOT twelve, and an earlier cut of this comment said - * it was. `ObjectGridSchema` carries `BaseSchema`'s `[key: string]: any`, so on - * the UNION the five `CalendarSchema`-only keys resolved through that index + * it was. `ObjectGridSchema` carried `BaseSchema`'s `[key: string]: any` (until + * objectui#8347), so on the UNION the five `CalendarSchema`-only keys resolved through that index * signature and compiled with no cast at all. The checker on the union itself: * DECLARED 8 of 15 (`colorField` `dateField` `defaultView` `endField` `filter` * `objectName` `sort` `titleField`), NOT declared 7 (`allDayField` `calendar` diff --git a/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx b/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx index 34e64ac512..b154eef8fd 100644 --- a/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx +++ b/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx @@ -21,8 +21,8 @@ * * ⚠️ Note what the union itself answers, because it is easy to get backwards * and this file's first cut did: on the UNION only SEVEN of the fifteen reads - * were undeclared. `ObjectGridSchema` carries `BaseSchema`'s index signature, - * so the five `CalendarSchema`-only keys resolved through it and compiled with + * were undeclared. `ObjectGridSchema` carried `BaseSchema`'s index signature + * (until objectui#8347), so the five `CalendarSchema`-only keys resolved through it and compiled with * no cast — silently typed `any`, which is the defect rather than the absence * of one. Per-ARM is the reading the card tabled; per-UNION is the reading the * compiler acts on; they are different numbers and both are measured here. @@ -132,9 +132,11 @@ * * ## The ceiling, stated rather than assumed (objectui#5155 / #7927) * - * `BaseSchema` ends in `[key: string]: any` and its mirror is `.passthrough()`, - * so declaring a key buys VALUE validation and never buys rejection of a - * MISSPELLING. The counter-probe below pins that honestly, so nobody reads this + * The mirror is `.passthrough()`, so declaring a key buys VALUE validation and + * never buys rejection of a MISSPELLING on that face. (The TS face refused none + * either while `BaseSchema` ended in `[key: string]: any`; objectui#8347 removed + * it, so a typed literal refuses a misspelling now.) The counter-probe below + * pins the mirror half honestly, so nobody reads this * file as claiming more than it does. */ @@ -264,8 +266,9 @@ type Expect = T; /** * `Equal`, not `extends`: a union arm carrying `BaseSchema`'s index signature - * makes an UNDECLARED member read `any`, and a one-way check accepts `any` on - * both sides — which is precisely the before-state this card removes. + * (every extender did until objectui#8347) made an UNDECLARED member read `any`, + * and a one-way check accepts `any` on both sides — which is precisely the + * before-state this card removed. */ export type assertionSchemaPropIsThePublishedElementSchema = Expect>; diff --git a/packages/plugin-charts/src/ObjectChart.tsx b/packages/plugin-charts/src/ObjectChart.tsx index afc61e3d33..b9fec0a036 100644 --- a/packages/plugin-charts/src/ObjectChart.tsx +++ b/packages/plugin-charts/src/ObjectChart.tsx @@ -452,11 +452,12 @@ export { extractRecords } from '@object-ui/core'; * * With the anchor, a wrong VALUE TYPE on a declared key is a compile error at * the producer (`xAxisKey: 42`, `series: 'x'`, `type: 'chart'`, and every - * `BaseSchema` member — `visible: 42`). ⚠️ A MISSPELLED key is still accepted: - * `BaseSchema` carries `[key: string]: any` (objectui#5155), the same ceiling - * objectui#6576 accepted knowingly. `__tests__/ObjectChart.schemaAnchor-7946.test.ts` - * pins both halves, the ceiling included, so the anchor is not read as more - * than it is. + * `BaseSchema` member — `visible: 42`). ⚠️ A MISSPELLED key was still accepted + * while `BaseSchema` carried `[key: string]: any` (objectui#5155), the same + * ceiling objectui#6576 accepted knowingly; objectui#8347 removed it, so a + * misspelled key in a fresh literal is refused now. + * `__tests__/ObjectChart.schemaAnchor-7946.test.ts` pins both halves, the + * flipped ceiling row included, so the anchor is not read as more than it is. */ export interface ObjectChartProps { /** diff --git a/packages/plugin-charts/src/__tests__/ObjectChart.compareTo.test.tsx b/packages/plugin-charts/src/__tests__/ObjectChart.compareTo.test.tsx index bbfe16327b..c705264edc 100644 --- a/packages/plugin-charts/src/__tests__/ObjectChart.compareTo.test.tsx +++ b/packages/plugin-charts/src/__tests__/ObjectChart.compareTo.test.tsx @@ -109,7 +109,7 @@ const comparisonFromOf = (src: { aggregate: any }) => * What it buys: objectui#8885 (PR objectui#8895) declares `compareTo` on * `ObjectChartSchema` bound to that same symbol. With `unknown` here, the * literal below stops compiling the moment the two PRs are UNIONISED — a defect - * neither branch can see alone, because on this branch the key still rides + * neither branch could see alone, because on this branch the key still rode * `BaseSchema`'s index signature. Measured on the merge of the two heads: the * union's `tsc -p tsconfig.test.json` reported this line, and it was masked in * the obvious reading because `type-check` is `tsc --noEmit && tsc -p diff --git a/packages/plugin-charts/src/__tests__/ObjectChart.schemaAnchor-7946.test.ts b/packages/plugin-charts/src/__tests__/ObjectChart.schemaAnchor-7946.test.ts index 66c1eedf33..d31a3bde98 100644 --- a/packages/plugin-charts/src/__tests__/ObjectChart.schemaAnchor-7946.test.ts +++ b/packages/plugin-charts/src/__tests__/ObjectChart.schemaAnchor-7946.test.ts @@ -37,12 +37,13 @@ * * ## The ceiling, stated rather than assumed (objectui#5155) * - * Anchoring buys DECLARED members their declared types. It does NOT buy - * rejection of a MISSPELLING: `BaseSchema` carries `[key: string]: any`, which - * `ObjectChartSchema` inherits, so `xAxisKy` compiles. objectui#6576 accepted - * that cost knowingly for the gallery; the counter-probe at the bottom keeps it - * visible so nobody reads this anchor as more than it is. Closing it is - * objectui#5155, not this card. + * Anchoring buys DECLARED members their declared types. It did NOT buy + * rejection of a MISSPELLING while `BaseSchema` carried `[key: string]: any`, + * which `ObjectChartSchema` inherited, so `xAxisKy` compiled. objectui#6576 + * accepted that cost knowingly for the gallery; the counter-probe at the bottom + * kept it visible so nobody read this anchor as more than it was. Closing it was + * objectui#5155's, not this card's: objectui#8347 closed it, and the + * counter-probe is flipped. * * The schema type's own members, the read census and the source-level pins are * in `packages/types/src/__tests__/widget-schema-anchors-7946.test.ts`. @@ -210,11 +211,12 @@ describe('ObjectChartProps.schema — anchored to ObjectChartSchema (objectui#79 expect(node.visibleWhen).toBe('${data.ready}'); }); - it('the ceiling, stated: an UNKNOWN key still compiles (inherited index signature, objectui#5155)', () => { - // Counter-probe against reading the anchor as more than it is. The ruling's - // acceptance is a wrong VALUE TYPE being refused loudly; a misspelled KEY - // is not reachable from here, exactly as on `ObjectGallerySchema`. + it('an UNKNOWN key is refused — the ceiling this row stated is gone (objectui#8347)', () => { + // This row was the counter-probe against reading the anchor as more than it + // was: the inherited index signature let a misspelled KEY compile. objectui#8347 + // removed it, so the misspelling is refused like a wrong value type. + // @ts-expect-error — `xAxisKy` is no member; the key is `xAxisKey` const node: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', xAxisKy: 'stage' }; - expect(node.xAxisKy).toBe('stage'); + expect(node.type).toBe('object-chart'); }); }); diff --git a/packages/plugin-charts/src/__tests__/ObjectChartBlock.props-8885.test.tsx b/packages/plugin-charts/src/__tests__/ObjectChartBlock.props-8885.test.tsx index bc461b1fd3..79e896bb33 100644 --- a/packages/plugin-charts/src/__tests__/ObjectChartBlock.props-8885.test.tsx +++ b/packages/plugin-charts/src/__tests__/ObjectChartBlock.props-8885.test.tsx @@ -28,10 +28,10 @@ * * ## The ceiling, stated rather than assumed * - * A closed PROPS type refuses a misspelled prop NAME. It does not refuse a - * misspelled key INSIDE the node: `BaseSchema` carries `[key: string]: any`, - * the protocol's passthrough. The counter-probe at the bottom keeps that - * visible so nobody reads this pin as more than it is. + * A closed PROPS type refuses a misspelled prop NAME. It did not refuse a + * misspelled key INSIDE the node while `BaseSchema` carried + * `[key: string]: any`; objectui#8347 removed it, and the counter-probe at the + * bottom that kept that visible is flipped (see its note). */ import { describe, it, expect } from 'vitest'; @@ -112,8 +112,13 @@ const probes = () => ( onSegmentClick={() => undefined} /> - {/* CEILING, not a refusal: a misspelled key INSIDE the node still - compiles, through `BaseSchema`'s passthrough index signature. */} + {/* This row was the CEILING: a misspelled key INSIDE the node compiled + through `BaseSchema`'s index signature. objectui#8347 removed it, so a + fresh literal at the shell's pre-gate `BaseSchema` prop is excess-checked + against `BaseSchema` itself: the misspelling is refused, and so would a + correctly spelled `objectName` be. Hand the shell a typed node, as the + controls above do. */} + {/* @ts-expect-error — `objectNme` is no `BaseSchema` member, and the pre-gate prop is `BaseSchema` */} ); diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index e9009338ab..4c53608c59 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -698,9 +698,10 @@ a card directly in `widgets[]` whose `trend` is outside the three, or whose The widget keys (`colorVariant`, `filter`, `dataset`, …) are declared on the widget arm, `DashboardWidgetSchema`, which takes them from the spec's -`DashboardWidget` row. The component arm declares none of them. Read straight -off a `widgets[]` entry, such a key is typed `any`, supplied by the component -arm's passthrough. This package's own readers take an entry by the slot's +`DashboardWidget` row. The component arm declares none of them, so read +straight off a `widgets[]` entry such a key is a compile error: the union offers +only the keys both arms declare (before objectui#8347 removed `BaseSchema`'s index +signature, the component arm answered it as `any`). This package's own readers take an entry by the slot's element type, `DashboardComponentSchema['widgets'][number]`. The component arm is not assignable to `DashboardWidgetSchema`, whose `type` names no component type, so narrow an entry on `type` first: `metric-card` is the one component diff --git a/packages/plugin-dashboard/src/ObjectPivotTable.tsx b/packages/plugin-dashboard/src/ObjectPivotTable.tsx index 13284e7bcc..4e4cd6a1d7 100644 --- a/packages/plugin-dashboard/src/ObjectPivotTable.tsx +++ b/packages/plugin-dashboard/src/ObjectPivotTable.tsx @@ -44,10 +44,11 @@ const EMPTY_ROWS = Object.freeze([]) as unknown as any[]; * below, and intersecting it with the tombstone would collapse that member to * `never`: the one pivot that drills could not be authored with a drill. * - * Key remapping, not `Omit`: `PivotTableSchema` extends `BaseSchema`, whose - * `[key: string]: any` widens `keyof` to all of `string`, so - * `Omit` would keep the index signature and drop - * every declared member. + * Key remapping, not `Omit`: written while `PivotTableSchema` inherited + * `BaseSchema`'s `[key: string]: any`, which widened `keyof` to all of + * `string`, so `Omit` would have kept the index + * signature and dropped every declared member. objectui#8347 removed the + * signature. */ type PivotTableSchemaWithoutDrillDown = { [K in keyof PivotTableSchema as K extends 'drillDown' ? never : K]: PivotTableSchema[K]; @@ -66,8 +67,8 @@ export interface ObjectPivotTableProps { * writes it and no reader was added. * * A `?: never` tombstone, not a plain deletion: `PivotTableSchema` extends - * `BaseSchema`, whose `[key: string]: any` would absorb a deleted member - * silently at any value. Licensed by prong 1 of the discriminator + * `BaseSchema`, whose `[key: string]: any` absorbed a deleted member + * silently at any value until objectui#8347 (a widened value still would). Licensed by prong 1 of the discriminator * (objectui#5941, #7526, in its amended form, `5f8190c8c`): it names the live * replacement, `objectName`. A JSON-authored value is refused by name at * parse time too, since objectui#11440: the `object-pivot` arm of diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.schemaAnchor-6576.test.ts b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.schemaAnchor-6576.test.ts index 18bbebc1c3..9b41407445 100644 --- a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.schemaAnchor-6576.test.ts +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.schemaAnchor-6576.test.ts @@ -30,8 +30,10 @@ * - WIDENS, in declaration only: `drillDown` / `onRowClick` are now DECLARED * with real types — before, they compiled through the index signature as * `any`, which is why a wrong-shaped `drillDown` compiled too. - * - UNCHANGED, pinned honestly: an unknown key still compiles, because - * `BaseSchema`'s `[key: string]: any` is inherited (objectui#5155). + * - UNCHANGED then, pinned honestly: an unknown key still compiled, because + * `BaseSchema`'s `[key: string]: any` was inherited (objectui#5155). + * objectui#8347 removed it, and that row is flipped: an unknown key is + * refused now. */ import { describe, it, expect } from 'vitest'; @@ -107,8 +109,10 @@ describe('ObjectDataTableProps.schema — anchored to ObjectDataTableSchema (obj expect(node.data).toBe('not-an-array'); }); - it('the ceiling, stated: an UNKNOWN key still compiles (inherited index signature, objectui#5155)', () => { + it('an UNKNOWN key is refused — the ceiling this row stated is gone (objectui#8347)', () => { + // The inherited index signature let it compile until objectui#8347 removed it. + // @ts-expect-error — `ObjectDataTableSchema` declares no `bogusKey` const node: Schema = { type: 'object-data-table', bogusKey: 1 }; - expect(node.bogusKey).toBe(1); + expect(node.type).toBe('object-data-table'); }); }); diff --git a/packages/plugin-dashboard/src/__tests__/widgetDataProviderRetired-7353.test.tsx b/packages/plugin-dashboard/src/__tests__/widgetDataProviderRetired-7353.test.tsx index 95efacbf01..a9a025e0c0 100644 --- a/packages/plugin-dashboard/src/__tests__/widgetDataProviderRetired-7353.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/widgetDataProviderRetired-7353.test.tsx @@ -206,8 +206,9 @@ describe('objectui#7353 — DashboardGridLayout: the table and pivot nodes carry * consumer-side half of the types tombstone: red on a stale `dist`. * * Why a tombstone and not a deletion: both carriers extend `BaseSchema`, whose - * `[key: string]: any` absorbs a deleted member silently at any value — an - * authored `dataProvider` would still compile and still do nothing. The + * `[key: string]: any` absorbed a deleted member silently at any value until + * objectui#8347 (a widened value still would) — an authored `dataProvider` + * would still compile there and still do nothing. The * `@ts-expect-error` directives are real enforcement because this package's * `type-check` chains `tsc -p tsconfig.test.json`; vitest erases them. */ @@ -237,10 +238,13 @@ describe('objectui#7353 — both widget prop types refuse an authored dataProvid // @ts-expect-error `dataProvider` is a retirement tombstone (objectui#7353) — write `objectName` dataProvider: { provider: 'object', object: 'account' }, }; - // Control: an undeclared key carries no directive — the index signature - // absorbs it, which is what a deletion would have left `dataProvider` as. + // Control: an undeclared key carried no directive while the index signature + // absorbed it — what a deletion would have left `dataProvider` as. Since + // objectui#8347 it is refused too, as undeclared, with no remedy named; the + // tombstone above is what names `objectName`. + // @ts-expect-error — the pivot node declares no `zzUndeclared7353` (objectui#8347) const undeclared: PivotSchema = { type: 'pivot', rowField: 'r', columnField: 'c', valueField: 'v', data: [], zzUndeclared7353: 1 }; - expect([node.objectName, undeclared.zzUndeclared7353]).toEqual(['account', 1]); + expect([node.objectName, undeclared.type]).toEqual(['account', 'pivot']); }); it('ObjectDataTableProps (anchored to ObjectDataTableSchema): the same, through the built types', () => { @@ -250,7 +254,8 @@ describe('objectui#7353 — both widget prop types refuse an authored dataProvid // @ts-expect-error `dataProvider` is a retirement tombstone (objectui#7353) — write `objectName` dataProvider: { provider: 'object', object: 'account' }, }; + // @ts-expect-error — `ObjectDataTableSchema` declares no `zzUndeclared7353` (objectui#8347) const undeclared: DataTableSchema = { type: 'object-data-table', zzUndeclared7353: 1 }; - expect([node.objectName, undeclared.zzUndeclared7353]).toEqual(['account', 1]); + expect([node.objectName, undeclared.type]).toEqual(['account', 'object-data-table']); }); }); diff --git a/packages/plugin-dashboard/src/widgetDispatch.ts b/packages/plugin-dashboard/src/widgetDispatch.ts index 2daed62abc..3c792cef6a 100644 --- a/packages/plugin-dashboard/src/widgetDispatch.ts +++ b/packages/plugin-dashboard/src/widgetDispatch.ts @@ -51,8 +51,8 @@ import type { MetricWidgetProps } from './MetricWidget'; * {@link isSlotComponentEntry} first (objectui#11598, N2 A), the read rule * `DashboardComponentSchema.widgets` states. The component arm declares none * of them, and a component node in the slot draws itself whatever else it - * carries; reading one off the entry whichever arm it is compiled only through - * `BaseSchema`'s index signature, which objectui#8347 removes. + * carries; reading one off the entry whichever arm it was compiled only through + * `BaseSchema`'s index signature, which objectui#8347 removed. */ export type DashboardWidgetSlotEntry = DashboardComponentSchema['widgets'][number]; diff --git a/packages/plugin-designer/src/DashboardEditor.tsx b/packages/plugin-designer/src/DashboardEditor.tsx index c36b87233e..08bce46f2e 100644 --- a/packages/plugin-designer/src/DashboardEditor.tsx +++ b/packages/plugin-designer/src/DashboardEditor.tsx @@ -100,7 +100,7 @@ type DashboardWidgetEntry = DashboardComponentSchema['widgets'][number]; * widget arm alone, so every such read below narrows with this first * (objectui#11598, N2 A): the component arm declares none of them, and reading * one off the entry whichever arm it was compiled only through `BaseSchema`'s - * index signature, which objectui#8347 removes. + * index signature, which objectui#8347 removed. * * The twin of plugin-dashboard's `isSlotComponentEntry` (`widgetDispatch.ts`), * which this package cannot import: it does not depend on plugin-dashboard diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index d1fb9a8a3c..1a48269c3f 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -197,11 +197,12 @@ unknown-component placeholder. | `onSubmit` / `onChange` / `onDirtyChange` / `onCancel` | callbacks | **TypeScript-authored schemas only** — a JSON metadata document cannot carry a function. Metadata pages go through the object-form route instead | | `className`, `id`, `hidden`, … | — | inherited from `BaseSchema` | -⚠️ `FormSchema` extends `BaseSchema`, which declares `[key: string]: any` -(`packages/types/src/base.ts`), so an invented or misspelled key on a form schema -is **not** a compile error — it is simply never read. That is why every example -below is annotated with its real type *and* checked against these key tables: -an un-annotated `const schema = { … }` type-checks whatever is written in it. +`FormSchema` extends `BaseSchema`, which declares no index signature +(objectui#8347), so an invented or misspelled key in a form schema literal +annotated with its type is a compile error. That is why every example below is +annotated with its real type: an un-annotated `const schema = { … }` type-checks +whatever is written in it. (`fields` entries are `FormField`s, which still carry +their own index signature — see below.) ### Form Field @@ -232,8 +233,8 @@ only **required** one: | `colSpan` | `number` | legacy column span (1–4), clamped to the current column count | | `field` | `Record` | the resolved object-field **metadata object**, stashed by the object-bound paths so widgets can read `precision`, `currency`, `reference`, … In the *spec* form-view vocabulary `field` is a string (the referenced field name); that shape ends at `normalizeSectionField` and never reaches a runtime `FormField` | -`FormField` also declares `[key: string]: any`, so an invented key type-checks -here too. Two that a reader might expect, and that are **not** declared: +`FormField` declares its own `[key: string]: any`, so an invented key on a field +still type-checks. Two that a reader might expect, and that are **not** declared: | Not a `FormField` key | Write this instead | |---|---| @@ -800,8 +801,8 @@ const wizard: WizardFormSchema = { ``` `WizardFormSchema` declares no index signature, so an invented key on *this* -type is a real compile error — unlike `ObjectFormSchema`, which inherits -`BaseSchema`'s `[key: string]: any`. +type is a real compile error — and since objectui#8347 removed `BaseSchema`'s +`[key: string]: any`, the same holds for `ObjectFormSchema`. One more route exists and is worth knowing about rather than reinventing: a flat `object-form` can be turned into a stepper on small viewports with @@ -1057,9 +1058,10 @@ nothing: | `dataSource` | **Discarded.** The basic form strips it in both directions — `dataSource: _dataSource` at `form.tsx:304` (`stripRendererOnlyProps`) and `:2168` — so it never reaches a widget and never reaches the DOM. The adapter the fields receive is the context one | | `resource` | **Never read.** It is not declared on `FormSchema` or `ObjectFormSchema` at all. It used to exist elsewhere in the protocol — on `CRUDSchema`, where `CRUDBuilder` set it — but objectui#5373 retired both under ADR-0049, so today the key names nothing anywhere in this package's surface, and no form renderer reads it under any spelling | -Both survive compilation for the reason [Schema API](#schema-api) gives: `FormSchema` -and `ObjectFormSchema` extend `BaseSchema`, which declares `[key: string]: any`, so an -invented key is not a type error — it is simply never read. That is also why the +Both survived compilation while `FormSchema` and `ObjectFormSchema` extended a +`BaseSchema` that declared `[key: string]: any`: an invented key was not a type +error, it was simply never read. objectui#8347 removed that signature, so both are +compile errors in an annotated literal now. That is also why the older version of this section looked like it worked: its `onSubmit` genuinely ran and genuinely saved, but through the adapter its closure captured. The `dataSource` and `resource` keys sitting beside it in the same object were inert. Delete them and diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index 2ae1112530..10053fabef 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -159,8 +159,9 @@ type ObjectFormLabelKey = (typeof OBJECT_FORM_LABEL_KEYS)[number]; /** * `ObjectFormSchema` as every presentation below reads it: the seven * `I18nLabel` members already resolved to a string. A homomorphic mapped type - * rather than `Omit`, because `BaseSchema`'s index signature makes `Omit` - * collapse to that signature and drop every declared member. + * rather than `Omit`, because `BaseSchema`'s index signature made `Omit` + * collapse to that signature and drop every declared member (until + * objectui#8347 removed it). */ type LocalizedObjectFormSchema = { [K in keyof ObjectFormSchema]: K extends ObjectFormLabelKey ? string : ObjectFormSchema[K]; diff --git a/packages/plugin-gantt/src/ObjectGantt.propertiesBag-10859.test.tsx b/packages/plugin-gantt/src/ObjectGantt.propertiesBag-10859.test.tsx index 5cccc6b353..8867eaf353 100644 --- a/packages/plugin-gantt/src/ObjectGantt.propertiesBag-10859.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.propertiesBag-10859.test.tsx @@ -34,7 +34,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, waitFor } from '@testing-library/react'; import React from 'react'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import type { DataSource, DeclaredNode } from '@object-ui/types'; +import type { DataSource, DeclaredNode, ObjectGanttBlockNode } from '@object-ui/types'; import { safeValidateSchema } from '@object-ui/types/zod'; vi.mock('sonner', () => ({ toast: { error: vi.fn() } })); @@ -73,7 +73,10 @@ vi.mock('@object-ui/plugin-detail', async (importOriginal) => ({ import './index'; const GANTT = { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name', progressField: 'progress' }; -const FILTER = [{ field: 'status', operator: 'equals', value: 'open' }]; +// `as const` keeps `operator` the literal the bag's filter row declares; a +// widened `string` is refused there (objectui#8347 took away the index +// signature that used to admit the bag on the post-hoist node type). +const FILTER = [{ field: 'status', operator: 'equals' as const, value: 'open' }]; const SORT: Array<{ field: string; order: 'asc' | 'desc' }> = [{ field: 'name', order: 'desc' }]; const ROWS = [ { id: 't1', name: 'Design', start_date: '2026-03-02', end_date: '2026-03-06', progress: 100, status: 'open' }, @@ -81,7 +84,7 @@ const ROWS = [ ]; /** The authored spelling, spec-valid. */ -const BAG: DeclaredNode = { type: 'object-gantt', properties: { objectName: 'task', gantt: GANTT, filter: FILTER, sort: SORT } }; +const BAG: ObjectGanttBlockNode = { type: 'object-gantt', properties: { objectName: 'task', gantt: GANTT, filter: FILTER, sort: SORT } }; /** The same props written flat, with the `gantt` block — what a code composer can build. */ const FLAT: DeclaredNode = { type: 'object-gantt', objectName: 'task', gantt: GANTT, filter: FILTER, sort: SORT }; /** The flattened `GanttConfig` keys `ObjectView` writes onto the node it composes. */ diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index 77f2d782b6..b9a8e46f94 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -311,9 +311,10 @@ export interface ObjectGanttProps { * objectui#6051 declared what the FLAT branch reads: the 24 flattened * `GanttConfig` keys `getGanttConfig`'s first branch consumes, plus the * `staticData` / `filter` / `sort` the fetch path reads. The grid-style - * `{ gantt: { … } }` block keeps working exactly as before and is still read - * through the index signature — declaring it is the one change that would not - * have been additive, and it is severed to objectui#6475. The registered + * `{ gantt: { … } }` block kept working exactly as before and was still read + * through the index signature — declaring it was the one change that would not + * have been additive, so it was severed to objectui#6475, which has since + * declared it (`gantt?: GanttConfig`). The registered * renderer (`index.tsx`) still passes `schema: any`, so no runtime shape is * turned away either way. */ diff --git a/packages/plugin-gantt/src/index.tsx b/packages/plugin-gantt/src/index.tsx index 7848b3afa5..38398f1004 100644 --- a/packages/plugin-gantt/src/index.tsx +++ b/packages/plugin-gantt/src/index.tsx @@ -212,9 +212,9 @@ ComponentRegistry.register('object-gantt', ObjectGanttRenderer, { * * ## Why unregistering is the whole retirement here — measured, not assumed * - * ⚠️ `BaseSchema` closes with `[key: string]: any` and `BaseSchemaCore` ends - * `.passthrough()`, so a dropped MEMBER KEY is KEPT, not refused (the - * objectui#7664 failure). That hazard does not reach a TYPE NAME on this + * ⚠️ `BaseSchemaCore` ends `.passthrough()` (and `BaseSchema` closed with + * `[key: string]: any` until objectui#8347), so a dropped MEMBER KEY is KEPT, + * not refused, on the zod face (the objectui#7664 failure). That hazard does not reach a TYPE NAME on this * surface, and the reason is structural rather than lucky: no schema face in * `@object-ui/types` ever declared `gantt` as a component node type — measured * whole-repo, zero declarations, against a firing control of two for diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 004444984f..197738d44a 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -1092,15 +1092,17 @@ export type ObjectGridColumn = * PLUS an undeclared `bogusKeyForProbe6459: true` WRITTEN OUT LONGHAND in * the (fresh) literal → `tsc --noEmit` exit 0, ZERO diagnostics. * - * The reason is `BaseSchema`'s `[key: string]: any` index signature, which - * `DataTableSchema` inherits: under an index signature EVERY key is a member, + * The reason was `BaseSchema`'s `[key: string]: any` index signature, which + * `DataTableSchema` inherited until objectui#8347: under an index signature + * EVERY key is a member, * so excess-property checking never has a non-member to refuse — at a fresh * literal, through a spread, anywhere. It is the terminal case of the rule the * `options` tombstone above records ("a pin enforced by a key's non-membership * silently stops enforcing the moment the key becomes a member"): with an * index signature there is no non-membership to enforce with, ever. * - * `RemoveIndexSignature` strips it — DERIVED from `DataTableSchema`, never a + * `RemoveIndexSignature` stripped it (since objectui#8347 it strips nothing on + * `DataTableSchema`) — DERIVED from `DataTableSchema`, never a * hand-copied member list, so a member added there tomorrow flows in on its * own and two enumerations of one vocabulary never exist. With the signature * gone, the same probe goes red (TS2353 naming the key), measured at both @@ -1189,7 +1191,7 @@ type RemoveIndexSignature = { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]; }; -/** `DataTableSchema`'s DECLARED members only — the index signature stripped. */ +/** `DataTableSchema`'s DECLARED members only — any index signature stripped (it has none since objectui#8347). */ export type DeclaredDataTableSchema = RemoveIndexSignature; /** @@ -1234,8 +1236,9 @@ export type DeclaredDataTableSchema = RemoveIndexSignature; * pinned, so a member added or removed here reddens too. ⚠️ The transplant is * NOT literal in one respect, deliberately: the probe reads * `DeclaredDataTableSchema`, not `DataTableSchema`, because the index signature - * stripped above makes the raw type answer "declared" to EVERY key — measured, - * and pinned in that suite as its own control. + * stripped above made the raw type answer "declared" to EVERY key — measured, + * and pinned in that suite as its own control, which is flipped since + * objectui#8347 removed the signature. * * ⇒ Anything in this census that a reader would otherwise have to re-measure by * hand is still prose; what is mechanical is the ENTRY CONDITION of the two diff --git a/packages/plugin-grid/src/__tests__/dataTableSchemaSlot-6459.test.ts b/packages/plugin-grid/src/__tests__/dataTableSchemaSlot-6459.test.ts index 7b90730e8e..3af9fe65bb 100644 --- a/packages/plugin-grid/src/__tests__/dataTableSchemaSlot-6459.test.ts +++ b/packages/plugin-grid/src/__tests__/dataTableSchemaSlot-6459.test.ts @@ -22,10 +22,11 @@ * both writers are literals sitting DIRECTLY in annotated positions, so * excess-property checking is the live instrument — PROVIDED the type has no * index signature. `BaseSchema`'s `[key: string]: any` (which `DataTableSchema` - * inherits) makes every key a member, and a check that refuses non-members has - * nothing to refuse when non-membership cannot exist. `RemoveIndexSignature` in - * `ObjectGrid.tsx` is what turns the annotation from inert to able-to-fail, - * and the pins below hold each half of that claim separately. + * inherited until objectui#8347) made every key a member, and a check that + * refuses non-members has nothing to refuse when non-membership cannot exist. + * `RemoveIndexSignature` in `ObjectGrid.tsx` is what turned the annotation from + * inert to able-to-fail (it strips nothing since objectui#8347), and the pins + * below hold each half of that claim separately. */ import { describe, it, expect } from 'vitest'; import type { DataTableSchema, TableColumn } from '@object-ui/types'; @@ -60,11 +61,12 @@ type HeldLocally = 'UNDECLARED by DataTableSchema — this hold is LOAD-BEARING. Re-derive the census prose in ObjectGrid.tsx (the census section, and the ObjectGridDataTableSchemaHolds docblock under it) before changing anything.'; /** - * ⚠️ Probes `DeclaredDataTableSchema`, NOT `DataTableSchema`, and that is not - * cosmetic: `DataTableSchema` inherits `BaseSchema`'s `[key: string]: any`, so - * `string extends keyof DataTableSchema` and the membership question is - * ALWAYS-TRUE against it — an instrument with no `false` to give. The third - * test below pins both halves of that, so the choice is measured, not asserted. + * ⚠️ Probes `DeclaredDataTableSchema`, NOT `DataTableSchema`, and that was not + * cosmetic: `DataTableSchema` inherited `BaseSchema`'s `[key: string]: any` + * until objectui#8347, so `string extends keyof DataTableSchema` and the + * membership question was ALWAYS-TRUE against it — an instrument with no + * `false` to give. The control test below pinned both halves of that, and is + * flipped since objectui#8347 made the raw type able to answer `false` too. */ type HoldVerdict = K extends keyof DeclaredDataTableSchema ? DeclaredUpstream @@ -84,34 +86,37 @@ const columns: TableColumn[] = [{ header: 'Name', accessorKey: 'name' }]; describe('objectui#6459 — the schema slot annotation is an instrument, not a decoration', () => { /** - * ⭐ THE ROOT CAUSE, PINNED: a bare `DataTableSchema` annotation refuses - * NOTHING. This assignment MUST COMPILE — the bogus key is written out - * longhand in a FRESH literal, the strongest position excess-property - * checking ever has, and the inherited `[key: string]: any` still admits it. - * Measured on the real seam before the fix was shaped (`tsc --noEmit` - * exit 0, zero diagnostics, with `bogusKeyForProbe6459: true` injected into - * the flat literal under a bare annotation). If this ever goes red, - * TypeScript or `BaseSchema` changed underneath and the docblock above - * `RemoveIndexSignature` in `ObjectGrid.tsx` needs re-measuring. + * ⭐ THE ROOT CAUSE, as it was: a bare `DataTableSchema` annotation refused + * NOTHING. The assignment below COMPILED — the bogus key written out longhand + * in a FRESH literal, the strongest position excess-property checking ever + * has, and the inherited `[key: string]: any` still admitted it. Measured on + * the real seam before the fix was shaped (`tsc --noEmit` exit 0, zero + * diagnostics). This docblock said that if it ever went red, `BaseSchema` + * had changed underneath and `RemoveIndexSignature` in `ObjectGrid.tsx` + * needed re-measuring: objectui#8347 removed the signature, the row went + * red, and it is flipped here. The local strip is redundant from that day + * (it strips nothing now); retiring it is left out of the removal PR. */ - it('a bare `DataTableSchema` annotation accepts a bogus key even at a fresh literal (the blind instrument)', () => { + it('a bare `DataTableSchema` annotation refuses a bogus key at a fresh literal since objectui#8347', () => { const blind: DataTableSchema = { type: 'data-table', columns, data: [], - bogusKeyRefusedNowhere: 'admitted by the index signature', + // @ts-expect-error — `DataTableSchema` declares no such key, and `BaseSchema` carries no index signature (objectui#8347) + bogusKeyRefusedNowhere: 'admitted by the index signature until objectui#8347', }; expect(blind.type).toBe('data-table'); }); /** - * …and the index signature is exactly what separates the two types: present - * on `DataTableSchema`, stripped from the seam type. If `@object-ui/types` - * ever removes the signature from `BaseSchema`, the first line goes red — - * a useful red: the local stripping machinery becomes redundant that day. + * …and the index signature was exactly what separated the two types: present + * on `DataTableSchema`, stripped from the seam type. This docblock predicted + * that the first line would go red if `@object-ui/types` removed the signature + * from `BaseSchema` — objectui#8347 did, and it is flipped: neither type is + * indexed by `string` now. */ - it('the strip is real: `string` indexes `DataTableSchema` but not the seam type', () => { - type _UpstreamHasIndex = Expect; + it('neither `DataTableSchema` nor the seam type is indexed by `string` (objectui#8347)', () => { + type _UpstreamHasNone = Expect; type _SeamHasNone = Expect; expect(true).toBe(true); }); @@ -276,19 +281,22 @@ describe('objectui#6459 — the schema slot annotation is an instrument, not a d /** * ⭐ THE CONTROL ON THE CHOICE OF TYPE, which is the one place this transplant * is NOT literal. Read against the raw `DataTableSchema`, the membership - * question is ALWAYS-TRUE: `BaseSchema`s `[key: string]: any` makes - * `string extends keyof DataTableSchema`, so a nonsense key is a member. A + * question was ALWAYS-TRUE: `BaseSchema`s `[key: string]: any` made + * `string extends keyof DataTableSchema`, so a nonsense key was a member. A * gate written the obvious way — asking `DataTableSchema` whether it declares - * the held key — would therefore be an instrument that can only answer "yes", - * green forever: the same failure objectui#7201 was filed about, one level up. - * The first line pins that blindness; the second pins that the strip + * the held key — would therefore have been an instrument that can only answer + * "yes", green forever: the same failure objectui#7201 was filed about, one + * level up. The first line pinned that blindness; the second pins that the strip * `ObjectGrid.tsx` already derives is what restores a usable `false`. * - * If the first line ever goes red, `BaseSchema` dropped its index signature — - * a useful red, and the same one "the strip is real" above reports. + * The first line went red when objectui#8347 dropped `BaseSchema`'s index + * signature, as predicted here, and is flipped: the raw probe can refuse now, + * so both types answer alike. */ - it('the gate must read the STRIPPED type — the raw `DataTableSchema` probe is blind', () => { - type _RawIsAlwaysTrue = Expect>; + it('the raw `DataTableSchema` probe can refuse since objectui#8347, as the stripped one always could', () => { + type _RawCanRefuse = Expect< + Has<'zzNotADataTableSchemaKeyZZ', DataTableSchema> extends false ? true : false + >; type _StrippedCanRefuse = Expect< Has<'zzNotADataTableSchemaKeyZZ', DeclaredDataTableSchema> extends false ? true : false >; diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index a4c9516e98..868bb43f08 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -7,7 +7,7 @@ */ import React, { useEffect, useState, useMemo } from 'react'; -import type { DataSource, ObjectKanbanSchema } from '@object-ui/types'; +import type { DataSource, ObjectKanbanSchema, SortConfig } from '@object-ui/types'; import { useDataScope, useNavigationOverlay, @@ -333,9 +333,12 @@ export interface ObjectKanbanComponentProps { * Nothing, for an `object-kanban` document. Such a node was never judged by * the `kanban` arm, so every key this component reads off `schema` that * `ObjectKanbanSchema` does not declare reached the renderer through - * {@link BaseSchema}'s `[key: string]: any` BEFORE the retirement and still - * does. ⇒ No `object-kanban` node changes meaning; what changed is that - * `kanban` nodes no longer exist. + * {@link BaseSchema}'s `[key: string]: any` BEFORE the retirement, and did + * after it. ⇒ No `object-kanban` node changed meaning; what changed is that + * `kanban` nodes no longer exist. Since objectui#8347 `BaseSchema` carries no + * index signature, so a read of an undeclared key here no longer compiles; + * the one key the gate writes that the node does not declare is typed at + * {@link GateBoundKanbanSchema}. * * ⛔ WHICH keys those are is deliberately not listed here, and neither is * how many there are. This paragraph listed both (objectui#8802, @@ -389,8 +392,32 @@ export interface ObjectKanbanComponentProps { onCardClick?: (record: any, event?: any) => void; } +/** + * The board node as this component READS it: {@link ObjectKanbanSchema} plus + * the one key `ElementDataSourceGate` writes onto it that the node does not + * declare (objectui#8347). + * + * `sort` is the gate's carrier for the per-element binding's `dataSource.sort` + * (`OBJECT_KANBAN_DATA_SOURCE` in `./index.tsx` maps it, objectui#10068), and + * the fetch lowers it onto `$orderby`. It is ⛔ not an authoring key: the + * spec's `object-kanban` props declare no top-level `sort` and refuse one, so + * neither `ObjectKanbanSchema` face declares it (objectui#8174, pinned by + * `_KanbanSortStaysUndeclared` in `@object-ui/types`). While `BaseSchema` + * carried `[key: string]: any` the read compiled as `any`; this type names it + * where its only reader lives instead, the way `plugin-timeline`'s + * `renderHandoff.ts` types the keys its composer writes (objectui#6356). + * + * ⛔ Deliberately NOT exported, and ⛔ never to be added to + * `ObjectKanbanComponentProps` or `@object-ui/types`: a public type naming + * `sort` would invite the spelling the authoring faces refuse. + */ +type GateBoundKanbanSchema = ObjectKanbanSchema & { + /** The binding's (or its view's) ordering, written by the gate. */ + sort?: SortConfig[]; +}; + export const ObjectKanban: React.FC = ({ - schema, + schema: boardSchema, dataSource, className, data: externalData, @@ -400,6 +427,9 @@ export const ObjectKanban: React.FC = ({ ..._props }) => { void _props; + // Widened, not cast: the gate-written `sort` is optional, so the declared + // node is assignable to the read type as it stands (see `GateBoundKanbanSchema`). + const schema: GateBoundKanbanSchema = boardSchema; const { translateOptions, fieldLabel } = useSafeFieldLabel(); const tt = useSafeTranslate(); // Separate from `tt` because the record-detail heading interpolates a label — diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index 54919dea47..fc767e71eb 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -365,8 +365,9 @@ export const KanbanRenderer: React.FC = ({ schema, objectFi * * ## Why unregistering is the whole retirement * - * ⚠️ `BaseSchema` closes with `[key: string]: any` and `BaseSchemaCore` ends - * `.passthrough()`, so a dropped MEMBER KEY is KEPT, not refused (objectui#7664). + * ⚠️ `BaseSchemaCore` ends `.passthrough()` (and `BaseSchema` closed with + * `[key: string]: any` until objectui#8347), so a dropped MEMBER KEY is KEPT, + * not refused, on the zod face (objectui#7664). * That hazard needs a schema face to arise on, and this key never had one: * measured whole-repo, `@object-ui/types` declares `kanban-ui` as a component * node type ZERO times (firing control: `object-kanban`, 2 — `objectql.ts` and diff --git a/packages/plugin-list/src/__tests__/ObjectGallery.schemaAnchor-6576.test.ts b/packages/plugin-list/src/__tests__/ObjectGallery.schemaAnchor-6576.test.ts index d2eedb1aaa..aef6bfe9b6 100644 --- a/packages/plugin-list/src/__tests__/ObjectGallery.schemaAnchor-6576.test.ts +++ b/packages/plugin-list/src/__tests__/ObjectGallery.schemaAnchor-6576.test.ts @@ -23,9 +23,10 @@ * - WIDENS: every `BaseSchema` member is now writable. `visibleWhen` — a real * base member — was a compile error on the literal. * - NARROWS: `type` is now required, and pinned to the registry key. - * - UNCHANGED, pinned honestly: an unknown key still compiles, because - * `BaseSchema`'s `[key: string]: any` is inherited (objectui#5155). The - * ruling accepted that cost; the counter-probe below keeps it visible. + * - UNCHANGED then, pinned honestly: an unknown key still compiled, because + * `BaseSchema`'s `[key: string]: any` was inherited (objectui#5155). The + * ruling accepted that cost; objectui#8347 removed the signature, and the + * counter-probe below is flipped: an unknown key is refused now. * * The schema type's own members and the widget's read census are pinned in * `packages/types/src/__tests__/widget-schema-anchors-6576.test.ts`. @@ -85,8 +86,10 @@ describe('ObjectGalleryProps.schema — anchored to ObjectGallerySchema (objectu expect(node.data).toBe('not-an-array'); }); - it('the ceiling, stated: an UNKNOWN key still compiles (inherited index signature, objectui#5155)', () => { + it('an UNKNOWN key is refused — the ceiling this row stated is gone (objectui#8347)', () => { + // The inherited index signature let it compile until objectui#8347 removed it. + // @ts-expect-error — `visibleWhn` is no member; the key is `visibleWhen` const node: ObjectGalleryProps['schema'] = { type: 'object-gallery', visibleWhn: 'typo' }; - expect(node.visibleWhn).toBe('typo'); + expect(node.type).toBe('object-gallery'); }); }); diff --git a/packages/plugin-map/src/ObjectMap.propertiesBag-10859.test.tsx b/packages/plugin-map/src/ObjectMap.propertiesBag-10859.test.tsx index fd35aafb40..302dcf396a 100644 --- a/packages/plugin-map/src/ObjectMap.propertiesBag-10859.test.tsx +++ b/packages/plugin-map/src/ObjectMap.propertiesBag-10859.test.tsx @@ -33,7 +33,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, waitFor } from '@testing-library/react'; import React from 'react'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import type { DataSource, DeclaredNode } from '@object-ui/types'; +import type { DataSource, DeclaredNode, ObjectMapBlockNode } from '@object-ui/types'; import { safeValidateSchema } from '@object-ui/types/zod'; type StandInProps = { children?: React.ReactNode; latitude?: number; longitude?: number }; @@ -56,7 +56,10 @@ vi.mock('react-map-gl/maplibre', () => ({ import './index'; const MAP = { latitudeField: 'lat', longitudeField: 'lng', titleField: 'name' }; -const FILTER = [{ field: 'region', operator: 'equals', value: 'west' }]; +// `as const` keeps `operator` the literal the bag's filter row declares; a +// widened `string` is refused there (objectui#8347 took away the index +// signature that used to admit the bag on the post-hoist node type). +const FILTER = [{ field: 'region', operator: 'equals' as const, value: 'west' }]; const SORT: Array<{ field: string; order: 'asc' | 'desc' }> = [{ field: 'name', order: 'desc' }]; const ROWS = [ { id: 's1', name: 'North Store', lat: 37.8044, lng: -122.2711, region: 'west' }, @@ -64,7 +67,7 @@ const ROWS = [ ]; /** The authored spelling, spec-valid. */ -const BAG: DeclaredNode = { type: 'object-map', properties: { objectName: 'store', map: MAP, filter: FILTER, sort: SORT } }; +const BAG: ObjectMapBlockNode = { type: 'object-map', properties: { objectName: 'store', map: MAP, filter: FILTER, sort: SORT } }; /** The same props written flat — what a code composer builds. */ const FLAT: DeclaredNode = { type: 'object-map', objectName: 'store', map: MAP, filter: FILTER, sort: SORT }; /** The bag with its object supplied by the node's binding instead. */ diff --git a/packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx b/packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx index d97590273e..ea66b3bf73 100644 --- a/packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx +++ b/packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx @@ -131,12 +131,12 @@ describe('ObjectMapSchema declares what the renderer reads (type pins)', () => { * The bound on (a), measured rather than assumed — the first draft of this * file pinned the opposite and `tsc` said otherwise (TS2578 on both). * - * `BaseSchema` carries `[key: string]: any` ("additional properties specific + * `BaseSchema` carried `[key: string]: any` ("additional properties specific * to the component type"), and `ObjectMapSchema extends BaseSchema`. So a - * misspelled key at the TOP level type-checks — for every component schema in - * the repo, not just this one. That index signature is a platform-wide - * decision well outside objectui#5018, and removing it here would only move - * the hole. + * misspelled key at the TOP level type-checked — for every component schema + * in the repo, not just this one. That index signature was a platform-wide + * decision well outside objectui#5018; objectui#8347 removed it, and the row + * below flipped with it: the top level is closed now too. * * What #5018 buys is the half that matters for the card's symptom: `ObjectMapConfig` * is a plain interface with no index signature, so the `map` BLOCK is closed @@ -144,12 +144,14 @@ describe('ObjectMapSchema declares what the renderer reads (type pins)', () => { * empty map. Pinning the real boundary keeps the next reader from believing * the whole surface is closed. */ - it('does NOT reject a misspelled TOP-LEVEL key — BaseSchema is open', () => { + it('rejects a misspelled TOP-LEVEL key since objectui#8347 closed `BaseSchema`', () => { const schema: ObjectMapSchema = { type: 'object-map', objectName: 'stores', - // No `@ts-expect-error`: `BaseSchema`'s index signature admits this, and - // saying otherwise is what the first draft got wrong. + // This row carried no directive while `BaseSchema`'s index signature + // admitted the key (the first draft of this file pinned the opposite too + // early); objectui#8347 removed the signature. + // @ts-expect-error — `enableClusterng` is no member; the key is `enableClustering` enableClusterng: true, }; diff --git a/packages/plugin-map/src/index.tsx b/packages/plugin-map/src/index.tsx index fe787831e5..50ac8ca93d 100644 --- a/packages/plugin-map/src/index.tsx +++ b/packages/plugin-map/src/index.tsx @@ -141,9 +141,9 @@ ComponentRegistry.register('object-map', ObjectMapRenderer, { * * ## Why unregistering is the whole retirement here — measured, not assumed * - * ⚠️ `BaseSchema` closes with `[key: string]: any` and `BaseSchemaCore` ends - * `.passthrough()`, so a dropped MEMBER KEY is KEPT, not refused (the - * objectui#7664 failure). That hazard needs a schema face to arise on, and this + * ⚠️ `BaseSchemaCore` ends `.passthrough()` (and `BaseSchema` closed with + * `[key: string]: any` until objectui#8347), so a dropped MEMBER KEY is KEPT, + * not refused, on the zod face (the objectui#7664 failure). That hazard needs a schema face to arise on, and this * TYPE NAME never had one: in `@object-ui/types` the literal `'map'` appears * only in the stored view-type unions (`NamedListView.type`, * `defaultViewType`), never as a component node type, against a firing control diff --git a/packages/plugin-timeline/src/__tests__/object-timeline-navigation-8654.test.tsx b/packages/plugin-timeline/src/__tests__/object-timeline-navigation-8654.test.tsx index 1887ff268d..027d3de1e4 100644 --- a/packages/plugin-timeline/src/__tests__/object-timeline-navigation-8654.test.tsx +++ b/packages/plugin-timeline/src/__tests__/object-timeline-navigation-8654.test.tsx @@ -139,9 +139,10 @@ type Equal = type Expect = T; /** - * `Equal`, not `extends`: an undeclared member reads `any` through - * `BaseSchema`'s index signature, and `any` passes a one-way check on both - * sides — which is precisely the before-state this card removes. + * `Equal`, not `extends`: an undeclared member read `any` through + * `BaseSchema`'s index signature until objectui#8347, and `any` passes a + * one-way check on both sides — which is precisely the before-state this card + * removed. */ export type assertionTimelineNavigationIsTheSpecType = Expect>; @@ -150,9 +151,11 @@ export type assertionEqualCanFail = Expect; + /** + * The click handler `ObjectTimeline` composes onto the schema it hands over + * (it resolves the item's record and fires the overlay navigation). A runtime + * slot, not authorable metadata: it stays off `TimelineSchema` on purpose, + * per the objectui#6170 docblock in `@object-ui/types` (`data-display.ts`). + * While `BaseSchema` carried `[key: string]: any` the renderer's read of it + * compiled as `any`; objectui#8347 removed the signature, so it is typed here, + * where its only producer and its only consumer live. The gantt branch also + * passes the authored row and both indices. + */ + onItemClick?: (item: unknown, authored?: unknown, rowIndex?: number, itemIndex?: number) => void; }; diff --git a/packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts b/packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts index 551a208c15..1fe09889d3 100644 --- a/packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts +++ b/packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts @@ -33,8 +33,9 @@ * component"). The `(schema as any)` at that read was hiding a declaration, * not reaching past its absence, so the cast is gone. This is the same * shape objectui#8655's body recorded for plugin-detail's `add`. - * - `navigation` is **UNDECLARED** — it survives on `BaseSchema`'s - * `[key: string]: any` alone. + * - `navigation` was **UNDECLARED** at this card — it survived on + * `BaseSchema`'s `[key: string]: any` alone (declared since objectui#11168 + * slice 3). * * ⚠️ And typing the prop made SIX more reads answerable that `any` had hidden — * the card calls that the expected outcome, ⛔ not scope creep. Five are @@ -116,9 +117,10 @@ * * ## The ceiling, stated so nobody reads this file as claiming more * - * `BaseSchema` ends in `[key: string]: any`. Typing the prop makes the question - * ANSWERABLE; it refuses nothing at the read site and rejects no misspelling - * (objectui#5155 / objectui#7927). The counter-probe below pins that honestly. + * `BaseSchema` ended in `[key: string]: any` when this was written. Typing the + * prop made the question ANSWERABLE; it refused nothing at the read site and + * rejected no misspelling (objectui#5155 / objectui#7927). objectui#8347 removed + * the signature, and the counter-probe below is flipped. */ /// // ⚠️ The triple-slash reference is the route, ⛔ not an oversight. This package's @@ -244,8 +246,9 @@ type IsAny = 0 extends (1 & T) ? true : false; /** * ⭐ THE instrument of this card, at the type level: the DECLARED member names * of `T`, with any index signature removed by key remapping. `keyof` alone - * cannot answer — `BaseSchema` ends in `[key: string]: any`, so `keyof` of any - * node schema is `string | number` and every token "extends" it. That is the + * could not answer while `BaseSchema` ended in `[key: string]: any` (until + * objectui#8347): `keyof` of any node schema was `string | number` and every + * token "extends" it. That is the * same blindness `checker.getPropertyOfType` avoids by returning a SYMBOL only * for a real member, and this is its compile-time twin. */ @@ -293,15 +296,17 @@ export type _TreeIsDeclared = Expect>; export type _StaticDataIsDeclared = Expect>; /** - * ⚠️ THE CEILING. An undeclared key still compiles and still types `any`, so - * declaring buys VALUE typing and never buys refusal of a misspelling. Both - * halves are pinned: the control key resolves to `any`, and so does a typo — - * while a declared key (`navigation`, since objectui#11168 slice 3) no longer - * does, which is the value typing declaring bought. + * THE CEILING, as it was: an undeclared key compiled and typed `any`, so + * declaring bought VALUE typing and never refusal of a misspelling. objectui#8347 + * removed `BaseSchema`'s index signature, so the control key and the typo are + * no members now, and a declared key (`navigation`, since objectui#11168 slice + * 3) still resolves to its own type, not `any`. */ -export type _UndeclaredStillResolvesToAny = Expect>; +export type _UndeclaredIsNoMember = Expect>; export type _DeclaredNoLongerResolvesToAny = Expect, false>>; -export type _MisspellingStillAdmitted = Expect>; +export type _MisspellingIsNoMember = Expect>; +/** Lit control for the detector: `IsAny` does answer `true`, so the line above is a reading. */ +export type _IsAnyCanAnswerTrue = Expect>; /** The node still type-checks the way a host writes it, with no cast. */ const node: ObjectTreeProps['schema'] = { diff --git a/packages/plugin-tree/src/ObjectTree.tsx b/packages/plugin-tree/src/ObjectTree.tsx index 115d1839f0..9b57e954c4 100644 --- a/packages/plugin-tree/src/ObjectTree.tsx +++ b/packages/plugin-tree/src/ObjectTree.tsx @@ -132,10 +132,12 @@ export interface ObjectTreeProps { * * ## ⚠️ The CEILING, so nobody reads this as more than it is * - * `BaseSchema` ends in `[key: string]: any`, so an UNDECLARED key read off this - * type still compiles and still types `any`. What the annotation buys is that the - * question becomes ANSWERABLE by the checker, ⛔ not that an undeclared read is - * refused — the same ceiling objectui#5155 / objectui#7927 record for the mirror. + * `BaseSchema` ended in `[key: string]: any` when this was written, so an + * UNDECLARED key read off this type still compiled and typed `any`. What the + * annotation bought was that the question became ANSWERABLE by the checker, ⛔ not + * that an undeclared read was refused — the same ceiling objectui#5155 / + * objectui#7927 recorded for the mirror. objectui#8347 removed the signature, so + * an undeclared read off this type no longer compiles. */ schema: ObjectTreeSchema; dataSource?: DataSource; @@ -261,7 +263,8 @@ function fieldKey(f: any): string | undefined { * `checker.getPropertyOfType` cannot say declared-or-not through `any` — it * answers `undefined` for a key this node certainly declares exactly as it does * for a nonsense token. The annotation is what makes those six ANSWERABLE; it - * refuses nothing, because `BaseSchema` ends in `[key: string]: any`. + * refused nothing while `BaseSchema` ended in `[key: string]: any` (since + * objectui#8347 it refuses an undeclared read too). * * objectui#8253's ruling said to declare the key only if the console writes it — * measured, it does not (`CreateViewDialog.tsx`'s `tree` slot collects diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index ef2d00c1e8..4b29146535 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -2138,7 +2138,8 @@ export const ObjectView: React.FC = ({ // ⛔ Do not restore this fallback as a convenience. Authoring // kanban conditional formatting has two declared homes; a top-level key // that nothing declares, nothing publishes in the registry `inputs` and - // tsc cannot see (BaseSchema's index signature) is precisely the + // tsc could not see (BaseSchema's index signature, until objectui#8347) is + // precisely the // "renderer reads it, manifest denies it" condition objectui#4648 / // objectui#5091 exist to close. The host `renderListView` delegation // below still reads and forwards the key — that half is NOT narrowed. diff --git a/packages/plugin-view/src/__tests__/ObjectView.kanbanConditionalFormatting.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.kanbanConditionalFormatting.test.tsx index d1b81d2c27..4c07b0f064 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.kanbanConditionalFormatting.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.kanbanConditionalFormatting.test.tsx @@ -25,7 +25,7 @@ * read straight off the `object-view` node. Nothing declared it — * `ObjectViewSchema` has no such member, the `object-view` registry * registration does not publish it in `inputs`, and `BaseSchema`'s index - * signature means tsc says nothing either. It was honoured anyway, because this + * signature meant tsc said nothing either. It was honoured anyway, because this * branch runs exactly when no host supplied `renderListView` — the path the * REGISTERED renderer takes. * diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index cf7584f4b7..e00718a537 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -120,9 +120,8 @@ type VisibilityPredicate = Parameters[ * and drops the `string` / `number` / `symbol` index keys. * * Applied to `BaseSchema` by {@link InterpreterWorkingCopy}, so that type's - * `[key: string]: any` does not ride into the working copy. objectui#8347 - * removes that signature from `BaseSchema`; from then on this is the identity - * on it. + * `[key: string]: any` did not ride into the working copy. objectui#8347 + * removed that signature from `BaseSchema`, so this is the identity on it now. */ type DeclaredMembersOf = { [K in keyof T as string extends K @@ -1050,7 +1049,7 @@ function withoutAuthoredObjectFields(bag: T): T { * `element:record_picker`, a stored page document under its page kind), and * the types an application declares in `CustomNodeRegistry`. It has no * `type: string` arm, so a node whose `type` no declaration names is refused - * here, and each node's own keys are judged: once objectui#8347 removes + * here, and each node's own keys are judged: since objectui#8347 removed * `BaseSchema`'s index signature, a misspelled key is refused while the * declared spelling compiles. ⛔ The prop is widened by declared types only, * never by an index signature, a `Record` or a `type: string` arm. diff --git a/packages/react/src/__tests__/SchemaNode.reconciliation.test.ts b/packages/react/src/__tests__/SchemaNode.reconciliation.test.ts index 2453555738..1cf7641d18 100644 --- a/packages/react/src/__tests__/SchemaNode.reconciliation.test.ts +++ b/packages/react/src/__tests__/SchemaNode.reconciliation.test.ts @@ -102,8 +102,8 @@ export const plainStringIsANode: CoreSchemaNode = 'Plain string'; export const nullishIsANode: CoreSchemaNode = null; // Authored as the `text` node it is (objectui#11349). `value` is the spelling // `TextSchema` retired for `content` (objectui#7016), and a literal checked -// against the union may author only `BaseSchema`'s own keys once objectui#8347 -// removes its index signature. Same string, same pin. +// against the union may author only `BaseSchema`'s own keys now that +// objectui#8347 has removed its index signature. Same string, same pin. const textNode: TextSchema = { type: 'text', content: 'Hello' }; export const objectIsStillANode: CoreSchemaNode = textNode; diff --git a/packages/react/src/__tests__/SchemaRenderer.aria.test.tsx b/packages/react/src/__tests__/SchemaRenderer.aria.test.tsx index 4a5745a8b0..8d93c0ada6 100644 --- a/packages/react/src/__tests__/SchemaRenderer.aria.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.aria.test.tsx @@ -24,8 +24,8 @@ import { SchemaRenderer } from '../SchemaRenderer'; * `@objectstack/spec`'s `AriaProps`, the vocabulary that reader documents. The * node also carries the `content` that `TestWidget` renders. * - * Each literal is checked against this node, so its keys stay checked once - * objectui#8347 removes `BaseSchema`'s index signature. It is declared to + * Each literal is checked against this node, so its keys stay checked now that + * objectui#8347 has removed `BaseSchema`'s index signature. It is declared to * `@object-ui/types` through `CustomNodeRegistry` below, the way an * application declares a type it registers (objectui#11466): the `schema` * prop takes the declared node types only. diff --git a/packages/react/src/__tests__/SchemaRenderer.errorBoundary.test.tsx b/packages/react/src/__tests__/SchemaRenderer.errorBoundary.test.tsx index 312500bf0e..563b5e3e45 100644 --- a/packages/react/src/__tests__/SchemaRenderer.errorBoundary.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.errorBoundary.test.tsx @@ -46,8 +46,8 @@ const StableWidget: React.FC = (props) => ( * The node a `stable-widget` case authors (objectui#11349): `BaseSchema` plus * the `content` that `StableWidget` renders. Each literal is checked against * this node rather than against the `BaseSchema` the `schema` prop accepts, so - * `content` stays checked once objectui#8347 removes `BaseSchema`'s index - * signature. + * `content` stays checked now that objectui#8347 has removed `BaseSchema`'s + * index signature. */ type StableWidgetNode = BaseSchema & { type: 'stable-widget'; content?: string }; const stableWidget = (schema: StableWidgetNode): StableWidgetNode => schema; diff --git a/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx b/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx index 7d27ccdc2c..8293db35a7 100644 --- a/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx @@ -32,7 +32,8 @@ import type { PageComponent } from '@objectstack/spec/ui'; * `BaseSchema` does not declare the key. `@objectstack/spec` declares it on * `PageComponentSchema`, and the alias case below is typed by reference to * that row. It is checked against this node, not against `BaseSchema`, so the - * key stays checked once objectui#8347 removes `BaseSchema`'s index signature. + * key stays checked now that objectui#8347 has removed `BaseSchema`'s index + * signature. */ type DeprecatedVisibilityNode = BaseSchema & Pick & { type: 'test-component' }; const deprecatedVisibilityNode = (schema: DeprecatedVisibilityNode): DeprecatedVisibilityNode => schema; diff --git a/packages/react/src/__tests__/SchemaRenderer.scopedStyleSchemaIdentity.test.tsx b/packages/react/src/__tests__/SchemaRenderer.scopedStyleSchemaIdentity.test.tsx index a47c66b213..458b5a84ac 100644 --- a/packages/react/src/__tests__/SchemaRenderer.scopedStyleSchemaIdentity.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.scopedStyleSchemaIdentity.test.tsx @@ -61,7 +61,7 @@ declare module '@object-ui/types' { * reference to the spec row that declares it (`PageComponentSchema`). * `BaseSchema` declares the key on no arm this test uses. The literal is * checked against this node rather than against the `BaseSchema` the `schema` - * prop accepts, so the key stays checked once objectui#8347 removes + * prop accepts, so the key stays checked now that objectui#8347 has removed * `BaseSchema`'s index signature. */ type ScopedStyleNode = BaseSchema & Pick & { content?: string }; diff --git a/packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx b/packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx index 5eb2bb3e21..8b10d7932e 100644 --- a/packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx @@ -64,7 +64,7 @@ const TextKeyProbe = ({ schema }: any) => ( * The node a `TextKeyProbe` case authors directly as a `schema` prop * (objectui#11349): `BaseSchema` plus the two text keys the probe reads. The * literal is checked against this node rather than against the `BaseSchema` - * the prop accepts, so its keys stay checked once objectui#8347 removes + * the prop accepts, so its keys stay checked now that objectui#8347 has removed * `BaseSchema`'s index signature. */ type TextKeyProbeNode = BaseSchema & { type: 'test:probe-4795'; value?: string; title?: string }; diff --git a/packages/react/src/utils/propsBagDiagnostic.ts b/packages/react/src/utils/propsBagDiagnostic.ts index 5469d86fba..85e87464cf 100644 --- a/packages/react/src/utils/propsBagDiagnostic.ts +++ b/packages/react/src/utils/propsBagDiagnostic.ts @@ -36,8 +36,9 @@ import { isConfigBag } from './configBag.js'; * (objectui#6665): the `props` leg renders `No results found`, the `properties` * leg renders the rows. * - * Every gate accepts the `props` spelling — `BaseSchema` is `.passthrough()` - * with `[key: string]: any` — and `props` is documented as the annotated legacy + * The tolerant gates accept the `props` spelling — the zod `BaseSchema` is + * `.passthrough()`, and the TS face admitted it through `[key: string]: any` + * until objectui#8347 (a typed literal refuses it now) — and `props` is documented as the annotated legacy * alias of the config bag, so nothing between the author and the screen says a * word. That is the success-receipt shape objectui#6575 and objectui#6665 exist * to remove; this is the third instance, and the first at a tier that covers diff --git a/packages/types/README.md b/packages/types/README.md index 8b7ad2189c..0f1afa641e 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -90,7 +90,8 @@ import type { DataTableSchema, FlexSchema, CardSchema } from '@object-ui/types'; const dashboard: CardSchema = { type: 'card', title: 'User Management', - content: { + // A card's child channel is `children` (objectui#6771); `content` is no card key. + children: { type: 'data-table', columns: [ { header: 'Name', accessorKey: 'name' }, @@ -424,8 +425,9 @@ Components can nest indefinitely: ```typescript import type { ContainerSchema, FlexBlockNode, HeaderBarSchema, SidebarSchema } from '@object-ui/types'; -// The two leaves are annotated so the nesting below is checked against the -// shipped types rather than absorbed by `BaseSchema`'s index signature. +// The two leaves are annotated with their node types, so each is checked +// against its own declaration (`BaseSchema` carries no index signature to +// absorb a stray key, objectui#8347). // The sidebar draws what it composes through `children` (an app's navigation // lives in the app's metadata, not on this node), and `collapsible: false` // draws it in the page flow, beside `main`. @@ -446,9 +448,10 @@ const main: ContainerSchema = { // An authored `flex` node takes its props, the child list included, in its // `properties` bag. `FlexBlockNode` is that node, its bag closed, and each // entry of the bag's child list is checked as a node of its own `type`. The -// nested `flex` still names its type with `satisfies`: as an entry it also -// matches the post-hoist `FlexSchema`, whose `BaseSchema` index signature -// admits any `properties` until that signature is removed. +// nested `flex` names its type with `satisfies` too. That was needed while the +// post-hoist `FlexSchema` inherited `BaseSchema`'s index signature, which +// admitted any `properties`; objectui#8347 removed it, so the entry is judged +// as `FlexBlockNode` either way, and the annotation stays as documentation. const page: FlexBlockNode = { type: 'flex', properties: { diff --git a/packages/types/examples/dashboard.ts b/packages/types/examples/dashboard.ts index 5f4b6a77f2..33d960bda9 100644 --- a/packages/types/examples/dashboard.ts +++ b/packages/types/examples/dashboard.ts @@ -18,10 +18,11 @@ import type { FlexBlockNode, SidebarSchema, HeaderBarSchema, CardSchema, DataTab // An authored `flex` node takes its props, the child list included, in its // `properties` bag (objectui#11276). `FlexBlockNode` is that node, its bag // closed (objectui#11468), and each entry of the bag's child list is typed as -// a node (objectui#11564). A `flex` entry also matches the post-hoist -// `FlexSchema`, whose `BaseSchema` index signature admits any `properties` -// until objectui#8347 removes it, so the nested `flex` node is checked with -// `satisfies FlexBlockNode`. +// a node (objectui#11564). The nested `flex` node is checked with +// `satisfies FlexBlockNode`: that was needed while a `flex` entry also matched +// the post-hoist `FlexSchema`, whose `BaseSchema` index signature admitted any +// `properties`. objectui#8347 removed the signature, so the entry is judged as +// `FlexBlockNode` either way; the annotation stays as documentation. export const dashboardSchema: FlexBlockNode = { type: 'flex', className: 'h-screen', diff --git a/packages/types/src/__tests__/action-callback-retired-7068.test.ts b/packages/types/src/__tests__/action-callback-retired-7068.test.ts index 0ae8f38df2..d79fe034d1 100644 --- a/packages/types/src/__tests__/action-callback-retired-7068.test.ts +++ b/packages/types/src/__tests__/action-callback-retired-7068.test.ts @@ -29,9 +29,10 @@ * * ## Why a tombstone on the keys, and a deletion of the type * - * `BaseSchema` is `.passthrough()` on the mirror and carries `[key: string]: any` - * on the interface, so DELETING the two keys would ADMIT an authored callback - * unchecked on both faces — kept, inert, and green. The keys therefore stay + * `BaseSchema` is `.passthrough()` on the mirror and carried `[key: string]: any` + * on the interface until objectui#8347, so DELETING the two keys would have + * ADMITTED an authored callback unchecked on both faces — kept, inert, and green + * (the interface refuses it on a fresh literal now, as the base control shows). The keys therefore stay * declared as `?: never` / `retirementTombstone()` (the PR #7761 / #7769 shape), * and the base-vs-extended contrast is measured below on both faces. The * standalone `ActionCallback` type and its mirror have no such escape hatch and @@ -200,7 +201,7 @@ describe('the retirement narrows exactly `onSuccess` / `onFailure` (objectui#706 const result = ActionSchema.safeParse({ ...LEGACY_ACTION, notAKeyAtAll: 'anything' }); expect(result.success).toBe(true); if (!result.success) return; - expect((result.data as Record).notAKeyAtAll).toBe('anything'); + expect((result.data as unknown as Record).notAKeyAtAll).toBe('anything'); }); }); @@ -313,12 +314,14 @@ describe('no docs fence authors the retired callback shape any more (objectui#70 /* ── the TS half ────────────────────────────────────────────────────────── */ describe('legacy ActionSchema.onSuccess / onFailure are RETIRED — the TS half of the tombstone (objectui#7068)', () => { - it('refuses both retired keys at compile time, in the form authors actually write — and beats the inherited index signature', () => { + it('refuses both retired keys at compile time, in the form authors actually write — by name, as `never` tombstones, not as undeclared keys', () => { // On the pre-fix tree both assignments were LEGAL (`ActionCallback | undefined`), // so each directive would be unused and `tsc -p tsconfig.test.json` fails the // build with TS2578 naming the line — red before the fix in `type-check`, not - // in vitest. `BaseSchema` carries `[key: string]: any`; a declared `never` - // member wins over it, which is why this is a tombstone and not a deletion. + // in vitest. `BaseSchema` carried `[key: string]: any` when this was written; + // a declared `never` member wins over it, which is why this is a tombstone and + // not a deletion (since objectui#8347 a deletion is refused on a fresh literal + // too, unnamed). const retired: ActionSchemaTS = { type: 'action', label: 'Load', @@ -338,8 +341,11 @@ describe('legacy ActionSchema.onSuccess / onFailure are RETIRED — the TS half redirect: '/done', }; - // BASE CONTROL on the TS face: the same literal IS a legal `BaseSchema` — the - // acceptance a deleted member would have fallen through to. + // BASE CONTROL on the TS face: the same literal WAS a legal `BaseSchema` — the + // acceptance a deleted member would have fallen through to — until + // objectui#8347 removed `BaseSchema`'s index signature. The base refuses it + // now too, so a deleted member is no longer silently acceptable here. + // @ts-expect-error — `BaseSchema` declares no `onSuccess` and no index signature (objectui#8347). const base: BaseSchemaTS = { type: 'action', onSuccess: { type: 'toast', message: 'ok' } }; expect([retired, migrated, base]).toHaveLength(3); diff --git a/packages/types/src/__tests__/ai-zero-read-members-retired-8178.test.ts b/packages/types/src/__tests__/ai-zero-read-members-retired-8178.test.ts index 48a2277096..41c68814be 100644 --- a/packages/types/src/__tests__/ai-zero-read-members-retired-8178.test.ts +++ b/packages/types/src/__tests__/ai-zero-read-members-retired-8178.test.ts @@ -44,10 +44,12 @@ * * ## Why tombstones and not deletions — the carrier decides * - * All three schemas extend {@link BaseSchema}, which carries - * `[key: string]: any`. On such a carrier a DELETED optional member is absorbed - * silently at any value: the index signature defeats excess-property checking - * on a fresh literal and the weak-type check on a widened one. Deletion would + * All three schemas extend {@link BaseSchema}, which carried + * `[key: string]: any` when this was written. On such a carrier a DELETED + * optional member was absorbed silently at any value: the index signature + * defeated excess-property checking on a fresh literal and the weak-type check + * on a widened one (since objectui#8347 a fresh literal is refused, and a + * widened value still rides through). Deletion would * therefore have left exactly the silent no-op the retirement exists to end, * and the ruling's first pin — *refused by the schema types (compile-time)* — * would have been unsatisfiable. The routes are loud-vs-silent here, not @@ -235,11 +237,15 @@ describe('the controls — without these, a broken import would satisfy every pi expect([assist.showConfidence, recs.layout, query.showHistory]).toEqual([true, 'grid', true]); }); - it('an UNDECLARED key rides both shapes on this carrier — the DELETED row, live', () => { - // No directive on purpose: this is where each of the seven would sit had it - // been deleted instead of tombstoned. `BaseSchema`'s `[key: string]: any` - // absorbs it at any value in every shape, so a deletion produces no - // diagnostic at all — it is not a quieter refusal, it is none. + it('an UNDECLARED key is refused on a fresh literal, and still rides a widened value — the DELETED row, live', () => { + // This is where each of the seven would sit had it been deleted instead of + // tombstoned. Until objectui#8347 `BaseSchema`'s `[key: string]: any` + // absorbed it at any value in every shape, so a deletion produced no + // diagnostic at all. The signature is gone: a fresh literal is refused, + // with no name for the remedy, while a value that reached its annotation + // through a wider variable is still not re-checked (no excess-property + // check on a non-fresh object). The tombstone is what refuses both. + // @ts-expect-error — `AIFormAssistSchema` declares no `bogusUndeclared` (objectui#8347) const fresh: AIFormAssistSchema = { type: 'ai-form-assist', suggestions, bogusUndeclared: 1 }; const raw = { type: 'ai-recommendations' as const, recommendations, bogusUndeclared: 1 }; const widened: AIRecommendationsSchema = raw; @@ -250,8 +256,10 @@ describe('the controls — without these, a broken import would satisfy every pi // `AIFieldSuggestion` and `AIRecommendationItem` declare no index // signature, so the compiler's two ordinary guards fire here: excess-property // checking on a fresh literal (TS2353) and the weak-type check on a - // lone-key widened value (TS2559). Their firing proves the silence above is - // the index signature and not a blind run. + // lone-key widened value (TS2559). Their firing proved the silence above + // was the index signature and not a blind run; since objectui#8347 the only + // silence left above is the widened value's (no excess-property check on a + // non-fresh object). const fresh: AIFieldSuggestion = { fieldName: 'company', value: 'ObjectStack Inc.', diff --git a/packages/types/src/__tests__/ai-zod-arms-10859.test.ts b/packages/types/src/__tests__/ai-zod-arms-10859.test.ts index 257eaca0af..ceb2adb91f 100644 --- a/packages/types/src/__tests__/ai-zod-arms-10859.test.ts +++ b/packages/types/src/__tests__/ai-zod-arms-10859.test.ts @@ -81,7 +81,7 @@ type Expect = T; type ShapeOf = M extends { shape: infer S } ? S : never; /** What a shape entry ACCEPTS (input side, so `.optional()` shows). */ type InputOf = T extends z.ZodType ? z.input : never; -/** The declaration's DECLARED keys — the `BaseSchema` index signature dropped. */ +/** The declaration's DECLARED keys — any index signature dropped (`BaseSchema` carried one until objectui#8347). */ type DeclaredKeys = Extract< keyof { [K in keyof D as string extends K ? never : number extends K ? never : K]: D[K] }, string diff --git a/packages/types/src/__tests__/alert-dialog-footer-keys-refusal-7963.test.ts b/packages/types/src/__tests__/alert-dialog-footer-keys-refusal-7963.test.ts index 289c8f29d4..66972f9f96 100644 --- a/packages/types/src/__tests__/alert-dialog-footer-keys-refusal-7963.test.ts +++ b/packages/types/src/__tests__/alert-dialog-footer-keys-refusal-7963.test.ts @@ -25,8 +25,9 @@ * * ## Why a REFUSAL and not a bare deletion * - * `BaseSchemaCore` ends `.passthrough()` and the TS `BaseSchema` closes with - * `[key: string]: any`, so a dropped MEMBER key is KEPT, not refused. Deleting + * `BaseSchemaCore` ends `.passthrough()` (and the TS `BaseSchema` closed with + * `[key: string]: any` until objectui#8347), so a dropped MEMBER key is KEPT, + * not refused, on the zod face. Deleting * the three declarations would have left the silent accept exactly as it was and * thrown the diagnostic away with it. `retirementTombstone()` keeps the key * DECLARED and unwritable — that is what makes the refusal loud, and it is why diff --git a/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts b/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts index 9d58318274..88bad5fc99 100644 --- a/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts +++ b/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts @@ -9,7 +9,7 @@ * (draws `AlertDialogCancel` only when truthy), `schema.actionText` (draws * `AlertDialogAction` only when truthy) and `schema.onAction` (that button's * `onClick`) — and NONE of the four was declared on the TS interface or in the - * zod mirror. They were accepted only because `BaseSchema` carries + * zod mirror. They were accepted only because `BaseSchema` carried * `[key: string]: any` and the mirror is `.passthrough()`: no editor completed * them, no page named them, and a wrong-typed value rode through unexamined. * Meanwhile the three keys the type DID declare for the same affordance diff --git a/packages/types/src/__tests__/anyschema-declared-node-types-11478.test.ts b/packages/types/src/__tests__/anyschema-declared-node-types-11478.test.ts index 1b2dd17af0..cfd3e8147d 100644 --- a/packages/types/src/__tests__/anyschema-declared-node-types-11478.test.ts +++ b/packages/types/src/__tests__/anyschema-declared-node-types-11478.test.ts @@ -19,7 +19,7 @@ * * The population is computed, never listed. The compiler API reads this * package's own sources with its own `tsconfig.json`, so nothing depends on a - * built `dist/`, the same reason `partial-schema-collapse-pin.test.ts` gives. + * built `dist/`, the same reason `object-view-slot-key-lists.test.ts` gives. * It takes every type exported from the root barrel or from a published * subpath entry (read from `package.json` `exports`) that: * diff --git a/packages/types/src/__tests__/app-actions-retired-7469.test.ts b/packages/types/src/__tests__/app-actions-retired-7469.test.ts index a8ed84b8cf..bbbd368eca 100644 --- a/packages/types/src/__tests__/app-actions-retired-7469.test.ts +++ b/packages/types/src/__tests__/app-actions-retired-7469.test.ts @@ -26,8 +26,9 @@ * * ## Why a tombstone and not a deletion * - * `BaseSchema` is `.passthrough()` on the zod side and carries an index - * signature on the TS side, so an UNDECLARED key is not refused, it is KEPT. + * `BaseSchema` is `.passthrough()` on the zod side (and carried an index + * signature on the TS side until objectui#8347), so an UNDECLARED key is not + * refused there, it is KEPT. * Block (d) takes that reading on this very mirror, so the reason is a * measurement and not prose. * @@ -167,10 +168,12 @@ type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; type Expect = T; -/** `Equal`, not `extends`: an UNDECLARED member reads `any` through the index signature. */ +/** `Equal`, not `extends`: an UNDECLARED member read `any` through the index signature until objectui#8347. */ export type assertionActionsRetired = Expect>; -/** The helper can FAIL — synthetic control (an undeclared key reads `any`). */ -export type assertionEqualCanFail = Expect, false>>; +/** The helper can FAIL — synthetic control (a declared member is not `undefined`). */ +export type assertionEqualCanFail = Expect, false>>; +/** An undeclared key is not a member at all since objectui#8347 — it read `any` before. */ +export type assertionUndeclaredKeyIsNoMember = Expect>; /** Keeps the retired-import directive above honest: the name is USED, so a * re-exported `AppAction` cannot leave the directive satisfied by an * unused-import diagnostic instead of the missing-export one. */ diff --git a/packages/types/src/__tests__/app-menu-item-shortcut-refusal-7719.test.ts b/packages/types/src/__tests__/app-menu-item-shortcut-refusal-7719.test.ts index af6a3edaf0..45f1526dbd 100644 --- a/packages/types/src/__tests__/app-menu-item-shortcut-refusal-7719.test.ts +++ b/packages/types/src/__tests__/app-menu-item-shortcut-refusal-7719.test.ts @@ -40,7 +40,8 @@ * * objectui#7997 (`DetailViewSchema.related`) recorded that a dropped member key * is KEPT: `BaseSchemaCore` ends `.passthrough()` and the TypeScript - * `BaseSchema` closes with an any-valued index signature. ⛔ That mechanism does + * `BaseSchema` closed with an any-valued index signature (until objectui#8347). + * ⛔ That mechanism does * NOT apply to this surface and assuming it would misdescribe the change. * `MenuItemSchema` is a plain `z.object` under a `z.lazy`, built on no base, and * `AppMenuItem` declares no index signature ⇒ an undeclared key is STRIPPED, diff --git a/packages/types/src/__tests__/authoring-nodes-11364.test.ts b/packages/types/src/__tests__/authoring-nodes-11364.test.ts index a21b3ceb18..63bf99e786 100644 --- a/packages/types/src/__tests__/authoring-nodes-11364.test.ts +++ b/packages/types/src/__tests__/authoring-nodes-11364.test.ts @@ -20,12 +20,13 @@ * * The card's pin is "a misspelled key inside an `element:text` `properties` * bag stops type-checking once `BaseSchema`'s index signature is gone, and the - * spec's spelling compiles". Today `BaseSchema` still carries + * spec's spelling compiles". When this was written `BaseSchema` still carried * `[key: string]: any`, so the union `SchemaRenderer`'s `schema` prop declares - * accepts that misspelling. `PostRemovalSchema` below is that same union with - * `BaseSchema`'s index signatures stripped at the type level. It simulates the - * removal without editing anything, so the pin runs in every type-check now and - * keeps running after the removal lands. `@object-ui/react` pins the prop's + * accepted that misspelling. `PostRemovalSchema` below is that same union with + * `BaseSchema`'s index signatures stripped at the type level. It simulated the + * removal without editing anything, so the pin ran in every type-check before + * objectui#8347 and keeps running now that the removal has landed (the strip is + * the identity on `BaseSchema` since then). `@object-ui/react` pins the prop's * union exactly (`SchemaRenderer.propsResolution.test.ts`, assertion 1), and * this file substitutes only its `BaseSchema` member. * diff --git a/packages/types/src/__tests__/authoring-nodes-bag-carriers-11468.test.ts b/packages/types/src/__tests__/authoring-nodes-bag-carriers-11468.test.ts index 94b9eb9201..9f5fee5d5e 100644 --- a/packages/types/src/__tests__/authoring-nodes-bag-carriers-11468.test.ts +++ b/packages/types/src/__tests__/authoring-nodes-bag-carriers-11468.test.ts @@ -15,9 +15,9 @@ * As in `authoring-nodes-11364.test.ts`, most assertions here are TYPE-level, * judged by this package's `type-check` (its `tsc -p tsconfig.test.json` leg), * never by vitest. `PostRemovalSchema` is `SchemaRenderer`'s `schema` prop with - * `BaseSchema`'s index signature stripped at the type level, so the pins run - * now, while the signature is still present, and keep running after - * objectui#8347 removes it. + * `BaseSchema`'s index signature stripped at the type level, so the pins ran + * while the signature was still present and keep running now that + * objectui#8347 has removed it. */ import { describe, expect, it } from 'vitest'; diff --git a/packages/types/src/__tests__/base-bind-declared.test.ts b/packages/types/src/__tests__/base-bind-declared.test.ts index c65a698749..8b239b7564 100644 --- a/packages/types/src/__tests__/base-bind-declared.test.ts +++ b/packages/types/src/__tests__/base-bind-declared.test.ts @@ -59,21 +59,25 @@ * ## The ceiling, stated rather than assumed (objectui#5155 / objectui#6269) * * Same ceiling as objectui#5903's gantt pin and objectui#6170's timeline pin. - * `BaseSchema` carries `[key: string]: any` on the TS side and is - * `.passthrough()` on the zod side, so: + * `BaseSchema` carried `[key: string]: any` on the TS side until objectui#8347 + * and is `.passthrough()` on the zod side, so: * - * - an UNDECLARED key is still accepted by both halves. Declaring `bind` did - * NOT buy rejection of `bindTo`, and the counter-probe below pins that + * - an UNDECLARED key is still accepted by the zod half (and was by the TS + * half while the signature stood). Declaring `bind` did NOT buy rejection of + * `bindTo` there, and the counter-probe below pins that * honestly rather than letting a reader assume otherwise; * - a DECLARED key IS validated. `bind: 42` type-checked and parsed green * before this card and is refused by both halves now — the accept-set * narrowing this card lands; - * - on the TS side a read site can never be the detector, because the index - * signature types `schema.bind` as `any` either way. So the compile-time - * pin is the `@ts-expect-error` block at the bottom: remove the - * declaration and the member resolves to `any`, the wrong-typed assignment - * starts succeeding, and the now-unused directive fails the build (TS2578) - * NAMING the key. `tsconfig.test.json` compiles this file, so that is real + * - on the TS side a read site could never be the detector while the index + * signature typed `schema.bind` as `any` either way. So the compile-time + * pin is the `@ts-expect-error` block at the bottom: when written, removing + * the declaration made the member resolve to `any`, the wrong-typed + * assignment started succeeding, and the now-unused directive failed the + * build (TS2578) NAMING the key. Since objectui#8347 a removal makes the + * indexed access itself an error, which the directive swallows, so a + * deletion is caught by the well-typed counter-probe beside it instead (its + * `bind` becomes an excess key). `tsconfig.test.json` compiles this file, so that is real * enforcement and not decoration (objectui#3009). * * The narrowing only refuses what already crashed: `useDataScope` is @@ -141,10 +145,13 @@ describe('BaseSchema (zod) — `bind` is mirrored and validated', () => { describe('BaseSchema (TS) — compile-time pin on `bind`', () => { it('refuses a wrong-typed `bind`', () => { - // This directive fails the build (TS2578, "unused '@ts-expect-error'") the - // moment `bind` stops being declared, because the member then resolves to - // `any` through the index signature and the assignment starts succeeding. - // That failure is the signal this card exists to create. + // This directive failed the build (TS2578, "unused '@ts-expect-error'") the + // moment `bind` stopped being declared, while the index signature resolved + // the member to `any` and the assignment started succeeding. Since + // objectui#8347 a removal makes `BaseSchemaTS['bind']` itself an error, which + // this directive swallows, so the deletion signal comes from the well-typed + // counter-probe below; this directive still fails the build if `bind` is + // widened to accept a number. // @ts-expect-error — `bind` is declared `string | undefined`. const bind: BaseSchemaTS['bind'] = 42; diff --git a/packages/types/src/__tests__/base-schema-closed-face-8347.test.ts b/packages/types/src/__tests__/base-schema-closed-face-8347.test.ts new file mode 100644 index 0000000000..346a1e194e --- /dev/null +++ b/packages/types/src/__tests__/base-schema-closed-face-8347.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `BaseSchema` carries no index signature, so a misspelled key stops + * type-checking — the compile-fail pin objectui#7927's ruling made + * non-negotiable, executed by objectui#8347. + * + * ## What the ruling asked for + * + * objectui#7927 measured the defect with seven planted probes on + * `plugin-calendar`'s README. Its P5 renamed `titleField` to `titleFieldd` + * inside an annotated block and came back GREEN, while the type, optionality + * and payload-member probes went red: `BaseSchema` ended in + * `[key: string]: any`, so no annotation on any node type could catch a + * misspelled key. The ruling: remove the signature, and pin it with a fixture + * whose misspelled key must stop type-checking, in BOTH directions — the + * correct spelling must still compile. + * + * ## Why the rows below are FRESH literals + * + * TypeScript runs its excess-property check only on a fresh object literal + * written straight against its target. P5 as #7927 ran it does not qualify on + * today's README: that block is a type alias plus two assignments of declared + * variables, which tsc never excess-checks, so P5 stays green with or without + * the signature (measured on objectui#8347's census, with a lit control that + * went red). Every refusal row here is therefore a fresh literal, and the + * bound is pinned too (section 3), so nobody reads this file as claiming + * more than it does. + * + * ## Who reads it + * + * The rows are TYPE-level: `tsc -p tsconfig.test.json`, chained from this + * package's `type-check` script, is their reader. An `@ts-expect-error` that + * stops matching an error is itself an error (TS2578), so a signature put back + * on `BaseSchema` turns every refusal row below red. The vitest leg only keeps + * the bindings alive. + */ + +import { describe, it, expect } from 'vitest'; +import type { BaseSchema, ComponentRendererProps } from '../base'; +import type { CalendarViewSchema } from '../complex'; +import type { ButtonSchema } from '../form'; +import type { PartialSchema } from '../index'; + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/* ── 1. The declarations: no string index on the authoring face ──────────── */ + +/** `string extends keyof T` is true exactly when `T` carries a string index signature. */ +type HasStringIndex = string extends keyof T ? true : false; + +export type _BaseSchemaHasNoIndexSignature = Expect, false>>; +export type _NodeTypesInheritNone = Expect, false>>; +// ⛔ Renderer props are not the authoring face: `ComponentRendererProps` keeps +// its own signature on purpose (objectui#8347's card body), and this row is +// what notices it being removed by momentum. +export type _RendererPropsKeepTheirs = Expect, true>>; + +/* ── 2. The pin, both directions, on fresh literals ───────────────────────── */ + +// #7927's P5, turned red: the misspelled key is refused… +export const p5Misspelled: CalendarViewSchema = { + type: 'calendar-view', + // @ts-expect-error — `titleFieldd` is no member of `CalendarViewSchema`; the key is `titleField` + titleFieldd: 'subject', +}; +// …and the correct spelling still compiles. +export const p5Correct: CalendarViewSchema = { type: 'calendar-view', titleField: 'subject' }; + +// The same on the base itself, with a member every node inherits. +export const baseMisspelled: BaseSchema = { + type: 'text', + // @ts-expect-error — `classNmae` is no member of `BaseSchema`; the key is `className` + classNmae: 'font-bold', +}; +export const baseCorrect: BaseSchema = { type: 'text', className: 'font-bold' }; + +// A node nested in a slot is judged as its own node type (objectui#11466), +// and now every node type refuses a key it does not declare. +export const nestedMisspelled: BaseSchema = { + type: 'card', + // @ts-expect-error — `labell` is no member of the `button` node; the key is `label` (tsc reports it at the slot) + children: [{ type: 'button', labell: 'Save' }], +}; +export const nestedCorrect: BaseSchema = { type: 'card', children: [{ type: 'button', label: 'Save' }] }; + +/* ── 3. The bound: a NON-fresh value is not re-checked ───────────────────── */ + +// No directive on purpose. A value that reached its annotation through a +// variable of a wider type carries its extra key past the check: TypeScript's +// excess-property check is a fresh-literal check, not a structural one. This is +// why `plugin-calendar`'s README assignments cannot carry P5, and why the +// strict zod face (`StrictAnyComponentSchema`, objectui#8345), not this face, +// is the refusal for metadata that arrives as data. +const arrived = { type: 'calendar-view' as const, titleField: 'subject', titleFieldd: 'subject' }; +export const widenedIsNotRechecked: CalendarViewSchema = arrived; + +/* ── 4. `PartialSchema` works as written now (objectui#6397) ─────────────── */ + +// While the signature stood, `keyof T` absorbed every member name and this alias +// declared `type` alone. It keeps `T`'s named members now… +export type _PartialKeepsTheMembers = Expect, keyof ButtonSchema>>; +// …with `type` required and the rest optional… +export const partialButton: PartialSchema = { type: 'button' }; +// …and it refuses a misspelling like every node type. +export const partialMisspelled: PartialSchema = { + type: 'button', + // @ts-expect-error — `labell` is no member of `ButtonSchema`; the key is `label` + labell: 'Save', +}; + +describe('`BaseSchema` declares no index signature (objectui#8347, the objectui#7927 pin)', () => { + it('keeps the type-level rows alive — `tsc -p tsconfig.test.json` is their reader', () => { + expect([p5Correct.titleField, baseCorrect.className, partialButton.type]).toEqual(['subject', 'font-bold', 'button']); + expect([p5Misspelled, baseMisspelled, nestedMisspelled, nestedCorrect, widenedIsNotRechecked, partialMisspelled]) + .toHaveLength(6); + }); +}); diff --git a/packages/types/src/__tests__/base-schema-hidden-predicate.test.ts b/packages/types/src/__tests__/base-schema-hidden-predicate.test.ts index 673f4f24a4..d80e65f65f 100644 --- a/packages/types/src/__tests__/base-schema-hidden-predicate.test.ts +++ b/packages/types/src/__tests__/base-schema-hidden-predicate.test.ts @@ -46,9 +46,9 @@ * until objectui#7530 declared the envelope), invariantly. `Equal`, not `extends`: * the narrow `boolean` is assignable to the wide union, so a one-way check * stays green on a widening that never happened, and `BaseSchema`'s - * `[key: string]: any` index signature means a DELETED member reads `any`, - * which a one-way check also accepts. The overshoot is the live risk here, - * not a hypothetical. + * `[key: string]: any` index signature meant a DELETED member read `any` + * (until objectui#8347), which a one-way check also accepts. The overshoot + * was the live risk here, not a hypothetical. * 2. The three keys are asserted to carry the SAME declared type. The ruling's * words are "matching `visible` and `disabled` on both faces"; asserting * `hidden` alone would stay green if a later change narrowed one of the diff --git a/packages/types/src/__tests__/base-schema-label-vocabulary.test.ts b/packages/types/src/__tests__/base-schema-label-vocabulary.test.ts index 8ac34c3250..3e1ca89f4a 100644 --- a/packages/types/src/__tests__/base-schema-label-vocabulary.test.ts +++ b/packages/types/src/__tests__/base-schema-label-vocabulary.test.ts @@ -89,8 +89,9 @@ * exported member 'KeyedI18nLabel'` (the name does not exist yet). * 2. `keyedAriaLabelIsAuthorable` / `keyedAriaLabelWithParamsIsAuthorable` — * TS2322, an object is not assignable to `string`. The explicit - * `ariaLabel?: string` member wins over `BaseSchema`'s - * `[key: string]: any` index signature, which is precisely why the shipped + * `ariaLabel?: string` member won over `BaseSchema`'s + * `[key: string]: any` index signature (removed by objectui#8347), which is + * precisely why the shipped * fixture in `SchemaRenderer.aria.test.tsx` needed * `as unknown as BaseSchema`. * 3. `disabledPredicateStringIsAuthorable` — TS2322, `Type 'string' is not @@ -132,9 +133,9 @@ * both directions — the narrow `string` is assignable to the wide * `string | KeyedI18nLabel`, so a widening that never happened AND a widening * that overshot to `any` would both stay green. `BaseSchema`'s - * `[key: string]: any` makes the overshoot live rather than hypothetical: - * deleting a declared property altogether leaves it typed `any` and every - * fixture below still compiles. + * `[key: string]: any` made the overshoot live rather than hypothetical until + * objectui#8347: deleting a declared property altogether left it typed `any` + * and every fixture below still compiled. */ import { describe, it, expect } from 'vitest'; @@ -198,7 +199,8 @@ export type assertionDisabled = Expect< * inherits: a one-way `extends` is vacuous for a widening in BOTH directions — * the narrow `string` is assignable to the wide `string | I18nLabel`, so a * widening that never happened AND one that overshot to `any` would both stay - * green. `BaseSchema`'s `[key: string]: any` makes the overshoot live. + * green. `BaseSchema`'s `[key: string]: any` made the overshoot live until + * objectui#8347. */ export type assertionLabel = Expect< Equal< BaseSchema['label'], string | I18nLabel | undefined > diff --git a/packages/types/src/__tests__/base-schema-predicate-envelope-7530.test.ts b/packages/types/src/__tests__/base-schema-predicate-envelope-7530.test.ts index b3f4cf5225..32c1f0769c 100644 --- a/packages/types/src/__tests__/base-schema-predicate-envelope-7530.test.ts +++ b/packages/types/src/__tests__/base-schema-predicate-envelope-7530.test.ts @@ -34,8 +34,9 @@ * invariantly, and the three carry the SAME declared type. `Equal`, not * `extends`: the old `boolean | string` is assignable to the wide union, * so a one-way check stays green on a widening that never happened, and - * `BaseSchema`'s `[key: string]: any` index signature means a DELETED - * member reads `any`, which a one-way check also accepts. + * `BaseSchema`'s `[key: string]: any` index signature meant a DELETED + * member read `any` (until objectui#8347), which a one-way check also + * accepts. * 2. Twin parity -- `ExpressionWire` (TS) and `z.input` of * `ExpressionWireSchema` (zod) are the same union, so the two faces cannot * drift apart the way `hidden` did between #4581 and #7455. diff --git a/packages/types/src/__tests__/base-schema-visible-predicate.test.ts b/packages/types/src/__tests__/base-schema-visible-predicate.test.ts index ab99f591c0..654f1fe05c 100644 --- a/packages/types/src/__tests__/base-schema-visible-predicate.test.ts +++ b/packages/types/src/__tests__/base-schema-visible-predicate.test.ts @@ -45,10 +45,10 @@ * `boolean` is assignable to the wide `boolean | string`, so a widening that * never happened and a widening that overshot to `any` would both stay green. * Pinning the exact union is the only assertion that can go red for the right - * reason — and `BaseSchema`'s `[key: string]: any` index signature makes the - * overshoot a live risk rather than a hypothetical one, since deleting the - * declared property altogether would leave `visible` typed `any` and every - * fixture below still compiling. + * reason — and `BaseSchema`'s `[key: string]: any` index signature made the + * overshoot a live risk rather than a hypothetical one (until objectui#8347), + * since deleting the declared property altogether would have left `visible` + * typed `any` and every fixture below still compiling. * * Widened again by objectui#7530 (ruled 2026-09-04, option A): the expression * half is now `ExpressionWire` — the string-or-CEL-envelope union — on all three diff --git a/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts b/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts index 109a3a4c86..ccce968145 100644 --- a/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts +++ b/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts @@ -52,8 +52,9 @@ * * ## The authority is the mirror's `.shape`, never parse acceptance * - * `BaseSchema` is `.passthrough()` and carries `[key: string]: any`, so an - * undeclared `selectionMode` PARSES GREEN and type-checks — acceptance cannot + * `BaseSchema` is `.passthrough()` (and carried `[key: string]: any` until + * objectui#8347), so an undeclared `selectionMode` PARSES GREEN (and + * type-checked) — acceptance cannot * tell "declared" from "admitted unexamined" (the reading * `undeclared-but-consumed-keys-6150.test.ts` and `object-grid-title-mirrored` * established). Membership is therefore read off `.shape`, optionality off each diff --git a/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts b/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts index 0964967fd8..b1fc28efcd 100644 --- a/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts +++ b/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts @@ -163,7 +163,7 @@ describe('objectui#8355 · the compile-time fixtures above are real program inpu expect(canonicalBlockCompiles.calendar?.startDateField).toBe('kickoff'); expect(canonicalNodeCompiles.endDateField).toBe('wrapup'); expect((viewBlockRefusesBothByTsc.calendar as Record).dateField).toBe('kickoff'); - expect((nodeRefusesBothByTsc as Record).endField).toBe('wrapup'); + expect((nodeRefusesBothByTsc as unknown as Record).endField).toBe('wrapup'); }); }); diff --git a/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts b/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts index 01722bae1a..9bbda9d7b1 100644 --- a/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts +++ b/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts @@ -65,13 +65,16 @@ * * ## What declaring buys — and what it does NOT, measured not assumed * - * objectui#7927 measured the ceiling: `BaseSchema` ends in `[key: string]: any`, - * so no annotation here can catch a MISSPELLED key. `colourField` stays admitted - * on both faces, and the control assertions below PIN that, so nobody reads this - * file as claiming more than it does. What the ceiling does not cap is the VALUE + * objectui#7927 measured the ceiling: `BaseSchema` ended in `[key: string]: any`, + * so no annotation here could catch a MISSPELLED key. objectui#8347 removed the + * signature, so the TS face refuses `colourField` on a fresh literal now (pinned + * below); the tolerant zod face still admits it, and the control assertions + * below PIN that, so nobody reads this file as claiming more than it does. What + * the ceiling never capped is the VALUE * dimension, and that is the half this file pins — on the TS face through * `@ts-expect-error` directives that go UNUSED (TS2578, a hard type-check - * failure) the moment their member is deleted, and on the mirror through + * failure) the moment their member is widened (since objectui#8347 a deletion + * keeps them used and is caught by the `Equal` rows), and on the mirror through * refusals that land ON the key and reach `safeValidateSchema`, the path the * CLI's `validate` / `check` take. * @@ -80,7 +83,9 @@ * Membership is asserted on the mirror's OWN `.shape`, never on parse acceptance * (under `.passthrough()` acceptance cannot tell "declared" from "admitted * unexamined"). Type-level pins use invariant equality, so a member that fell - * back to the index signature reads as `any` and therefore as a failure. And + * back to the index signature read as `any` and therefore as a failure (since + * objectui#8347 removed the signature, a deleted member's indexed access does + * not compile at all). And * every claim carries a CONTROL asserted to hold the opposite verdict, so no * assertion can pass vacuously. * @@ -138,16 +143,16 @@ const NEWLY_DECLARED = ['colorField', 'allDayField'] as const; /** * A key the renderer never reads and neither face declares. Non-vacuity control - * for every "declared" assertion: it must stay `any` on the TS face and out of - * the mirror shape, while still being ADMITTED — the objectui#7927 ceiling, - * pinned rather than claimed away. + * for every "declared" assertion: it must stay no member of the TS face (it + * read `any` there until objectui#8347) and out of the mirror shape, while + * still being ADMITTED by the tolerant mirror's `.passthrough()`. */ const CONTROL_KEY = 'swatchField'; /** A declared-and-read control for the off-disk read census, read BARE. */ const READ_CONTROL_KEY = 'objectName'; /** The same, read through a CAST — the census must see both forms, not one. */ const CAST_READ_CONTROL_KEY = 'defaultView'; -/** The misspelling the ceiling still admits. Pinned, not fixed here. */ +/** The misspelling the tolerant mirror still admits (the TS face refuses it since objectui#8347). */ const MISSPELLING = 'colourField'; const CALENDAR_NODE = { type: 'object-calendar', objectName: 'event' } as const; @@ -163,8 +168,9 @@ type IsAny = 0 extends (1 & T) ? true : false; type IsOptional = Record extends Pick ? true : false; // `colorField` is DERIVED from the spec's `CalendarConfig`, so it resolves to -// that member's type. Delete the member and the indexed access falls back to -// `[key: string]: any`, making `IsAny` true and this `Equal` false. +// that member's type. Delete the member and the indexed access fell back to +// `[key: string]: any`, making `IsAny` true and this `Equal` false; since +// objectui#8347 it stops compiling instead. export type _ColorFieldIsString = Expect>; export type _ColorFieldIsNotAny = Expect, false>>; export type _ColorFieldIsOptional = Expect>; @@ -172,15 +178,21 @@ export type _AllDayFieldIsString = Expect, false>>; export type _AllDayFieldIsOptional = Expect>; // The control key is undeclared, exactly as the two above were before this card. -// Same instrument, opposite verdict — which is what makes the six lines above -// readings rather than a type that says `true` for everything. -export type _ControlKeyFallsThrough = Expect>; -// objectui#7927's ceiling, pinned rather than claimed away. -export type _MisspellingStillAdmitted = Expect>; +// Until objectui#8347 it fell through `BaseSchema`'s index signature and read +// `any`; that signature is gone, so it is no member at all. `IsAny` is the +// lit control that keeps the `IsNotAny` lines above readings rather than a +// type that says `false` for everything. +export type _IsAnyCanAnswerTrue = Expect>; +export type _ControlKeyIsNoMember = Expect>; +// objectui#7927's ceiling ("a misspelling is still admitted") was pinned here +// rather than claimed away; objectui#8347 removed it, so the misspelling is no +// member either, and a fresh literal carrying it is refused (below). +export type _MisspellingIsNoMember = Expect>; // The SIBLING interface in the same plugin — served by its OWN renderer, which // reads the same five flat keys — already declares all five. A member that fell -// back to the index signature reads as `any` and fails these, so these five +// back to the index signature read as `any` and failed these (since +// objectui#8347 a deleted member's indexed access does not compile), so these five // lines are what would catch the shared flat vocabulary drifting apart. type Declared = Equal, false>; const siblingPins: [ @@ -190,9 +202,9 @@ const siblingPins: [ Expect>, Expect>, ] = [true, true, true, true, true]; -// Control: the same instrument returns the OPPOSITE verdict for a key the -// sibling does not declare either, so the five above are readings. -export type _SiblingControlFallsThrough = Expect>; +// Control: a key the sibling does not declare either is no member of it — it +// read `any` through the index signature until objectui#8347 removed it. +export type _SiblingControlIsNoMember = Expect>; // The TS face ACCEPTS the flat handoff shape… const calendarLiteral: TsObjectCalendarSchema = { @@ -202,11 +214,16 @@ const calendarLiteral: TsObjectCalendarSchema = { }; // …and REFUSES wrong-typed values the index signature used to admit. Each -// directive goes unused — TS2578, a hard failure — if its member is deleted. +// directive goes unused — TS2578, a hard failure — if its member is widened to +// accept the value. A deletion keeps it used since objectui#8347 (the value is +// refused as an undeclared key) and is caught by the `Equal` rows above. // @ts-expect-error — `colorField` names a FIELD, so it is a string, not the colour itself const calendarBadColorField: TsObjectCalendarSchema = { ...CALENDAR_NODE, colorField: 0xff0000 }; // @ts-expect-error — `allDayField` names a FIELD; a boolean is the VALUE, the confusion this declaration catches const calendarBadAllDayField: TsObjectCalendarSchema = { ...CALENDAR_NODE, allDayField: true }; +// …and, since objectui#8347 removed `BaseSchema`'s index signature, the misspelling itself. +// @ts-expect-error — `colourField` is no member of `ObjectCalendarSchema`; the key is `colorField` +const calendarMisspelledKey: TsObjectCalendarSchema = { ...CALENDAR_NODE, colourField: 'status_colour' }; /* ── Off-disk derivations ─────────────────────────────────────────────────── */ @@ -662,6 +679,6 @@ describe('objectui#8466 — the TS face accepts the flat handoff node', () => { expect(calendarLiteral.allDayField).toBe('is_all_day'); // The refused literals exist only so their `@ts-expect-error` directives do; // referencing them keeps `noUnusedLocals` off this file's back. - expect([calendarBadColorField, calendarBadAllDayField]).toHaveLength(2); + expect([calendarBadColorField, calendarBadAllDayField, calendarMisspelledKey]).toHaveLength(3); }); }); diff --git a/packages/types/src/__tests__/chart-data-model-7113.test.ts b/packages/types/src/__tests__/chart-data-model-7113.test.ts index 5bfeea4910..f0064e0eaa 100644 --- a/packages/types/src/__tests__/chart-data-model-7113.test.ts +++ b/packages/types/src/__tests__/chart-data-model-7113.test.ts @@ -51,13 +51,15 @@ * * objectui#7113's ruling justifies option B by saying a misspelling like * `xAxiskey` / `datas` moves "from tsc-green + zod-green + empty chart to loud - * at authoring time". That is FALSE on both faces, before AND after this change, - * and section (f) below pins it rather than letting the claim stand unmeasured: + * at authoring time". When written that was FALSE on both faces, before AND + * after this change, and section (f) below pins the zod half rather than letting + * the claim stand unmeasured: * * - the mirror's `BaseSchema` is `.passthrough()` (`base.zod.ts:212`), so an * undeclared key is KEPT, never refused; - * - the TS `BaseSchema` carries `[key: string]: any` (`base.ts:409`), which - * suppresses excess-property checking on every chart literal. + * - the TS `BaseSchema` carried `[key: string]: any`, which suppressed + * excess-property checking on every chart literal; objectui#8347 removed it, + * so a typed literal refuses the misspelling now. * * What the declaration DOES buy is the VALUE check — `data` and `xAxisKey` are * now refused BY NAME when malformed, where before they drew an empty chart in diff --git a/packages/types/src/__tests__/chart-inline-data-retired.test.ts b/packages/types/src/__tests__/chart-inline-data-retired.test.ts index a8c983f8f1..5d96927c79 100644 --- a/packages/types/src/__tests__/chart-inline-data-retired.test.ts +++ b/packages/types/src/__tests__/chart-inline-data-retired.test.ts @@ -21,8 +21,8 @@ * (`@object-ui/plugin-charts`) reads `dataKey`/`name`, `label`, * `chartType`/`type`, `variant`, `opacity`, `dashArray`, `stack`, `yAxis` * and `color` — `data` is not among them. Rows come from the chart-level - * `data`, a key `ChartSchema` never declared at all, which survives only - * because `BaseSchema` carries an index signature. + * `data`, a key `ChartSchema` did not declare at the time, which survived + * only because `BaseSchema` carried an index signature. * 2. `categories` was documented as "X-axis labels/categories" and is read as * an ALTERNATIVE SERIES LIST — consulted only when `series` is absent, each * entry normalized as `{ dataKey }`. The category axis comes from diff --git a/packages/types/src/__tests__/chatbot-authoring-face-keys.test.ts b/packages/types/src/__tests__/chatbot-authoring-face-keys.test.ts index a6cb18cb39..03e01d2c4a 100644 --- a/packages/types/src/__tests__/chatbot-authoring-face-keys.test.ts +++ b/packages/types/src/__tests__/chatbot-authoring-face-keys.test.ts @@ -29,7 +29,8 @@ * * ## Why the TS half is real enforcement, not decoration * - * `BaseSchema` carries `[key: string]: any` (objectui#5155). Before this + * `BaseSchema` carried `[key: string]: any` (objectui#5155) until + * objectui#8347. Before this * change, `autoResponseDelay: 'not-a-number'` on a `ChatbotSchema`-typed * object type-checked FINE — the index signature swallowed the unlisted key * as `any`. Declaring the field with its real type is what makes a wrong diff --git a/packages/types/src/__tests__/chatbot-dark-keys-retired-7703.test.ts b/packages/types/src/__tests__/chatbot-dark-keys-retired-7703.test.ts index 672f1d0c68..1412d5d7fd 100644 --- a/packages/types/src/__tests__/chatbot-dark-keys-retired-7703.test.ts +++ b/packages/types/src/__tests__/chatbot-dark-keys-retired-7703.test.ts @@ -48,8 +48,9 @@ * ## Why tombstones and not deletions * * All six HAVE a Zod arm, which is what decides the route here: `BaseSchema` is - * `.passthrough()` on the Zod side and carries a `[key: string]: any` index - * signature on the TS side, so an UNDECLARED key is not refused, it is KEPT. + * `.passthrough()` on the Zod side (and carried a `[key: string]: any` index + * signature on the TS side until objectui#8347), so an UNDECLARED key is not + * refused there, it is KEPT. * Deleting the members would hand the authored spelling exactly the silent * no-op this card exists to close. `?: never` + `retirementTombstone()` is this * package's convention — `MarkdownSchema.sanitize` / `.components` @@ -368,9 +369,10 @@ describe('the six keys are RETIRED on the TypeScript face too (objectui#7703)', }); it('refuses them in the form authors actually write — a fresh document literal', () => { - // The leg that proves the tombstones survive `BaseSchema`'s - // `[key: string]: any`: if the index signature won, each key would widen - // back to `any` here and every directive would go unused (TS2578). + // The leg that proved the tombstones survive `BaseSchema`'s + // `[key: string]: any` while it stood (objectui#8347 removed it): if the + // index signature had won, each key would have widened back to `any` + // here and every directive would have gone unused (TS2578). const retiredDocument: ChatbotSchemaTS = { type: 'chatbot', messages: [], @@ -399,8 +401,8 @@ describe('the six keys are RETIRED on the TypeScript face too (objectui#7703)', // Excess-property checking only reaches a FRESH literal (objectui#7654 // measured the contrast on this very carrier): a deleted key would ride a // widened value silently, and on a `BaseSchema` carrier even a wrong-TYPED - // value goes quiet, because the index signature defeats the weak-type check - // as well. The declared `never` makes the assignment itself ill-typed, so + // value went quiet while the index signature defeated the weak-type check as + // well (until objectui#8347). The declared `never` makes the assignment itself ill-typed, so // freshness stops mattering. const raw = { type: 'chatbot' as const, messages: [], userAvatar: 'https://example.com/me.png' }; // @ts-expect-error — `userAvatar` is RETIRED (objectui#7703), reached through a non-fresh value. @@ -413,21 +415,22 @@ describe('the six keys are RETIRED on the TypeScript face too (objectui#7703)', // boundary on the `tsc` face. `ChatbotEnhancedSchema` and // `ChatbotFloatingSchema` reach `ChatbotSchema` only through // `Pick<..., ChatbotSharedKey | ...>`, and none of the six is a picked key, - // so no tombstone can travel to them: the keys stay UNDECLARED there and - // ride `BaseSchema`'s index signature, exactly as before this change. Both - // annotated assignments below are directive-FREE on purpose — if a - // tombstone ever reached either face, they would stop compiling and this - // leg would be the one that says so. - const enhanced: ChatbotEnhancedSchemaTS = { - type: 'chatbot-enhanced', - messages: [], - showAvatars: true, - }; - const floating: ChatbotFloatingSchemaTS = { - type: 'chatbot-floating', - messages: [], - height: 400, - }; - expect([enhanced.type, floating.type]).toEqual(['chatbot-enhanced', 'chatbot-floating']); + // so no tombstone can travel to them: the keys stay UNDECLARED there — no + // member at all, where a tombstone would be a `?: never` member. Until + // objectui#8347 they rode `BaseSchema`'s index signature, and a + // directive-free annotated literal was this leg's instrument. With the + // signature gone an undeclared key is refused on a fresh literal as well, + // so the leg reads membership instead, which separates the two. + type IsMember = K extends keyof T ? true : false; + const undeclared: [IsMember, IsMember] = [ + false, + false, + ]; + // Control: a picked key IS a member of each sibling, so `false` is a reading. + const picked: [IsMember, IsMember] = [ + true, + true, + ]; + expect([...undeclared, ...picked]).toEqual([false, false, true, true]); }); }); diff --git a/packages/types/src/__tests__/chatbot-display-mode-retired.test.ts b/packages/types/src/__tests__/chatbot-display-mode-retired.test.ts index a237f25973..ea0ae7b3eb 100644 --- a/packages/types/src/__tests__/chatbot-display-mode-retired.test.ts +++ b/packages/types/src/__tests__/chatbot-display-mode-retired.test.ts @@ -29,11 +29,12 @@ * * ## Why a tombstone, and what it buys on THIS carrier * - * `ChatbotSchema` extends `BaseSchema`, which carries `[key: string]: any`. - * On such a carrier deleting an optional member is SILENT in every value shape - * — the index signature defeats both excess-property checking and the - * weak-type check — so the usual "a fresh literal would at least trip TS2353" - * comfort does not exist here. Measured on this member with + * `ChatbotSchema` extends `BaseSchema`, which carried `[key: string]: any` + * when this was written. On such a carrier deleting an optional member was + * SILENT in every value shape — the index signature defeated both + * excess-property checking and the weak-type check — so the usual "a fresh + * literal would at least trip TS2353" comfort did not exist. Measured on this + * member then with * `tsc -p tsconfig.test.json`, `FloatingChatbotConfig` (no index signature) * lit as the control carrier in the same run: * @@ -44,10 +45,11 @@ * | TOMBSTONED | TS2322 | TS2322 | TS2322 | clean | * | control | — | — | — | TS2353 / TS2559| * - * Deleted, the member reads as `any` and even a wrong-typed value goes quiet. - * Tombstoned, PRESENCE with any value is a compile error — a channel deletion - * cannot produce on this carrier at all. The routes are loud-vs-silent, not - * louder-vs-quieter, which is the discriminator's carrier branch as corrected + * Deleted, the member read as `any` and even a wrong-typed value went quiet. + * objectui#8347 removed the signature, so a deletion now trips TS2353 on a + * fresh literal, unnamed, and still rides a widened value clean. Tombstoned, + * PRESENCE with any value is a compile error in every shape, by name. The + * routes were loud-vs-silent, and through a widened value they still are, which is the discriminator's carrier branch as corrected * on objectui#7678. Prong 2 licenses the tombstone: the key was advertised in * the 3.3.0 release record (`CHANGELOG.md:578`) and its comment taught it as * the presentation switch. @@ -56,8 +58,9 @@ * type-checks its tests through `tsconfig.test.json`, so re-widening the * declaration fails the build on the unused directive. A green `vitest` run is * NOT evidence about them — type assertions are erased before it runs. The - * "deleted" row is pinned LIVE below, as an undeclared key that carries no - * directive, so the contrast cannot rot into prose. + * "deleted" row is pinned LIVE below, as an undeclared key (refused on the + * fresh literal, carried by the widened value), so the contrast cannot rot + * into prose. */ import { describe, it, expect } from 'vitest'; @@ -148,11 +151,14 @@ describe('the `displayMode` tombstone makes authoring a `tsc` error on both face expect(onChatbot.processVisibility).toBe('debug'); }); - it('an UNDECLARED key rides a fresh literal AND a widened value on this carrier — the DELETED row, live', () => { - // No directive on purpose: this is where `displayMode` would sit had it - // been deleted instead of tombstoned. `BaseSchema`'s `[key: string]: any` - // absorbs the key at any value in every shape, so a deletion here is not - // "quieter" than a tombstone — it produces no diagnostic at all. + it('an UNDECLARED key is refused on a fresh literal and still rides a widened value — the DELETED row, live', () => { + // This is where `displayMode` would sit had it been deleted instead of + // tombstoned. Until objectui#8347 `BaseSchema`'s `[key: string]: any` + // absorbed the key at any value in every shape, so a deletion produced no + // diagnostic at all. The signature is gone: a fresh literal is refused, + // with no name for the remedy, while a widened value is still not + // re-checked (no excess-property check on a non-fresh object). + // @ts-expect-error — `ChatbotSchema` declares no `bogusUndeclared` (objectui#8347) const fresh: TsChatbotSchema = { type: 'chatbot', messages, bogusUndeclared: 1 }; const raw = { type: 'chatbot' as const, messages, bogusUndeclared: 1 }; const widened: TsChatbotSchema = raw; diff --git a/packages/types/src/__tests__/chatbot-registration-authoring-faces-7655.test.ts b/packages/types/src/__tests__/chatbot-registration-authoring-faces-7655.test.ts index 72a4da6280..3a402455ca 100644 --- a/packages/types/src/__tests__/chatbot-registration-authoring-faces-7655.test.ts +++ b/packages/types/src/__tests__/chatbot-registration-authoring-faces-7655.test.ts @@ -98,7 +98,7 @@ type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; type Expect = T; -/** Declared keys, read past `BaseSchema`'s `[key: string]: any` index signature. */ +/** Declared keys, read past any index signature (`BaseSchema` carried one until objectui#8347). */ type WithoutIndexSignature = { [K in keyof D as string extends K ? never : number extends K ? never : K]: D[K]; }; @@ -182,10 +182,11 @@ export type assertionChatbotKeepsItsWholeFace = Expect< /** * The two floating keys stay DECLARED on `ChatbotSchema` — as tombstones now. Read - * off the member, not the key set: a member that fell off the declaration would not - * go missing here — it would read as `any` through `BaseSchema`'s index signature, - * any value would compile on a `chatbot` node (measured on #7655's first cut, which - * moved the keys off this face by DELETION). `Equal` is what catches the `any`. + * off the member, not the key set: while `BaseSchema` carried its index signature, a + * member that fell off the declaration did not go missing here — it read as `any`, + * and any value compiled on a `chatbot` node (measured on #7655's first cut, which + * moved the keys off this face by DELETION). `Equal` is what caught the `any`; since + * objectui#8347 a member that falls off fails to compile at the indexed access. * `displayMode` reads as `undefined` since objectui#7654 tombstoned it, and * `floatingConfig` since objectui#6152 round 5 RETIRED it on this face (the `chatbot` * registration never read it): `?: never` without `exactOptionalPropertyTypes` is @@ -211,8 +212,9 @@ export type assertionSharedKeysAreOneDeclaration = [ * ── `maxToolRoundtrips`: retired on all three faces (objectui#5605) ────────── * * `?: never` reads `undefined` (without `exactOptionalPropertyTypes`), which - * `Equal` tells apart from the `any` a DELETION would leave through - * `BaseSchema`'s index signature — the reading that says the tombstone is + * `Equal` tells apart from the `any` a DELETION left through `BaseSchema`'s + * index signature until objectui#8347 (a deletion fails to compile here now) — + * the reading that says the tombstone is * there, and one declaration, on every face. */ export type assertionMaxToolRoundtripsIsATombstoneOnEveryFace = [ diff --git a/packages/types/src/__tests__/checkbox-wrapper-class-6938.test.ts b/packages/types/src/__tests__/checkbox-wrapper-class-6938.test.ts index dba76dee11..3f6aec0184 100644 --- a/packages/types/src/__tests__/checkbox-wrapper-class-6938.test.ts +++ b/packages/types/src/__tests__/checkbox-wrapper-class-6938.test.ts @@ -31,8 +31,9 @@ * membership is asserted on the mirror's OWN `.shape`, never on parse * acceptance — under `.passthrough()` acceptance cannot tell "declared" from * "admitted unexamined". The type-level pin uses invariant equality, so an - * undeclared key (which resolves to `any` through the index signature) reads - * as a failure rather than as a match. + * undeclared key (which resolved to `any` through the index signature until + * objectui#8347, and fails to compile at an indexed access now) reads as a + * failure rather than as a match. * * The CONTROL is a key the renderer does NOT read — derived from the renderer * source, not asserted from memory — and it must stay undeclared on both @@ -70,18 +71,20 @@ type Expect = T; /** The canonical `any` detector: only `any` absorbs `1 &` down to something `0` extends. */ type IsAny = 0 extends (1 & T) ? true : false; -// Declared as `string`. Were the member removed, the read would fall back to -// the index signature and resolve to `any`, and `Equal` is false. +// Declared as `string`. Were the member removed, the read would not compile +// (objectui#8347 took away the index signature it used to fall back to as `any`). export type _WrapperClassIsString = Expect, string>>; export type _WrapperClassIsNotAny = Expect, false>>; -// The control key is NOT declared: it resolves to `any` through the index -// signature, exactly as `wrapperClass` did before this card. Declaring it turns -// this red — which is the point. -export type _ControlKeyFallsThroughToIndexSignature = Expect>; - -// The TS face accepts the key on a literal. ⚠️ This is the WEAK half: the index -// signature would accept it undeclared too. The invariant pins above are the -// guard; this line only shows the declared spelling in use. +// Lit control for the detector: `IsAny` does answer `true`, so the line above is a reading. +export type _IsAnyCanAnswerTrue = Expect>; +// The control key is NOT declared: it resolved to `any` through the index +// signature, exactly as `wrapperClass` did before this card, until objectui#8347 +// removed the signature. It is no member now. Declaring it turns this red. +export type _ControlKeyIsNoMember = Expect>; + +// The TS face accepts the key on a literal. Since objectui#8347 this is no +// longer the weak half: without the index signature an undeclared key is +// refused on a fresh literal, so the acceptance reads the declaration. const literal: TsCheckboxSchema = { type: 'checkbox', label: 'Accept', wrapperClass: 'gap-4' }; /** Every `schema.KEY` read in the renderer, off disk. */ diff --git a/packages/types/src/__tests__/collapsible-open-refusal-8236.test.ts b/packages/types/src/__tests__/collapsible-open-refusal-8236.test.ts index 17e642997d..da4410f118 100644 --- a/packages/types/src/__tests__/collapsible-open-refusal-8236.test.ts +++ b/packages/types/src/__tests__/collapsible-open-refusal-8236.test.ts @@ -16,8 +16,9 @@ * * ## Why a REFUSAL and not a bare deletion * - * `BaseSchemaCore` ends `.passthrough()` and the TS `BaseSchema` closes with an - * index signature, so a dropped MEMBER key is KEPT, not refused. Deleting the + * `BaseSchemaCore` ends `.passthrough()` (and the TS `BaseSchema` closed with an + * index signature until objectui#8347), so a dropped MEMBER key is KEPT, not + * refused, on the zod face. Deleting the * two declarations would have left the silent accept exactly as it was and * thrown the diagnostic away with it. `retirementTombstone()` keeps the key * DECLARED and unwritable, which is what makes the refusal loud — and it keeps diff --git a/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts b/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts index 1d23f211e1..b946ed2538 100644 --- a/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts +++ b/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts @@ -350,9 +350,11 @@ describe('objectui#9256 E3 residual — the TypeScript face refuses both channel it('CONTROL — the same nodes WITHOUT a content channel compile (no `@ts-expect-error` here, and `tsc` is the reader)', () => { const ok = [grid, form, kanban, map, tree, view, gantt, calendar, chart, detail, email, password, uiCalendar]; - // The repair keeps the index signature, so it narrows nothing an author - // could write before beyond the members' own declared types — an - // undeclared key still compiles, exactly as it did. + // The repair kept the index signature, so it narrowed nothing an author + // could write before beyond the members' own declared types. objectui#8347 + // then removed `BaseSchema`'s signature, so an undeclared key no longer + // compiles on these nodes either. + // @ts-expect-error — `InputShorthandSchema` declares no `notAMember` (objectui#8347) const undeclared = { type: 'email', label: 'Email', notAMember: 1 } satisfies InputShorthandSchema; const calendarRange = { type: 'ui:calendar', mode: 'range' } satisfies UiCalendarSchema; expect(ok).toHaveLength(13); diff --git a/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts b/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts index c8ac799e20..53561d9f76 100644 --- a/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts +++ b/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts @@ -22,7 +22,7 @@ * of #4631's "declared surfaces disagree". * * What the deletion changed at the type level, stated honestly: `BaseSchema` - * carries `[key: string]: any`, so after the removal an authored `aria:` on a + * carried `[key: string]: any` then, so after the removal an authored `aria:` on a * dashboard literal still COMPILED — it fell to the index signature, and a * `@ts-expect-error` pin on an authored literal could not stick. * diff --git a/packages/types/src/__tests__/dashboard-title-retired-declaration.test.ts b/packages/types/src/__tests__/dashboard-title-retired-declaration.test.ts index 185791ed16..5962783094 100644 --- a/packages/types/src/__tests__/dashboard-title-retired-declaration.test.ts +++ b/packages/types/src/__tests__/dashboard-title-retired-declaration.test.ts @@ -36,11 +36,14 @@ * * ## The trap: why this is a key-set probe and NOT `@ts-expect-error` * - * ⚠️ `BaseSchema` carries `[key: string]: any`, so an authored `title:` on a - * dashboard literal STILL COMPILES after the member is gone — it falls to the index - * signature. A `@ts-expect-error` pin on an authored literal therefore cannot stick: - * it would pass for the wrong reason before the removal and fail after it. The - * pinnable effect is that `title` stops being a DECLARED member. The probe below + * ⚠️ When this was written `BaseSchema` carried `[key: string]: any`, so an + * authored `title:` on a dashboard literal STILL COMPILED after the member was gone + * — it fell to the index signature, and a `@ts-expect-error` pin on an authored + * literal could not stick. objectui#8347 removed that signature, so the literal is + * refused now too; the key-set probe stays the pin because it reads the + * DECLARATION, which an excess-key refusal on a literal cannot tell apart from a + * `?: never` tombstone. The pinnable effect is that `title` stops being a DECLARED + * member. The probe below * extracts the interface's literal key set — the index signature is filtered out by * `string extends K` — and asserts `title` is out while its neighbours stay in. * Real enforcement because `packages/types/tsconfig.test.json` is chained from this diff --git a/packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts b/packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts index 96821ae4dd..f2928ddf91 100644 --- a/packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts +++ b/packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts @@ -36,10 +36,11 @@ * spec-family widget with an undeclared key is refused on both faces, and a * `type` in neither vocabulary is refused; * 4. the measured limit of a TypeScript union with a passthrough arm, recorded - * two-faced so it cannot be read as a hatch: a `type`-less legacy envelope - * with an undeclared key COMPILES (nothing to discriminate on, so the arm's - * index signature satisfies the excess-property check) while the Zod face - * refuses it by name; + * two-faced so it could not be read as a hatch: a `type`-less legacy + * envelope with an undeclared key COMPILED (nothing to discriminate on, so + * the arm's index signature satisfied the excess-property check) while the + * Zod face refused it by name; objectui#8347 removed the signature, tsc + * refuses it now, and the block is gone (a note marks where it stood); * 5. shape identity: the slot's element type IS the two-arm union, the arm's * `type` IS `DashboardComponentWidgetType`, and the arm is NOT assignable * to `DashboardWidgetSchema` (objectui#11514). It was until that card, @@ -49,7 +50,7 @@ * type, so a `metric-card` with no `value` is refused on this face too; * 6. objectui#11467: the arm DECLARES `MetricCard`'s registered inputs, so the * README's `metric-card` literals compile against members rather than - * against `BaseSchema`'s index signature (which objectui#8347 removes); + * against `BaseSchema`'s index signature (which objectui#8347 removed); * the Zod twin declares the same members; and the widget's `component` * slot takes the arm first, on both faces. The members' parity with the * live registration and `MetricCardProps` is pinned in @@ -180,6 +181,9 @@ describe('NOT A HATCH — what the union still refuses, on both faces', () => { const doc: DashboardComponentSchema = { type: 'dashboard', // `value` is the card's required input since objectui#11467; `someProp` is the undeclared key. + // The TS face refuses it since objectui#8347 removed `BaseSchema`'s index + // signature; the passthrough this test reads is the zod face's. + // @ts-expect-error — `DashboardWidgetSlotComponentSchema` declares no `someProp` (objectui#8347) widgets: [{ type: 'metric-card', title: 'x', value: '1', someProp: 1 }], }; const result = DashboardComponentZod.safeParse(doc); @@ -189,27 +193,25 @@ describe('NOT A HATCH — what the union still refuses, on both faces', () => { }); }); -describe('MEASURED LIMIT of a TypeScript union with a passthrough arm — recorded, not a contract', () => { - it('a `type`-less legacy envelope with an undeclared key compiles, and the Zod face refuses it by name', () => { - // Nothing to discriminate on (the legacy `component` envelope has no - // `type`), so the union's excess-property check accepts any key one arm - // could hold, and the component arm's index signature holds every key. - // If tsc ever refuses this literal, the corner has closed: delete this - // constant and the note on `widgets` in `complex.ts` — ⛔ do not add an - // `@ts-expect-error` to keep the file green. - const envelopeStray: DashboardComponentSchema = { +// A `MEASURED LIMIT` block stood here: a `type`-less legacy envelope carrying an +// undeclared key compiled, because the component arm's index signature +// satisfied the union's excess-property check. objectui#8347 removed +// `BaseSchema`'s signature, tsc refused the literal, and the block's own removal +// condition deleted it together with the note on `widgets` in `complex.ts`. +describe('the legacy `component` envelope on the TypeScript face (objectui#7952, objectui#8347)', () => { + it('a `metric-card` with no `value` inside the envelope is refused (objectui#8347, Q3 = A)', () => { + const valueless: DashboardComponentSchema = { type: 'dashboard', - widgets: [{ id: 'w', component: { type: 'metric-card', value: '1' }, bogus: 1 }], + // @ts-expect-error — `value` is the card's one required input, inside the envelope too (the diagnostic names `value`). + widgets: [{ id: 'w', component: { type: 'metric-card', title: 'Revenue' } }], }; - const result = DashboardComponentZod.safeParse(envelopeStray); - expect(result.success, 'the runtime is the strict face on this corner').toBe(false); - if (result.success) return; - const flat = JSON.stringify(result.error.issues); - expect(flat).toContain('unrecognized_keys'); - expect(flat).toContain('bogus'); + // The tolerant zod face still accepts this document, through the widget + // `component` slot's `BaseSchema` arm (objectui#8344); that is the zod + // face's question, which this card leaves alone (objectui#8345). + expect(valueless.type).toBe('dashboard'); }); - it('the same envelope without the stray key is legal on both faces', () => { + it('the same envelope with `value` is legal on both faces (the control)', () => { const envelope: DashboardComponentSchema = { type: 'dashboard', widgets: [{ id: 'w', component: { type: 'metric-card', value: '1' }, layout: { x: 0, y: 0, w: 1, h: 1 } }], @@ -271,7 +273,7 @@ describe('objectui#11467 — the component arm declares `MetricCard`\'s register expect(twins).toBe(true); }); - it('the TypeScript face judges each value by its member, while the index signature still stands', () => { + it('the TypeScript face judges each value by its member', () => { // @ts-expect-error — TS2322: `trend` is the registration's enum. const sideways: DashboardWidgetSlotComponentSchema = { type: 'metric-card', value: '1', trend: 'sideways' }; // @ts-expect-error — TS2741: `value` is the card's one required input. diff --git a/packages/types/src/__tests__/data-table-declared-keys-6882.test.ts b/packages/types/src/__tests__/data-table-declared-keys-6882.test.ts index 1aa7f1fc5b..d033c85f1c 100644 --- a/packages/types/src/__tests__/data-table-declared-keys-6882.test.ts +++ b/packages/types/src/__tests__/data-table-declared-keys-6882.test.ts @@ -25,17 +25,18 @@ * Same reading as `plugin-grid/src/__tests__/dataTableSchemaSlot-6459.test.ts` * next door. * - * ## ⚠️ The index signature is what makes the naive pin vacuous + * ## ⚠️ The index signature is what made the naive pin vacuous * - * `DataTableSchema extends BaseSchema`, and `BaseSchema` carries - * `[key: string]: any`. So `DataTableSchema['renderCellEditor']` resolves to - * `any` whether or not the key is declared, and every "is this key there?" - * spelling written over the raw type answers `true` for EVERY string — - * including `bogusKeyNobodyDeclared`. A pin written that way is green before - * the fix, green after it, and measures nothing. + * `DataTableSchema extends BaseSchema`, and `BaseSchema` carried + * `[key: string]: any` until objectui#8347. So `DataTableSchema['renderCellEditor']` + * resolved to `any` whether or not the key was declared, and every "is this key + * there?" spelling written over the raw type answered `true` for EVERY string — + * including `bogusKeyNobodyDeclared`. A pin written that way was green before + * the fix, green after it, and measured nothing. * * `Declared<>` below strips the signature so NON-MEMBERSHIP can exist, which is - * the only state in which a membership question has an answer. + * the only state in which a membership question has an answer (since + * objectui#8347 the strip is the identity on `DataTableSchema`). * * ## ⚠️ …and `extends` alone is vacuous a second way * diff --git a/packages/types/src/__tests__/data-table-toolbar-retired.test.ts b/packages/types/src/__tests__/data-table-toolbar-retired.test.ts index d9ac8c96e2..1952142a5a 100644 --- a/packages/types/src/__tests__/data-table-toolbar-retired.test.ts +++ b/packages/types/src/__tests__/data-table-toolbar-retired.test.ts @@ -29,9 +29,10 @@ * * ## Why the tombstone, and not simply deleting the key * - * `BaseSchema` is `.passthrough()` on the Zod side and carries a - * `[key: string]: any` index signature on the TS side. An UNDECLARED key is - * accepted by both halves, unvalidated — deleting `toolbar` outright would + * `BaseSchema` is `.passthrough()` on the Zod side and carried a + * `[key: string]: any` index signature on the TS side until objectui#8347. An + * UNDECLARED key is accepted by the Zod half, unvalidated (and was by the TS + * half; since objectui#8347 only through a widened value) — deleting `toolbar` outright would * hand the authored spelling exactly the silent no-op this card exists to * close. `?: never` / `retirementTombstone()` is this package's convention — * {@link StaticTableColumn} (objectui#5474), `crud.ts` `confirm` @@ -167,9 +168,10 @@ describe('DataTableSchema.toolbar is RETIRED — the TS half of the tombstone (o }); it('refuses the retired key in the form authors actually write', () => { - // The leg that proves the tombstone survives `BaseSchema`'s - // `[key: string]: any`: if the index signature won, `toolbar` would widen - // back to `any` here and the directive would go unused (TS2578). + // The leg that proved the tombstone survives `BaseSchema`'s + // `[key: string]: any` while it stood (objectui#8347 removed it): if the + // index signature had won, `toolbar` would have widened back to `any` + // here and the directive would have gone unused (TS2578). const columns: TableColumn[] = [{ header: 'Name', accessorKey: 'name' }]; const retiredDocument: DataTableSchemaTS = { diff --git a/packages/types/src/__tests__/designer-members-retired-11434.test.ts b/packages/types/src/__tests__/designer-members-retired-11434.test.ts index 17fdb41fdd..f3ac095aa0 100644 --- a/packages/types/src/__tests__/designer-members-retired-11434.test.ts +++ b/packages/types/src/__tests__/designer-members-retired-11434.test.ts @@ -94,7 +94,8 @@ type InputOf = T extends z.ZodType ? z.input : never; * without `exactOptionalPropertyTypes` is `never | undefined`, which collapses. * `Equal` separates that from the `boolean` / `string` / array types the * members carried, and, on the two `BaseSchema`-derived nodes, from the `any` - * a DELETION would leave behind the index signature. + * a DELETION left behind the index signature until objectui#8347 (a deletion + * fails to compile at the indexed access now). */ export type assertionRetiredMembersReadAsTombstones = [ Expect>, diff --git a/packages/types/src/__tests__/designer-zod-arms-10859.test.ts b/packages/types/src/__tests__/designer-zod-arms-10859.test.ts index 3c8b362098..d5b1ab4245 100644 --- a/packages/types/src/__tests__/designer-zod-arms-10859.test.ts +++ b/packages/types/src/__tests__/designer-zod-arms-10859.test.ts @@ -113,7 +113,7 @@ type Expect = T; type ShapeOf = M extends { shape: infer S } ? S : never; /** What a shape entry ACCEPTS (input side, so `.optional()` shows). */ type InputOf = T extends z.ZodType ? z.input : never; -/** The declaration's DECLARED keys — the `BaseSchema` index signature dropped. */ +/** The declaration's DECLARED keys — any index signature dropped (`BaseSchema` carried one until objectui#8347). */ type DeclaredKeys = Extract< keyof { [K in keyof D as string extends K ? never : number extends K ? never : K]: D[K] }, string diff --git a/packages/types/src/__tests__/detail-view-related-retired-7997.test.ts b/packages/types/src/__tests__/detail-view-related-retired-7997.test.ts index 0c8e120c3e..f93acb254c 100644 --- a/packages/types/src/__tests__/detail-view-related-retired-7997.test.ts +++ b/packages/types/src/__tests__/detail-view-related-retired-7997.test.ts @@ -34,8 +34,9 @@ * * ## Why a refusal and not a deletion — the mechanism, not a preference * - * `BaseSchema` closes with an any-valued index signature and `BaseSchemaCore` - * ends `.passthrough()`. A DROPPED member key is therefore KEPT, not refused: + * `BaseSchemaCore` ends `.passthrough()` (and `BaseSchema` closed with an + * any-valued index signature until objectui#8347). A DROPPED member key is + * therefore KEPT, not refused, on the zod face: * deleting the declaration would have left the silent accept exactly as it was * and thrown the diagnostic away with it (the mechanism objectui#7963 * measured). Declared-and-unwritable is what makes the refusal loud — diff --git a/packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts b/packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts index 5a854781af..153a874241 100644 --- a/packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts +++ b/packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts @@ -31,8 +31,9 @@ * `boolean | string | undefined`, invariantly. `Equal`, not `extends`: the * narrow `boolean` is assignable to the wide union, so a one-way check * stays green on a narrowing that was never removed; and `BaseSchema`'s - * `[key: string]: any` index signature means an interface that LOST the - * member reads `any`, which a one-way check also accepts. `visible` is + * `[key: string]: any` index signature meant an interface that LOST the + * member read `any` (until objectui#8347), which a one-way check also + * accepts. `visible` is * asserted beside it as the twin control, so a schema that dropped both * keys cannot pass vacuously. * 2. Runtime — each of the 18 zod mirrors `safeParse`s a predicate string on diff --git a/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts b/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts index 58a5b9a069..87b1250d0b 100644 --- a/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts +++ b/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts @@ -96,8 +96,9 @@ export type assertionZodMemberIsTheSpecType = [ ]; /** * Each published TypeScript twin declares the member as the spec's type. Without - * the declaration the member resolves through `BaseSchema`'s index signature to - * `any`, which `Equal` refuses. + * the declaration the member resolved through `BaseSchema`'s index signature to + * `any`, which `Equal` refuses; since objectui#8347 the indexed access does not + * compile at all. */ export type assertionTsTwinIsTheSpecType = [ Expect>, @@ -105,12 +106,17 @@ export type assertionTsTwinIsTheSpecType = [ Expect>, ]; /** - * The helper can FAIL, on the exact shape a missing declaration takes: `stack` - * declares no `responsiveStyles`, so the member resolves through the index - * signature, and `Equal` refuses it. + * The helper can FAIL: `Equal` refuses a member whose type differs from the + * spec's (`stack`'s declared `className` is no `SpecResponsiveStyles`), so the + * lines above are readings. `stack` declares no `responsiveStyles` at all: the + * member used to resolve through the index signature, and since objectui#8347 + * removed it the key is no member of `stack`. */ -export type assertionUndeclaredTwinIsRefused = Expect< - Equal, false> +export type assertionEqualCanFail = Expect< + Equal, false> +>; +export type assertionUndeclaredTwinIsNoMember = Expect< + Equal<'responsiveStyles' extends keyof TsStackSchema ? true : false, false> >; /* ── The population ───────────────────────────────────────────────────────── */ diff --git a/packages/types/src/__tests__/flex-bag-children-list-11564.test.ts b/packages/types/src/__tests__/flex-bag-children-list-11564.test.ts index 2fa115df66..4b40a8257b 100644 --- a/packages/types/src/__tests__/flex-bag-children-list-11564.test.ts +++ b/packages/types/src/__tests__/flex-bag-children-list-11564.test.ts @@ -21,8 +21,9 @@ * TypeScript face the same member read `unknown[]`, so the list, the form a * `flex` node is authored in, took any entry while a single child was judged * as a node. With `BaseSchema`'s index signature present nothing showed, since - * the signature absorbs a misspelled key either way; once objectui#8347 - * removes it, the list would have been the one unchecked node slot. + * the signature absorbed a misspelled key either way; once objectui#8347 + * removed it, the list would have been the one unchecked node slot, which is + * why this card landed first. * * ## What is pinned here * @@ -31,10 +32,11 @@ * * - the bag's `children` is one node or a list of nodes, the flat mirror's * own member, and it is the only member that differs from the arm; - * - a closed node's misspelling inside the list is refused today. A - * spec-derived node carries no index signature, so it is the shape every - * node has after objectui#8347; the control is the same document typed - * the way the arm reads it, which compiles; + * - a closed node's misspelling inside the list is refused. A spec-derived + * node carries no index signature, which is the shape every node has had + * since objectui#8347, so a node type that extends `BaseSchema` is refused + * there too (the `text` row); the control is the same document typed the + * way the arm reads it, which compiles; * - an undeclared `type` and a nested array inside the list are refused, * and a primitive entry compiles, as in every node slot. * @@ -110,7 +112,11 @@ describe('objectui#11564: the flex bag\'s child list is typed as nodes', () => { // Control: the same misspelling as a single child was refused before this card too. // @ts-expect-error `contnt` is not a member of `element:text`'s bag const single: FlexBlockNode = { type: 'flex', properties: { children: { type: 'element:text', properties: { contnt: 'x' } } } }; - expect([inList, armReading, single]).toHaveLength(3); + // Since objectui#8347 a node type that extends `BaseSchema` refuses a misspelling in the list + // too: `text` declares `content` (the well-formed list below writes it and compiles). + // @ts-expect-error `contnt` is no member of the `text` node + const extendsBase: FlexBlockNode = { type: 'flex', properties: { children: [{ type: 'text', contnt: 'x' }] } }; + expect([inList, armReading, single, extendsBase]).toHaveLength(4); }); it('a well-formed list compiles: declared nodes, primitives, a nested flex', () => { diff --git a/packages/types/src/__tests__/floating-chatbot-trigger-icon-retired.test.ts b/packages/types/src/__tests__/floating-chatbot-trigger-icon-retired.test.ts index d26f889a29..f7e9bd57b4 100644 --- a/packages/types/src/__tests__/floating-chatbot-trigger-icon-retired.test.ts +++ b/packages/types/src/__tests__/floating-chatbot-trigger-icon-retired.test.ts @@ -111,8 +111,9 @@ describe('the `triggerIcon` tombstone makes authoring a `tsc` error', () => { it('reaches a `chatbot-floating` node through its face; on a `chatbot` node the WHOLE `floatingConfig` key is refused since objectui#6152 round 5', () => { // The pins above sit on `FloatingChatbotConfig` directly, so a face that LOST - // the member would not turn them red — the member would read as `any` through - // `BaseSchema`'s index signature and the literal would compile clean. That is + // the member would not turn them red — while `BaseSchema` carried its index + // signature the member read as `any` and the literal compiled clean (since + // objectui#8347 the literal is refused as undeclared instead). That is // what #7655's contract review measured on its first cut, which DELETED the key // off `ChatbotSchema`; pinned on the nodes since. const onFloating: TsChatbotFloatingSchema = { diff --git a/packages/types/src/__tests__/gantt-declared-keys.test.ts b/packages/types/src/__tests__/gantt-declared-keys.test.ts index ad7cc2af4f..0a5179ea6b 100644 --- a/packages/types/src/__tests__/gantt-declared-keys.test.ts +++ b/packages/types/src/__tests__/gantt-declared-keys.test.ts @@ -26,21 +26,26 @@ * * ## What the pin has teeth against, and what it does not * - * `BaseSchema` is `.passthrough()` on the zod side and carries - * `[key: string]: any` on the TS side (objectui#5155 records that ceiling), so: + * `BaseSchema` is `.passthrough()` on the zod side and carried + * `[key: string]: any` on the TS side until objectui#8347 (objectui#5155 + * recorded that ceiling), so: * - * - an UNDECLARED key is still accepted, by both halves. Declaring these ten - * did NOT buy rejection of a misspelling, and the test below pins that + * - an UNDECLARED key is still accepted by the zod half (and was by the TS + * half while the signature stood). Declaring these ten did NOT buy + * rejection of a misspelling there, and the test below pins that * plainly rather than leaving it to be assumed; * - a DECLARED key IS validated. `readOnly: 'yes'` parsed green before this * card and is refused now — that is the accept-set narrowing landed here, * the same one objectui#5074 landed for `viewMode`; - * - on the TS side the index signature means a read site can never be the - * detector: `schema.readOnly` type-checks as `any` whether or not the key - * is declared. So the compile-time pin is the `@ts-expect-error` block at - * the bottom — remove a declaration and its member resolves to `any`, the - * wrong-typed assignment starts succeeding, and the now-unused directive - * fails the build (TS2578) NAMING the key. `tsconfig.test.json` compiles + * - on the TS side the index signature meant a read site could never be the + * detector: `schema.readOnly` type-checked as `any` whether or not the key + * was declared. So the compile-time pin is the `@ts-expect-error` block at + * the bottom — when written, removing a declaration made its member resolve + * to `any`, the wrong-typed assignment started succeeding, and the + * now-unused directive failed the build (TS2578) NAMING the key. Since + * objectui#8347 a removal makes the indexed access itself an error, which + * the directive swallows, so a deletion is caught by the well-typed + * counter-probe instead (its literal names all ten keys). `tsconfig.test.json` compiles * this file, so that is real enforcement and not decoration (#3009). */ @@ -149,10 +154,13 @@ describe('ObjectGanttSchema — the ten cast-read keys are declared (objectui#59 describe('ObjectGanttSchema (TS) — compile-time pin on the same ten keys', () => { it('refuses a wrong-typed value on every declared key', () => { - // Each directive below fails the build (TS2578, "unused '@ts-expect-error'") - // the moment its key stops being declared, because the member then resolves - // to `any` through `BaseSchema`'s index signature and the assignment starts - // succeeding. That failure is the signal this card exists to create. + // Each directive below failed the build (TS2578, "unused '@ts-expect-error'") + // the moment its key stopped being declared, while the member resolved to + // `any` through `BaseSchema`'s index signature. Since objectui#8347 a removal + // makes the indexed access itself an error, which its directive swallows, so + // the deletion signal comes from the well-typed counter-probe below; each + // directive still fails the build if its member is widened to accept the + // value. // @ts-expect-error — `skipWeekends` is declared `boolean | undefined`. const skipWeekends: ObjectGanttSchemaTS['skipWeekends'] = 'yes'; diff --git a/packages/types/src/__tests__/gantt-dependency-field-deprecated-alias.test.ts b/packages/types/src/__tests__/gantt-dependency-field-deprecated-alias.test.ts index 865ad3df40..8aaafbf9bd 100644 --- a/packages/types/src/__tests__/gantt-dependency-field-deprecated-alias.test.ts +++ b/packages/types/src/__tests__/gantt-dependency-field-deprecated-alias.test.ts @@ -207,11 +207,13 @@ describe('ObjectGanttSchema.dependencyField — still ACCEPTED (removal was excl describe('ObjectGanttSchema.dependencyField — the TS member is unchanged', () => { it('is still declared `string | undefined`', () => { - // Real enforcement: `tsconfig.test.json` compiles this file. If the member - // were deleted, it would resolve to `any` through `BaseSchema`'s - // `[key: string]: any` index signature, the assignment below would start - // succeeding, and the directive would fail the build as an unused - // `@ts-expect-error` (TS2578) — NAMING the removal. That is the pin against + // Real enforcement: `tsconfig.test.json` compiles this file. When written, a + // deleted member resolved to `any` through `BaseSchema`'s + // `[key: string]: any` index signature, the assignment below started + // succeeding, and the directive failed the build as an unused + // `@ts-expect-error` (TS2578) — NAMING the removal. Since objectui#8347 a + // deletion makes the indexed access itself an error: the directive swallows + // it, and the undirected counter-probe below fails instead. That is the pin against // the deprecation quietly becoming the excluded removal. // @ts-expect-error — `dependencyField` is declared `string | undefined`. diff --git a/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts b/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts index 3145cdb7fb..fbf7854600 100644 --- a/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts +++ b/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts @@ -14,14 +14,15 @@ * ## The concealment is an INDEX SIGNATURE, not a cast * * objectui#5903's ten keys were hidden by `(schema as any).K` — a syntax a regex - * can find. These are hidden by `BaseSchema`'s `[key: string]: any` + * can find. These were hidden by `BaseSchema`'s `[key: string]: any` * (objectui#5155's structural ceiling) reached through a parameter typed * `ObjectGridSchema | any`: `schema.colorField` type-checked as `any` with no cast * anywhere. Same outcome, no syntax to grep for. * * The consequence for anyone re-measuring this: "annotate the parameter and see - * what errors" DOES NOT WORK here. The index signature absorbs every literal name, - * so the annotation compiles clean while enforcing nothing — the same blind + * what errors" DID NOT WORK here while `BaseSchema` carried the signature (until + * objectui#8347): it absorbed every literal name, so the annotation compiled clean + * while enforcing nothing — the same blind * instrument objectui#6373 records. The census behind this card is therefore an * AST enumeration of every top-level key read off the `schema` prop in * `packages/plugin-gantt/src/**` (non-test), stripping `as` / parenthesis / @@ -88,9 +89,9 @@ * ## What the pin has teeth against, and what it does not * * Unchanged from #5903, and worth restating because it is the half people read - * wrongly: `BaseSchema` is `.passthrough()` on the zod side and carries - * `[key: string]: any` on the TS side, so declaring these keys does NOT buy - * rejection of a misspelling. What it buys is that a DECLARED key is validated + * wrongly: `BaseSchema` is `.passthrough()` on the zod side (and carried + * `[key: string]: any` on the TS side until objectui#8347), so declaring these + * keys does NOT buy rejection of a misspelling on the zod face. What it buys is that a DECLARED key is validated * (`capacity: 'one'` is refused where it used to parse green), that the published * types now TEACH the vocabulary, and that the type-level pins below fail when a * declaration is removed. @@ -98,7 +99,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectGanttSchema } from '../zod/objectql.zod.js'; -import type { GanttConfig, ObjectGanttSchema as ObjectGanttSchemaTS } from '../objectql.js'; +import type { GanttConfig, ObjectGanttSchema as ObjectGanttSchemaTS, SortConfig } from '../objectql.js'; const MINIMAL = { type: 'object-gantt', @@ -282,8 +283,9 @@ describe('ObjectGanttSchema — the flattened gantt config is declared (objectui * A declaration's OWN declared members, with any index signature stripped. * * Same construction as `zod-mirror-parity.test.ts` and for the same measured - * reason: `keyof ObjectGanttSchema` resolves to bare `string`, because - * `BaseSchema`'s `[key: string]: any` absorbs every literal name. A homomorphic + * reason: `keyof ObjectGanttSchema` resolved to bare `string` while + * `BaseSchema`'s `[key: string]: any` absorbed every literal name (until + * objectui#8347), and `GanttConfig` still carries a signature of its own. A homomorphic * mapped type maps declared members and index signatures separately, so remapping * the index-signature keys to `never` leaves the literal members. */ @@ -336,11 +338,17 @@ describe('ObjectGanttSchema (TS) — the flat face declares the whole block voca describe('ObjectGanttSchema (TS) — compile-time pin on every declared key', () => { it('refuses a wrong-typed value on every declared key', () => { - // Each directive below fails the build (TS2578, "unused '@ts-expect-error'") - // the moment its key stops being declared, because the member then resolves to - // `any` through `BaseSchema`'s index signature and the assignment starts - // succeeding. That failure is the signal this card exists to create, and - // `tsconfig.test.json` compiles this file, so it is real enforcement (#3009). + // Each directive below failed the build (TS2578, "unused '@ts-expect-error'") + // the moment its key stopped being declared, while the member resolved to + // `any` through `BaseSchema`'s index signature. Since objectui#8347 a removal + // makes the indexed access itself an error, which its directive swallows, so + // these directives guard each member's TYPE only. The deletion guards are + // elsewhere in this file: the derived invariant above catches a deletion of + // any `GanttConfig` key (measured by ablation: deleting `colorField` from + // the interface reddens `noGaps`, naming the key), and the three query keys, + // which `GanttConfig` does not carry, have the `_StaticData…` / `_Filter…` / + // `_Sort…` rows after this block. `tsconfig.test.json` compiles this file, so + // it is real enforcement (#3009). // @ts-expect-error — `colorField` is declared `string | undefined`. const colorField: ObjectGanttSchemaTS['colorField'] = 5; @@ -416,3 +424,26 @@ describe('ObjectGanttSchema (TS) — compile-time pin on every declared key', () expect(ok.interactions?.resize).toBe(false); }); }); + +/* ── The three query keys EXIST on the flat face, with their declared type ─── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** + * `staticData` / `filter` / `sort` are not `GanttConfig` keys, so the derived + * invariant above is blind to their deletion, and the counter-probe literal + * spreads `GOOD`, which excess-property checking does not reach. These rows are + * their deletion guard: an indexed access on a member that does not exist is + * itself a compile error, and `Equal` pins each declared type. Measured by + * ablation on this tree: deleting any one of the three from `ObjectGanttSchema` + * turns its row red while its directive above stays green; restored, green. + */ +// The two `any[]` below restate the members' own declared type (`staticData?: any[]`, +// `filter?: any[]` in `objectql.ts`); `Equal` is strict, so a narrower spelling would be red. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type _StaticDataIsDeclared = Expect>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type _FilterIsDeclared = Expect>; +export type _SortIsDeclared = Expect>; diff --git a/packages/types/src/__tests__/gantt-search-keys-10250.test.ts b/packages/types/src/__tests__/gantt-search-keys-10250.test.ts index 9b51316b26..30d8406f04 100644 --- a/packages/types/src/__tests__/gantt-search-keys-10250.test.ts +++ b/packages/types/src/__tests__/gantt-search-keys-10250.test.ts @@ -14,13 +14,15 @@ * field list as `$searchFields` alongside it — because a `ListView` gantt's * toolbar Search box can reach the chart's own query through no other door. * A key the renderer reads is declared on both faces, so it is validated: - * `BaseSchema` is `.passthrough()` and carries `[key: string]: any`, so an - * undeclared read would type as `any` and parse unjudged. + * `BaseSchema` is `.passthrough()`, so an undeclared key would parse unjudged + * (and an undeclared read typed as `any` until objectui#8347 removed + * `[key: string]: any`; it does not compile now). * - * The compile-time half is the `@ts-expect-error` pair at the bottom: drop a - * declaration and its member resolves to `any` through the index signature, - * the wrong-typed assignment starts succeeding, and the unused directive fails - * `tsc -p tsconfig.test.json` (TS2578) naming the key. + * The compile-time half is the `@ts-expect-error` pair at the bottom, which + * guards each member's TYPE. When written, a dropped declaration made its member + * resolve to `any` and the unused directive failed `tsc -p tsconfig.test.json` + * (TS2578); since objectui#8347 the indexed access itself errors, the directive + * swallows that, and the well-typed counter-probe beside it fails instead. */ import { describe, it, expect } from 'vitest'; diff --git a/packages/types/src/__tests__/grid-flat-columns-retired-11505.test.ts b/packages/types/src/__tests__/grid-flat-columns-retired-11505.test.ts index 62800a4312..30f1b12888 100644 --- a/packages/types/src/__tests__/grid-flat-columns-retired-11505.test.ts +++ b/packages/types/src/__tests__/grid-flat-columns-retired-11505.test.ts @@ -61,8 +61,9 @@ type InputOf = Shape[K] extends z.ZodType ? z.input>, diff --git a/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts b/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts index 1dc6b13d56..34981fc21f 100644 --- a/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts +++ b/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts @@ -733,8 +733,9 @@ type Equal = type Expect = T; /** `?: never` reads as exactly `undefined` off the interface. `Equal`, not - * `extends`: `BaseSchema`'s `[key: string]: any` means a DELETED member reads - * `any`, which a one-way check would accept (the disabled-twin lesson). */ + * `extends`: `BaseSchema`'s `[key: string]: any` meant a DELETED member read + * `any` until objectui#8347, which a one-way check would accept (the + * disabled-twin lesson). */ type RetiredIsNever = Equal; /** A runtime slot keeps a callable member: some function type survives the diff --git a/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts b/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts index 735a8ca1e5..ea3138ee1f 100644 --- a/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts +++ b/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts @@ -770,8 +770,8 @@ type Equal = type Expect = T; /** `?: never` reads as exactly `undefined` off the interface (`Equal`, not - * `extends`, because `BaseSchema`'s index signature makes a DELETED member read - * `any`, which a one-way check would accept). */ + * `extends`, because `BaseSchema`'s index signature made a DELETED member read + * `any` until objectui#8347, which a one-way check would accept). */ type RetiredIsNever = Equal; /** A runtime slot keeps a callable member. Over `NonNullable` so `undefined` @@ -815,8 +815,8 @@ export type assertionStringTwinsStopDeclaringString = [ ]; /** - * The member is DECLARED on the interface, not inherited from `BaseSchema`'s - * `[key: string]: any` index signature. + * The member is DECLARED on the interface, not read through the + * `[key: string]: any` index signature `BaseSchema` carried until objectui#8347. * * ⚠️ This exists because objectui#7804's two keys enter this ledger from a base * state the other four never had: ABSENT. `Extract`-based helpers read an @@ -836,7 +836,7 @@ export type assertionDetailSlotsAreDECLARED = [ // The four helpers must be able to FAIL — synthetic controls, both directions. export type assertionDeclaresExactlyCanFail = [ - // an ABSENT member, as `BaseSchema`'s index signature types it + // an ABSENT member, as `BaseSchema`'s index signature typed it (until objectui#8347) Expect void>, false>>, // a DECLARED member with the wrong signature Expect void) | undefined, () => void>, false>>, diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index fe02078f24..9478300384 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -138,7 +138,10 @@ import { checkDashboardWidgetMetricMeasureArity, } from '@objectstack/spec/ui'; import { FieldSchema as SpecFieldSchema, SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; -import { EvaluatedExpressionInputSchema as SpecEvaluatedExpressionInputSchema } from '@objectstack/spec/shared'; +import { + EvaluatedExpressionInputSchema as SpecEvaluatedExpressionInputSchema, + EvaluatedExpressionSchema as SpecEvaluatedExpressionSchema, +} from '@objectstack/spec/shared'; import { stripImportedDefaults } from '../zod/imported-defaults.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -248,6 +251,9 @@ const IMPORTED: Array = [ // TEXT to this schema and takes its verdict and sentence (ADR-0137 D1), so // the crossing is measured here like every other one. ['EvaluatedExpressionInputSchema', SpecEvaluatedExpressionInputSchema], + // objectui#8347: `BaseSchema.visibleWhen`'s envelope arm is this schema, by + // reference, and its verdict is `EvaluatedExpressionInputSchema`'s above. + ['EvaluatedExpressionSchema', SpecEvaluatedExpressionSchema], // objectui#7265, the @object-ui/types slice: `UserFiltersSchema.fields[]` // stopped being a hand copy of the spec's field shape and now derives from // it, so that crossing is measured here like every other one. diff --git a/packages/types/src/__tests__/inline-locale-declared-face-9092.test.ts b/packages/types/src/__tests__/inline-locale-declared-face-9092.test.ts index 3010b32830..8d0863ec8d 100644 --- a/packages/types/src/__tests__/inline-locale-declared-face-9092.test.ts +++ b/packages/types/src/__tests__/inline-locale-declared-face-9092.test.ts @@ -13,10 +13,10 @@ * `WiderThanDeclared` ledger is the instrument that saw it, and its entries for * these keys retired with this card. * - * A one-sided pin would go green for the wrong reason. `BaseSchema` carries - * `[key: string]: any`, so a key REMOVED from a declaration type-checks exactly - * like a key WIDENED — the assignment below would keep compiling if someone - * deleted the member outright. So each positive case is paired with a + * A one-sided pin would have gone green for the wrong reason while `BaseSchema` + * carried `[key: string]: any` (until objectui#8347): a key REMOVED from a + * declaration type-checked exactly like a key WIDENED — the assignment below + * would have kept compiling if someone deleted the member outright. So each positive case is paired with a * `@ts-expect-error` negative on a key that is genuinely a plain `string`: a * `@ts-expect-error` whose error stops occurring is itself a compile error, so * that half fails loudly if the narrow face ever becomes uncheckable. diff --git a/packages/types/src/__tests__/input-otp-separator-11365.test.ts b/packages/types/src/__tests__/input-otp-separator-11365.test.ts index 9d63642647..37ccd13a1d 100644 --- a/packages/types/src/__tests__/input-otp-separator-11365.test.ts +++ b/packages/types/src/__tests__/input-otp-separator-11365.test.ts @@ -10,8 +10,8 @@ * objectui#11365 — `InputOTPSchema.separator` is declared on BOTH faces, as a * boolean (triage's enforce arm under ADR-0049). * - * The TypeScript face carries `BaseSchema`'s `[key: string]: any`, so an - * undeclared `separator` already compiled whatever its value; the declared + * The TypeScript face carried `BaseSchema`'s `[key: string]: any` when this was + * written, so an undeclared `separator` already compiled whatever its value; the declared * member is what makes a non-boolean refused. The compile-time rows below are * checked by `tsc -p tsconfig.test.json`; the `describe` block reads the zod * mirror at runtime, value probe and control included. The rendering half @@ -32,7 +32,7 @@ export type _SeparatorIsABoolean = Expect { diff --git a/packages/types/src/__tests__/input-wrapper-class-mirrored-8072.test.ts b/packages/types/src/__tests__/input-wrapper-class-mirrored-8072.test.ts index df95880347..fc16077a20 100644 --- a/packages/types/src/__tests__/input-wrapper-class-mirrored-8072.test.ts +++ b/packages/types/src/__tests__/input-wrapper-class-mirrored-8072.test.ts @@ -90,17 +90,20 @@ type Expect = T; type IsAny = 0 extends (1 & T) ? true : false; // The TS face already declared this key; these guard it against being removed -// on the way past, in which case the read falls back to `BaseSchema`'s index -// signature and resolves to `any`, and `Equal` is false. +// on the way past, in which case the read stops compiling (it fell back to +// `BaseSchema`'s index signature as `any` until objectui#8347 removed it). export type _WrapperClassIsString = Expect, string>>; export type _WrapperClassIsNotAny = Expect, false>>; -// The control key is NOT declared: it resolves to `any` through the index -// signature, exactly as `wrapperClass` did on the MIRROR before this card. -export type _ControlKeyFallsThroughToIndexSignature = Expect>; - -// The TS face accepts the key on a literal. ⚠️ This is the WEAK half: the index -// signature would accept it undeclared too. The invariant pins above are the -// guard; this line only shows the declared spelling in use. +// Lit control for the detector: `IsAny` does answer `true`, so the line above is a reading. +export type _IsAnyCanAnswerTrue = Expect>; +// The control key is NOT declared: it resolved to `any` through the index +// signature, exactly as `wrapperClass` did on the MIRROR before this card, +// until objectui#8347 removed the signature. It is no member now. +export type _ControlKeyIsNoMember = Expect>; + +// The TS face accepts the key on a literal. Since objectui#8347 this is no +// longer the weak half: without the index signature an undeclared key is +// refused on a fresh literal, so the acceptance reads the declaration. const literal: TsInputSchema = { type: 'input', label: 'Name', wrapperClass: 'gap-4' }; interface Mirror { diff --git a/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts b/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts index e0bc0b8675..a0baa7017c 100644 --- a/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts +++ b/packages/types/src/__tests__/kanban-calendar-filter-sort-8174.test.ts @@ -27,11 +27,12 @@ * * ## What declaring buys — and what it does NOT, measured not assumed * - * objectui#7927 measured the ceiling: `BaseSchema` ends in `[key: string]: any`, - * so no annotation on any node schema can catch a MISSPELLED key. That ceiling - * is real and this card does not lift it — `filtr` stays admitted on both - * faces, and the control assertions below PIN that, so nobody reads this file as - * claiming more than it does. + * objectui#7927 measured the ceiling: `BaseSchema` ended in `[key: string]: any`, + * so no annotation on any node schema could catch a MISSPELLED key. This card + * did not lift it; objectui#8347 did, by removing the signature — `filtr` is + * refused on a fresh literal on the TS face now (pinned below) and stays + * admitted by the tolerant mirror's `.passthrough()`, which the control + * assertions below PIN, so nobody reads this file as claiming more than it does. * * What the ceiling does not cap is the VALUE dimension, and that is the half * this file pins: @@ -39,8 +40,10 @@ * - TS: the member narrows `any` to `any[]` / `SortConfig[]`. `filter: 'a = b'` * and `sort: 'name asc'` were assignable through the index signature and are * now type errors. Each `@ts-expect-error` below goes UNUSED — TS2578, a hard - * type-check failure — the moment its member is deleted, which is what makes - * these pins rather than restatements. + * type-check failure — the moment its member is widened; since objectui#8347 a + * deletion keeps it used (the value is refused as an undeclared key) and is + * caught by the `Equal` rows instead, which is what makes these pins rather + * than restatements. * - Mirror: under `.passthrough()` a DECLARED key is value-validated where an * undeclared one rides through unexamined, and this mirror reaches * `safeValidateSchema` through `AnyComponentSchema`, so the refusal reaches @@ -60,8 +63,9 @@ * Membership is asserted on the mirror's OWN `.shape`, never on parse * acceptance (under `.passthrough()` acceptance cannot tell "declared" from * "admitted unexamined"). Type-level pins use invariant equality, so a member - * that fell back to the index signature reads as `any` and therefore as a - * failure. And every claim carries a CONTROL that is asserted to stay + * that fell back to the index signature read as `any` and therefore as a + * failure (since objectui#8347 removed the signature, a deleted member's + * indexed access does not compile at all). And every claim carries a CONTROL that is asserted to stay * undeclared — including the deliberate absence of `sort` on the kanban board, * which is measured off the spec rather than assumed (the board reads `sort` * only as the binding carrier since objectui#10068). @@ -117,9 +121,10 @@ type IsAny = 0 extends (1 & T) ? true : false; /** An object with no keys is assignable to `Pick` only when `K` is optional on `T`. */ type IsOptional = Record extends Pick ? true : false; -// `filter` on BOTH interfaces: declared `any[]`, optional, not `any`. Delete -// either member and the indexed access falls back to `[key: string]: any`, -// making `IsAny` true and `Equal` false. +// `filter` on BOTH interfaces: declared `any[]`, optional, not `any`. Deleting +// either member made the indexed access fall back to `[key: string]: any`, +// making `IsAny` true and `Equal` false; since +// objectui#8347 the indexed access stops compiling instead. export type _KanbanFilterIsArray = Expect>; export type _KanbanFilterIsNotAny = Expect, false>>; export type _KanbanFilterIsOptional = Expect>; @@ -133,23 +138,30 @@ export type _CalendarSortIsOptional = Expect>; +// It fell through to the index signature as `any` until objectui#8347 removed +// that; it is no member now, and `ObjectKanban` types the gate-written key on +// its own read type (`GateBoundKanbanSchema`). Declaring it turns this red. +export type _KanbanSortStaysUndeclared = Expect>; +// Lit control for the detector: `IsAny` does answer `true`, so the `IsNotAny` +// lines above are readings. +export type _IsAnyCanAnswerTrue = Expect>; // The control key is undeclared on both, exactly as `filter` was before this -// card. Same instrument, opposite verdict. -export type _KanbanControlKeyFallsThrough = Expect>; -export type _CalendarControlKeyFallsThrough = Expect>; -// objectui#7927's ceiling, pinned rather than claimed away: a MISSPELLED key -// still resolves to `any` and still type-checks. This card does not fix that, -// and this line is what stops anyone reading it as if it had. -export type _MisspellingStillAdmitted = Expect>; +// card: no member of either (it read `any` before objectui#8347). +export type _KanbanControlKeyIsNoMember = Expect>; +export type _CalendarControlKeyIsNoMember = Expect>; +// objectui#7927's ceiling — a MISSPELLED key still resolved to `any` and still +// type-checked — was pinned here rather than claimed away. objectui#8347 +// removed it: the misspelling is no member, and a fresh literal is refused. +export type _MisspellingIsNoMember = Expect>; // The TS face ACCEPTS the documented shapes on a literal… const kanbanLiteral: TsObjectKanbanSchema = { ...KANBAN_NODE, filter: FILTER_VALUE }; const calendarLiteral: TsObjectCalendarSchema = { ...CALENDAR_NODE, filter: FILTER_VALUE, sort: SORT_VALUE }; // …and REFUSES wrong-typed values that the index signature used to admit. Each -// directive goes unused — TS2578, a hard failure — if its member is deleted. +// directive goes unused — TS2578, a hard failure — if its member is widened. A +// deletion keeps it used since objectui#8347 (the value is refused as an +// undeclared key) and is caught by the `Equal` rows above. // @ts-expect-error — `filter` is `any[]`; a string clause is not a JSON-Rules filter const kanbanBadFilter: TsObjectKanbanSchema = { ...KANBAN_NODE, filter: 'status = open' }; // @ts-expect-error — `filter` is `any[]`; a string clause is not a JSON-Rules filter @@ -158,6 +170,11 @@ const calendarBadFilter: TsObjectCalendarSchema = { ...CALENDAR_NODE, filter: 's const calendarRetiredSort: TsObjectCalendarSchema = { ...CALENDAR_NODE, sort: 'start_date asc' }; // @ts-expect-error — `order` is `'asc' | 'desc'`; the member is `SortConfig[]`, not `any[]` const calendarBadSortOrder: TsObjectCalendarSchema = { ...CALENDAR_NODE, sort: [{ field: 'start_date', order: 'sideways' }] }; +// …and, since objectui#8347, the misspelling itself and the gate-only key on the board. +// @ts-expect-error — `filtr` is no member of `ObjectCalendarSchema`; the key is `filter` +const calendarMisspelledFilter: TsObjectCalendarSchema = { ...CALENDAR_NODE, filtr: FILTER_VALUE }; +// @ts-expect-error — `sort` is not an `object-kanban` authoring key; the binding's `dataSource.sort` is +const kanbanAuthoredSort: TsObjectKanbanSchema = { ...KANBAN_NODE, sort: SORT_VALUE }; /* ── Off-disk derivations ─────────────────────────────────────────────────── */ @@ -361,6 +378,6 @@ describe('objectui#8174 — the TS face accepts the documented nodes', () => { expect(calendarLiteral.sort).toEqual(SORT_VALUE); // The refused literals exist only so their `@ts-expect-error` directives do; // referencing them keeps `noUnusedLocals` off this file's back. - expect([kanbanBadFilter, calendarBadFilter, calendarRetiredSort, calendarBadSortOrder]).toHaveLength(4); + expect([kanbanBadFilter, calendarBadFilter, calendarRetiredSort, calendarBadSortOrder, calendarMisspelledFilter, kanbanAuthoredSort]).toHaveLength(6); }); }); diff --git a/packages/types/src/__tests__/markdown-inert-keys-retired-6972.test.ts b/packages/types/src/__tests__/markdown-inert-keys-retired-6972.test.ts index e66476feb5..a06f3c6274 100644 --- a/packages/types/src/__tests__/markdown-inert-keys-retired-6972.test.ts +++ b/packages/types/src/__tests__/markdown-inert-keys-retired-6972.test.ts @@ -52,9 +52,10 @@ * * ## Why the tombstone, and not simply deleting the keys * - * `BaseSchema` is `.passthrough()` on the Zod side and carries a - * `[key: string]: any` index signature on the TS side. An UNDECLARED key is - * accepted by both halves, unvalidated — deleting `sanitize` outright would + * `BaseSchema` is `.passthrough()` on the Zod side and carried a + * `[key: string]: any` index signature on the TS side until objectui#8347. An + * UNDECLARED key is accepted by the Zod half, unvalidated (and was by the TS + * half; since objectui#8347 only through a widened value) — deleting `sanitize` outright would * hand the authored spelling exactly the silent no-op this card exists to * close (and would hand `components` the same). `?: never` / * `retirementTombstone()` is this package's convention — @@ -261,9 +262,10 @@ describe('MarkdownSchema.sanitize is RETIRED — the TS half of the tombstone (o }); it('refuses the retired key in the form authors actually write', () => { - // The leg that proves the tombstone survives `BaseSchema`'s - // `[key: string]: any`: if the index signature won, `sanitize` would widen - // back to `any` here and the directive would go unused (TS2578). + // The leg that proved the tombstone survives `BaseSchema`'s + // `[key: string]: any` while it stood (objectui#8347 removed it): if the + // index signature had won, `sanitize` would have widened back to `any` + // here and the directive would have gone unused (TS2578). const retiredDocument: MarkdownSchemaTS = { type: 'markdown', content: '# Hello', @@ -307,9 +309,10 @@ describe('MarkdownSchema.components is RETIRED — the TS half of the tombstone }); it('refuses the retired key in the form authors actually write', () => { - // Survives `BaseSchema`'s `[key: string]: any`: if the index signature - // won, `components` would widen back to `any` here and the directive - // would go unused (TS2578). + // Survived `BaseSchema`'s `[key: string]: any` while it stood (objectui#8347 + // removed it): if the index signature had won, `components` would have + // widened back to `any` here and the directive would have gone unused + // (TS2578). const retiredDocument: MarkdownSchemaTS = { type: 'markdown', content: '# Hello', diff --git a/packages/types/src/__tests__/mirror-only-published-keys-9729.test.ts b/packages/types/src/__tests__/mirror-only-published-keys-9729.test.ts index c84397a8d5..68d38b9d2a 100644 --- a/packages/types/src/__tests__/mirror-only-published-keys-9729.test.ts +++ b/packages/types/src/__tests__/mirror-only-published-keys-9729.test.ts @@ -61,13 +61,16 @@ * renderer's behaviour. It still carries no index signature — which is what * keeps the assignment at the bottom of this file a real assertion. * - `ObjectGridSchema` extends `BaseSchema`, whose `[key: string]: any` index - * signature absorbs any unstated key. TypeScript neither declares nor - * refuses `operators`; it types it `any`. The faces did not contradict — + * signature absorbed any unstated key. TypeScript neither declared nor + * refused `operators`; it typed it `any`. The faces did not contradict — * one was simply silent, which is a weaker defect and was a different * decision. ⭐ It was decided (objectui#9739, letter C): the MIRROR now * refuses the key by name and prints the upstream spelling, while the twin * is deliberately left alone — so the two faces still do not contradict, * but the silent one is no longer the only one an author hears from. + * ⚠️ Dated note (objectui#8347): `BaseSchema` no longer carries that index + * signature, so the twin refuses an authored `operators` too, as an + * undeclared key. Still not declared, still not tombstoned on the twin. * * ## ⚠️ The third key the ledger groups with these two is NOT this defect * @@ -246,21 +249,24 @@ export type assertionDueLikeIsDeclaredOnTheTwin = Expect< Equal< DetailViewField['dueLike'], boolean | undefined > >; /** - * The TWIN still does NOT refuse `operators` — and the absence of a - * `@ts-expect-error` here is the measurement, not an omission: adding one - * reddens the same type-check leg as unused, which is how this claim fails if - * the twin ever loses `BaseSchema`'s index signature. + * The TWIN refuses `operators` too since objectui#8347 removed `BaseSchema`'s + * index signature. Before it, the absence of a `@ts-expect-error` here was the + * measurement — this binding compiled through the signature — and this + * docblock named the removal as the way that claim would fail. It failed as + * named: the key is still NOT declared on the twin (below), so it is refused + * as an undeclared key, with no name for the remedy; the mirror's tombstone + * is what names `operations`. * * ⭐ Unchanged by objectui#9739 ON PURPOSE. Letter C rules the key "not declared * on the TypeScript twin" — declaring it was letter A, refused, on the ground * that it writes a misspelling into the published interface beside the correct - * spelling. So this binding compiling is still the true reading of the twin; - * what changed is that the MIRROR beside it no longer agrees, and that asymmetry - * is the ruled outcome rather than an unrepaired gap. + * spelling. That ruling stands: the twin declares nothing for `operators`, + * and both faces now refuse it, the mirror by name and the twin as undeclared. */ export const authoredOperators: ObjectGridSchemaType = { type: 'object-grid', objectName: 'probe', + // @ts-expect-error — `operators` is not declared on the twin; since objectui#8347 an undeclared key is refused operators: { name: ['equals'] }, }; diff --git a/packages/types/src/__tests__/node-slot-union-11466.test.ts b/packages/types/src/__tests__/node-slot-union-11466.test.ts index adafee6843..277636863b 100644 --- a/packages/types/src/__tests__/node-slot-union-11466.test.ts +++ b/packages/types/src/__tests__/node-slot-union-11466.test.ts @@ -18,14 +18,15 @@ * * ## The compile-fail pin, and why it does not wait for objectui#8347 * - * Until objectui#8347 removes `BaseSchema`'s index signature, a misspelled key on - * a node type that extends `BaseSchema` still compiles: the signature absorbs it - * on the type's own arm. What this card changes is the SLOT: it discriminates on + * Until objectui#8347 removed `BaseSchema`'s index signature, a misspelled key on + * a node type that extends `BaseSchema` still compiled: the signature absorbed it + * on the type's own arm. (Since the removal it is refused there too, pinned in + * `base-schema-closed-face-8347.test.ts`.) What this card changed is the SLOT: it discriminates on * `type`, so an inline child is judged against its own type's arm wherever it is * nested. `ClosedProbeNode` below is a node type declared WITHOUT an index - * signature, the shape every arm has once the removal lands, registered through - * `CustomNodeRegistry`. Its misspelling is refused at a nested slot today, so the - * pin runs in every type-check now and keeps running after the removal. The + * signature, the shape every arm has now that the removal has landed (objectui#8347), registered through + * `CustomNodeRegistry`. Its misspelling is refused at a nested slot, so the pin + * ran in every type-check before the removal and keeps running after it. The * spec-derived `AuthoringNode`s carry no signature either and are pinned the * same way. */ diff --git a/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts b/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts index 87e8fb5a55..0bf2d00524 100644 --- a/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts +++ b/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts @@ -81,8 +81,9 @@ type IsOptionalKey< T, K extends keyof T > = Partial< Pick< T, K > > extends Pic /** * `objectName` is OPTIONAL and still `string`. Both directions bite: required * again -> `string` is not `string | undefined` -> red; member DELETED -> the - * key resolves through `BaseSchema`'s index signature to `any`, and - * `Equal< any, … >` is false -> red. + * indexed access stops compiling -> red (until objectui#8347 the key resolved + * through `BaseSchema`'s index signature to `any`, and `Equal< any, … >` was + * false -> red). */ export type _CalendarObjectNameIsOptionalString = Expect< Equal< TsObjectCalendarSchema['objectName'], string | undefined > >; diff --git a/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts b/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts index f7b075c34f..9c25a0fed6 100644 --- a/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts +++ b/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts @@ -147,9 +147,9 @@ type Equal = type Expect = T; /** - * `Equal`, not `extends`: an UNDECLARED member reads `any` through - * `BaseSchema`'s index signature, and a one-way check accepts `any` — which is - * precisely the before-state. + * `Equal`, not `extends`: an UNDECLARED member read `any` through + * `BaseSchema`'s index signature until objectui#8347, and a one-way check + * accepts `any` — which is precisely the before-state. */ export type assertionYAxisIsTheSpecList = Expect>; /** `xAxis` is the spec's ONE object — not a string, not a list, not `any`. */ diff --git a/packages/types/src/__tests__/object-chart-legacy-axis-keys-retired-10608.test.ts b/packages/types/src/__tests__/object-chart-legacy-axis-keys-retired-10608.test.ts index b485159fce..fed9861212 100644 --- a/packages/types/src/__tests__/object-chart-legacy-axis-keys-retired-10608.test.ts +++ b/packages/types/src/__tests__/object-chart-legacy-axis-keys-retired-10608.test.ts @@ -25,8 +25,9 @@ * * ## Why tombstones and not deletions * - * `BaseSchema` is `.passthrough()` on the zod side and carries an index - * signature on the TS side, so an UNDECLARED key is not refused, it is KEPT. + * `BaseSchema` is `.passthrough()` on the zod side (and carried an index + * signature on the TS side until objectui#8347), so an UNDECLARED key is not + * refused there, it is KEPT. * Deleting the three members would hand the authored spelling exactly the * silent no-op this card closes; block (d) pins that consequence on a * misspelling, so the reason is a reading and not prose. `?: never` + @@ -212,12 +213,14 @@ type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; type Expect = T; -/** `Equal`, not `extends`: an UNDECLARED member reads `any` through the index signature. */ +/** `Equal`, not `extends`: an UNDECLARED member read `any` through the index signature until objectui#8347. */ export type assertionXAxisFieldRetired = Expect>; export type assertionYAxisFieldsRetired = Expect>; export type assertionAggregationRetired = Expect>; -/** The helper can FAIL — synthetic control (an undeclared key reads `any`). */ -export type assertionEqualCanFail = Expect, false>>; +/** The helper can FAIL — synthetic control (a declared member is not `undefined`). */ +export type assertionEqualCanFail = Expect, false>>; +/** An undeclared key is no member at all since objectui#8347 — it read `any` before. */ +export type assertionUndeclaredKeyIsNoMember = Expect>; describe('objectui#10608 (f) — the TS twin refuses what the mirror refuses', () => { it('each retired key is a compile error — checked by `tsc -p tsconfig.test.json`', () => { diff --git a/packages/types/src/__tests__/object-chart-undeclared-keys-8885.test.ts b/packages/types/src/__tests__/object-chart-undeclared-keys-8885.test.ts index 2a52e8e9b8..ec92e40e0b 100644 --- a/packages/types/src/__tests__/object-chart-undeclared-keys-8885.test.ts +++ b/packages/types/src/__tests__/object-chart-undeclared-keys-8885.test.ts @@ -49,12 +49,13 @@ * * ## The ceiling, stated rather than assumed (objectui#5155) * - * `BaseSchema` is `.passthrough()` and its TS twin carries `[key: string]: any`, - * so declaring a key buys it its declared TYPE — `title: 42` is refused now — - * but does NOT buy rejection of a MISSPELLING: `drillDwn: {}` still parses and - * still compiles, exactly as `visibleWhn` does on `ObjectGallerySchema` - * (objectui#6576). The counter-probe below pins that honestly so nobody reads - * the declaration as more than it is. + * `BaseSchema` is `.passthrough()` and its TS twin carried `[key: string]: any` + * until objectui#8347, so declaring a key bought it its declared TYPE — + * `title: 42` is refused now — but did NOT buy rejection of a MISSPELLING: + * `drillDwn: {}` still parsed and still compiled, exactly as `visibleWhn` did on + * `ObjectGallerySchema` (objectui#6576). It still parses (the mirror is + * unchanged); since objectui#8347 it no longer compiles, and the counter-probe + * below is flipped to pin that. * * ## Four keys stay ledgered, and the ledger is not a waiver * @@ -103,7 +104,7 @@ type Expect = T; /** * `Equal`, not `extends`: through `BaseSchema`'s index signature an UNDECLARED - * member reads `any`, and a one-way check accepts `any` on both sides — which + * member read `any` (until objectui#8347), and a one-way check accepts `any` on both sides — which * is precisely the before-state this card removed (the objectui#7087 * disabled-twin lesson). */ @@ -160,11 +161,13 @@ describe('ObjectChartSchema — compile-time pins for the three keys (objectui#8 expect(withMode.drillDown).toBeTruthy(); }); - it('the ceiling: a MISSPELLING still compiles, because `BaseSchema` carries an index signature', () => { - // Not a defect being papered over — the honest bound of what declaring a - // key buys. Revisit deliberately when objectui#5155 lands. + it('the ceiling is gone: a MISSPELLING stops compiling, since objectui#8347 removed `BaseSchema`\'s index signature', () => { + // This row pinned the honest bound of what declaring a key bought while the + // signature stood ("revisit deliberately when objectui#5155 lands"). The + // removal landed as objectui#8347, and the row flipped with it. + // @ts-expect-error — `drillDwn` is no member of `ObjectChartSchema`; the key is `drillDown` const typo: ObjectChartSchema = { type: 'object-chart', chartType: 'bar', drillDwn: { enabled: true } }; - expect(typo.drillDwn).toEqual({ enabled: true }); + expect(typo.type).toBe('object-chart'); }); }); diff --git a/packages/types/src/__tests__/object-data-table-dataprovider-retired-7353.test.ts b/packages/types/src/__tests__/object-data-table-dataprovider-retired-7353.test.ts index 56c4ad4015..8eee406e97 100644 --- a/packages/types/src/__tests__/object-data-table-dataprovider-retired-7353.test.ts +++ b/packages/types/src/__tests__/object-data-table-dataprovider-retired-7353.test.ts @@ -20,8 +20,9 @@ * ## Why a tombstone and not a deletion — the carrier decides * * `ObjectDataTableSchema` survives the retirement and extends `BaseSchema`: - * `[key: string]: any` on the TS face, `.passthrough()` on the zod face. On - * such a carrier a DELETED optional member is absorbed silently at any value — + * `.passthrough()` on the zod face, and `[key: string]: any` on the TS face + * until objectui#8347. On such a carrier a DELETED optional member was absorbed + * silently at any value (the zod face still keeps one) — * measured on this branch before the tombstone: an authored `dataProvider` * parsed green and was KEPT on the parsed value, and a malformed one that the * typed member used to refuse parsed green too. That is the silent no-op the @@ -88,14 +89,17 @@ describe('objectui#7353 — the TS face refuses an authored dataProvider', () => // @ts-expect-error `dataProvider` is a retirement tombstone (objectui#7353) — write `objectName` dataProvider: MALFORMED, }; - // The contrast, pinned LIVE rather than in prose: an undeclared key carries - // no directive, because `BaseSchema`'s index signature absorbs it. That is - // what a deletion would have left `dataProvider` as. + // The contrast, pinned LIVE rather than in prose: an undeclared key carried + // no directive while `BaseSchema`'s index signature absorbed it — what a + // deletion would have left `dataProvider` as. objectui#8347 removed the + // signature, so it is refused too now, but as an undeclared key with no + // remedy named; the tombstone above is what names `objectName`. + // @ts-expect-error — `ObjectDataTableSchema` declares no `zzUndeclared7353` (objectui#8347) const undeclared: ObjectDataTableSchema = { type: 'object-data-table', zzUndeclared7353: 1 }; - expect([wellFormed.type, malformed.type, undeclared.zzUndeclared7353]).toEqual([ + expect([wellFormed.type, malformed.type, undeclared.type]).toEqual([ + 'object-data-table', 'object-data-table', 'object-data-table', - 1, ]); }); }); diff --git a/packages/types/src/__tests__/object-data-table-page-size-11348.test.ts b/packages/types/src/__tests__/object-data-table-page-size-11348.test.ts index f14d6942f8..643561b554 100644 --- a/packages/types/src/__tests__/object-data-table-page-size-11348.test.ts +++ b/packages/types/src/__tests__/object-data-table-page-size-11348.test.ts @@ -12,7 +12,7 @@ * `ObjectDataTable` spreads its node into the `data-table` it renders, and that * node reads `pageSize`, so the key was honoured on `object-data-table` while * neither face declared it. It reached the compiler only through `BaseSchema`'s - * index signature, which objectui#8347 removes. `plugin-dashboard`'s drill-down + * index signature, which objectui#8347 removed. `plugin-dashboard`'s drill-down * drawer writes it on the node it builds (from its `maxRows`), and the drilled * list's row count depends on it: that runtime half is pinned by * `ObjectMetricWidget.drillRoutedToSharedDrawer-8970.test.tsx` in that package. @@ -27,7 +27,8 @@ * tolerant zod mirror and the strict authoring face; a non-number is refused * by value on the mirror (an `invalid_type` at `pageSize`, not an unrecognized * key) and by `tsc` (the `@ts-expect-error` below turns into TS2578 if the - * member is ever widened or dropped back into the index signature). + * member is ever widened; a deletion did too, by dropping it back into the index + * signature, until objectui#8347, and is refused as an undeclared key now). * * ⚠️ The compile-time lines are erased at runtime; `tsc -p tsconfig.test.json`, * chained from this package's `type-check` script, is what executes them. diff --git a/packages/types/src/__tests__/object-gallery-filter-9309.test.ts b/packages/types/src/__tests__/object-gallery-filter-9309.test.ts index b6cdf7e96e..4d1032daab 100644 --- a/packages/types/src/__tests__/object-gallery-filter-9309.test.ts +++ b/packages/types/src/__tests__/object-gallery-filter-9309.test.ts @@ -87,10 +87,11 @@ export type _GalleryFilterIsOptional = Expect, false>>; @@ -110,12 +111,16 @@ const galleryStringFilter: TsObjectGallerySchema = { type: 'object-gallery', fil const galleryNumberFilter: TsObjectGallerySchema = { type: 'object-gallery', filter: 42 }; /** - * The objectui#7927 ceiling, pinned rather than claimed away: `BaseSchema` ends - * in `[key: string]: any`, so a MISSPELLED key still resolves to `any` and - * still compiles. This card does not lift that, and this line is what stops - * anyone reading it as if it had. + * The objectui#7927 ceiling was pinned here rather than claimed away: while + * `BaseSchema` ended in `[key: string]: any`, a MISSPELLED key resolved to `any` + * and compiled. objectui#8347 removed the signature, so the misspelling is no + * member, and a fresh literal carrying it is refused. */ -export type _MisspellingStillAdmitted = Expect>; +export type _MisspellingIsNoMember = Expect>; +// Lit control for the detector: `IsAny` does answer `true`, so `_GalleryFilterIsNotAny` is a reading. +export type _IsAnyCanAnswerTrue = Expect>; +// @ts-expect-error — `filtr` is no member of `ObjectGallerySchema`; the key is `filter` +const galleryMisspelledFilter: TsObjectGallerySchema = { type: 'object-gallery', filtr: 'stage=won' }; /* ── The mirror, at runtime ───────────────────────────────────────────────── */ @@ -239,5 +244,6 @@ describe('objectui#9309 — the type-face literals above are real', () => { expect(galleryRecordArm.type).toBe('object-gallery'); expect(galleryStringFilter.type).toBe('object-gallery'); expect(galleryNumberFilter.type).toBe('object-gallery'); + expect(galleryMisspelledFilter.type).toBe('object-gallery'); }); }); diff --git a/packages/types/src/__tests__/object-grid-declared-keys-11068.test.ts b/packages/types/src/__tests__/object-grid-declared-keys-11068.test.ts index 6615d2788e..e328a866b3 100644 --- a/packages/types/src/__tests__/object-grid-declared-keys-11068.test.ts +++ b/packages/types/src/__tests__/object-grid-declared-keys-11068.test.ts @@ -20,9 +20,10 @@ * `BaseSchema` members with no meaning on a grid — a grid is neither a form * field nor an input — and nothing read either on this node. * - * Deleting them would refuse nothing: the zod twin ends `.passthrough()` (and - * its base declares `name` / `placeholder` itself), and the interface inherits - * `BaseSchema`'s members and index signature. So each stays DECLARED and + * Deleting them would refuse nothing on the zod face: the twin ends + * `.passthrough()` (and its base declares `name` / `placeholder` itself). The + * interface inherits `BaseSchema`'s members (and inherited its index signature + * until objectui#8347; a widened value still skips the excess-property check). So each stays DECLARED and * unwritable on both faces — `retirementTombstone()` and `?: never` — the * convention `defaultSort` set (objectui#5861). * @@ -163,9 +164,13 @@ describe('the UPSTREAM half — the canonical action spellings, re-derived from /** * The `@ts-expect-error` lines below ARE the assertions: each retired member is * `?: never`, so the literal is a compile error. They fail this package's - * `tsc -p tsconfig.test.json` leg as UNUSED directives if a member is deleted - * (`name` / `placeholder` would fall back to `BaseSchema`'s `string`, the other - * two to the index signature) or re-typed as a live value. + * `tsc -p tsconfig.test.json` leg as UNUSED directives if a member is re-typed + * as a live value, or if `name` / `placeholder` is deleted (each would fall back + * to `BaseSchema`'s `string`). Deleting either of the other two fell to the + * index signature until objectui#8347; it is refused as an undeclared key now, + * which keeps its directive used, so the directives no longer see that + * deletion. The deletion guards for those two are the `_RowSpecActions…` and + * `_BulkSpecActions…` rows at the bottom of this file. */ export const authoredRowSpecActionsRefused: ObjectGridSchemaType = { ...NODE, @@ -210,3 +215,21 @@ describe('the TypeScript twin', () => { ]).toHaveLength(5); }); }); + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** + * The two tombstones `BaseSchema` does not declare are MEMBERS on the TS face: + * an indexed access on a member that does not exist is itself a compile error, + * and each row pins the tombstone type (`?: never` reads as `undefined`), so a + * re-typing to a live value reddens it as well. Measured by ablation on this + * tree: deleting either member from `ObjectGridSchema` turns its row red while + * its directive above stays green (the `zod-mirror-parity` ledger reddens on + * the same deletion, since the zod twin still declares the member); restored, + * both are green. `name` and `placeholder` need no row: deleting either falls + * back to `BaseSchema`'s `string`, and the directive above goes unused (TS2578). + */ +export type _RowSpecActionsIsATombstone = Expect>; +export type _BulkSpecActionsIsATombstone = Expect>; diff --git a/packages/types/src/__tests__/object-grid-default-sort-tombstone-5861.test.ts b/packages/types/src/__tests__/object-grid-default-sort-tombstone-5861.test.ts index a28ddd29a2..6e869b0afb 100644 --- a/packages/types/src/__tests__/object-grid-default-sort-tombstone-5861.test.ts +++ b/packages/types/src/__tests__/object-grid-default-sort-tombstone-5861.test.ts @@ -19,8 +19,10 @@ * * - the zod mirror reaches `BaseSchema`, which is `.passthrough()`, so an * undeclared key is KEPT unexamined — the document would parse green; - * - the TypeScript twin extends `BaseSchema`'s `[key: string]: any`, so an - * undeclared key is absorbed as `any` — the literal would compile. + * - the TypeScript twin extended `BaseSchema`'s `[key: string]: any`, so an + * undeclared key was absorbed as `any` — the literal would have compiled + * (since objectui#8347 a fresh literal is refused, unnamed, and a widened + * value still passes). * * Either way an author would write a key nothing reads and get an unsorted * grid with no signal. So the key stays DECLARED and unwritable on both faces: @@ -126,8 +128,13 @@ describe('the UPSTREAM half — re-derived from the installed pin, not copied in * The `@ts-expect-error` below IS the assertion: `defaultSort?: never` makes * the legacy literal a compile error. It fails this package's * `tsc -p tsconfig.test.json` leg as an UNUSED directive if the member is ever - * deleted (the index signature would absorb the key) or re-typed as a live - * `{ field, order }`. + * re-typed as a live `{ field, order }`. A deletion failed it too while the index + * signature absorbed the key; since objectui#8347 a deleted member's key is + * refused as undeclared, which keeps the directive used, so the directive no + * longer sees a deletion. The deletion guard is the `_DefaultSortIsATombstone` + * row below: an indexed access on a member that does not exist is itself a + * compile error, and the row pins the tombstone type (`?: never` reads as + * `undefined`), so a re-typing to a live value reddens it as well. */ export const authoredDefaultSortRefused: ObjectGridSchemaType = { type: 'object-grid', @@ -143,6 +150,19 @@ export const authoredSortIsDeclared: ObjectGridSchemaType = { sort: AUTHORED_CANONICAL, }; +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** + * The tombstone is a MEMBER on the TS face too. Measured by ablation on this + * tree: deleting `defaultSort?: never` from `ObjectGridSchema` turns this row + * red while the directive above stays green (the `zod-mirror-parity` ledger and + * the `ObjectGridSlotKey` constraint in `objectql.ts` redden on the same + * deletion, since both still name the member); restored, it is green. + */ +export type _DefaultSortIsATombstone = Expect>; + describe('the TypeScript twin', () => { it('is compiled by this package’s type-check leg, which is where the two bindings above are read', () => { expect([authoredDefaultSortRefused, authoredSortIsDeclared]).toHaveLength(2); diff --git a/packages/types/src/__tests__/object-grid-operators-tombstone-9739.test.ts b/packages/types/src/__tests__/object-grid-operators-tombstone-9739.test.ts index 257243d2a4..75ef79a763 100644 --- a/packages/types/src/__tests__/object-grid-operators-tombstone-9739.test.ts +++ b/packages/types/src/__tests__/object-grid-operators-tombstone-9739.test.ts @@ -162,24 +162,28 @@ describe('the UPSTREAM half — the remedy is the protocol’s, re-derived from * ⛔ `operators` is NOT declared on `ObjectGridSchema` — letter C says so in as * many words, and letter A (declaring it) was the option refused. * - * The absence of a `@ts-expect-error` on the literal below is the measurement, - * not an omission: `ObjectGridSchema` extends `BaseSchema`, whose index - * signature absorbs the key as `any`, so the compiler stays silent. Adding a - * directive here would redden this package's `tsc -p tsconfig.test.json` leg as - * unused — which is exactly how this claim fails if anyone ever declares the - * key on the twin, or if the twin loses that index signature. + * While `ObjectGridSchema` extended a `BaseSchema` carrying an index + * signature, the literal below compiled — the signature absorbed the key as + * `any` — and the absence of a directive on it was the measurement. This + * docblock named the signature's removal as one way that claim would fail. + * objectui#8347 removed it, so the compiler refuses the key too now, as an + * UNDECLARED key with no remedy named; `_OperatorsIsNoMember` keeps the + * "not declared" half measurable, since a refusal alone cannot tell an + * undeclared key from a `?: never` tombstone. * - * ⚠️ This is the asymmetry the tombstone deliberately creates and does not - * hide: the VALIDATOR now refuses the document the COMPILER still absorbs. The - * ruling accepted it — narrowing the mirror is the protocol-aligned direction, - * and widening the interface to match would have written the misspelling into - * the published TypeScript face. + * ⚠️ The asymmetry the tombstone created — the VALIDATOR refused the document + * the COMPILER absorbed — is closed by the removal, not by widening the + * interface: declaring the key would have written the misspelling into the + * published TypeScript face, which the ruling refused. */ -export const authoredOperatorsStillCompiles: ObjectGridSchemaType = { +export const authoredOperatorsIsRefused: ObjectGridSchemaType = { type: 'object-grid', objectName: 'probe', + // @ts-expect-error — `operators` is not declared on the twin (letter C); since objectui#8347 it is refused as undeclared operators: AUTHORED_MISSPELLING, }; +export type _OperatorsIsNoMember = 'operators' extends keyof ObjectGridSchemaType ? never : true; +const operatorsIsNoMember: _OperatorsIsNoMember = true; /** LIT CONTROL: the spelling the tombstone names is a DECLARED member of the twin. */ export const authoredOperationsIsDeclared: ObjectGridSchemaType = { @@ -190,6 +194,6 @@ export const authoredOperationsIsDeclared: ObjectGridSchemaType = { describe('the TypeScript twin', () => { it('is compiled by this package’s type-check leg, which is where the two bindings above are read', () => { - expect([authoredOperatorsStillCompiles, authoredOperationsIsDeclared]).toHaveLength(2); + expect([authoredOperatorsIsRefused, authoredOperationsIsDeclared, operatorsIsNoMember]).toHaveLength(3); }); }); diff --git a/packages/types/src/__tests__/object-grid-show-filters-retired-11068.test.ts b/packages/types/src/__tests__/object-grid-show-filters-retired-11068.test.ts index 31ec2d9fe5..3e1a20b92a 100644 --- a/packages/types/src/__tests__/object-grid-show-filters-retired-11068.test.ts +++ b/packages/types/src/__tests__/object-grid-show-filters-retired-11068.test.ts @@ -18,8 +18,10 @@ * `list-view` toolbar's filter builder, switched by `userActions.filter`, so * honouring the key on the grid would have meant building a second filter * surface. The upstream protocol's `object-grid` row does not declare the key - * either. Deleting the member would refuse nothing: the zod twin ends - * `.passthrough()` and the interface inherits `BaseSchema`'s index signature. + * either. Deleting the member would refuse nothing on the zod face, whose twin + * ends `.passthrough()`; the interface inherited `BaseSchema`'s index signature + * until objectui#8347 (and a widened value still skips the excess-property + * check). * So the key stays DECLARED and unwritable on both faces — `retirementTombstone()` * and `?: never` — as `rowSpecActions` and `defaultSort` are. * @@ -168,8 +170,13 @@ describe('the UPSTREAM half, re-derived from the installed pin', () => { * The `@ts-expect-error` line below IS the assertion: the member is `?: never`, * so the literal is a compile error. It fails this package's * `tsc -p tsconfig.test.json` leg as an UNUSED directive if the member is - * deleted (the key would fall back to `BaseSchema`'s index signature) or - * re-typed as a live value. + * re-typed as a live value. A deletion failed it too while the key fell back to + * `BaseSchema`'s index signature; since objectui#8347 a deleted member's key is + * refused as undeclared, which keeps the directive used, so the directive no + * longer sees a deletion. The deletion guard is the `_ShowFiltersIsATombstone` + * row below: an indexed access on a member that does not exist is itself a + * compile error, and the row pins the tombstone type (`?: never` reads as + * `undefined`), so a re-typing to a live value reddens it as well. */ export const authoredShowFiltersRefused: ObjectGridSchemaType = { ...GRID_POST_HOIST, @@ -177,6 +184,22 @@ export const authoredShowFiltersRefused: ObjectGridSchemaType = { showFilters: true, }; +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** + * The tombstone is a MEMBER on the TS face too. Measured by ablation on this + * tree: deleting `showFilters?: never` from `ObjectGridSchema` turns this row + * red while the directive above stays green (the `zod-mirror-parity` ledger + * reddens on the same deletion, since the zod twin still declares the member); + * restored, it is green. The two controls below it pin that the spellings a + * renderer still reads stay LIVE, not tombstoned. + */ +export type _ShowFiltersIsATombstone = Expect>; +export type _ListViewShowFiltersIsLive = Expect>; +export type _ObjectViewShowFiltersIsLive = Expect>; + /** LIT CONTROL: the spellings that stay live are declared, writable members. */ export const listViewFilterToggleIsDeclared: ListViewSchemaType = { type: 'list-view', diff --git a/packages/types/src/__tests__/object-kanban-allow-collapse-retired-8801.test.ts b/packages/types/src/__tests__/object-kanban-allow-collapse-retired-8801.test.ts index 2a298a0871..4b80c44a5d 100644 --- a/packages/types/src/__tests__/object-kanban-allow-collapse-retired-8801.test.ts +++ b/packages/types/src/__tests__/object-kanban-allow-collapse-retired-8801.test.ts @@ -31,9 +31,9 @@ * * ## Why a TOMBSTONE rather than a deleted member * - * `BaseSchema` carries `[key: string]: any` and its mirror ends - * `.passthrough()`, so a deleted member is KEPT, not refused — one silent - * no-op traded for another. The member stays declared and unwritable: `?: never` + * `BaseSchema`'s mirror ends `.passthrough()` (and the interface carried + * `[key: string]: any` until objectui#8347), so a deleted member is KEPT, not + * refused, on the zod face — one silent no-op traded for another. The member stays declared and unwritable: `?: never` * on the interface, `retirementTombstone()` on the mirror. Suite "the near * miss" below is what separates the two outcomes on one instrument. * @@ -73,18 +73,21 @@ type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? type IsAny = 0 extends 1 & T ? true : false; // The tombstone admits exactly one value — absence. ⭐ DELETING the member -// instead would make this indexed access fall through `BaseSchema`'s index -// signature to `any`, and `Equal` is false — so this pin is -// what keeps the retirement from decaying into a silent strip. +// instead made this indexed access fall through `BaseSchema`'s index signature +// to `any` until objectui#8347 (it stops compiling now), and either way this +// pin goes red — so this pin is what keeps the retirement from decaying into a silent strip. type _RetiredIsTombstone = Assert>; type _RetiredIsNotAny = Assert, false>>; // Non-vacuity on the same instrument: a live member still resolves to its own // declared type, so the `undefined` above is a reading and not the shape the // whole interface collapsed to. type _LiveMemberStillDeclared = Assert>; -// …and the never-declared near miss DOES fall through to `any`, which is what -// the retired key would look like had it been deleted rather than tombstoned. -type _NearMissFallsThroughToIndexSignature = Assert>; +// …and the never-declared near miss is no member at all, which is what the +// retired key would look like had it been deleted rather than tombstoned (it +// fell through to `any` until objectui#8347 removed `BaseSchema`'s signature). +type _NearMissIsNoMember = Assert>; +// Lit control for the detector: `IsAny` does answer `true`, so `_RetiredIsNotAny` is a reading. +type _IsAnyCanAnswerTrue = Assert>; // The TS face still accepts a live board… // (`quickAdd` stood in this literal until it was retired on this arm too — @@ -92,7 +95,9 @@ type _NearMissFallsThroughToIndexSignature = Assert = type Expect = T; /** - * `Equal`, not `extends`: an undeclared member reads `any` through - * `BaseSchema`'s index signature, and `any` passes a one-way check on both - * sides — which is precisely the before-state this card removes. + * `Equal`, not `extends`: an undeclared member read `any` through + * `BaseSchema`'s index signature until objectui#8347, and `any` passes a + * one-way check on both sides — which is precisely the before-state this card + * removed. */ export type assertionKanbanNavigationIsTheSpecType = Expect>; @@ -204,9 +205,10 @@ export type assertionEqualCanFail = Expect = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** + * The deletion guard on the TS twin (objectui#8347 silenced the TS2578 one): + * `TsObjectKanbanSchema['cardTitle']` stops compiling the moment the member is + * deleted, and `Equal` fails the moment it is re-typed. Measured by ablation on + * this tree: deleting `cardTitle?: string` from the interface turns this row + * red (beside the two fresh literals above and the `zod-mirror-parity` ledger, + * since the zod twin still declares the member); restored, it is green. The + * alias row is the type-level half of the third pin: reddening it means + * `titleField` was retired, which the ruling forbids. + */ +export type _CardTitleIsADeclaredString = Expect>; +export type _TitleFieldAliasIsStillDeclared = Expect>; + describe('the TS face admits what the zod face admits (objectui#9606)', () => { it.each([ ['the canonical spelling', CANONICAL_BOARD], diff --git a/packages/types/src/__tests__/object-kanban-columns-declared-8913.test.ts b/packages/types/src/__tests__/object-kanban-columns-declared-8913.test.ts index 1ef5a162ba..aae7048eb6 100644 --- a/packages/types/src/__tests__/object-kanban-columns-declared-8913.test.ts +++ b/packages/types/src/__tests__/object-kanban-columns-declared-8913.test.ts @@ -18,8 +18,9 @@ * `columns: "todo"`, `columns: [42]`, a lane with no `title` and — the one * objectui#6939 was filed for — a lane card with no `title` all parsed GREEN. * - * Declaring on a face that already carries `[key: string]: any` / - * `.passthrough()` can only NARROW. That direction check is the first `it` + * Declaring on a face that already carries `.passthrough()` can only NARROW + * (the TS face carried `[key: string]: any` too when this was written; + * objectui#8347 removed it). That direction check is the first `it` * below, and it is asserted rather than asserted-in-prose. * * ## ⛔ Why the element is NOT `KanbanColumn` diff --git a/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts b/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts index c1c9214bd3..663ae6b90e 100644 --- a/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts +++ b/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts @@ -171,9 +171,9 @@ type IsAny = 0 extends (1 & T) ? true : false; type IsOptional = Record extends Pick ? true : false; // `groupBy`: declared `string`, OPTIONAL since objectui#8990, not `any`. Were -// the member removed the indexed access would fall back to the index signature -// and resolve to `any`, and `Equal` is false — so the -// DECLARED-ness this card pinned is still pinned; only its requiredness moved. +// the member removed, the indexed access fell back to the index signature and +// resolved to `any` until objectui#8347 (it stops compiling now); either way the +// pin goes red — so the DECLARED-ness this card pinned is still pinned; only its requiredness moved. export type _GroupByIsString = Expect>; export type _GroupByIsNotAny = Expect, false>>; // objectui#8990 — `@objectstack/spec` declares `groupBy: z.string().optional()`; @@ -186,21 +186,27 @@ export type _LimitIsNumberOrUndefined = Expect, false>>; export type _LimitIsOptional = Expect>; // `groupField`: a `?: never` tombstone — the only value it admits is absence. -// Deleting the member instead would make this `any` (index signature) and the -// pin red, which is the point: the tombstone is load-bearing. +// Deleting the member instead would make this pin red (`any` through the index +// signature until objectui#8347, a non-compiling indexed access since), which is +// the point: the tombstone is load-bearing. export type _GroupFieldIsTombstone = Expect>; export type _GroupFieldIsNotAny = Expect, false>>; -// The control key is NOT declared: it resolves to `any` through the index -// signature, exactly as `groupBy` and `limit` did before this card. Declaring -// it turns this red — which is the point. -export type _ControlKeyFallsThroughToIndexSignature = Expect>; +// The control key is NOT declared: it resolved to `any` through the index +// signature, exactly as `groupBy` and `limit` did before this card, until +// objectui#8347 removed the signature; it is no member now. Declaring it turns +// this red — which is the point. +export type _ControlKeyIsNoMember = Expect>; +// Lit control for the detector: `IsAny` does answer `true`, so the `IsNotAny` lines are readings. +export type _IsAnyCanAnswerTrue = Expect>; // The TS face accepts the documented shape on a literal — the exact node the // doc page's row-cap block now annotates. const literal: TsObjectKanbanSchema = { ...NODE, limit: 250 }; // …REFUSES the retired spelling on a literal (a string is not `never`). This // directive goes unused — and the type-check goes red with TS2578 — the moment -// the tombstone is deleted or widened back to `string`. +// the tombstone is widened back to `string`. A deletion keeps it used since +// objectui#8347 (the key is refused as undeclared) and is caught by the +// tombstone `Equal` row above instead. // @ts-expect-error — `groupField` is RETIRED on this node (objectui#7322); author `groupBy` const retiredLiteral: TsObjectKanbanSchema = { ...NODE, groupField: 'stage' }; // …and ACCEPTS a lane-less node since objectui#8990. This was a @@ -336,8 +342,8 @@ describe('objectui#7322 — the zod mirror declares `groupBy` and `limit`', () = // CONTROL for the pin above — the key is still DECLARED and still ENFORCED // when present. A widening that lost the declaration (letting `groupBy` fall - // back to `BaseSchema`'s index signature) would keep the lane-less pin green - // while silently un-judging every authored value; this is what separates the + // back to `BaseSchema`'s index signature, while it stood) would have kept the + // lane-less pin green while silently un-judging every authored value; this is what separates the // two. The wrong-typed case is covered by the table below. it('CONTROL — optional does not mean unjudged: an authored `groupBy` is still typed', () => { const good = ObjectKanbanSchema.safeParse({ type: 'object-kanban', objectName: 'opportunity', groupBy: 'stage' }); @@ -417,7 +423,8 @@ describe('objectui#7322 — the control key stays undeclared, so nothing outside // is not read: `.passthrough()` admits it, of any type, and it survives. // This is the proof the mirror's unknown-key policy is byte-for-byte what // it was — neither `.strict()` nor a strip was reached for on the way past - // (`BaseSchema`'s index signature is objectui#5155, not this card). + // (`BaseSchema`'s index signature was objectui#5155's, not this card's; + // objectui#8347 removed it). const r = ObjectKanbanSchema.safeParse({ ...NODE, [CONTROL_KEY]: 42 }); expect(r.success).toBe(true); if (r.success) expect((r.data as Record)[CONTROL_KEY]).toBe(42); diff --git a/packages/types/src/__tests__/object-kanban-on-quick-add-retired-11234.test.ts b/packages/types/src/__tests__/object-kanban-on-quick-add-retired-11234.test.ts index 717e87576d..c0110d96d4 100644 --- a/packages/types/src/__tests__/object-kanban-on-quick-add-retired-11234.test.ts +++ b/packages/types/src/__tests__/object-kanban-on-quick-add-retired-11234.test.ts @@ -70,18 +70,24 @@ type IsAny = 0 extends 1 & T ? true : false; type KeepsFunction = [Extract, (...args: never[]) => unknown>] extends [never] ? false : true; // The tombstone admits exactly one value — absence. Deleting the member instead -// would make this indexed access fall through the index signature to `any`. +// made this indexed access fall through the index signature to `any` until +// objectui#8347; it stops compiling now. export type _RetiredIsTombstone = Assert>; export type _RetiredIsNotAny = Assert, false>>; export type _RetiredIsNotCallable = Assert, false>>; // Non-vacuity on the same instruments: the sibling slot is still callable… export type _LiveSlotStillCallable = Assert>; -// …and the never-declared near miss does fall through to `any`. -export type _NearMissFallsThroughToIndexSignature = Assert>; +// …and the never-declared near miss is no member at all (it fell through to +// `any` until objectui#8347 removed `BaseSchema`'s index signature). +export type _NearMissIsNoMember = Assert>; +// Lit control for the detector: `IsAny` does answer `true`, so `_RetiredIsNotAny` is a reading. +export type _IsAnyCanAnswerTrue = Assert>; const liveLiteral: TsObjectKanbanSchema = { ...NODE, onCardClick: () => undefined }; // This directive goes unused — and the type-check goes red with TS2578 — the -// moment the tombstone is deleted or widened back to a callable. +// moment the tombstone is widened back to a callable. A deletion keeps it used +// since objectui#8347 (the key is refused as undeclared) and is caught by the +// tombstone `Equal` row above instead. // @ts-expect-error — `onQuickAdd` is RETIRED on this node (objectui#11234); mount `KanbanRenderer` for Quick Add const retiredLiteral: TsObjectKanbanSchema = { ...NODE, onQuickAdd: LIVE_FUNCTION }; void liveLiteral; diff --git a/packages/types/src/__tests__/object-kanban-quick-add-retired-8285.test.ts b/packages/types/src/__tests__/object-kanban-quick-add-retired-8285.test.ts index e23750ec41..1f8868d798 100644 --- a/packages/types/src/__tests__/object-kanban-quick-add-retired-8285.test.ts +++ b/packages/types/src/__tests__/object-kanban-quick-add-retired-8285.test.ts @@ -21,8 +21,9 @@ * * ## Why a TOMBSTONE rather than a deleted member * - * `BaseSchema` carries `[key: string]: any` and its mirror ends - * `.passthrough()`, so a deleted member is KEPT, not refused. The member stays + * `BaseSchema`'s mirror ends `.passthrough()` (and the interface carried + * `[key: string]: any` until objectui#8347), so a deleted member is KEPT, not + * refused, on the zod face. The member stays * declared and unwritable: `?: never` on the interface, `retirementTombstone()` * on the mirror — the `allowCollapse` shape next door * (`object-kanban-allow-collapse-retired-8801.test.ts`). "The near miss" below @@ -56,17 +57,23 @@ type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? type IsAny = 0 extends 1 & T ? true : false; // The tombstone admits exactly one value — absence. Deleting the member instead -// would make this indexed access fall through the index signature to `any`. +// made this indexed access fall through the index signature to `any` until +// objectui#8347; it stops compiling now. type _RetiredIsTombstone = Assert>; type _RetiredIsNotAny = Assert, false>>; // Non-vacuity on the same instrument: a live member resolves to its own type… type _LiveMemberStillDeclared = Assert>; -// …and the never-declared near miss does fall through to `any`. -type _NearMissFallsThroughToIndexSignature = Assert>; +// …and the never-declared near miss is no member at all (it fell through to +// `any` until objectui#8347 removed `BaseSchema`'s index signature). +type _NearMissIsNoMember = Assert>; +// Lit control for the detector: `IsAny` does answer `true`, so `_RetiredIsNotAny` is a reading. +type _IsAnyCanAnswerTrue = Assert>; const liveLiteral: TsObjectKanbanSchema = { ...NODE, coverImageField: 'cover' }; // This directive goes unused — and the type-check goes red with TS2578 — the -// moment the tombstone is deleted or widened back to `boolean`. +// moment the tombstone is widened back to `boolean`. A deletion keeps it used +// since objectui#8347 (the key is refused as undeclared) and is caught by the +// tombstone `Equal` row above instead. // @ts-expect-error — `quickAdd` is RETIRED on this node (objectui#8285); delete the key const retiredLiteral: TsObjectKanbanSchema = { ...NODE, quickAdd: true }; void liveLiteral; diff --git a/packages/types/src/__tests__/object-tree-filter-9549.test.ts b/packages/types/src/__tests__/object-tree-filter-9549.test.ts index a4b21dab0d..c1a90a1228 100644 --- a/packages/types/src/__tests__/object-tree-filter-9549.test.ts +++ b/packages/types/src/__tests__/object-tree-filter-9549.test.ts @@ -44,8 +44,9 @@ export type _TreeFilterMatchesTheGallery = Expect>; export type _TreeFilterIsOptional = Expect>; /** - * FIRING CONTROL: deleting the member drops the indexed access through - * `BaseSchema`'s `[key: string]: any`, which would otherwise read as a pass. + * FIRING CONTROL: deleting the member dropped the indexed access through + * `BaseSchema`'s `[key: string]: any` (until objectui#8347; it stops compiling + * now), which would otherwise have read as a pass. */ export type _TreeFilterIsNotAny = Expect, false>>; @@ -56,14 +57,24 @@ const treeRecordArm: TsObjectTreeSchema = { type: 'object-tree', objectName: 'account', filter: { age: { $gt: 18 } } }; // …and REFUSES what the index signature used to admit. Each directive goes -// UNUSED — TS2578, a hard type-check failure — the moment the member is removed. +// UNUSED — TS2578, a hard type-check failure — the moment the member is widened; +// since objectui#8347 a removal keeps it used (the value is refused as an +// undeclared key) and is caught by the `Equal` rows above instead. // @ts-expect-error — `filter` is `QueryParams['$filter']`; a string clause is neither arm const treeStringFilter: TsObjectTreeSchema = { type: 'object-tree', objectName: 'account', filter: 'stage=won' }; // @ts-expect-error — `filter` is `QueryParams['$filter']`; a number is neither arm const treeNumberFilter: TsObjectTreeSchema = { type: 'object-tree', objectName: 'account', filter: 42 }; -/** The objectui#7927 ceiling is unchanged: a misspelled key still resolves to `any`. */ -export type _MisspellingStillAdmitted = Expect>; +/** + * The objectui#7927 ceiling — a misspelled key resolved to `any` — was pinned + * here; objectui#8347 removed `BaseSchema`'s index signature, so the misspelling + * is no member and a fresh literal carrying it is refused. + */ +export type _MisspellingIsNoMember = Expect>; +// @ts-expect-error — `filtr` is no member of `ObjectTreeSchema`; the key is `filter` +const treeMisspelledFilter: TsObjectTreeSchema = { type: 'object-tree', objectName: 'account', filtr: 'stage=won' }; +// Lit control for the detector: `IsAny` does answer `true`, so the `IsNotAny` lines are readings. +export type _IsAnyCanAnswerTrue = Expect>; /* ── The mirror, at runtime ───────────────────────────────────────────────── */ @@ -107,7 +118,7 @@ describe('objectui#9549 — the tree mirror refuses what the declaration refuses /* Keep the type-face literals referenced so `noUnusedLocals` cannot drop them. */ describe('objectui#9549 — the type-face literals above are real', () => { it('each literal builds a node', () => { - for (const n of [treeArrayArm, treeRecordArm, treeStringFilter, treeNumberFilter]) { + for (const n of [treeArrayArm, treeRecordArm, treeStringFilter, treeNumberFilter, treeMisspelledFilter]) { expect(n.type).toBe('object-tree'); } }); diff --git a/packages/types/src/__tests__/object-view-slot-key-lists.test.ts b/packages/types/src/__tests__/object-view-slot-key-lists.test.ts index 142d71274a..f84064075e 100644 --- a/packages/types/src/__tests__/object-view-slot-key-lists.test.ts +++ b/packages/types/src/__tests__/object-view-slot-key-lists.test.ts @@ -14,8 +14,8 @@ * Both derived types declared ZERO properties. `Omit` is * `Pick>`, and `keyof T` on a type carrying a string * index signature is `string | number` — the literal member names are ABSORBED. - * `ObjectGridSchema` and `ObjectFormSchema` both inherit `BaseSchema`'s - * `[key: string]: any` (objectui#5155), so each `Pick` rebuilt a type holding + * `ObjectGridSchema` and `ObjectFormSchema` both inherited `BaseSchema`'s + * `[key: string]: any` (objectui#5155) until objectui#8347, so each `Pick` rebuilt a type holding * the index signature and none of the named members. Measured through the * checker before the fix: * @@ -37,7 +37,7 @@ * the `table` slot withholds it as a node-level key. Section 1 pins the counts * as they stand; the "63 and 69" above are the reading of their day. * - * Nothing errored — the index signature answers every key as `any` — so the + * Nothing errored — the index signature answered every key as `any` — so the * symptoms were in the tools that READ the declaration: `table: { colunms: 3 }` * type-checked, `table: { pageSize: 'ten' }` type-checked, and editor completion * inside `table: { … }` offered nothing at all for a slot documented as @@ -93,17 +93,20 @@ * * ## 🗑️ Removal condition (recorded at triage's request) * - * These `Pick` lists exist because `BaseSchema` carries a root string index - * signature. When an objectui#5155 phase removes it, `keyof ObjectGridSchema` - * becomes the literal member union again and `Omit` stops collapsing. + * These `Pick` lists exist because `BaseSchema` carried a root string index + * signature. objectui#8347 removed it (the objectui#5155 phase this condition + * waited for), so `keyof ObjectGridSchema` is the literal member union again + * and `Omit` no longer collapses. * `ObjectFormSlotKey` and the `form` half of this file then become removable in * favour of the original `Omit` form. The `table` half does NOT: since * objectui#10976 its list is a deliberate subset, so an `Omit` would have to * name the withheld set, and what this file guards for `table` — that every * grid member is either handed to the grid or withheld, and that both faces * withhold the same keys — stays true work. `declaresStringIndex` below is the - * tripwire that will notice the index signature going: when it reports `false` - * for the source schemas, the collapse half of this file is gone. + * tripwire that noticed the index signature going: it reports `false` for the + * source schemas since objectui#8347, so the collapse half of this file is gone + * and the `form` half may move back to `Omit` (not done here: the removal PR + * flipped the tripwires and left the lists, which still hold). */ import { describe, it, expect, afterAll } from 'vitest'; @@ -262,20 +265,22 @@ function slotType(slot: 'table' | 'form'): ts.Type { */ describe('the source schemas still declare their full member sets', () => { // 64 since objectui#10872 batch 9 declared `responsiveStyles` (withheld from the slot, above). - it('ObjectGridSchema declares 64 members and carries the #5155 index signature', () => { + it('ObjectGridSchema declares 64 members and no longer carries the #5155 index signature', () => { const grid = exportedType('ObjectGridSchema'); expect(memberNames(grid)).toHaveLength(64); expect(memberNames(grid)).toEqual(expect.arrayContaining(['columns', 'pageSize', 'rowActions'])); - // When this flips to `false`, objectui#5155 has removed the root index - // signature and the `Pick` lists this file pins become removable. - expect(declaresStringIndex(grid)).toBe(true); + // Flipped to `false` by objectui#8347, which removed the root index + // signature (the objectui#5155 phase this tripwire waited for): the `form` + // `Pick` list this file pins is removable now (see the removal condition + // in the header), and the `table` one stays deliberate. + expect(declaresStringIndex(grid)).toBe(false); }); - it('ObjectFormSchema declares 69 members and carries the #5155 index signature', () => { + it('ObjectFormSchema declares 69 members and no longer carries the #5155 index signature', () => { const form = exportedType('ObjectFormSchema'); expect(memberNames(form)).toHaveLength(69); expect(memberNames(form)).toEqual(expect.arrayContaining(['fields', 'sections', 'submitText'])); - expect(declaresStringIndex(form)).toBe(true); + expect(declaresStringIndex(form)).toBe(false); }); }); diff --git a/packages/types/src/__tests__/object-view-spec-parity.test.ts b/packages/types/src/__tests__/object-view-spec-parity.test.ts index 9e6c2884d6..3ea532dc41 100644 --- a/packages/types/src/__tests__/object-view-spec-parity.test.ts +++ b/packages/types/src/__tests__/object-view-spec-parity.test.ts @@ -168,14 +168,15 @@ const ouiDeclaredKeys = [ * The EXPLICITLY DECLARED keys of an interface, with any index signature * filtered out. * - * ⛔ The plain `keyof` spelling does not work here, and silently produces a - * guard-shaped no-op rather than an error. `BaseSchema` ends with + * ⛔ The plain `keyof` spelling did not work here, and silently produced a + * guard-shaped no-op rather than an error, while `BaseSchema` ended with * `[key: string]: any` (`packages/types/src/base.ts`) — the escape hatch that - * lets component nodes carry type-specific extensions. Any interface extending - * it therefore has `keyof` = `string | number`, so - * `Exclude` collapses to - * `never`, `Record` is `{}`, and the exhaustiveness check below - * accepts literally any object — including one missing half the node's keys. + * let component nodes carry type-specific extensions until objectui#8347 + * removed it. Any interface extending it therefore had `keyof` = + * `string | number`, so `Exclude` + * collapsed to `never`, `Record` was `{}`, and the exhaustiveness + * check below accepted literally any object — including one missing half the + * node's keys. * * Measured, not reasoned: with the `keyof` spelling, deleting `viewActions` * from the record left `pnpm --filter @object-ui/types type-check` green. @@ -183,8 +184,8 @@ const ouiDeclaredKeys = [ * keeps the declared names, which restores the error. * * This is also the deeper reason the TS half of this node drifted unnoticed for - * so long: the same index signature means the interface never rejected an - * undeclared key either. + * so long: the same index signature meant the interface never rejected an + * undeclared key either (until objectui#8347). */ type KnownKeys = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: unknown; diff --git a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts index 612fc16368..7f39466c1d 100644 --- a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts +++ b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -565,23 +565,29 @@ type ViewKind = 'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt export type _DefaultViewTypeIsSevenUnion = Expect>; export type _DefaultViewTypeIsNotAny = Expect, false>>; // `viewTabBar`: a `?: never` tombstone — the only value it admits is absence. -// Deleting the member instead would make this `any` (index signature) and the -// pin red, which is the point: the tombstone is load-bearing. +// Deleting the member instead would make this pin red (`any` through the index +// signature until objectui#8347, a non-compiling indexed access since), which is +// the point: the tombstone is load-bearing. export type _ViewTabBarIsTombstone = Expect>; export type _ViewTabBarIsNotAny = Expect, false>>; // `listViews`: the protocol's record since objectui#7928 — inverted from "STILL the // declaration's local value", which this card (objectui#7779) pinned. export type _ListViewsIsNoLongerTheLocalRecord = Expect | undefined>, false>>; export type _ListViewsIsTheSpecRecord = Expect> | undefined>>; -// The control key is NOT declared: it resolves to `any` through the index -// signature, exactly as the ten did on the zod side before this card. -export type _ControlKeyFallsThroughToIndexSignature = Expect>; +// The control key is NOT declared: it resolved to `any` through the index +// signature, exactly as the ten did on the zod side before this card, until +// objectui#8347 removed the signature; it is no member now. +export type _ControlKeyIsNoMember = Expect>; +// Lit control for the detector: `IsAny` does answer `true`, so the `IsNotAny` lines are readings. +export type _IsAnyCanAnswerTrue = Expect>; // The TS face accepts the documented shape on a literal. export const literal: TsObjectViewSchema = { ...NODE, ...(ACCEPTED as Record) }; // …REFUSES the retired spelling on a literal (an object is not `never`). This // directive goes unused — and the type-check goes red with TS2578 — the moment -// the tombstone is deleted or widened back to `ViewTabBarConfig`. +// the tombstone is widened back to `ViewTabBarConfig`. A deletion keeps it used +// since objectui#8347 (the key is refused as undeclared) and is caught by the +// tombstone `Equal` row above instead. // @ts-expect-error — `viewTabBar` is RETIRED on this node (objectui#7779); it was never read export const retiredLiteral: TsObjectViewSchema = { ...NODE, viewTabBar: { showAddButton: true } }; // …and REFUSES the host-only view kind: `tree` is not authorable here (objectui#5321). diff --git a/packages/types/src/__tests__/p1-spec-alignment.test.ts b/packages/types/src/__tests__/p1-spec-alignment.test.ts index c02724d03c..2012fcf2f9 100644 --- a/packages/types/src/__tests__/p1-spec-alignment.test.ts +++ b/packages/types/src/__tests__/p1-spec-alignment.test.ts @@ -12,8 +12,9 @@ */ import { describe, it, expect } from 'vitest'; // The one runtime import in this otherwise type-only file: the retirement pin -// below has to read a zod shape, because the TS interfaces here inherit -// `BaseSchema`'s `[key: string]: any` and cannot reject a key. +// below had to read a zod shape when written, because the TS interfaces here +// inherited `BaseSchema`'s `[key: string]: any` (until objectui#8347) and could +// not reject a key. import { ObjectGridSchema as ObjectGridZodSchema } from '../zod/index.zod'; import type { // P1.1 ListView types @@ -82,11 +83,12 @@ describe('P1.1 ListView Spec Alignment', () => { // the spec's own `retiredKey()` tombstones arrive with it and do the // rejecting, which is the protocol's job, not this repo's. // Asserted against the ZOD ObjectGrid schema, not the TS interface, and that - // is forced rather than stylistic: `ObjectGridSchema` extends `BaseSchema`, - // which carries `[key: string]: any` for type-specific extensions, so no - // interface in that family can reject an excess key. Measured — the - // `@ts-expect-error` first written here failed as TS2578 "unused directive", - // i.e. the type still admitted `virtualScroll` and always would have. The + // was forced rather than stylistic when written: `ObjectGridSchema` extends + // `BaseSchema`, which carried `[key: string]: any` for type-specific + // extensions until objectui#8347, so no interface in that family could reject + // an excess key. Measured — the `@ts-expect-error` first written here failed + // as TS2578 "unused directive", i.e. the type still admitted `virtualScroll` + // then. The // zod shape is the surface that can actually say no, and it is objectui's // own, so the claim holds on the rc.6 pin and on GA alike. it('should no longer declare the retired keys on the ObjectGrid zod schema', () => { diff --git a/packages/types/src/__tests__/page-body-arity-8310.test.ts b/packages/types/src/__tests__/page-body-arity-8310.test.ts index 9e0f045175..aecdb34b1a 100644 --- a/packages/types/src/__tests__/page-body-arity-8310.test.ts +++ b/packages/types/src/__tests__/page-body-arity-8310.test.ts @@ -64,12 +64,14 @@ * * ## ⚠️ The ceiling, measured — do not read this pin as more than it is * - * `BaseSchema` carries `[key: string]: any`. Annotating an authored page - * therefore catches a value of the WRONG TYPE and never a MISSPELLED KEY: an - * undeclared key is absorbed by the index signature and draws zero - * diagnostics, while a type-wrong value draws TS2322. This card repairs the - * arity of one declared key; it does not turn `PageNodeSchema` into a closed - * surface, and nothing here should be quoted as if it did. + * `BaseSchema` carried `[key: string]: any` when this was written. Annotating an + * authored page therefore caught a value of the WRONG TYPE and never a + * MISSPELLED KEY: an undeclared key was absorbed by the index signature and drew + * zero diagnostics, while a type-wrong value drew TS2322. This card repairs the + * arity of one declared key; it did not turn `PageNodeSchema` into a closed + * surface (objectui#8347 later removed the signature, which refuses a + * misspelled key on a fresh literal), and nothing here should be quoted as if + * this card did. * * ⚠️ A second, unrelated defect in the same fence was measured while writing * this file and filed as objectui#8912: the flagship grid SPELLED its child @@ -241,8 +243,8 @@ describe('PageNodeSchema flat content still refuses a non-node — the widening * block pins what happened to the key it moved off. * * ⛔ The assertion is NOT "the old pin is gone". A deleted member would be - * ACCEPTED, silently: `BaseSchemaCore` ends `.passthrough()` and the TS face - * carries `[key: string]: any`, so dropping the declaration would leave a page + * ACCEPTED, silently: `BaseSchemaCore` ends `.passthrough()` (and the TS face + * carried `[key: string]: any` until objectui#8347), so dropping the declaration would leave a page * written in the retired spelling parsing green and rendering nothing — the * exact silence the retirement exists to end. What is pinned is the REFUSAL, * at the key's own path, carrying the remedy. diff --git a/packages/types/src/__tests__/partial-schema-collapse-pin.test.ts b/packages/types/src/__tests__/partial-schema-collapse-pin.test.ts deleted file mode 100644 index d432e34502..0000000000 --- a/packages/types/src/__tests__/partial-schema-collapse-pin.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * `PartialSchema` declares ONE property for every instantiation — pinned, not - * repaired (objectui#6397). - * - * ## What this file asserts, and why it is a PIN rather than a fix - * - * `packages/types/src/index.ts` exports: - * - * export type PartialSchema = { - * type: T['type']; - * } & Partial>; - * - * Its doc comment promises "all properties optional except the type". It does - * not deliver that. Every instantiation declares exactly ONE property — `type` - * — and carries a live `[key: string]: any`, so it accepts anything. Measured - * through the checker against the emitted `index.d.ts`, the same instrument - * that produced objectui#6269's 61 -> 0 reading: - * - * PartialSchema -> 1 declared property: type (source: 61) - * PartialSchema -> 1 declared property: type (source: 67) - * PartialSchema -> 1 declared property: type (source: 42) - * PartialSchema -> 1 declared property: type (source: 27) - * - * ## The mechanism — objectui#6151 / #6269, third position - * - * `Omit` is `Pick>`, and `keyof T` on a type - * carrying a string index signature is `string | number` — the literal member - * names are ABSORBED. Every `T extends BaseSchema` inherits `BaseSchema`'s - * `[key: string]: any` (objectui#5155), so `Partial>` rebuilds a - * type holding the index signature and none of the named members. The explicit - * `{ type: T['type'] }` half of the intersection is the only reason the count is - * 1 and not 0. - * - * objectui#6151 fixed this collapse in a HERITAGE clause and #6269 fixed it in - * PROPERTY position. Neither of their guards can see this one: #6151's walks the - * `LayoutSchema` union, #6269's reads `ObjectViewSchema`'s two slot properties, - * and this is a GENERIC MAPPED-TYPE ALIAS with no union membership and no - * declaring property. Hence a third pin. - * - * ## Why a pin and not a repair - * - * ⛔ Do not "fix" the declaration here, and do not tag it `@deprecated`. Triage - * ruled (objectui#6397, 2026-08-25 re-judgement): - * - * - Retiring the alias is a REMOVAL OF A PUBLISHED EXPORT of - * `@object-ui/types` — a breaking removal of published capability, which - * sits on the human floor. Not a dev's call, and not this file's business. - * - Repairing it in place was read as not available: `T` is generic, so there - * is no literal key list to `Pick` (the escape #6269 used for its two - * concrete schemas), and the triage held that every generic re-spelling — - * `{ [K in keyof T as K extends 'type' ? never : K]?: T[K] }` included — - * collapses for the same `keyof T` reason. - * ⚠️ That last reading is FALSE. A - * key-remapping mapped type iterates the named members and the index - * signature separately, so it keeps the named members; `OmitDeclared` in - * `../form.ts` is that spelling, and objectui#9256's E3 slice repaired two - * published faces with it (pinned in - * `content-channel-e3-residual-9256.test.ts`). `PartialSchema`'s own - * docblock in `../index.ts` records why the alias is still left as - * written; this file's assertions are about the alias as written and do - * not move. - * - Once objectui#5155 removes the root index signature, the alias starts - * working AS WRITTEN with no edit at all, and the removal question - * dissolves. - * - * So the one state that must not survive is "declared, published, collapsed, - * and UNPINNED" — a published type that promises - * `{ type } & everything-else-optional` and delivers `{ type } & any`, ready for - * the next consumer to adopt as protection it does not provide. This file is - * what removes the "unpinned". - * - * ## 🗑️ Removal condition — the objectui#5155 sequencing note - * - * ⭐ THIS FILE IS EXPECTED TO GO RED WHEN objectui#5155 LANDS. That is the - * point, not a regression. When a #5155 phase drops `[key: string]: any` from - * `BaseSchema`, `keyof T` becomes the literal member union again, `Omit` stops - * absorbing names, and `PartialSchema` jumps from 1 declared - * property to 61. Section 3's tripwire (`BaseSchema` still declares a string - * index) is the flag that says which side of that transition you are on. - * - * The correct response to that red is NOT to loosen these assertions. It is to - * delete this file and, if `PartialSchema` is still unconsumed at that point, - * file a fresh enforce-or-remove card for the maintainer — removal of a - * published export stays on the human floor either way. - * - * ## Why it emits its own declarations instead of reading `dist/` - * - * This repo's per-PR `test` job runs `pnpm test` with NO build ahead of it - * (turbo's `test` task only `dependsOn: ["^build"]` — the DEPENDENCY closure, - * never the package's own build), and `packages/types` has no workspace - * dependencies, so nothing builds it. A guard reading `dist/index.d.ts` would be - * absent-or-stale on a cold CI cache — vacuous exactly where it is needed. So - * this file runs the package's OWN tsconfig through the compiler API and - * measures the emitted declaration: the artifact a consumer resolves, derived - * deterministically and with no dependence on CI job ordering. Same reasoning as - * `stack-schema-emitted-members.test.ts`, `object-view-slot-key-lists.test.ts` - * and `package-exports-manifest.test.ts`. - * - * ## Census (objectui#6397, re-run on this branch) - * - * `PartialSchema` has ZERO in-repo instantiations. A grep across `packages/`, - * `apps/`, `examples/`, `content/`, `docs/` and `scripts/` (excluding - * `node_modules` and `dist`) finds exactly ONE occurrence of the identifier: the - * declaration itself at `packages/types/src/index.ts`. There is no call site to - * pin against, so the four types measured below are chosen deliberately — they - * are the four the card itself measured, they are real published schemas from - * four different modules of this package (`objectql`, `objectql`, `objectql`, - * `base`-derived form/action families), and they are exactly the population an - * "editor partial schema" alias would be pointed at. The synthetic type appears - * only in section 1, where it is the CONTROL, never the subject. - */ - -import { describe, it, expect, afterAll } from 'vitest'; -import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; - -const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); - -/** - * Emit declarations with the package's OWN build settings, into a scratch dir - * under `node_modules/` — which is gitignored, and from which Node's module - * resolution still walks up to `packages/types/node_modules`, so the emitted - * `import type … from '@objectstack/spec/ui'` still resolves. - */ -function emitDeclarations(): { dir: string; index: string } { - const configPath = join(packageRoot, 'tsconfig.json'); - const readConfig = ts.readConfigFile(configPath, ts.sys.readFile); - if (readConfig.error) { - throw new Error(ts.flattenDiagnosticMessageText(readConfig.error.messageText, '\n')); - } - const parsed = ts.parseJsonConfigFileContent(readConfig.config, ts.sys, packageRoot); - - const dir = mkdtempSync(join(packageRoot, 'node_modules', '.partial-schema-pin-')); - const program = ts.createProgram([join(packageRoot, 'src', 'index.ts')], { - ...parsed.options, - outDir: dir, - declaration: true, - emitDeclarationOnly: true, - declarationMap: false, - noEmit: false, - // The real build is `composite`/incremental; neither is meaningful for a - // one-shot emit into a scratch dir, and both would write build info next to - // the package's real artifacts. - composite: false, - incremental: false, - tsBuildInfoFile: undefined, - }); - const emitted = program.emit(); - const index = join(dir, 'index.d.ts'); - if (!existsSync(index)) { - const diagnostics = [...emitted.diagnostics, ...program.getSemanticDiagnostics()] - .map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n')) - .slice(0, 10); - throw new Error(`declaration emit produced no index.d.ts:\n${diagnostics.join('\n')}`); - } - return { dir, index }; -} - -const { dir: scratchDir, index: emittedIndex } = emitDeclarations(); -afterAll(() => rmSync(scratchDir, { recursive: true, force: true })); - -/** - * `PartialSchema` is a GENERIC alias: there is nothing to measure until it is - * instantiated, and this repo instantiates it nowhere (see the census above). - * So the probe below is where the instantiations are made — a source file - * written NEXT TO the emitted `index.d.ts`, resolving `./index` to it, so every - * type read here is read out of the artifact a consumer would resolve. - * - * `IndexFreeSchema` is the CONTROL and only the control: a `T` whose `keyof` - * resolves to its literal member names because it declares no index signature. - * It is the shape `PartialSchema`'s author was writing for. - */ -const PROBE_SOURCE = ` -import type { - PartialSchema, - ObjectGridSchema, - ObjectFormSchema, - ObjectViewSchema, - ButtonSchema, - BaseSchema, -} from './index'; - -export type SourceGrid = ObjectGridSchema; -export type SourceForm = ObjectFormSchema; -export type SourceView = ObjectViewSchema; -export type SourceButton = ButtonSchema; -export type SourceBase = BaseSchema; - -export type PartialGrid = PartialSchema; -export type PartialForm = PartialSchema; -export type PartialView = PartialSchema; -export type PartialButton = PartialSchema; - -/** The control: no index signature, so \`keyof\` resolves to literal names. */ -export type IndexFreeSchema = { - type: 'index_free_control'; - alpha?: number; - beta?: string; - gamma?: boolean; -}; -export type PartialIndexFree = PartialSchema; -`; - -const probeFile = join(scratchDir, '__partial-schema-probe.ts'); -writeFileSync(probeFile, PROBE_SOURCE, 'utf8'); - -const program = ts.createProgram([probeFile], { - noEmit: true, - skipLibCheck: true, - strict: true, - target: ts.ScriptTarget.ESNext, - module: ts.ModuleKind.ESNext, - moduleResolution: ts.ModuleResolutionKind.Bundler, -}); -const checker = program.getTypeChecker(); - -function probeExports(): Map { - const sourceFile = program.getSourceFile(probeFile); - const moduleSymbol = sourceFile && checker.getSymbolAtLocation(sourceFile); - if (!moduleSymbol) throw new Error(`no module symbol for ${probeFile}`); - return new Map(checker.getExportsOfModule(moduleSymbol).map((s) => [s.getName(), s])); -} - -function probeType(name: string): ts.Type { - const symbol = probeExports().get(name); - if (!symbol) throw new Error(`${name} is not exported from the probe`); - return checker.getDeclaredTypeOfSymbol(symbol); -} - -/** The property names a type DECLARES — not the keys its index signature answers. */ -const declaredMembers = (name: string): string[] => - checker.getPropertiesOfType(probeType(name)).map((p) => p.getName()).sort(); - -const declaresStringIndex = (name: string): boolean => - checker.getIndexInfoOfType(probeType(name), ts.IndexKind.String) !== undefined; - -/* ── 0. Non-vacuity — the probe really resolved the emitted declaration ───── */ - -/** - * ⚠️ Load-bearing. Section 2 asserts a ONE-element member set; if the probe had - * failed to resolve `./index`, or the emit had collapsed, every type here would - * read as an empty or `any` shape and section 2 would pass over nothing. These - * are what makes that impossible. - */ -describe('the probe resolved the emitted index.d.ts (guards everything below)', () => { - it('reports no semantic errors — every import in the probe resolved', () => { - const errors = program - .getSemanticDiagnostics(program.getSourceFile(probeFile)) - .map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n')); - expect(errors).toEqual([]); - }); - - it('read the FRESHLY EMITTED index.d.ts, not `dist/` or a node_modules copy', () => { - // `./index` from the probe must resolve to the scratch emit above. If it - // ever resolved elsewhere, every number below would describe some other - // artifact — possibly a stale one — while still looking like a measurement. - expect(program.getSourceFiles().map((f) => f.fileName)).toContain(emittedIndex); - }); - - it.each([ - { name: 'SourceGrid', min: 20, sample: ['columns', 'pageSize', 'rowActions'] }, - { name: 'SourceForm', min: 20, sample: ['fields', 'sections', 'submitText'] }, - { name: 'SourceView', min: 20, sample: ['objectName', 'table', 'form'] }, - { name: 'SourceButton', min: 20, sample: ['buttonType', 'className', 'type'] }, - ])('$name declares its full member set before any Omit touches it', ({ name, min, sample }) => { - // Counts at the time of the pin: 61 / 67 / 42 / 27. Asserted as a floor plus - // named members rather than an exact number, so adding a member to a schema - // does not have to be re-declared here — the collapse in section 2 is what - // this file owns, not the census of these schemas (that is #6269's job for - // grid and form). - expect(declaredMembers(name).length).toBeGreaterThan(min); - expect(declaredMembers(name)).toEqual(expect.arrayContaining(sample)); - }); -}); - -/* ── 1. The control — the alias body is fine; the COLLAPSE is about `keyof T` ─ */ - -/** - * ⭐ This is the ghost-assertion guard. "Declares exactly `['type']`" would be a - * decoration if the instrument answered `['type']` for everything, or if - * `PartialSchema` were incapable of declaring more than one property in any - * circumstance. It is not: the SAME alias, measured by the SAME checker, - * declares all four members of a `T` whose `keyof` resolves to literal names. - * - * Read the two together and the mechanism is unambiguous: - * - * PartialSchema -> 4 declared, no index signature (intended) - * PartialSchema -> 1 declared, index signature live (actual) - * - * That is also exactly what `PartialSchema` becomes once - * objectui#5155 removes `BaseSchema`'s index signature. - */ -describe('the intended reading — `PartialSchema` over an index-signature-free T', () => { - it('declares `type` PLUS every other member, each optional', () => { - expect(declaredMembers('PartialIndexFree')).toEqual(['alpha', 'beta', 'gamma', 'type']); - }); - - it('declares no string index signature — excess-property checks still fire', () => { - expect(declaresStringIndex('PartialIndexFree')).toBe(false); - }); - - it('keeps everything but `type` optional (the `Partial` wrapper did its job)', () => { - const optional = checker - .getPropertiesOfType(probeType('PartialIndexFree')) - .filter((p) => (p.getFlags() & ts.SymbolFlags.Optional) !== 0) - .map((p) => p.getName()) - .sort(); - expect(optional).toEqual(['alpha', 'beta', 'gamma']); - }); - - it('matches the control type it was derived from, member for member', () => { - // Same population — so section 2's `['type']` cannot be blamed on the probe. - expect(declaredMembers('PartialIndexFree')).toEqual(declaredMembers('IndexFreeSchema')); - }); -}); - -/* ── 2. The measurement — every real instantiation collapses to `type` ────── */ - -describe.each([ - { alias: 'PartialGrid', source: 'SourceGrid' }, - { alias: 'PartialForm', source: 'SourceForm' }, - { alias: 'PartialView', source: 'SourceView' }, - { alias: 'PartialButton', source: 'SourceButton' }, -])('$alias collapses to one declared property (objectui#6397)', ({ alias, source }) => { - it('declares EXACTLY `["type"]`, against a source that declares 20+ members', () => { - // ⛔ If this goes red with a LARGER set, objectui#5155 has landed and the - // alias now works as written — see this file's removal condition. Do not - // widen the assertion; delete the file. - expect(declaredMembers(alias)).toEqual(['type']); - expect(declaredMembers(source).length).toBeGreaterThan(20); - }); - - it('drops every named member the source declared apart from `type`', () => { - const lost = declaredMembers(source).filter((m) => m !== 'type'); - expect(lost.length).toBeGreaterThan(20); - for (const member of lost) expect(declaredMembers(alias)).not.toContain(member); - }); - - it('carries a live string index signature — it accepts ANY key, at `any`', () => { - // This is the second half of the harm and the reason the alias is not - // protection: nothing errors, because every absent key is answered `any`. - expect(declaresStringIndex(alias)).toBe(true); - }); - - it('still discriminates on `type` — the intersection’s explicit half survives', () => { - const typeProp = checker.getPropertyOfType(probeType(alias), 'type'); - expect(typeProp).toBeDefined(); - // A literal type here (not `string`) is why the count is 1 rather than 0. - expect(checker.typeToString(checker.getTypeOfSymbol(typeProp!))).not.toBe('any'); - }); -}); - -/* ── 3. The objectui#5155 sequencing tripwire ────────────────────────────── */ - -describe('the collapse is caused by BaseSchema’s root index signature (objectui#5155)', () => { - it('BaseSchema still declares `[key: string]: any`', () => { - // When this flips to `false`, objectui#5155 has removed the root index - // signature: `keyof T` resolves to literal member names again, `Omit` stops - // absorbing them, `PartialSchema` starts working as written with no edit, - // and section 2 above inverts. That is the sequencing this card pinned — - // the response is to DELETE this file, not to relax it, and the retirement - // question of objectui#6397 becomes a fresh enforce-or-remove card for the - // maintainer (removal of a published export sits on the human floor). - expect(declaresStringIndex('SourceBase')).toBe(true); - }); - - it.each(['SourceGrid', 'SourceForm', 'SourceView', 'SourceButton'])( - '%s inherits that index signature, which is what absorbs its member names', - (name) => { - expect(declaresStringIndex(name)).toBe(true); - }, - ); -}); diff --git a/packages/types/src/__tests__/pivot-drilldown-retired-10932.test.ts b/packages/types/src/__tests__/pivot-drilldown-retired-10932.test.ts index a611e10976..9fa9b568b8 100644 --- a/packages/types/src/__tests__/pivot-drilldown-retired-10932.test.ts +++ b/packages/types/src/__tests__/pivot-drilldown-retired-10932.test.ts @@ -52,8 +52,8 @@ type Equal = type Expect = T; /** - * `?: never` reads as `undefined`. A deletion would read as `any` (the - * `BaseSchema` index signature), and the old member as `DrillDownConfig | undefined`. + * `?: never` reads as `undefined`. A deletion read as `any` (the `BaseSchema` + * index signature) until objectui#8347 and fails to compile now, and the old member as `DrillDownConfig | undefined`. */ export type assertionPivotDrillDownIsATombstone = Expect>; /** Non-vacuity twin: a member the node does read keeps its real type. */ diff --git a/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts b/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts index c918aceb9f..5ad98bc0dc 100644 --- a/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts +++ b/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts @@ -71,7 +71,7 @@ type Expect = T; type ShapeOf = M extends { shape: infer S } ? S : never; /** What a shape entry ACCEPTS (input side, so `.optional()` shows). */ type InputOf = T extends z.ZodType ? z.input : never; -/** The declaration's DECLARED keys — the `BaseSchema` index signature dropped. */ +/** The declaration's DECLARED keys — any index signature dropped (`BaseSchema` carried one until objectui#8347). */ type DeclaredKeys = Extract< keyof { [K in keyof D as string extends K ? never : number extends K ? never : K]: D[K] }, string diff --git a/packages/types/src/__tests__/schema-reference-named-list-view-keys-7923.test.ts b/packages/types/src/__tests__/schema-reference-named-list-view-keys-7923.test.ts index 771159aee2..744aa531cb 100644 --- a/packages/types/src/__tests__/schema-reference-named-list-view-keys-7923.test.ts +++ b/packages/types/src/__tests__/schema-reference-named-list-view-keys-7923.test.ts @@ -23,12 +23,13 @@ * * ## Why a fixture pin and not a type annotation * - * Annotating the snippet against `ObjectViewSchema` would catch nothing. - * `BaseSchema` closes with `[key: string]: any` (`../base.ts`), so every - * invented key type-checks through the index signature — measured directly in - * objectui#7927 (probe P5: renaming `titleField` to `titleFieldd` inside an - * annotated block left `check:doc-snippets` at exit 0). The annotation checks a - * key's TYPE and never its NAME. `NamedListView` is a standalone interface that + * Annotating the snippet against `ObjectViewSchema` would have caught nothing + * when this was written: `BaseSchema` closed with `[key: string]: any` + * (`../base.ts`), so every invented key type-checked through the index signature + * — measured directly in objectui#7927 (probe P5: renaming `titleField` to + * `titleFieldd` inside an annotated block left `check:doc-snippets` at exit 0). + * The annotation checked a key's TYPE and never its NAME (objectui#8347 has + * since removed the signature). `NamedListView` is a standalone interface that * does NOT extend `BaseSchema`, so its declared members are a genuinely closed * list — which is what makes a name-level comparison possible here at all. * @@ -189,7 +190,7 @@ describe(`objectui#7923 — ${DOC}'s object-view example against \`${INTERFACE}\ `${DOC}'s object-view example authors \`listViews\` key(s) that \`${INTERFACE}\` ` + '(packages/types/src/objectql.ts) does not declare. An undeclared key here is not ' + 'caught by anything else: the fence is JSON so no snippet gate compiles it, and ' + - '`BaseSchema`\'s index signature would swallow an annotation anyway (objectui#7927). ' + + 'the typed-literal refusal objectui#7927 ruled (executed by objectui#8347) never sees a JSON fence. ' + 'Fix the doc — or declare the key AND give it a read site.', ).toEqual([]); }); diff --git a/packages/types/src/__tests__/sidebar-declared-surface-11465.test.ts b/packages/types/src/__tests__/sidebar-declared-surface-11465.test.ts index 98aaeb13ea..240e05adae 100644 --- a/packages/types/src/__tests__/sidebar-declared-surface-11465.test.ts +++ b/packages/types/src/__tests__/sidebar-declared-surface-11465.test.ts @@ -22,9 +22,10 @@ * * Each retired key is a `?: never` tombstone on the TypeScript face and a * `retirementTombstone` on the zod face, refused by name with the reason and - * what to write instead. ⛔ Not a deletion: `BaseSchema` carries an index - * signature on the TypeScript face and `.passthrough()` on the zod face, so a - * deleted key would type as `any` and parse green unexamined. + * what to write instead. ⛔ Not a deletion: `BaseSchema` is `.passthrough()` on + * the zod face (and carried an index signature on the TypeScript face until + * objectui#8347), so a deleted key would parse green unexamined and, through a + * widened value, still type-check. * * ## Two instruments, and which half each one reads * diff --git a/packages/types/src/__tests__/stack-schema-emitted-members.test.ts b/packages/types/src/__tests__/stack-schema-emitted-members.test.ts index 8a7a623356..c4dc875239 100644 --- a/packages/types/src/__tests__/stack-schema-emitted-members.test.ts +++ b/packages/types/src/__tests__/stack-schema-emitted-members.test.ts @@ -11,8 +11,8 @@ * * `Omit` is `Pick>`, and `keyof T` on a type * carrying a string index signature is `string | number` — the literal member - * names are ABSORBED. `FlexSchema` inherits `BaseSchema`'s `[key: string]: any` - * (objectui#5155), so `Exclude` is still + * names are ABSORBED. `FlexSchema` inherited `BaseSchema`'s `[key: string]: any` + * (objectui#5155) until objectui#8347, so `Exclude` is still * `string | number`, and the `Pick` reconstructed a type with the index * signature and none of the named members. Measured on the emitted * `dist/layout.d.ts` before the fix: @@ -21,7 +21,7 @@ * StackSchema -> 1 declared property: type * * `gap`, `children`, `align`, `justify`, `direction`, `wrap` and all 19 other - * `BaseSchema` members were absent. Nothing errored — the index signature keeps + * `BaseSchema` members were absent. Nothing errored — the index signature kept * every absent key assignable and readable as `any` — so the only symptom was * in the tools that READ the declaration: editor completion on a `stack` node * offered `type` and nothing else, and objectui#6143's docs-vs-type sweep read @@ -31,8 +31,8 @@ * ## Why this reads the EMITTED declaration and not the source * * ⚠️ This is the load-bearing part of the guard. A source-level type assertion - * (`Expect>`) passes on the broken - * code: the index signature answers for `gap` with `any`, and `any` satisfies + * (`Expect>`) passed on the broken + * code: the index signature answered for `gap` with `any`, and `any` satisfies * everything. The gap between "what the source says" and "what the `.d.ts` * declares" IS this bug, so a guard that never opens the `.d.ts` cannot see it. * @@ -202,7 +202,7 @@ describe('StackSchema ships the members it declares (objectui#6151)', () => { }); it.each(['gap', 'children', 'align', 'justify', 'direction', 'wrap', 'className'])( - 'declares `%s` in the emitted declaration, not merely via the index signature', + 'declares `%s` as a named member of the emitted declaration, not a key a mapped type dropped', (member) => { // `getPropertyOfType` returned undefined for every one of these before the // fix, while `StackSchema['gap']` in SOURCE resolved to `any` and hid it. diff --git a/packages/types/src/__tests__/text-value-retired-6951.test.ts b/packages/types/src/__tests__/text-value-retired-6951.test.ts index a372bc2656..2792db5509 100644 --- a/packages/types/src/__tests__/text-value-retired-6951.test.ts +++ b/packages/types/src/__tests__/text-value-retired-6951.test.ts @@ -28,8 +28,9 @@ * * ## Why a tombstone and not a deletion * - * `BaseSchema` is `.passthrough()` on the Zod side and carries a - * `[key: string]: any` index signature on the TS side, and the renderer no + * `BaseSchema` is `.passthrough()` on the Zod side and carried a + * `[key: string]: any` index signature on the TS side until objectui#8347 (a + * widened value still skips the excess-property check), and the renderer no * longer reads `value` at all. Deleting the member would therefore hand an * authored `value` the WORST outcome available: accepted, unvalidated, and * rendered as a blank. `?: never` / `retirementTombstone()` is this package's @@ -309,9 +310,10 @@ describe('TextSchema.value is RETIRED — the TS half of the tombstone (objectui }); it('refuses the retired key in the form authors actually write', () => { - // The leg that proves the tombstone survives `BaseSchema`'s - // `[key: string]: any`: if the index signature won, `value` would widen - // back to `any` here and the directive would go unused (TS2578). + // The leg that proved the tombstone survives `BaseSchema`'s + // `[key: string]: any` while it stood (objectui#8347 removed it): if the + // index signature had won, `value` would have widened back to `any` + // here and the directive would have gone unused (TS2578). const retiredDocument: TextSchemaTS = { type: 'text', // @ts-expect-error — `value` is RETIRED (objectui#6951); write `content`. diff --git a/packages/types/src/__tests__/timeline-dead-keys-retired-6170.test.ts b/packages/types/src/__tests__/timeline-dead-keys-retired-6170.test.ts index 6f3ca11dc3..da76e82b3e 100644 --- a/packages/types/src/__tests__/timeline-dead-keys-retired-6170.test.ts +++ b/packages/types/src/__tests__/timeline-dead-keys-retired-6170.test.ts @@ -27,10 +27,11 @@ * * ## Why tombstones, and not simply deleting the members * - * `BaseSchema` is `.passthrough()` on the Zod side and carries a - * `[key: string]: any` index signature on the TS side (objectui#5155 / - * objectui#6269 own that ceiling). An UNDECLARED key is therefore accepted, - * unvalidated, by both halves — deleting the members would hand the retired + * `BaseSchema` is `.passthrough()` on the Zod side and carried a + * `[key: string]: any` index signature on the TS side until objectui#8347 + * (objectui#5155 / objectui#6269 owned that ceiling). An UNDECLARED key is + * therefore accepted, unvalidated, by the Zod half (and was by the TS half; + * since objectui#8347 only through a widened value) — deleting the members would hand the retired * keys exactly the silent no-op this pin exists to prevent. `?: never` / * `retirementTombstone()` on both halves, in lockstep, is this package's * convention (`timeline-timescale-retired.test.ts` pins the same shape for @@ -229,11 +230,12 @@ describe('events / orientation / position are RETIRED — the TS half of the tom expect([events, orientation, position, items, variant]).toHaveLength(5); }); - it('refuses them in the form authors actually write — survives the index signature', () => { + it('refuses them in the form authors actually write — by name, as `never` tombstones on a fresh literal', () => { // The member reads above could pass through a mapped or indexed type; this // leg writes a DOCUMENT, the shape an author (or an AI generating metadata) - // produces. If `BaseSchema`'s `[key: string]: any` won, the key would widen - // back to `any` here and the directive would go unused (TS2578). + // produces. If `BaseSchema`'s `[key: string]: any` had won (it stood until + // objectui#8347), the key would have widened back to `any` here and the + // directive would have gone unused (TS2578). // The directive sits on the PROPERTY: excess-property checking reports the // error at the member, so a directive on the `const` would suppress nothing. const retiredDocument: TimelineSchemaTS = { diff --git a/packages/types/src/__tests__/timeline-declared-keys.test.ts b/packages/types/src/__tests__/timeline-declared-keys.test.ts index 6007ee0a09..a0e815884d 100644 --- a/packages/types/src/__tests__/timeline-declared-keys.test.ts +++ b/packages/types/src/__tests__/timeline-declared-keys.test.ts @@ -22,7 +22,7 @@ * registration's own `inputs`), nor what the renderer reads; those three agreed * with each other all along. * - * The divergence was invisible to `tsc` because `BaseSchema` carries + * The divergence was invisible to `tsc` because `BaseSchema` carried * `[key: string]: any`, so every undeclared key resolved as `any` and the * annotation constrained nothing. Its most visible casualty was the docs page's * own TypeScript example, which did not compile — `events` was required and @@ -36,21 +36,25 @@ * ## What the pin has teeth against, and what it does not * * Same ceiling as objectui#5903's gantt pin, and stated here rather than left - * to be assumed. `BaseSchema` is `.passthrough()` on the zod side and carries - * an index signature on the TS side (objectui#5155 / objectui#6269 own that - * ceiling; this card does not touch it), so: + * to be assumed. `BaseSchema` is `.passthrough()` on the zod side and carried + * an index signature on the TS side until objectui#8347 (objectui#5155 / + * objectui#6269 owned that ceiling; this card did not touch it), so: * - * - an UNDECLARED key is still accepted by both halves. Declaring these eight - * did NOT buy rejection of a misspelling; + * - an UNDECLARED key is still accepted by the zod half (and was by the TS + * half while the signature stood). Declaring these eight did NOT buy + * rejection of a misspelling there; * - a DECLARED key IS validated. `variant: 'diagonal'` type-checked and * parsed green before this card and is refused now — that is the accept-set * narrowing landed here; - * - on the TS side a read site can never be the detector, because the index - * signature types `schema.variant` as `any` either way. So the compile-time - * pin is the `@ts-expect-error` block at the bottom: remove a declaration - * and its member resolves to `any`, the wrong-typed assignment starts - * succeeding, and the now-unused directive fails the build (TS2578) NAMING - * the key. `tsconfig.test.json` compiles this file, so that is real + * - on the TS side a read site could never be the detector while the index + * signature typed `schema.variant` as `any` either way. So the compile-time + * pin is the `@ts-expect-error` block at the bottom: when written, removing a + * declaration made its member resolve to `any`, the wrong-typed assignment + * started succeeding, and the now-unused directive failed the build + * (TS2578) NAMING the key. Since objectui#8347 a removal makes the indexed + * access itself an error, which the directive swallows, so a deletion is + * caught by the well-typed counter-probe instead (its literal names every + * key). `tsconfig.test.json` compiles this file, so that is real * enforcement and not decoration (objectui#3009). * * ## The three keys that were declared and never read — now RETIRED @@ -247,10 +251,13 @@ describe('TimelineSchema (TS) — compile-time pin on the same keys', () => { }); it('refuses a wrong-typed value on every declared key', () => { - // Each directive below fails the build (TS2578, "unused '@ts-expect-error'") - // the moment its key stops being declared, because the member then resolves - // to `any` through `BaseSchema`'s index signature and the assignment starts - // succeeding. That failure is the signal this card exists to create. + // Each directive below failed the build (TS2578, "unused '@ts-expect-error'") + // the moment its key stopped being declared, while the member resolved to + // `any` through `BaseSchema`'s index signature. Since objectui#8347 a removal + // makes the indexed access itself an error, which its directive swallows, so + // the deletion signal comes from the well-typed counter-probe below; each + // directive still fails the build if its member is widened to accept the + // value. // @ts-expect-error — `variant` is declared `'vertical' | 'horizontal' | 'gantt' | undefined`. const variant: TimelineSchemaTS['variant'] = 'diagonal'; diff --git a/packages/types/src/__tests__/timeline-timescale-retired.test.ts b/packages/types/src/__tests__/timeline-timescale-retired.test.ts index 8d0bd51127..44ea926395 100644 --- a/packages/types/src/__tests__/timeline-timescale-retired.test.ts +++ b/packages/types/src/__tests__/timeline-timescale-retired.test.ts @@ -31,12 +31,14 @@ * * ## Why the tombstone, and not simply deleting the key * - * `BaseSchema` is `.passthrough()` on the Zod side and carries a - * `[key: string]: any` index signature on the TS side (objectui#5155 / - * objectui#6269 own that ceiling). An UNDECLARED key is therefore accepted by - * both halves, unvalidated. Deleting `timeScale` outright would hand the old - * spelling exactly the silent no-op this card exists to prevent: it would parse - * green, type-check green, and do nothing. + * `BaseSchema` is `.passthrough()` on the Zod side and carried a + * `[key: string]: any` index signature on the TS side until objectui#8347 + * (objectui#5155 / objectui#6269 owned that ceiling). An UNDECLARED key is + * therefore accepted by the Zod half, unvalidated (and was by the TS half; since + * objectui#8347 only through a widened value). Deleting `timeScale` outright + * would hand the old spelling exactly the silent no-op this card exists to + * prevent: it would parse green, type-check green through a widened value, and + * do nothing. * * Keeping the key declared as `?: never` / `z.never().optional()` is what makes * the retirement audible. That is this package's tombstone convention — @@ -151,9 +153,10 @@ describe('timeScale is RETIRED — the TS half of the tombstone (objectui#6355)' it('refuses the retired spelling in the form authors actually write', () => { // The assertion above reads the MEMBER's type. This one writes a DOCUMENT, // which is the shape an author (or an AI generating metadata) produces, and - // it is the leg that proves the tombstone survives `BaseSchema`'s - // `[key: string]: any`: if the index signature won, `timeScale` would widen - // back to `any` here and the directive would go unused (TS2578). + // it is the leg that proved the tombstone survives `BaseSchema`'s + // `[key: string]: any` while it stood (objectui#8347 removed it): if the + // index signature had won, `timeScale` would have widened back to `any` + // here and the directive would have gone unused (TS2578). const retiredDocument: TimelineSchemaTS = { type: 'timeline', diff --git a/packages/types/src/__tests__/toast-button-keys.test.ts b/packages/types/src/__tests__/toast-button-keys.test.ts index 387e86d883..cf3dc835a3 100644 --- a/packages/types/src/__tests__/toast-button-keys.test.ts +++ b/packages/types/src/__tests__/toast-button-keys.test.ts @@ -202,12 +202,14 @@ describe('ToastSchema (TS) — compile-time pin on the same keys', () => { }); it('refuses a wrong-typed value on every declared key', () => { - // Each directive below fails the build (TS2578, "unused '@ts-expect-error'") - // the moment its key stops being declared, because the member then resolves - // to `any` through `BaseSchema`'s index signature and the assignment starts - // succeeding. That failure is the signal this card exists to create, and it - // is real enforcement rather than decoration because `tsconfig.test.json` - // compiles this file (objectui#3009). + // Each directive below failed the build (TS2578, "unused '@ts-expect-error'") + // the moment its key stopped being declared, while the member resolved to + // `any` through `BaseSchema`'s index signature. Since objectui#8347 a removal + // makes the indexed access itself an error, which its directive swallows, so + // the deletion signal comes from the well-typed counter-probe below; each + // directive still fails the build if its member is widened, and it is real + // enforcement rather than decoration because `tsconfig.test.json` compiles + // this file (objectui#3009). // @ts-expect-error — `buttonLabel` is declared `string | undefined`. const buttonLabel: ToastSchemaTS['buttonLabel'] = 42; @@ -311,9 +313,11 @@ describe('ToastSchema — `action` is retired, not deleted (objectui#8338)', () describe('ToastSchema (TS) — `action` is a `?: never` tombstone (objectui#8338)', () => { it('refuses the object the declaration used to carry', () => { // Compiled by `tsconfig.test.json` (objectui#3009), so this directive is - // real enforcement. It fails the build with TS2578 the moment the member is - // DELETED rather than tombstoned: the key then resolves to `any` through - // `BaseSchema`'s index signature and the assignment starts succeeding. + // real enforcement. It failed the build with TS2578 the moment the member was + // DELETED rather than tombstoned, while the key resolved to `any` through + // `BaseSchema`'s index signature. Since objectui#8347 a deletion makes the + // indexed access itself an error, which this directive swallows, so the + // `Equal` row below catches it instead. // @ts-expect-error — `action` is retired; the type is `never`. const action: ToastSchemaTS['action'] = { label: 'Undo', onClick: () => undefined }; // @ts-expect-error — and the node shape the mirror used to admit is not it either. @@ -324,8 +328,8 @@ describe('ToastSchema (TS) — `action` is a `?: never` tombstone (objectui#8338 it('reads as exactly `undefined` off the interface', () => { // The type-level half runs at `tsc`; this body only keeps the assertion // reachable from a test name. `Equal`, ⛔ not `extends`: a DELETED member - // reads `any` through the index signature and a one-way check would accept - // it — the same trap `handler-keys-json-refusal-6124.test.ts` names. + // read `any` through the index signature until objectui#8347 and a one-way + // check would accept it — the same trap `handler-keys-json-refusal-6124.test.ts` names. const absent: ToastSchemaTS = { type: 'toast' }; expect('action' in absent).toBe(false); }); diff --git a/packages/types/src/__tests__/tree-view-data-optional-6939.test.ts b/packages/types/src/__tests__/tree-view-data-optional-6939.test.ts index 1fbd0941c3..c28a527c53 100644 --- a/packages/types/src/__tests__/tree-view-data-optional-6939.test.ts +++ b/packages/types/src/__tests__/tree-view-data-optional-6939.test.ts @@ -82,9 +82,9 @@ type Expect< T extends true > = T; * * ⚠️ The second limb is NOT the index signature, and the difference matters * because it is the whole reason this card keeps the member instead of deleting - * it. `BaseSchema` carries BOTH `data?: any` (`base.ts:183`) and - * `[key: string]: any` (`base.ts:409`), and a declared member — inherited or - * not — wins over an index signature. Measured: strip the index signature with + * it. `BaseSchema` carries `data?: any` (and carried `[key: string]: any` too, + * until objectui#8347), and a declared member — inherited or not — wins over + * an index signature. Measured: strip the index signature with * the homomorphic `keyof`-remap that `zod-mirror-parity.test.ts` uses, and * `['data']` is STILL `any`, while a key reachable only through the index * signature stops resolving at all. So deletion does not fall through to an diff --git a/packages/types/src/__tests__/tree-view-data-retired-6951.test.ts b/packages/types/src/__tests__/tree-view-data-retired-6951.test.ts index 749eacee16..27673c7a24 100644 --- a/packages/types/src/__tests__/tree-view-data-retired-6951.test.ts +++ b/packages/types/src/__tests__/tree-view-data-retired-6951.test.ts @@ -313,10 +313,10 @@ describe('TreeViewSchema.data is RETIRED — the TS half of the tombstone (objec }); it('refuses the retired key in the form authors actually write — and shadows the inherited `BaseSchema.data?: any`', () => { - // The leg that proves the tombstone beats BOTH escape hatches on the TS - // face: `BaseSchema` carries `data?: any` AND `[key: string]: any`. A - // declared `never` member wins over the inherited `any` and over the index - // signature; if either won, `data` would widen back and the directive + // The leg that proves the tombstone beats the escape hatches on the TS + // face: `BaseSchema` carries `data?: any` (and carried `[key: string]: any` + // until objectui#8347). A declared `never` member wins over the inherited + // `any` and over an index signature; if either won, `data` would widen back and the directive // would go unused (TS2578). const retiredDocument: TreeViewSchemaTS = { type: 'tree-view', diff --git a/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts index 5f31bf2698..af0d81fb26 100644 --- a/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts +++ b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts @@ -25,9 +25,10 @@ * * ## Why the positive pins are TYPE equalities, not assignments * - * `BaseSchema` carries `[key: string]: any`, so an object literal carrying ANY - * key compiles against these interfaces whether or not the key is declared — - * an "it compiles" pin would be green on the tree before this change. So: + * `BaseSchema` carried `[key: string]: any` when this was written, so an object + * literal carrying ANY key compiled against these interfaces whether or not the + * key was declared — an "it compiles" pin would have been green on the tree + * before this change. So: * - declared-ness is read through `DeclaredKeys`, which filters the index * signature out (`string extends K`); * - member types are compared with `Equal`, against the spec's own member; @@ -84,9 +85,10 @@ describe('admitted spec keys land on the twin with the SPEC\'S type, not `any`', const pageSource: Equal = true; const pageRequires: Equal = true; const pageInterfaceConfig: Equal = true; - // Control: an undeclared key reads as `any` through the index signature, and - // `Equal` tells `any` apart from a real member type. - const anyControl: Equal = false; + // Control: `Equal` tells `any` apart from a real member type. (An undeclared + // key read `any` through the index signature until objectui#8347 removed it; + // the control reads `any` directly now.) + const anyControl: Equal = false; expect([lock, appProtection, defaultAgent, dashProtection, dashPackageId, pageSource, pageRequires, pageInterfaceConfig, anyControl]) .toEqual([true, true, true, true, true, true, true, true, false]); }); @@ -130,8 +132,10 @@ describe('spec tombstones surface on the twin as a refusal — the verdict the m // The control for the line above, through the same helper: an admitted // spec key on the same twin is NOT a retired-key type. const sourceIsLive: Equal, false> = true; - // The `@ts-expect-error` below only sticks because the key is DECLARED; - // undeclared, the index signature would absorb it as `any`. + // The `@ts-expect-error` below stuck only because the key is DECLARED while + // the index signature would have absorbed an undeclared one as `any`; since + // objectui#8347 an undeclared key is refused too, so `declared` here is what + // tells the two apart. const declared: 'assignedProfiles' extends DeclaredKeys ? true : false = true; expect([isSpecMember, admitsNoValue, sourceIsLive, declared]).toEqual([true, true, true, true]); diff --git a/packages/types/src/__tests__/undeclared-but-consumed-keys-6150.test.ts b/packages/types/src/__tests__/undeclared-but-consumed-keys-6150.test.ts index 290858df76..3e6edd281c 100644 --- a/packages/types/src/__tests__/undeclared-but-consumed-keys-6150.test.ts +++ b/packages/types/src/__tests__/undeclared-but-consumed-keys-6150.test.ts @@ -9,9 +9,9 @@ * keys that are not declared members of their shipped type; **13** were shown * to be genuinely READ by the renderer. Those are capabilities that work at * runtime, that the docs describe, and that the published contract never - * mentioned — the reads compile only because `BaseSchema` ends with + * mentioned — the reads compiled only because `BaseSchema` ended with * `[key: string]: any` (objectui#5155), so `schema.trigger` on a type that - * never declared `trigger` resolves to `any` instead of erroring. + * never declared `trigger` resolved to `any` instead of erroring. * * Re-derived on this branch before anything was edited, through the SAME * resolution path the census used (built `packages/types/dist/index.d.ts`, and @@ -131,9 +131,10 @@ type Expect< T extends true > = T; /** * The declared TYPE of each of the 13, pinned invariantly. * - * `[key: string]: any` would make every one of these `any` if the member were - * NOT declared, and `Equal< any, string >` is false — so these fail the moment a - * declaration is removed and the key falls back to the index signature. That is + * `[key: string]: any` made every one of these `any` if the member was NOT + * declared (until objectui#8347; an undeclared member's indexed access does not + * compile now), and `Equal< any, string >` is false — so these fail the moment + * a declaration is removed, either way. That is * what makes them a guard and not a restatement. */ export type _ContentIsString = Expect< Equal< NonNullable< TsTextSchema['content'] >, string > >; @@ -151,8 +152,9 @@ export type _AlignIsOverlayAlignment = Expect< Equal< NonNullable< TsHoverCardSc // `null | undefined` (`base.ts`), so `NonNullable` would strip those limbs out of // the union and compare a different type. Read raw, the optional `?` adds // `undefined` to a union that already carries it, so the two sides are equal — -// and the guard still bites, because an undeclared `trigger` would resolve to -// `any` through `BaseSchema`'s index signature and `Equal< any, … >` is false. +// and the guard still bites, because an undeclared `trigger` would not compile +// at the indexed access (until objectui#8347 it resolved to `any` through +// `BaseSchema`'s index signature, and `Equal< any, … >` is false). export type _TriggerIsNodeOrNodes = Expect< Equal< TsContextMenuSchema['trigger'], SchemaNode | SchemaNode[] > >; /** diff --git a/packages/types/src/__tests__/unmirrored-declared-round5-6152.test.ts b/packages/types/src/__tests__/unmirrored-declared-round5-6152.test.ts index 2d073a512f..86a8f49832 100644 --- a/packages/types/src/__tests__/unmirrored-declared-round5-6152.test.ts +++ b/packages/types/src/__tests__/unmirrored-declared-round5-6152.test.ts @@ -126,9 +126,9 @@ describe('objectui#6152 round 5 — the `chatbot-floating` face keeps its own `f /* * `editable` / `singleClickEdit` were filed by NAME as runtime slots (the seat's answer - * B): the declarations did NOT move. Read off the member, so a deletion — which would - * read as `any` through `BaseSchema`'s index signature — or a tombstone — which would - * read as `undefined` — is red here. + * B): the declarations did NOT move. Read off the member, so a deletion — which read + * as `any` through `BaseSchema`'s index signature until objectui#8347, and does not + * compile now — or a tombstone — which would read as `undefined` — is red here. */ type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; type Expect = T; diff --git a/packages/types/src/__tests__/visible-when-spec-input-8347.test.ts b/packages/types/src/__tests__/visible-when-spec-input-8347.test.ts new file mode 100644 index 0000000000..f5a5d10be2 --- /dev/null +++ b/packages/types/src/__tests__/visible-when-spec-input-8347.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `BaseSchema.visibleWhen` is the spec's evaluated-slot INPUT, by reference, on + * both faces (objectui#8347, ruled Q6 = B). + * + * `@objectstack/spec` types every evaluated `visibleWhen` it declares, + * `PageComponentSchema`'s included, as `EvaluatedExpressionInputSchema`: a + * predicate string, or a `{ dialect, source }` envelope whose dialect is + * `cel` / `cron` / `template` and whose `source` is not blank. Its parse + * rewrites a string into the envelope, so a spec-parsed component carries the + * envelope here, and `SchemaRenderer` evaluates it. Both faces of this key read + * `string` before, so the envelope a spec parse produces was refused on the zod + * face and was not assignable on the TypeScript face. + * + * ## What is pinned + * + * - TYPE level (judged by `tsc -p tsconfig.test.json`): the TypeScript face + * IS the spec's `EvaluatedExpressionInput`, with nothing restated, and both + * the string and the envelope are authorable on a node literal. + * - RUNTIME: the zod twin returns the spec's verdict on every probe value, + * AND it does not take the spec's transform — a string parses to the same + * string. The probe set's controls are the spec schema itself, read on the + * same values in the same run. + * + * ⚠️ The envelope here is NARROWER than `ExpressionWire`, the wire `visible` / + * `hidden` / `disabled` carry (objectui#7530): a dialect-less envelope and an + * unknown dialect are refused on this key, because the spec refuses them. + */ + +import { describe, it, expect } from 'vitest'; +import { + EvaluatedExpressionInputSchema as SpecEvaluatedExpressionInputSchema, + type EvaluatedExpressionInput, +} from '@objectstack/spec/shared'; +import { PageComponentSchema as SpecPageComponentSchema } from '@objectstack/spec/ui'; +import type { BaseSchema } from '../base'; +import { BaseSchema as Mirror } from '../zod/base.zod'; +import { AnyComponentSchema, StrictAnyComponentSchema } from '../zod/index.zod.js'; + +/* ── Type level ───────────────────────────────────────────────────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +// By reference: the member IS the spec's input type. A local restatement that +// drifted from the spec (a dialect added, `source` made optional) turns this red. +export type _VisibleWhenIsTheSpecInput = Expect, EvaluatedExpressionInput>>; + +export const stringPredicateIsAuthorable: BaseSchema = { type: 'test-component', visibleWhen: "record.status == 'open'" }; +export const envelopeIsAuthorable: BaseSchema = { + type: 'test-component', + visibleWhen: { dialect: 'cel', source: "record.status == 'open'" }, +}; +export const dialectlessEnvelopeIsRefused: BaseSchema = { + type: 'test-component', + // @ts-expect-error the spec's envelope requires `dialect`; `ExpressionWire`'s optional one does not reach this key + visibleWhen: { source: "record.status == 'open'" }, +}; + +/* ── Runtime ──────────────────────────────────────────────────────────────── */ + +const ENVELOPE = { dialect: 'cel', source: "record.status == 'open'" }; + +const PROBES: Array<{ label: string; value: unknown }> = [ + { label: 'a predicate string', value: "record.status == 'open'" }, + { label: 'a template string', value: "${data.role === 'admin'}" }, + { label: 'a cel envelope', value: ENVELOPE }, + { label: 'a template envelope', value: { dialect: 'template', source: '${data.ready}' } }, + { label: 'an envelope with meta', value: { dialect: 'cel', source: 'true', meta: { rationale: 'why' } } }, + { label: 'a blank string', value: ' ' }, + { label: 'an empty string', value: '' }, + { label: 'a dialect-less envelope', value: { source: "record.status == 'open'" } }, + { label: 'an unknown dialect', value: { dialect: 'sql', source: 'x' } }, + { label: 'an envelope with a blank source', value: { dialect: 'cel', source: ' ' } }, + { label: 'an ast-only envelope', value: { dialect: 'cel', ast: {} } }, + { label: 'a number', value: 42 }, + { label: 'a boolean', value: true }, +]; + +const mirrorVerdict = (value: unknown) => Mirror.safeParse({ type: 'test-component', visibleWhen: value }); + +describe('BaseSchema.visibleWhen is the spec\'s evaluated-slot input on the zod face (objectui#8347)', () => { + it('the probe set reads both verdicts on the spec schema itself (lit control)', () => { + const verdicts = PROBES.map(({ value }) => SpecEvaluatedExpressionInputSchema.safeParse(value).success); + expect(verdicts).toContain(true); + expect(verdicts).toContain(false); + }); + + it.each(PROBES)('$label: the mirror answers what the spec answers', ({ value }) => { + expect(mirrorVerdict(value).success).toBe(SpecEvaluatedExpressionInputSchema.safeParse(value).success); + }); + + it('…and what `PageComponentSchema.visibleWhen` answers, the key this one mirrors', () => { + for (const { value } of PROBES) { + const spec = SpecPageComponentSchema.safeParse({ type: 'element:text', visibleWhen: value }); + const specIssueAtKey = spec.success ? false : spec.error.issues.some((i) => i.path[0] === 'visibleWhen'); + expect(mirrorVerdict(value).success, JSON.stringify(value)).toBe(!specIssueAtKey); + } + }); + + it('a blank predicate string is refused with the spec\'s own sentence', () => { + const mine = mirrorVerdict(' '); + const spec = SpecEvaluatedExpressionInputSchema.safeParse(' '); + expect(mine.success).toBe(false); + expect(spec.success).toBe(false); + if (mine.success || spec.success) return; + expect(mine.error.issues.map((i) => i.message)).toEqual(spec.error.issues.map((i) => i.message)); + }); + + it('does NOT take the spec\'s transform: a string parses to the same string', () => { + const predicate = "record.status == 'open'"; + // The spec rewrites it — the control that the transform exists. + expect(SpecEvaluatedExpressionInputSchema.parse(predicate)).toEqual({ dialect: 'cel', source: predicate }); + const mine = mirrorVerdict(predicate); + expect(mine.success).toBe(true); + if (!mine.success) return; + expect(mine.data.visibleWhen).toBe(predicate); + }); + + it('an envelope parses to the same envelope', () => { + const mine = mirrorVerdict(ENVELOPE); + expect(mine.success).toBe(true); + if (!mine.success) return; + expect(mine.data.visibleWhen).toEqual(ENVELOPE); + }); + + it('the envelope a spec parse writes is accepted by both component faces', () => { + const parsed = SpecEvaluatedExpressionInputSchema.parse("record.status == 'open'"); + const node = { type: 'text', content: 'Open', visibleWhen: parsed }; + expect(AnyComponentSchema.safeParse(node).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse(node).success).toBe(true); + // Control: the same node with a dialect-less envelope is refused at the key. + const refused = StrictAnyComponentSchema.safeParse({ ...node, visibleWhen: { source: 'x' } }); + expect(refused.success).toBe(false); + }); +}); diff --git a/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts b/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts index 2d48b72a5d..a2e09f2a3b 100644 --- a/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts +++ b/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts @@ -52,12 +52,13 @@ * * ## The ceiling, stated rather than assumed (objectui#5155) * - * `BaseSchema` still carries `[key: string]: any`, so anchoring buys DECLARED + * While `BaseSchema` carried `[key: string]: any`, anchoring bought DECLARED * members their declared types (`visible: 42` is refused on both widgets now) - * but does NOT buy rejection of a misspelling: `visibleWhn` compiles on both. - * The ruling accepted that cost knowingly; the counter-probe below pins it - * honestly so nobody reads the anchor as more than it is. When objectui#5155 - * lands, that expectation is the one to revisit deliberately. + * but did NOT buy rejection of a misspelling: `visibleWhn` compiled on both. + * The ruling accepted that cost knowingly, and the counter-probe below pinned + * it honestly so nobody read the anchor as more than it was. objectui#8347 + * landed that objectui#5155 phase and the expectation was revisited: the + * counter-probe now pins the refusal. */ import { describe, it, expect } from 'vitest'; @@ -72,6 +73,7 @@ import { ObjectDataTableSchema as ObjectDataTableMirror, } from '../zod/objectql.zod.js'; import type { ExpressionWire } from '../expression'; +import type { EvaluatedExpressionInput } from '@objectstack/spec/shared'; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(HERE, '..', '..', '..', '..'); @@ -107,10 +109,12 @@ export type assertionDrillDownDeclared = Expect void) | undefined>>; /** * Inherited members resolve to their DECLARED types. `Equal`, not `extends`: - * through the index signature a missing member reads `any`, which a one-way - * check would accept (the objectui#7087 disabled-twin lesson). + * through the index signature a missing member read `any` until objectui#8347 + * (it fails to compile now), which a one-way check would accept (the + * objectui#7087 disabled-twin lesson). */ -export type assertionGalleryInheritsVisibleWhen = Expect>; +// `string` until objectui#8347 typed it as the spec's evaluated-slot input, by reference. +export type assertionGalleryInheritsVisibleWhen = Expect>; export type assertionGalleryInheritsBind = Expect>; // `boolean | string` until objectui#7530 declared the CEL envelope on the base union. export type assertionDataTableInheritsVisible = Expect>; @@ -128,7 +132,9 @@ describe('ObjectGallerySchema / ObjectDataTableSchema — compile-time pins (obj // unknown-key error, for the wrong reason); the data-table literal's own // index signature typed it `any` and ACCEPTED 42. Now both refuse it for // the declared reason. Each directive fails the build (TS2578) the moment - // the member stops being declared. + // the member is widened to accept a number; since objectui#8347 an + // undeclared `visible` is refused as excess, so a deletion is caught by the + // inherited-member `Equal` rows instead. // @ts-expect-error — `visible` is `boolean | ExpressionWire | undefined` through `BaseSchema`. const gallery: ObjectGallerySchema = { type: 'object-gallery', visible: 42 }; @@ -154,13 +160,16 @@ describe('ObjectGallerySchema / ObjectDataTableSchema — compile-time pins (obj expect(typeof table.onRowClick).toBe('function'); }); - it('still accepts a MISSPELLING — the ceiling, pinned honestly (objectui#5155)', () => { - // Counter-probe against reading the anchor as more than it is. `BaseSchema`'s - // `[key: string]: any` is inherited, so `visibleWhn` compiles on both. The - // ruling accepted this cost; closing it is objectui#5155, not this card. + it('refuses a MISSPELLING — the ceiling this row pinned is gone (objectui#8347)', () => { + // This row was the counter-probe against reading the anchor as more than it + // was: `BaseSchema`'s inherited `[key: string]: any` let `visibleWhn` compile + // on both, and closing that was left to the removal of the signature, which + // landed as objectui#8347. + // @ts-expect-error — `visibleWhn` is no member; the key is `visibleWhen` const gallery: ObjectGallerySchema = { type: 'object-gallery', visibleWhn: 'typo' }; + // @ts-expect-error — `visibleWhn` is no member; the key is `visibleWhen` const table: ObjectDataTableSchema = { type: 'object-data-table', visibleWhn: 'typo' }; - expect([gallery.visibleWhn, table.visibleWhn]).toEqual(['typo', 'typo']); + expect([gallery.type, table.type]).toEqual(['object-gallery', 'object-data-table']); }); }); diff --git a/packages/types/src/__tests__/widget-schema-anchors-7946.test.ts b/packages/types/src/__tests__/widget-schema-anchors-7946.test.ts index 36632c22a0..2956c7eabd 100644 --- a/packages/types/src/__tests__/widget-schema-anchors-7946.test.ts +++ b/packages/types/src/__tests__/widget-schema-anchors-7946.test.ts @@ -69,9 +69,10 @@ * * ## The ceiling, stated rather than assumed (objectui#5155) * - * `BaseSchema` still carries `[key: string]: any`, so anchoring buys DECLARED - * members their declared types and does NOT buy rejection of a misspelling. - * The counter-probe below pins that honestly, as #6576's does. + * While `BaseSchema` carried `[key: string]: any`, anchoring bought DECLARED + * members their declared types and did NOT buy rejection of a misspelling. + * The counter-probe below pinned that honestly, as #6576's did; objectui#8347 + * removed the signature, and both counter-probes are flipped. */ import { describe, it, expect } from 'vitest'; @@ -105,8 +106,9 @@ export type assertionChartExtendsBase = Expect>; export type assertionChartTypeIsRegistryKey = Expect>; /** * Inherited members resolve to their DECLARED types. `Equal`, not `extends`: - * through the index signature a missing member reads `any`, which a one-way - * check would accept (the objectui#7087 disabled-twin lesson). + * through the index signature a missing member read `any` until objectui#8347 + * (it fails to compile now), which a one-way check would accept (the + * objectui#7087 disabled-twin lesson). */ export type assertionChartInheritsVisible = Expect>; export type assertionChartInheritsBind = Expect>; @@ -178,9 +180,12 @@ describe('ObjectChartSchema — compile-time pins (objectui#7946)', () => { expect([chart.aggregate?.groupBy, chart.xAxisKey, first && 'dataKey' in first ? first.dataKey : undefined]).toEqual(['stage', 'stage', 'amount']); }); - it('still accepts a MISSPELLING — the ceiling, pinned honestly (objectui#5155)', () => { + it('refuses a MISSPELLING — the ceiling this row pinned is gone (objectui#8347)', () => { + // `BaseSchema`'s inherited `[key: string]: any` let `xAxisKy` compile until + // objectui#8347 removed it. + // @ts-expect-error — `xAxisKy` is no member; the key is `xAxisKey` const chart: ObjectChartSchema = { type: 'object-chart', chartType: 'bar', xAxisKy: 'stage' }; - expect(chart.xAxisKy).toBe('stage'); + expect(chart.type).toBe('object-chart'); }); }); diff --git a/packages/types/src/__tests__/wrapper-class-declared-7722.test.ts b/packages/types/src/__tests__/wrapper-class-declared-7722.test.ts index 7df0286784..440e7f49be 100644 --- a/packages/types/src/__tests__/wrapper-class-declared-7722.test.ts +++ b/packages/types/src/__tests__/wrapper-class-declared-7722.test.ts @@ -101,8 +101,9 @@ type Expect = T; type IsAny = 0 extends (1 & T) ? true : false; // Declared as `string` on each of the five. Were a member removed, the read -// would fall back to the index signature and resolve to `any`, and -// `Equal` is false — so these are guards, not restatements. +// fell back to the index signature and resolved to `any` until objectui#8347 +// (it does not compile now); either way these go red — so these are guards, +// not restatements. export type _SwitchWrapperClassIsString = Expect, string>>; export type _SwitchWrapperClassIsNotAny = Expect, false>>; export type _TextareaWrapperClassIsString = Expect, string>>; @@ -114,18 +115,22 @@ export type _SelectWrapperClassIsNotAny = Expect, string>>; export type _ListWrapperClassIsNotAny = Expect, false>>; -// The control key is NOT declared on any of the five: it resolves to `any` -// through the index signature, exactly as `wrapperClass` did before this card. -// Declaring it anywhere turns the matching line red — which is the point. -export type _SwitchControlFallsThrough = Expect>; -export type _TextareaControlFallsThrough = Expect>; -export type _DatePickerControlFallsThrough = Expect>; -export type _SelectControlFallsThrough = Expect>; -export type _ListControlFallsThrough = Expect>; - -// The TS face accepts the key on a literal. ⚠️ This is the WEAK half: the index -// signature would accept it undeclared too. The invariant pins above are the -// guard; these lines only show the declared spelling in use, once per type. +// The control key is NOT declared on any of the five: it resolved to `any` +// through the index signature, exactly as `wrapperClass` did before this card, +// until objectui#8347 removed the signature; it is no member now. Declaring it +// anywhere turns the matching line red — which is the point. +type NoMember = Equal; +export type _SwitchControlIsNoMember = Expect>; +export type _TextareaControlIsNoMember = Expect>; +export type _DatePickerControlIsNoMember = Expect>; +export type _SelectControlIsNoMember = Expect>; +export type _ListControlIsNoMember = Expect>; +// Lit control for the detector: `IsAny` does answer `true`, so the `IsNotAny` lines are readings. +export type _IsAnyCanAnswerTrue = Expect>; + +// The TS face accepts the key on a literal. Since objectui#8347 this is no +// longer the weak half: without the index signature an undeclared key is +// refused on a fresh literal, so each acceptance reads the declaration. const literals = { switch: { type: 'switch', label: 'Enabled', wrapperClass: 'gap-4' } satisfies TsSwitchSchema, textarea: { type: 'textarea', label: 'Notes', wrapperClass: 'gap-4' } satisfies TsTextareaSchema, diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 94ac86df28..220c097798 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -154,7 +154,13 @@ * a delta to this number; count the registry. Nothing asserts it against a written * one, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **50 entries** in `KnownDrift`, **87 keys** across them — 50 / 88 until + * - **55 entries** in `KnownDrift`, **92 keys** across them — 50 / 87 until + * objectui#8347 removed `BaseSchema`'s index signature, which ledgered FIVE + * new entries of one key each, all the INTERFACE-TO-PASSTHROUGH artifact + * (`app.zod.ts#AppSchemaRendererNodeSchema` and four `reports.zod.ts` + * pairs: a member typed with an `interface` against a `.passthrough()` + * mirror input, which an interface is never implicitly assignable to). It + * was 50 / 88 until * objectui#11514 dropped the component type from the TS * `DashboardWidgetSchema`'s `type`, once `plugin-dashboard` read slot entries * by the slot's element type: both faces name the same widget vocabulary, so @@ -841,9 +847,10 @@ * Each of the three was blind for a DIFFERENT structural reason, which is why the * gap survived the card that closed its mirror image: * - * - `WithoutIndexSignature` strips `BaseSchema`'s `[key: string]: any`, so a key - * the declaration never states has nothing to be stripped FROM and - * `DeclaredKeys< D >` cannot enumerate it; + * - `WithoutIndexSignature` strips any index signature (`BaseSchema`'s + * `[key: string]: any` until objectui#8347), so a key the declaration never + * states has nothing to be stripped FROM and `DeclaredKeys< D >` cannot + * enumerate it; * - `UnmirroredDeclaredKeys` is declared-minus-mirrored — the other subtraction; * - `WiderThanDeclaredKeys` maps over the INTERSECTION, so the key is outside its * domain and LEAVES THE COMPARISON — the same shape objectui#6058 measured in @@ -876,7 +883,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 50 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 55 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is * pinned to its EXACT drifted key set, so the entry fails when new drift appears on * that mirror AND when the recorded drift is fixed — a stale entry cannot rot * quietly. Correcting them is not one change: the pairs below split into DISJOINT @@ -994,10 +1001,11 @@ export type NarrowerThanDeclared< M, D > = { /** * A declaration's OWN declared members, with any index signature stripped. * - * `keyof D` is unusable on this population: most component declarations extend - * `BaseSchema`, which carries `[key: string]: any` (objectui#5155), and a string - * index signature ABSORBS every literal name — `keyof ObjectGanttSchema` resolves - * to bare `string`. Measured, not assumed: + * `keyof D` was unusable on this population while most component declarations + * extended `BaseSchema`'s `[key: string]: any` (objectui#5155, removed by + * objectui#8347): a string index signature ABSORBS every literal name — + * `keyof ObjectGanttSchema` resolved to bare `string` — and a declaration that + * carries its own signature still does. Measured, not assumed, at the time: * * declare const bare: keyof ObjectGanttSchema; * const _: never = bare; @@ -1050,9 +1058,9 @@ export type UnmirroredDeclaredKeys< M, D > = Exclude< DeclaredKeys< D >, Mirrore * stood before it are each blind to it, for three DIFFERENT structural reasons — * which is why the gap survived the card that closed its mirror image: * - * - `WithoutIndexSignature` strips `BaseSchema`'s `[key: string]: any`, so a key - * the declaration never states has nothing to be stripped FROM — it is simply - * not a member, and `DeclaredKeys< D >` cannot enumerate it; + * - `WithoutIndexSignature` strips any index signature (`BaseSchema`'s + * `[key: string]: any` until objectui#8347), so a key the declaration never + * states has nothing to be stripped FROM — it is simply not a member, and `DeclaredKeys< D >` cannot enumerate it; * - `UnmirroredDeclaredKeys` is `Exclude< DeclaredKeys< D >, MirroredKeys< M > >`, * declared-minus-mirrored — the other subtraction by construction; * - `WiderThanDeclaredKeys` maps over `MirroredKeys< M > & DeclaredKeys< D >`, the @@ -1295,9 +1303,9 @@ type MirrorAdmitsOpenRecord< MirrorIn, DeclaredType > = * reader receives input the published types say cannot exist. * * `DeclaredKeys` and not `keyof D` supplies the declared half, for the reason - * `WithoutIndexSignature` gives: most declarations carry `BaseSchema`'s string - * index signature, which resolves `D[K]` to `any` for a key the declaration does - * not really state — and `any` absorbs the comparison in this direction exactly as + * `WithoutIndexSignature` gives: a declaration carrying a string index signature + * (as most did through `BaseSchema` until objectui#8347) resolves `D[K]` to `any` + * for a key it does not really state — and `any` absorbs the comparison in this direction exactly as * silently as in the other one. */ export type WiderThanDeclaredKeys< M, D > = { @@ -1644,7 +1652,8 @@ export type assertionUnmirroredOperatorIsBlindToAnOpenRecord = * and SILENT on this pair, so its silence is a measurement and not a broken instrument. * * The declaration carries `[key: string]: any`, which is what `BaseSchema` really - * carries and what makes the fourth direction hard to measure at all: `keyof D` + * carried until objectui#8347 (and what a declaration with its own signature + * still carries), and what makes the fourth direction hard to measure at all: `keyof D` * resolves to bare `string`, so the obvious spelling of "mirrored but undeclared" * subtracts every key and reads `never` for every pair forever. * `assertionNaiveMirroredUndeclaredIsAbsorbedByTheIndexSignature` below pins that @@ -1669,7 +1678,7 @@ interface SyntheticFourWayDeclaration { wide: 'a' | 'b'; narrow: number | string; missing: number; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the shape `BaseSchema` really has; the whole point of this pair + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the shape `BaseSchema` really had until objectui#8347; the whole point of this pair [key: string]: any; } @@ -2209,6 +2218,21 @@ interface KnownDrift { * `AIRecommendationsSchema` above. */ 'ai.zod.ts#NLQuerySchema': 'onSubmit'; + /** + * INTERFACE-TO-PASSTHROUGH (objectui#8347), an artifact of this ratchet's + * assignability test and ⛔ not a narrowing a document can hit. The member is a + * node type declared as an `interface`; its mirror is a `.passthrough()` object, + * whose `z.input` carries `[k: string]: unknown`. TypeScript grants an IMPLICIT + * index signature to anonymous and mapped object types and never to an + * interface, so an interface is not assignable to it. While `BaseSchema` + * carried `[key: string]: any` the declared member had an explicit signature + * and fit; objectui#8347 removed it. Measured at removal: a one-level anonymous + * copy of the declared type (`{ [P in keyof D]: D[P] }`) IS assignable to the + * same mirror input, so the residue is exactly the interface rule. The four + * `reports.zod.ts` entries below are the same cause. They leave if the + * operator learns the rule, or if the member stops being an interface. + */ + 'app.zod.ts#AppSchemaRendererNodeSchema': 'schema'; /** * RUNTIME SLOT (objectui#6124): `calendar-view`'s `pickHostCallbacks` reads * `onViewChange` off the spread props (function values only) and hands it to @@ -2676,6 +2700,22 @@ interface KnownDrift { 'overlay.zod.ts#PopoverSchema': 'onOpenChange'; /** RUNTIME SLOT (objectui#6124): the `sheet` renderer spreads leftover props onto the Radix `Sheet` (Dialog) root. */ 'overlay.zod.ts#SheetSchema': 'onOpenChange'; + /** + * INTERFACE-TO-PASSTHROUGH (objectui#8347): the same artifact as + * `app.zod.ts#AppSchemaRendererNodeSchema` above — each member is, or holds, + * a declared `interface` (`report` is `ReportComponentSchema`, `chart` is + * `ChartSchema`, and `sections` holds `ReportSection`s, whose `chart` is) + * against a `.passthrough()` mirror whose input demands a string index + * signature an interface never implicitly has. Measured at removal for + * `chart`: the declared type is refused, a one-level anonymous copy fits. + */ + 'reports.zod.ts#ReportBuilderSchema': 'report'; + /** INTERFACE-TO-PASSTHROUGH (objectui#8347): see `ReportBuilderSchema` above; `sections` carries the element. */ + 'reports.zod.ts#ReportComponentSchema': 'sections'; + /** INTERFACE-TO-PASSTHROUGH (objectui#8347): see `ReportBuilderSchema` above. */ + 'reports.zod.ts#ReportSectionSchema': 'chart'; + /** INTERFACE-TO-PASSTHROUGH (objectui#8347): see `ReportBuilderSchema` above. */ + 'reports.zod.ts#ReportViewerSchema': 'report'; /** * RUNTIME SLOT (objectui#7344): `detail-view` spreads the node's keys onto * `DetailView`, whose `handleBack` calls `onBack()`. The TS twin declared the @@ -3980,10 +4020,13 @@ interface WiderThanDeclared { * `SchemaNode`'s object arm became `DeclaredNode`, the union of the declared node types * with no `type: string` arm. The mirror's slot still ends in the `BaseSchema` fallback * (`z.union([DashboardWidgetSlotComponentSchema, BaseSchema])`), which admits any `type` - * string, so it accepts a `component` node the declaration now refuses. That fallback is - * objectui#8347's to retire with the index signature, ⛔ not this ledger's: narrowing it - * here is the mirror change that card reserves. When it goes, this entry and the - * `widgets` one above measure clean and are deleted (clause 4 above). + * string, so it accepts a `component` node the declaration refuses. That fallback STAYS, + * by objectui#8344's decision: the card that added the slot chose that arm on purpose, + * and objectui#8347's Q3 = A (the seat's ACCEPT `5977022487`) put the zod face out of + * that card's scope, so the TS index-signature removal left it untouched. ⛔ Not this + * ledger's to narrow: that is a mirror change, and no open card retires the arm. When a + * card does retire it, this entry and the `widgets` one above measure clean and are + * deleted (clause 4 above). */ 'complex.zod.ts#DashboardWidgetSchema': 'component'; // `complex.zod.ts#ChatbotSchema` recorded `body` here (CONCRETE, and DISJOINT rather @@ -4483,7 +4526,7 @@ export const assertionWiderLedgerRecordsEveryKey: never = 0 as unknown as WiderL * is the same one objectstack#4115 opened when it made the keys flow in at all, * and it is a spec-alignment decision, not a typo. * - **`Omit` COLLAPSE — EMPTIED by objectui#9256.** The declaration was - * `Omit< Base, K >` over an interface that carries `BaseSchema`'s index + * `Omit< Base, K >` over an interface that carried `BaseSchema`'s index * signature, so `Exclude< keyof Base, K >` resolved through `string` and the * `Pick` collapsed EVERY member into the index signature; the mirror propagated * because zod `.extend()` carries the shape. Both instances (the `email` / @@ -4556,8 +4599,9 @@ interface MirroredUndeclared { * `@objectstack/spec` declares on `object-grid` — `operations`, re-derived * against the installed pin by `object-grid-operators-tombstone-9739.test.ts`, * not copied from prose. The twin is deliberately untouched (declaring the key - * was the refused option), so `BaseSchema`'s index signature still absorbs an - * authored `operators` as `any`. + * was the refused option); `BaseSchema`'s index signature absorbed an authored + * `operators` as `any` until objectui#8347, and the twin refuses it as an + * undeclared key now. * * ⚠️ WHY THE ROW DOES NOT LEAVE THIS LEDGER. This direction measures "the zod * mirror STATES a key the TypeScript twin does not", and a retirement tombstone @@ -5075,6 +5119,11 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ // to the split's LOCAL half (its `UnmirroredDeclared` docblock carries the // per-key reading). The split's counts now derive from THIS list's membership // and are pinned against the header by 'objectui#7279' below. + // objectui#8347: `BaseSchema` is back, for a real reference this time — + // `visibleWhen` is the spec's `EvaluatedExpressionSchema` (the envelope arm) + // with `EvaluatedExpressionInputSchema`'s verdict, so a spec bump that moves + // the evaluated-slot input moves ONE side of this pair. + 'base.zod.ts#BaseSchema', 'complex.zod.ts#DashboardComponentSchema', 'complex.zod.ts#DashboardWidgetSchema', // ⛔ `complex.zod.ts#KanbanSchema` removed with the retired `kanban` arm diff --git a/packages/types/src/ai.ts b/packages/types/src/ai.ts index 498de7859a..5d792af261 100644 --- a/packages/types/src/ai.ts +++ b/packages/types/src/ai.ts @@ -115,10 +115,12 @@ export interface AIFormAssistSchema extends BaseSchema { * A tombstone rather than a plain removal on PRONG 2 of the discriminator * (objectui#5941, #7526, `5f8190c8c`): this package's README taught the key as * working, in the Quick Start, in the `AIFormAssist` API example and in the - * schema-driven JSON example. {@link BaseSchema} carries `[key: string]: any`, - * so a DELETED member is absorbed silently at ANY value — deletion here is not - * a quieter refusal, it is no refusal — and the tombstone is what makes the - * compile-time refusal exist, by name. + * schema-driven JSON example. {@link BaseSchema} carried `[key: string]: any` + * when this was written, so a DELETED member was absorbed silently at ANY + * value — deletion was not a quieter refusal, it was no refusal. Since + * objectui#8347 a deletion is refused on a fresh literal, but a widened value + * still carries the key silently, and the tombstone is what makes the + * compile-time refusal exist in both positions, by name. * * ⛔ Not enforced instead (the ruling's words): implementing reads nobody asked * for is capability growth without pull. If an AI backend later needs the @@ -175,9 +177,10 @@ export interface AIFormAssistSchema extends BaseSchema { * the node as `suggestions`. * * A tombstone rather than a deletion on the grounds - * {@link AIFormAssistSchema.formId} records: {@link BaseSchema} carries - * `[key: string]: any`, so a DELETED member is absorbed silently at any value, - * and the tombstone is what makes the compile-time refusal exist, by name. The + * {@link AIFormAssistSchema.formId} records: {@link BaseSchema} carried + * `[key: string]: any`, so a DELETED member was absorbed silently at any value + * (since objectui#8347, through a widened value only), and the tombstone is + * what makes the compile-time refusal exist, by name. The * zod mirror refuses the key by name with the same prescription * (`retirementTombstone`), so the two faces agree. * diff --git a/packages/types/src/app.ts b/packages/types/src/app.ts index 7e26865d00..16d78debdc 100644 --- a/packages/types/src/app.ts +++ b/packages/types/src/app.ts @@ -558,8 +558,9 @@ export interface AppComponentSchema extends BaseSchema, Omit; * path (`./zod/nested-component-walk.ts`, objectui#11223). Its input type is * `unknown[]`, though, and nothing on the TypeScript face does what the walk * does. So read off the arm, the list, which is the form a `flex` node is - * authored in, took any entry at all, and stays unchecked after objectui#8347 - * removes `BaseSchema`'s index signature, while a single child was judged as a - * node. The mirror's member is the accept set the walk and the single-node arm + * authored in, took any entry at all, and would have stayed unchecked after + * objectui#8347 removed `BaseSchema`'s index signature, while a single child was + * judged as a node. The mirror's member is the accept set the walk and the single-node arm * judge between them. * * The zod arm does not move. The divergence is recorded where the mirror @@ -271,8 +271,8 @@ type FlexBlockArmShape = ClosedArmShape<(typeof FlexBlockSchema)['shape']>; * `__tests__/flex-bag-children-list-11564.test.ts`. Both faces judge an object * entry as a node of its own `type`: an undeclared `type` is refused by both, * and a key the node does not declare is refused by the strict zod face and, - * once objectui#8347 removes the index signature, by this type (until then the - * signature absorbs it on the node types that extend `BaseSchema`, as it does + * since objectui#8347 removed the index signature, by this type (before it the + * signature absorbed it on the node types that extend `BaseSchema`, as it did * for a single child). A primitive entry is admitted by both. They differ on * one entry kind: a nested array, which the walk passes through unvisited and * this type refuses, as the mirror does and as `renderChildren` diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts index c0d9ad447f..83278ba92c 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -17,6 +17,7 @@ */ import type { I18nLabel } from '@objectstack/spec/ui'; +import type { EvaluatedExpressionInput } from '@objectstack/spec/shared'; import type { AuthoringNode } from './authoring-nodes.js'; import type { ExpressionWire } from './expression.js'; import type { AnySchema } from './index.js'; @@ -60,7 +61,21 @@ export type KeyedI18nLabel = { /** * Base schema interface that all component schemas extend. * This is the fundamental building block of the Object UI protocol. - * + * + * ⭐ It declares NO index signature (objectui#8347, executing the + * objectui#7927 ruling: the TypeScript face is a contract). A node literal + * written against a node type is excess-property checked, so a key no + * declaration names, a misspelling included, stops type-checking at the + * authoring site. When a key you need is refused, declare it on the node + * type that reads it, by reference to the spec row, or fix the producer + * (AGENTS.md #0.1); ⛔ do not cast past it or put the signature back. + * The bound: TypeScript checks excess keys only on a FRESH object literal, so + * a value that reached its annotation through a wider variable is not + * re-checked. `__tests__/base-schema-closed-face-8347.test.ts` pins both + * directions. Renderer props keep their tolerance: {@link ComponentRendererProps} + * still carries its own signature, and the zod mirror is still + * `.passthrough()` on the rendering face. + * * @example * ```typescript * const schema: BaseSchema = { @@ -292,10 +307,11 @@ export interface BaseSchema { * * ## What declaring it buys, and what it does not (objectui#5155 / #6269) * - * Same ceiling as the gantt and timeline pins. `BaseSchema` carries an index - * signature on the TS side and is `.passthrough()` on the zod side, so an - * UNDECLARED key is still accepted by both halves — this did NOT buy - * rejection of a misspelling such as `bindTo`. What it DOES buy is the VALUE: + * Same ceiling as the gantt and timeline pins. `BaseSchema` is + * `.passthrough()` on the zod side (and carried an index signature on the TS + * side until objectui#8347), so an UNDECLARED key is still accepted by the + * zod half — this did NOT buy rejection of a misspelling such as `bindTo` + * there. What it DOES buy is the VALUE: * `bind: 42` type-checked and parsed green before this declaration and is * refused by both halves now. That narrowing only refuses what already * crashed — `useDataScope` is `(path?: string)` and resolves via @@ -312,10 +328,12 @@ export interface BaseSchema { * * ## Why it is refused by name rather than merely deleted * - * `BaseSchema` carries an index signature on the TS side and * `BaseSchemaCore` ends `.passthrough()` on the zod side, so DELETING a - * member does not refuse it — it makes it silently acceptable and - * silently inert, which is the state this retirement exists to leave. The + * member does not refuse it there — it makes it silently acceptable and + * silently inert, which is the state this retirement exists to leave. (The + * TS side carried an index signature with the same effect when this was + * written; objectui#8347 removed it, so a deleted member is refused at the + * authoring site now, but the parse still needs the tombstone.) The * `?: never` twin of the mirror's `aliasKeyRefusal` is what turns the * removal into an answer: `tsc` refuses the key at the authoring site and * the parse names `children` as the remedy. @@ -386,9 +404,22 @@ export interface BaseSchema { * Canonical conditional-visibility predicate (ADR-0089) — the element is shown * when this evaluates truthy. The spec folds the deprecated `visibleOn` / * `visibility` aliases into this key at parse. + * + * Typed BY REFERENCE as the spec's own input type for an evaluated slot, + * `EvaluatedExpressionInput` (objectui#8347, ruled Q6 = B): a predicate + * string, or the `{ dialect, source }` envelope the spec's parse of + * `PageComponentSchema.visibleWhen` produces and `SchemaRenderer` evaluates. + * It read `string` before, which refused the envelope the spec's own parse + * writes into this key. ⚠️ Narrower than + * `ExpressionWire` (`visible` / `hidden` / `disabled`, objectui#7530): the + * spec refuses a dialect-less envelope and an unknown dialect, and so does + * this key. Its zod twin is the same accept set without the spec's string + * transform (`../zod/base.zod.ts`). + * * @example "${data.role === 'admin'}" + * @example { dialect: 'cel', source: "record.status == 'open'" } */ - visibleWhen?: string; + visibleWhen?: EvaluatedExpressionInput; /** * Expression for conditional visibility. @@ -592,12 +623,6 @@ export interface BaseSchema { * @example { key: 'dialog.close', defaultValue: 'Close dialog' } */ ariaLabel?: string | KeyedI18nLabel; - - /** - * Additional properties specific to the component type. - * This index signature allows type-specific extensions. - */ - [key: string]: any; } /** diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index bb81b4c587..f6c7be2550 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -225,9 +225,10 @@ export interface KanbanColumn { * * ## ⚠️ Why the Zod mirror is a NAMED REFUSAL and not a deletion * - * {@link BaseSchema} closes with `[key: string]: any` and its Zod twin ends - * `.passthrough()`. A dropped MEMBER key is therefore KEPT, not refused — the - * failure objectui#7664's own first cut shipped at `onCardClick`. A dropped + * {@link BaseSchema}'s Zod twin ends `.passthrough()` (and the interface closed + * with `[key: string]: any` until objectui#8347). A dropped MEMBER key is + * therefore KEPT, not refused, on the Zod face — the failure objectui#7664's own + * first cut shipped at `onCardClick`. A dropped * TYPE LITERAL behaves differently on a DISCRIMINATED union (`AnyComponentSchema` * selects one arm from the authored literal), so removal alone would already * refuse — but only with the union's generic `Invalid input`, naming no remedy. @@ -1190,9 +1191,9 @@ type ChatMessageHandedBack = Omit & { * ## Why tombstones and not deletions — the mirror decides it here * * All six HAVE a Zod arm, so deleting the declaration would trade one silent - * no-op for another: `BaseSchema` is `.passthrough()` on the Zod side and - * carries a `[key: string]: any` index signature on the TS side, so an - * UNDECLARED key is not refused, it is KEPT. That is the hazard the two-prong + * no-op for another: `BaseSchema` is `.passthrough()` on the Zod side (and + * carried a `[key: string]: any` index signature on the TS side until + * objectui#8347), so an UNDECLARED key is not refused there, it is KEPT. That is the hazard the two-prong * discriminator leaves to the carrier — where there is no mirror there is "no * silent-strip hazard for prong 2 to guard" (`mobile.ts`, objectui#5941 / * #7526 / `5f8190c8c`: a `?: never` tombstone is available only on a SURVIVING @@ -1545,10 +1546,10 @@ export interface ChatbotSchema extends BaseSchema { * new key — which is why the guidance above names `type`. * * On a carrier extending {@link BaseSchema} — every component schema in this - * package — deleting an optional member is SILENT in every value shape, - * because the `[key: string]: any` index signature defeats both - * excess-property checking and the weak-type check. Measured on THIS member - * with `tsc -p tsconfig.test.json`, a no-index-signature control carrier + * package — deleting an optional member was SILENT in every value shape while + * `BaseSchema` carried `[key: string]: any` (until objectui#8347), because the + * index signature defeated both excess-property checking and the weak-type + * check. Measured on THIS member at the time with `tsc -p tsconfig.test.json`, a no-index-signature control carrier * (`FloatingChatbotConfig`) lit in the same run (TS2353 on a fresh undeclared * key, TS2559 on a lone-key widened value): * @@ -1558,11 +1559,13 @@ export interface ChatbotSchema extends BaseSchema { * | DELETED | clean | clean | clean | * | TOMBSTONED | TS2322 | TS2322 | TS2322 | * - * Deleted, the member reads as `any` through the index signature and even a - * wrong-typed value goes quiet. Tombstoned, PRESENCE with any value is a - * compile error — a channel deletion cannot produce on this carrier at all: - * on a `BaseSchema` carrier the two routes are loud-vs-silent, not - * louder-vs-quieter. Pinned, the deleted row included as a live control, in + * Deleted, the member read as `any` through the index signature and even a + * wrong-typed value went quiet. Since objectui#8347 a deletion trips TS2353 + * on a fresh literal, with no name for the remedy, and still rides a widened + * value clean. Tombstoned, PRESENCE with any value is a compile error in every + * shape, by name — a channel deletion cannot produce through a widened value: + * on a `BaseSchema` carrier the two routes were loud-vs-silent, and through a + * widened value they still are. Pinned, the deleted row included as a live control, in * `__tests__/chatbot-display-mode-retired.test.ts`. * * ## Runtime: unchanged, deliberately — zero validation before and after @@ -1613,8 +1616,9 @@ export interface ChatbotSchema extends BaseSchema { * * A `?: never` tombstone and ⛔ not a deletion, by the discriminator stated * on {@link ChatbotSchema.displayMode} above (cited, not restated): a deleted - * member on this {@link BaseSchema} carrier reads as `any` through the index - * signature, and this member's published comment advertised the floating + * member on this {@link BaseSchema} carrier read as `any` through the index + * signature until objectui#8347 (and still rides a widened value), and this + * member's published comment advertised the floating * configuration on the `chatbot` face. The zod twin refuses it by name. * * @deprecated Not a key `chatbot` reads — author `type: 'chatbot-floating'` @@ -2387,7 +2391,7 @@ export interface DashboardWidgetSchema * inherited, not restated. Until objectui#11467 the five reached the compiler * only through `BaseSchema`'s `[key: string]: any`, so each was `any` here and * the README's `metric-card` literals compiled on nothing but that signature, - * which objectui#8347 removes. + * which objectui#8347 removed. * * Each member's type is the prop `MetricCard` declares for it, and `trend`'s * vocabulary is the registration's enum. This package depends on neither, so @@ -2559,9 +2563,10 @@ export interface DashboardComponentSchema extends BaseSchema, Omit` = * `string` and collapsed every member into the signature — the checker * answered `any` for `UiCalendarSchema['body']` and for @@ -1404,7 +1404,7 @@ export interface CalendarSchema extends BaseSchema { * * The tombstone reaches {@link UiCalendarSchema} on BOTH faces. On this one * it did not until objectui#9256's E3 slice: `UiCalendarSchema` was declared - * as a plain `Omit` of this interface, and because {@link BaseSchema} carries + * as a plain `Omit` of this interface, and because {@link BaseSchema} carried * an index signature that `Omit` resolved through `Exclude` = * `string` and collapsed every member into the signature — the checker * answered `any` for `UiCalendarSchema['body']` and for @@ -2357,8 +2357,9 @@ export interface LabelSchema extends BaseSchema { * no alias window. Which of `text` / `label` is canonical is a separate question * and is not decided here. * - * `?: never` rather than deleted: this interface carries `BaseSchema`'s index - * signature, so a deleted member would type-check silently, while a tombstone + * `?: never` rather than deleted: this interface carried `BaseSchema`'s index + * signature, so a deleted member would type-check silently (since + * objectui#8347, through a widened value only), while a tombstone * makes presence a `tsc` error, and the zod twin refuses the key by name. * * @deprecated RETIRED (objectui#6152) — write the text as `text` (or `label`). @@ -2804,7 +2805,8 @@ export interface CodeEditorSchema extends BaseSchema { * moved onto that card). * * The built-in `Omit` is `Pick>`, and `keyof T` on - * a type carrying {@link BaseSchema}'s `[key: string]: any` is `string | number`: + * a type carrying a string index signature (as every {@link BaseSchema} + * extender did until objectui#8347) is `string | number`: * the literal member names are absorbed, `Exclude` leaves `string`, and the * `Pick` rebuilds the index signature and NONE of the named members (the same * mechanism objectui#6151 and objectui#6269 measured at other positions). The @@ -2836,8 +2838,8 @@ type OmitDeclared = { * `{ type: 'input', inputType: 'email' }` when the input type is the choice. * * ⚠️ objectui#8499 shipped this interface with the key merely OMITTED, and - * recorded why that was not enough: {@link BaseSchema} carries an index - * signature and its mirror is `.passthrough()`, so `inputType` rode through + * recorded why that was not enough: {@link BaseSchema} carried an index + * signature then and its mirror is `.passthrough()`, so `inputType` rode through * both faces unchallenged while the renderer threw the value away. objectui#8762 * closes that — the key is DECLARED and unwritable on both faces, so `tsc` * refuses it at the authoring site and the zod twin refuses it BY NAME with diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 69e06e4f1e..e37bbd4535 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -135,7 +135,7 @@ export type { // the `./zod` entry. export type { ExpressionWire } from './expression.js'; // TypeScript authoring types for the spec-declared nodes `BaseSchema` gives no -// face once its index signature goes (objectui#11364): the public blocks, the +// face now that objectui#8347 removed its index signature (objectui#11364): the public blocks, the // `element:text_input` / `element:record_picker` rows, and a stored page // document under its page kind. Each is derived by reference from its zod arm // or spec row; `@object-ui/react`'s `SchemaRendererProps.schema` accepts them. @@ -1076,58 +1076,27 @@ export type AnySchema = export type SchemaByType = Extract; /** - * Utility type INTENDED to make all properties optional except the type. + * Utility type to make all properties optional except the type. * Useful for partial schema definitions in editors. * - * ⚠️ MEASURED READING — it does not currently deliver that (objectui#6397). - * Every instantiation declares exactly ONE property, `type`, and carries a live - * `[key: string]: any`, so it accepts any key at `any`. Measured through the - * TypeScript checker against the emitted `index.d.ts`: - * - * PartialSchema -> 1 declared property: type (source: 61) - * PartialSchema -> 1 declared property: type (source: 67) - * PartialSchema -> 1 declared property: type (source: 42) - * PartialSchema -> 1 declared property: type (source: 27) - * + * ⚠️ It did NOT deliver that until objectui#8347 (objectui#6397 measured it): * `Omit` is `Pick>`, and `keyof T` on a type - * carrying a string index signature is `string | number` — the literal member - * names are ABSORBED. Every `T extends BaseSchema` inherits `BaseSchema`'s - * `[key: string]: any` (objectui#5155), so `Partial>` rebuilds a - * type holding the index signature and none of the named members; the explicit - * `{ type: T['type'] }` half is the only reason the count is 1 and not 0. Same - * mechanism as objectui#6151 (heritage clause) and objectui#6269 (property - * position) — this is its generic mapped-type-alias position. - * - * ⭐ SEQUENCING (objectui#6397 triage, 2026-08-25) — this declaration is - * deliberately left AS WRITTEN. The triage also held that it was not - * repairable in place: `T` is generic, so there is no literal key list to - * `Pick` the way objectui#6269 could for its two concrete schemas, and it read - * every generic re-spelling as collapsing for the same `keyof T` reason. - * - * ⚠️ That last reading is FALSE, and objectui#9256 is the counter-example. A - * key-remapping mapped type (`{ [P in keyof T as P extends K ? never : P]: … }`) - * iterates the named members and the index signature separately, so it drops - * exactly `K` and keeps every other named member — generic in `T` and all. - * `OmitDeclared` in `./form.ts` is that spelling; the E3 slice of objectui#9256 - * re-spelled `InputShorthandSchema` and `UiCalendarSchema` with it, and - * `__tests__/content-channel-e3-residual-9256.test.ts` pins inherited members - * on both faces. So the obstacle to a repair here is not the type system. - * Re-spelling this alias would NARROW what every instantiation accepts (a - * declared member would stop accepting a value of the wrong type), which is a - * contract change and is not made in a comment correction; objectui#6397, the - * card that triaged it, is closed. - * - * It is also not removable here — dropping a published export - * of `@object-ui/types` is a breaking removal of published capability and sits - * on the human floor. Once objectui#5155 removes the root index signature, - * `keyof T` resolves to the literal member union again and this alias starts - * working exactly as its first line promises, with no edit here at all. + * carrying a string index signature is `string | number`, so while every + * `T extends BaseSchema` inherited `BaseSchema`'s `[key: string]: any`, the + * literal member names were absorbed and every instantiation declared exactly + * one property, `type`, beside a live index signature. objectui#8347 removed + * that signature, so `keyof T` is the literal member union again and the + * alias works as written, with no edit here. The collapse pin that recorded + * the old reading (`partial-schema-collapse-pin.test.ts`) asked to be deleted + * on that day, and was; `__tests__/base-schema-closed-face-8347.test.ts` pins + * the working reading. * - * Until then, do not adopt it as protection it does not provide: name the - * concrete schema type, or write `Partial>` over literal keys - * (objectui#6269's repair shape), when you need a real partial. The reading - * above is pinned by `src/__tests__/partial-schema-collapse-pin.test.ts`, which - * goes red — by design — the day objectui#5155 lands. + * ⚠️ A `T` that declares its OWN index signature still collapses the same way; + * reach for `OmitDeclared` (`./form.ts`) there, the key-remapping spelling + * objectui#9256 used. It had no in-repo consumer at the removal (a reading + * taken then, which nothing re-derives); whether to keep or retire it is a + * published-export question for the maintainer (the pin's own removal + * condition asked for that card), ⛔ not something to settle here. */ export type PartialSchema = { type: T['type']; diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index 5f8d2355e1..403e6cbe5f 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -619,8 +619,8 @@ export interface ContainerSchema extends BaseSchema { * erased every named member from the SHIPPED declaration, silently: * `Omit` is `Pick>`, and `keyof T` on a type * carrying a string index signature is `string | number` — the literal member - * names are absorbed. `FlexSchema` inherits `BaseSchema`'s `[key: string]: any` - * (objectui#5155), so `Exclude` is still + * names are absorbed. `FlexSchema` inherited `BaseSchema`'s `[key: string]: any` + * (objectui#5155) until objectui#8347, so `Exclude` is still * `string | number`, and the `Pick` reconstructed a type with the index * signature and NONE of the named members. Measured against the emitted * `dist/layout.d.ts`: `FlexSchema` declared 25 properties, `StackSchema` @@ -783,7 +783,8 @@ export interface FlexSchema extends BaseSchema, FlexLayoutProps { * different set of steps (objectui#11474). The `Omit` here crosses * {@link FlexLayoutProps}, which carries no index signature, so it keeps every * other member's name; the objectui#6151 hazard is an `Omit` over a type that - * inherits `BaseSchema`'s. `__tests__/stack-schema-emitted-members.test.ts` + * carries a string index signature, as every `BaseSchema` extender did until + * objectui#8347. `__tests__/stack-schema-emitted-members.test.ts` * measures the emitted declaration and holds the two member sets equal. */ export interface StackSchema extends BaseSchema, Omit { @@ -957,9 +958,10 @@ export interface CardSchema extends BaseSchema { * one of the fallback readers whose `body` arm the ruling drops in the * same change as the `body`-only registrations converge. * - * Refused by name rather than deleted: `BaseSchema` carries - * `[key: string]: any` and the zod twin ends `.passthrough()`, so a - * deleted member is ACCEPTED silently and rendered by nothing. The + * Refused by name rather than deleted: the zod twin ends `.passthrough()` + * (and `BaseSchema` carried `[key: string]: any` until objectui#8347; a + * widened value still skips the excess-property check), so a deleted member + * is ACCEPTED silently and rendered by nothing. The * `?: never` is what makes `tsc` answer at the authoring site, and the * mirror's `aliasKeyRefusal` names `children` at parse time. * @@ -1349,9 +1351,10 @@ export interface AspectRatioSchema extends BaseSchema { * whenever no `image` was set, so it is one of the fallback readers whose * `body` arm drops in the same change. * - * Refused by name rather than deleted: `BaseSchema` carries - * `[key: string]: any` and the zod twin ends `.passthrough()`, so a - * deleted member is ACCEPTED silently and rendered by nothing. The + * Refused by name rather than deleted: the zod twin ends `.passthrough()` + * (and `BaseSchema` carried `[key: string]: any` until objectui#8347; a + * widened value still skips the excess-property check), so a deleted member + * is ACCEPTED silently and rendered by nothing. The * `?: never` is what makes `tsc` answer at the authoring site, and the * mirror's `aliasKeyRefusal` names `children` at parse time. * @@ -1664,9 +1667,10 @@ export interface PageNodeSchema extends BaseSchema, Omit` is + * because `Omit` collapsed here (objectui#6269). `Omit` is * `Pick>`, and `keyof T` on a type carrying a string * index signature is `string | number` — the literal member names are ABSORBED. - * `ObjectGridSchema` inherits `BaseSchema`'s `[key: string]: any` - * (objectui#5155), so the `Omit` rebuilt a type holding the index signature and + * `ObjectGridSchema` inherited `BaseSchema`'s `[key: string]: any` + * (objectui#5155) until objectui#8347, so the `Omit` rebuilt a type holding the index signature and * none of the named members: measured through the checker at objectui#6269, * `Omit` declared 0 properties. The * slot accepted anything (`table: { colunms: 3 }` type-checked), offered no @@ -2300,9 +2301,9 @@ export interface ObjectFormSchema extends BaseSchema { * A new grid member therefore turns it red until it is relayed (here and in * `OBJECT_VIEW_TABLE_RELAY_KEYS`) or withheld (there and on the zod twin). * - * 🗑️ When a #5155 phase removes `BaseSchema`'s root index signature, `Omit` - * stops collapsing, and this list may be spelled as an `Omit` of the identity - * keys and the withheld set. The pin stays: what it guards now is that every + * 🗑️ objectui#8347 removed `BaseSchema`'s root index signature (the #5155 phase + * this waited for), so `Omit` no longer collapses here, and this list may be + * spelled as an `Omit` of the identity keys and the withheld set. The pin stays: what it guards now is that every * grid member is classified, which an `Omit` does not do by itself. */ type ObjectGridSlotKey = @@ -2350,9 +2351,10 @@ type ObjectGridSlotKey = * * Same `Omit` collapse and same pin as `ObjectGridSlotKey` above — see its * comment. Unlike that list, this one withholds nothing but the identity keys, - * so its removal condition is the original one: when a #5155 phase removes - * `BaseSchema`'s root index signature, this list and its half of the pin become - * removable in favour of `Omit`. + * so its removal condition is the original one, and objectui#8347 met it by + * removing `BaseSchema`'s root index signature (the #5155 phase it waited for): + * this list and its half of the pin are removable in favour of + * `Omit`. * Measured before the objectui#6269 fix: that `Omit` declared 0 of * `ObjectFormSchema`'s 67 members. */ @@ -2610,9 +2612,10 @@ export interface ObjectViewSchema extends BaseSchema { * * `?: never` is this package's tombstone convention (see * `ObjectKanbanSchema.groupField`, objectui#7322), and it is load-bearing - * rather than decorative: {@link BaseSchema} carries `[key: string]: any`, - * so DELETING this member would let the retired spelling type-check green - * and go on doing nothing. Keeping the key declared as `never` is what makes + * rather than decorative: {@link BaseSchema} carried `[key: string]: any` + * until objectui#8347, and a deletion is still refused only on a fresh + * literal, so DELETING this member would let the retired spelling type-check + * green through a widened value and go on doing nothing. Keeping the key declared as `never` is what makes * the retirement audible at the authoring boundary. Lockstep with the Zod * twin (`zod/objectql.zod.ts`, `retirementTombstone()`): both halves or * neither. Absent stays valid on both, so a node that never wrote the key is @@ -4166,9 +4169,9 @@ export interface ObjectCalendarSchema extends BaseSchema { * `startDateField`. * * ⚠️ It is a TOMBSTONE, not a deletion, and the difference is the whole - * ruling. `BaseSchema` carries `[key: string]: any` on this face and - * `.passthrough()` on the mirror, so a DELETED key is not refused — it is - * KEPT, unexamined, and then ignored now that `ObjectCalendar`'s alias ladder + * ruling. `BaseSchema` is `.passthrough()` on the mirror (and carried + * `[key: string]: any` on this face until objectui#8347), so a DELETED key is + * not refused there — it is KEPT, unexamined, and then ignored now that `ObjectCalendar`'s alias ladder * is gone. Silent is the failure mode an earlier attempt shipped * (objectui#8651 records it); declared-and-unwritable is what makes the same * document fail loudly instead. @@ -4243,7 +4246,7 @@ export interface ObjectCalendarSchema extends BaseSchema { * {@link ObjectCalendarSchema.endDateField} above, which have shipped * DECLARED for releases. This key is the fifth member of that flat handoff, * not a new dialect. And under `BaseSchema`'s - * index signature the value was already `any`, so declaring only NARROWS. + * index signature the value was already `any`, so declaring only NARROWED. * * ⭐ Nor is it a new precedent: {@link CalendarViewSchema} — a sibling * calendar interface in the same plugin, whose OWN renderer @@ -4524,9 +4527,10 @@ export interface ObjectKanbanSchema extends BaseSchema { * * `?: never` is this package's tombstone convention (see * `TimelineSchema.timeScale`, objectui#6355), and it is load-bearing rather - * than decorative: {@link BaseSchema} carries `[key: string]: any`, so - * DELETING this member would let the retired spelling type-check green and - * go on doing nothing. Keeping the key declared as `never` is what makes the + * than decorative: {@link BaseSchema} carried `[key: string]: any` until + * objectui#8347, and a deletion is still refused only on a fresh literal, so + * DELETING this member would let the retired spelling type-check green + * through a widened value and go on doing nothing. Keeping the key declared as `never` is what makes the * retirement audible at the authoring boundary. Lockstep with the Zod twin * (`zod/objectql.zod.ts`, `retirementTombstone()`): both halves or neither. * Absent stays valid on both, so a node that never wrote the key is untouched. @@ -4548,8 +4552,8 @@ export interface ObjectKanbanSchema extends BaseSchema { * {@link BaseSchema}'s `[key: string]: any` on this face: the renderer read * it at three sites while neither published face of this package named it, * so `columns: "todo"`, `columns: [42]` and a lane card with no `title` all - * parsed green. Declaring on a face that already carries an index signature - * can only NARROW — it adds validation where there was none — and that is + * parsed green. Declaring on a face that carries an index signature (as this + * one did until objectui#8347) can only NARROW — it adds validation where there was none — and that is * the whole of what this member does. * * ⚠️ Narrowing is not licence to bless whatever the protocol's literal @@ -4777,10 +4781,11 @@ export interface ObjectKanbanSchema extends BaseSchema { * reverse left open (objectui#9711). * * What this member buys, measured: {@link BaseSchema}'s `[key: string]: any` - * would admit `cardTitle: 42` in a typed corpus, and with the member declared - * `tsc` refuses it. The `@ts-expect-error` row in - * `__tests__/object-kanban-card-title-9606.test.ts` is what turns that into a - * guard — delete this member and the directive goes unused, TS2578. + * admitted `cardTitle: 42` in a typed corpus until objectui#8347, and with the + * member declared `tsc` refuses it. The `@ts-expect-error` row in + * `__tests__/object-kanban-card-title-9606.test.ts` guards the member's TYPE; + * it no longer catches a deletion, because since objectui#8347 a deleted + * member's `42` is refused as an undeclared key and the directive stays used. */ cardTitle?: string; /** Field for card title */ @@ -4842,9 +4847,10 @@ export interface ObjectKanbanSchema extends BaseSchema { * resolves to that component since the `kanban-ui` tag retired * (objectui#8257), so this is a host capability, not a document one. * - * A tombstone rather than a deleted member because `BaseSchema` carries an - * index signature: a deleted member would type as `any` and be KEPT, not - * refused. Pinned in `__tests__/object-kanban-quick-add-retired-8285.test.ts`. + * A tombstone rather than a deleted member because `BaseSchema` carried an + * index signature when this was written: a deleted member would type as + * `any` and be KEPT, not refused. Since objectui#8347 a deletion is refused + * on a fresh literal, but a widened value still carries the key silently. Pinned in `__tests__/object-kanban-quick-add-retired-8285.test.ts`. * * @deprecated Not part of this contract — delete the key. */ @@ -4931,9 +4937,9 @@ export interface ObjectKanbanSchema extends BaseSchema { * * ## Why a tombstone rather than a plain deletion * - * {@link BaseSchema} carries `[key: string]: any` and its mirror ends - * `.passthrough()`, so a deleted member is KEPT, not refused — one silent - * no-op traded for another. The member therefore stays declared and + * {@link BaseSchema}'s mirror ends `.passthrough()` (and the interface carried + * `[key: string]: any` until objectui#8347), so a deleted member is KEPT, not + * refused, on the zod face — one silent no-op traded for another. The member therefore stays declared and * unwritable on both faces, paired with a `retirementTombstone()` on the zod * twin so the value is refused BY NAME instead of stripped, which is also * what keeps the two faces' key sets equal for the parity ratchet. @@ -5273,11 +5279,13 @@ export interface KanbanConditionalFormattingRule extends SpecConditionalFormatti * * ## The ceiling, stated rather than assumed (objectui#5155) * - * `BaseSchema` still carries `[key: string]: any`, so declaring a key buys it + * While `BaseSchema` carried `[key: string]: any`, declaring a key bought it * its declared TYPE — `xAxisKey: 42` and `title: 42` are both refused now — but - * does NOT buy rejection of a MISSPELLING: `xAxisKy: 'x'` and `drillDwn: {}` - * still compile, exactly as they do on `ObjectGallerySchema` (objectui#6576). - * ONE ceiling, two cards, and each pins it honestly with its own counter-probe: + * did NOT buy rejection of a MISSPELLING: `xAxisKy: 'x'` and `drillDwn: {}` + * still compiled, exactly as they did on `ObjectGallerySchema` (objectui#6576). + * objectui#8347 removed the signature, so a misspelling in a fresh literal is + * refused now. ONE ceiling, two cards, and each pinned it with its own + * counter-probe, flipped by objectui#8347: * `__tests__/widget-schema-anchors-7946.test.ts` and * `__tests__/object-chart-undeclared-keys-8885.test.ts`. * @@ -5427,8 +5435,9 @@ export interface ObjectChartSchema extends BaseSchema { * `xAxis: { field: 'status' }`; on the inline `objectName` path the category * is `aggregate.groupBy`. * - * A tombstone rather than a deletion: `BaseSchema`'s index signature and the - * mirror's `.passthrough()` would otherwise KEEP an authored value in silence. + * A tombstone rather than a deletion: the mirror's `.passthrough()` would + * otherwise KEEP an authored value in silence, and so did `BaseSchema`'s + * index signature until objectui#8347 (a widened value still would). * * @deprecated Not a key this node reads. Write `xAxis: { field }`. */ @@ -6009,9 +6018,10 @@ export interface ObjectDataTableSchema extends BaseSchema { * and no reader was added. * * A `?: never` tombstone, not a plain deletion: this carrier survives the - * retirement and extends {@link BaseSchema} (`[key: string]: any` here, - * `.passthrough()` on the zod mirror), so a deleted member would be absorbed - * silently at any value — the silent no-op the retirement exists to end. + * retirement and extends {@link BaseSchema} (`.passthrough()` on the zod + * mirror, and `[key: string]: any` here until objectui#8347; a widened value + * still skips the excess-property check), so a deleted member would be + * absorbed silently at any value — the silent no-op the retirement exists to end. * Licensed by prong 1 of the discriminator (objectui#5941, #7526, in its * amended form, `5f8190c8c`): it steers authors to the named live replacement, * `objectName`. The zod mirror refuses the key by name with that guidance. diff --git a/packages/types/src/overlay.ts b/packages/types/src/overlay.ts index 065e71cca6..81610d54b4 100644 --- a/packages/types/src/overlay.ts +++ b/packages/types/src/overlay.ts @@ -253,8 +253,8 @@ export interface AlertDialogSchema extends BaseSchema { * that root renders a context provider, not an element, so the prop was * dropped before any DOM node * (`packages/components/src/__tests__/alert-dialog-footer-keys-liveness-7963.test.tsx`). - * `BaseSchema` closes with `[key: string]: any`, so the value was never - * refused, only KEPT. + * `BaseSchema` closed with `[key: string]: any` (until objectui#8347), so the + * value was never refused, only KEPT. * * Write {@link cancelText} instead — the key the renderer reads. * diff --git a/packages/types/src/reports.ts b/packages/types/src/reports.ts index 92108b793d..f209413834 100644 --- a/packages/types/src/reports.ts +++ b/packages/types/src/reports.ts @@ -381,8 +381,9 @@ export interface ReportComponentSchema extends BaseSchema { * that write is removed in the same change. A spec report's own `type` is what * selects a dataset-bound report's layout; this presentation key selected nothing. * - * `?: never` rather than deleted: this interface carries `BaseSchema`'s index - * signature, so a deleted member would type-check silently, while a tombstone + * `?: never` rather than deleted: this interface carried `BaseSchema`'s index + * signature, so a deleted member would type-check silently (since + * objectui#8347, through a widened value only), while a tombstone * makes presence a `tsc` error, and the zod twin refuses the key by name. * * @deprecated RETIRED (objectui#6152) — nothing reads it. Delete the key. @@ -524,8 +525,9 @@ export interface ReportComponentSchema extends BaseSchema { * key on a different type), no in-code producer, and no document authoring it, * so an authored value configured nothing. * - * `?: never` rather than deleted: this interface carries `BaseSchema`'s index - * signature, so a deleted member would type-check silently, while a tombstone + * `?: never` rather than deleted: this interface carried `BaseSchema`'s index + * signature, so a deleted member would type-check silently (since + * objectui#8347, through a widened value only), while a tombstone * makes presence a `tsc` error, and the zod twin refuses the key by name. * * @deprecated RETIRED (objectui#6152) — nothing reads it. Delete the key. diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index 0a85fc0bfe..2f238bbb90 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -921,9 +921,9 @@ export interface DetailViewSchema extends BaseSchema { * * `?: never` is the twin of `zod/views.zod.ts`'s `retirementTombstone` arm, * and the pair is deliberate: a BARE DELETE would not refuse this key, it - * would KEEP it. `BaseSchema` closes with an any-valued index signature and - * `BaseSchemaCore` ends `.passthrough()`, so an undeclared member is passed - * through silently — the mechanism objectui#7963 measured. Declared-and- + * would KEEP it. `BaseSchemaCore` ends `.passthrough()` (and `BaseSchema` + * closed with an any-valued index signature until objectui#8347), so an + * undeclared member is passed through silently on the zod face — the mechanism objectui#7963 measured. Declared-and- * unwritable is what makes the refusal loud. * * @deprecated Not part of this contract. Author a `record:related_list` block. @@ -964,8 +964,9 @@ export interface DetailViewSchema extends BaseSchema { * related lists from it; the related-list capability is the protocol-governed * `record:related_list` block. A key nothing honours is retired, not mirrored. * - * `?: never` rather than deleted: this interface carries `BaseSchema`'s index - * signature, so a deleted member would type-check silently, while a tombstone + * `?: never` rather than deleted: this interface carried `BaseSchema`'s index + * signature, so a deleted member would type-check silently (since + * objectui#8347, through a widened value only), while a tombstone * makes presence a `tsc` error, and the zod twin refuses the key by name. * * @deprecated RETIRED (objectui#6152) — nothing reads it. Author a diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts index 899648142d..5e0eed917e 100644 --- a/packages/types/src/zod/base.zod.ts +++ b/packages/types/src/zod/base.zod.ts @@ -20,6 +20,10 @@ import { z } from 'zod'; import { I18nLabelSchema } from '@objectstack/spec/ui'; import { aliasKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import { ExpressionWireSchema } from './expression.zod.js'; +import { + EvaluatedExpressionInputSchema as SpecEvaluatedExpressionInputSchema, + EvaluatedExpressionSchema as SpecEvaluatedExpressionSchema, +} from '@objectstack/spec/shared'; import type { SchemaNode } from '../base.js'; import { stripImportedDefaults } from './imported-defaults.js'; @@ -284,14 +288,15 @@ type NodeSlotZodType = z.ZodOptional { + const verdict = stripImportedDefaults(SpecEvaluatedExpressionInputSchema).safeParse(predicate); + if (!verdict.success) { + for (const issue of verdict.error.issues) ctx.addIssue({ code: 'custom', message: issue.message }); + } + }); + /** * Base Schema - Core validation schema that all components extend * @@ -448,8 +477,10 @@ const BaseSchemaCore = z.object({ /** * Canonical conditional-visibility predicate (ADR-0089) — shown when truthy. * The spec folds the deprecated `visibleOn` / `visibility` aliases into this. + * The spec's evaluated-slot input, by reference and untransformed — see + * `VisibleWhenInputSchema` above (objectui#8347). */ - visibleWhen: z.string().optional().describe('Canonical conditional-visibility predicate (ADR-0089)'), + visibleWhen: VisibleWhenInputSchema.optional().describe('Canonical conditional-visibility predicate (ADR-0089)'), /** * Conditional visibility expression diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 18f83bb995..1216368886 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -663,7 +663,8 @@ export const ObjectGridSchema = BaseSchema.extend({ // // ⛔ NOT declared on the TypeScript twin — the ruling says so in as many // words. `ObjectGridSchema` extends `BaseSchema`, whose index signature - // absorbs an authored `operators` as `any`; adding a `?: never` half would + // absorbed an authored `operators` as `any` until objectui#8347 (that face + // now refuses it as an undeclared key); adding a `?: never` half would // write the misspelling INTO the published interface, which is the ruling's // letter A and was refused. operators: retirementTombstone( @@ -2753,7 +2754,7 @@ export const ObjectGanttSchema = BaseSchema.extend({ // config from these top-level keys when the node carries no `gantt` block and // `startDateField` / `endDateField` are both present — the block OUTRANKS this // face (objectui#6469); nothing declared them, on either side, - // because `BaseSchema`'s index signature admits them untyped. Mirrored at the + // because `BaseSchema`'s index signature admitted them untyped. Mirrored at the // SAME requiredness as `../objectql.ts` (all optional) so the zod-mirror-parity // ratchet stays at zero drift for this pair. // diff --git a/packages/types/src/zod/overlay.zod.ts b/packages/types/src/zod/overlay.zod.ts index 7e2280f8a9..017e0b50cd 100644 --- a/packages/types/src/zod/overlay.zod.ts +++ b/packages/types/src/zod/overlay.zod.ts @@ -61,8 +61,9 @@ export const DialogSchema = BaseSchema.extend({ * * ## Why a REFUSAL and not a deletion * - * `BaseSchemaCore` ends `.passthrough()` and the TS `BaseSchema` closes with - * `[key: string]: any`, so a dropped MEMBER key is KEPT, not refused — deleting + * `BaseSchemaCore` ends `.passthrough()` (and the TS `BaseSchema` closed with + * `[key: string]: any` until objectui#8347), so a dropped MEMBER key is KEPT, + * not refused, on this face — deleting * these three declarations would have left the silent accept exactly as it was * and thrown away the diagnostic with it. {@link retirementTombstone} keeps the * key DECLARED and unwritable, which is what makes the refusal loud. Same diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index 0683858a54..e531cf9196 100644 --- a/packages/types/src/zod/views.zod.ts +++ b/packages/types/src/zod/views.zod.ts @@ -336,9 +336,9 @@ export const DetailViewSchema = BaseSchema.extend({ * * ## Why a REFUSAL and not a deletion * - * `BaseSchemaCore` ends `.passthrough()` and the TypeScript `BaseSchema` - * closes with an any-valued index signature, so a dropped MEMBER key is - * KEPT, not refused — deleting this declaration would have left the silent + * `BaseSchemaCore` ends `.passthrough()` (and the TypeScript `BaseSchema` + * closed with an any-valued index signature until objectui#8347), so a + * dropped MEMBER key is KEPT, not refused, on this face — deleting this declaration would have left the silent * accept exactly as it was and thrown the diagnostic away with it. * `retirementTombstone` keeps the key DECLARED and unwritable, which is what * makes the refusal loud. Same mechanism and same reasoning as the diff --git a/skills/objectui/guides/architecture.md b/skills/objectui/guides/architecture.md index 95e29fb44e..9aeeb10d4e 100644 --- a/skills/objectui/guides/architecture.md +++ b/skills/objectui/guides/architecture.md @@ -19,13 +19,18 @@ Every node in the UI tree follows this shape — enforce it on every input. ⚠️ The fence below is a deliberately SIMPLIFIED teaching copy of the published `BaseSchema` (`import type { BaseSchema } from '@object-ui/types';`), KEPT -rather than replaced by an import: the published interface declares **21** -members plus an index signature, and this section teaches the SHAPE, not the -member list. Two consequences before you go looking in the `.d.ts`: nothing -checks the copy against the real type, and -`props` / `events` below are **not declared members** — they reach a node -through `BaseSchema`'s `[key: string]: any` index signature, which is why you -will not find them in the published member list. +rather than replaced by an import: it teaches the SHAPE, not the member list, +and nothing checks the copy against the real type. `BaseSchema` has **no index +signature** (objectui#8347), so a key no member declares gets one verdict per +face: on the TypeScript authoring face it is a compile error in a fresh typed +literal (TypeScript excess-checks only a FRESH object literal, so a value that +reached its annotation through a wider variable is not re-checked); on the +tolerant zod / JSON face (`.passthrough()`) it is kept, and the named reader +decides. That is why `props` and `events` are **absent** from the fence: +`props` is the `element:*` config envelope the runtime reads and the TypeScript +face does not declare; `events` is read by nothing on either face. The "Keys +Live on the Node" and "Actions Are Node Types" rules in `rules/protocol.md` say +who reads each. ```typescript // @object-ui/types — abridged; the full BaseSchema member list is packages/types/src/base.ts @@ -36,9 +41,6 @@ interface BaseSchema { /** Unique ID for DOM accessibility and event targeting */ id?: string; - /** Config envelope — read only by element:* renderers; see rules/protocol.md */ - props?: Record; - /** Data binding path (e.g., 'user.address.city') */ bind?: string; @@ -49,11 +51,10 @@ interface BaseSchema { hidden?: boolean | ExpressionWire; // e.g. "${data.role != 'admin'}" disabled?: boolean | ExpressionWire; // same wire as hidden - /** Event Handlers */ - events?: Record; // onClick -> [Action1, Action2] - /** Layout Slots */ children?: BaseSchema[]; // object half; the real slot also admits primitives + + // no index signature: a key no member declares is a compile error in a fresh typed literal } ``` diff --git a/skills/objectui/rules/protocol.md b/skills/objectui/rules/protocol.md index 8018b80279..ff716489de 100644 --- a/skills/objectui/rules/protocol.md +++ b/skills/objectui/rules/protocol.md @@ -47,27 +47,28 @@ Every UI component node MUST follow this shape: ⚠️ A deliberately SIMPLIFIED teaching copy of the published `BaseSchema` (`import type { BaseSchema } from '@object-ui/types';`), KEPT rather than -replaced by an import: the published interface declares **21** members plus an -index signature, and this rule is about the SHAPE, not the full member list. -Nothing checks the copy against the real type. `properties`, `props` and -`events` are **not declared members** — they reach a node through -`BaseSchema`'s `[key: string]: any` index signature, which is exactly why the -two rules below have to state who reads each of them. +replaced by an import: it teaches the SHAPE, not the full member list, and +nothing checks the copy against the real type. `BaseSchema` has **no index +signature** (objectui#8347): on the TypeScript authoring face a key no member +declares is a compile error in a fresh typed literal (TypeScript excess-checks +only a FRESH object literal, so a value that reached its annotation through a +wider variable is not re-checked); on the tolerant zod / JSON face +(`.passthrough()`) it is kept, and the named reader decides. So `properties`, +`props` and `events` are **absent** from the fence: `properties` is declared per +block on the authoring node types (`AuthoringNode`), not on `BaseSchema`; `props` +is the `element:*` envelope the runtime reads and the TypeScript face does not +declare; `events` is read by nothing. The two rules below state who reads each. ```typescript interface BaseSchema { // abridged — full member list: packages/types/src/base.ts type: string; // Required: component type identifier id?: string; // Optional: unique identifier - properties?: Record; // Optional: spec config bag, hoisted onto - // the node. See "Rule: Keys Live on the Node". - props?: Record; // Optional: element:* config envelope — NOT a - // general bag. See "Rule: Keys Live on the Node". bind?: string; // Optional: data binding path className?: string; // Optional: Tailwind CSS classes hidden?: boolean | ExpressionWire; // Optional: visibility predicate disabled?: boolean | ExpressionWire; // Optional: disabled predicate - events?: Record; // Optional: event handlers children?: BaseSchema[]; // Optional: object nodes; real slot is wider + // no index signature: a key no member declares is a compile error in a fresh typed literal } ``` @@ -276,9 +277,9 @@ component refuses a node-level `actionType` / `target` as mis-layered **❌ DO NOT** use function references or inline callbacks in JSON schemas, and **❌ DO NOT** author an `events` bag. `BaseSchema` declares no `events` member and no -renderer reads `schema.events`; "Rule: Component Schema Structure" above lists it -only because the node accepts any key — `.passthrough()` keeps such a node, judges -it by nothing and runs it by nothing (objectui#6497). `ButtonSchema.onClick` is a +renderer reads `schema.events`: a typed literal refuses it (objectui#8347), and the +tolerant zod face is `.passthrough()`, so one that arrives as data is kept, judged +by nothing and run by nothing (objectui#6497). `ButtonSchema.onClick` is a runtime slot for a host-supplied function and is refused by name for the same reason. ## Rule: Action Params Use Field Types (Shared Widget Renderer)