diff --git a/.changeset/11262-blank-gates-diagnosed.md b/.changeset/11262-blank-gates-diagnosed.md new file mode 100644 index 0000000000..c85522639f --- /dev/null +++ b/.changeset/11262-blank-gates-diagnosed.md @@ -0,0 +1,46 @@ +--- +'@object-ui/types': minor +'@object-ui/core': patch +'@object-ui/plugin-form': patch +--- + +A blank gate predicate is diagnosed on the three paths that still drew it in +silence, and objectui's two gate mirrors whose protocol key refuses a blank now +refuse it too (objectui#11262, ADR-0137 D4 and D1). **No drawn verdict moves:** a +blank gate is still "no gate". + +**Diagnosed, verdict unchanged (`@object-ui/core`, `@object-ui/plugin-form`).** +ADR-0137 D4 says a blank gate predicate is "diagnosed, never a silent `true`". +objectui#8069 diagnosed the `{ dialect: 'cel' }` route; three paths stayed silent, +and each now reports through that same guard (`evalFieldPredicate`'s `[blank]` +report, deduped per blank text and locator): + +- `ExpressionEvaluator.evaluateCondition`'s legacy path: a bare `''`, a + whitespace-only string, and an envelope without `dialect` whose `source` is + blank. These are the values `SchemaRenderer`'s visibility legs pass through + raw. The answer is still `true` in every mode, `throwOnError` included. A + caller that passes `onFault` receives the `[blank]` reason there; every other + caller gets the built-in warning. A blank on either route lands on one dedupe + key, so `''` and `{ dialect: 'cel', source: '' }` print one line. +- `evalRowPredicate`: a bare blank string returned the caller's fallback before + anything could say so. It now takes the route its blank-envelope twin already + took, with the same fallback and the same report (labelled on the + `warnOnError` route). +- `sectionFields`' `attachVisibility` (every sectioned `object-form` arm): a + blank view-level predicate is still dropped, so the field draws with no gate, + and the drop is reported, naming the field. A runtime field that already + carries its own blank `visibleOn` keeps it. The form renderer reports that one + when it evaluates it. + +**Refused at authoring (`@object-ui/types`, minor, a narrowing).** The protocol +declares an option's `visibleWhen` and a form view field's `visibleWhen` / +`visibleOn` as `EvaluatedExpressionInputSchema`, which refuses a blank predicate +(since `@objectstack/spec` 17.5.0). objectui's `SelectOptionSchema.visibleWhen` +and `FormFieldSchema.visibleOn` override those keys to keep objectui's wire, and +they still accepted `''`, whitespace and a blank envelope `source`. Both now +refuse them at the key, with the protocol's own sentence, through the check the +field-rule triad already carries. The accepted shape is unchanged: the same wire +options, by reference, and the parsed value is the authored one. + +`BaseSchema`'s `visible` / `hidden` / `disabled` have no protocol counterpart, +so a blank there still parses and is only diagnosed. diff --git a/apps/console/src/components/FormPage.visibleWhenFault-8069.test.tsx b/apps/console/src/components/FormPage.visibleWhenFault-8069.test.tsx index 7a1e60c71f..f6b737a7df 100644 --- a/apps/console/src/components/FormPage.visibleWhenFault-8069.test.tsx +++ b/apps/console/src/components/FormPage.visibleWhenFault-8069.test.tsx @@ -159,16 +159,27 @@ describe('objectui#8069 — FormPage refuses a faulted visibleWhen at submit', ( expect(warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('[blank]'))).toBe(true); }); - it('control — a blank VIEW-level visibleWhen is a layout gate, not a field rule: it submits', async () => { - // The view's section entry is normalized before this page builds its rows, - // and a blank view-level predicate does not survive that step — "no gate", - // and nothing on this path refuses it. + it('control — a blank VIEW-level visibleWhen is a layout gate, not a field rule: it submits, and the blank is said', async () => { + // This page carries the view's predicate onto its row verbatim and judges + // it at render (`isFieldVisible`, through `evalFieldPredicate`), so a blank + // one reads as "no gate" there — the field is drawn and nothing on this + // path refuses it — and it is REPORTED there, on the `[blank]` channel, + // naming the field (ADR-0137 D4; restated with the diagnostic by + // objectui#11262). It does not pass through `sectionFields`. + // + // On `status`, not `notes`: the stored-blank row above already printed the + // one line for `''` under the locator both of a field's `visibleWhen` + // slots share on this page ("visibleWhen of field 'notes'"), and the + // one-time dedupe is module state for the whole file. renderForm(objectWith({}), { - sections: [{ label: 'Basics', fields: ['title', 'priority', { field: 'notes', visibleWhen: '' }, 'status'] }], + sections: [{ label: 'Basics', fields: ['title', 'priority', 'notes', { field: 'status', visibleWhen: '' }] }], }); - await waitFor(() => expect(screen.getByLabelText('Notes')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByLabelText('Status')).toBeInTheDocument()); await submit(); await waitFor(() => expect(writes()).toHaveLength(1)); + expect( + warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('[blank]') && String(c[0]).includes("'status'")), + ).toBe(true); }); it('a faulted requiredWhen is NOT refused on the client — it is the server’s to refuse (D2)', async () => { diff --git a/content/docs/guide/metadata-diagnostics.md b/content/docs/guide/metadata-diagnostics.md index 675b992991..ac850d976a 100644 --- a/content/docs/guide/metadata-diagnostics.md +++ b/content/docs/guide/metadata-diagnostics.md @@ -186,9 +186,15 @@ blank) is not "no rule" — it is refused, at the first place that can see it parse, as the protocol's field schema does. One already stored is refused at submit, like any rule that cannot be evaluated: the client refuses a blank `visibleWhen`, the server a blank `requiredWhen` / `readonlyWhen`. A blank -**gate** is different: a blank `visible`, `hidden` or `disabled` is still read -as "no gate", with a one-time `[blank]` warning in the console, and a form -view's own blank field `visibleWhen` is likewise read as no gate, never refused. +**gate** is different: at runtime it is read as "no gate" and never refused — +a blank `visible`, `hidden` or `disabled`, a page node's `visibleWhen`, or a +form view's own field `visibleWhen` — but it is not silent either: it is +reported once in the console with the `[blank]` reason (ADR-0137 D4). Where the +protocol already refuses a blank gate at authoring, the form schema does too: a +form field's view-level `visibleOn` and a select option's `visibleWhen` reject +a blank predicate at parse, as their protocol counterparts do. `visible`, +`hidden` and `disabled` have no protocol counterpart, so a blank there parses +and is only diagnosed. The same is now true of a **component node's own gate** — `visibleWhen` on a page component (and its `visible` / `visibleOn` / `visibility` / `hidden` / diff --git a/packages/core/src/evaluator/ExpressionEvaluator.ts b/packages/core/src/evaluator/ExpressionEvaluator.ts index bcf875eeb5..17377593f3 100644 --- a/packages/core/src/evaluator/ExpressionEvaluator.ts +++ b/packages/core/src/evaluator/ExpressionEvaluator.ts @@ -362,7 +362,21 @@ export class ExpressionEvaluator { condition = (condition as any).source as string; } - // No condition → default to visible/enabled (undefined, null, ''). + // A BLANK gate — `''`, whitespace-only text, or an envelope whose `source` + // is either (unwrapped just above) — is "no gate": visible/enabled, as it + // always was here. It is answered by the SAME diagnosed guard the + // `{ dialect: 'cel' }` route above uses (objectui#11262, ADR-0137 D4: a + // blank gate predicate is "diagnosed, never a silent `true`"), so a bare + // blank, a dialect-less blank envelope and a blank CEL envelope are one + // report on one dedupe key. This used to be two silent returns — an + // `if (!condition)` and a whitespace-only `trim()` — on the path + // `SchemaRenderer`'s visibility legs reach with the raw authored value. + if (isBlankPredicateText(condition)) { + return this.answerBlankGate(condition as string, options); + } + + // No condition at all → default to visible/enabled (`undefined`, `null`). + // Not blank TEXT, so nothing was declared here to report. if (!condition) { return true; } @@ -372,9 +386,6 @@ export class ExpressionEvaluator { } const trimmed = condition.trim(); - if (!trimmed) { - return true; // Whitespace-only → treat as "no condition". - } // A condition is semantically a single boolean expression. When it's a // `${...}` template, evaluate via the template path. Otherwise treat the @@ -420,29 +431,7 @@ export class ExpressionEvaluator { * syntax) throws; a genuine `false` never throws. */ private evaluateCelCondition(source: string, options: EvaluationOptions): boolean { - if (isBlankPredicateText(source)) { - // A BLANK gate: no predicate → visible/enabled, exactly as before - // (objectui#3850 / #3960's verdict), in EVERY mode — `throwOnError` - // included, since the author wrote nothing that could fault. What - // changed is that it is no longer SILENT (ADR-0137 D4: a blank gate - // predicate is "diagnosed, never a silent `true`"; objectui#8069). The - // report is `evalFieldPredicate`'s own `[blank]` one — the channel every - // other predicate fault already uses — so a caller that passed `onFault` - // receives it there (and prints its own, node-named line), and every - // other caller gets the deduped built-in warning. No engine call is made: - // a blank predicate never reaches the engine. - evalFieldPredicate( - source, - {}, - true, - undefined, - undefined, - options.onFault - ? { warn: false, onFault: options.onFault } - : { context: 'a CEL gate predicate, read as no gate' }, - ); - return true; - } + if (isBlankPredicateText(source)) return this.answerBlankGate(source, options); const bag = this.context.toObject(); const rec = bag.record; const record = (rec && typeof rec === 'object' && !Array.isArray(rec)) @@ -480,6 +469,41 @@ export class ExpressionEvaluator { return asTrue; } + /** + * Answer a BLANK gate predicate — the ONE diagnosed guard of + * {@link evaluateCondition}, on both of its routes (objectui#8069 put it on + * the `{ dialect: 'cel' }` route; objectui#11262 routes the legacy path's + * blank here too, so the two are one report rather than two). + * + * The verdict is `true` — no predicate → visible/enabled, exactly as before + * (objectui#3850 / #3960's verdict) — in EVERY mode, `throwOnError` included, + * since the author wrote nothing that could fault. What it is not is SILENT + * (ADR-0137 D4: a blank gate predicate is "diagnosed, never a silent + * `true`"). The report is `evalFieldPredicate`'s own `[blank]` one — the + * channel every other predicate fault already uses — so a caller that passed + * `onFault` receives it there (and prints its own, node-named line), and + * every other caller gets the deduped built-in warning. No engine call is + * made: a blank predicate never reaches the engine. + * + * The locator names a CEL gate on both routes: a blank has no syntax to put + * it in either dialect, and a bare-string predicate is CEL by the spec's + * contract. One locator is also what keeps `''` and `{ dialect: 'cel', + * source: '' }` on one dedupe key. + */ + private answerBlankGate(text: string, options: EvaluationOptions): true { + evalFieldPredicate( + text, + {}, + true, + undefined, + undefined, + options.onFault + ? { warn: false, onFault: options.onFault } + : { context: 'a CEL gate predicate, read as no gate' }, + ); + return true; + } + /** * Update the context with new data */ diff --git a/packages/core/src/evaluator/__tests__/blankGateDiagnosed-11262.test.ts b/packages/core/src/evaluator/__tests__/blankGateDiagnosed-11262.test.ts new file mode 100644 index 0000000000..9bdacdc9a4 --- /dev/null +++ b/packages/core/src/evaluator/__tests__/blankGateDiagnosed-11262.test.ts @@ -0,0 +1,166 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11262 — ADR-0137 D4 on the two CORE paths that still answered a + * blank gate in silence after objectui#8069: a gate predicate that is blank is + * "diagnosed, never a silent `true`". + * + * 1. `ExpressionEvaluator.evaluateCondition`'s LEGACY path. objectui#8069 + * diagnosed the `{ dialect: 'cel' }` route only; a bare string and an + * envelope without `dialect` took the legacy path, whose `if (!condition)` + * and whitespace-only `trim()` returned `true` before anything could say + * so. `SchemaRenderer`'s `visibleWhen` / `visible` / `visibleOn` / + * `visibility` legs hand it the raw authored value, so this is the path a + * page author reaches. + * 2. `evalRowPredicate`'s bare-blank fallback (`listConditional.ts`). A blank + * ENVELOPE already reached `evalFieldPredicate`'s `[blank]` report; a + * blank STRING returned the caller's fallback one line earlier. + * + * ## What each row pins + * + * The VERDICT does not move (D3/D4: a blank gate is "no gate") and the silence + * ends, so every row asserts both halves. The report is the one channel + * objectui#8069 put on the `dialect: 'cel'` route — `evalFieldPredicate`'s + * `[blank]` reason — which the rows read by its tag, never by its prose. + * + * The "one diagnosis point" rows are what distinguish ONE guard from a second + * blank test with its own reporter: a blank spelled on the legacy path and the + * same blank spelled on the CEL route land on the same dedupe key, so they + * print one line between them. A second reporter prints two. + * + * ## Fresh module graph per case + * + * The one-time warning dedupe is MODULE state and the `unit` project runs with + * `isolate: false`, so a blank spelling another file already warned about + * would read as silence here. `vi.resetModules()` + fresh imports give every + * case its own dedupe `Set` — the shape `fieldRuleFaults-8069.test.ts` uses. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +let ExpressionEvaluator: typeof import('../ExpressionEvaluator.js').ExpressionEvaluator; +let evalRowPredicate: typeof import('../listConditional.js').evalRowPredicate; +let warn: ReturnType; + +beforeEach(async () => { + vi.resetModules(); + ({ ExpressionEvaluator } = await import('../ExpressionEvaluator.js')); + ({ evalRowPredicate } = await import('../listConditional.js')); + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); +afterEach(() => warn.mockRestore()); + +const warnings = (): string[] => warn.mock.calls.map((call: unknown[]) => String(call[0])); +const blankLines = (): string[] => warnings().filter((w) => w.includes('[blank]')); + +/** The three blank spellings the triage names, on every path. */ +const BLANKS = [ + ['an empty string', ''], + ['a whitespace string', ' \t '], + ["{ source: '' }", { source: '' }], +] as const; + +describe('path 1 — evaluateCondition’s legacy path diagnoses a blank gate (objectui#11262, ADR-0137 D4)', () => { + it.each(BLANKS)('%s: still true ("no gate"), and now said once', (_label, gate) => { + const ev = new ExpressionEvaluator({ record: {} }); + expect(ev.evaluateCondition(gate)).toBe(true); + expect(ev.evaluateCondition(gate)).toBe(true); + expect(blankLines()).toHaveLength(1); + }); + + it.each(BLANKS)('%s: the [blank] reason goes to onFault instead, when the caller supplies one', (_label, gate) => { + const reasons: string[] = []; + const ev = new ExpressionEvaluator({ record: {} }); + expect(ev.evaluateCondition(gate, { onFault: (r) => reasons.push(r) })).toBe(true); + expect(reasons).toHaveLength(1); + expect(reasons[0]).toMatch(/^\[blank\]/); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each(BLANKS)('%s: throwOnError does not throw on it — nothing was authored that could fault — and it is still said', (_label, gate) => { + const ev = new ExpressionEvaluator({ record: {} }); + expect(ev.evaluateCondition(gate, { throwOnError: true })).toBe(true); + expect(blankLines()).toHaveLength(1); + }); + + it('one diagnosis point: a blank on the legacy path and its dialect: cel twin print ONE line between them', () => { + const ev = new ExpressionEvaluator({ record: {} }); + expect(ev.evaluateCondition('')).toBe(true); + expect(blankLines()).toHaveLength(1); + expect(ev.evaluateCondition({ dialect: 'cel', source: '' })).toBe(true); + expect(ev.evaluateCondition({ source: '' })).toBe(true); + expect(blankLines()).toHaveLength(1); + }); + + it.each([ + ['undefined', undefined], + ['null', null], + ] as const)('control — %s is no gate at all, not blank TEXT: true and silent', (_label, gate) => { + const ev = new ExpressionEvaluator({ record: {} }); + expect(ev.evaluateCondition(gate as never)).toBe(true); + expect(warn).not.toHaveBeenCalled(); + }); + + it('control — a written legacy gate answers its own verdict and is silent', () => { + const ev = new ExpressionEvaluator({ data: { stage: 'open' } }); + expect(ev.evaluateCondition("${data.stage === 'open'}")).toBe(true); + expect(ev.evaluateCondition("${data.stage === 'won'}")).toBe(false); + expect(ev.evaluateCondition('1 > 2')).toBe(false); + expect(warn).not.toHaveBeenCalled(); + }); +}); + +describe('path 2 — evalRowPredicate diagnoses a bare blank instead of returning its fallback in silence (objectui#11262)', () => { + const ROUTES = [ + ['the single-eval route', false], + ['the labelled fail-closed route (warnOnError)', true], + ] as const; + + describe.each(ROUTES)('%s', (_route, warnOnError) => { + it.each(BLANKS)('%s: the caller’s fallback, both directions, and a [blank] report', (_label, pred) => { + const opts = { warnOnError, label: 'row action "archive_11262"' }; + expect(evalRowPredicate(pred as never, { a: 1 }, { ...opts, fallback: true })).toBe(true); + expect(evalRowPredicate(pred as never, { a: 1 }, { ...opts, fallback: false })).toBe(false); + // One line for both calls: the dedupe keys on the text and the label, + // never on the fallback. + const lines = blankLines(); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('archive_11262'); + }); + + it('one diagnosis point: the bare blank takes its envelope twin’s route — one line between them', () => { + const opts = { warnOnError, fallback: false, label: 'formatting rule 11262' }; + evalRowPredicate('', { a: 1 }, opts); + expect(blankLines()).toHaveLength(1); + evalRowPredicate({ source: '' } as never, { a: 1 }, opts); + evalRowPredicate('', { a: 2 }, opts); + expect(blankLines()).toHaveLength(1); + }); + + it.each([ + ['undefined', undefined], + ['null', null], + ] as const)('control — %s is no predicate: the fallback, silent', (_label, pred) => { + expect(evalRowPredicate(pred, { a: 1 }, { warnOnError, fallback: true })).toBe(true); + expect(evalRowPredicate(pred, { a: 1 }, { warnOnError, fallback: false })).toBe(false); + expect(warn).not.toHaveBeenCalled(); + }); + + it('control — a written predicate answers its own verdict and is silent', () => { + expect(evalRowPredicate('record.a == 1', { a: 1 }, { warnOnError, fallback: false })).toBe(true); + expect(evalRowPredicate('record.a == 1', { a: 2 }, { warnOnError, fallback: true })).toBe(false); + expect(warn).not.toHaveBeenCalled(); + }); + }); + + it('rowless (a param dialog) takes the same guard — the fallback, and the report', () => { + expect(evalRowPredicate(' ', null, { rowless: true, fallback: true, warnOnError: true, label: 'param "p_11262"' })).toBe(true); + expect(blankLines()).toHaveLength(1); + }); +}); diff --git a/packages/core/src/evaluator/declaredPredicate.ts b/packages/core/src/evaluator/declaredPredicate.ts index 748c4f1709..4c97917944 100644 --- a/packages/core/src/evaluator/declaredPredicate.ts +++ b/packages/core/src/evaluator/declaredPredicate.ts @@ -24,11 +24,12 @@ import { evalFieldPredicate, type FieldRulePredicate } from './fieldRules.js'; * `ActionRunner` refused to execute, for a predicate that says nothing). * * This is not a fourth dialect of "empty": it is the SAME rule core's evaluation - * entries already apply on the value side — `evaluateCondition` - * (`if (!trimmed) return true`), `evaluateCelCondition` (which asks THIS - * function since objectui#8069), `evalRowPredicate` (`listConditional.ts`) — - * brought to the one place that answers "is there a condition at all?", so the - * two halves cannot disagree about the same blank. + * entries already apply on the value side — `evaluateCondition` on both of its + * routes (which ask THIS function: the CEL route since objectui#8069, the + * legacy path since objectui#11262), `evalRowPredicate` (`listConditional.ts`, + * whose blank reaches `evalFieldPredicate`, which asks it too) — brought to the + * one place that answers "is there a condition at all?", so the two halves + * cannot disagree about the same blank. * * Exported since objectui#8069 for its second consumer, `evalFieldPredicate` * (`evaluator/fieldRules.ts`), which is the entry this docblock's list did NOT @@ -80,10 +81,11 @@ export function isBlankPredicateText(value: unknown): boolean { * Neither can be read off the normalizer's answer, so both are named here — * see {@link isBlankPredicateText} for why this is the layer that says it. * Core's other predicate entries already treat both as blank: - * `evaluateCondition` (`if (!trimmed) return true`), `evaluateCelCondition` - * (`isBlankPredicateText(source)`, then `true` — diagnosed since - * objectui#8069) and `evalRowPredicate` (`evaluator/listConditional.ts`, - * `if (!source.trim())`). + * `evaluateCondition` (`isBlankPredicateText`, then `true` — diagnosed on + * the CEL route since objectui#8069 and on the legacy path since + * objectui#11262) and `evalRowPredicate` (`evaluator/listConditional.ts`, + * the caller's fallback, through `evalFieldPredicate`'s `[blank]` report + * since objectui#11262). * - `{ dialect, source: '' }` — the empty ENVELOPE. This is not an exotic * spelling: `@objectstack/spec`'s `ExpressionInputSchema` normalizes every * authored predicate into an envelope, so "author left the predicate empty" diff --git a/packages/core/src/evaluator/listConditional.ts b/packages/core/src/evaluator/listConditional.ts index 60bae30492..4336917b84 100644 --- a/packages/core/src/evaluator/listConditional.ts +++ b/packages/core/src/evaluator/listConditional.ts @@ -249,8 +249,16 @@ export function evalRowPredicate( ): boolean { const fallback = opts.fallback ?? false; if (pred == null) return fallback; + // A BLANK predicate is not answered here. It used to be, for the bare-string + // spelling only: `''` / whitespace returned the fallback on this line, in + // silence, while a blank ENVELOPE went on to `evalFieldPredicate`'s `[blank]` + // report below (objectui#11262, ADR-0137 D4: a blank gate predicate is + // "diagnosed, never a silent `true`"). Both spellings now take that one + // route: no legacy marker can match blank text, so a blank string reaches + // the CEL path like its envelope twin and gets the same verdict — the + // caller's fallback — and the same report, labelled on the fail-closed route + // and by the canonical helper on the single-eval one. const source = typeof pred === 'string' ? pred : undefined; - if (source !== undefined && !source.trim()) return fallback; // Relations collapse to the foreign key the server stores, so `record.owner` // means one thing whether or not this surface expanded the column diff --git a/packages/plugin-form/src/sectionFields.blankGate-11262.test.ts b/packages/plugin-form/src/sectionFields.blankGate-11262.test.ts new file mode 100644 index 0000000000..379d4c71ff --- /dev/null +++ b/packages/plugin-form/src/sectionFields.blankGate-11262.test.ts @@ -0,0 +1,126 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11262 — `attachVisibility` diagnoses the blank view-level predicate + * it drops (ADR-0137 D4: a gate predicate that is blank is "diagnosed, never a + * silent `true`"). + * + * `sectionFields.ts` is where every sectioned `object-form` arm (and, through + * `SectionFieldsContext.pool`, the default arm) turns an authored section entry + * into a runtime field. A blank predicate — a string, or an envelope whose + * `source` is blank — never reached the runtime field's `visibleOn` slot, so the + * form renderer, which reports a blank predicate it evaluates, never saw it: the + * field was drawn with no gate and nothing said the author's gate was empty. + * That verdict stands ("no gate"); only the silence ends. + * + * Every chain that reaches `attachVisibility` is pinned, because each reads a + * different key: the object schema's `visible_on ?? visibleOn` behind a name + * string, a spec form-view entry's `visibleWhen ?? visibleOn`, and an + * already-built runtime field's own `visibleOn`. The last one is the exception + * the card's reading did not draw: that field IS the output, so its blank is + * not dropped at all. It reaches the form renderer, which evaluates it and + * reports it (the `form.tsx` pin "a blank view-level visibleOn is a layout + * GATE: no gate plus a diagnostic" in `visibleWhen-fault-refuses-submit-8069`), + * so its rows pin that it is carried unchanged and NOT reported a second time. + * + * Field names are unique per row: the one-time warning dedupe is module state + * in `@object-ui/core`, keyed on the predicate text and the locator (which names + * the field), and the `unit` project shares one module graph per worker. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { normalizeSectionField } from './sectionFields'; + +let warn: ReturnType; +beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); +afterEach(() => warn.mockRestore()); + +const blankLines = (): string[] => + warn.mock.calls.map((call: unknown[]) => String(call[0])).filter((w: string) => w.includes('[blank]')); + +const BLANKS = [ + ['an empty string', ''], + ['a whitespace string', ' \n '], + ["{ source: '' }", { source: '' }], +] as const; + +let seq = 0; +/** A field name no other row (or file) has warned about. */ +const fresh = (chain: string) => `${chain}_${++seq}_11262`; + +function ctxWith(name: string, meta: Record = {}) { + return { + objectSchema: { name: 'case_11262', fields: { [name]: { type: 'text', label: name, ...meta } } }, + objectName: 'case_11262', + fieldLabel: (_obj: string, _name: string, fallback: string) => fallback, + }; +} + +/** The chains into `attachVisibility` that DROP a blank, each handed the predicate under test. */ +const CHAINS: Array ReturnType]> = [ + ['a name string over the object schema’s visible_on', (pred, name) => + normalizeSectionField(name, ctxWith(name, { visible_on: pred }))], + ['a name string over the object schema’s visibleOn', (pred, name) => + normalizeSectionField(name, ctxWith(name, { visibleOn: pred }))], + ['a form-view entry’s visibleWhen', (pred, name) => + normalizeSectionField({ field: name, visibleWhen: pred }, ctxWith(name))], + ['a form-view entry’s deprecated visibleOn', (pred, name) => + normalizeSectionField({ field: name, visibleOn: pred }, ctxWith(name))], +]; + +describe.each(CHAINS)('attachVisibility via %s (objectui#11262, ADR-0137 D4)', (_chain, build) => { + it.each(BLANKS)('%s: no gate on the runtime field, as before — and a [blank] report naming the field', (_label, pred) => { + const name = fresh('blank'); + const f = build(pred, name); + expect(f.name).toBe(name); + expect((f as { visibleOn?: unknown }).visibleOn).toBeUndefined(); + const lines = blankLines(); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain(`'${name}'`); + }); + + it('control — a written predicate is carried into the view-level slot, silently', () => { + const name = fresh('written'); + const pred = { dialect: 'cel', source: "record.stage == 'won'" }; + expect((build(pred, name) as { visibleOn?: unknown }).visibleOn).toEqual(pred); + expect((build("record.stage == 'won'", fresh('written')) as { visibleOn?: unknown }).visibleOn) + .toBe("record.stage == 'won'"); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each([ + ['absent', undefined], + ['a number', 42], + ['a boolean', true], + ['an object without source', {}], + ] as const)('control — %s is not predicate text: dropped as before, and silent', (_label, pred) => { + const f = build(pred, fresh('junk')); + expect((f as { visibleOn?: unknown }).visibleOn).toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + }); +}); + +describe('a runtime FormField carrying its own blank visibleOn (objectui#11262)', () => { + const runtime = (pred: unknown, name: string) => + normalizeSectionField({ name, type: 'text', visibleOn: pred } as never, ctxWith(name)); + + it.each(BLANKS)('%s: carried unchanged to the renderer, which reports it — nothing is dropped, so nothing is said here', (_label, pred) => { + const f = runtime(pred, fresh('runtime')); + expect((f as { visibleOn?: unknown }).visibleOn).toBe(pred); + expect(warn).not.toHaveBeenCalled(); + }); + + it('control — a written predicate on a runtime field is carried too', () => { + const f = runtime("record.stage == 'won'", fresh('runtime')); + expect((f as { visibleOn?: unknown }).visibleOn).toBe("record.stage == 'won'"); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-form/src/sectionFields.ts b/packages/plugin-form/src/sectionFields.ts index e2c3459d42..a352be47ee 100644 --- a/packages/plugin-form/src/sectionFields.ts +++ b/packages/plugin-form/src/sectionFields.ts @@ -41,6 +41,7 @@ */ import type { FormField } from '@object-ui/types'; +import { evalFieldPredicate, isBlankPredicateText, type FieldRulePredicate } from '@object-ui/core'; import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields'; import { isCreateFormMode, isRequiredInForm } from './schemaDefaults'; import { findCustomFieldMember } from './customFieldsMerge'; @@ -123,15 +124,39 @@ export interface SectionFieldsContext { * by `evaluateCondition`, which is a legacy `{field, operator, value}` * matcher, not a CEL evaluator — and nothing in the form render chain ever * called the closure, so `visibleOn` silently did nothing. + * + * ## A BLANK predicate is dropped — and said (objectui#11262) + * + * Blank predicate TEXT — `''`, whitespace-only, or an envelope whose `source` + * is either — is not attached: the runtime field draws with no view-level gate, + * which is the verdict a blank gate has everywhere (ADR-0137 D3 / D4, "no + * gate"). What ADR-0137 D4 rules out is the SILENCE of that: a blank gate + * predicate is "diagnosed, never a silent `true`". Dropped here, it never + * reached the form renderer's own evaluation — the one place that would have + * reported it — so this function reports it, through the channel that + * evaluation (and `ExpressionEvaluator`'s blank gate guard) already use: + * `isBlankPredicateText` decides, `evalFieldPredicate`'s `[blank]` report + * speaks, deduped per blank text and field. No engine call is made. + * + * Only what this function DROPS is reported here. A runtime field that already + * carries the blank in its own `visibleOn` (an inline member drawn as it is) + * keeps it, and the form renderer that evaluates it reports it at render, so a + * second line here would describe a drop that did not happen. */ -function attachVisibility(formField: FormField, expr: any): FormField { - const isExpression = - (typeof expr === 'string' && expr.trim()) || - (expr != null && typeof expr === 'object' && typeof expr.source === 'string' && expr.source.trim()); - if (isExpression) { - return { ...formField, visibleOn: expr } as FormField; +function attachVisibility(formField: FormField, expr: unknown): FormField { + const isPredicateText = + typeof expr === 'string' || + (expr != null && typeof expr === 'object' && typeof (expr as { source?: unknown }).source === 'string'); + if (!isPredicateText) return formField; + if (isBlankPredicateText(expr)) { + if ((formField as { visibleOn?: unknown }).visibleOn !== expr) { + evalFieldPredicate(expr as FieldRulePredicate, {}, true, undefined, undefined, { + context: `view-level visibility of field '${formField.name}', read as no gate`, + }); + } + return formField; } - return formField; + return { ...formField, visibleOn: expr } as FormField; } /** diff --git a/packages/plugin-form/src/wizardVisibleWhenFault-8069.test.tsx b/packages/plugin-form/src/wizardVisibleWhenFault-8069.test.tsx index e9cd794204..da885848ca 100644 --- a/packages/plugin-form/src/wizardVisibleWhenFault-8069.test.tsx +++ b/packages/plugin-form/src/wizardVisibleWhenFault-8069.test.tsx @@ -30,11 +30,12 @@ import { WizardForm } from './WizardForm'; registerAllFields(); let toastError: ReturnType; +let warn: ReturnType; beforeEach(() => { toastError = vi.spyOn(toast, 'error').mockImplementation(() => 'id' as never); // Muted: the faults these cases provoke warn once each by design. - vi.spyOn(console, 'warn').mockImplementation(() => {}); + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { @@ -124,12 +125,16 @@ describe('objectui#8069 — the wizard gate refuses a faulted visibleWhen', () = expect(messages.some((m) => /visibleWhen rule of Owner could not be evaluated/.test(m))).toBe(true); }); - it('control — a blank VIEW-level visibleWhen is a layout gate, not a field rule: the submit goes through', async () => { + it('control — a blank VIEW-level visibleWhen is a layout gate, not a field rule: the submit goes through, and the blank is said', async () => { // `sectionFields` drops a blank view-level predicate before it reaches the // runtime field (`attachVisibility`), so the gate never sees it at all — - // "no gate", and nothing on this path refuses it. + // "no gate", and nothing on this path refuses it. Since objectui#11262 the + // drop is not silent (ADR-0137 D4): `attachVisibility` reports the blank it + // drops through the `[blank]` channel, naming the field. const ds = await skipPastOwnerAndSubmit({}, { field: 'owner', visibleWhen: '' }); await expectSubmitted(ds); + const lines = warn.mock.calls.map((c: unknown[]) => String(c[0])).filter((w: string) => w.includes('[blank]')); + expect(lines.some((w: string) => w.includes("'owner'"))).toBe(true); }); it('a faulted requiredWhen is NOT refused by the gate — the server refuses it (D2)', async () => { diff --git a/packages/types/src/__tests__/base-schema-predicate-envelope-7530.test.ts b/packages/types/src/__tests__/base-schema-predicate-envelope-7530.test.ts index ace47cec20..b3f4cf5225 100644 --- a/packages/types/src/__tests__/base-schema-predicate-envelope-7530.test.ts +++ b/packages/types/src/__tests__/base-schema-predicate-envelope-7530.test.ts @@ -40,8 +40,7 @@ * `ExpressionWireSchema` (zod) are the same union, so the two faces cannot * drift apart the way `hidden` did between #4581 and #7455. * 3. Reuse by REFERENCE -- the object arm each key's zod union carries IS - * `ExpressionWireSchema`, and so are the form's GATE legs (`visibleOn`, - * an option's `visibleWhen`). A faithful copy passes every value + * `ExpressionWireSchema`. A faithful copy passes every value * comparison; identity is the only check that distinguishes reuse from * the second envelope type the ruling forbids (the insight * `spec-subschema-parity` already wrote down for spec re-exports). @@ -54,7 +53,11 @@ * as the seat applied it after contract review 5915380177) handles * objectui's own wire in the same round. What this pin protects is the * ONE accepted SHAPE across the platform; a refused VALUE is not a second - * shape, and the case below pins exactly that line. + * shape, and the case below pins exactly that line. The form's two GATE + * mirrors whose spec key refuses a blank -- `visibleOn` and an option's + * `visibleWhen` -- carry that same refined wire since objectui#11262 + * (ADR-0137 D1, followed where the spec narrowed the key); `BaseSchema`'s + * three objectui-only gates have no spec twin and keep the bare const. * 4. Runtime -- the envelope `safeParse`s GREEN in full on all three keys, * with and without `dialect`, beside the string and boolean controls; and * the anti-overshoot guards: `{}` (no `source`), `{ dialect: 'cel' }`, @@ -209,9 +212,11 @@ describe('one envelope type across the package -- reuse pinned by reference (obj expect(envelopeArmOf(key)).toBe(ExpressionWireSchema); }); - it('the form GATE legs carry the same object', () => { - expect(FormFieldSchema.shape.visibleOn.unwrap()).toBe(ExpressionWireSchema); - expect(SelectOptionSchema.shape.visibleWhen.unwrap()).toBe(ExpressionWireSchema); + it('the form GATE mirrors carry the triad\u2019s refined wire -- one object, not a third wire (objectui#11262)', () => { + const triad = FormFieldSchema.shape.visibleWhen.unwrap(); + expect(FormFieldSchema.shape.visibleOn.unwrap()).toBe(triad); + expect(SelectOptionSchema.shape.visibleWhen.unwrap()).toBe(triad); + expect(triad.options).toBe(ExpressionWireSchema.options); }); it.each(TRIAD)('FormFieldSchema.shape.%s is the same wire SHAPE -- the same option schemas by reference -- plus a blank check (objectui#8069)', (key) => { @@ -296,14 +301,62 @@ describe.each(TRIAD)('FormFieldSchema.%s refuses a blank predicate (objectui#806 }); }); -describe('control -- a blank GATE keeps the plain wire: "no gate", not a refusal (ADR-0137 D4, objectui#3850)', () => { +/* -- objectui#11262: the two gate MIRRORS whose spec key refuses a blank follow D1 -- */ + +/** + * Each parse, and the path its refusal must land on. The spec declares both + * keys `EvaluatedExpressionInputSchema` (the data `SelectOptionSchema`'s + * `visibleWhen`; the form view field's `visibleWhen` / deprecated `visibleOn`), + * so the spec refuses a blank there at authoring and the mirror, which + * overrides the key to keep objectui's wire, now refuses it too. + */ +const GATE_MIRRORS = [ + ['FormFieldSchema.visibleOn', 'visibleOn', (value: unknown) => FormFieldSchema.safeParse({ name: 'amount', visibleOn: value })], + [ + 'SelectOptionSchema.visibleWhen', + 'visibleWhen', + (value: unknown) => SelectOptionSchema.safeParse({ label: 'Draft', value: 'draft', visibleWhen: value }), + ], +] as const; + +describe.each(GATE_MIRRORS)( + '%s refuses a blank predicate, as the spec key it mirrors does (objectui#11262, ADR-0137 D1)', + (_label, key, parse) => { + it.each(BLANKS)('$label is refused AT THE KEY, with the spec\u2019s own sentence', ({ value }) => { + const result = parse(value); + expect(result.success).toBe(false); + if (!result.success) { + const atKey = result.error.issues.filter((issue) => issue.path.join('.') === key); + expect(atKey.map((issue) => issue.message)).toEqual([EVALUATED_EXPRESSION_SOURCE_REQUIRED]); + } + }); + + it('a non-blank string, a CEL envelope and a dialect-less envelope all parse, the value unchanged', () => { + for (const value of [PREDICATE, "record.stage == 'won'", CEL_ENVELOPE, DIALECTLESS_ENVELOPE]) { + const result = parse(value); + expect(result.success).toBe(true); + if (result.success) expect((result.data as Record)[key]).toEqual(value); + } + }); + + it('junk is still refused at the key -- the anti-overshoot guard, unchanged', () => { + expect(parse({}).success).toBe(false); + expect(parse(42).success).toBe(false); + }); + }, +); + +describe('control -- an objectui-only GATE keeps the plain wire: "no gate", not a refusal (ADR-0137 D4, objectui#3850)', () => { + // `BaseSchema`'s three gates have no spec twin, so there is no spec refusal + // for them to follow (objectui#11262's triage): a blank stays "no gate" plus + // the one-time `[blank]` diagnostic, and it parses. it.each(KEYS)("BaseSchema.%s: '' and a blank envelope still parse", (key) => { expect(Mirror.safeParse({ type: 'test-component', [key]: '' }).success).toBe(true); expect(Mirror.safeParse({ type: 'test-component', [key]: { dialect: 'cel', source: ' ' } }).success).toBe(true); }); - it("FormFieldSchema.visibleOn (the view-level gate) and an option's visibleWhen still parse a blank", () => { - expect(FormFieldSchema.safeParse({ name: 'amount', visibleOn: '' }).success).toBe(true); - expect(SelectOptionSchema.safeParse({ label: 'Draft', value: 'draft', visibleWhen: ' ' }).success).toBe(true); + it('an ABSENT view-level visibleOn and an option without visibleWhen still parse -- no gate is no gate', () => { + expect(FormFieldSchema.safeParse({ name: 'amount' }).success).toBe(true); + expect(SelectOptionSchema.safeParse({ label: 'Draft', value: 'draft' }).success).toBe(true); }); }); diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index 1301034f97..17e8e5e168 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -61,6 +61,71 @@ import { closeStrictUnionArms } from './node-derivation.js'; */ +/** + * The wire of the field-rule TRIAD — `visibleWhen` / `readonlyWhen` / + * `requiredWhen` on a form field: `ExpressionWireSchema` itself, with ONE check + * added. A predicate whose text is blank after trimming — `''`, whitespace, or + * an envelope whose `source` is either — is refused at parse (objectui#8069). + * + * This is ADR-0137 D1 ("a predicate slot accepts only what the engine can + * run"; an authored blank predicate is "refused at authoring") on objectui's + * own form wire, mirroring what `@objectstack/spec` already does to the same + * three keys on `FieldSchema` with `EvaluatedExpressionInputSchema`. The + * blankness is not restated here: the predicate's TEXT (the string itself, or + * an envelope's `source`) is handed to that spec schema, and its verdict and + * its sentence (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`) are the refusal — so + * the two refusals of one mistake read alike and cannot drift apart (the + * objectui#8563 reason for chaining a spec rule rather than copying it). It is + * the authoring half of a pair: D2 refuses a blank that is + * already STORED at submit (the form renderer, the console's form page and the + * wizard's final gate refuse a blank `visibleWhen`; the server refuses a blank + * `requiredWhen` / `readonlyWhen`), so a blank is refused at the first place + * that can see it, never silently read as "no rule" (ADR-0137 refuses that + * reading for the triad in its Alternatives). + * + * ## Also the two GATE mirrors whose spec key refuses a blank (objectui#11262) + * + * Named for the triad it was written for, and since objectui#11262 carried by + * two gate keys as well: {@link SelectOptionSchema}'s `visibleWhen` and + * {@link FormFieldSchema}'s view-level `visibleOn`. Each mirrors a key + * `@objectstack/spec` declares as `EvaluatedExpressionInputSchema` — the + * option's `visibleWhen` on the data `SelectOptionSchema`, the form view + * field's `visibleWhen` / deprecated `visibleOn` — so the spec refuses a blank + * there at authoring (D1) and objectui's mirror, which overrides the key to keep + * its own wire, followed it nowhere: a mirror wider than the spec on the same + * key is a second authority. They follow D1 with this same check, not a copy of + * it. Their RUNTIME verdict is unchanged: a stored blank gate is still "no gate" + * plus a one-time `[blank]` diagnostic (ADR-0137 D4), never refused at submit. + * + * ⚠️ Deliberately a REFINEMENT and not a second schema. The accepted SHAPE is + * exactly the one wire type objectui#7530 ruled for the whole platform — the + * very two option schemas `ExpressionWireSchema` holds, by reference, with the + * same output — and only the blank VALUE is taken out of it. The spec's + * `EvaluatedExpressionInputSchema` is not reused because it is a different + * shape: it rewrites a string into an envelope and narrows `dialect` to the + * spec enum. The pin is `base-schema-predicate-envelope-7530.test.ts`. + * + * ⛔ Not for objectui-only gates. `BaseSchema`'s `visible` / `hidden` / + * `disabled` have no spec twin — `BaseSchema` is objectui's own declaration — + * so there is no spec refusal for them to follow: they keep the plain wire, + * and a blank there stays "no gate" plus a one-time diagnostic (ADR-0137 D4, + * objectui#3850 / #3960), which objectui#11262's triage records as diagnosed, + * not refused. + * + * Declared ahead of {@link SelectOptionSchema}, its first reader. + */ +const FieldRulePredicateWireSchema = ExpressionWireSchema.superRefine((predicate, ctx) => { + // Only the TEXT is judged by the spec schema — never the envelope itself, + // whose `dialect` enum is narrower than this wire's and would refuse shapes + // objectui#7530 admits. A non-blank text always passes its string arm. + const verdict = stripImportedDefaults(SpecEvaluatedExpressionInputSchema).safeParse( + typeof predicate === 'string' ? predicate : predicate.source, + ); + if (!verdict.success) { + for (const issue of verdict.error.issues) ctx.addIssue({ code: 'custom', message: issue.message }); + } +}); + /** * Select Option Schema — derived from `@objectstack/spec/data` * `SelectOptionSchema` (objectstack#4115), with two pinned divergences and two @@ -78,9 +143,11 @@ export const SelectOptionSchema = z.object({ // standalone UI forms legitimately bind numeric/boolean values. The parity // test pins both directions so a future spec widening gets noticed. value: z.union([z.string(), z.number(), z.boolean()]).describe('Option value'), - // Deliberate divergence: keep objectui's wire contract (#2212) instead of - // the spec's envelope-canonicalizing ExpressionInput pipe. - visibleWhen: ExpressionWireSchema.optional() + // Deliberate divergence in SHAPE: keep objectui's wire contract (#2212) + // instead of the spec's envelope-canonicalizing ExpressionInput pipe. Not in + // VALUE: a blank predicate is refused here as the spec refuses it + // (objectui#11262, ADR-0137 D1) — see `FieldRulePredicateWireSchema`. + visibleWhen: FieldRulePredicateWireSchema.optional() .describe('Per-option visibility predicate (CEL) — option offered only when TRUE'), // objectui-only UI extensions (not in the spec; the parity test asserts the // spec has not claimed these names). @@ -925,53 +992,6 @@ function unresolvableFieldWidgetNamespaceMessage(id: string): string { ); } -/** - * The wire of the field-rule TRIAD — `visibleWhen` / `readonlyWhen` / - * `requiredWhen` on a form field: `ExpressionWireSchema` itself, with ONE check - * added. A predicate whose text is blank after trimming — `''`, whitespace, or - * an envelope whose `source` is either — is refused at parse (objectui#8069). - * - * This is ADR-0137 D1 ("a predicate slot accepts only what the engine can - * run"; an authored blank predicate is "refused at authoring") on objectui's - * own form wire, mirroring what `@objectstack/spec` already does to the same - * three keys on `FieldSchema` with `EvaluatedExpressionInputSchema`. The - * blankness is not restated here: the predicate's TEXT (the string itself, or - * an envelope's `source`) is handed to that spec schema, and its verdict and - * its sentence (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`) are the refusal — so - * the two refusals of one mistake read alike and cannot drift apart (the - * objectui#8563 reason for chaining a spec rule rather than copying it). It is - * the authoring half of a pair: D2 refuses a blank that is - * already STORED at submit (the form renderer, the console's form page and the - * wizard's final gate refuse a blank `visibleWhen`; the server refuses a blank - * `requiredWhen` / `readonlyWhen`), so a blank is refused at the first place - * that can see it, never silently read as "no rule" (ADR-0137 refuses that - * reading for the triad in its Alternatives). - * - * ⚠️ Deliberately a REFINEMENT and not a second schema. The accepted SHAPE is - * exactly the one wire type objectui#7530 ruled for the whole platform — the - * very two option schemas `ExpressionWireSchema` holds, by reference, with the - * same output — and only the blank VALUE is taken out of it. The spec's - * `EvaluatedExpressionInputSchema` is not reused because it is a different - * shape: it rewrites a string into an envelope and narrows `dialect` to the - * spec enum. The pin is `base-schema-predicate-envelope-7530.test.ts`. - * - * ⛔ Not for gates. `BaseSchema`'s `visible` / `hidden` / `disabled`, this - * form's view-level `visibleOn` and an option's `visibleWhen` keep the plain - * wire: a blank GATE stays "no gate" plus a one-time diagnostic (ADR-0137 D4, - * objectui#3850 / #3960), and none of them is refused at submit. - */ -const FieldRulePredicateWireSchema = ExpressionWireSchema.superRefine((predicate, ctx) => { - // Only the TEXT is judged by the spec schema — never the envelope itself, - // whose `dialect` enum is narrower than this wire's and would refuse shapes - // objectui#7530 admits. A non-blank text always passes its string arm. - const verdict = stripImportedDefaults(SpecEvaluatedExpressionInputSchema).safeParse( - typeof predicate === 'string' ? predicate : predicate.source, - ); - if (!verdict.success) { - for (const issue of verdict.error.issues) ctx.addIssue({ code: 'custom', message: issue.message }); - } -}); - export const FormFieldSchema = z.object({ id: z.string().optional().describe('Field ID'), name: z.string().describe('Field name (form data path)'), @@ -995,7 +1015,10 @@ export const FormFieldSchema = z.object({ ]).nullish().describe('Parent field(s) for cascading/dependent fields'), hidden: z.boolean().optional().describe('Whether the field is hidden'), readonly: z.boolean().optional().describe('Whether the field is read-only'), - visibleOn: ExpressionWireSchema.optional() + // The form view's gate, refused blank at authoring as the spec's form view + // field refuses it (objectui#11262, ADR-0137 D1) — see + // `FieldRulePredicateWireSchema`. + visibleOn: FieldRulePredicateWireSchema.optional() .describe('View-level visibility predicate (CEL) — ANDed with visibleWhen'), visibleWhen: FieldRulePredicateWireSchema.optional() .describe('Field-level visibility rule (CEL) — field shown only when TRUE'),