diff --git a/.changeset/unknown-key-strictness-ui-batch16.md b/.changeset/unknown-key-strictness-ui-batch16.md new file mode 100644 index 0000000000..b32cf81324 --- /dev/null +++ b/.changeset/unknown-key-strictness-ui-batch16.md @@ -0,0 +1,95 @@ +--- +'@objectstack/spec': major +--- + +Close `AriaProps` against unknown keys, and reclassify `widget` + five `i18n` shapes as no-door (#4001 batch 16, ADR-0078) + +zod's default is `.strip`: a key a schema does not declare is silently discarded +and the parse still succeeds. On an authoring surface that is the worst failure +mode — the author (increasingly, an AI) gets a success envelope and ships +metadata that quietly ignores what they wrote. + +**BREAKING — one shape.** `AriaPropsSchema` (`ui/i18n.zod.ts`) now raises a +named, fixable error instead of dropping the key. It is carried as `aria:` on +roughly thirty live shapes under six metadata-type roots — `ListViewSchema`, +`PageSchema`, `PageComponentSchema`, `DashboardWidgetSchema`, `ChartConfigSchema`, +`ActionSchema`, and twenty SDUI component defs — so this is the highest-fan-out +single site the `ui/` wave has closed. + +**What it was doing.** Through the `view` metadata root, this parsed **clean**: + +```ts +getMetadataTypeSchema('view').parse({ + listViews: { my_view: { type: 'grid', columns: ['name'], + aria: { label: 'Accounts', describedBy: 'accounts-help' } } }, +}) +// → aria: {} +``` + +Both keys gone, reported valid. The accessible name existed in the source file +and nowhere else — a screen-reader user hears the DOM default, and nothing in the +toolchain ever said so. Those two spellings are not hypothetical: they are what +objectui's `ARIA_KEY_ALIASES` normalizer folds at the `ListView` boundary +(objectui#2890), i.e. what stored view metadata actually carries. + +**The renames, each anchored to a named sibling contract.** + +| you wrote | write instead | where the wrong word comes from | +|---|---|---| +| `label` | `ariaLabel` | objectui's stored legacy spelling, folded by `normalizeListViewSchema` | +| `describedBy` | `ariaDescribedBy` | same | +| `ariaRole` | `role` | this shape's own inconsistency — two of its three keys carry the `aria` prefix and `role` does not | + +`arialabel`, `ariaLabell`, `ariadescribedby`, `aria-label` and `roles` are left to +the edit-distance fallback, measured before anything was hand-written: an alias +for a key the fallback already reaches is transcription, not judgement. + +**Two keys get a prescription instead of a rename**, because renaming them would +be wrong (the ledger's finding 7 — this campaign's own fix once signposting the +way into the failure it exists to kill): + +- `live` is real and rendered — by objectui's `ListView` alone, which reads + `schema.aria?.live` and emits `aria-live`. objectui declares it as + `AriaPropsSchema.extend({ live })`, so **that surface keeps accepting it** (and + now inherits this error map for everything else). On any other surface the + message says where `live` IS valid rather than pointing at a declared key that + means something else. Promoting it into the shared shape would advertise + `aria-live` on twenty-nine renderers that do not implement it; the promotion + question is **#5058**. +- `ariaLabelledBy` / `labelledBy` — `aria-labelledby` references another + element's id, which is not the same thing as `ariaLabel` (a literal string), so + there is nothing to rename it to. The gap is named, and is also #5058. + +**A `.strip()` was added to four files this batch did not otherwise touch.** +`animation.zod.ts`, `dnd.zod.ts` (×2), `keyboard.zod.ts` and `touch.zod.ts` build +their config shapes as `z.object({…}).merge(AriaPropsSchema.partial())`, and +`.merge()` adopts the incoming schema's unknown-key posture — so closing +`AriaProps` would have silently closed all five of those shapes too, with zod's +generic message and against #4988's measured verdict that nothing parses them. +The explicit `.strip()` holds their posture; `i18n.test.ts` pins it. + +**Nothing in `ui/widget.zod.ts` changed, and five of `ui/i18n.zod.ts`'s six +shapes were left open** — deliberately, on measurement. The ledger scheduled +`widget` as `authorable (p)` / 9 sites and warned that `i18n`'s label shapes were +"wide-open records by design"; resolving both found something more specific. +`widget.zod.ts` has no authoring door at all: nothing under `packages/spec/src` +imports it except the barrel, a BFS from all 24 metadata-type roots plus +`defineStack` never reaches it, and no `.parse()` on any of its shapes exists in +`objectstack`, `objectui` or `cloud` outside its own tests. The same holds for +`I18nObjectSchema`, `PluralRuleSchema`, `NumberFormatSchema`, `DateFormatSchema` +and `LocaleConfigSchema`. `.strict()` is a property of a parse; there is no parse. +Retiring them or giving them a carrier is ADR-0049 enforce-or-remove, tracked in +**#5055** — not a breaking change to spend here. + +The warning about the open record was aimed one level off, and both levels are +now recorded: `I18nObject.params` is a `z.record` interpolation bag whose key +space is whatever the message template names — openness there is the contract, and +it was never a site this ratchet could close. The config block the map assumed was +open alongside it (`AriaProps`) turned out to be the directory's most widely +carried live shape. + +Zero-breakage evidence: full `@objectstack/spec` suite, `tsc --noEmit`, all ten +spec `check:*` gates, `objectstack validate` on app-showcase / app-crm / app-todo, +and an ADR-0087 direct-parse probe over the three apps' **built** artifacts — +zero `aria` slots present, with the probe's negative control proven red on a +legacy-spelled block. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index eb89b15a33..61a4d367bb 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -472,6 +472,52 @@ dropped at parse, and nothing failed. has 8, and `ui/` as 49 when it has 72. Both the map and the gate now carry the open-site count directly (see the remaining-strip map below). +20. **The DOOR measurement was wrong in the one direction that costs a breaking + change** (批 16; filed as #5056). The counter in finding 19 answers "how many + sites"; this is its opposite number — the instrument that answers "is there + anybody on the other side of them", which is the question the whole + authorable / `no door` split turns on. + + 批 13 built the BFS and 批 15 added a **derived-clone bridge** to it, for a + real reason: `.extend()` / `.strip()` produce a clone that shares no identity + with its base but DOES share the base's per-property schema instances, and + `ChartConfigSchema` is reached exactly that way through `ReportChartSchema`. + The bridge fired when **any one** property matched under the same name. + + Two facts turn that into a false door. Zod's `.describe()` returns a clone + that shares the original `_zod.def` **object** — so every described + `SnakeCaseIdentifierSchema` and `I18nLabelSchema` is def-identical across the + entire spec. And `name` / `label` are two keys almost every authorable shape + here declares. So `WidgetManifestSchema` — in a file **nothing imports** — + measured as REACHABLE on 2 shared keys out of 20, and 批 16 came within one + control of closing nine sites in a dead file: a breaking change spent to + produce *"a precisely validated dead slot — the more convincing lie"* + (#4583), which is the exact artifact the `no door` class was invented to stop + the campaign from manufacturing. + + **The error is one-directional.** A too-eager bridge can only invent a door, + never hide one — so its entire failure budget is spent on making batches + tighten things nothing parses. That is what makes it worth a numbered finding + rather than a fix in passing. + + Two method notes, both of which the campaign has now paid for twice: + + - **The control that catches it is the one nobody writes.** 批 15's near-miss + (`typeof v !== 'object'` skipping every lazy Proxy, which halved the graph) + was caught by a POSITIVE control. This one is invisible to positive + controls — every root still resolved — and needed a NEGATIVE one plus the + synthetic-carrier flip. A door measurement owes all three, in the same run: + a known-live schema, a known-dead one, and an injected carrier that must + flip the verdict. + - **The fix is to ask how much of the shape is shared, not whether anything + is.** A real derived clone carries nearly all of its base's properties; a + coincidence carries one or two of twenty. `ui/door-reachability.testkit.ts` + is now the one implementation, with the threshold justified against both + ends of the measured range (批 15's real derivation far above it, 批 16's + false positive far below). The duplicate copy still living in + `chart.test.ts` is part of #5056 — the campaign's own recurring lesson + about a second copy of the truth, arriving this time in its instruments. + ## Where this ended up **24 of 25 registered types closed** (from 9 when the line started), and the @@ -535,10 +581,10 @@ not verdicts). | `theme.zod.ts` | 14 | authorable | **strict as of #4001 批 15** — all 14 sites. The `(p)` resolved to authorable on two doors, both measured: `stack.zod.ts` declares `themes: z.array(ThemeSchema)` (so `defineStack()` parses every theme on boot and on `objectstack build`), and `defineTheme()` parses one directly. A BFS from all 24 metadata-type roots plus `ObjectStackSchema` reaches every schema in the file, with `PageSchema`/`DashboardSchema`/`ReportSchema`/`WebhookSchema`/`StateMachineSchema` passing as positive controls and 批 13's no-door shapes failing as negative controls **in the same run**. Note what is NOT claimed: `theme` is deliberately absent from `BUILTIN_METADATA_TYPE_SCHEMAS`, so a stored theme row is not validated by the metadata REST door — the gate is the authoring one, and the file says so rather than implying reach it lacks. **The `passthrough` question was asked per BLOCK, not per file**, and the answer split: objectui's `ThemeEngine` reads `colors`/`borderRadius`/`shadows`/`typography.fontFamily` through FIXED maps (an extra key is read by nothing, ever), but spreads `fontSize`/`fontWeight`/`lineHeight`/`letterSpacing`/`duration`/`timing`/`zIndex` with `Object.entries` into `--font-size-` … — the #4909 open shape at the runtime. Closed anyway, on two measurements: `.strip` already discarded those extras before the engine saw them (so no author depends on the openness and nothing the renderer receives changes), and `customVars` is a DECLARED escape hatch that emits an arbitrary CSS custom property by name, so closing the token scales removes no capability and only removes a second, undocumented way to spell one — the way whose typos are indistinguishable from intent. Curation is measured throughout: the shadcn vocabulary (`card`→`surface`, `foreground`→`text`, `destructive`→`error`) comes from objectui's own `COLOR_TO_CSS_MAP`, which RENAMES every palette key on the way out; `md`→`base` on `fontSize` and `base`→`normal` on `fontWeight` are a same-file scale disagreement (`borderRadius`/`shadows` declare `md`, `fontSize` does not); `radius`→`base` because `base` is emitted as the bare `--radius`, the one radius variable objectui's CSS actually reads; and `easeIn`→`ease_in` because `animation.timing` is the file's single snake_case vocabulary, so the camelCase spelling is an author obeying AGENTS.md #3 rather than making a typo. The eight #3494 removals get one distinct tombstone each. ⚠️ **Two of those tombstones deliberately prescribe NO replacement slot**: `touchTarget`/`keyboardNavigation` read like they should point at `ui/touch.zod.ts`/`ui/keyboard.zod.ts`, which 批 13 measured as having no carrier at all (#4988) — prescribing them would walk an author out of a loud rejection into a silent one, the ledger's finding 7. ⚠️ **Separately filed, not answered here**: `--font-size-*`, `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--z-*`, `--duration-*`, `--timing-*`, `--font-heading` and `--font-mono` have ZERO first-party consumers (only the colour vars, `--radius*`, `--shadow*` and `--font-sans` are read). That is ADR-0049 liveness, not unknown keys, and the two must not be run together — strictness makes a dropped key loud, it cannot make a slot live | | `app.zod.ts` | 18 | authorable | **strict as of #4001 PR B** — `AppSchema` + branding / area / context-selector / contribution, and the nav-item union converted to `z.discriminatedUnion('type', …)` (the union-error question, settled empirically: matched-branch-only errors, exact recursive paths, `toJSONSchema` clean). Per-target `params` stay open. PR A (#4142) tombstoned the seven audit-dead keys first | | `dashboard.zod.ts` | 11 | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DashboardWidgetSchema` has been strict since the ADR-0021 cutover; 批 14 closed the two NESTED holes inside it (`compareTo`'s object arm, `layout`), the same strict-shell-over-strip-children silhouette 批 13 found on `page.components[]`. `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch) and the `responsive` tombstone (#4876) is untouched. ⚠️ `compareTo` is a UNION, so its curated prescription is produced but not delivered — `zodIssuesToFields` maps only top-level issues and a failed union collapses to a bare `Invalid input` (#5014). The REJECTION is unaffected | -| `widget.zod.ts` | 9 | authorable (p) | | +| `widget.zod.ts` | 9 | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 16)** — the `(p)` resolved NEGATIVE for the whole file, the second such run after 批 13's five. Three independent measurements on 2026-08-04: (1) nothing under `packages/spec/src` imports this module except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for a widget shape — `field.widget` is a `z.string()` naming a registered *component* and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack` (4 766 nodes) reaches none of the six shapes, while `PageSchema` / `ObjectListViewSchema` resolve in the same run, a fresh `z.object` and a deliberate look-alike both resolve unreachable, and a synthetic carrier flips all six to reachable; (3) zero `.parse()` / `.safeParse()` in `objectstack`, `objectui` or `cloud` outside this file's own tests — objectui re-exports the inferred TYPES only and under different names (`RuntimeWidgetManifest` / `FieldWidgetComponentProps`, #4115 / #3161), and a `cloud` code search returns 0 for every symbol against a working index (`"@objectstack/spec"` → 345). ADR-0049 enforce-or-remove is **#5055**. ⚠️ **The campaign's own BFS said REACHABLE on the first run** — a false positive in the derived-clone bridge, filed as **#5056**: zod's `.describe()` returns a clone that SHARES the original `_zod.def`, so `WidgetManifestSchema.name` / `.label` (a described `SnakeCaseIdentifierSchema` / `I18nLabelSchema`) are def-identical to the same leaves on live schemas, and a bridge firing on ANY one shared property links two unrelated shapes. 2 shared keys of 20. The error is one-directional — it can only manufacture a door, i.e. it can only make a batch tighten something dead. Corrected to whole-shape overlap in `ui/door-reachability.testkit.ts` and pinned in `widget.test.ts` | | `page.zod.ts` | 7 | authorable | partially strict (ADR-0089) | | `chart.zod.ts` | 7 | **mixed — 5 authorable, 2 no gate** | **5 strict as of #4001 批 15**; 2 deliberately left open. `ChartConfigSchema` / `ChartAxis` / `ChartSeries` / `ChartAnnotation` / `ChartInteraction` are `root-graph`-reachable from the `dashboard` and `report` metadata roots (`DashboardWidget.chartConfig`, `ReportChartSchema`), so they are judged on the stored-metadata path and are now closed. **`ChartAggregateSchema` and `ChartGroupBySchema`'s object arm are NOT**, and this is the batch's real finding. They are not 批 13's no-door case — their carrier is LIVE: `aggregate` is a real authorable prop on the react tier's `` (ADR-0081), published in the generated react-blocks contract, and objectui's `ObjectChart` reads `schema.aggregate` to run the query. What is missing is the PARSE: neither schema is reachable from any metadata-type root or from `ObjectStackSchema` (both `UNREACHABLE` in the run where the five above come back `root-graph`), nothing in the three repos calls `.parse()` on them outside this file's unit tests, and the gate that DOES judge an authored `aggregate` — the react-page publish lint — re-derives the rules by hand (`CHART_FUNCTIONS`, the count/field requirement, the result-column naming) and never checks unknown keys. `react-blocks.ts` publishes the prop as a hand-written TYPE STRING; the Zod schema beside it is not what the contract is generated from. So `groupby` / `dateGranularty` are silently dropped today and would go on being silently dropped after a `strictObject` here — `.strict()` is a property of a parse. A fourth class, **`no gate`**: carrier live, parse absent. Distinct from `no door` (批 13), where the carrier itself does not exist. The contract-first fix is to make the publish gate PARSE the schema instead of re-deriving it — a `packages/lint` change, filed rather than smuggled into a spec strictness batch. Recorded in three places (schema-adjacent comment, test pin incl. a standing BFS assertion that goes red the day a carrier key appears, this row). ⚠️ One correction shipped with the tightening: the `clickAction` migration text #3752 wrote into this file prescribed **`drillDown`, which is not a key this protocol declares anywhere** — it is an untyped `(schema as any).drillDown` read inside objectui's `ObjectChart`. Promoting that sentence into a strict rejection would have handed an author the platform's authority for a key the same gate then rejects: finding 7, third occurrence, this time caught before shipping. The prose and the tombstone now name `onSegmentClick` / `ReportSchema.drilldown` / the widget's `options` bag, all of which exist. Filed separately. **`chart` 6 → 7 at the re-measurement** — no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break | -| `i18n.zod.ts` | 6 | authorable (p) | i18n label shapes are wide-open records by design — verify | +| `i18n.zod.ts` | 6 | **split** | **`i18n` SPLITS across two classes (measured, #4001 批 16)** and is the file this table's standing warning was about. The warning said "label shapes are wide-open records by design"; measurement says something more useful. `AriaPropsSchema` is a **real door and is closed** — carried as `aria:` on ~30 live shapes under six metadata-type roots (`ListViewSchema`, `PageSchema`, `PageComponentSchema`, `DashboardWidgetSchema`, `ChartConfigSchema`, `ActionSchema`, 20 SDUI component defs) and directly BFS-reachable. It was stripping in the wild: through the `view` root, `aria: { label: 'Accounts', describedBy: 'x' }` parsed CLEAN and returned `aria: {}`, so the accessible name existed in the source file and nowhere else. The other five (`I18nObject`, `PluralRule`, `NumberFormat`, `DateFormat`, `LocaleConfig`) are **no door** — no carrier, unreachable, zero parse in all three repos; ADR-0049 is #5055. Note `NumberFormat` / `DateFormat` DO have a carrier (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier is itself doorless, so the subtree is `no door`, not `no gate`. And the warning's own subject — the wide-open **record** level — was never one of the six sites: `I18nObject.params` is a `z.record` interpolation bag whose key space is whatever the message template names, so openness there is the contract and there was nothing to close. Pinned in `i18n.zod.ts`'s header, in `i18n.test.ts`, and here | | `responsive.zod.ts` | 4 | authorable | **strict as of #4001 批 13** — all four sites (`ResponsiveConfig`, `ResponsiveStyles`, and the two per-breakpoint maps). This is the one file of batch 13's six whose `(p)` resolved POSITIVE, and it resolved on the graph rather than on the file's face: `page.components[].responsive` / `.responsiveStyles` put both shapes inside the `page` metadata-type root (`dashboard.widgets[].responsive` was the second carrier until #4876 retired it, same day). What the closure bought is the batch's whole argument in one parse — **`PageComponentSchema` has been `.strict()` since ADR-0089 D3a and that never reached these blocks**, so `{ type:'element:text', responsiveStyles: { lg: {…} }, responsive: { colums: {…}, hideOn: [] } }` parsed CLEAN and returned `responsiveStyles: {}, responsive: {}` — every styling and layout instruction the author wrote, gone, reported valid. A strict shell over strip-mode children is a closed surface's silhouette, not a closed surface. The curation is the file's real hazard rather than typos: it carries TWO breakpoint vocabularies sixteen lines apart on the same component (`responsiveStyles`' `large`/`medium`/`small`/`xsmall`, ADR-0065, against `responsive`'s Tailwind `xs`…`2xl`), so the aliases run BOTH ways between them and are anchored to the named sibling, not to edit distance — batch 12's method, and the only thing that can answer `lg` → `large`. Two entries had to be measured rather than reasoned: `{ columns: { large: 4, lg: 3 } }` used to keep HALF the map (the node laid out, at the wrong width, on breakpoints the author never named — worse than a total loss, which is at least visible); and `hideOn` → `hiddenOn` needed a hand-written alias because the distance fallback provably cannot reach it — it lowercases the input but not the candidates, so a capital in a declared key costs an extra edit against a budget of 2, and the all-lowercase `hiddenon` resolves while the correctly-cased `hideOn` does not. That asymmetry is general to camelCase keys, i.e. to most of the spec, and is filed as **#4990**. `StyleMapSchema` stays deliberately OPEN (its key space is every CSS property; objectui's `declarations()` emits whatever it is handed) — recorded in the schema JSDoc, in a test pin, and in this row | | `dataset.zod.ts` | 4 | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DatasetSchema` was strict from the ADR-0021 cutover while the two shapes carrying the actual semantic contract — `DatasetDimension`, `DatasetMeasure` (+ `.derived`) — were not. Curated against the sibling this module's own header names, `data/analytics.zod.ts`'s Cube layer: a Cube metric's `type` IS its aggregation, so `{ name: 'revenue', type: 'sum', field: 'amount' }` parsed clean and computed a `count`; `sql` gets guidance rather than an alias, because aiming `SUM(amount)` at `field` is finding 7's trap | | `animation.zod.ts` / `dnd.zod.ts` / `keyboard.zod.ts` / `touch.zod.ts` / `offline.zod.ts` | 4+4+4+7+3 | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 13)** — the `(p)` resolved NEGATIVE and the row is kept only so the arithmetic stays complete. Three independent measurements on 2026-08-03: (1) nothing under `packages/spec/src` imports these modules except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for them; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` — the closure `build-schemas.ts` uses for the #4650 deletion check — reaches none of the 22 sites, while its three positive controls (`PageSchema`, batch 11's `WebhookSchema`, batch 10's `StateMachineSchema`) all resolve `root-graph` in the same run; (3) no `.parse()` / `.safeParse()` on any of them exists in `objectstack`, `objectui` or the example apps outside their own unit tests — objectui re-exports the inferred TYPES only and says so (#2561). `.strict()` is a property of a PARSE and there is no parse, so closing them would enforce nothing and would spend a v17 breaking change to leave *"a precisely validated dead slot — the more convincing lie"* (the #4583 row below). The live question is ADR-0049 enforce-or-remove, filed as **#4988**; each file's header comment and its test file carry the same verdict (the batch 12 three-places standard). **Do not reschedule these as strictness work** — that is what the `(p)` was for, and it has been answered | @@ -709,16 +755,16 @@ it the same way: the decision is also written beside the schema and pinned in a test (`flow.test.ts`, `etl.test.ts`), because a row in a table is not where the next person to open that file will look. -#### `ui/` — 91 strip of 198 +#### `ui/` — 90 strip of 198 | File | Strip | Sites | Class | Batch | |---|---|---|---|---| | `component.zod.ts` | 29 | 29 | authorable (p) | Largest single block left. SDUI component props — **verify the React-prop open slots first**; `check:react-declaration-parity` compares two DECLARATIONS and cannot tell you which props a renderer reads | | `view.zod.ts` | 20 | 50 | mixed | Top level and the form/page shapes are closed (ADR-0089 + the final batch). Remaining are sub-blocks; `UserFiltersSchema` is the one the last batch **named as deliberately left open** — it strips page-only keys with a test pinning that, so closing it needs its own verification | -| `widget.zod.ts` | 9 | 9 | authorable (p) | Widget manifest + lifecycle/event/property/source | +| `widget.zod.ts` | 9 | 9 | **no door** | ⛔ **not strictness work** — the whole file measured unreachable from every authoring root (#4001 批 16), with no carrier key and zero parse in all three repos. ADR-0049 triage is **#5055**. See the triage row above, including why the campaign's own BFS said otherwise first (**#5056**) | | `chart.zod.ts` | 2 | 7 | **no gate** | `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two are NOT unfinished work — their carrier (``) is live but nothing parses them, so closing them would gate nothing (#4583). Blocked on wiring the react-page publish gate to parse the schema instead of re-deriving it — see the triage row | | `touch.zod.ts` | 7 | 7 | **no door** | ⛔ **not strictness work** — measured unreachable from every authoring root (#4001 批 13); ADR-0049 triage is #4988. See the triage row above | -| `i18n.zod.ts` | 6 | 6 | authorable (p) | ⚠️ the triage row warns label shapes are wide-open records **by design** — verify before closing | +| `i18n.zod.ts` | 5 | 6 | **split** | **批 16 closed the one real door**: `AriaPropsSchema` (`strictObject`, carried as `aria:` on ~30 shapes under six metadata-type roots — it was returning `aria: {}` for a legacy-spelled block). The 5 left are `I18nObject` / `PluralRule` / `NumberFormat` / `DateFormat` / `LocaleConfig`, all **no door** (#5055) — ⛔ **do not close them**. This row shrinks without disappearing, the third such in the ledger after `flow` (批 11) and `etl` (批 12): the reverse pin fires on ZERO, so a row parked at a deliberate floor looks exactly like a row nobody finished, and only the `Class` column separates them | | `animation.zod.ts` | 4 | 4 | **no door** | ⛔ same as `touch` — #4988 | | `dnd.zod.ts` | 4 | 4 | **no door** | ⛔ same as `touch` — #4988 | | `keyboard.zod.ts` | 4 | 4 | **no door** | ⛔ same as `touch` — #4988 | @@ -751,14 +797,38 @@ here each time and would have reported none at all had the batches touched different paragraphs. Every row from every side was kept and the arithmetic redone from them; nothing was resolved in favour of a side. -**Authorable strip in `ui/`: 65 of 91** (was 123 of 123 when the ruling was -written). `app.zod.ts`'s single site is held pending the finding-16 `.extend()` -check rather than counted as ready. **26 of the 91 are the two no-parse classes**: -24 `no door` — `touch` (7), `animation` (4), `dnd` (4), `keyboard` (4) and -`offline` (3) from 批 13, plus `sharing.zod.ts`'s `EmbedConfig` and -`notification.zod.ts`'s `NotificationAction` from 批 14 (#4988, #5015) — and 2 -`no gate`, `chart.zod.ts`'s remaining pair from 批 15. Read the difference before -acting on either: they imply OPPOSITE follow-ups. +**批 16 is the eighth instance, and the first to be caught by the header rather +than by the subtotal.** It computed 118 against a tree where 批 14's and 批 15's +rows still existed — `theme` at 14, `chart` at 7, `dashboard`/`report`/`dataset` +still open, `sharing` at 2 — and every one of those numbers was right against its +own branch. The merge is neither side's. Rows from all sides kept, arithmetic +redone from them. + +批 16 moved the authorable subtotal by 14 while CLOSING exactly one site, and the +gap is the batch's finding rather than a rounding of it: `widget.zod.ts` (9) and +five of `i18n.zod.ts`'s six (`I18nObject`, `PluralRule`, `NumberFormat`, +`DateFormat`, `LocaleConfig`) left `authorable` because their `(p)` resolved +negative. The one closure is the file this map had flagged as most likely to be +deliberately open — `AriaPropsSchema` — which is worth reading twice: **the +standing warning and the measurement pointed in opposite directions**, and the +warning was not wrong so much as aimed one level off. The wide-open record it +described is real (`I18nObject.params`) and was never a site this ratchet could +close; the config block the map assumed was open alongside it turned out to be the +directory's most widely carried live shape (~30 `aria:` carriers under six +metadata-type roots), and it was returning `aria: {}` for a legacy-spelled block. + +**Authorable strip in `ui/`: 50 of 90** (was 123 of 123 when the ruling was +written). Recomputed from the surviving rows at 批 16, not decremented: +29+20+9+2+7+5+4+4+4+3+1+1+1 = 90, of which 40 are the two no-parse classes, so +the authorable half is `component` 29 + `view` 20 + `app` 1 = 50. `app.zod.ts`'s +single site is held pending the finding-16 `.extend()` check rather than counted +as ready. **40 of the 90 are the two no-parse classes**: 38 `no door` — `touch` +(7), `animation` (4), `dnd` (4), `keyboard` (4) and `offline` (3) from 批 13, +`sharing.zod.ts`'s `EmbedConfig` and `notification.zod.ts`'s `NotificationAction` +from 批 14, and `widget.zod.ts` (9) plus `i18n.zod.ts`'s remaining 5 from 批 16 +(#4988, #5015, #5055) — and 2 `no gate`, `chart.zod.ts`'s remaining pair from +批 15. Read the difference before acting on either: they imply OPPOSITE +follow-ups. ## What the three `ui/` batches measured, and why the answers differ diff --git a/packages/spec/src/ui/animation.zod.ts b/packages/spec/src/ui/animation.zod.ts index 616dc2b33e..a1424cd378 100644 --- a/packages/spec/src/ui/animation.zod.ts +++ b/packages/spec/src/ui/animation.zod.ts @@ -121,7 +121,14 @@ export const ComponentAnimationSchema = lazySchema(() => z.object({ trigger: AnimationTriggerSchema.optional().describe('When to trigger the animation'), reducedMotion: z.enum(['respect', 'disable', 'alternative']).default('respect') .describe('Accessibility: how to handle prefers-reduced-motion'), -}).merge(AriaPropsSchema.partial()).describe('Component-level animation configuration')); +}).merge(AriaPropsSchema.partial()) + // `.strip()` is LOAD-BEARING (#4001 批 16): `AriaPropsSchema` became `strictObject` and + // `.merge()` adopts the incoming schema's unknown-key posture, so without this the shape + // would silently become `.strict()` — with zod's generic message, not the campaign's — and + // would contradict this file's measured `no door` verdict (#4988: nothing parses it, so a + // strict shell enforces nothing). Keep it until #4988 says what happens to this file. + .strip() + .describe('Component-level animation configuration')); export type ComponentAnimation = z.infer; diff --git a/packages/spec/src/ui/dnd.zod.ts b/packages/spec/src/ui/dnd.zod.ts index 9e44b4a39d..eddd86f0db 100644 --- a/packages/spec/src/ui/dnd.zod.ts +++ b/packages/spec/src/ui/dnd.zod.ts @@ -92,7 +92,14 @@ export const DropZoneSchema = lazySchema(() => z.object({ maxItems: z.number().optional().describe('Maximum items allowed in drop zone'), highlightOnDragOver: z.boolean().default(true).describe('Highlight drop zone when dragging over'), dropEffect: DropEffectSchema.default('move').describe('Visual effect on drop'), -}).merge(AriaPropsSchema.partial()).describe('Drop zone configuration')); +}).merge(AriaPropsSchema.partial()) + // `.strip()` is LOAD-BEARING (#4001 批 16): `AriaPropsSchema` became `strictObject` and + // `.merge()` adopts the incoming schema's unknown-key posture, so without this the shape + // would silently become `.strict()` — with zod's generic message, not the campaign's — and + // would contradict this file's measured `no door` verdict (#4988: nothing parses it, so a + // strict shell enforces nothing). Keep it until #4988 says what happens to this file. + .strip() + .describe('Drop zone configuration')); export type DropZone = z.infer; @@ -107,7 +114,14 @@ export const DragItemSchema = lazySchema(() => z.object({ constraint: DragConstraintSchema.optional().describe('Drag movement constraints'), preview: z.enum(['element', 'custom', 'none']).default('element').describe('Drag preview type'), disabled: z.boolean().default(false).describe('Disable dragging'), -}).merge(AriaPropsSchema.partial()).describe('Draggable item configuration')); +}).merge(AriaPropsSchema.partial()) + // `.strip()` is LOAD-BEARING (#4001 批 16): `AriaPropsSchema` became `strictObject` and + // `.merge()` adopts the incoming schema's unknown-key posture, so without this the shape + // would silently become `.strict()` — with zod's generic message, not the campaign's — and + // would contradict this file's measured `no door` verdict (#4988: nothing parses it, so a + // strict shell enforces nothing). Keep it until #4988 says what happens to this file. + .strip() + .describe('Draggable item configuration')); export type DragItem = z.infer; diff --git a/packages/spec/src/ui/door-reachability.testkit.ts b/packages/spec/src/ui/door-reachability.testkit.ts new file mode 100644 index 0000000000..7937162125 --- /dev/null +++ b/packages/spec/src/ui/door-reachability.testkit.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The #4001 campaign's door measurement, as ONE implementation. + * + * "Is this schema reachable from an authoring root?" is the question that + * decides a batch's whole verdict — `strictObject` when the answer is yes, + * `no door` (reclassify, do not tighten) when it is no. Getting it wrong in the + * false-positive direction spends a breaking change to produce *"a precisely + * validated dead slot — the more convincing lie"* (#4583). + * + * Not exported from `ui/index.ts` and not a tsup entry, so it never reaches the + * package's public surface; `*.testkit.ts` is also outside the strictness + * ledger's `*.zod.ts` walk, so it adds no site to any count. + * + * ## Why the obvious walk is wrong twice + * + * Both corrections below were found by a control going red, not by reading: + * + * 1. **`typeof v !== 'object'` silently halves the graph.** `lazySchema`'s Proxy + * target is `function lazyZod() {}`, so every lazy schema is + * `typeof 'function'`. Skipping those makes the BFS stop at the first lazy + * node and report whole families unreachable (批 15; `build-schemas.ts`'s + * equivalent never hit it because it runs under `OS_EAGER_SCHEMAS=1`, where + * there are no proxies). + * 2. **A single shared property is NOT evidence of a derived clone** — this is + * #5056, found at 批 16. `.extend()` / `.strip()` produce a clone that shares + * no identity with its base but DOES share the base's per-property schema + * instances, so a bridge over shared property defs is genuinely needed. The + * bridge as first written fired when **any one** property matched under the + * same name — and zod's `.describe()` returns a clone that shares the + * original `_zod.def` OBJECT, which makes every described + * `SnakeCaseIdentifierSchema` / `I18nLabelSchema` def-identical across the + * whole spec. Two unrelated shapes that both declare `name` and `label` (i.e. + * almost every authorable shape here) therefore bridged, and + * `WidgetManifestSchema` — a file nothing imports — measured as REACHABLE. + * The error is one-directional: it can only produce a false door, i.e. it can + * only cause a batch to tighten something dead. + * + * The fix is to ask how much of the shape is shared rather than whether + * anything is: a real derived clone carries nearly all of its base's + * properties, while a coincidence carries one or two out of twenty. + */ + +import { getMetadataTypeSchema, listMetadataTypeSchemaTypes } from '../kernel/metadata-type-schemas'; +import { ObjectStackSchema } from '../stack.zod'; + +/** + * Identity of a schema NODE. + * + * Keyed on `_zod.def`, never on the schema binding: `lazySchema` hands out a + * Proxy unless `OS_EAGER_SCHEMAS=1` while the graph holds the real instances, so + * comparing bindings reports every root as unreachable. `def` survives the Proxy + * (the `_zod` facade delegates to the real internals), so it is the one stable + * key for both identities. + */ +const defOf = (s: unknown): unknown => (s as { _zod?: { def?: unknown } })?._zod?.def; + +const shapeOf = (node: unknown): Record | null => { + const def = defOf(node) as { type?: string; shape?: Record } | undefined; + return def?.type === 'object' && def.shape ? def.shape : null; +}; + +function childrenOf(node: unknown): unknown[] { + const out: unknown[] = []; + const seen = new Set(); + const walk = (v: unknown): void => { + // See correction 1 in the module doc: `typeof v !== 'object'` alone skips + // every lazy schema, because the Proxy's target is a function. + if (v === null || (typeof v !== 'object' && typeof v !== 'function') || seen.has(v)) return; + seen.add(v); + if (defOf(v)) { out.push(v); return; } + if (Array.isArray(v)) { for (const x of v) walk(x); return; } + if (v instanceof Map) { for (const x of v.values()) walk(x); return; } + for (const x of Object.values(v as Record)) walk(x); + }; + walk(defOf(node)); + return out; +} + +/** How a schema was (or was not) reached from the authoring roots. */ +export type DoorVerdict = 'direct' | 'derived-clone' | 'unreachable'; + +export interface DoorMeasurement { + /** `direct` / `derived-clone` mean there IS a door; `unreachable` means there is not. */ + verdict: (schema: unknown) => DoorVerdict; + /** Fraction of the candidate's own shape shared with the best-matching visited object. */ + cloneOverlap: (schema: unknown) => number; + /** Nodes walked — a sanity floor for "the graph actually got built". */ + nodeCount: number; + /** Roots walked from. */ + rootCount: number; +} + +/** + * A schema is treated as a derived clone when it shares at least this much of + * its own shape, by property def identity under the same name, with one visited + * object node. + * + * Chosen against both ends of the measured range rather than by taste: 批 15's + * real derivation (`ChartConfigSchema` reached through `ReportChartSchema`, + * which re-narrows two of its keys) sits far above it, and 批 16's false + * positive (`WidgetManifestSchema`, 2 shared keys of 20 — `name` and `label`, + * both shared LEAVES rather than shared structure) sits far below. + */ +const DERIVED_CLONE_MIN_OVERLAP = 0.5; + +/** + * BFS the in-memory Zod graph from every metadata-type root plus `defineStack`'s + * `ObjectStackSchema` — the closure `build-schemas.ts` uses for the #4650 + * deletion check. + * + * `extraRoots` exists for the control every measurement owes: inject a synthetic + * carrier for the schema under test and the verdict MUST flip. Without it, + * "unreachable" and "the walker is broken" are the same output. + */ +export function measureDoors(extraRoots: readonly unknown[] = []): DoorMeasurement { + const roots: unknown[] = []; + for (const type of listMetadataTypeSchemaTypes()) { + const s = getMetadataTypeSchema(type); + if (s) roots.push(s); + } + roots.push(ObjectStackSchema, ...extraRoots); + + const visitedDefs = new Set(); + const visitedShapes: Array> = []; + const queue = [...roots]; + while (queue.length > 0) { + const node = queue.pop(); + const def = defOf(node); + if (!def || visitedDefs.has(def)) continue; + visitedDefs.add(def); + const shape = shapeOf(node); + if (shape) visitedShapes.push(shape); + for (const child of childrenOf(node)) queue.push(child); + } + + const cloneOverlap = (schema: unknown): number => { + const shape = shapeOf(schema); + if (!shape) return 0; + const entries = Object.entries(shape); + if (entries.length === 0) return 0; + let best = 0; + for (const visited of visitedShapes) { + let shared = 0; + for (const [name, prop] of entries) { + const d = defOf(prop); + if (d && defOf(visited[name]) === d) shared++; + } + if (shared > best) best = shared; + } + return best / entries.length; + }; + + const verdict = (schema: unknown): DoorVerdict => { + const def = defOf(schema); + if (!def) return 'unreachable'; + if (visitedDefs.has(def)) return 'direct'; + return cloneOverlap(schema) >= DERIVED_CLONE_MIN_OVERLAP ? 'derived-clone' : 'unreachable'; + }; + + return { verdict, cloneOverlap, nodeCount: visitedDefs.size, rootCount: roots.length }; +} diff --git a/packages/spec/src/ui/i18n.test.ts b/packages/spec/src/ui/i18n.test.ts index f40d76eab7..e591fdb9c7 100644 --- a/packages/spec/src/ui/i18n.test.ts +++ b/packages/spec/src/ui/i18n.test.ts @@ -14,6 +14,13 @@ import { type PluralRule, type LocaleConfig, } from './i18n.zod'; +import { measureDoors } from './door-reachability.testkit'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { PageSchema } from './page.zod'; +import { ComponentAnimationSchema } from './animation.zod'; +import { DropZoneSchema, DragItemSchema } from './dnd.zod'; +import { KeyboardNavigationConfigSchema } from './keyboard.zod'; +import { TouchInteractionSchema } from './touch.zod'; describe('I18nObjectSchema', () => { it('should accept valid i18n object with key only', () => { @@ -245,3 +252,184 @@ describe('LocaleConfigSchema', () => { expect(() => LocaleConfigSchema.parse({})).toThrow(); }); }); + +// ============================================================================ +// #4001 批 16 — the SPLIT verdict for this file, both halves pinned. +// +// `AriaPropsSchema` is closed on a measured door; the other five shapes are +// `no door` and stay open. Both halves can regress, in OPPOSITE directions: +// the closed half by someone reopening it, the open half by a later sweep +// "finishing the file" with a `strictObject` that gates nothing (#4583). The +// same split is recorded in `i18n.zod.ts`'s header and in the ui/ row of +// `docs/audits/2026-07-unknown-key-strictness-ledger.md`. +// ============================================================================ + +/** Reject `value` and hand back the serialized issues, so a pin reads what an author would. */ +function reject(schema: { safeParse: (v: unknown) => { success: boolean; error?: { issues: unknown } } }, value: unknown): string { + const r = schema.safeParse(value); + expect(r.success, `expected ${JSON.stringify(value)} to be REJECTED`).toBe(false); + return JSON.stringify(r.error?.issues ?? []); +} + +describe('#4001 批 16 — AriaPropsSchema is closed (the door is real)', () => { + it('the controls parse — this suite fails closed, not by rejecting everything', () => { + expect(AriaPropsSchema.safeParse({}).success).toBe(true); + expect(AriaPropsSchema.safeParse({ ariaLabel: 'Close dialog' }).success).toBe(true); + expect(AriaPropsSchema.safeParse({ ariaLabel: 'x', ariaDescribedBy: 'y', role: 'dialog' }).success).toBe(true); + }); + + it('rejects an undeclared key and names the surface', () => { + const msg = reject(AriaPropsSchema as never, { ariaLabel: 'x', notAnAriaKey: 1 }); + expect(msg).toContain('notAnAriaKey'); + expect(msg).toContain('these ARIA attributes'); + }); + + // ---- the door: a strict schema nobody parses gates nothing -------------- + it('binds through the `view` metadata root, at the real carrier slot path', () => { + const view = getMetadataTypeSchema('view'); + expect(view, 'the view root must resolve — this is the parse door').toBeTruthy(); + const doc = (aria: unknown) => ({ listViews: { my_view: { type: 'grid', columns: ['name'], aria } } }); + + const control = view!.safeParse(doc({ ariaLabel: 'Accounts' })); + expect(control.success, 'control: a declared key still parses through the root').toBe(true); + + const r = view!.safeParse(doc({ ariaLabel: 'Accounts', describedBy: 'accounts-help' })); + expect(r.success).toBe(false); + expect(JSON.stringify(r.error?.issues)).toContain('listViews'); + }); + + it('is what was silently stripping BEFORE this batch — the legacy pair vanished whole', () => { + // Regression narrative, asserted rather than told: this exact document used + // to parse clean and come back `aria: {}`. The accessible name the author + // wrote existed in the source file and nowhere else. + const msg = reject(AriaPropsSchema as never, { label: 'Accounts', describedBy: 'accounts-help' }); + expect(msg).toContain('`label` → `ariaLabel`'); + expect(msg).toContain('`describedBy` → `ariaDescribedBy`'); + }); + + // ---- curation, each entry anchored to a NAMED sibling contract ---------- + it('renames objectui\'s stored legacy spellings (its own ARIA_KEY_ALIASES)', () => { + expect(reject(AriaPropsSchema as never, { label: 'x' })).toContain('`label` → `ariaLabel`'); + expect(reject(AriaPropsSchema as never, { describedBy: 'x' })).toContain('`describedBy` → `ariaDescribedBy`'); + }); + + it('renames the shape\'s own over-generalised prefix (`ariaRole` → `role`)', () => { + expect(reject(AriaPropsSchema as never, { ariaRole: 'dialog' })).toContain('`ariaRole` → `role`'); + }); + + it('leaves the reachable typos to edit distance rather than hand-writing them', () => { + // Measured before any alias was written: these four the fallback already + // reaches, so an alias entry for them would be transcription, not judgement. + expect(reject(AriaPropsSchema as never, { arialabel: 'x' })).toContain('`arialabel` → `ariaLabel`'); + expect(reject(AriaPropsSchema as never, { ariaLabell: 'x' })).toContain('`ariaLabell` → `ariaLabel`'); + expect(reject(AriaPropsSchema as never, { ariadescribedby: 'x' })).toContain('`ariadescribedby` → `ariaDescribedBy`'); + expect(reject(AriaPropsSchema as never, { roles: 'x' })).toContain('`roles` → `role`'); + }); + + // ---- the two protocol gaps, prescribed WITHOUT a wrong rename ---------- + it('`live` gets a prescription, never a rename — it is a key this schema cannot accept', () => { + // The ledger's finding 7: this campaign's own fix once signposted the way + // into the failure mode it exists to kill. `live` is real and rendered — by + // objectui's ListView alone — so the message says where it IS valid instead + // of pointing at a declared key that means something else. + const msg = reject(AriaPropsSchema as never, { live: 'polite' }); + expect(msg).toContain('objectui'); + expect(msg).toContain('#5058'); + expect(msg, 'must not suggest a rename for a key with no correct target').not.toContain('`live` →'); + }); + + it('never offers `live` as a suggestion for a NEIGHBOURING typo either', () => { + // `extraKeys: ['live']` would have made the objectui-extended surface's + // suggestions richer and this surface's suggestions WRONG. It is deliberately + // not used: `live` is not a key this schema accepts. + for (const typo of ['liv', 'lives', 'Live']) { + expect(reject(AriaPropsSchema as never, { [typo]: 'polite' })).not.toContain('→ `live`'); + } + }); + + it('`aria-labelledby` names the gap instead of renaming to a different concept', () => { + for (const key of ['ariaLabelledBy', 'labelledBy']) { + const msg = reject(AriaPropsSchema as never, { [key]: 'other-element' }); + expect(msg).toContain('#5058'); + expect(msg, 'labelledby references an id; ariaLabel is a literal string').not.toContain(`\`${key}\` → \`ariaLabel\``); + } + }); + + // ---- what the strictness RIDES onto, in both directions ----------------- + it('rides `.extend()` onto objectui\'s list-view aria — which is why `live` still works there', () => { + // objectui declares `SpecAriaPropsSchema.extend({ live })`. `.extend()` + // inherits `.strict()` AND the error map, so that surface accepts exactly + // `ariaLabel | ariaDescribedBy | role | live` and rejects the rest with this + // batch's message. Asserted on a local reconstruction because objectui is a + // separate repo — the mechanic is what matters and it is the finding-16 one. + const objectuiAria = AriaPropsSchema.extend({ live: z.enum(['polite', 'assertive', 'off']).optional() }); + expect(objectuiAria.safeParse({ ariaLabel: 'x', live: 'polite' }).success).toBe(true); + expect(reject(objectuiAria as never, { ariaLabel: 'x', bogus: 1 })).toContain('these ARIA attributes'); + }); + + it('does NOT ride `.merge()` into the four no-door files — they stay open', () => { + // `X.merge(AriaPropsSchema.partial())` adopts the INCOMING posture, so + // closing this shape silently closed `animation` / `dnd` / `keyboard` / + // `touch` too — with zod's generic message, no changeset, and against + // #4988's measured verdict that nothing parses them. The explicit `.strip()` + // in those four files is what holds this line; this is its pin. + expect(ComponentAnimationSchema.safeParse({ name: 'a', notAnAnimationKey: 1 }).success).toBe(true); + expect(DropZoneSchema.safeParse({ accept: ['card'], notADropZoneKey: 1 }).success).toBe(true); + expect(DragItemSchema.safeParse({ type: 'card', notADraggableKey: 1 }).success).toBe(true); + expect(KeyboardNavigationConfigSchema.safeParse({ notAKeyboardKey: 1 }).success).toBe(true); + expect(TouchInteractionSchema.safeParse({ notATouchKey: 1 }).success).toBe(true); + }); +}); + +describe('#4001 批 16 — the other five shapes have no authoring door', () => { + const NO_DOOR: Array<[string, unknown]> = [ + ['I18nObjectSchema', I18nObjectSchema], + ['PluralRuleSchema', PluralRuleSchema], + ['NumberFormatSchema', NumberFormatSchema], + ['DateFormatSchema', DateFormatSchema], + ['LocaleConfigSchema', LocaleConfigSchema], + ]; + + it('measures: AriaProps reachable, the other five not — controls in the same run', () => { + const { verdict, nodeCount, rootCount } = measureDoors(); + expect(rootCount).toBeGreaterThan(20); + expect(nodeCount).toBeGreaterThan(1000); + expect(verdict(PageSchema), 'positive control').toBe('direct'); + expect(verdict(AriaPropsSchema), 'the half of this file that HAS a door').toBe('direct'); + expect(verdict(z.object({ a: z.string() })), 'negative control').toBe('unreachable'); + for (const [name, schema] of NO_DOOR) { + expect(verdict(schema), `${name} must have no door`).toBe('unreachable'); + } + }); + + it('a synthetic carrier flips all five — the verdict is the graph, not the walker', () => { + const carrier = z.object({ + i18nObject: I18nObjectSchema, + plural: PluralRuleSchema, + numberFormat: NumberFormatSchema, + dateFormat: DateFormatSchema, + locale: LocaleConfigSchema, + }); + const { verdict } = measureDoors([carrier]); + for (const [name, schema] of NO_DOOR) { + expect(verdict(schema), `${name} must become reachable once something carries it`).toBe('direct'); + } + }); + + it('they still accept undeclared keys — this pins "open", not "broken"', () => { + expect(I18nObjectSchema.safeParse({ key: 'k', notAnI18nKey: 1 }).success).toBe(true); + expect(PluralRuleSchema.safeParse({ key: 'k', other: 'x', notAPluralForm: 1 }).success).toBe(true); + expect(NumberFormatSchema.safeParse({ notANumberFormatKey: 1 }).success).toBe(true); + expect(DateFormatSchema.safeParse({ notADateFormatKey: 1 }).success).toBe(true); + expect(LocaleConfigSchema.safeParse({ code: 'en-US', notALocaleKey: 1 }).success).toBe(true); + }); + + it('`I18nObject.params` stays a record ON PURPOSE — openness there is the contract', () => { + // The remeasure's standing warning for this file. `params` is an + // interpolation bag whose key space is whatever the message template names; + // it is not a site this ratchet could close and must not become one. + const r = I18nObjectSchema.safeParse({ key: 'items.count', params: { count: 5, anything: 'at all', ok: true } }); + expect(r.success).toBe(true); + expect((r.data as { params?: Record }).params).toEqual({ count: 5, anything: 'at all', ok: true }); + }); +}); diff --git a/packages/spec/src/ui/i18n.zod.ts b/packages/spec/src/ui/i18n.zod.ts index 429091f2f7..1d256d9810 100644 --- a/packages/spec/src/ui/i18n.zod.ts +++ b/packages/spec/src/ui/i18n.zod.ts @@ -2,6 +2,36 @@ import { z } from 'zod'; +// ⚠️ #4001 批 16 — this file SPLITS across the ledger's classes. Read before editing. +// +// `AriaPropsSchema` is a REAL DOOR and is closed (`strictObject`). Every other +// shape here is `no door` and must NOT be tightened — see the per-schema notes. +// +// The split was measured on 2026-08-04, three independent ways with positive AND +// negative controls in the same run (`i18n.test.ts` pins both halves): +// +// - `AriaPropsSchema` is carried as `aria:` on ~30 live shapes across six +// metadata-type roots — `ListViewSchema`, `PageSchema`, `PageComponentSchema`, +// `DashboardWidgetSchema`, `ChartConfigSchema`, `ActionSchema`, and 20 SDUI +// component defs — and a BFS from all 24 roots plus `defineStack` reaches it +// directly. It was silently stripping: through the `view` root, +// `aria: { label: 'Accounts', describedBy: 'x' }` parsed CLEAN and returned +// `aria: {}`, so the accessible name the author wrote simply did not exist. +// - `I18nObjectSchema` / `PluralRuleSchema` / `NumberFormatSchema` / +// `DateFormatSchema` / `LocaleConfigSchema` have no carrier key anywhere, are +// unreachable in that same BFS, and are never parsed in `objectstack`, +// `objectui` or `cloud` outside this file's own tests. `.strict()` is a +// property of a PARSE; with no parse it enforces nothing and only makes a dead +// slot look load-bearing (#4583). ADR-0049 enforce-or-remove is #5055. +// +// Note `NumberFormatSchema` / `DateFormatSchema` DO have a carrier +// (`LocaleConfig.numberFormat` / `.dateFormat`) — but the carrier is itself +// doorless, so the whole subtree is `no door`, not `no gate`. +// +// The `z.record` slots stay open ON PURPOSE and are not sites this ratchet can +// close: `I18nObject.params` is an interpolation bag whose key space is whatever +// the message template names. Openness there is the contract. + /** * I18n Object Schema * Structured internationalization label with translation key and parameters. @@ -16,6 +46,7 @@ import { z } from 'zod'; * ``` */ import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; export const I18nObjectSchema = lazySchema(() => z.object({ /** Translation key (e.g., "views.task_list.label", "apps.crm.description") */ key: z.string().describe('Translation key (e.g., "views.task_list.label")'), @@ -47,16 +78,28 @@ export const I18nLabelSchema = lazySchema(() => z.string().describe('Display lab export type I18nLabel = z.infer; +// The one closed shape in this file (#4001 批 16). Everything below `AriaProps` +// stays open on the `no door` verdict recorded at the top. +// +// Curation is anchored to NAMED SIBLING CONTRACTS, never to edit distance — +// each entry below was measured against a real producer, and the distance +// fallback was measured first so nothing is hand-written that it already reaches +// (it reaches `arialabel`, `ariaLabell`, `ariadescribedby`, `aria-label`, +// `roles`; it does NOT reach any of the four aliases here). +const ARIA_HISTORY = + 'Until #4001 closed this shape an unknown key was dropped silently — the component still ' + + 'rendered, with no accessible name and nothing to say the one you wrote had been discarded.'; + /** * ARIA Accessibility Properties Schema - * + * * Common ARIA attributes for UI components to support screen readers * and assistive technologies. - * + * * Aligned with WAI-ARIA 1.2 specification. - * + * * @see https://www.w3.org/TR/wai-aria-1.2/ - * + * * @example * ```typescript * const aria: AriaProps = { @@ -66,7 +109,44 @@ export type I18nLabel = z.infer; * }; * ``` */ -export const AriaPropsSchema = lazySchema(() => z.object({ +export const AriaPropsSchema = lazySchema(() => strictObject({ + surface: 'these ARIA attributes', + history: ARIA_HISTORY, + aliases: { + // objectui's `ARIA_KEY_ALIASES` (`packages/core/src/utils/normalize-list-view.ts`) + // is the measured source for these two: they are the spellings stored view + // metadata really carries, folded onto the canonical keys at the ListView + // boundary (objectui#2890). A view authored the legacy way reached this + // schema and lost BOTH keys — `aria: {}` — so the accessible name existed in + // the source file and nowhere else. + label: 'ariaLabel', + describedBy: 'ariaDescribedBy', + // Two of this shape's three keys carry the `aria` prefix and `role` does not. + // An author who has just written `ariaLabel` and `ariaDescribedBy` generalises + // to `ariaRole`; that is the shape's own inconsistency, not a typo, and the + // distance fallback provably cannot bridge it (measured). + ariaRole: 'role', + }, + guidance: { + // NOT an alias — `live` is not a key this schema accepts, and suggesting one + // the schema cannot accept is the ledger's finding 7 (this campaign's own fix + // signposting the way into the failure it exists to kill). + live: '`live` is objectui\'s LIST-VIEW-ONLY extension, declared there as ' + + '`AriaPropsSchema.extend({ live })` and read by `ListView` alone — this shared shape is ' + + 'carried by ~30 renderers and only one of them applies `aria-live`, so declaring it here ' + + 'would advertise a capability the other 29 do not deliver. On an objectui list view the key ' + + 'is valid as-is; anywhere else, drop it. Promoting it into the protocol is #5058.', + // `aria-labelledby` REFERENCES another element's id; `ariaLabel` is a literal + // string. Renaming between them would be a wrong prescription, so this names + // the gap instead of pretending there is a target. + ariaLabelledBy: '`aria-labelledby` has no counterpart in this protocol — it references another ' + + 'element\'s id, which is not the same thing as `ariaLabel` (a literal accessible name), so ' + + 'there is nothing to rename it to. Use `ariaLabel` only if a literal string is what you meant. ' + + 'Declaring the referencing form is #5058.', + labelledBy: '`aria-labelledby` has no counterpart in this protocol — see `ariaLabelledBy`. ' + + 'Use `ariaLabel` only if a literal accessible name is what you meant; #5058 tracks the gap.', + }, +}, { /** Accessible label for screen readers */ ariaLabel: I18nLabelSchema.optional().describe('Accessible label for screen readers (WAI-ARIA aria-label)'), diff --git a/packages/spec/src/ui/keyboard.zod.ts b/packages/spec/src/ui/keyboard.zod.ts index f6bee5e5a4..23d01a31b1 100644 --- a/packages/spec/src/ui/keyboard.zod.ts +++ b/packages/spec/src/ui/keyboard.zod.ts @@ -97,6 +97,13 @@ export const KeyboardNavigationConfigSchema = lazySchema(() => z.object({ focusManagement: FocusManagementSchema.optional().describe('Focus and tab order management'), rovingTabindex: z.boolean().default(false) .describe('Enable roving tabindex pattern for composite widgets'), -}).merge(AriaPropsSchema.partial()).describe('Keyboard navigation and shortcut configuration')); +}).merge(AriaPropsSchema.partial()) + // `.strip()` is LOAD-BEARING (#4001 批 16): `AriaPropsSchema` became `strictObject` and + // `.merge()` adopts the incoming schema's unknown-key posture, so without this the shape + // would silently become `.strict()` — with zod's generic message, not the campaign's — and + // would contradict this file's measured `no door` verdict (#4988: nothing parses it, so a + // strict shell enforces nothing). Keep it until #4988 says what happens to this file. + .strip() + .describe('Keyboard navigation and shortcut configuration')); export type KeyboardNavigationConfig = z.infer; diff --git a/packages/spec/src/ui/touch.zod.ts b/packages/spec/src/ui/touch.zod.ts index 261aeade11..c50f03a986 100644 --- a/packages/spec/src/ui/touch.zod.ts +++ b/packages/spec/src/ui/touch.zod.ts @@ -140,6 +140,13 @@ export const TouchInteractionSchema = lazySchema(() => z.object({ gestures: z.array(GestureConfigSchema).optional().describe('Configured gesture recognizers'), touchTarget: TouchTargetConfigSchema.optional().describe('Touch target sizing and hit area'), hapticFeedback: z.boolean().optional().describe('Enable haptic feedback on touch interactions'), -}).merge(AriaPropsSchema.partial()).describe('Touch and gesture interaction configuration')); +}).merge(AriaPropsSchema.partial()) + // `.strip()` is LOAD-BEARING (#4001 批 16): `AriaPropsSchema` became `strictObject` and + // `.merge()` adopts the incoming schema's unknown-key posture, so without this the shape + // would silently become `.strict()` — with zod's generic message, not the campaign's — and + // would contradict this file's measured `no door` verdict (#4988: nothing parses it, so a + // strict shell enforces nothing). Keep it until #4988 says what happens to this file. + .strip() + .describe('Touch and gesture interaction configuration')); export type TouchInteraction = z.infer; diff --git a/packages/spec/src/ui/widget.test.ts b/packages/spec/src/ui/widget.test.ts index b05ef5515f..f115bc32b7 100644 --- a/packages/spec/src/ui/widget.test.ts +++ b/packages/spec/src/ui/widget.test.ts @@ -1,8 +1,18 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; import { FieldWidgetPropsSchema, type FieldWidgetProps } from './widget.zod'; -import { WidgetManifestSchema, type WidgetManifest } from './widget.zod'; +import { + WidgetManifestSchema, + WidgetLifecycleSchema, + WidgetEventSchema, + WidgetPropertySchema, + WidgetSourceSchema, + type WidgetManifest, +} from './widget.zod'; import { Field } from '../data/field.zod'; +import { measureDoors } from './door-reachability.testkit'; +import { PageSchema } from './page.zod'; +import { ObjectListViewSchema } from './view.zod'; describe('FieldWidgetPropsSchema', () => { describe('Valid Widget Props', () => { @@ -366,3 +376,80 @@ describe('Widget — retired performance (#3896 close-out)', () => { expect(message).toMatch(/#3896/); }); }); + +// ============================================================================ +// #4001 批 16 — the `no door` verdict for this WHOLE FILE, pinned. +// +// This file was scheduled as `authorable (p)` / 9 sites and resolved NEGATIVE. +// The pin exists because the verdict regresses in one specific way: a later +// sweep "finishing the ui/ directory" wraps these nine sites in `strictObject`, +// spends a breaking change, and gates nothing (#4583). Same verdict is recorded +// in this file's header comment and in the ui/ row of +// `docs/audits/2026-07-unknown-key-strictness-ledger.md` — the three-places +// standard, because a row in a table is not where the next person looks. +// +// ADR-0049 enforce-or-remove for these shapes is #5055. +// ============================================================================ +describe('#4001 批 16 — widget.zod.ts has no authoring door', () => { + const SHAPES: Array<[string, unknown]> = [ + ['WidgetManifestSchema', WidgetManifestSchema], + ['WidgetLifecycleSchema', WidgetLifecycleSchema], + ['WidgetEventSchema', WidgetEventSchema], + ['WidgetPropertySchema', WidgetPropertySchema], + ['WidgetSourceSchema', WidgetSourceSchema], + ['FieldWidgetPropsSchema', FieldWidgetPropsSchema], + ]; + + it('is unreachable from all 24 metadata-type roots and defineStack', () => { + const { verdict, nodeCount, rootCount } = measureDoors(); + + // Controls FIRST, in the same run. An empty result and a broken walker + // produce the same output, and only these tell them apart. + expect(rootCount, 'roots must include every metadata type plus ObjectStackSchema').toBeGreaterThan(20); + expect(nodeCount, 'the graph must actually have been walked').toBeGreaterThan(1000); + expect(verdict(PageSchema), 'positive control').toBe('direct'); + expect(verdict(ObjectListViewSchema), 'positive control').toBe('direct'); + expect(verdict(z.object({ a: z.string() })), 'negative control').toBe('unreachable'); + + for (const [name, schema] of SHAPES) { + expect(verdict(schema), `${name} must have no door`).toBe('unreachable'); + } + }); + + it('a synthetic carrier flips every one of them — the verdict is the graph, not the walker', () => { + // Without this the assertion above is satisfiable by a walker that reaches + // nothing at all. 批 15 shipped exactly that shape of vacuous pin once. + const carrier = z.object({ + manifest: WidgetManifestSchema, + lifecycle: WidgetLifecycleSchema, + event: WidgetEventSchema, + property: WidgetPropertySchema, + source: WidgetSourceSchema, + props: FieldWidgetPropsSchema, + }); + const { verdict } = measureDoors([carrier]); + for (const [name, schema] of SHAPES) { + expect(verdict(schema), `${name} must become reachable once something carries it`).toBe('direct'); + } + }); + + it('#5056 — the OLD any-one-shared-property bridge would have called this file reachable', () => { + // The regression pin for the instrument defect this batch found. zod's + // `.describe()` returns a clone sharing the original `_zod.def`, so + // `WidgetManifestSchema.name` (a described SnakeCaseIdentifierSchema) and + // `.label` (a described I18nLabelSchema) are def-identical to the same + // leaves on live schemas. Two keys out of twenty is a coincidence, not a + // derivation — assert the OVERLAP is low, so a future edit that reinstates + // the any-property bridge cannot pass this file off as live surface. + const { cloneOverlap } = measureDoors(); + expect(cloneOverlap(WidgetManifestSchema)).toBeGreaterThan(0); // it DOES share leaves… + expect(cloneOverlap(WidgetManifestSchema)).toBeLessThan(0.2); // …but nothing structural + }); + + it('the shapes still accept their own vocabulary — this pins "open", not "broken"', () => { + expect(WidgetLifecycleSchema.safeParse({ onMount: 'x', notAHook: 1 }).success).toBe(true); + expect(WidgetEventSchema.safeParse({ name: 'e', notAnEventKey: 1 }).success).toBe(true); + expect(WidgetPropertySchema.safeParse({ name: 'p', type: 'string', notAPropKey: 1 }).success).toBe(true); + expect(WidgetManifestSchema.safeParse({ name: 'w_one', label: 'W', notAManifestKey: 1 }).success).toBe(true); + }); +}); diff --git a/packages/spec/src/ui/widget.zod.ts b/packages/spec/src/ui/widget.zod.ts index dedc9e7a3e..502d8a5ba0 100644 --- a/packages/spec/src/ui/widget.zod.ts +++ b/packages/spec/src/ui/widget.zod.ts @@ -6,6 +6,41 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { retiredKey } from '../shared/retired-key'; +// ⛔ #4001 批 16 — EVERY shape in this file is `no door`. Do NOT `.strict()` them. +// +// The ledger scheduled this file as `authorable (p)` / 9 sites. Resolving the +// `(p)` found no authoring door at all, measured three independent ways on +// 2026-08-04, with positive AND negative controls in the same run: +// +// 1. **No carrier key.** Nothing under `packages/spec/src` imports this module +// except the `ui/index.ts` barrel, so no schema anywhere declares a key +// whose value is a widget shape. `field.widget` is a `z.string()` naming a +// registered *component*; it has never referenced `WidgetManifest`. +// 2. **Unreachable.** A BFS over this build's in-memory Zod graph from all 24 +// metadata-type roots plus `defineStack`'s `ObjectStackSchema` (4 766 nodes) +// reaches none of them, while `PageSchema` / `ObjectListViewSchema` resolve +// in the same run and a synthetic carrier flips every one of them to +// reachable — so the verdict is a fact about the graph, not a broken walker. +// 3. **Never parsed.** No `.parse()` / `.safeParse()` on any of these exists in +// `objectstack`, `objectui` or `cloud` outside this file's own unit tests. +// objectui re-exports the inferred TYPES only, under different names +// (`RuntimeWidgetManifest` / `FieldWidgetComponentProps`, #4115 / #3161). +// +// `.strict()` is a property of a PARSE. With no parse it enforces nothing and +// only makes a dead slot look load-bearing — "a precisely validated dead slot, +// the more convincing lie" (#4583). The live question here is ADR-0049 +// enforce-or-remove, filed as #5055 (same class as #4988), NOT this ratchet. +// +// ⚠️ The campaign's own BFS reported `WidgetManifestSchema` as REACHABLE on the +// first run. That was a false positive in the walker's derived-clone bridge, not +// a door: zod's `.describe()` returns a clone that SHARES the original `_zod.def` +// object, so `WidgetManifestSchema.name` (a described `SnakeCaseIdentifierSchema`) +// and `.label` (a described `I18nLabelSchema`) are def-identical to the same +// leaves on live schemas, and a bridge that fires on ANY one shared property +// under a shared name links two unrelated shapes. Filed as #5056; `widget.test.ts` +// pins the corrected (whole-shape overlap) form. Same verdict recorded in the +// ui/ row of `docs/audits/2026-07-unknown-key-strictness-ledger.md`. + /** * Widget Lifecycle Hooks Schema *