From c8510e69748be02535462d01bcd14851b659cc86 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 19:10:45 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec)!:=20reject=20unknown=20keys=20on?= =?UTF-8?q?=20the=20responsive/SDUI-styling=20shapes=20(#4001=20=E6=89=B9?= =?UTF-8?q?=2013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close all four sites in `ui/responsive.zod.ts` with `strictObject`. The nested gap is what this fixes: `PageComponentSchema` has been `.strict()` since ADR-0089 D3a and strictness does not recurse, so a component whose every styling and layout instruction was written in the wrong breakpoint vocabulary parsed clean and returned `{ responsiveStyles: {}, responsive: {} }`. The file carries TWO breakpoint vocabularies sixteen lines apart on the same component (ADR-0065 buckets vs the Tailwind ramp), so the aliases run both ways and are anchored to the named sibling rather than to edit distance. The other five files in this batch are NOT closed, on a measurement: their 22 sites have no authoring door (no carrier key; unreachable by BFS from all 24 metadata-type roots plus defineStack, with three positive controls passing in the same run; no .parse() anywhere in three repos). ADR-0049 triage is #4988. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- .../unknown-key-strictness-ui-batch13.md | 66 ++++ .../2026-07-unknown-key-strictness-ledger.md | 64 +++- packages/spec/src/ui/animation.test.ts | 66 ++++ packages/spec/src/ui/animation.zod.ts | 43 ++- packages/spec/src/ui/dnd.test.ts | 66 ++++ packages/spec/src/ui/dnd.zod.ts | 43 ++- packages/spec/src/ui/keyboard.test.ts | 66 ++++ packages/spec/src/ui/keyboard.zod.ts | 43 ++- packages/spec/src/ui/offline.test.ts | 61 ++++ packages/spec/src/ui/offline.zod.ts | 43 ++- packages/spec/src/ui/responsive.test.ts | 216 +++++++++++- packages/spec/src/ui/responsive.zod.ts | 308 +++++++++++++++--- packages/spec/src/ui/touch.test.ts | 82 +++++ packages/spec/src/ui/touch.zod.ts | 43 ++- 14 files changed, 1141 insertions(+), 69 deletions(-) create mode 100644 .changeset/unknown-key-strictness-ui-batch13.md diff --git a/.changeset/unknown-key-strictness-ui-batch13.md b/.changeset/unknown-key-strictness-ui-batch13.md new file mode 100644 index 0000000000..55239b5ed3 --- /dev/null +++ b/.changeset/unknown-key-strictness-ui-batch13.md @@ -0,0 +1,66 @@ +--- +'@objectstack/spec': major +--- + +Close the responsive/SDUI-styling shapes against unknown keys (#4001 batch 13, 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.** All four shapes in `ui/responsive.zod.ts` now raise a named, +fixable error instead of dropping the key: `ResponsiveConfigSchema`, +`ResponsiveStylesSchema`, and the two per-breakpoint maps behind +`responsive.columns` / `responsive.order`. + +**What this actually fixes is a nested one.** `PageComponentSchema` has been +`.strict()` since ADR-0089 D3a — and that never reached these blocks, because +strictness does not recurse. So this component parsed **clean**: + +```ts +PageComponentSchema.parse({ + type: 'element:text', id: 't1', + responsiveStyles: { lg: { fontSize: '40px' } }, + responsive: { colums: { lg: 4 }, hideOn: ['xs'] }, +}) +// → { …, responsiveStyles: {}, responsive: {} } +``` + +Every styling and layout instruction the author wrote, gone, reported valid — the +node renders unstyled and nothing says why. + +**The renames, and where the wrong word comes from.** This file carries TWO +breakpoint vocabularies sixteen lines apart on the same page component: +`responsiveStyles` uses ADR-0065's desktop-first buckets, `responsive` uses the +Tailwind `xs`…`2xl` ramp. Crossing them is not a typo and edit distance cannot +bridge it, so the aliases run both ways: + +| you wrote | write instead | where the other word comes from | +|---|---|---| +| `responsiveStyles: { xs / sm / md }` | `xsmall` / `small` / `medium` | the sibling `responsive` key's `BreakpointName` ramp | +| `responsiveStyles: { lg / xl / 2xl }` | `large` | same, folded onto the unconditional base | +| `columns: { large / medium / small / xsmall }` | `lg` / `md` / `sm` / `xs` | the sibling `responsiveStyles` buckets | +| `columns: { xxl }` | `2xl` | the near-miss this file's own test has pinned as invalid since before #4001 | +| `responsive: { hidden }` / `{ hideOn }` | `hiddenOn` | objectui's resolved `useResponsiveConfig` result | + +Two are prescriptions rather than renames, because a rename would be wrong. A +bare breakpoint name at the `responsive` level (`responsive: { sm: … }`) is the +legacy breakpoint-keyed shape from the `view.responsive` retired in 17 (#3896) — +three keys are plausible targets, so each name gets its own text naming all +three. And a `responsiveStyles` bucket written on `responsive` (or vice versa) is +a wrong-layer pointer to the sibling key, not a rename. + +`StyleMapSchema` stays **deliberately open** — its key space is every CSS +property, not a contract we own — pinned in the schema JSDoc, in a test, and in +the #4001 ledger. + +**Nothing in `ui/touch|animation|dnd|keyboard|offline.zod.ts` changed**, and that +is deliberate. The ledger scheduled their 22 sites as `authorable (p)`; resolving +the `(p)` found no authoring door at all — nothing declares a carrier key for +them, a BFS from all 24 metadata-type roots plus `defineStack` never reaches +them (with three positive controls passing in the same run), and no `.parse()` on +any of them exists in this repo, objectui, or the example apps. `.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 #4988 — not a breaking change to spend +here. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 5e8cf23ae2..4f53272754 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -20,6 +20,13 @@ One question decides the class: **who writes this schema's input?** | **authorable** | A human or AI author, into `*.object.ts` / `defineStack` config / Studio / MCP | `.strict()` + fixable error (the ratchet target) | | **wire** | Another machine: server responses, connector payloads, runtime envelopes, persisted runtime state | stay tolerant (`.strip` / `.passthrough`); strictness here turns an upstream *addition* into our parse crash | | **open** | Deliberately schemaless user data (record bodies, per-node-type `config`, React props) | stay open; a *sibling* contract validates it (e.g. a node executor's `configSchema`, #4027/#4040) | +| **no door** | **Nobody — nothing parses it.** The shape is exported and typed, but no schema declares a carrier key for it, so it is unreachable from every metadata-type root and from `defineStack`. Added at 批 13, when the first run of files resolved its `(p)` this way | **out of this ratchet's scope.** `.strict()` is a property of a PARSE; with no parse it enforces nothing and only makes a dead slot look load-bearing (#4583). The live question is ADR-0049 enforce-or-remove — retire the vocabulary or give it a carrier — so a row here points at an issue, never at a batch | + +A fourth answer to "who writes this input" is **nobody**, and it is only +reachable by measurement rather than by reading the file: `no door` was added at +批 13 after a BFS from every authoring root (with positive controls) came back +empty on five `ui/` files at once. Reading a schema's exports and JSDoc cannot +distinguish it from `authorable` — which is exactly why the `(p)` exists. Mixed files carry both — classify per schema, not per file. A **response-side extension of an authoring schema** (e.g. `EffectiveObjectPermissionSchema`) @@ -495,9 +502,11 @@ not verdicts). | `dashboard.zod.ts` | 11 | authorable | partially strict | | `widget.zod.ts` | 9 | authorable (p) | | | `page.zod.ts` | 7 | authorable | partially strict (ADR-0089) | -| `chart.zod.ts` / `i18n.zod.ts` / `responsive.zod.ts` | 7+6+4 | 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 | -| `dataset.zod.ts` / `animation.zod.ts` / `dnd.zod.ts` / `keyboard.zod.ts` / `touch.zod.ts` | 4+4+4+4+7 | authorable (p) | interaction configs | -| `offline.zod.ts` / `report.zod.ts` | 3 ea | authorable (p) | | +| `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 | +| `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 | +| `report.zod.ts` | 3 | authorable (p) | | | `notification.zod.ts` | 1 | authorable (p) | **#4610 dropped two sites** — the `./ui` `Notification` (toast/banner instance) and `NotificationConfig` (toaster global config) shapes were removed: zero importers in all three repos, and both shadowed live names owned elsewhere (`./api` owns the inbox row). What remains is `NotificationActionSchema`, part of the presentation vocabulary the ui entry keeps | | `sharing.zod.ts` | 2 | authorable (p) | public-sharing config | @@ -641,7 +650,7 @@ what settles it. A clean-looking merge here is not evidence of anything. waves). What remains of the ruling's "known main body" is `etl` 7, `flow` 6, and one each from `flow-function` / `time-relative-trigger` / `webhook`. -#### `ui/` — 123 strip of 198 +#### `ui/` — 119 strip of 198 | File | Strip | Sites | Class | Batch | |---|---|---|---|---| @@ -650,14 +659,13 @@ and one each from `flow-function` / `time-relative-trigger` / `webhook`. | `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 | | `chart.zod.ts` | 7 | 7 | authorable (p) | Axis / series / annotation / interaction / config / groupBy / aggregate | -| `touch.zod.ts` | 7 | 7 | authorable (p) | Gesture configs | +| `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 | -| `animation.zod.ts` | 4 | 4 | authorable (p) | | -| `dnd.zod.ts` | 4 | 4 | authorable (p) | | -| `keyboard.zod.ts` | 4 | 4 | authorable (p) | | -| `responsive.zod.ts` | 4 | 4 | authorable (p) | | +| `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 | | `dataset.zod.ts` | 3 | 4 | authorable (p) | `DatasetDimension` / `DatasetMeasure` + `.derived` | -| `offline.zod.ts` | 3 | 3 | authorable (p) | | +| `offline.zod.ts` | 3 | 3 | **no door** | ⛔ same as `touch` — #4988 | | `dashboard.zod.ts` | 2 | 11 | authorable | Only `DashboardWidget.compareTo` and `.layout` left; `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch — see the triage row) | | `report.zod.ts` | 2 | 3 | authorable (p) | `ReportSort` / `JoinedReportBlock` | | `sharing.zod.ts` | 2 | 2 | authorable (p) | `SharingConfig` / `EmbedConfig` | @@ -665,9 +673,39 @@ and one each from `flow-function` / `time-relative-trigger` / `webhook`. | `app.zod.ts` | 1 | 18 | verify | `BaseNavItemSchema` — the base the strict discriminated-union members extend. Closing a base that is `.extend()`ed is the #4001 trap that bit `view` (finding 16); confirm the members' strictness is not already covering it before touching | | `notification.zod.ts` | 1 | 1 | authorable (p) | `NotificationActionSchema` | -**Authorable strip in `ui/`: 123 of 123** — every remaining strip site in this -directory is authorable. Of those 123, `app.zod.ts`'s single site is held pending -the finding-16 `.extend()` check rather than counted as ready. +`responsive.zod.ts` left this table at **批 13** (#4001) on reverse-pin evidence +— it reached 0 strip, the gate went red on the row still being there, and the row +was deleted. Header and subtotal are **recomputed from the surviving rows** (29 + +20 + 14 + 9 + 7 + 7 + 6 + 4 + 4 + 4 + 3 + 3 + 2 + 2 + 2 + 1 + 1 + 1 = 119), not +decremented by this batch's own count. That is not pedantry: it happened three +times in one day in `automation/` — each branch's arithmetic was right against +itself, git merged the rows cleanly because they do not overlap, and the subtotal +line, which conflicts with nothing, merged clean and wrong on both sides. +`check:strictness-ledger`'s header arithmetic is what settles it. Note this batch +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), +`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 +triage row above; the live question is ADR-0049 enforce-or-remove (#4988), not +this ratchet. Of the 97 that remain, `app.zod.ts`'s single site is still held +pending the finding-16 `.extend()` check rather than counted as ready. + +The reclassification is worth reading as a method note, because batch 13 is the +first time the `(p)` came back negative on a whole run of files rather than on +one. The three `automation/` waves each resolved their `(p)` by finding a door +that the ledger's prose had missed — batch 10's `agent.lifecycle`, batch 11's +boot-time `bootstrapDeclaredWebhooks`. That created a quiet expectation that +verification means *finding* the door. Here the same procedure, run with positive +controls in the same execution, found no door at all five times — and the correct +output of a verification step is whatever it measures, including "this was never +ratchet work". A batch that had skipped the check would have shipped 22 strict +schemas, a breaking changeset, and ~58 curated alias entries that no parse would +ever consult. The one `open` site this directory carried is **gone, and not by being closed**: `bulk-action.zod.ts`'s `BulkActionParamSchema.options` was the row that read diff --git a/packages/spec/src/ui/animation.test.ts b/packages/spec/src/ui/animation.test.ts index 2ebf42db24..23a4c60a85 100644 --- a/packages/spec/src/ui/animation.test.ts +++ b/packages/spec/src/ui/animation.test.ts @@ -261,3 +261,69 @@ describe('TransitionConfigSchema - themeToken', () => { expect(result.themeToken).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// #4001 batch 13 -- THIS FILE IS DELIBERATELY NOT `.strict()`, on a measurement. +// +// The strictness ledger scheduled these 4 sites as `authorable (p)`. Resolving +// the `(p)` found no authoring door at all: nothing under `packages/spec/src` +// imports this module except the `ui/index.ts` barrel, a BFS from all 24 +// metadata-type roots plus `defineStack`'s `ObjectStackSchema` never reaches +// these schemas (`PageSchema` / `WebhookSchema` / `StateMachineSchema` pass as +// positive controls in the same run), and no `.parse()` on any of them exists +// in `objectstack`, `objectui` or the example apps outside this test file. +// `.strict()` is a property of a PARSE, and there is no parse to gate. +// +// So the strip pinned below is not an unfinished row -- it is the recorded +// verdict. The open question is ADR-0049 enforce-or-remove, filed as #4988. +// These assertions exist so the next sweep stops and reads instead of reaching +// for `strictObject` and shipping a precisely-validated dead slot (#4583). The +// header comment in `animation.zod.ts` and this file's ledger row carry the same verdict. +// --------------------------------------------------------------------------- +describe('unknown-key posture is an open question, not an omission (#4001 batch 13 -> #4988)', () => { + it('TransitionConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = TransitionConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + it('ComponentAnimationSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = ComponentAnimationSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + it('PageTransitionSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = PageTransitionSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + it('MotionConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = MotionConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + // The standing half of measurement 1, so the verdict cannot go stale in + // silence: the day someone gives this vocabulary a carrier they will add an + // import, and this is where they are told to revisit #4988 and the ledger. + it('is still imported by nothing but the ui/ barrel', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const importers: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') + && full !== path.join(root, 'ui', 'animation.zod.ts')) { + if (/(?:import|export)[^;]*['"][^'"]*\/animation\.zod['"]/.test(fs.readFileSync(full, 'utf-8'))) { + importers.push(path.relative(root, full)); + } + } + } + }; + walk(root); + expect(importers, 'a new importer means this vocabulary got a carrier -- re-read #4988') + .toEqual(['ui/index.ts']); + }); +}); diff --git a/packages/spec/src/ui/animation.zod.ts b/packages/spec/src/ui/animation.zod.ts index 90442e6a0c..616dc2b33e 100644 --- a/packages/spec/src/ui/animation.zod.ts +++ b/packages/spec/src/ui/animation.zod.ts @@ -2,12 +2,53 @@ import { z } from 'zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; +import { lazySchema } from '../shared/lazy-schema'; + +// --------------------------------------------------------------------------- +// NOT CLOSED AGAINST UNKNOWN KEYS -- AND THAT IS THE MEASURED VERDICT +// (#4001 batch 13 / 批 13, ADR-0078). Read this before "finishing" the file. +// +// The strictness ledger scheduled this file's 4 object sites as `authorable +// (p)` -- provisional. #4001's own rule is verify-before-tightening, and here +// the verification came back NEGATIVE: no metadata document is ever parsed +// against these shapes, because nothing in the protocol carries them. +// +// Three independent measurements, 2026-08-03: +// +// 1. STATIC -- nothing under `packages/spec/src` imports this module except +// the `ui/index.ts` barrel. No schema anywhere declares a `component.animation / app.motion` +// slot, so there is no key an author can write to reach these shapes. +// 2. GRAPH -- a BFS over this build's in-memory Zod graph from all 24 +// metadata-type roots (`listMetadataTypeSchemaTypes`) plus +// `ObjectStackSchema` (`defineStack`) -- the closure `build-schemas.ts` +// uses for the #4650 deletion check -- reaches none of them. Its three +// positive controls resolve `root-graph` in the same run: `PageSchema`, +// `WebhookSchema` (batch 11's `defineStack({ webhooks })` door) and +// `StateMachineSchema` (batch 10's `agent.lifecycle` door). So +// "unreachable" is a fact about the graph, not a broken instrument. +// 3. CALL SITES -- no `.parse()` / `.safeParse()` on any schema here exists +// in `objectstack`, `objectui` or the example apps, outside this file's +// own unit test. objectui re-exports the inferred TYPES only and says so +// (`@object-ui/types`, the #2561 note: the validators are deliberately +// NOT re-exported). +// +// `.strict()` would therefore gate nothing -- strictness is a property of a +// PARSE, and there is no parse. Adding it would spend a v17 breaking change to +// make this file LOOK finished, and leave behind the artefact the ledger +// itself warns about: "a *precisely validated* dead slot is the more +// convincing lie" (#4583). The real question is ADR-0049 enforce-or-remove -- +// retire this vocabulary or give it a carrier -- filed as #4988, with the same +// verdict recorded in this file's ledger row. +// +// DO NOT convert these sites to `strictObject` before #4988 is decided: a +// strict shape reads as load-bearing and makes the retirement harder, which is +// the opposite of what the measurement asks for. +// --------------------------------------------------------------------------- /** * Transition Preset Schema * Common animation transition presets. */ -import { lazySchema } from '../shared/lazy-schema'; export const TransitionPresetSchema = lazySchema(() => z.enum([ 'fade', 'slide_up', diff --git a/packages/spec/src/ui/dnd.test.ts b/packages/spec/src/ui/dnd.test.ts index db3b96fa91..4b815583ab 100644 --- a/packages/spec/src/ui/dnd.test.ts +++ b/packages/spec/src/ui/dnd.test.ts @@ -232,3 +232,69 @@ describe('I18n and ARIA integration', () => { expect(item.ariaLabel).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// #4001 batch 13 -- THIS FILE IS DELIBERATELY NOT `.strict()`, on a measurement. +// +// The strictness ledger scheduled these 4 sites as `authorable (p)`. Resolving +// the `(p)` found no authoring door at all: nothing under `packages/spec/src` +// imports this module except the `ui/index.ts` barrel, a BFS from all 24 +// metadata-type roots plus `defineStack`'s `ObjectStackSchema` never reaches +// these schemas (`PageSchema` / `WebhookSchema` / `StateMachineSchema` pass as +// positive controls in the same run), and no `.parse()` on any of them exists +// in `objectstack`, `objectui` or the example apps outside this test file. +// `.strict()` is a property of a PARSE, and there is no parse to gate. +// +// So the strip pinned below is not an unfinished row -- it is the recorded +// verdict. The open question is ADR-0049 enforce-or-remove, filed as #4988. +// These assertions exist so the next sweep stops and reads instead of reaching +// for `strictObject` and shipping a precisely-validated dead slot (#4583). The +// header comment in `dnd.zod.ts` and this file's ledger row carry the same verdict. +// --------------------------------------------------------------------------- +describe('unknown-key posture is an open question, not an omission (#4001 batch 13 -> #4988)', () => { + it('DragConstraintSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = DragConstraintSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + it('DropZoneSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = DropZoneSchema.parse({ accept: ['card'], aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + it('DragItemSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = DragItemSchema.parse({ type: 'card', aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + it('DndConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = DndConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + // The standing half of measurement 1, so the verdict cannot go stale in + // silence: the day someone gives this vocabulary a carrier they will add an + // import, and this is where they are told to revisit #4988 and the ledger. + it('is still imported by nothing but the ui/ barrel', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const importers: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') + && full !== path.join(root, 'ui', 'dnd.zod.ts')) { + if (/(?:import|export)[^;]*['"][^'"]*\/dnd\.zod['"]/.test(fs.readFileSync(full, 'utf-8'))) { + importers.push(path.relative(root, full)); + } + } + } + }; + walk(root); + expect(importers, 'a new importer means this vocabulary got a carrier -- re-read #4988') + .toEqual(['ui/index.ts']); + }); +}); diff --git a/packages/spec/src/ui/dnd.zod.ts b/packages/spec/src/ui/dnd.zod.ts index 9874395252..9e44b4a39d 100644 --- a/packages/spec/src/ui/dnd.zod.ts +++ b/packages/spec/src/ui/dnd.zod.ts @@ -2,12 +2,53 @@ import { z } from 'zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; +import { lazySchema } from '../shared/lazy-schema'; + +// --------------------------------------------------------------------------- +// NOT CLOSED AGAINST UNKNOWN KEYS -- AND THAT IS THE MEASURED VERDICT +// (#4001 batch 13 / 批 13, ADR-0078). Read this before "finishing" the file. +// +// The strictness ledger scheduled this file's 4 object sites as `authorable +// (p)` -- provisional. #4001's own rule is verify-before-tightening, and here +// the verification came back NEGATIVE: no metadata document is ever parsed +// against these shapes, because nothing in the protocol carries them. +// +// Three independent measurements, 2026-08-03: +// +// 1. STATIC -- nothing under `packages/spec/src` imports this module except +// the `ui/index.ts` barrel. No schema anywhere declares a `component.dnd / view.dnd` +// slot, so there is no key an author can write to reach these shapes. +// 2. GRAPH -- a BFS over this build's in-memory Zod graph from all 24 +// metadata-type roots (`listMetadataTypeSchemaTypes`) plus +// `ObjectStackSchema` (`defineStack`) -- the closure `build-schemas.ts` +// uses for the #4650 deletion check -- reaches none of them. Its three +// positive controls resolve `root-graph` in the same run: `PageSchema`, +// `WebhookSchema` (batch 11's `defineStack({ webhooks })` door) and +// `StateMachineSchema` (batch 10's `agent.lifecycle` door). So +// "unreachable" is a fact about the graph, not a broken instrument. +// 3. CALL SITES -- no `.parse()` / `.safeParse()` on any schema here exists +// in `objectstack`, `objectui` or the example apps, outside this file's +// own unit test. objectui re-exports the inferred TYPES only and says so +// (`@object-ui/types`, the #2561 note: the validators are deliberately +// NOT re-exported). +// +// `.strict()` would therefore gate nothing -- strictness is a property of a +// PARSE, and there is no parse. Adding it would spend a v17 breaking change to +// make this file LOOK finished, and leave behind the artefact the ledger +// itself warns about: "a *precisely validated* dead slot is the more +// convincing lie" (#4583). The real question is ADR-0049 enforce-or-remove -- +// retire this vocabulary or give it a carrier -- filed as #4988, with the same +// verdict recorded in this file's ledger row. +// +// DO NOT convert these sites to `strictObject` before #4988 is decided: a +// strict shape reads as load-bearing and makes the retirement harder, which is +// the opposite of what the measurement asks for. +// --------------------------------------------------------------------------- /** * Drag Handle Schema * Defines how a drag interaction is initiated on an element. */ -import { lazySchema } from '../shared/lazy-schema'; export const DragHandleSchema = lazySchema(() => z.enum([ 'element', 'handle', diff --git a/packages/spec/src/ui/keyboard.test.ts b/packages/spec/src/ui/keyboard.test.ts index 67cafd9762..cbc35519c8 100644 --- a/packages/spec/src/ui/keyboard.test.ts +++ b/packages/spec/src/ui/keyboard.test.ts @@ -187,3 +187,69 @@ describe('I18n and ARIA integration', () => { expect(nav.ariaLabel).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// #4001 batch 13 -- THIS FILE IS DELIBERATELY NOT `.strict()`, on a measurement. +// +// The strictness ledger scheduled these 4 sites as `authorable (p)`. Resolving +// the `(p)` found no authoring door at all: nothing under `packages/spec/src` +// imports this module except the `ui/index.ts` barrel, a BFS from all 24 +// metadata-type roots plus `defineStack`'s `ObjectStackSchema` never reaches +// these schemas (`PageSchema` / `WebhookSchema` / `StateMachineSchema` pass as +// positive controls in the same run), and no `.parse()` on any of them exists +// in `objectstack`, `objectui` or the example apps outside this test file. +// `.strict()` is a property of a PARSE, and there is no parse to gate. +// +// So the strip pinned below is not an unfinished row -- it is the recorded +// verdict. The open question is ADR-0049 enforce-or-remove, filed as #4988. +// These assertions exist so the next sweep stops and reads instead of reaching +// for `strictObject` and shipping a precisely-validated dead slot (#4583). The +// header comment in `keyboard.zod.ts` and this file's ledger row carry the same verdict. +// --------------------------------------------------------------------------- +describe('unknown-key posture is an open question, not an omission (#4001 batch 13 -> #4988)', () => { + it('FocusTrapConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = FocusTrapConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + it('KeyboardShortcutSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = KeyboardShortcutSchema.parse({ key: 'Ctrl+S', action: 'save', aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + it('FocusManagementSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = FocusManagementSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + it('KeyboardNavigationConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = KeyboardNavigationConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + // The standing half of measurement 1, so the verdict cannot go stale in + // silence: the day someone gives this vocabulary a carrier they will add an + // import, and this is where they are told to revisit #4988 and the ledger. + it('is still imported by nothing but the ui/ barrel', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const importers: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') + && full !== path.join(root, 'ui', 'keyboard.zod.ts')) { + if (/(?:import|export)[^;]*['"][^'"]*\/keyboard\.zod['"]/.test(fs.readFileSync(full, 'utf-8'))) { + importers.push(path.relative(root, full)); + } + } + } + }; + walk(root); + expect(importers, 'a new importer means this vocabulary got a carrier -- re-read #4988') + .toEqual(['ui/index.ts']); + }); +}); diff --git a/packages/spec/src/ui/keyboard.zod.ts b/packages/spec/src/ui/keyboard.zod.ts index 15ee360db9..f6bee5e5a4 100644 --- a/packages/spec/src/ui/keyboard.zod.ts +++ b/packages/spec/src/ui/keyboard.zod.ts @@ -2,12 +2,53 @@ import { z } from 'zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; +import { lazySchema } from '../shared/lazy-schema'; + +// --------------------------------------------------------------------------- +// NOT CLOSED AGAINST UNKNOWN KEYS -- AND THAT IS THE MEASURED VERDICT +// (#4001 batch 13 / 批 13, ADR-0078). Read this before "finishing" the file. +// +// The strictness ledger scheduled this file's 4 object sites as `authorable +// (p)` -- provisional. #4001's own rule is verify-before-tightening, and here +// the verification came back NEGATIVE: no metadata document is ever parsed +// against these shapes, because nothing in the protocol carries them. +// +// Three independent measurements, 2026-08-03: +// +// 1. STATIC -- nothing under `packages/spec/src` imports this module except +// the `ui/index.ts` barrel. No schema anywhere declares a `component.keyboard / app.keyboard` +// slot, so there is no key an author can write to reach these shapes. +// 2. GRAPH -- a BFS over this build's in-memory Zod graph from all 24 +// metadata-type roots (`listMetadataTypeSchemaTypes`) plus +// `ObjectStackSchema` (`defineStack`) -- the closure `build-schemas.ts` +// uses for the #4650 deletion check -- reaches none of them. Its three +// positive controls resolve `root-graph` in the same run: `PageSchema`, +// `WebhookSchema` (batch 11's `defineStack({ webhooks })` door) and +// `StateMachineSchema` (batch 10's `agent.lifecycle` door). So +// "unreachable" is a fact about the graph, not a broken instrument. +// 3. CALL SITES -- no `.parse()` / `.safeParse()` on any schema here exists +// in `objectstack`, `objectui` or the example apps, outside this file's +// own unit test. objectui re-exports the inferred TYPES only and says so +// (`@object-ui/types`, the #2561 note: the validators are deliberately +// NOT re-exported). +// +// `.strict()` would therefore gate nothing -- strictness is a property of a +// PARSE, and there is no parse. Adding it would spend a v17 breaking change to +// make this file LOOK finished, and leave behind the artefact the ledger +// itself warns about: "a *precisely validated* dead slot is the more +// convincing lie" (#4583). The real question is ADR-0049 enforce-or-remove -- +// retire this vocabulary or give it a carrier -- filed as #4988, with the same +// verdict recorded in this file's ledger row. +// +// DO NOT convert these sites to `strictObject` before #4988 is decided: a +// strict shape reads as load-bearing and makes the retirement harder, which is +// the opposite of what the measurement asks for. +// --------------------------------------------------------------------------- /** * Focus Trap Configuration Schema * Constrains keyboard focus within a specific container (e.g., modals, dialogs). */ -import { lazySchema } from '../shared/lazy-schema'; export const FocusTrapConfigSchema = lazySchema(() => z.object({ enabled: z.boolean().default(false).describe('Enable focus trapping within this container'), initialFocus: z.string().optional().describe('CSS selector for the element to focus on activation'), diff --git a/packages/spec/src/ui/offline.test.ts b/packages/spec/src/ui/offline.test.ts index 7d6ee8d366..ade99c572a 100644 --- a/packages/spec/src/ui/offline.test.ts +++ b/packages/spec/src/ui/offline.test.ts @@ -179,3 +179,64 @@ describe('I18n integration', () => { expect(result.offlineMessage).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// #4001 batch 13 -- THIS FILE IS DELIBERATELY NOT `.strict()`, on a measurement. +// +// The strictness ledger scheduled these 3 sites as `authorable (p)`. Resolving +// the `(p)` found no authoring door at all: nothing under `packages/spec/src` +// imports this module except the `ui/index.ts` barrel, a BFS from all 24 +// metadata-type roots plus `defineStack`'s `ObjectStackSchema` never reaches +// these schemas (`PageSchema` / `WebhookSchema` / `StateMachineSchema` pass as +// positive controls in the same run), and no `.parse()` on any of them exists +// in `objectstack`, `objectui` or the example apps outside this test file. +// `.strict()` is a property of a PARSE, and there is no parse to gate. +// +// So the strip pinned below is not an unfinished row -- it is the recorded +// verdict. The open question is ADR-0049 enforce-or-remove, filed as #4988. +// These assertions exist so the next sweep stops and reads instead of reaching +// for `strictObject` and shipping a precisely-validated dead slot (#4583). The +// header comment in `offline.zod.ts` and this file's ledger row carry the same verdict. +// --------------------------------------------------------------------------- +describe('unknown-key posture is an open question, not an omission (#4001 batch 13 -> #4988)', () => { + it('SyncConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = SyncConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + it('OfflineCacheConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = OfflineCacheConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + it('OfflineConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = OfflineConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + // The standing half of measurement 1, so the verdict cannot go stale in + // silence: the day someone gives this vocabulary a carrier they will add an + // import, and this is where they are told to revisit #4988 and the ledger. + it('is still imported by nothing but the ui/ barrel', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const importers: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') + && full !== path.join(root, 'ui', 'offline.zod.ts')) { + if (/(?:import|export)[^;]*['"][^'"]*\/offline\.zod['"]/.test(fs.readFileSync(full, 'utf-8'))) { + importers.push(path.relative(root, full)); + } + } + } + }; + walk(root); + expect(importers, 'a new importer means this vocabulary got a carrier -- re-read #4988') + .toEqual(['ui/index.ts']); + }); +}); diff --git a/packages/spec/src/ui/offline.zod.ts b/packages/spec/src/ui/offline.zod.ts index 618acc5ea3..af452f98d2 100644 --- a/packages/spec/src/ui/offline.zod.ts +++ b/packages/spec/src/ui/offline.zod.ts @@ -2,12 +2,53 @@ import { z } from 'zod'; import { I18nLabelSchema } from './i18n.zod'; +import { lazySchema } from '../shared/lazy-schema'; + +// --------------------------------------------------------------------------- +// NOT CLOSED AGAINST UNKNOWN KEYS -- AND THAT IS THE MEASURED VERDICT +// (#4001 batch 13 / 批 13, ADR-0078). Read this before "finishing" the file. +// +// The strictness ledger scheduled this file's 3 object sites as `authorable +// (p)` -- provisional. #4001's own rule is verify-before-tightening, and here +// the verification came back NEGATIVE: no metadata document is ever parsed +// against these shapes, because nothing in the protocol carries them. +// +// Three independent measurements, 2026-08-03: +// +// 1. STATIC -- nothing under `packages/spec/src` imports this module except +// the `ui/index.ts` barrel. No schema anywhere declares a `app.offline / page.offline` +// slot, so there is no key an author can write to reach these shapes. +// 2. GRAPH -- a BFS over this build's in-memory Zod graph from all 24 +// metadata-type roots (`listMetadataTypeSchemaTypes`) plus +// `ObjectStackSchema` (`defineStack`) -- the closure `build-schemas.ts` +// uses for the #4650 deletion check -- reaches none of them. Its three +// positive controls resolve `root-graph` in the same run: `PageSchema`, +// `WebhookSchema` (batch 11's `defineStack({ webhooks })` door) and +// `StateMachineSchema` (batch 10's `agent.lifecycle` door). So +// "unreachable" is a fact about the graph, not a broken instrument. +// 3. CALL SITES -- no `.parse()` / `.safeParse()` on any schema here exists +// in `objectstack`, `objectui` or the example apps, outside this file's +// own unit test. objectui re-exports the inferred TYPES only and says so +// (`@object-ui/types`, the #2561 note: the validators are deliberately +// NOT re-exported). +// +// `.strict()` would therefore gate nothing -- strictness is a property of a +// PARSE, and there is no parse. Adding it would spend a v17 breaking change to +// make this file LOOK finished, and leave behind the artefact the ledger +// itself warns about: "a *precisely validated* dead slot is the more +// convincing lie" (#4583). The real question is ADR-0049 enforce-or-remove -- +// retire this vocabulary or give it a carrier -- filed as #4988, with the same +// verdict recorded in this file's ledger row. +// +// DO NOT convert these sites to `strictObject` before #4988 is decided: a +// strict shape reads as load-bearing and makes the retirement harder, which is +// the opposite of what the measurement asks for. +// --------------------------------------------------------------------------- /** * Offline Strategy Schema * Determines how data is fetched when connectivity is limited. */ -import { lazySchema } from '../shared/lazy-schema'; export const OfflineStrategySchema = lazySchema(() => z.enum([ 'cache_first', 'network_first', diff --git a/packages/spec/src/ui/responsive.test.ts b/packages/spec/src/ui/responsive.test.ts index ba5f522894..b8e417694f 100644 --- a/packages/spec/src/ui/responsive.test.ts +++ b/packages/spec/src/ui/responsive.test.ts @@ -1,10 +1,14 @@ import { describe, it, expect } from 'vitest'; import { ResponsiveConfigSchema, + ResponsiveStylesSchema, + StyleMapSchema, + BreakpointColumnMapSchema, + BreakpointOrderMapSchema, BreakpointName, type ResponsiveConfig, - type PerformanceConfig, } from './responsive.zod'; +import { PageComponentSchema } from './page.zod'; describe('BreakpointName', () => { it('should accept all valid breakpoint names', () => { @@ -89,4 +93,214 @@ describe('ResponsiveConfigSchema', () => { // PerformanceConfigSchema tests removed with the schema (#3896 close-out): // every carrier of a `performance` block was authorable and inert. +// +// The `type PerformanceConfig` import that survived beside them until #4001 +// batch 13 did not: it named an export removed with the schema, so this file +// had been importing a type that does not exist. It compiled only because +// nothing here used it. +// --------------------------------------------------------------------------- +// #4001 batch 13 (ADR-0078). This file carries TWO breakpoint vocabularies and +// they sit sixteen lines apart on the same page component — `responsiveStyles` +// (large/medium/small/xsmall, ADR-0065) and `responsive` (columns/order keyed +// xs…2xl). Crossing them is the mistake this file invites, and until now every +// crossing was silent. +// --------------------------------------------------------------------------- +describe('unknown keys are rejected, not stripped (#4001 batch 13)', () => { + const unknownKeyIssue = (schema: { safeParse: (v: unknown) => any }, value: unknown) => { + const result = schema.safeParse(value); + expect(result.success).toBe(false); + return result.error!.issues.find((i: { code: string }) => i.code === 'unrecognized_keys'); + }; + + describe('ResponsiveStylesSchema', () => { + it('rejects an undeclared bucket instead of dropping it', () => { + expect(unknownKeyIssue(ResponsiveStylesSchema, { desktop: { fontSize: '40px' } })!.message) + .toContain('`desktop`'); + }); + + it('maps the Tailwind ramp onto the max-width bucket that contains it', () => { + // The sibling vocabulary: `BreakpointName`, declared in this same file and + // used by `responsive` on the same component. Edit distance cannot get + // from `lg` to `large`, so only a written-down alias answers it. + const cases: Array<[string, string]> = [ + ['xs', 'xsmall'], ['sm', 'small'], ['md', 'medium'], + ['lg', 'large'], ['xl', 'large'], ['2xl', 'large'], + ]; + for (const [wrote, meant] of cases) { + expect( + unknownKeyIssue(ResponsiveStylesSchema, { [wrote]: { fontSize: '1px' } })!.message, + `\`${wrote}\` should point at \`${meant}\``, + ).toContain(`\`${wrote}\` → \`${meant}\``); + } + }); + + it('points a `responsive` key at the sibling key, not at a bucket', () => { + for (const key of ['columns', 'hiddenOn', 'order']) { + const message = unknownKeyIssue(ResponsiveStylesSchema, { [key]: {} })!.message; + expect(message, `\`${key}\` should be sent one level out`).toContain('sibling key'); + expect(message).toContain('`responsive:'); + } + }); + + it('still accepts every declared bucket', () => { + const parsed = ResponsiveStylesSchema.parse({ + large: { display: 'flex', gap: 'var(--space-2)' }, + medium: { gap: '4px' }, + small: { fontSize: '18px' }, + xsmall: { display: 'none' }, + }); + expect(parsed.large?.display).toBe('flex'); + expect(parsed.xsmall?.display).toBe('none'); + }); + }); + + describe('ResponsiveConfigSchema', () => { + it('rejects an undeclared key instead of dropping it', () => { + expect(unknownKeyIssue(ResponsiveConfigSchema, { colums: { lg: 4 } })!.message) + .toContain('`colums` → `columns`'); + }); + + it('answers a bare breakpoint name with the knob-first shape', () => { + // The legacy breakpoint-keyed form. Not hypothetical: it is the `before` + // fixture of the `view-inert-keys-removed` conversion, from the + // `view.responsive` retired in 17 (#3896). + const message = unknownKeyIssue(ResponsiveConfigSchema, { sm: {} })!.message; + expect(message).toContain('BREAKPOINT NAME'); + expect(message).toContain("columns: { sm: 6 }"); + }); + + it('names each offending breakpoint separately, not once for all of them', () => { + // `guidance` emits one bullet VERBATIM per key, so a shared prescription + // string prints the same paragraph N times (the batch 10 `join` / + // `joinGateway` lesson). Each name carries its own text instead. + const message = unknownKeyIssue(ResponsiveConfigSchema, { sm: {}, lg: {} })!.message; + expect(message).toContain("columns: { sm: 6 }"); + expect(message).toContain("columns: { lg: 6 }"); + }); + + it('sends a `responsiveStyles` bucket back to the sibling key', () => { + for (const bucket of ['large', 'medium', 'small', 'xsmall']) { + expect(unknownKeyIssue(ResponsiveConfigSchema, { [bucket]: {} })!.message) + .toContain('`responsiveStyles`'); + } + expect(unknownKeyIssue(ResponsiveConfigSchema, { responsiveStyles: {} })!.message) + .toContain('one level'); + }); + + it('reaches `hiddenOn` from both wrong spellings', () => { + // `hidden` is objectui's RESOLVED spelling (`useResponsiveConfig` returns + // `{ hidden, columns, order, breakpoint }`). `hideOn` is the same word, + // and the distance fallback measurably cannot reach it — it lowercases + // the input but not the candidates, so the capital in `hiddenOn` costs an + // extra edit against a budget of 2 (filed as #4990). + expect(unknownKeyIssue(ResponsiveConfigSchema, { hidden: true })!.message) + .toContain('`hidden` → `hiddenOn`'); + expect(unknownKeyIssue(ResponsiveConfigSchema, { hideOn: ['xs'] })!.message) + .toContain('`hideOn` → `hiddenOn`'); + }); + }); + + describe('the per-breakpoint maps', () => { + it('maps the styling vocabulary back onto the Tailwind ramp', () => { + for (const schema of [BreakpointColumnMapSchema, BreakpointOrderMapSchema]) { + for (const [wrote, meant] of [['large', 'lg'], ['medium', 'md'], ['small', 'sm'], ['xsmall', 'xs']]) { + expect(unknownKeyIssue(schema, { [wrote]: 4 })!.message) + .toContain(`\`${wrote}\` → \`${meant}\``); + } + } + }); + + it('answers the recorded `xxl` near-miss with `2xl`', () => { + // Pinned as an invalid breakpoint name by this file's own test since + // before #4001 ("should reject invalid breakpoint names", above). + expect(unknownKeyIssue(BreakpointColumnMapSchema, { xxl: 4 })!.message) + .toContain('`xxl` → `2xl`'); + }); + + it('was the worst of the three, because half the map survived', () => { + // Measured on `main` before this change: + // ResponsiveConfigSchema.parse({ columns: { large: 4, lg: 3 } }) + // → { columns: { lg: 3 } } + // The node did lay out — at the wrong width, on the breakpoints the + // author never named. A total loss is at least visible. + const result = ResponsiveConfigSchema.safeParse({ columns: { large: 4, lg: 3 } }); + expect(result.success).toBe(false); + }); + + it('still accepts every declared breakpoint, including the quoted one', () => { + expect(BreakpointColumnMapSchema.parse({ xs: 12, sm: 6, md: 4, lg: 3, xl: 2, '2xl': 1 })['2xl']).toBe(1); + expect(BreakpointOrderMapSchema.parse({ xs: 3, '2xl': 1 })['2xl']).toBe(1); + }); + }); + + // THE SEAM THIS CHANGE EXISTS FOR. `PageComponentSchema` has been `.strict()` + // since ADR-0089 D3a, and that never reached these blocks — a strict shell + // over strip-mode children is not a closed surface, it is a closed surface's + // silhouette. + describe('the page-component seam', () => { + const component = (extra: Record) => ({ + type: 'element:text', id: 't1', ...extra, + }); + + it('keeps parsing exactly what it parsed before — pinned', () => { + // The showcase authors `responsiveStyles` on ~40 nodes in this shape + // (`examples/app-showcase/src/ui/pages/*.page.ts`); `page.test.ts` authors + // `responsive: { hiddenOn: [...] }`. Both must be untouched by this change. + const parsed = PageComponentSchema.parse(component({ + responsiveStyles: { + large: { fontSize: '40px', fontWeight: '700' }, + small: { fontSize: '30px' }, + }, + responsive: { hiddenOn: ['xs', 'sm'], columns: { xs: 12, lg: 4 }, order: { lg: 1 } }, + })); + expect(parsed.responsiveStyles?.large?.fontSize).toBe('40px'); + expect(parsed.responsive?.hiddenOn).toEqual(['xs', 'sm']); + expect(parsed.responsive?.columns?.lg).toBe(4); + }); + + it('no longer accepts a component whose styling silently evaporates', () => { + // Before this change the SAME input parsed clean and returned + // `{ responsiveStyles: {}, responsive: {} }` — every styling and layout + // instruction the author wrote, gone, reported valid. + const result = PageComponentSchema.safeParse(component({ + responsiveStyles: { lg: { fontSize: '40px' } }, + responsive: { colums: { lg: 4 }, hideOn: ['xs'] }, + })); + expect(result.success).toBe(false); + }); + + it('reports the nested failure at the nested path, not at the component', () => { + const result = PageComponentSchema.safeParse(component({ + responsiveStyles: { lg: { fontSize: '40px' } }, + })); + expect(result.success).toBe(false); + const issue = result.error!.issues[0]; + expect(issue.path).toEqual(['responsiveStyles']); + expect(issue.message).toContain('`lg` → `large`'); + }); + }); + + // Asserted, not assumed, so the next sweep reads a test rather than reaching + // for `strictObject`. + describe('deliberately still open', () => { + it('StyleMapSchema stays open — its key space is every CSS property', () => { + // objectui's `declarations()` camel→kebab-cases whatever it is handed and + // emits it verbatim (`@object-ui/core`, `styling/scoped-styles.ts`), so + // closing this would mean transcribing the CSS property list into the + // spec and rejecting each new one until someone noticed. + const parsed = StyleMapSchema.parse({ + containerType: 'inline-size', + aspectRatio: '16 / 9', + '--custom-token': 'red', + }); + expect(parsed.containerType).toBe('inline-size'); + expect(parsed['--custom-token']).toBe('red'); + }); + + it('…and an open style map does not reopen the bucket around it', () => { + const result = ResponsiveStylesSchema.safeParse({ lg: { containerType: 'inline-size' } }); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/packages/spec/src/ui/responsive.zod.ts b/packages/spec/src/ui/responsive.zod.ts index d8822e402d..ce81e1454b 100644 --- a/packages/spec/src/ui/responsive.zod.ts +++ b/packages/spec/src/ui/responsive.zod.ts @@ -2,15 +2,171 @@ import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; + +// ───────────────────────────────────────────────────────────────────────────── +// WHY THIS FILE IS STRICT (#4001 批 13, ADR-0078) — engineering rationale; the +// author-facing text is the JSDoc on each schema, which is what the generated +// reference page renders. +// +// THIS FILE CARRIES TWO BREAKPOINT VOCABULARIES, AND THEY SIT SIXTEEN LINES +// APART ON THE SAME PAGE COMPONENT. `PageComponentSchema` declares both: +// +// responsiveStyles: ResponsiveStylesSchema // large | medium | small | xsmall +// responsive: ResponsiveConfigSchema // columns/order keyed xs … 2xl +// +// The first is desktop-first max-width buckets (ADR-0065, mirroring Builder.io's +// SDK); the second is the Tailwind-style `BreakpointName` ramp declared at the +// top of this file. Both are correct, neither is a typo of the other, and edit +// distance cannot bridge `lg` → `large`. So the curation below is not decoration +// — crossing the two vocabularies is THE mistake this file invites, and until +// #4001 批 13 every crossing was silent. +// +// Measured on `main` before the change (`ResponsiveStylesSchema.parse`): +// +// { lg: { fontSize: '40px' } } → {} +// { desktop: { fontSize: '40px' } } → {} +// { columns: { large: 4, lg: 3 } } → { columns: { lg: 3 } } +// +// The third one is the worst of the three: half the author's map survived, so +// the node did lay out — at the wrong width, on the breakpoints they did not +// name. And none of it was caught upstream, because THE OUTER GATE DOES NOT +// RECURSE. `PageComponentSchema` has been `.strict()` since ADR-0089 D3a, and it +// still accepted this whole component: +// +// PageComponentSchema.parse({ +// type: 'element:text', id: 't1', +// responsiveStyles: { lg: { fontSize: '40px' } }, +// responsive: { colums: { lg: 4 }, hideOn: ['xs'] }, +// }) +// → { …, responsiveStyles: {}, responsive: {} } +// +// Every styling and layout instruction the author wrote, gone, reported valid. +// That is the campaign's thesis in one parse: a strict shell over strip-mode +// blocks is not a closed surface, it is a closed surface's silhouette. +// +// LIVENESS IS NOT WHAT THIS CHANGE CLAIMS. `responsiveStyles` is read — +// objectui's `compileScopedStyles` (`@object-ui/core`, `styling/scoped-styles.ts`) +// compiles exactly `large`/`medium`/`small`/`xsmall` into id-scoped CSS, and the +// showcase authors it on ~40 nodes. `ResponsiveConfigSchema`'s own liveness is a +// separate, open question tracked outside #4001 — closing a shape says which +// keys it declares, never that a renderer reads them. +// ───────────────────────────────────────────────────────────────────────────── + /** * Breakpoint Name Enum * Standard Tailwind-style breakpoint names (xs–2xl). */ -import { lazySchema } from '../shared/lazy-schema'; export const BreakpointName = z.enum(['xs', 'sm', 'md', 'lg', 'xl', '2xl']); export type BreakpointName = z.infer; +/** + * Aliases for the two per-breakpoint MAPS (`columns` / `order`), which are keyed + * by {@link BreakpointName}. + * + * Anchored to a named sibling rather than to edit distance: the four targets are + * the key set of {@link ResponsiveStylesSchema}, the OTHER breakpoint vocabulary + * on the same page component. `large` → `lg` is a vocabulary crossing, not a + * misspelling, so only a written-down entry can answer it. + * + * `xxl` is the one entry that is not a crossing — it is the recorded near-miss + * for `2xl`, pinned as rejected by this file's own test since before #4001 + * (`responsive.test.ts`, "should reject invalid breakpoint names"). + */ +const BREAKPOINT_MAP_ALIASES = { + large: 'lg', + medium: 'md', + small: 'sm', + xsmall: 'xs', + xxl: '2xl', +} as const; + +const BREAKPOINT_MAP_HISTORY = + 'Until #4001 批 13 a breakpoint name this map does not declare was dropped silently — ' + + 'the surviving half of the map still laid the node out, at the wrong width, on the ' + + 'breakpoints the author never named.'; + +/** + * Breakpoint Column Map Schema + * Maps breakpoint names to grid column counts (1-12). + * All entries are optional — only specified breakpoints are configured. + */ +export const BreakpointColumnMapSchema = lazySchema(() => strictObject( + { + surface: 'this per-breakpoint column map', + history: BREAKPOINT_MAP_HISTORY, + aliases: BREAKPOINT_MAP_ALIASES, + }, + { + xs: z.number().min(1).max(12).optional(), + sm: z.number().min(1).max(12).optional(), + md: z.number().min(1).max(12).optional(), + lg: z.number().min(1).max(12).optional(), + xl: z.number().min(1).max(12).optional(), + '2xl': z.number().min(1).max(12).optional(), + }, +).describe('Grid columns per breakpoint (1-12)')); + +/** + * Breakpoint Order Map Schema + * Maps breakpoint names to display order numbers. + * All entries are optional — only specified breakpoints are configured. + */ +export const BreakpointOrderMapSchema = lazySchema(() => strictObject( + { + surface: 'this per-breakpoint order map', + history: BREAKPOINT_MAP_HISTORY, + aliases: BREAKPOINT_MAP_ALIASES, + }, + { + xs: z.number().optional(), + sm: z.number().optional(), + md: z.number().optional(), + lg: z.number().optional(), + xl: z.number().optional(), + '2xl': z.number().optional(), + }, +).describe('Display order per breakpoint')); + +/** + * A bare breakpoint name written at the `responsive` LEVEL rather than inside + * one of its maps — the legacy breakpoint-keyed shape. + * + * This is not hypothetical: the retired `view.responsive` was authored that way, + * and the conversion registry still carries `responsive: { sm: {} }` as the + * `before` fixture of `view-inert-keys-removed`. A rename cannot answer it — + * three different keys are plausible targets — so each name gets its own + * prescription. Per-key rather than one shared string, because `guidance` emits + * one bullet VERBATIM per offending key and a shared string prints the same + * paragraph N times (the 批 10 `join`/`joinGateway` lesson). + */ +const BREAKPOINT_AT_TOP_LEVEL = Object.fromEntries( + (['xs', 'sm', 'md', 'lg', 'xl', '2xl'] as const).map((bp) => [ + bp, + `\`${bp}\` is a BREAKPOINT NAME, not a key on this block — the breakpoint-keyed ` + + `shape (\`responsive: { ${bp}: … }\`) belonged to \`view.responsive\`, retired in 17 ` + + `(#3896). Name the knob first, the breakpoint second: \`columns: { ${bp}: 6 }\`, ` + + `\`order: { ${bp}: 2 }\`, or \`hiddenOn: ['${bp}']\`.`, + ]), +); + +/** + * A key from the SIBLING vocabulary — `responsiveStyles`' max-width buckets — + * written on the layout block instead. The fix is the sibling key on the same + * component, so this is a wrong-layer pointer rather than a rename. + */ +const STYLE_BUCKET_AT_LAYOUT_LEVEL = Object.fromEntries( + (['large', 'medium', 'small', 'xsmall'] as const).map((bucket) => [ + bucket, + `\`${bucket}\` is a \`responsiveStyles\` bucket, not a \`responsive\` key — this block ` + + `configures LAYOUT (grid columns / visibility / order) on the \`xs\`…\`2xl\` axis. For ` + + `per-breakpoint CSS write the sibling key on this component: ` + + `\`responsiveStyles: { ${bucket}: { … } }\` (ADR-0065).`, + ]), +); + /** * Responsive Configuration Schema * @@ -26,49 +182,54 @@ export type BreakpointName = z.infer; * }; * ``` */ -/** - * Breakpoint Column Map Schema - * Maps breakpoint names to grid column counts (1-12). - * All entries are optional — only specified breakpoints are configured. - */ -export const BreakpointColumnMapSchema = lazySchema(() => z.object({ - xs: z.number().min(1).max(12).optional(), - sm: z.number().min(1).max(12).optional(), - md: z.number().min(1).max(12).optional(), - lg: z.number().min(1).max(12).optional(), - xl: z.number().min(1).max(12).optional(), - '2xl': z.number().min(1).max(12).optional(), -}).describe('Grid columns per breakpoint (1-12)')); +export const ResponsiveConfigSchema = lazySchema(() => strictObject( + { + surface: 'this responsive layout configuration', + history: + 'Until #4001 批 13 these were dropped silently — and the component around them ' + + 'parsed clean, because `PageComponentSchema` is strict only at its own level.', + aliases: { + // objectui's `useResponsiveConfig` resolves this block and returns + // `{ hidden, columns, order, breakpoint }` (`@object-ui/mobile`, + // `useResponsiveConfig.ts`). `hidden` is that RESULT's spelling of the + // authored `hiddenOn`, which is where the wrong word comes from. + hidden: 'hiddenOn', + // `hideOn` is not a different word — it is the SAME word, and the + // distance fallback still cannot reach it. Measured, not assumed: + // `findClosestMatches('hideOn', ['hiddenOn'], 2)` returns `[]`, because + // the fallback lowercases the INPUT but not the CANDIDATES, so every + // capital in a declared key costs one extra edit — `hideOn` scores 3 + // against a budget of 2 while the all-lowercase `hiddenon` scores 1 and + // resolves fine. That asymmetry is general to camelCase keys (i.e. to + // most of the spec, per AGENTS.md naming) and is filed as #4990; this + // entry covers the one instance this file owns. + hideOn: 'hiddenOn', + }, + guidance: { + ...BREAKPOINT_AT_TOP_LEVEL, + ...STYLE_BUCKET_AT_LAYOUT_LEVEL, + responsiveStyles: + '`responsiveStyles` is a SIBLING key on the component, not a key inside ' + + '`responsive`. Move it out one level: `{ …component, responsive: { … }, ' + + 'responsiveStyles: { large: { … } } }` (ADR-0065).', + }, + }, + { + /** Minimum breakpoint for visibility */ + breakpoint: BreakpointName.optional() + .describe('Minimum breakpoint for visibility'), -/** - * Breakpoint Order Map Schema - * Maps breakpoint names to display order numbers. - * All entries are optional — only specified breakpoints are configured. - */ -export const BreakpointOrderMapSchema = lazySchema(() => z.object({ - xs: z.number().optional(), - sm: z.number().optional(), - md: z.number().optional(), - lg: z.number().optional(), - xl: z.number().optional(), - '2xl': z.number().optional(), -}).describe('Display order per breakpoint')); - -export const ResponsiveConfigSchema = lazySchema(() => z.object({ - /** Minimum breakpoint for visibility */ - breakpoint: BreakpointName.optional() - .describe('Minimum breakpoint for visibility'), - - /** Hide on specific breakpoints */ - hiddenOn: z.array(BreakpointName).optional() - .describe('Hide on these breakpoints'), - - /** Grid columns per breakpoint (1-12 column grid) */ - columns: BreakpointColumnMapSchema.optional().describe('Grid columns per breakpoint'), - - /** Display order per breakpoint */ - order: BreakpointOrderMapSchema.optional().describe('Display order per breakpoint'), -}).describe('Responsive layout configuration')); + /** Hide on specific breakpoints */ + hiddenOn: z.array(BreakpointName).optional() + .describe('Hide on these breakpoints'), + + /** Grid columns per breakpoint (1-12 column grid) */ + columns: BreakpointColumnMapSchema.optional().describe('Grid columns per breakpoint'), + + /** Display order per breakpoint */ + order: BreakpointOrderMapSchema.optional().describe('Display order per breakpoint'), + }, +).describe('Responsive layout configuration')); export type ResponsiveConfig = z.infer; @@ -78,6 +239,17 @@ export type ResponsiveConfig = z.infer; * A CSS property → value map (camelCase keys, e.g. `flexDirection`). Values are * arbitrary CSS strings/numbers but authors should prefer design tokens * (`var(--space-6)`, `var(--surface)`) for consistency and AI-safety. + * + * **Deliberately OPEN, and it must stay open** (#4001 批 13 exemption). The key + * space here is *every CSS property*, not a contract this repo owns: objectui's + * `declarations()` camel→kebab-cases whatever it is handed and emits it verbatim + * (`@object-ui/core`, `styling/scoped-styles.ts`), so closing it would mean + * transcribing the CSS property list into the spec and rejecting each new one + * until we noticed. That is the `open` class of the #4001 ledger, not an + * unfinished row — pinned in `responsive.test.ts` so a later sweep stops here + * instead of "finishing" the file. (`z.record` has no unknown-key posture at + * all, so this exemption costs no `.strict()` decision; it is recorded because + * the REASON is what a later reader needs.) */ export const StyleMapSchema = lazySchema(() => z.record(z.string(), z.union([z.string(), z.number()])) @@ -97,12 +269,49 @@ export type StyleMap = z.infer; * columns / visibility / order) on the Tailwind `xs..2xl` axis. This styles a * node's own box; that arranges a node within a grid. */ -export const ResponsiveStylesSchema = lazySchema(() => z.object({ - large: StyleMapSchema.optional().describe('Unconditional base (desktop-first)'), - medium: StyleMapSchema.optional().describe('Applied at ≤ medium breakpoint'), - small: StyleMapSchema.optional().describe('Applied at ≤ small breakpoint'), - xsmall: StyleMapSchema.optional().describe('Applied at ≤ xsmall breakpoint'), -}).describe('Per-breakpoint scoped style maps (ADR-0065)')); +export const ResponsiveStylesSchema = lazySchema(() => strictObject( + { + surface: 'this per-breakpoint style map', + history: + 'Until #4001 批 13 a bucket this shape does not declare was dropped silently — ' + + 'and since the whole block is optional, a node whose every style was written ' + + 'under the wrong vocabulary rendered completely unstyled and parsed clean.', + aliases: { + // The Tailwind ramp is `BreakpointName`, declared at the top of this file + // and used by the SIBLING `responsive` key on the same page component. + // Mapped onto the max-width bucket that contains each one: objectui's + // `STYLE_BREAKPOINTS` cuts at medium ≤991px, small ≤640px, xsmall ≤479px + // (`@object-ui/core`, `styling/scoped-styles.ts`), and `large` is the + // unconditional base, so everything above `md` lands there. + xs: 'xsmall', + sm: 'small', + md: 'medium', + lg: 'large', + xl: 'large', + '2xl': 'large', + }, + guidance: { + columns: + '`columns` is a `responsive` key, not a `responsiveStyles` bucket — this block ' + + 'holds per-breakpoint CSS. For a grid column count write the sibling key on this ' + + 'component: `responsive: { columns: { lg: 4 } }`.', + hiddenOn: + '`hiddenOn` is a `responsive` key, not a `responsiveStyles` bucket. Write the ' + + "sibling key on this component: `responsive: { hiddenOn: ['xs'] }` — or express it " + + 'as CSS here with `xsmall: { display: \'none\' }`.', + order: + '`order` is a `responsive` key, not a `responsiveStyles` bucket. Write the sibling ' + + 'key on this component: `responsive: { order: { lg: 1 } }` — or express it as CSS ' + + "here with `large: { order: '1' }`.", + }, + }, + { + large: StyleMapSchema.optional().describe('Unconditional base (desktop-first)'), + medium: StyleMapSchema.optional().describe('Applied at ≤ medium breakpoint'), + small: StyleMapSchema.optional().describe('Applied at ≤ small breakpoint'), + xsmall: StyleMapSchema.optional().describe('Applied at ≤ xsmall breakpoint'), + }, +).describe('Per-breakpoint scoped style maps (ADR-0065)')); export type ResponsiveStyles = z.infer; @@ -114,4 +323,3 @@ export type ResponsiveStyles = z.infer; * ever read a performance block. An exported schema with no consumer is read * as a capability by whoever finds it (#3950 precedent). */ - diff --git a/packages/spec/src/ui/touch.test.ts b/packages/spec/src/ui/touch.test.ts index cc63c0d18b..56c774da5c 100644 --- a/packages/spec/src/ui/touch.test.ts +++ b/packages/spec/src/ui/touch.test.ts @@ -195,3 +195,85 @@ describe('I18n and ARIA integration', () => { expect(interaction.ariaLabel).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// #4001 batch 13 -- THIS FILE IS DELIBERATELY NOT `.strict()`, on a measurement. +// +// The strictness ledger scheduled these 7 sites as `authorable (p)`. Resolving +// the `(p)` found no authoring door at all: nothing under `packages/spec/src` +// imports this module except the `ui/index.ts` barrel, a BFS from all 24 +// metadata-type roots plus `defineStack`'s `ObjectStackSchema` never reaches +// these schemas (`PageSchema` / `WebhookSchema` / `StateMachineSchema` pass as +// positive controls in the same run), and no `.parse()` on any of them exists +// in `objectstack`, `objectui` or the example apps outside this test file. +// `.strict()` is a property of a PARSE, and there is no parse to gate. +// +// So the strip pinned below is not an unfinished row -- it is the recorded +// verdict. The open question is ADR-0049 enforce-or-remove, filed as #4988. +// These assertions exist so the next sweep stops and reads instead of reaching +// for `strictObject` and shipping a precisely-validated dead slot (#4583). The +// header comment in `touch.zod.ts` and this file's ledger row carry the same verdict. +// --------------------------------------------------------------------------- +describe('unknown-key posture is an open question, not an omission (#4001 batch 13 -> #4988)', () => { + it('TouchTargetConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = TouchTargetConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + it('TouchTargetConfigSchema.hitSlop (the nested site) strips too -- pending #4988', () => { + const parsed = TouchTargetConfigSchema.parse({ hitSlop: { top: 4, aKeyThisShapeDoesNotDeclare: 1 } }); + expect((parsed.hitSlop as Record).aKeyThisShapeDoesNotDeclare).toBeUndefined(); + expect(parsed.hitSlop?.top).toBe(4); + }); + + it('SwipeGestureConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = SwipeGestureConfigSchema.parse({ direction: ['left'], aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + it('PinchGestureConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = PinchGestureConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + it('LongPressGestureConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = LongPressGestureConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + it('GestureConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = GestureConfigSchema.parse({ type: 'swipe', aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + it('TouchInteractionSchema still strips rather than rejecting -- deliberate, pending #4988', () => { + const parsed = TouchInteractionSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record; + expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined(); + }); + + // The standing half of measurement 1, so the verdict cannot go stale in + // silence: the day someone gives this vocabulary a carrier they will add an + // import, and this is where they are told to revisit #4988 and the ledger. + it('is still imported by nothing but the ui/ barrel', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const importers: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') + && full !== path.join(root, 'ui', 'touch.zod.ts')) { + if (/(?:import|export)[^;]*['"][^'"]*\/touch\.zod['"]/.test(fs.readFileSync(full, 'utf-8'))) { + importers.push(path.relative(root, full)); + } + } + } + }; + walk(root); + expect(importers, 'a new importer means this vocabulary got a carrier -- re-read #4988') + .toEqual(['ui/index.ts']); + }); +}); diff --git a/packages/spec/src/ui/touch.zod.ts b/packages/spec/src/ui/touch.zod.ts index 7ea0edbba2..261aeade11 100644 --- a/packages/spec/src/ui/touch.zod.ts +++ b/packages/spec/src/ui/touch.zod.ts @@ -2,12 +2,53 @@ import { z } from 'zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; +import { lazySchema } from '../shared/lazy-schema'; + +// --------------------------------------------------------------------------- +// NOT CLOSED AGAINST UNKNOWN KEYS -- AND THAT IS THE MEASURED VERDICT +// (#4001 batch 13 / 批 13, ADR-0078). Read this before "finishing" the file. +// +// The strictness ledger scheduled this file's 7 object sites as `authorable +// (p)` -- provisional. #4001's own rule is verify-before-tightening, and here +// the verification came back NEGATIVE: no metadata document is ever parsed +// against these shapes, because nothing in the protocol carries them. +// +// Three independent measurements, 2026-08-03: +// +// 1. STATIC -- nothing under `packages/spec/src` imports this module except +// the `ui/index.ts` barrel. No schema anywhere declares a `component.touch / page.touch` +// slot, so there is no key an author can write to reach these shapes. +// 2. GRAPH -- a BFS over this build's in-memory Zod graph from all 24 +// metadata-type roots (`listMetadataTypeSchemaTypes`) plus +// `ObjectStackSchema` (`defineStack`) -- the closure `build-schemas.ts` +// uses for the #4650 deletion check -- reaches none of them. Its three +// positive controls resolve `root-graph` in the same run: `PageSchema`, +// `WebhookSchema` (batch 11's `defineStack({ webhooks })` door) and +// `StateMachineSchema` (batch 10's `agent.lifecycle` door). So +// "unreachable" is a fact about the graph, not a broken instrument. +// 3. CALL SITES -- no `.parse()` / `.safeParse()` on any schema here exists +// in `objectstack`, `objectui` or the example apps, outside this file's +// own unit test. objectui re-exports the inferred TYPES only and says so +// (`@object-ui/types`, the #2561 note: the validators are deliberately +// NOT re-exported). +// +// `.strict()` would therefore gate nothing -- strictness is a property of a +// PARSE, and there is no parse. Adding it would spend a v17 breaking change to +// make this file LOOK finished, and leave behind the artefact the ledger +// itself warns about: "a *precisely validated* dead slot is the more +// convincing lie" (#4583). The real question is ADR-0049 enforce-or-remove -- +// retire this vocabulary or give it a carrier -- filed as #4988, with the same +// verdict recorded in this file's ledger row. +// +// DO NOT convert these sites to `strictObject` before #4988 is decided: a +// strict shape reads as load-bearing and makes the retirement harder, which is +// the opposite of what the measurement asks for. +// --------------------------------------------------------------------------- /** * Touch Target Configuration Schema * Ensures touch targets meet WCAG 2.5.5 minimum size requirements (44x44px). */ -import { lazySchema } from '../shared/lazy-schema'; export const TouchTargetConfigSchema = lazySchema(() => z.object({ minWidth: z.number().default(44).describe('Minimum touch target width in pixels (WCAG 2.5.5: 44px)'), minHeight: z.number().default(44).describe('Minimum touch target height in pixels (WCAG 2.5.5: 44px)'), From a9edc417a8146941449b7ca56aad1679b77bcba3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 19:32:37 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(spec):=20record=20the=20measured=20bin?= =?UTF-8?q?ding=20door=20for=20the=20responsive=20strictness=20(#4001=20?= =?UTF-8?q?=E6=89=B9=2013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The door is getMetadataTypeSchema('page') — MetadataManager.validate, GET /api/v1/meta, the Studio page form. It is NOT objectstack build/validate: a key PageComponentSchema has rejected since ADR-0089 D3a passes both and lands in the built artifact. Pre-existing, filed as #5000; recorded here so the campaign's usual "three example apps validate" line is not read as evidence for this surface. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- packages/spec/src/ui/responsive.zod.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/spec/src/ui/responsive.zod.ts b/packages/spec/src/ui/responsive.zod.ts index ce81e1454b..ffe159f882 100644 --- a/packages/spec/src/ui/responsive.zod.ts +++ b/packages/spec/src/ui/responsive.zod.ts @@ -46,6 +46,18 @@ import { strictObject } from '../shared/strict-object'; // That is the campaign's thesis in one parse: a strict shell over strip-mode // blocks is not a closed surface, it is a closed surface's silhouette. // +// WHERE THIS BINDS, MEASURED — because a tightening must not claim reach it does +// not have. The door is `getMetadataTypeSchema('page')`, i.e. the registry read +// by `MetadataManager.validate`, `GET /api/v1/meta` and the Studio page form: +// against it, the crossed vocabulary and the legacy breakpoint-keyed shape are +// both rejected, and a clean page still parses. It does NOT bind in +// `objectstack build` / `validate` — that path never parses page metadata at +// all, which a control proves rather than a guess: a key `PageComponentSchema` +// has rejected since ADR-0089 D3a passes both commands and lands in the built +// artifact. That gap predates this change and is filed as #5000; it is recorded +// here so nobody reads the campaign's usual "three example apps validate" line +// as evidence for this surface. +// // LIVENESS IS NOT WHAT THIS CHANGE CLAIMS. `responsiveStyles` is read — // objectui's `compileScopedStyles` (`@object-ui/core`, `styling/scoped-styles.ts`) // compiles exactly `large`/`medium`/`small`/`xsmall` into id-scoped CSS, and the