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 9ead54a487..bcc6059ac3 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 | @@ -664,7 +673,7 @@ 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/` — 123 strip of 198 +#### `ui/` — 119 strip of 198 | File | Strip | Sites | Class | Batch | |---|---|---|---|---| @@ -673,14 +682,13 @@ next person to open that file will look. | `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` | @@ -688,9 +696,39 @@ next person to open that file will look. | `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..ffe159f882 100644 --- a/packages/spec/src/ui/responsive.zod.ts +++ b/packages/spec/src/ui/responsive.zod.ts @@ -2,15 +2,183 @@ 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. +// +// 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 +// 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 +194,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 +251,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 +281,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 +335,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)'),