From 9960dc9a0937ddb40a0cb1423e0443a85a060f41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 17:53:46 +0000 Subject: [PATCH 1/2] spec: fold the three hand-written unrecognized_keys maps into strictObject guidance (#6619) - strictUnknownKeyError grows a set-keyed guidance form (KeySetGuidance + guidanceSets): one prescription per named key family, exact entry wins, declaration order among sets, one bullet per message, rename suppressed. - strictVisibilityError -> VISIBILITY_STRICT_OPTIONS (pattern-keyed set); strictWidgetAnalyticsError -> WIDGET_GUIDANCE_SETS (three sets); strictTenancyError -> exact guidance + history-slot explainer. - strictObjectError split out of strictObject so FormFieldBaseSchema registers with the audit WITHOUT closing the base (acceptance unchanged); spelled as a literal z.object so the strictness ledger keeps its site. - alias-integrity gains set-member/dead-entry, pattern-example and fold-closure checks (#6416 blind spot closed). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk --- .../2026-07-unknown-key-strictness-ledger.md | 2 +- packages/spec/api-surface/shared.json | 4 +- .../spec/scripts/strictness-ledger.test.ts | 25 +-- packages/spec/src/data/field.zod.ts | 2 +- packages/spec/src/data/object.test.ts | 75 +++++--- packages/spec/src/data/object.zod.ts | 85 +++++---- .../spec/src/shared/alias-integrity.test.ts | 104 +++++++++++ .../spec/src/shared/strict-object.test.ts | 128 +++++++++++++ packages/spec/src/shared/strict-object.ts | 52 +++++- packages/spec/src/shared/suggestions.zod.ts | 128 ++++++++++++- packages/spec/src/shared/visibility.ts | 113 ++++++------ packages/spec/src/ui/dashboard.test.ts | 74 ++++++-- packages/spec/src/ui/dashboard.zod.ts | 171 +++++++++--------- packages/spec/src/ui/page.zod.ts | 13 +- packages/spec/src/ui/view.test.ts | 32 ++-- packages/spec/src/ui/view.zod.ts | 68 ++++--- 16 files changed, 798 insertions(+), 278 deletions(-) diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 74758fbb27..1a3d2244de 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -886,7 +886,7 @@ next person to open that file will look. | File | Class | Batch | |---|---|---| | `component.zod.ts` | **authorable** | **was `no gate` until #5068** (one verdict per cell on purpose — it is the machine-readable input to the generated subtotal, so the history lives here in the evidence). ⛔ **was not strictness work** — measured at 批 17 as having no parse at all: BFS-unreachable from every metadata root (all 52 targets, controls green in the same run), zero production `.parse()` sites in the three repos, and an unknown key inside `components[].properties` demonstrably survives the live `definePage()` door. The carrier (`PageComponentSchema.properties`) is live but is `z.record(z.string(), z.unknown())` — ADR-0089 D3a strictness does not recurse into it. Closing these 29 sites would have gated nothing (#4583), so the batch recorded the verdict and filed the wiring as **#5068**. ✅ **#5068 wired it, and this row's `no gate` verdict is spent — the sites are `authorable`.** `packages/lint/src/validate-component-props.ts` dispatches `ComponentPropsMap` by the component's `type` and judges `properties`: undeclared keys through `lintUnknownKeysAgainstSchema` (the same walker `lintUnknownAuthoringKeys` runs on every metadata collection — one implementation of the posture rules, not a second), values through `safeParse`. It runs on all three authoring commands from the shared registry. **Read the flip precisely, exactly as at #5020: what changed is the PARSE, not the posture.** All 31 entries still STRIP; the gate reports an undeclared key because the walker reads a strip-mode object, and converting these sites to `strictObject` moves that same report into the gate's `safeParse` half (`unrecognized_keys`, routed to the same rule id) — which is what makes the ratchet meaningful rather than cosmetic. Three things the flip did NOT do, each of which someone will otherwise assume: (1) **the carrier is unchanged, by decision** — the maintainer's 2026-08-05 ruling took direction A (gate at the authoring door) and DECLINED direction B (a discriminated `properties`) as breaking against an open `type` union, so `PageComponentSchema.properties` is still `z.record(z.string(), z.unknown())` and `component.test.ts`'s three standing assertions stay GREEN — measured against the landed gate, with their prose updated to say which dispatch landed; (2) **unregistered types are SKIPPED**, a required semantic rather than leniency — the example corpus alone authors 87 nodes across ten types this map does not carry (`flex`, `grid`, `object-metric`, `object-chart`, `record:line_items`, …), and judging them against an absent schema would report every one as broken; (3) **the storage path is still open** — a `saveMetaItem` / REST `/meta` write stores an unvalidated props bag (#4463's fourth wall), recorded rather than fixed. ⚠️ The gate is **WARNING-level** in this first step, and the reason is a measurement: on the example corpus + the three published platform pages it reports **52 findings** (44 value verdicts, 8 undeclared keys), of which 34 are inline `{ en, 'zh-CN' }` label maps against an `I18nLabelSchema` that is a plain `z.string()` (**#5728**, undecided) and 8 more are the same shape on `element:text.content`. Gating those would fail the platform's own pages to enforce declarations the platform does not keep — the `groupBy` judgement #5020 had to make, at corpus scale. That inventory is the acceptance baseline for the error upgrade, which is its own step. See the triage row for the full 批 17 measurement | -| `view.zod.ts` | mixed · 1 authorable, 2 wire | **15 of 20 closed at #4001 批 18**, a sixteenth (`UserFiltersSchema`) at **#5073** once its protocol blocker was adjudicated, a seventeenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**, and then the file's last authoring debt cleared at **#5074**, which closed `ViewItemSchema` (×2 arms), `ListView.sort` AND `ViewFilterRuleSchema` in one structural change. **The strip count went 5 → 3, and the arithmetic is the finding, not the number: FOUR sites closed and TWO were ADDED** — the two arms of the new `ViewItemWireSchema`, which are strip BY DESIGN. That is why this row's Class cell is now a split (`1 authorable, 2 wire`) rather than a smaller `authorable` count: the wire contract that used to live on "the member nobody closed" now has a name, and this map measures posture, not intent. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the fifteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. ⚠️ **`ListView.sort` was closed, REVERTED, and closed again at #5074 — the round trip is the file's most useful finding.** It carried `direction → order`, the #4721 alias for the identical tuple (`{field, direction:'desc'}` parsed to `{field, order:'asc'}` — a silently REVERSED sort). The full suite then failed one case: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as the exact body a console column-sort PUT persists, and objectui stamps that `id` per row (`components/src/custom/sort-builder.tsx:68`/`:94`, `crypto.randomUUID()`). **The mechanism governs every nested block in this file and is the opposite of what the union's own comment implies: `.strip()` does NOT recurse.** `ViewMetadataSchema` rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside `ListViewSchema` is still reached through that member, so a console-stamped key inside it becomes a 422 regardless. `id` was deliberately NOT declared to silence it: it is a React list key, and declaring it would put a UI artifact on the authorable surface and tell an AI author to emit one. **#5074 supplied the missing half and the shape is now CLOSED**: the write door removes the declared decoration vocabulary (`VIEW_CONSOLE_ROW_DECORATIONS` / `stripViewConsoleDecorations`, the mirror of `stripReadDecorations`) BEFORE the union runs, so the opening is recursive-effective where a member-level `.strip()` can never be, and the authoring surface never grew the key. The `direction → order` alias came back with it. Curation on what DID close is anchored to named siblings: an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter used to read them as `strict`, because `postureOf` returned early on the `strictObject` idiom instead of walking the chain. **Fixed at #5072**: the idiom now seeds the initial posture and the chain always runs, so the two read `passthrough` and the directory's strict count drops by 2. The strip count was never affected — neither posture is strip — so this row's numbers do not move. **`UserFiltersSchema` is CLOSED as of #5073, and it is the one site in this file whose blocker was never a strictness question.** Closing it would have 422'd `allowAddTab` — a key objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; because `saveMetaItem` validates but persists the ORIGINAL body, the stripped key still reached the renderer, so the capability WORKED and closing would have removed it rather than making a silent failure loud. 批 18 stopped and filed rather than guessing, and the maintainer adjudicated **promote, then close, in one PR** (2026-08-04): `allowAddTab` is now DECLARED here, so the capability is discoverable from the contract (JSON Schema / Studio SchemaForm / an AI author) instead of living in one React file, and the shape closes behind it with no intermediate state. The rejected option was `SANCTIONED_LOCAL` in objectui, which would have made spec and objectui two sources of truth for one contract — the fork #2231's derive-by-reference exists to prevent (PD#12) — and would have taught authors to delete a working key with a rejection that was itself "correct" (finding 7). Two details the close is worth remembering for. **(a)** The promotion is scoped to what the renderer really does: the add-tab button objectui renders carries no click handler, so `allowAddTab` declares that the affordance RENDERS and deliberately says nothing about creating presets — a `.describe()` promising more would be PD#10's advertise-what-you-don't-deliver, and the renderer gap is filed as **#5236**. **(b)** The 批 6e reliance question resolved exactly as predicted — `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flipped from "drops" to "rejects", which is wanted (the CLI lint `validate-list-view-mode.ts` was already reporting these) — but inheriting the posture also inherits the base's ERROR MAP, whose `knownKeys` were read from the base shape and therefore still listed the omitted keys. Measured on the flip: `tab` was answered *"Did you mean `tab` → `tabs`?"*, steering the author at the one key that surface refuses — finding 7 produced by the fix for finding 7. So the object variant now carries its own map built over the OMITTED shape (the shape still derived by `.omit()`, so #2231 holds), with `guidance` pointing all three page-only keys at `listViews`. **⚠️ #5074 — the authoring/wire SPLIT, and the row's headline.** `ViewItemSchema` wore two contracts: the authoring gate (`defineViewItem`, objectui's view-create form, which validates `createBuildBody`'s output against the real spec schema) and member 1 of `ViewMetadataSchema`, the union `saveMetaItem` validates every persisted `view` body against. The wire role was measured, not inferred — objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so the merged body lands on member 1 (the flattened members are excluded by their `config: z.undefined()` guard) and closing the one schema would have 422'd pinning a saved view. The maintainer ruled **split** (2026-08-04), and the two-axis reasoning is worth keeping: `defineViewItem({name, object, viewKind, confg: {…}})` — one letter — used to strip the typo and hand back a ViewItem with **no view configuration at all**, parsed clean, which is #1535's `workflows: [...]` replayed on the file's densest authoring surface. `ViewItemSchema` is now `strictObject` on both arms; `ViewItemWireSchema` is the `.strip()` wire variant, built from the SAME `viewItemArmShape()` (derive-by-reference, #2231 — a `discriminatedUnion` cannot be `.extend()`ed, so sharing the shape factory is what keeps one contract from becoming two transcriptions), and `isPinned`/`sortOrder` are DECLARED on it — an explicit home, instead of surviving because nobody closed the member. **The scope addendum's hard requirement was recursive-effective openness, and that is the part a posture flip could not deliver.** `.strip()` re-opens a member's TOP level only, so the two console-decorated NESTED blocks (`ListView.sort[].id`, `ViewFilterRule.id`) were still reached at full strictness through it. The route taken is the addendum's second sanctioned one: a declared decoration vocabulary stripped before validation, at the wire door, reaching every carrier at every depth — including ones added later, which a hand-maintained parallel wire tree would not. It is deliberately NOT a second schema tree (PD#12's fork) and deliberately NOT a declared `id` (批 18 Q1's two-axis rejection: a React list key on the authoring surface teaches AI authors to emit UUIDs). Two landmines were named in the ruling and both are pinned in `view-authoring-wire-split.test.ts` §5: `z.toJSONSchema()` must still emit a four-member `anyOf` (the `/api/v1/meta/types/view` endpoint feeds Studio's SchemaForm from it — it does; a pipe converts to its output side, asserted in BOTH io directions), and the `lazySchema` Proxy's ADR-0089 D3a crash (`Cannot set properties of undefined (setting 'ref')`) must not recur under a pipe-rooted lazy schema — it does not, and each new schema is converted directly rather than only through its parent. **One real hazard the change surfaced, fixed in the same PR:** a `z.preprocess` at a registered root put TWO gate walkers into the exact blind spot #4488 had already found and fixed in `check-liveness.mts` — `metadata-authoring-lint.ts` and `metadata-form-zod-reconciliation.test.ts` both unwrapped a pipe via `def.in`, which for a preprocess is the TRANSFORM, so each reported `view` as *not key-bearing* and silently stopped covering it. Caught by their own coverage assertions (`lintables.length >= 1`, `root schema is not key-bearing`), which is precisely what those assertions exist for; both now prefer whichever side is not the transform. **A gate going quiet is worse than a gate failing** — and the pattern will recur on the next preprocess-rooted registration, so it is recorded here rather than only in the diff. **Still open, one site, measured:** `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 `strictVisibilityError` map; the door is closed, the ledger counts the base. The two remaining strip sites beyond it are `ViewItemWireSchema`'s arms, which are `wire` by design and are not debt. `ViewFilterRuleSchema` — **the same wire contamination, one block over, and it was already LIVE on `main`** (#5114): closed by an earlier wave, while objectui's filter builder stamps `id: crypto.randomUUID()` on every row it writes (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` persists the AUTHORED body verbatim — so saving a filter from the console 422'd, on all three paths including the flattened overlay that is the body actually PUT. Reopened as a p1 hotfix; `id` deliberately NOT declared, for the reason given for `sort` above. **That reopen was explicitly PROVISIONAL — "pending #5074" — and #5074 retired it rather than leaving it standing: the shape is CLOSED again, by the same decoration strip that closed `sort`, so the authoring gate rejects `id` by name while the console's own three paths still parse.** Its pin file now asserts the split per door, and the direction is the INVERTED one worth flagging to the next reader: probes 1/3 and 2/3 were GREEN before #5074 and are RED after (that IS the close), while 3/3 — the body the console actually PUTs — is green on BOTH sides and must stay so; a file that only asserted "the console body parses" would have passed unchanged through a change that quietly declared `id` as authorable. Two details worth keeping: the overlay path's rejection surfaces as `invalid_union` / *"Invalid input"* — the #5014 flattening, so the key that caused it is not in the message the author sees, which is why this sat on `main` unnoticed; and the reopening was verified in BOTH directions (re-close it and 7 assertions in `view-filter-rule-wire-id.test.ts` go red, while that file's two mechanism CONTROLS — top-level aux key rides, nested `emptyState` still rejects — stay green either way, which is what makes them controls). #5074's scope addendum named this site; the gate it was waiting on — a wire opening that REACHES a nested block — landed with it. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` / `view-filter-rule-wire-id.test.ts` + this row) | +| `view.zod.ts` | mixed · 1 authorable, 2 wire | **15 of 20 closed at #4001 批 18**, a sixteenth (`UserFiltersSchema`) at **#5073** once its protocol blocker was adjudicated, a seventeenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**, and then the file's last authoring debt cleared at **#5074**, which closed `ViewItemSchema` (×2 arms), `ListView.sort` AND `ViewFilterRuleSchema` in one structural change. **The strip count went 5 → 3, and the arithmetic is the finding, not the number: FOUR sites closed and TWO were ADDED** — the two arms of the new `ViewItemWireSchema`, which are strip BY DESIGN. That is why this row's Class cell is now a split (`1 authorable, 2 wire`) rather than a smaller `authorable` count: the wire contract that used to live on "the member nobody closed" now has a name, and this map measures posture, not intent. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the fifteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. ⚠️ **`ListView.sort` was closed, REVERTED, and closed again at #5074 — the round trip is the file's most useful finding.** It carried `direction → order`, the #4721 alias for the identical tuple (`{field, direction:'desc'}` parsed to `{field, order:'asc'}` — a silently REVERSED sort). The full suite then failed one case: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as the exact body a console column-sort PUT persists, and objectui stamps that `id` per row (`components/src/custom/sort-builder.tsx:68`/`:94`, `crypto.randomUUID()`). **The mechanism governs every nested block in this file and is the opposite of what the union's own comment implies: `.strip()` does NOT recurse.** `ViewMetadataSchema` rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside `ListViewSchema` is still reached through that member, so a console-stamped key inside it becomes a 422 regardless. `id` was deliberately NOT declared to silence it: it is a React list key, and declaring it would put a UI artifact on the authorable surface and tell an AI author to emit one. **#5074 supplied the missing half and the shape is now CLOSED**: the write door removes the declared decoration vocabulary (`VIEW_CONSOLE_ROW_DECORATIONS` / `stripViewConsoleDecorations`, the mirror of `stripReadDecorations`) BEFORE the union runs, so the opening is recursive-effective where a member-level `.strip()` can never be, and the authoring surface never grew the key. The `direction → order` alias came back with it. Curation on what DID close is anchored to named siblings: an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter used to read them as `strict`, because `postureOf` returned early on the `strictObject` idiom instead of walking the chain. **Fixed at #5072**: the idiom now seeds the initial posture and the chain always runs, so the two read `passthrough` and the directory's strict count drops by 2. The strip count was never affected — neither posture is strip — so this row's numbers do not move. **`UserFiltersSchema` is CLOSED as of #5073, and it is the one site in this file whose blocker was never a strictness question.** Closing it would have 422'd `allowAddTab` — a key objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; because `saveMetaItem` validates but persists the ORIGINAL body, the stripped key still reached the renderer, so the capability WORKED and closing would have removed it rather than making a silent failure loud. 批 18 stopped and filed rather than guessing, and the maintainer adjudicated **promote, then close, in one PR** (2026-08-04): `allowAddTab` is now DECLARED here, so the capability is discoverable from the contract (JSON Schema / Studio SchemaForm / an AI author) instead of living in one React file, and the shape closes behind it with no intermediate state. The rejected option was `SANCTIONED_LOCAL` in objectui, which would have made spec and objectui two sources of truth for one contract — the fork #2231's derive-by-reference exists to prevent (PD#12) — and would have taught authors to delete a working key with a rejection that was itself "correct" (finding 7). Two details the close is worth remembering for. **(a)** The promotion is scoped to what the renderer really does: the add-tab button objectui renders carries no click handler, so `allowAddTab` declares that the affordance RENDERS and deliberately says nothing about creating presets — a `.describe()` promising more would be PD#10's advertise-what-you-don't-deliver, and the renderer gap is filed as **#5236**. **(b)** The 批 6e reliance question resolved exactly as predicted — `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flipped from "drops" to "rejects", which is wanted (the CLI lint `validate-list-view-mode.ts` was already reporting these) — but inheriting the posture also inherits the base's ERROR MAP, whose `knownKeys` were read from the base shape and therefore still listed the omitted keys. Measured on the flip: `tab` was answered *"Did you mean `tab` → `tabs`?"*, steering the author at the one key that surface refuses — finding 7 produced by the fix for finding 7. So the object variant now carries its own map built over the OMITTED shape (the shape still derived by `.omit()`, so #2231 holds), with `guidance` pointing all three page-only keys at `listViews`. **⚠️ #5074 — the authoring/wire SPLIT, and the row's headline.** `ViewItemSchema` wore two contracts: the authoring gate (`defineViewItem`, objectui's view-create form, which validates `createBuildBody`'s output against the real spec schema) and member 1 of `ViewMetadataSchema`, the union `saveMetaItem` validates every persisted `view` body against. The wire role was measured, not inferred — objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so the merged body lands on member 1 (the flattened members are excluded by their `config: z.undefined()` guard) and closing the one schema would have 422'd pinning a saved view. The maintainer ruled **split** (2026-08-04), and the two-axis reasoning is worth keeping: `defineViewItem({name, object, viewKind, confg: {…}})` — one letter — used to strip the typo and hand back a ViewItem with **no view configuration at all**, parsed clean, which is #1535's `workflows: [...]` replayed on the file's densest authoring surface. `ViewItemSchema` is now `strictObject` on both arms; `ViewItemWireSchema` is the `.strip()` wire variant, built from the SAME `viewItemArmShape()` (derive-by-reference, #2231 — a `discriminatedUnion` cannot be `.extend()`ed, so sharing the shape factory is what keeps one contract from becoming two transcriptions), and `isPinned`/`sortOrder` are DECLARED on it — an explicit home, instead of surviving because nobody closed the member. **The scope addendum's hard requirement was recursive-effective openness, and that is the part a posture flip could not deliver.** `.strip()` re-opens a member's TOP level only, so the two console-decorated NESTED blocks (`ListView.sort[].id`, `ViewFilterRule.id`) were still reached at full strictness through it. The route taken is the addendum's second sanctioned one: a declared decoration vocabulary stripped before validation, at the wire door, reaching every carrier at every depth — including ones added later, which a hand-maintained parallel wire tree would not. It is deliberately NOT a second schema tree (PD#12's fork) and deliberately NOT a declared `id` (批 18 Q1's two-axis rejection: a React list key on the authoring surface teaches AI authors to emit UUIDs). Two landmines were named in the ruling and both are pinned in `view-authoring-wire-split.test.ts` §5: `z.toJSONSchema()` must still emit a four-member `anyOf` (the `/api/v1/meta/types/view` endpoint feeds Studio's SchemaForm from it — it does; a pipe converts to its output side, asserted in BOTH io directions), and the `lazySchema` Proxy's ADR-0089 D3a crash (`Cannot set properties of undefined (setting 'ref')`) must not recur under a pipe-rooted lazy schema — it does not, and each new schema is converted directly rather than only through its parent. **One real hazard the change surfaced, fixed in the same PR:** a `z.preprocess` at a registered root put TWO gate walkers into the exact blind spot #4488 had already found and fixed in `check-liveness.mts` — `metadata-authoring-lint.ts` and `metadata-form-zod-reconciliation.test.ts` both unwrapped a pipe via `def.in`, which for a preprocess is the TRANSFORM, so each reported `view` as *not key-bearing* and silently stopped covering it. Caught by their own coverage assertions (`lintables.length >= 1`, `root schema is not key-bearing`), which is precisely what those assertions exist for; both now prefer whichever side is not the transform. **A gate going quiet is worse than a gate failing** — and the pattern will recur on the next preprocess-rooted registration, so it is recorded here rather than only in the diff. **Still open, one site, measured:** `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 visibility error map (`strictVisibilityError` when this was measured; folded into the shared template as `VISIBILITY_STRICT_OPTIONS` + `strictObjectError` at #6619, deliberately WITHOUT closing the base — the site keeps its literal `z.object(` spelling so this instrument keeps counting it); the door is closed, the ledger counts the base. The two remaining strip sites beyond it are `ViewItemWireSchema`'s arms, which are `wire` by design and are not debt. `ViewFilterRuleSchema` — **the same wire contamination, one block over, and it was already LIVE on `main`** (#5114): closed by an earlier wave, while objectui's filter builder stamps `id: crypto.randomUUID()` on every row it writes (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` persists the AUTHORED body verbatim — so saving a filter from the console 422'd, on all three paths including the flattened overlay that is the body actually PUT. Reopened as a p1 hotfix; `id` deliberately NOT declared, for the reason given for `sort` above. **That reopen was explicitly PROVISIONAL — "pending #5074" — and #5074 retired it rather than leaving it standing: the shape is CLOSED again, by the same decoration strip that closed `sort`, so the authoring gate rejects `id` by name while the console's own three paths still parse.** Its pin file now asserts the split per door, and the direction is the INVERTED one worth flagging to the next reader: probes 1/3 and 2/3 were GREEN before #5074 and are RED after (that IS the close), while 3/3 — the body the console actually PUTs — is green on BOTH sides and must stay so; a file that only asserted "the console body parses" would have passed unchanged through a change that quietly declared `id` as authorable. Two details worth keeping: the overlay path's rejection surfaces as `invalid_union` / *"Invalid input"* — the #5014 flattening, so the key that caused it is not in the message the author sees, which is why this sat on `main` unnoticed; and the reopening was verified in BOTH directions (re-close it and 7 assertions in `view-filter-rule-wire-id.test.ts` go red, while that file's two mechanism CONTROLS — top-level aux key rides, nested `emptyState` still rejects — stay green either way, which is what makes them controls). #5074's scope addendum named this site; the gate it was waiting on — a wire opening that REACHES a nested block — landed with it. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` / `view-filter-rule-wire-id.test.ts` + this row) | | `widget.zod.ts` | **no door** | ⛔ **not strictness work** — the whole file measured unreachable from every authoring root (#4001 批 16), with no carrier key and zero parse in all three repos. ADR-0049 triage was **#5055**, and it is ANSWERED: eight of the nine sites were REMOVED (the whole widget-registration vocabulary). The row does not disappear, because the NINTH — `FieldWidgetPropsSchema` — was deliberately KEPT: it is a React props contract rather than authorable metadata, it never appeared in the authorable surface at all, and objectui PR #3289 gave it a live compile-time consumer. ⛔ **Do not close it and do not finish this file** — this is the fourth row in the ledger parked at a deliberate floor (after `flow` 批 11, `etl` 批 12 and `i18n` above), and the reverse pin fires on ZERO either way, so only this cell separates "parked" from "unfinished". See the triage row above, including why the campaign's own BFS said otherwise first (**#5056**) | | `app.zod.ts` | covered | **批 19 ran the check and it came back NEGATIVE — no posture change; the `Class` was held at `verify` pending #5249 and is now `covered`, the verdict that ruling created (see below).** `BaseNavItemSchema`. The instruction here was to confirm the members' strictness was not already covering it before touching; it is, and the premise this row carried was wrong twice. (1) **The members do not `.extend()` the base — they spread `...BaseNavItemSchema.shape`.** That is a different mechanism, and the difference is the whole of finding 16: `.extend()` clones INHERIT the base's posture (which is how closing two `view` authoring schemas silently closed the Studio round-trip overlay), while a `...shape` spread copies the per-key schemas into a FRESH `z.object` whose posture is its own. Measured in both directions rather than read off the source, because *"closing the base closes the members"* and *"closing the base is a no-op"* are opposite claims: `strictBase.extend({…})` rejects an unknown key, `z.object({...strictBase.shape})` accepts it, `z.object({...openBase.shape}).strict()` rejects it. (2) **All nine branches already apply their own `.strict()`** with the curated `navItemUnknownKeyError` — asserted per branch through the real door (`AppSchema.navigation`, a `discriminatedUnion` on `type`), with a positive control (every base-contributed key, incl. `requiresService` which no branch declares itself, is ACCEPTED) and a negative control (an undeclared key is REJECTED) in the same run. The base is also module-private and has zero `.parse()` anywhere, so `.strict()` here would be a property of a parse that does not exist. Closing it is therefore a guaranteed no-op, and #4583 is explicit that a no-op closure is not neutral. ⚠️ **The open question was the VOCABULARY, not the measurement** — which is why 批 19 left the cell alone, since it is machine-read and a guess here would be published as a confident subtotal. The two-axis table above resolved carrier-absent + parse-absent to `no door`, whose prescribed follow-up is ADR-0049 retirement — and that prescription is *destructive* here: the vocabulary is fully ALIVE and fully GATED at nine consumers, so retiring the base would delete nine branches' shared keys. `no gate` is wrong for the mirror reason (the gate exists, at the members). `authorable` is the `FormFieldBaseSchema` precedent one row over in `view.zod.ts` — but that base really is `.extend()`ed, so closing it WOULD change behaviour, and calling this one `authorable` invites exactly the later sweep that "finishes the job" on a shape nothing parses. ✅ **RESOLVED at #5249 (maintainer ruling 2026-08-06, option A): the vocabulary grew a ninth verdict, `covered`, and this row is its first and — as of the sweep below — its ONLY instance.** The ruling took the same route 批 15 took for `no gate` rather than rounding to the nearest wrong answer, on the ground that the cell's readers are later agents and a verdict naming the wrong ACTION is amplified by whoever acts on it. The re-review the ruling required was run over all **197** strip sites in the five triaged directories, not just this file, and it is mechanical rather than a reading: `covered` requires the keys to reach consumers by `...X.shape` SPREAD (a spread lands them in a fresh `z.object` with its own posture, so the base is inert), whereas `.extend()`/`.merge()`/`.omit()` inherit posture and keep the base a real door. Exactly **one** of the 197 sites spreads — this one, into eight of the nine branches (`SeparatorNavItemSchema` declares its own two keys and spreads nothing, and is `.strict()` all the same). The three other module-private strip bases all resolve elsewhere and stay put: `view.zod.ts`'s `FormFieldBaseSchema` is `.extend()`ed at `:1475` → posture inherits → a real door → stays `authorable`; `query.zod.ts`'s `BaseQuerySchema` is `.extend()`ed at `:485` into `QuerySchema` → same → stays `open`; `component.zod.ts`'s `EmptyProps` is used as a VALUE under eleven `ComponentPropsMap` carrier keys → carrier present → not carrier-absent at all. The remaining ~50 sites are inline nested literals under a property, so they carry a carrier by construction and cannot be `covered`. Recorded in three places (the `BaseNavItemSchema` JSDoc + `app-strictness-batch19.test.ts` + this row); the pin includes a guard that fails if any branch ever stops rejecting unknown keys, which is the one change that would make this verdict need re-taking | | `action-params.zod.ts` | wire | **out of scope** — `ActionSessionSchema`, the action-body `ctx.session` the runtime hands a body (#5697). Tolerant on purpose, same disposition as `data/hook.zod.ts`'s `HookContextSchema`. What this surface needed was never a closed door but a gate that RUNS: its consistency with the real producer is pinned in `packages/runtime/src/action-session-shape-contract.test.ts`, which asserts that a non-strict parse of the built object returns it UNCHANGED — so a key the builder starts producing without declaring it here is stripped, and the pin goes red | diff --git a/packages/spec/api-surface/shared.json b/packages/spec/api-surface/shared.json index c811e0d757..6453bb870c 100644 --- a/packages/spec/api-surface/shared.json +++ b/packages/spec/api-surface/shared.json @@ -44,6 +44,7 @@ "HttpRequestSchema (const)", "IsolationLevel (type)", "IsolationLevelEnum (const)", + "KeySetGuidance (interface)", "MAP_SUPPORTED_FIELDS (const)", "METADATA_ALIASES (const)", "MapSupportedField (type)", @@ -89,6 +90,7 @@ "TemplateExpressionInput (type)", "TemplateExpressionInputSchema (const)", "VISIBILITY_ALIAS_KEYS (const)", + "VISIBILITY_STRICT_OPTIONS (const)", "ViewName (type)", "ViewNameParsed (type)", "ViewNameSchema (const)", @@ -100,6 +102,7 @@ "formatSuggestion (function)", "formatZodError (function)", "formatZodIssue (function)", + "keySetMatches (function)", "lazySchema (function)", "levenshteinDistance (function)", "normalizeMetadataCollection (function)", @@ -113,7 +116,6 @@ "safeParsePretty (function)", "singularToPlural (function)", "strictUnknownKeyError (function)", - "strictVisibilityError (const)", "suggestFieldType (function)", "tmpl (function)" ] diff --git a/packages/spec/scripts/strictness-ledger.test.ts b/packages/spec/scripts/strictness-ledger.test.ts index 4afdab95e1..b1e1f0ca31 100644 --- a/packages/spec/scripts/strictness-ledger.test.ts +++ b/packages/spec/scripts/strictness-ledger.test.ts @@ -167,18 +167,21 @@ describe('posture reading, with a red control for each', () => { // The fixture used to be `security/permission.zod.ts`, whose four sites // were the campaign's canonical `z.object(shape, { error }).strict()` // wiring; #5593 migrated all four to `strictObject`, so the file no longer - // exercises the branch under test. `data/object.zod.ts` carries the - // spelling deliberately and is expected to keep carrying it: its two - // remaining `{ error: … }` maps are HAND-WRITTEN `$ZodErrorMap`s - // (`strictCapabilitiesError`, `strictTenancyError`) that emit a standing - // explainer the shared template cannot express, which #6416 recorded as - // out of #5593's reach. If they are ever converted, move this fixture - // rather than deleting the assertion — the AST reader still has to make - // the reading, and `packages/spec` is not the only tree it reads. + // exercised the branch under test, and the fixture moved to + // `TenancyConfigSchema` — until #6619 folded ITS hand-written map into the + // shared template (the set-keyed `guidance` form gave the template the + // vocabulary #6416 had recorded as out of reach) and the site became + // `strictObject` like the rest. `ObjectCapabilities`, same file, is the + // spelling's remaining deliberate carrier: its map + // (`strictCapabilitiesError`) emits NO trailing history sentence, which + // the shared template still cannot express. If it is ever converted, move + // this fixture rather than deleting the assertion — the AST reader still + // has to make the reading, and `packages/spec` is not the only tree it + // reads. const objectSites = analyzeSites(at('data/object.zod.ts')); - const tenancy = objectSites.find((s) => s.name === 'TenancyConfigSchema'); - expect(tenancy?.posture, 'a plain `.strict()` chain is still strict').toBe('strict'); - expect(tenancy?.idiom).toBe('z.object'); + const capabilities = objectSites.find((s) => s.name === 'ObjectCapabilities'); + expect(capabilities?.posture, 'a plain `.strict()` chain is still strict').toBe('strict'); + expect(capabilities?.idiom).toBe('z.object'); // The permission file's four are now the helper, and still strict — the // control that keeps this test a statement about the READER rather than diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 39b08f27ea..dfa6fa27c4 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -262,7 +262,7 @@ export const AddressSchema = lazySchema(() => z.object({ */ /** * Prescriptive rejection for a mis-spelled `unique` scope (ADR-0120 - * §Terminology; pattern of `strictTenancyError`): the error must carry the + * §Terminology; pattern of `strictCapabilitiesError`): the error must carry the * vocabulary and, for the two predictable near-misses (`'tenant'`, `'org'`), * name `'organization'` explicitly — a typo must be a loud, fixable parse * error, never a silent scope change. Declared before `UniqueScopeSchema` diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index feb8434fb3..1ea16a57f6 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1504,10 +1504,24 @@ describe('TenancyConfigSchema — #2763 strategy/crossTenantAccess removal', () }); it('rejects arbitrary unknown tenancy keys instead of silently stripping them (#1535)', () => { + // Truly arbitrary — no tombstone, no near-declared-key. Rejected with the + // surface named; there is nothing more the message can honestly offer. + const result = TenancyConfigSchema.safeParse({ enabled: true, zzNotAKey: 1 }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.message).join('\n')) + .toContain('Unrecognized key(s) on `tenancy`: `zzNotAKey`'); + }); + + it('a near-miss of a live key gets the template rename, not a dead-end verdict (#6619)', () => { + // While the map was hand-written, `tenantfield` was answered with + // "`tenantfield` is not a `tenancy` key." — a verdict that names the + // problem and never the fix. The fold onto `strictObject` brought the + // edit-distance channel with it: the same input now points at the key the + // author meant. A deliberate byte change, recorded as such. const result = TenancyConfigSchema.safeParse({ enabled: true, tenantfield: 'org_id' }); expect(result.success).toBe(false); expect(result.error!.issues.map((i) => i.message).join('\n')) - .toContain('`tenantfield` is not a `tenancy` key'); + .toContain('Did you mean `tenantfield` → `tenantField`?'); }); it('rejects a retired key on ObjectSchema.create() (the authoring entrypoint)', () => { @@ -1522,22 +1536,28 @@ describe('TenancyConfigSchema — #2763 strategy/crossTenantAccess removal', () }); /** - * Message ORDER on `strictTenancyError` (#6416, applying #5955's ruling). + * Message ORDER on the `tenancy` unknown-key rejection (#6416, applying + * #5955's ruling; #6619 folded the map into the shared template). * - * A hand-written `$ZodErrorMap`: it never calls `strictUnknownKeyError`, so - * #5955's reorder of the shared template did not reach it, and it is not one of - * the 44 direct call sites #5593 migrates to `strictObject` either. Its - * explanatory sentence is the standing two-modes explainer, and its FIX channel - * is the per-key ` • ` bullets built just above it — the tombstone that tells - * an upgrading author what to write instead. Those bullets used to sit BEHIND - * ~160 characters of standing background, which is past the front of the - * single-line renders several consumers use (`os validate`'s `• where: message`, - * CI logs). + * Written against `strictTenancyError`, the hand-written `$ZodErrorMap` that + * neither #5955 nor #5593 could reach; #6416 direction 1 reordered it in place + * with these pins as acceptance criteria, and #6619 folded it into + * `strictObject` — the tombstones as exact `guidance` entries, the standing + * two-modes explainer in the template's `history` slot (which is the slot for + * "the sentence that must come LAST"). The pins migrated with the code: the + * ORDER contract (front matter → fix channels → explainer last) is now the + * template's own. Two byte-level changes rode the fold, pinned below: + * + * - a near-miss of a live key (`tenantfield`) gets the template's rename in + * the front matter instead of the dead-end "`x` is not a `tenancy` key." + * bullet the hand-written map emitted; + * - a key with no fix at all gets NO bullet — the front matter plus the + * explainer carry everything the old catch-all bullet said. * * ORDER pins, not presence checks. Every `toContain` in the block above stays * green under either order — that is exactly why they cannot carry this fact. */ -describe('strictTenancyError message order — bullets before the explainer (#6416)', () => { +describe('tenancy unknown-key message order — bullets before the explainer (#6416 / #6619)', () => { const EXPLAINER = 'The two supported tenancy modes are: database-per-tenant = environment-level ' + 'deployment (no object config); row-level isolation = `tenancy.enabled` + ' + @@ -1562,30 +1582,43 @@ describe('strictTenancyError message order — bullets before the explainer (#64 expect(m.endsWith(` ${EXPLAINER}`)).toBe(true); }); - it('keeps EVERY per-key bullet ahead of the explainer, not just the first', () => { + it('keeps EVERY fix channel ahead of the explainer, not just the first', () => { // One issue names every offending key, so the explainer is a per-MESSAGE - // sentence: a reorder that put it after the first bullet would bury the rest. + // sentence: a reorder that put it after the first fix would bury the rest. + // Three keys, all three channels at once: two tombstone bullets plus the + // rename `tenantfield` earns since #6619 (the hand-written map answered it + // with a dead-end "is not a `tenancy` key." bullet instead). const m = messageFor({ strategy: 'isolated', crossTenantAccess: true, tenantfield: 'org_id' }); - for (const bullet of [ + for (const fix of [ + 'Did you mean `tenantfield` → `tenantField`?', '`tenancy.strategy` was removed', '`tenancy.crossTenantAccess` was removed', - '`tenantfield` is not a `tenancy` key.', ]) { - expect(m).toContain(bullet); - expect(m.indexOf(bullet), bullet).toBeLessThan(m.indexOf(EXPLAINER)); + expect(m).toContain(fix); + expect(m.indexOf(fix), fix).toBeLessThan(m.indexOf(EXPLAINER)); } expect(m.split(EXPLAINER)).toHaveLength(2); expect(m.endsWith(` ${EXPLAINER}`)).toBe(true); }); - it('is a full-message pin for the plain unknown-key case', () => { + it('is a full-message pin for the near-miss case', () => { // Any stray separator, dropped newline or duplicated clause fails here. + // Byte change vs the hand-written map, deliberate (#6619): the dead-end + // bullet became the rename the author can act on. expect(messageFor({ tenantfield: 'org_id' })).toBe( - 'Unrecognized key(s) on `tenancy`: `tenantfield`.\n' + - ' • `tenantfield` is not a `tenancy` key. ' + + 'Unrecognized key(s) on `tenancy`: `tenantfield`. ' + + 'Did you mean `tenantfield` → `tenantField`? ' + EXPLAINER, ); }); + + it('is a full-message pin for the no-fix case', () => { + // No tombstone, no near key: no bullet at all — the front matter and the + // explainer carry everything the old catch-all bullet said. + expect(messageFor({ zzNotAKey: 1 })).toBe( + `Unrecognized key(s) on \`tenancy\`: \`zzNotAKey\`. ${EXPLAINER}`, + ); + }); }); describe('isTenancyDisabled — platform-global posture predicate (#3249, ADR-0066)', () => { diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 53da385766..73ec84dbdc 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -151,11 +151,20 @@ const CAPABILITIES_RETIRED_KEY_GUIDANCE: Record = { }; /** - * Custom zod `error` for the `.strict()` capabilities block (pattern of - * `strictTenancyError` below): an unknown key — a retired `trash`/`mru` or a - * typo like `feedEnabled` — is a loud, *fixable* parse error instead of a - * silent strip (#1535), and a retired key's error carries its upgrade - * prescription. Every other issue code defers to zod's default. + * Custom zod `error` for the `.strict()` capabilities block: an unknown key — + * a retired `trash`/`mru` or a typo like `feedEnabled` — is a loud, *fixable* + * parse error instead of a silent strip (#1535), and a retired key's error + * carries its upgrade prescription. Every other issue code defers to zod's + * default. + * + * The LAST hand-written `unrecognized_keys` map in this file — #6619 folded + * its sibling `strictTenancyError` into the shared `strictObject` template, + * and this one stayed out for a reason the template can measure: it emits NO + * trailing history sentence, and `strictUnknownKeyError` appends its `history` + * unconditionally. Fold it only when the template can express a + * history-less surface; `scripts/strictness-ledger.test.ts` uses the + * `ObjectCapabilities` site below as its `z.object(…).strict()` fixture, so + * move that fixture in the same change. */ const strictCapabilitiesError: z.core.$ZodErrorMap = (issue) => { if (issue.code !== 'unrecognized_keys') return undefined; @@ -415,44 +424,21 @@ const TENANCY_RETIRED_KEY_GUIDANCE: Record = { }; /** - * Custom zod `error` for the `.strict()` tenancy block (#2763, pattern of - * `strictVisibilityError` / ADR-0089 D3a): an unknown key — a retired - * `strategy`/`crossTenantAccess` or a typo — is a loud, *fixable* parse error - * instead of a silent strip (#1535), and a retired key's error carries its - * upgrade prescription. Every other issue code defers to zod's default. - * - * ## Message order: the fix comes before the explainer (#5955 / #6416) - * - * ```text - * Unrecognized key(s) on `tenancy`: `k1`. ← which key is wrong - * • {per-key tombstone / "not a `tenancy` key"} ← the fix - * The two supported tenancy modes are: … ← the standing explainer - * ``` + * The standing two-modes explainer, emitted LAST on every `tenancy` rejection. * - * Same emission order the shared `strictUnknownKeyError` template took in - * #5955 — bullets first, the surface-level sentence appended to the last one. - * A hand-written `$ZodErrorMap` is reachable by neither that fix nor #5593's - * `strictObject` migration, so #6416 applies the ruling here directly. The - * two-modes explainer used to sit between the key statement and the bullets, - * which on the single-line renders several consumers use (`os validate`'s - * `• where: message`, CI logs) buried each key's actual prescription behind - * ~160 characters of standing background. Nothing is dropped: the explainer is - * still emitted verbatim, just last. + * It occupies the template's `history` slot, which is the slot for exactly this + * — the one sentence of standing background that follows both fix channels + * (#5955 / #6416). It is background rather than history in the literal sense, + * and that is fine: the contract the slot encodes is *position*, and this + * sentence is the thing that must not sit in front of a key's own prescription. + * On the single-line renders several consumers use (`os validate`'s + * `• where: message`, CI logs) it used to bury each bullet behind ~160 + * characters. */ -const strictTenancyError: z.core.$ZodErrorMap = (issue) => { - if (issue.code !== 'unrecognized_keys') return undefined; - const keys = (issue as { keys?: readonly string[] }).keys ?? []; - const lines = keys.map((key) => - TENANCY_RETIRED_KEY_GUIDANCE[key] ?? `\`${key}\` is not a \`tenancy\` key.`, - ); - return ( - `Unrecognized key(s) on \`tenancy\`: ${keys.map((k) => `\`${k}\``).join(', ')}.\n` + - lines.map((l) => ` • ${l}`).join('\n') + - ' The two supported tenancy modes are: database-per-tenant = environment-level ' + - 'deployment (no object config); row-level isolation = `tenancy.enabled` + ' + - '`tenancy.tenantField`.' - ); -}; +const TENANCY_MODES_EXPLAINER = + 'The two supported tenancy modes are: database-per-tenant = environment-level ' + + 'deployment (no object config); row-level isolation = `tenancy.enabled` + ' + + '`tenancy.tenantField`.'; /** * Multi-Tenancy Configuration Schema @@ -465,6 +451,15 @@ const strictTenancyError: z.core.$ZodErrorMap = (issue) => { * `.strict()`: unknown keys (incl. the retired `strategy` / * `crossTenantAccess`, #2763) are rejected with guidance, not stripped (#1535). * + * Closed with the shared `strictObject` template since #6619. The tombstone + * bullets and the trailing explainer are byte-for-byte what the hand-written + * `strictTenancyError` emitted; what the fold changes is the *other* key — a + * near-miss like `tenantfield` now resolves to `tenantField` through the + * template's rename channel instead of being told only that it "is not a + * `tenancy` key", which named the problem and never the fix. Folding it in is + * also what puts this table under `alias-integrity.test.ts`, which no + * hand-rolled map has ever been judged by. + * * `tenantField` carries **no default** (#5315). It used to default to * `'tenant_id'`, which no consumer could act on: the platform's tenant column * is `organization_id` (kernel-injected; the same column `tenantPolicy()` in @@ -488,7 +483,11 @@ const strictTenancyError: z.core.$ZodErrorMap = (issue) => { * tenantField: 'workspace_id' * } */ -export const TenancyConfigSchema = lazySchema(() => z.object({ +export const TenancyConfigSchema = lazySchema(() => strictObject({ + surface: '`tenancy`', + history: TENANCY_MODES_EXPLAINER, + guidance: TENANCY_RETIRED_KEY_GUIDANCE, +}, { enabled: z.boolean().describe('Enable multi-tenancy for this object'), tenantField: z.string().optional().describe( 'Column this object is tenant-scoped by. Omit it unless the tenant column ' + @@ -498,7 +497,7 @@ export const TenancyConfigSchema = lazySchema(() => z.object({ 'object really has that field — otherwise the same `organization_id` ' + 'fallback applies. No default is materialized here on purpose (#5315).', ), -}, { error: strictTenancyError }).strict()); +})); /** * [ADR-0066] Platform-global posture: `tenancy.enabled === false` explicitly diff --git a/packages/spec/src/shared/alias-integrity.test.ts b/packages/spec/src/shared/alias-integrity.test.ts index b9236add27..3e6b5827bb 100644 --- a/packages/spec/src/shared/alias-integrity.test.ts +++ b/packages/spec/src/shared/alias-integrity.test.ts @@ -123,6 +123,7 @@ import ts from 'typescript'; import { aliasProbe } from './alias-probe'; import { acceptsNothing, strictObjectDeclarations, type StrictObjectDeclaration } from './strict-object'; +import { keySetMatches } from './suggestions.zod'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const SPEC_SRC = path.resolve(HERE, '..'); @@ -651,6 +652,109 @@ describe('alias integrity — every table is a true claim about its schema', () expect([...PROSE_ALIAS_TARGETS].filter((x) => !used.has(x)).sort()).toEqual([]); }); + it('no guidance SET member is itself a declared key, and no two entries claim one key (#6619)', () => { + // The set-keyed guidance form arrived with #6619's fold of the three + // hand-written `$ZodErrorMap`s — maps that, being hand-rolled, no registry + // saw and nothing judged (#6416's blind spot). Folding them in is only + // worth it if the sets are held to the same claims the exact channel is: + // + // - an enumerated member the shape DECLARES is dead — a declared key never + // reaches the `unrecognized_keys` path (tombstones included: writing one + // raises `invalid_type` from its `z.never()`, not `unrecognized_keys`); + // - a member also filed under exact `guidance` is unreachable for that key + // (the exact entry always wins — pinned in `strict-object.test.ts`), so + // the overlap is at best dead weight and at worst a wrong claim; + // - two enumerated sets sharing a member means declaration order decides + // which prescription the author sees. The precedence is pinned, but no + // in-repo table gets to DEPEND on a tie-break being read correctly. + const broken: string[] = []; + for (const s of SURFACES) { + const declared = new Set(Object.keys(s.shape)); + const exact = new Set(Object.keys(s.options.guidance ?? {})); + const seen = new Map(); + for (const set of s.options.guidanceSets ?? []) { + if (set.keys instanceof RegExp) continue; // judged in the pattern test below + for (const member of set.keys) { + if (declared.has(member)) { + broken.push(`"${s.options.surface}": set \`${set.name}\` lists \`${member}\`, which is declared here`); + } + if (exact.has(member)) { + broken.push(`"${s.options.surface}": set \`${set.name}\` lists \`${member}\`, which exact guidance already answers`); + } + const prior = seen.get(member); + if (prior && prior !== set.name) { + broken.push(`"${s.options.surface}": \`${member}\` is claimed by both \`${prior}\` and \`${set.name}\``); + } + seen.set(member, set.name); + } + } + } + expect(broken.sort()).toEqual([]); + }); + + it('every pattern-keyed set carries examples that really match it and are really rejected (#6619)', () => { + // A pattern is an OPEN family, so the dead-entry question cannot be asked + // of its membership the way it is of a list — the visibility pattern + // deliberately also matches the canonical `visibleWhen`, which the shape + // declares and which therefore never arrives at the map. What CAN be + // asked, and is: the spellings the pattern was written for really match it + // (a pattern typo fails here), and none of them is a key the shape + // declares (a pattern fully shadowed by the shape is a phantom check). + const broken: string[] = []; + for (const s of SURFACES) { + for (const set of s.options.guidanceSets ?? []) { + if (!(set.keys instanceof RegExp)) continue; + const examples = set.examples ?? []; + if (examples.length === 0) { + broken.push(`"${s.options.surface}": pattern set \`${set.name}\` carries no examples — nothing anchors what it is for`); + continue; + } + const declared = new Set(Object.keys(s.shape)); + for (const example of examples) { + if (!keySetMatches(set, example)) { + broken.push(`"${s.options.surface}": \`${set.name}\` example \`${example}\` does not match its own pattern`); + } + if (declared.has(example)) { + broken.push(`"${s.options.surface}": \`${set.name}\` example \`${example}\` is a declared key — it can never reach the map`); + } + } + } + } + expect(broken.sort()).toEqual([]); + }); + + it('the three #6416 hand-written maps are FOLDED and judged here — the blind spot stays closed (#6619)', () => { + // The reason #6619 existed: `strictVisibilityError`, + // `strictWidgetAnalyticsError` and `strictTenancyError` were hand-rolled + // `$ZodErrorMap`s, so their alias pointers and prescriptions registered in + // NO registry — unmeasured rather than clean. This assertion is the + // closure itself: the three surfaces are declarations this file walks, and + // their prescriptions ride channels every test above judges. Reverting any + // of them to a hand-written map fails HERE, loudly, instead of quietly + // re-opening the blind spot. + const bySurface = new Map(SURFACES.map((s) => [s.options.surface, s])); + + const visibility = [...SURFACES].filter((s) => s.options.surface === 'this view/page schema'); + // Three call sites share the options: FormFieldBase (via strictObjectError), + // FormSection, PageComponent — distinct shapes, so distinct declarations. + expect(visibility.length).toBeGreaterThanOrEqual(3); + for (const v of visibility) { + expect(v.options.guidanceSets?.map((g) => g.name)).toContain('VISIBILITY_KEY_PATTERN'); + } + + const widget = bySurface.get('this dashboard widget'); + expect(widget, 'DashboardWidgetSchema no longer declares through strictObject').toBeDefined(); + expect(widget!.options.guidanceSets?.map((g) => g.name)).toEqual([ + 'LEGACY_WIDGET_ANALYTICS_KEYS', + 'QUARANTINED_WIDGET_KEYS', + 'WIDGET_DRILL_NEAR_KEYS', + ]); + + const tenancy = bySurface.get('`tenancy`'); + expect(tenancy, 'TenancyConfigSchema no longer declares through strictObject').toBeDefined(); + expect(Object.keys(tenancy!.options.guidance ?? {}).sort()).toEqual(['crossTenantAccess', 'strategy']); + }); + it('no guidance key is itself a declared key (the same dead entry, other channel)', () => { // `guidance` is consulted from the same `unrecognized_keys` path, so a // prescription filed under a key the shape DECLARES is unreachable in diff --git a/packages/spec/src/shared/strict-object.test.ts b/packages/spec/src/shared/strict-object.test.ts index 7ada4744e7..7a91e831cc 100644 --- a/packages/spec/src/shared/strict-object.test.ts +++ b/packages/spec/src/shared/strict-object.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest'; import { lazySchema } from './lazy-schema'; import { strictObject } from './strict-object'; +import { keySetMatches } from './suggestions.zod'; const WidgetSchema = lazySchema(() => strictObject( @@ -247,6 +248,133 @@ describe('message order — the fix comes before the history (#5955)', () => { }); }); +/** + * The set-keyed `guidance` form (#6619) — one prescription shared by a named + * key family, the vocabulary the three hand-written `$ZodErrorMap`s needed + * before they could fold into this template at all. + * + * A second shape on a shared template is where accidental precedence bugs + * live, so the resolution rules are pinned here as behaviour rather than left + * to the docblock: + * + * 1. an exact `guidance` entry ALWAYS wins over any set; + * 2. among sets, declaration order wins; + * 3. a set match suppresses the rename channel for that key; + * 4. a set speaks once per message, at the first key that matched it. + */ +describe('strictObject guidanceSets — the set-keyed prescription channel (#6619)', () => { + const HISTORY = 'Until #4001 these were dropped silently.'; + const SetSchema = lazySchema(() => + strictObject( + { + surface: 'this set surface', + history: HISTORY, + guidance: { + // Deliberately ALSO a member of `legacySet` below: the exact entry + // must win (rule 1), so this text — not the set's — answers `alpha`. + alpha: '`alpha` has its own exact prescription.', + }, + guidanceSets: [ + { + name: 'legacySet', + keys: ['alpha', 'beta', 'gamma'], + prescription: 'The legacy family was retired — use `replacement`.', + }, + { + name: 'overlapSet', + // `beta` is also in legacySet; declaration order (rule 2) decides. + keys: ['beta', 'delta'], + prescription: 'The overlap family answer.', + }, + { + name: 'patternSet', + keys: /^exp(ort|erimental)/, + examples: ['exportMode', 'experimentalFlag'], + prescription: 'Export/experimental knobs live in `replacement`.', + }, + ], + }, + { + name: z.string(), + replacement: z.string().optional(), + }, + ), + ); + + const messageFor = (body: Record) => { + const r = SetSchema.safeParse({ name: 'x', ...body }); + expect(r.success).toBe(false); + return r.error!.issues[0]!.message; + }; + + it('answers a set member with the set prescription, as a bullet, ahead of the history', () => { + const m = messageFor({ beta: 1 }); + expect(m.startsWith('Unrecognized key(s) on this set surface: `beta`.')).toBe(true); + expect(m).toContain('\n • The legacy family was retired — use `replacement`.'); + expect(m.endsWith(` ${HISTORY}`)).toBe(true); + }); + + it('rule 1 — an exact guidance entry always wins over a set that also claims the key', () => { + const m = messageFor({ alpha: 1 }); + expect(m).toContain('`alpha` has its own exact prescription.'); + expect(m).not.toContain('The legacy family was retired'); + }); + + it('rule 2 — among sets, declaration order decides', () => { + // `beta` is a member of BOTH sets; the first declared set answers. + const m = messageFor({ beta: 1 }); + expect(m).toContain('The legacy family was retired'); + expect(m).not.toContain('The overlap family answer.'); + // The second set is not dead — its unshared member still reaches it. + expect(messageFor({ delta: 1 })).toContain('The overlap family answer.'); + }); + + it('rule 3 — a set match suppresses the rename channel for that key', () => { + // `gamma` is within edit distance of nothing declared, but make the point + // on a key that IS: `replacment` (no set) gets a rename, while a set + // member never does — matched means answered. + expect(messageFor({ replacment: 'x' })).toContain('`replacment` → `replacement`'); + expect(messageFor({ gamma: 1 })).not.toContain('Did you mean'); + }); + + it('rule 4 — a set speaks once per message, however many members are written', () => { + const m = messageFor({ beta: 1, gamma: 1 }); + expect(m.split('The legacy family was retired')).toHaveLength(2); + }); + + it('rules compose in one message: exact + two sets + a rename, each exactly once', () => { + const m = messageFor({ alpha: 1, beta: 1, delta: 1, replacment: 'x' }); + expect(m).toContain('`alpha` has its own exact prescription.'); + expect(m).toContain('The legacy family was retired'); + expect(m).toContain('The overlap family answer.'); + expect(m).toContain('`replacment` → `replacement`'); + expect(m.endsWith(` ${HISTORY}`)).toBe(true); + // Bullets appear in KEY order (alpha, beta, delta), not set-declaration + // order — the set's bullet sits at the first key that matched it. + expect(m.indexOf('exact prescription')).toBeLessThan(m.indexOf('legacy family')); + expect(m.indexOf('legacy family')).toBeLessThan(m.indexOf('overlap family')); + }); + + it('the pattern form answers spellings nobody enumerated', () => { + for (const key of ['exportMode', 'experimentalFlag', 'exportTarget']) { + expect(messageFor({ [key]: 1 }), key).toContain('Export/experimental knobs live in `replacement`.'); + } + // …and does not claim keys outside itself. + expect(messageFor({ zz: 1 })).not.toContain('Export/experimental knobs'); + }); + + it('keySetMatches is stateless even on a /g pattern', () => { + // `RegExp#test` advances `lastIndex` on a sticky/global regex, making the + // same key alternate between matched and unmatched on repeated parses. + // `keySetMatches` uses `String#search`, which restores `lastIndex` by + // spec — asserted directly so a refactor back to `.test()` fails here. + const set = { name: 's', keys: /vis/g, prescription: 'p' } as const; + expect(keySetMatches(set, 'visibleWhenn')).toBe(true); + expect(keySetMatches(set, 'visibleWhenn')).toBe(true); + expect(keySetMatches(set, 'visibleWhenn')).toBe(true); + }); +}); + /** * Never suggest a key the schema cannot accept. * diff --git a/packages/spec/src/shared/strict-object.ts b/packages/spec/src/shared/strict-object.ts index 4e70f672a4..963b15686d 100644 --- a/packages/spec/src/shared/strict-object.ts +++ b/packages/spec/src/shared/strict-object.ts @@ -60,7 +60,7 @@ import { z } from 'zod'; -import { strictUnknownKeyError } from './suggestions.zod'; +import { strictUnknownKeyError, type KeySetGuidance } from './suggestions.zod'; /** * True when `schema` accepts no value at all — a `z.never()`, however wrapped. @@ -123,6 +123,18 @@ export interface StrictObjectOptions { * keys, wrong-layer pointers. An entry here suppresses the rename suggestion. */ guidance?: Readonly>; + /** + * The same channel, keyed by a **named key set** instead of an exact key: one + * prescription for a whole family, emitted once per message. Added at #6619 + * for the three prescriptions that were hand-written `$ZodErrorMap`s precisely + * because this form did not exist — a set of eleven retired analytics keys + * with one migration answer, and the ADR-0089 visibility family, which is a + * pattern rather than a list. + * + * An exact {@link guidance} entry always wins over a set; among sets, + * declaration order decides. See {@link KeySetGuidance}. + */ + guidanceSets?: readonly KeySetGuidance[]; /** * Extra candidates for the "did you mean" fallback beyond the shape's own * keys. For a base that gets `.extend()`ed elsewhere, naming the extension's @@ -225,12 +237,28 @@ export function strictObjectDeclarations(): readonly StrictObjectDeclaration[] { } /** - * A `.strict()` object whose unknown-key error names the surface, echoes the - * offending key, and suggests the closest declared key — with the candidate - * list read from `shape` rather than transcribed alongside it. + * Register an authoring surface and build its unknown-key error map, **without + * closing the shape** — the half of {@link strictObject} that a schema whose + * door is one level up needs on its own (#6619). + * + * The case it exists for is `view.zod.ts`'s `FormFieldBaseSchema`: a + * module-private base with exactly ONE consumer, `FormFieldSchema = + * base.extend({ fields }).strict()`. Both the error map and the strictness ride + * the `.extend()`, so the base is not a door and #4001 批 18 deliberately left + * it open — the ledger's `strip` row for that site is a measurement artifact, + * not authorable surface. Folding its bespoke map in with `strictObject` would + * have flipped that posture as a side effect of a TEXT change, which is exactly + * the acceptance-surface edit this migration is not allowed to make. + * + * Everything else is identical to `strictObject`, because `strictObject` is + * this function plus `z.object(shape, { error }).strict()`: same lazy build, + * same tombstone-aware candidate list, same one registration the audit reads. */ -export function strictObject(options: StrictObjectOptions, shape: T) { - const { surface, history, aliases, guidance, extraKeys = [], retiredForms } = options; +export function strictObjectError( + options: StrictObjectOptions, + shape: T, +): z.core.$ZodErrorMap { + const { surface, history, aliases, guidance, guidanceSets, extraKeys = [], retiredForms } = options; // The error map is built on FIRST USE, not at construction. // @@ -282,10 +310,20 @@ export function strictObject(options: StrictObjectOptio history, aliases, guidance, + guidanceSets, }))(issue); }; declarationStore().push({ options, shape }); - return z.object(shape, { error }).strict(); + return error; +} + +/** + * A `.strict()` object whose unknown-key error names the surface, echoes the + * offending key, and suggests the closest declared key — with the candidate + * list read from `shape` rather than transcribed alongside it. + */ +export function strictObject(options: StrictObjectOptions, shape: T) { + return z.object(shape, { error: strictObjectError(options, shape) }).strict(); } diff --git a/packages/spec/src/shared/suggestions.zod.ts b/packages/spec/src/shared/suggestions.zod.ts index 3dfee12854..e0dc2cf66c 100644 --- a/packages/spec/src/shared/suggestions.zod.ts +++ b/packages/spec/src/shared/suggestions.zod.ts @@ -218,6 +218,107 @@ export function formatSuggestion(suggestions: string[]): string { return `Did you mean one of: ${suggestions.map((s) => `'${s}'`).join(', ')}?`; } +/** + * One prescription shared by a **named set of keys** — the second `guidance` + * form, added at #6619 so the three hand-written `$ZodErrorMap`s could be folded + * into this template at all. + * + * ## Why the exact-key form was not enough + * + * `guidance` is `Record`: it answers *this exact spelling*. + * The three maps this form was written for do not work that way — each keys its + * answer on **membership of a family**: + * + * - `LEGACY_WIDGET_ANALYTICS_KEYS` — eleven pre-ADR-0021 inline-analytics keys, + * one migration answer ("bind a `dataset`, select `dimensions` + `values`"); + * - `QUARANTINED_WIDGET_KEYS` — `component` / inline `data`, one quarantine + * verdict; + * - the ADR-0089 conditional-visibility family, which is not enumerable at all: + * it is *any* key that reads like a visibility predicate, matched by pattern. + * + * Transcribing those into N identical exact entries loses two things. It emits + * the prescription **once per matching key** (eleven bullets of the same + * paragraph for a widget carrying the whole legacy shape), and it cannot express + * the pattern case at all. + * + * ## The two membership forms, and what each buys the audit + * + * - `keys: readonly string[]` — an enumerated family. `alias-integrity.test.ts` + * judges every member against the shape exactly as it judges an exact + * `guidance` key: a member the shape *declares* is a dead entry, because a + * declared key never reaches the `unrecognized_keys` path. + * - `keys: RegExp` — an open family, for the case where the point is to catch + * spellings nobody enumerated (`/vis|conceal|hidden|show.?when/i` answers + * `visibleWhenn`, `visibleIf`, `hiddenWhen`, `conceal`). The dead-entry claim + * cannot be asked of a pattern the same way — this one *deliberately* also + * matches the canonical `visibleWhen`, which the shape declares and which + * therefore never arrives here. So a pattern must carry {@link examples}, and + * the audit asks the answerable question instead: do the spellings this + * pattern was written for really match it, and are they really keys the shape + * rejects? A pattern typo fails that; a pattern shadowed into uselessness by + * the shape fails it too. + * + * ## Precedence — stated here because a second shape on a shared template is + * where accidental precedence bugs live + * + * 1. An **exact `guidance` entry always wins** over any set. The more specific + * entry decides, so adding a set can never silently steal a key that already + * had a hand-written answer. + * 2. Among sets, **declaration order wins** — the first set that claims a key + * answers it, matching the top-to-bottom `if` chain the hand-written maps + * read as. + * 3. A set match **suppresses the rename suggestion** for that key, exactly as + * an exact entry does: a prescription and a "did you mean" are two answers to + * one question. + * 4. A set contributes **at most one bullet per message**, positioned at the + * first key that matched it — the property that keeps eleven legacy keys to + * one paragraph. + * + * Rules 1 and 2 are pinned in `strict-object.test.ts`; `alias-integrity.test.ts` + * additionally holds every in-repo surface to *unambiguous* tables, so no + * shipped message depends on a tie-break being read correctly. + */ +export interface KeySetGuidance { + /** + * The set's name — the constant an author greps for + * (`LEGACY_WIDGET_ANALYTICS_KEYS`). Deliberately **not** rendered into the + * message: an author-facing rejection should name the KEYS, not the array + * that holds them, and each prescription below already spells its family out + * in prose (that is what makes it legible). The name is for the declaration + * and for the audit's failure text. + */ + readonly name: string; + /** + * Membership: an enumerated family, or a pattern for the open case. Matched + * against the authored spelling **case-sensitively** (an enumerated list is + * tested with `includes`; a pattern with its own flags), the same exactness + * `guidance` uses — case folding is the rename channel's job. + */ + readonly keys: readonly string[] | RegExp; + /** + * Required when {@link keys} is a pattern, ignored otherwise: spellings this + * pattern exists for. The audit asserts each one matches, and that none of + * them is a key the shape declares. + */ + readonly examples?: readonly string[]; + /** The prescription, emitted verbatim as one bullet line. */ + readonly prescription: string; +} + +/** + * True when `key` is claimed by `set` — the one place membership is decided, so + * the template and the audit can never disagree about what a set contains. + * + * Patterns are tested with `String#search` rather than `RegExp#test`: `test` is + * stateful on a `/g` or `/y` regex (it advances `lastIndex`, so the same key + * alternates between matching and not), while `search` saves and restores + * `lastIndex` by specification. A declaration must not have to remember which + * flags are safe. + */ +export function keySetMatches(set: KeySetGuidance, key: string): boolean { + return set.keys instanceof RegExp ? key.search(set.keys) !== -1 : set.keys.includes(key); +} + /** Options for {@link strictUnknownKeyError}. */ export interface StrictUnknownKeyErrorOptions { /** Prose name of the authoring surface the key was written on (e.g. `'this permission set'`). */ @@ -240,6 +341,13 @@ export interface StrictUnknownKeyErrorOptions { * key. Matched case-sensitively (exact authored spelling). */ guidance?: Readonly>; + /** + * The set-keyed half of the same channel: one prescription shared by a named + * family of keys, emitted once per message however many members were written. + * Consulted only after {@link guidance} has had its exact say — see + * {@link KeySetGuidance} for the full precedence rule and why the form exists. + */ + guidanceSets?: readonly KeySetGuidance[]; /** * One sentence of history: why this key would previously have failed * silently. Rendered **last**, after both fix channels (`Did you mean` and @@ -308,7 +416,7 @@ export interface StrictUnknownKeyErrorOptions { * arrive outside the shape-backed audit. */ export function strictUnknownKeyError(options: StrictUnknownKeyErrorOptions): z.core.$ZodErrorMap { - const { surface, knownKeys, guidance = {}, history } = options; + const { surface, knownKeys, guidance = {}, guidanceSets = [], history } = options; const aliases: Record = {}; for (const [key, canonical] of Object.entries(options.aliases ?? {})) { // Two keys in ONE table that share a probe collapse here, later silently @@ -326,12 +434,30 @@ export function strictUnknownKeyError(options: StrictUnknownKeyErrorOptions): z. const keys = (issue as { keys?: readonly string[] }).keys ?? []; const renames: string[] = []; const prescriptions: string[] = []; + // A set answers once per MESSAGE, at the position of the first key that + // reached it — eleven legacy analytics keys are one migration paragraph, + // not eleven copies of it (#6619). + const firedSets = new Set(); for (const key of keys) { + // Precedence, in the order the two channels are consulted: the exact + // entry is the more specific claim, so it decides before any set is + // asked. Pinned in `strict-object.test.ts`. const prescription = guidance[key]; if (prescription) { prescriptions.push(prescription); continue; } + const set = guidanceSets.find((s) => keySetMatches(s, key)); + if (set) { + // Matched, therefore answered — the rename channel is skipped for this + // key exactly as an exact entry skips it, even when the set has already + // spoken and adds no second bullet. + if (!firedSets.has(set)) { + firedSets.add(set); + prescriptions.push(set.prescription); + } + continue; + } const maxDistance = Math.max(2, Math.floor(key.length / 3)); const canonical = aliases[aliasProbe(key)] ?? findClosestMatches(key, knownKeys, maxDistance, 1)[0]; diff --git a/packages/spec/src/shared/visibility.ts b/packages/spec/src/shared/visibility.ts index 028cd48d40..17648e698e 100644 --- a/packages/spec/src/shared/visibility.ts +++ b/packages/spec/src/shared/visibility.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { z } from 'zod'; +import type { StrictObjectOptions } from './strict-object'; /** * # Conditional-visibility predicate normalization (ADR-0089) @@ -45,7 +45,7 @@ type WithVisibilityAliases = { * a conditional-visibility predicate: * * ```ts - * z.object({ ..., visibleWhen: Expr.optional(), visibleOn: Expr.optional() }) + * strictObject(VISIBILITY_STRICT_OPTIONS, { ..., visibleWhen: Expr.optional() }) * .transform(normalizeVisibleWhen) * ``` */ @@ -67,65 +67,74 @@ export function normalizeVisibleWhen( return { ...rest, visibleWhen: canonical } as Omit; } -/** A key that is (or is a likely mis-spelling of) the visibility predicate. */ -function looksLikeVisibilityKey(key: string): boolean { - return /vis|conceal|hidden|show.?when/i.test(key); -} - /** - * Custom zod `error` for the `.strict()` view/page schemas (ADR-0089 D3a). + * A key that is (or is a likely mis-spelling of) the visibility predicate. * - * With `.strict()`, a key these schemas do not declare — a stale `visibleOn` past - * removal, a `visibleWhen` typo, or a wrong-layer paste — is now a **loud parse - * error** instead of a silent strip (ADR-0049 enforce-or-remove, ADR-0078 - * no-silently-inert). This error map turns that rejection into a *fixable* one: it - * always names the offending key(s), and when a key looks like the - * conditional-visibility predicate it points the author at the canonical - * `visibleWhen`. Every other issue code defers to zod's default (`undefined`). + * **Deliberately a pattern and not a list.** The keys this has to answer are the + * ones nobody enumerated: `visibleWhenn`, `visibleIf`, `hiddenWhen`, `conceal`, + * `showWhen`, a `visibility` pasted onto a view form. Enumerating them is the + * shape of guess this family exists to stop the author making. + * + * It also matches the CANONICAL `visibleWhen` and both deprecated aliases, and + * that is harmless by construction: a key the shape declares is recognised, so + * it never reaches the `unrecognized_keys` path this pattern is consulted from. + * It is the reason the audit asks a pattern about {@link + * VISIBILITY_STRICT_OPTIONS}'s `examples` rather than about the shape's declared + * keys — see `KeySetGuidance`. + */ +const VISIBILITY_KEY_PATTERN = /vis|conceal|hidden|show.?when/i; + +/** + * The shared `strictObject` options for every `.strict()` view/page schema that + * carries a conditional-visibility predicate (ADR-0089 D3a). * - * Wire it as the object's `error` alongside `.strict()`: + * With the shape closed, a key these schemas do not declare — a stale + * `visibleOn` past removal, a `visibleWhen` typo, or a wrong-layer paste — is a + * **loud parse error** instead of a silent strip (ADR-0049 enforce-or-remove, + * ADR-0078 no-silently-inert). The rejection is *fixable*: it names the + * offending key(s), points a visibility-shaped key at the canonical + * `visibleWhen`, and — new with the fold below — suggests the closest declared + * key for everything else. * * ```ts - * z.object({ ..., visibleWhen: Expr.optional() }, { error: strictVisibilityError }) - * .strict() + * strictObject(VISIBILITY_STRICT_OPTIONS, { ..., visibleWhen: Expr.optional() }) * .transform(normalizeVisibleWhen) * ``` * - * ## Message order: the fix comes before the history (#5955 / #6416) + * ## Folded out of a hand-written `$ZodErrorMap` (#6619, #6416 direction 2) * - * ```text - * Unrecognized key(s) on this view/page schema: `k1`. ← which key is wrong - * [ If this is the conditional-visibility predicate … ] ← the fix - * Before ADR-0089 D3a these were dropped silently … ← why it used to be silent - * ``` + * This used to be `strictVisibilityError`, a bespoke map that re-implemented + * the front matter, the prescription branch and the trailing history sentence + * by hand. Being hand-written is what put it out of reach of #5955 (the shared + * template's reorder) and #5593 (the `strictObject` migration) — and, the + * reason this card was worth doing, out of reach of `alias-integrity.test.ts`: + * a hand-rolled map registers in no registry, so its prescription was + * **unmeasured rather than clean**. Declared here, it is judged with every + * other table in the package. * - * This map is a hand-written `$ZodErrorMap`, so #5955's fix to the shared - * `strictUnknownKeyError` template could not reach it and #5593's - * `strictObject` migration cannot either — #6416 applies the same ruling here. - * The history sentence used to sit between the key statement and the alias - * pointer, which is the position several consumers render on ONE line - * (`os validate`'s `• where: message`, CI logs, and - * `validateFlowTriggerReadiness`, which flattens the newlines): an author — - * often an AI — reads the front of that line and acts on it, so the canonical - * key has to be there. Nothing is dropped and nothing is conditional; the - * sentence is still emitted verbatim, just last. + * The emission ORDER the pins in `ui/view.test.ts` encode is the template's own + * and unchanged: front matter → fix channels → the explanatory sentence last. + * What changed in the bytes is that the prescription is now rendered as the + * template's `\n • ` bullet rather than joined inline with a space, which is + * how every other closed surface in this package already reads it. */ -export const strictVisibilityError: z.core.$ZodErrorMap = (issue) => { - if (issue.code !== 'unrecognized_keys') return undefined; - const keys = (issue as { keys?: readonly string[] }).keys ?? []; - const list = keys.map((k) => `\`${k}\``).join(', '); - const front = `Unrecognized key(s) on this view/page schema: ${list}.`; - const history = - `Before ADR-0089 D3a these were dropped silently, shipping inert metadata; ` + - `a mis-layered or stale key is now a loud parse error.`; - if (keys.some(looksLikeVisibilityKey)) { - return ( - front + - ' If this is the conditional-visibility predicate, the canonical key is ' + - '`visibleWhen` (ADR-0089) — `visibleOn` (view form) and `visibility` (page ' + - 'component) are still accepted as deprecated aliases. ' + - history - ); - } - return `${front} ${history}`; +export const VISIBILITY_STRICT_OPTIONS: StrictObjectOptions = { + surface: 'this view/page schema', + history: + 'Before ADR-0089 D3a these were dropped silently, shipping inert metadata; ' + + 'a mis-layered or stale key is now a loud parse error.', + guidanceSets: [ + { + name: 'VISIBILITY_KEY_PATTERN', + keys: VISIBILITY_KEY_PATTERN, + // The spellings the pattern is FOR — the audit asserts each really + // matches and that none is a key the shape declares, which is the + // answerable half of the dead-entry claim for an open family. + examples: ['visibleWhenn', 'visibleIf', 'hiddenWhen', 'conceal', 'showWhen'], + prescription: + 'If this is the conditional-visibility predicate, the canonical key is ' + + '`visibleWhen` (ADR-0089) — `visibleOn` (view form) and `visibility` (page ' + + 'component) are still accepted as deprecated aliases.', + }, + ], }; diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index b5cf9ce080..b3d26423d3 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -128,22 +128,29 @@ describe('DashboardWidgetSchema (dataset-bound)', () => { }); /** - * Message ORDER on `strictWidgetAnalyticsError` (#6416, applying #5955's ruling). + * Message ORDER on the widget unknown-key rejection (#6416, applying #5955's + * ruling; #6619 folded the map into the shared template). * - * This map is a hand-written `$ZodErrorMap`, so neither #5955 (which moved the - * history sentence to the end inside the shared `strictUnknownKeyError`) nor - * #5593 (which migrates the direct call sites to `strictObject`) reached it. It - * carried the same defect: a ~150-char history sentence sitting BETWEEN the - * offending key and whichever of the three prescription branches fixes it — - * the ADR-0021 dataset migration, the objectui `component`/`data` quarantine, - * and the #5022 drill near-key answer — pushing all three past the front of the - * single-line renders several consumers use. + * Written against `strictWidgetAnalyticsError`, the hand-written `$ZodErrorMap` + * that neither #5955 nor #5593 could reach; #6416 direction 1 reordered it in + * place with these pins as the acceptance criteria, and #6619 folded the three + * prescription branches into `strictObject` `guidanceSets` + * ({@link WIDGET_GUIDANCE_SETS} in `dashboard.zod.ts`). The pins migrated with + * the code: the ORDER contract (front matter → fix channels → history last) + * is now the template's own. Two byte-level changes rode the fold, each pinned + * below where it lands: * - * ORDER pins, not presence checks: the reorder deletes nothing, so every + * - prescriptions render as the template's `\n • ` bullets instead of joined + * inline with a space; + * - keys with no family and no prescription now get the template's + * edit-distance rename (`titel` → `title`), which the hand-written map had + * no channel for. + * + * ORDER pins, not presence checks: the fold deletes nothing, so every * `toContain` in the block above stays green either way. An edit that folds the * sentence back into the middle passes all of them and fails here. */ -describe('strictWidgetAnalyticsError message order — fix before history (#6416)', () => { +describe('widget unknown-key message order — fix before history (#6416 / #6619)', () => { const HISTORY = 'Undeclared top-level keys were dropped silently before strict validation, ' + 'shipping inert metadata; a stale or mis-layered key is now a loud parse error.'; @@ -200,9 +207,24 @@ describe('strictWidgetAnalyticsError message order — fix before history (#6416 .toBeLessThan(m.indexOf(HISTORY)); }); - it('is unchanged in SHAPE when no branch matches — full-message pin', () => { - expect(messageFor({ colourVariant: 'blue' })) - .toBe(`Unrecognized key(s) on this dashboard widget: \`colourVariant\`. ${HISTORY}`); + it('is unchanged in SHAPE when no fix matches — full-message pin', () => { + // A key with no family, no guidance and no near-declared-key: the history + // follows the key statement directly. Any stray separator fails here. + expect(messageFor({ zzWrongKey: 'blue' })) + .toBe(`Unrecognized key(s) on this dashboard widget: \`zzWrongKey\`. ${HISTORY}`); + }); + + it('a near-miss of a DECLARED key now gets the rename the bespoke map never offered (#6619)', () => { + // `colourVariant` was this block's no-fix fixture while the map was + // hand-written: it answered with nothing but the history, leaving the + // author to find `colorVariant` alone. Folding onto the shared template + // brought the edit-distance channel with it — a deliberate byte change, + // in the fix-before-history order the block pins. + const m = messageFor({ colourVariant: 'blue' }); + expect(m).toBe( + 'Unrecognized key(s) on this dashboard widget: `colourVariant`. ' + + `Did you mean \`colourVariant\` → \`colorVariant\`? ${HISTORY}`, + ); }); it('emits the history exactly once, whatever the key count', () => { @@ -210,6 +232,30 @@ describe('strictWidgetAnalyticsError message order — fix before history (#6416 expect(m.split(HISTORY)).toHaveLength(2); expect(m.endsWith(` ${HISTORY}`)).toBe(true); }); + + it('keys from TWO families now surface BOTH prescriptions, in declaration order (#6619)', () => { + // The one deliberate behaviour change in the fold. The hand-written map + // was an if/else chain: a widget carrying `categoryField` AND `component` + // got only the legacy-analytics answer, and the quarantine verdict was + // silently dropped. Sets answer independently — one bullet each, ordered + // as declared — and each family speaks exactly once however many of its + // members were written. + const m = messageFor({ categoryField: 'stage', component: {} }); + const legacy = 'The pre-ADR-0021 inline analytics shape'; + const quarantine = '`component` and inline `data` are objectui-internal renderer capabilities'; + expect(m).toContain(legacy); + expect(m).toContain(quarantine); + expect(m.indexOf(legacy)).toBeLessThan(m.indexOf(quarantine)); + expect(m.endsWith(` ${HISTORY}`)).toBe(true); + // Once per family, not once per member. + expect(m.split(legacy)).toHaveLength(2); + }); + + it('a set answers once however many of its members are written (#6619)', () => { + const m = messageFor({ categoryField: 'stage', valueField: 'amount', aggregate: 'sum' }); + expect(m.split('The pre-ADR-0021 inline analytics shape')).toHaveLength(2); + expect(m.endsWith(` ${HISTORY}`)).toBe(true); + }); }); describe('DashboardSchema', () => { diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 8eaeb01491..eb42da0fa9 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { strictObject } from '../shared/strict-object'; +import type { KeySetGuidance } from '../shared/suggestions.zod'; import { FilterConditionSchema } from '../data/filter.zod'; import { DateGranularity } from '../data/query.zod'; import { DATE_MACRO_WRAPPED_RE, isDateMacroToken } from '../data/date-macros.zod'; @@ -117,9 +118,16 @@ export const DashboardHeaderSchema = lazySchema(() => strictObject({ }).describe('Dashboard header configuration')); /** - * Legacy / quarantined widget keys that `.strict()` now rejects. Naming them - * lets the error map hand the author a fixable message instead of a bare - * "unrecognized key". Two families: + * The three key FAMILIES the widget shape rejects with a prescription rather + * than a rename — declared as `strictObject` `guidanceSets` (#6619), which is + * the form the shared template grew so these could stop being a hand-written + * `$ZodErrorMap`. + * + * Set-keyed, not exact-keyed, and that is the whole reason the form had to + * exist: eleven legacy analytics keys share ONE migration answer, and + * transcribing it eleven times into `guidance` would have emitted the paragraph + * once per key written. A set answers once per message however many of its + * members appear. * * - **Pre-ADR-0021 inline analytics** (`object`/`categoryField`/`valueField`/ * `aggregate`/`rowField`/`columnField`/…): removed from the authorable spec at @@ -128,87 +136,63 @@ export const DashboardHeaderSchema = lazySchema(() => strictObject({ * - **objectui-internal props** (`component`, inline `data`): renderer-only * capabilities that are intentionally not modeled server-side (framework#3251 * decision tree) — they must not appear on AI-authored dashboard metadata. + * - **The #5022 drill near-key**, in all three spellings an author reaches for. + * A dashboard widget has NO per-widget drill configuration, by design: an + * ADR-0021 dataset-bound widget drills through the semantic layer, deriving + * the target object and filter from the clicked dataset row. Saying only + * "unrecognized key" here would leave the author to conclude the capability + * is missing, when in fact it is automatic — and would leave them guessing + * between two real keys on two other surfaces. + * + * ⚠️ The three used to be an if/else chain, so exactly ONE fired even when a + * widget carried keys from two families and the other prescription was silently + * dropped. Declared as sets they all fire, one bullet each, in declaration + * order — the one deliberate behaviour change in this fold (#6619). */ -const LEGACY_WIDGET_ANALYTICS_KEYS = new Set([ - 'object', 'categoryField', 'categoryGranularity', 'valueField', 'aggregate', - 'aggregation', 'rowField', 'columnField', 'xAxisField', 'yAxisFields', 'measures', -]); -const QUARANTINED_WIDGET_KEYS = new Set(['component', 'data']); +const WIDGET_GUIDANCE_SETS = [ + { + name: 'LEGACY_WIDGET_ANALYTICS_KEYS', + keys: [ + 'object', 'categoryField', 'categoryGranularity', 'valueField', 'aggregate', + 'aggregation', 'rowField', 'columnField', 'xAxisField', 'yAxisFields', 'measures', + ], + prescription: + 'The pre-ADR-0021 inline analytics shape (`object` + `categoryField` + ' + + '`valueField` + `aggregate`, pivot `rowField`/`columnField`) was removed — ' + + 'bind a `dataset` and select `dimensions` + `values` by name. Renderer-only ' + + 'settings belong under `options`.', + }, + { + name: 'QUARANTINED_WIDGET_KEYS', + keys: ['component', 'data'], + prescription: + '`component` and inline `data` are objectui-internal renderer capabilities, ' + + 'not part of the author-facing dashboard spec (framework#3251).', + }, + { + name: 'WIDGET_DRILL_NEAR_KEYS', + keys: ['drillDown', 'drilldown', 'drill'], + prescription: + 'Drill-through on a dashboard is AUTOMATIC and not configurable per widget: ' + + 'a dataset-bound widget derives the drill target and filter from the dataset row ' + + 'that was clicked, and a `table`/`pivot` widget is the one to reach for when you ' + + 'want the detail to be clickable (`metric`/`chart` render the aggregate only). ' + + 'The two configurable drills live elsewhere and neither is a widget key: ' + + '`drillDown` (camelCase, a config object) is the react-tier `` ' + + 'prop — `ChartDrillDownSchema`; `drilldown` (all lowercase, a boolean) is ' + + '`ReportSchema.drilldown` (ADR-0021 D2, on by default).', + }, +] as const satisfies readonly KeySetGuidance[]; /** - * Error map for the strict `DashboardWidgetSchema`. Turns an - * `unrecognized_keys` rejection into a *fixable* message: it always names the - * offending key(s), and when a key is a removed inline-analytics key or an - * objectui-internal prop it points the author at the ADR-0021 dataset shape - * (and `options` for renderer-specific extras). Mirrors `strictVisibilityError` - * (ADR-0089 D3a); every other issue code defers to zod's default. - * - * ## Message order: the fix comes before the history (#5955 / #6416) - * - * ```text - * Unrecognized key(s) on this dashboard widget: `k1`. ← which key is wrong - * [ one of the three prescription branches ] ← the fix - * Undeclared top-level keys were dropped silently … ← why it used to be silent - * ``` - * - * Hand-written `$ZodErrorMap`s were out of reach of both #5955 (which moved the - * sentence inside the shared `strictUnknownKeyError`) and #5593 (which migrates - * the direct call sites to `strictObject`); #6416 applies the same ruling here. - * The history sentence used to sit between the key statement and the branch that - * fixes it, pushing every prescription — the ADR-0021 dataset migration, the - * objectui quarantine, the #5022 drill answer — past character ~220 of a message - * several consumers render on ONE line. Nothing is dropped or made conditional: - * the sentence is still emitted verbatim, just last. + * The widget's own history sentence — the explanatory clause emitted LAST, + * after both fix channels (#5955 / #6416). Distinct from `DASHBOARD_HISTORY`: + * the widget has been strict since the ADR-0021 cutover, so it says what + * happened before THAT, in its own words. */ -const strictWidgetAnalyticsError: z.core.$ZodErrorMap = (issue) => { - if (issue.code !== 'unrecognized_keys') return undefined; - const keys = (issue as { keys?: readonly string[] }).keys ?? []; - const list = keys.map((k) => `\`${k}\``).join(', '); - const front = `Unrecognized key(s) on this dashboard widget: ${list}.`; - const history = - `Undeclared top-level keys were dropped silently before strict validation, ` + - `shipping inert metadata; a stale or mis-layered key is now a loud parse error.`; - if (keys.some((k) => LEGACY_WIDGET_ANALYTICS_KEYS.has(k))) { - return ( - front + - ' The pre-ADR-0021 inline analytics shape (`object` + `categoryField` + ' + - '`valueField` + `aggregate`, pivot `rowField`/`columnField`) was removed — ' + - 'bind a `dataset` and select `dimensions` + `values` by name. Renderer-only ' + - 'settings belong under `options`. ' + - history - ); - } - if (keys.some((k) => QUARANTINED_WIDGET_KEYS.has(k))) { - return ( - front + - ' `component` and inline `data` are objectui-internal renderer capabilities, ' + - 'not part of the author-facing dashboard spec (framework#3251). ' + - history - ); - } - // #5022 — the drill near-key, in all three spellings an author reaches for. - // A dashboard widget has NO per-widget drill configuration, by design: an - // ADR-0021 dataset-bound widget drills through the semantic layer, deriving - // the target object and filter from the clicked dataset row. Saying only - // "unrecognized key" here would leave the author to conclude the capability - // is missing, when in fact it is automatic — and would leave them guessing - // between two real keys on two other surfaces. - if (keys.some((k) => k === 'drillDown' || k === 'drilldown' || k === 'drill')) { - return ( - front + - ' Drill-through on a dashboard is AUTOMATIC and not configurable per widget: ' + - 'a dataset-bound widget derives the drill target and filter from the dataset row ' + - 'that was clicked, and a `table`/`pivot` widget is the one to reach for when you ' + - 'want the detail to be clickable (`metric`/`chart` render the aggregate only). ' + - 'The two configurable drills live elsewhere and neither is a widget key: ' + - '`drillDown` (camelCase, a config object) is the react-tier `` ' + - 'prop — `ChartDrillDownSchema`; `drilldown` (all lowercase, a boolean) is ' + - '`ReportSchema.drilldown` (ADR-0021 D2, on by default). ' + - history - ); - } - return `${front} ${history}`; -}; +const WIDGET_HISTORY = + 'Undeclared top-level keys were dropped silently before strict validation, ' + + 'shipping inert metadata; a stale or mis-layered key is now a loud parse error.'; /** * Widget `options` — the renderer-extras escape hatch, with the keys that @@ -333,8 +317,19 @@ const WIDGET_ACTION_RETIRED = (key: 'actionUrl' | 'actionType' | 'actionIcon') = /** * Dashboard Widget Schema * A single component on the dashboard grid. + * + * Strict since the ADR-0021 cutover; since #6619 strict through the SHARED + * `strictObject` template rather than a hand-written `$ZodErrorMap`. The three + * prescriptions are unchanged in wording ({@link WIDGET_GUIDANCE_SETS}); what + * the fold adds is the rename channel the bespoke map never had — `titel`, + * `datset`, `dimension`, `option` now resolve to the declared key instead of + * being echoed back with nothing but the history sentence. */ -export const DashboardWidgetSchema = lazySchema(() => z.object({ +export const DashboardWidgetSchema = lazySchema(() => strictObject({ + surface: 'this dashboard widget', + history: WIDGET_HISTORY, + guidanceSets: WIDGET_GUIDANCE_SETS, +}, { /** Unique widget identifier (snake_case, used for targetWidgets references) */ id: SnakeCaseIdentifierSchema.describe('Unique widget identifier (snake_case)'), @@ -627,13 +622,13 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({ ), // ADR-0021 single-form: every widget binds a `dataset` and selects `values` // (both required above) — there is no inline-query shape to disambiguate. -}, { error: strictWidgetAnalyticsError }) - // ADR-0021 endpoint (framework#3251, protocol 16 `step16`): reject undeclared - // top-level keys instead of silently stripping them. A hallucinated or legacy - // key is now a deterministic author-time error (CI) rather than a silent - // no-op a human reviewer would miss. `options` stays the free-form escape - // hatch for renderer-specific extras. - .strict()); + // + // ADR-0021 endpoint (framework#3251, protocol 16 `step16`): `strictObject` + // rejects undeclared top-level keys instead of silently stripping them. A + // hallucinated or legacy key is a deterministic author-time error (CI) rather + // than a silent no-op a human reviewer would miss. `options` stays the + // free-form escape hatch for renderer-specific extras. +})); /** * Dashboard date-range presets — the named windows a dashboard date filter may diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index ae94307432..95ca7186cd 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; -import { normalizeVisibleWhen, strictVisibilityError } from '../shared/visibility'; +import { normalizeVisibleWhen, VISIBILITY_STRICT_OPTIONS } from '../shared/visibility'; import { SortItemSchema } from '../shared/enums.zod'; import { FilterConditionSchema } from '../data/filter.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; @@ -93,8 +93,15 @@ export const ElementDataSourceSchema = lazySchema(() => strictObject({ /** * Page Component Schema * A configured instance of a UI component. + * + * Closed under ADR-0089 D3a. Its unknown-key error came from the bespoke + * `strictVisibilityError` until #6619 folded that map into the shared + * `strictObject` template's set-keyed `guidance` channel — same prescription, + * now under `alias-integrity.test.ts` and with an edit-distance rename for the + * page-component keys the hand-written map had no channel for (`classNam` → + * `className`). */ -export const PageComponentSchema = lazySchema(() => z.object({ +export const PageComponentSchema = lazySchema(() => strictObject(VISIBILITY_STRICT_OPTIONS, { /** Definition */ type: z.union([ PageComponentType, @@ -152,7 +159,7 @@ export const PageComponentSchema = lazySchema(() => z.object({ /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), -}, { error: strictVisibilityError }).strict().transform(normalizeVisibleWhen)); +}).transform(normalizeVisibleWhen)); /** * Page Variable Schema diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 448b87f926..d419dffccc 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -2954,23 +2954,26 @@ describe('ADR-0089 D3a — strict view form schemas (loud mis-layered keys)', () }); /** - * Message ORDER on `strictVisibilityError` (#6416, applying #5955's ruling). + * Message ORDER on the ADR-0089 visibility rejection (#6416, applying #5955's + * ruling; #6619 folded the map into the shared template). * - * The map in `shared/visibility.ts` is a hand-written `$ZodErrorMap`: it never - * calls `strictUnknownKeyError`, so #5955's reorder of the shared template did - * not reach it, and it is not one of the 44 direct call sites #5593 migrates to - * `strictObject` either. It had the same defect the ruling was filed against — - * a ~120-char history sentence sitting BETWEEN the offending key and the - * canonical-key pointer that fixes it, which is past the front of the single- - * line renders several consumers use (`os validate`'s `• where: message`, CI - * logs, and `validateFlowTriggerReadiness`, which flattens the newlines). + * This block was written against `strictVisibilityError`, the hand-written + * `$ZodErrorMap` that #5955 and #5593 could not reach; #6416 direction 1 + * reordered it in place, and these pins were that reorder's acceptance + * criteria. #6619 then folded the map into `strictObject`'s set-keyed + * `guidance` channel (`VISIBILITY_STRICT_OPTIONS`), and the pins migrated with + * the code — the emission ORDER they encode (front matter → fix channels → + * explanatory sentence last) is the template's own contract. One byte-level + * change rode the fold and is pinned below as such: the prescription is now + * rendered as the template's `\n • ` bullet instead of joined inline with a + * space, the same channel every other closed surface's prescriptions use. * - * These are ORDER pins, not presence checks. The reorder deletes nothing, so + * These are ORDER pins, not presence checks. The fold deletes nothing, so * every existing `toContain` in the block above stays green either way; a * future edit that folds the sentence back into the middle passes all of them * and fails here. */ -describe('strictVisibilityError message order — fix before history (#6416)', () => { +describe('visibility unknown-key message order — fix before history (#6416 / #6619)', () => { const HISTORY = 'Before ADR-0089 D3a these were dropped silently, shipping inert metadata; ' + 'a mis-layered or stale key is now a loud parse error.'; @@ -2988,8 +2991,11 @@ describe('strictVisibilityError message order — fix before history (#6416)', ( const m = messageFor({ visibleWhenn: 'record.a == 1' }); // 1. which key is wrong — and nothing before it expect(m.startsWith('Unrecognized key(s) on this view/page schema: `visibleWhenn`.')).toBe(true); - // 2. the fix, immediately after it — this is the whole point of the reorder - expect(m).toContain('`visibleWhenn`. If this is the conditional-visibility predicate'); + // 2. the fix, immediately after it — this is the whole point of the reorder. + // Since #6619 the prescription arrives as the shared template's ` • ` + // bullet (it rides the set-keyed guidance channel); the position is + // unchanged — directly after the key statement, before the history. + expect(m).toContain('`visibleWhenn`.\n • If this is the conditional-visibility predicate'); // 3. the history sentence, verbatim, last — moved, never dropped expect(m).toContain(PRESCRIPTION); expect(m.indexOf(PRESCRIPTION)).toBeLessThan(m.indexOf(HISTORY)); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 5e90b1138f..de1c618080 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -3,10 +3,10 @@ import { z } from 'zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; -import { strictObject } from '../shared/strict-object'; +import { strictObject, strictObjectError } from '../shared/strict-object'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; -import { normalizeVisibleWhen, strictVisibilityError } from '../shared/visibility'; +import { normalizeVisibleWhen, VISIBILITY_STRICT_OPTIONS } from '../shared/visibility'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { ChartTypeSchema } from './chart.zod'; import { SharingConfigSchema } from './sharing.zod'; @@ -1310,27 +1310,46 @@ export const ListViewSchema = lazySchema(() => strictObject({ * is the allocation `lazySchema` exists to defer. */ /** - * [#4001 批 18] Deliberately NOT converted to `strictObject` — and the ledger - * row for this site is a measurement artifact, not open surface. + * [#4001 批 18] Deliberately still a BARE `z.object` — and the ledger row for + * this site is a measurement artifact, not open surface. * - * Two independent reasons, both verified rather than assumed: + * Two reasons were recorded; #6619 retired one of them and left the other + * standing, which is why the posture here did not move with the fold: * * 1. **The posture here was never the live one.** This base is module-private * and has exactly ONE consumer, {@link FormFieldSchema}, which applies * `.strict()` after extending it (ADR-0089 D3a). An unknown form-field key * is already rejected at the only door; the ledger reads `strip` because it - * counts the BASE, and the base is not a door. - * 2. **It already carries a bespoke error map.** The `{ error: - * strictVisibilityError }` below is the ADR-0089 map that resolves the - * `visibleWhen` / `visibility` pair. Converting would mean re-expressing - * that map as `guidance` and re-proving the `.transform()` — a refactor of - * working, tested behaviour rather than a strictness change. Same call the - * note on {@link FormSectionSchema} records, for the same family of shape. + * counts the BASE, and the base is not a door. Still true — and it is why + * this site takes `strictObjectError` rather than `strictObject`: the fold + * is a change to what the message can SAY, and closing this shape as a side + * effect would have been an acceptance change smuggled in with it. + * 2. ~~It already carries a bespoke error map.~~ **Retired by #6619.** The + * ADR-0089 map that resolves the `visibleWhen` / `visibility` pair is no + * longer bespoke: it is `VISIBILITY_STRICT_OPTIONS`, a shared `strictObject` + * options fragment whose prescription rides the template's set-keyed + * `guidance` channel. `strictObjectError` builds exactly the map + * `strictObject` would and registers the surface with + * `alias-integrity.test.ts` — the audit this site used to sit outside — with + * the shape left open for the extension to close. + * + * `extraKeys: ['fields']` is the base/extension boundary the helper documents: + * the candidate list is read from THIS shape, and `fields` is declared one + * level up by the extension that also closes it, so naming it here is what + * keeps `feilds` pointing at `fields` instead of at `field`. + * + * Spelled as a literal `z.object(shape, { error: strictObjectError(…) })` + * rather than through a wrapper on purpose: the strictness ledger's AST reader + * (`scripts/lib/strictness-ledger.ts`) recognises the `z.object(` idiom and + * counts this site's OPEN posture — the row `ui/view.zod.ts` carries as its + * one `authorable` strip site. A wrapper would hide the site from the + * instrument entirely, which is a gate going quiet, not a posture change. * * The nested `keyField` block below IS converted: strictness does not recurse, * so a closed parent said nothing about it. */ -const FormFieldBaseSchema = lazySchema(() => z.object({ +const FormFieldBaseSchema = lazySchema(() => { + const shape = { /** Field name (snake_case) */ field: z.string().describe('Field name (snake_case)'), @@ -1441,7 +1460,11 @@ const FormFieldBaseSchema = lazySchema(() => z.object({ /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ visibleOn: ExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'), disclosure: z.enum(['inline', 'popover']).optional().describe('Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure).'), -}, { error: strictVisibilityError })); + }; + return z.object(shape, { + error: strictObjectError({ ...VISIBILITY_STRICT_OPTIONS, extraKeys: ['fields'] }, shape), + }); +}); /** * A parsed form field — the TYPE half of {@link FormFieldSchema}. @@ -1506,14 +1529,15 @@ export const FormFieldSchema: z.ZodType = lazySchema( /** * Form Layout Section * - * Deliberately NOT converted to `strictObject` by #4001: this shape already - * closed under ADR-0089 D3a, with `strictVisibilityError` — the bespoke map that - * resolves the `visibleWhen` / `visibility` pair — and a `.transform()` that - * normalizes them. Converting would mean re-expressing that map as `guidance` - * and re-proving the transform: a refactor of working, tested behaviour rather - * than a strictness change. Same call as the widget map in `dashboard.zod.ts`. + * Closed under ADR-0089 D3a, and — since #6619 — closed with the SHARED + * template rather than a bespoke map. #4001 skipped this shape because + * converting it meant re-expressing `strictVisibilityError` as `guidance`, and + * `guidance` could not express a family of keys; #6619 gave the channel a + * set-keyed form and the conversion became the same one call every other closed + * shape in this package makes. The `.transform()` that folds + * `visibleOn` → `visibleWhen` is unchanged and still runs after the parse. */ -export const FormSectionSchema = lazySchema(() => z.object({ +export const FormSectionSchema = lazySchema(() => strictObject(VISIBILITY_STRICT_OPTIONS, { /** * Stable identifier for translation lookup. snake_case convention. * When provided, translation bundles can target this section's `label` @@ -1560,7 +1584,7 @@ export const FormSectionSchema = lazySchema(() => z.object({ z.string(), // Legacy: simple field name FormFieldSchema, // Enhanced: detailed field config ])), -}, { error: strictVisibilityError }).strict().transform(normalizeVisibleWhen)); +}).transform(normalizeVisibleWhen)); /** * A single form action button (submit / cancel / reset): visibility + label. From 0c247c0d438fc29fb185dedce70920728bbaed15 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 18:15:11 +0000 Subject: [PATCH 2/2] changeset for #6619 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk --- .../fold-unrecognized-key-error-maps.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .changeset/fold-unrecognized-key-error-maps.md diff --git a/.changeset/fold-unrecognized-key-error-maps.md b/.changeset/fold-unrecognized-key-error-maps.md new file mode 100644 index 0000000000..d394a58bb8 --- /dev/null +++ b/.changeset/fold-unrecognized-key-error-maps.md @@ -0,0 +1,27 @@ +--- +"@objectstack/spec": patch +--- + +refactor(spec): 三个手写 unrecognized_keys 错误映射折叠进共享 `strictObject` guidance 模板(#6619,#6416 方向 2) + +`strictVisibilityError`(`shared/visibility.ts`)、`strictWidgetAnalyticsError` +(`ui/dashboard.zod.ts`)、`strictTenancyError`(`data/object.zod.ts`)此前是 +手写 `$ZodErrorMap`——#5955 的模板重排、#5593 的 `strictObject` 迁移都够不到 +它们,`alias-integrity.test.ts` 的两个注册表也都看不见它们(#6416 命名的盲区)。 + +- **共享模板新增按集合取键的 guidance 形态**(`KeySetGuidance` / `guidanceSets`): + 一条处方由一个具名键族共享(枚举列表或模式),每条消息每个集合最多发声一次。 + 优先级规则固定并有测试钉住:精确 `guidance` 条目永远胜过集合;集合之间按声明 + 顺序;集合命中即抑制改名建议。 +- **三个映射全部折入模板**,`alias-integrity.test.ts` 新增集合成员死条目检查、 + 模式 examples 检查与折叠闭合钉(还原任何一个手写映射会红在门上而不是重新 + 变成盲区)。 +- **接受面逐字节不变**:所有 schema 接受/拒绝的输入集合与折叠前完全一致。 + #6453 留下的 13 个消息顺序钉全部随代码迁移、零删除。消息字节有三处刻意变化 + (处方从内联空格改为模板的 `\n • ` 项目符号;无处方的键获得模板的编辑距离 + 改名建议,如 `tenantfield` → `tenantField`;widget 多键族时各族处方全部给出 + 而非只给第一个命中分支)。 +- 公开导出面:`./shared` 移除 `strictVisibilityError`,新增 + `VISIBILITY_STRICT_OPTIONS`、`KeySetGuidance`、`keySetMatches`;新增 + `strictObjectError`(`strictObject` 的注册+错误映射一半,供不关门的 + module-private base 使用)。