Skip to content

Commit 85fe3ee

Browse files
author
os-zhuang
committed
docs(spec): alias guidance for visible/showWhen/disabled on the visibleWhen shapes (#7832)
Give the `visibleWhen`-family strict shapes the curation the action shapes have carried in the opposite direction since #3746, so `visible` / `showWhen` / `disabled` written on the wrong surface names the key the author wanted. Acceptance is byte-identical: every key touched here was rejected before and is rejected after. `RowCrudActionOverrideSchema` moves from a hand-written `.strict()` to the shared `strictObject` helper, which is `z.object(shape, { error }).strict()` — the same door with a curated map. Changed: `RowCrudActionOverrideSchema` (surface was unnamed; `showWhen` renames, `visible`/`disabled` answered in prose naming both `enabled` and the `*When` slot), `FieldSchema` (`showWhen` renames, `visible` answered in prose naming `hidden` and its inversion alongside `visibleWhen`), `FormFieldSchema` (`disabled` renames onto `readonly`). Already compliant: `SelectOptionSchema`, and form sections / page components via the ADR-0089 guidance set. Deliberately uncurated: `disabled` where the shape declares no landing key. Fixes #7832 { "issue": 7832, "status": "done", "branch": "claude/issue-7832-visiblewhen-aliases", "pr": "#7884", "premise_still_valid": true, "summary": "Premise held, but only PARTLY as the card described it — three of the five enumerated surfaces needed nothing, and the `disabled` ask was impossible on four of five. Measured every surface against origin/main before editing. CHANGED: (1) RowCrudActionOverrideSchema — the real gap; it produced zod's bare `Unrecognized key: \"visible\"` with the surface unnamed. Converted to strictObject (verified acceptance-equivalent: strictObject IS z.object(shape,{error}).strict(), strict-object.ts:327). Added alias showWhen->visibleWhen and guidance prose for visible/disabled naming BOTH landing keys — honoring #7816's note that the boolean reading belongs on `enabled`, not `visibleWhen`. Prose not alias because this shape splits into enabled+visibleWhen+disabledWhen what a custom row action spells with one dual-form key, so a rename must guess. (2) FieldSchema — visible/showWhen had no hint at all; showWhen renames onto visibleWhen, visible answered in prose naming `hidden` (INVERTED: visible:false is hidden:true) and `visibleWhen`; a rename either way would have the author ship a CEL slot a boolean, or the inverse of what they wrote. FieldSchema's existing `disabled -> readonly` is already correct (a field has readonlyWhen, not disabledWhen) and was left alone. (3) FormFieldSchema (view.zod.ts, NOT named by the card) — the one view/page shape declaring `readonly`, so it gets `disabled -> readonly`, filed on its own call site rather than the shared VISIBILITY_STRICT_OPTIONS, which two sibling consumers would make false. ALREADY COMPLIANT: SelectOptionSchema (card was stale, as the claim comment warned); FormSectionSchema + PageComponentSchema + FormFieldSchema answer visible/showWhen through VISIBILITY_STRICT_OPTIONS's ADR-0089 guidanceSets — VISIBILITY_KEY_PATTERN /vis|conceal|hidden|show.?when/i matches both, and a set match `continue`s past the rename channel, so an alias there would be DEAD CODE, not a second opinion. OUT OF SCOPE with reason: `disabled` on SelectOptionSchema / FormSectionSchema / PageComponentSchema — none declares disabledWhen, disabled or readonly, so a row would name a key the shape rejects next (alias-integrity.test.ts fails such a row outright); pinned as a deliberate gap that FAILS if any of them ever gains a disabled-ish key. Also drafted and dropped: hideWhen->visibleWhen on the row override — hideWhen is the INVERSE predicate, so the rename would preserve the expression and flip its meaning.", "tests": { "new_file": "packages/spec/src/shared/visible-when-alias-guidance.test.ts (19 pins: curated messages name the intended key; already-compliant surfaces pinned so a later sweep can tell 'answered' from 'missed'; deliberate gaps pinned to fail if a landing key appears; acceptance-invariance asserted directly)", "spec_full_suite": "PASS — 380 files / 10002 tests (npx vitest run in packages/spec)", "post_rebase_affected_suites": "PASS — 24 files / 808 tests (src/shared, object-strictness-batch20, field, view, page)", "alias_integrity_audit": "PASS — every new row judged as a claim about its schema (target declared, key not declared, no probe collision)", "typecheck": "PASS — npx tsc --noEmit -p packages/spec/tsconfig.json, clean", "spec_build": "PASS — pnpm build, dist hash 3fcf5590a8092eea", "check_spec_parsed_alias": "PASS — 18 self-test assertions; 1512/826/686 OK", "check_adr_anchors": "PASS — 48 anchored files, 22537 citations resolve", "check_nul_bytes": "PASS — 7215 files scanned, clean", "acceptance_equivalence": "VERIFIED EMPIRICALLY — 17 surface x key pairs probed against a real built dist before and after; every one REJECTED before and REJECTED after, no verdict moved. Only messages differ.", "ci": "in_progress — draft PR #7884 just opened; PM owns CI convergence" }, "open_questions": [ "FormFieldSchema was NOT in the card's enumeration but is in ui/view.zod.ts, in the visibleWhen family, and is the only view/page shape that can answer `disabled` truthfully. Included and flagged rather than silently skipped — trivially revertible if the PM wants the card's list honored literally.", "Three surfaces (select option, form section, page component) reject `disabled` loudly but with no pointer, because they have no key to point at. A wrong-layer prescription ('this layer has no disabled — gate with visibleWhen') was considered and NOT written: it is a claim about the whole platform rather than about the shape, and I could not verify it to the standard the other rows meet. Recorded as an out_of_scope_finding instead.", "The card asked for aliases; three rows landed as `guidance` prose instead. The rule used: one landing key => alias, two (boolean + predicate) => prose naming both, none => nothing. Stated in the PR body and in the test file's docblock so it can be overruled in review." ], "out_of_scope_findings": [ "The card's enumeration was stale in two directions, not one. Beyond the SelectOptionSchema staleness the claim comment flagged, form sections and page components were ALSO already compliant for visible/showWhen (via the ADR-0089 guidanceSets added in #6619) — so 3 of 5 enumerated surfaces needed no visible/showWhen work, and the single real gap was RowCrudActionOverrideSchema.", "`disabled` on view/page shapes has no vocabulary at all: FormSectionSchema and PageComponentSchema declare no readonly/disabled/disabledWhen key, so there is no way to express 'this section/component is disabled'. Whether that is a deliberate design boundary or a gap is a question for domain:spec, not this card. If it is a gap, the alias rows this PR could not write become writable.", "VISIBILITY_KEY_PATTERN (/vis|conceal|hidden|show.?when/i) silently makes any alias for a vis-shaped key dead on every VISIBILITY_STRICT_OPTIONS surface, because a guidanceSet match skips the rename channel. That is correct behaviour but not documented at the alias-authoring site — a future card adding a vis-shaped alias to those shapes would write dead code and no gate would say so (alias-integrity checks that a target is declared, not that the row is reachable past a set). Possible follow-up: extend alias-integrity to fail an alias key that a guidanceSet in the same options already consumes.", "#7816 ask 2 (one canonical spelling across the action and visibleWhen vocabularies) remains open and untouched. If it converges them, every row added here becomes the migration hint, exactly as the card predicted." ] }
1 parent f28ef3b commit 85fe3ee

5 files changed

Lines changed: 342 additions & 3 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): alias guidance for `visible` / `showWhen` / `disabled` on the `visibleWhen` shapes (#7832)
6+
7+
`ui/action.zod.ts` has carried this table in the OTHER direction since #3746: on
8+
an action, `visible` and `disabled` are the canonical keys, so the aliases run
9+
`visibleWhen → visible`, `showWhen → visible`, `disabledWhen → disabled`. The
10+
reverse direction — an author who learned the action vocabulary writing it on a
11+
shape whose canonical key is `visibleWhen` — was curated on some surfaces and
12+
bare on others, and nothing recorded which was which.
13+
14+
**Nothing changes about what parses.** Every key named here was rejected before
15+
and is rejected after; only the message differs. `RowCrudActionOverrideSchema`
16+
moves from a hand-written `.strict()` to the shared `strictObject` helper, which
17+
is `z.object(shape, { error }).strict()` — the same door, now with a curated
18+
error map behind it.
19+
20+
What each surface says now:
21+
22+
- **`userActions.edit` / `.delete` overrides** (`RowCrudActionOverrideSchema`)
23+
produced zod's own bare `Unrecognized key: "visible"` — the surface unnamed
24+
and no key to write instead. It now names the surface, renames `showWhen` onto
25+
`visibleWhen`, and answers `visible` / `disabled` in prose naming BOTH landing
26+
keys: `enabled: false` for the object-level switch, `visibleWhen` /
27+
`disabledWhen` for the per-record predicate. Prose rather than a rename
28+
because this shape splits into a boolean and a predicate what a custom row
29+
action spells with one key, so a rename would have to guess which the author
30+
meant.
31+
- **Fields** rename `showWhen` onto `visibleWhen`, and answer `visible` in prose
32+
naming both `hidden` and `visibleWhen` — including the inversion, since
33+
`visible: false` is `hidden: true` and a rename would have the author ship the
34+
opposite of what they wrote. `disabled → readonly` was already correct here: a
35+
field has `readonlyWhen`, not `disabledWhen`.
36+
- **Form fields** rename `disabled` onto `readonly`, the one shape in the
37+
view/page family that declares a key for it.
38+
39+
Three surfaces already answered `visible` / `showWhen` and gained no row: select
40+
options rename both onto `visibleWhen`, and form sections and page components
41+
answer them through the shared ADR-0089 conditional-visibility prescription.
42+
Select options, form sections and page components declare no disabled-ish key at
43+
all, so `disabled` there stays a bare rejection rather than being pointed at a
44+
key the shape would reject next — pinned so a later sweep can tell a deliberate
45+
gap from a missed one.
46+
47+
Choosing ONE canonical spelling across the two vocabularies remains open
48+
(#7816); if that ruling ever converges them, these rows become the migration
49+
hint.

packages/spec/src/data/field.zod.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,12 @@ export const FieldSchema = lazySchema(() => strictObject({
458458
decimals: 'scale', decimalPlaces: 'scale', digits: 'precision',
459459
isReadonly: 'readonly', disabled: 'readonly',
460460
isHidden: 'hidden', invisible: 'hidden',
461+
// `showWhen` has only one reading — a predicate — so it renames. Its
462+
// sibling `visible` has two on this surface and is answered in prose
463+
// below; `disabled` already renames onto `readonly` above, which is the
464+
// right target here because a field has `readonlyWhen`, not `disabledWhen`
465+
// (#7832).
466+
showWhen: 'visibleWhen',
461467
section: 'group', category: 'group', fieldset: 'group',
462468
component: 'widget', renderer: 'widget', control: 'widget',
463469
mimeTypes: 'accept', allowedTypes: 'accept', fileTypes: 'accept',
@@ -485,6 +491,16 @@ export const FieldSchema = lazySchema(() => strictObject({
485491
+ '(ADR-0113). `required` is the WRITE contract and deliberately does not imply the column '
486492
+ 'constraint.',
487493
tracked: '`tracked` is not a field key — per-field timeline tracking is `trackHistory: true` (ADR-0052 §5b).',
494+
// Prose rather than a rename, because this surface declares BOTH forms and
495+
// the two answers have opposite polarity: renaming onto `visibleWhen` sends
496+
// `visible: false` to a slot that wants a CEL string, and renaming onto
497+
// `hidden` silently inverts the value the author already wrote. Naming both
498+
// is the only answer that cannot be acted on wrongly (#7832 / #7816).
499+
visible:
500+
'`visible` is not a field key, and which key you want depends on the form: a static '
501+
+ 'boolean is `hidden` — INVERTED, so `visible: false` is `hidden: true` — while a '
502+
+ 'per-record CEL predicate is `visibleWhen` (shown only when TRUE). Its siblings are '
503+
+ '`readonlyWhen` and `requiredWhen`.',
488504
},
489505
}, {
490506
/** Identity */

packages/spec/src/data/object.zod.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,7 +1072,40 @@ export type ObjectExternalBindingParsed = z.infer<typeof ObjectExternalBindingSc
10721072
* record bound as `record.*` (and bare fields) — the same machinery custom
10731073
* actions already use, so authoring is identical.
10741074
*/
1075-
export const RowCrudActionOverrideSchema = z.object({
1075+
export const RowCrudActionOverrideSchema = strictObject({
1076+
surface: 'this row CRUD override',
1077+
// Closed from birth (objectui#2614), so nothing was ever silently stripped
1078+
// here — but the rejection was zod's own bare `Unrecognized key: "visible"`,
1079+
// which names neither the surface nor a key to write instead. #7832.
1080+
history:
1081+
'This shape has been closed since objectui#2614, so the key was never silently dropped — '
1082+
+ 'until #7832 the rejection just could not tell you which key to write instead.',
1083+
aliases: {
1084+
// `showWhen` carries no boolean reading: whatever surface an author borrowed
1085+
// it from, they meant a predicate, and the predicate slot here is
1086+
// `visibleWhen`. The two keys that DO have both readings are answered by
1087+
// `guidance` below rather than renamed, because a rename has to pick one.
1088+
showWhen: 'visibleWhen',
1089+
},
1090+
guidance: {
1091+
// The near-misses on this surface all arrive from custom row actions
1092+
// (`actions[].visible` / `.disabled`), where ONE key takes either a boolean
1093+
// or a CEL string. This shape splits that pair in two — `enabled` for the
1094+
// object-level switch, `*When` for the per-record predicate — so a rename
1095+
// cannot answer either key without guessing which form the author meant,
1096+
// and guessing wrong just relocates the confusion (#7816's own note: point
1097+
// the boolean reading at `enabled`, not at `visibleWhen`).
1098+
visible:
1099+
'`visible` is the CUSTOM row-action spelling (`actions[].visible`), where one key takes '
1100+
+ 'either form. This override splits them: write `enabled: false` for the object-level '
1101+
+ 'on/off, or `visibleWhen: <CEL over record.*>` for a per-record predicate (FALSE hides '
1102+
+ 'that row\'s button).',
1103+
disabled:
1104+
'`disabled` is the CUSTOM row-action spelling (`actions[].disabled`). The per-record form '
1105+
+ 'here is `disabledWhen: <CEL over record.*>` (TRUE renders that row\'s button disabled); '
1106+
+ 'there is no boolean `disabled` — switch the affordance off with `enabled: false`.',
1107+
},
1108+
}, {
10761109
enabled: z.boolean().optional().describe(
10771110
'Object-level on/off for the generic affordance; same meaning as the bare boolean form. Omitted → managedBy bucket default.',
10781111
),
@@ -1082,7 +1115,7 @@ export const RowCrudActionOverrideSchema = z.object({
10821115
disabledWhen: ExpressionInputSchema.optional().describe(
10831116
'Per-record CEL predicate; true → render the row button disabled for that record. Fail-soft.',
10841117
),
1085-
}).strict().describe('Boolean-or-predicates override for a built-in row CRUD affordance.');
1118+
}).describe('Boolean-or-predicates override for a built-in row CRUD affordance.');
10861119
export type RowCrudActionOverride = z.input<typeof RowCrudActionOverrideSchema>;
10871120
/** Post-parse shape of {@link RowCrudActionOverride} — defaults applied, transforms run (ADR-0122). */
10881121
export type RowCrudActionOverrideParsed = z.infer<typeof RowCrudActionOverrideSchema>;
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #7832 — `visible` / `showWhen` / `disabled` on the `visibleWhen` shapes.
5+
*
6+
* `ui/action.zod.ts` has carried this table in the OTHER direction since #3746:
7+
* on an action, `visible` / `disabled` are the canonical keys and the aliases
8+
* run `visibleWhen → visible`, `showWhen → visible`, `disabledWhen → disabled`.
9+
* The reverse direction — an author who learned the action vocabulary writing
10+
* it on a `visibleWhen` shape — was curated on some surfaces and bare on
11+
* others, and nothing said which was which. This file is that inventory,
12+
* asserted rather than asserted-in-prose.
13+
*
14+
* ## Why the answers differ per surface, and why that is not inconsistency
15+
*
16+
* The rule the six cases below encode:
17+
*
18+
* - ONE landing key ⇒ **alias** (a rename is unambiguous and reads best).
19+
* - TWO landing keys, a boolean and a predicate ⇒ **guidance prose** naming
20+
* both. A rename has to pick one, and picking wrong just relocates the
21+
* confusion — #7816's own note, and the reason `RowCrudActionOverride`'s
22+
* `visible` points at `enabled` *and* `visibleWhen` rather than at either.
23+
* On `FieldSchema` the two answers additionally have OPPOSITE POLARITY
24+
* (`visible: false` is `hidden: true`), so a rename onto `hidden` would
25+
* have the author ship the inverse of what they wrote.
26+
* - NO landing key ⇒ **nothing**. An alias whose target the shape does not
27+
* declare is the ledger's finding-12 shape: the author is told to write
28+
* the one key guaranteed to be rejected next. `alias-integrity.test.ts`
29+
* fails such a row outright; the cases below record WHICH surfaces are in
30+
* that bucket so the next sweep does not read them as missed.
31+
*
32+
* ## Acceptance is untouched
33+
*
34+
* Every key probed here is rejected before this change and rejected after it —
35+
* only the message differs. That is the whole card: `strictObject` is
36+
* `z.object(shape, { error }).strict()`, so converting a plain `.strict()` to
37+
* it (`RowCrudActionOverrideSchema`) adds an error map and changes no verdict.
38+
* Section 3 pins that directly.
39+
*/
40+
41+
import { describe, it, expect } from 'vitest';
42+
43+
import { RowCrudActionOverrideSchema } from '../data/object.zod';
44+
import { FieldSchema, SelectOptionSchema } from '../data/field.zod';
45+
import { FormFieldSchema, FormSectionSchema } from '../ui/view.zod';
46+
import { PageComponentSchema } from '../ui/page.zod';
47+
48+
/**
49+
* The `unrecognized_keys` message for `value`, or a loud failure.
50+
*
51+
* Deliberately narrowed to that ONE issue code: several of these probes are
52+
* minimal bodies that also miss a required key, and a whole-error stringify
53+
* would let an assertion pass on text from an unrelated issue.
54+
*/
55+
function unknownKeyMessage(
56+
schema: { safeParse: (v: unknown) => { success: boolean; error?: unknown } },
57+
value: unknown,
58+
): string {
59+
const r = schema.safeParse(value);
60+
expect(r.success, `expected REJECTION, got a successful parse of ${JSON.stringify(value)}`).toBe(false);
61+
const issues = (r.error as { issues?: Array<{ code?: string; message?: string }> }).issues ?? [];
62+
const hit = issues.find((i) => i.code === 'unrecognized_keys');
63+
expect(hit, `no \`unrecognized_keys\` issue in ${JSON.stringify(issues)}`).toBeDefined();
64+
return hit?.message ?? '';
65+
}
66+
67+
/** Minimal bodies that reach each surface's unknown-key path. */
68+
const FIELD = { name: 'probe', type: 'text' } as const;
69+
const OPTION = { label: 'A', value: 'aa' } as const;
70+
const SECTION = { fields: [] } as const;
71+
const COMPONENT = { type: 'text' } as const;
72+
const FORM_FIELD = { field: 'probe' } as const;
73+
74+
// ===========================================================================
75+
// 1. The surfaces this card CHANGED
76+
// ===========================================================================
77+
describe('#7832 — the curation added here', () => {
78+
describe('`RowCrudActionOverrideSchema` — was a bare zod message, surface unnamed', () => {
79+
it('names the surface at all (it did not before — `Unrecognized key: "visible"` was the whole message)', () => {
80+
const m = unknownKeyMessage(RowCrudActionOverrideSchema, { visible: true });
81+
expect(m).toContain('this row CRUD override');
82+
});
83+
84+
it('`visible` names BOTH landing keys — the boolean one first, per #7816', () => {
85+
const m = unknownKeyMessage(RowCrudActionOverrideSchema, { visible: true });
86+
expect(m).toMatch(/`enabled: false`/);
87+
expect(m).toMatch(/`visibleWhen/);
88+
// The claim the prose makes about the boolean form has to be true.
89+
expect(RowCrudActionOverrideSchema.safeParse({ enabled: false }).success).toBe(true);
90+
});
91+
92+
it('`disabled` points at `disabledWhen`, and says the boolean form is `enabled`', () => {
93+
const m = unknownKeyMessage(RowCrudActionOverrideSchema, { disabled: true });
94+
expect(m).toMatch(/`disabledWhen/);
95+
expect(m).toContain('`enabled: false`');
96+
expect(RowCrudActionOverrideSchema.safeParse({ disabledWhen: 'record.locked' }).success).toBe(true);
97+
});
98+
99+
it('`showWhen` RENAMES — one reading, so it gets the rename channel, not prose', () => {
100+
const m = unknownKeyMessage(RowCrudActionOverrideSchema, { showWhen: 'record.x' });
101+
expect(m).toContain('Did you mean `showWhen` → `visibleWhen`?');
102+
expect(RowCrudActionOverrideSchema.safeParse({ visibleWhen: 'record.x' }).success).toBe(true);
103+
});
104+
});
105+
106+
describe('`FieldSchema` — `visible` and `showWhen` had no hint at all', () => {
107+
it('`showWhen` renames onto `visibleWhen`', () => {
108+
const m = unknownKeyMessage(FieldSchema, { ...FIELD, showWhen: 'record.x' });
109+
expect(m).toContain('Did you mean `showWhen` → `visibleWhen`?');
110+
expect(FieldSchema.safeParse({ ...FIELD, visibleWhen: 'record.x' }).success).toBe(true);
111+
});
112+
113+
it('`visible` names both forms AND the inversion — the trap a rename would spring', () => {
114+
const m = unknownKeyMessage(FieldSchema, { ...FIELD, visible: false });
115+
expect(m).toContain('`hidden`');
116+
expect(m).toContain('`visibleWhen`');
117+
expect(m).toContain('INVERTED');
118+
// Both keys the prose names are really accepted here.
119+
expect(FieldSchema.safeParse({ ...FIELD, hidden: true }).success).toBe(true);
120+
expect(FieldSchema.safeParse({ ...FIELD, visibleWhen: 'record.x' }).success).toBe(true);
121+
});
122+
});
123+
124+
describe('`FormFieldSchema` — the one view/page shape that declares a landing key for `disabled`', () => {
125+
it('`disabled` renames onto `readonly`', () => {
126+
const m = unknownKeyMessage(FormFieldSchema, { ...FORM_FIELD, disabled: true });
127+
expect(m).toContain('Did you mean `disabled` → `readonly`?');
128+
expect(FormFieldSchema.safeParse({ ...FORM_FIELD, readonly: true }).success).toBe(true);
129+
});
130+
131+
it('…and the row is filed HERE, not on the shared table — the siblings would be lying', () => {
132+
// `VISIBILITY_STRICT_OPTIONS` is shared with the two shapes below, and
133+
// neither declares `readonly`. This is the assertion that keeps a future
134+
// tidy-up from hoisting the alias into the shared options.
135+
expect(FormSectionSchema.safeParse({ ...SECTION, readonly: true }).success).toBe(false);
136+
expect(PageComponentSchema.safeParse({ ...COMPONENT, readonly: true }).success).toBe(false);
137+
});
138+
});
139+
});
140+
141+
// ===========================================================================
142+
// 2. The surfaces that were ALREADY compliant — pinned so a sweep can tell
143+
// "already answered" from "nobody got to it"
144+
// ===========================================================================
145+
describe('#7832 — already curated before this card, and why no row was added', () => {
146+
it('`SelectOptionSchema` already renames `visible` and `showWhen` onto `visibleWhen`', () => {
147+
expect(unknownKeyMessage(SelectOptionSchema, { ...OPTION, visible: true }))
148+
.toContain('Did you mean `visible` → `visibleWhen`?');
149+
expect(unknownKeyMessage(SelectOptionSchema, { ...OPTION, showWhen: 'record.x' }))
150+
.toContain('Did you mean `showWhen` → `visibleWhen`?');
151+
});
152+
153+
it.each([
154+
['FormSectionSchema', FormSectionSchema, SECTION],
155+
['PageComponentSchema', PageComponentSchema, COMPONENT],
156+
['FormFieldSchema', FormFieldSchema, FORM_FIELD],
157+
])('%s answers `visible` / `showWhen` through the ADR-0089 guidance SET, not an alias', (_n, schema, base) => {
158+
for (const key of ['visible', 'showWhen']) {
159+
const m = unknownKeyMessage(schema, { ...base, [key]: 'record.x' });
160+
expect(m).toContain('the canonical key is `visibleWhen` (ADR-0089)');
161+
// The set consumes the key and `continue`s, so the rename channel never
162+
// runs for it — which is precisely why adding an alias for either key on
163+
// these three surfaces would be dead code, not a second opinion.
164+
expect(m).not.toContain('Did you mean');
165+
}
166+
});
167+
});
168+
169+
// ===========================================================================
170+
// 3. Where NO row was added, because the target key does not exist
171+
// ===========================================================================
172+
describe('#7832 — the deliberate gaps (an alias here would name a key the shape rejects)', () => {
173+
it.each([
174+
['SelectOptionSchema', SelectOptionSchema, OPTION],
175+
['FormSectionSchema', FormSectionSchema, SECTION],
176+
['PageComponentSchema', PageComponentSchema, COMPONENT],
177+
])('%s declares no `disabledWhen` / `disabled` / `readonly`, so `disabled` stays uncurated', (_n, schema, base) => {
178+
// Rejected — loudly, with the surface named — just without a pointer,
179+
// because there is nothing on this shape to point at. If any of these ever
180+
// gains a disabled-ish key, this assertion fails and the row becomes owed.
181+
for (const target of ['disabledWhen', 'disabled', 'readonly']) {
182+
expect(
183+
schema.safeParse({ ...base, [target]: 'x' }).success,
184+
`${_n} now declares \`${target}\` — file the \`disabled\` row`,
185+
).toBe(false);
186+
}
187+
expect(unknownKeyMessage(schema, { ...base, disabled: true })).toContain('`disabled`');
188+
});
189+
190+
it('`FieldSchema.disabled` was already pointed at `readonly`, which is right — a field has `readonlyWhen`, not `disabledWhen`', () => {
191+
expect(unknownKeyMessage(FieldSchema, { ...FIELD, disabled: true }))
192+
.toContain('Did you mean `disabled` → `readonly`?');
193+
expect(FieldSchema.safeParse({ ...FIELD, disabledWhen: 'record.x' }).success).toBe(false);
194+
expect(FieldSchema.safeParse({ ...FIELD, readonlyWhen: 'record.x' }).success).toBe(true);
195+
});
196+
});
197+
198+
// ===========================================================================
199+
// 4. Acceptance is byte-identical — the constraint this card was scoped under
200+
// ===========================================================================
201+
describe('#7832 — no acceptance change', () => {
202+
it('`RowCrudActionOverrideSchema` still accepts exactly its three declared keys', () => {
203+
expect(RowCrudActionOverrideSchema.safeParse({}).success).toBe(true);
204+
expect(
205+
RowCrudActionOverrideSchema.safeParse({
206+
enabled: true,
207+
visibleWhen: 'record.status != "closed"',
208+
disabledWhen: 'record.locked',
209+
}).success,
210+
).toBe(true);
211+
});
212+
213+
it('…and still REJECTS every key it rejected before the error map was attached', () => {
214+
for (const key of ['visible', 'disabled', 'showWhen', 'hidden', 'nonsenseKey']) {
215+
expect(
216+
RowCrudActionOverrideSchema.safeParse({ [key]: true }).success,
217+
`\`${key}\` must stay rejected — this card curates messages, it does not widen the shape`,
218+
).toBe(false);
219+
}
220+
});
221+
222+
it('the surfaces that gained prose/aliases did not gain KEYS', () => {
223+
for (const key of ['visible', 'showWhen']) {
224+
expect(FieldSchema.safeParse({ ...FIELD, [key]: true }).success).toBe(false);
225+
}
226+
expect(FormFieldSchema.safeParse({ ...FORM_FIELD, disabled: true }).success).toBe(false);
227+
});
228+
});

0 commit comments

Comments
 (0)