From ffbab85a0246538e1033a711e37d622fa96d5938 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 01:49:48 +0000 Subject: [PATCH] =?UTF-8?q?feat(spec)!:=20close=20AriaProps=20against=20un?= =?UTF-8?q?known=20keys,=20reclassify=20widget=20+=20five=20i18n=20shapes?= =?UTF-8?q?=20as=20no-door=20(#4001=20=E6=89=B9=2016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifteen strip sites measured per the #4852 remeasure; the counts held exactly (widget 9/9, i18n 6/6, confirmed against the ledger gate's own AST counter). Per-schema door measurement then split them one / fourteen. Closed with strictObject: ui/i18n.zod.ts AriaPropsSchema The highest-fan-out single site the ui/ wave has closed — carried as `aria:` on ~30 shapes under six metadata-type roots (ListView, Page, PageComponent, DashboardWidget, ChartConfig, Action, 20 SDUI component defs). Through the view root, `aria: { label, describedBy }` parsed clean and came back `aria: {}`: the accessible name existed in the source file and nowhere else. Those two spellings are objectui's own ARIA_KEY_ALIASES, i.e. what stored view metadata carries. Reclassified as no door, NOT tightened (14 sites): ui/widget.zod.ts all six shapes / nine sites ui/i18n.zod.ts I18nObject, PluralRule, NumberFormat, DateFormat, LocaleConfig Measured three ways with positive AND negative controls in the same run: no carrier key (nothing imports widget.zod but the barrel), unreachable in a 4766-node BFS from the 24 roots + defineStack, zero parse in all three repos (cloud checked by code search against a proven-working index). A synthetic carrier flips all of them, so the verdict is a fact about the graph. Curation anchored on named sibling contracts, not edit distance: the two legacy spellings on objectui's normalizer, and `ariaRole` on this shape's own inconsistent prefix (two of three keys carry `aria`, `role` does not). The four the distance fallback already reaches were measured first and left to it. `live` and `ariaLabelledBy` get a prescription rather than a rename — finding 7 discipline. `live` is real and rendered by objectui's ListView alone, which declares it via `AriaPropsSchema.extend({ live })`, so that surface keeps accepting it and now inherits this error map; promoting it into the shared shape would advertise aria-live on 29 renderers that do not implement it (#5058). A `.strip()` was added to four files this batch does not otherwise touch: animation, dnd (x2), keyboard, touch build their shapes as `z.object({…}).merge(AriaPropsSchema.partial())`, and `.merge()` adopts the incoming posture — closing AriaProps would have silently closed all five of those no-door shapes with zod's generic message and no changeset. Pinned. Ledger finding 20: the campaign's own BFS reported widget.zod.ts as REACHABLE. Zod's `.describe()` returns a clone sharing the original `_zod.def`, so a bridge firing on any ONE shared property under a shared name links unrelated shapes via `name`/`label`. 2 keys of 20. The error is one-directional — it can only make a batch tighten something dead (#4583). Corrected to whole-shape overlap in ui/door-reachability.testkit.ts, pinned as a regression test, filed as #5056. Instruments proven red before trusting green: six sabotages, one per assertion class (walker, clone bridge, strictness, curation, finding-7 guidance, merge-strip), each reverted. Ledger: ui/ 118 strip of 198, authorable subtotal 82 — both recomputed from the surviving rows, never decremented by this batch's count. Refs #4001. Filed out of scope: #5055 (ADR-0049 for the 14 no-door sites), #5056, #5058, #5059. --- .../unknown-key-strictness-ui-batch16.md | 95 +++++++++ .../2026-07-unknown-key-strictness-ledger.md | 84 +++++++- packages/spec/src/ui/animation.zod.ts | 9 +- packages/spec/src/ui/dnd.zod.ts | 18 +- .../spec/src/ui/door-reachability.testkit.ts | 163 +++++++++++++++ packages/spec/src/ui/i18n.test.ts | 188 ++++++++++++++++++ packages/spec/src/ui/i18n.zod.ts | 90 ++++++++- packages/spec/src/ui/keyboard.zod.ts | 9 +- packages/spec/src/ui/touch.zod.ts | 9 +- packages/spec/src/ui/widget.test.ts | 89 ++++++++- packages/spec/src/ui/widget.zod.ts | 35 ++++ 11 files changed, 770 insertions(+), 19 deletions(-) create mode 100644 .changeset/unknown-key-strictness-ui-batch16.md create mode 100644 packages/spec/src/ui/door-reachability.testkit.ts 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 bcc6059ac3..c2cd5bbb7c 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -437,6 +437,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 @@ -500,9 +546,9 @@ not verdicts). | `theme.zod.ts` | 14 | authorable (p) | authored themes | | `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 | partially strict | -| `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` / `i18n.zod.ts` | 7+6 | authorable (p) | i18n label shapes are wide-open records by design — verify. **`chart` 6 → 7 at the re-measurement** — again no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break | +| `chart.zod.ts` / `i18n.zod.ts` | 7+6 | authorable / **split** | **`chart` 6 → 7 at the re-measurement** — again no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break. **`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 (p) | analytics dimension/measure config | | `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 | @@ -673,17 +719,17 @@ 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/` — 119 strip of 198 +#### `ui/` — 118 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 | | `theme.zod.ts` | 14 | 14 | authorable (p) | Authored themes; `Typography` / `Animation` sub-blocks dominate | -| `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` | 7 | 7 | authorable (p) | Axis / series / annotation / interaction / config / groupBy / aggregate | | `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 | @@ -708,9 +754,31 @@ line, which conflicts with nothing, merged clean and wrong on both sides. merged alongside #4876, which edits this same section, so the conflict was expected and both sides' row edits were kept before recomputing. -**Authorable strip in `ui/`: 97 of 119** (was 123 of 123). The subtotal moved by -26 while only 4 sites were CLOSED, and the 22-site gap is the batch's actual -finding rather than a rounding of it: `touch` (7), `animation` (4), `dnd` (4), +**Authorable strip in `ui/`: 82 of 118** (批 16; was 97 of 119 at 批 13, 123 of +123 before the campaign reached this directory). Both numbers are **recomputed +from the surviving rows**, never decremented by a batch's own count — 批 16's +header is 29+20+14+9+7+7+5+4+4+4+3+3+2+2+2+1+1+1 = 118, and its authorable +subtotal is that minus the 36 sites now classified `no door` (`widget` 9, +`touch` 7, `i18n`'s remaining 5, `animation` 4, `dnd` 4, `keyboard` 4, +`offline` 3) = 82. See the `automation/` section's note on why this is +mechanical rather than remembered: it has now been got wrong on both sides of a +clean merge six times, and this section is three-way contended (批 14 #5042 and +批 15 #5043 both edit it). + +批 16 moved the subtotal by 15 while CLOSING exactly one site, and the 14-site +gap is again 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 the 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 with it turned out to be the +directory's most widely carried live shape. + +批 13's own subtotal note, kept for the arithmetic trail: it moved by +26 while only 4 sites were CLOSED, and that 22-site gap was that batch's finding: `touch` (7), `animation` (4), `dnd` (4), `keyboard` (4) and `offline` (3) were reclassified out of `authorable` because their `(p)` resolved negative — **no metadata document is ever parsed against them**, so there is no author for strictness to protect. The evidence is in their 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 *