Skip to content

Commit 58da8ae

Browse files
fix(core,plugin-form,types): a blank gate on the three remaining paths is diagnosed, never a silent true; mirror keys the spec narrowed refuse a blank (objectui#11262) (#11283)
Fixes #11262 Clause-②: no (narrowing) ADR-0137 D4 says a blank gate predicate is "diagnosed, never a silent `true`". objectui#8069 (PR objectui#11233) diagnosed the `dialect: 'cel'` route. Three paths still drew a blank gate in silence, and each now reports through that same guard: `isBlankPredicateText` decides, and `evalFieldPredicate`'s `[blank]` report speaks. **No drawn verdict moves.** A blank gate is still "no gate" (D3/D4). ADR-0137 D1 now also covers objectui's two gate mirrors that override a spec key the spec narrowed: they refuse a blank at authoring, as the spec does. Direction: triage comment 5918741052. Claim: 5919879003. ## Narrowing - **Accepted before:** objectui's `SelectOptionSchema.visibleWhen` and `FormFieldSchema.visibleOn` parsed `''`, whitespace, and an envelope whose `source` is blank. - **Refused now (`@object-ui/types`, minor):** the same values, at the key, with the spec's own sentence (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`). - **Why:** `@objectstack/spec` 17.5.0 declares both keys as `EvaluatedExpressionInputSchema`, which refuses a blank. That is the data `SelectOptionSchema.visibleWhen` and the form view field's `visibleWhen` / deprecated `visibleOn`. objectui's mirror overrides each key to keep its own wire, and it was wider than the spec on the same key. - **Unchanged:** the accepted shape, since the refined union's arms are `ExpressionWireSchema.options` by reference and the parsed value is the authored one. The runtime verdict of a stored blank gate is also unchanged ("no gate" plus a diagnostic). `BaseSchema`'s `visible` / `hidden` / `disabled` have no spec twin, so they stay D4-diagnosed and are not refused. The triage recorded this, and the `FieldRulePredicateWireSchema` docblock records it too. - Nothing widens. The diagnostics add console output only. ## What changed 1. **`ExpressionEvaluator.evaluateCondition`'s legacy path** (`packages/core`). The CEL route's blank block moved into one private method, `answerBlankGate`, and both routes call it. The legacy path used an `if (!condition)` and a whitespace-only `trim()` to answer `true` in silence. Now, after the envelope unwrap, a blank text (a bare `''` or whitespace, or a dialect-less envelope's blank `source`) goes to the same method. Every mode still answers `true`, `throwOnError` included. `onFault` receives the `[blank]` reason, and every other caller gets the built-in warning. Both routes share one locator, so `''` and `{ dialect: 'cel', source: '' }` land on one dedupe key and print one line. This path goes first because `SchemaRenderer`'s `visibleWhen` / `visible` / `visibleOn` / `visibility` legs pass the raw authored value into it. 2. **`evalRowPredicate`** (`listConditional.ts`). The bare-blank early return is removed. A blank string now takes the route its blank-envelope twin already took (no legacy marker can match blank text). It gets the same verdict, which is the caller's fallback, and the same report: labelled through `warnEvalError` on the `warnOnError` route, and through the canonical helper on the single-eval route. This removes a blank test instead of adding one. 3. **`sectionFields`' `attachVisibility`** (`packages/plugin-form`). A blank predicate is still dropped, so the field draws with no view-level gate. The drop is now reported, naming the field. `isBlankPredicateText` replaces the two local `trim()` tests. The function reports only what it drops (see the premise note below). 4. **`form.zod.ts`**. `SelectOptionSchema.visibleWhen` and `FormFieldSchema.visibleOn` now carry `FieldRulePredicateWireSchema`, the refinement PR objectui#11233 put on the triad. That refinement passes the predicate TEXT to `stripImportedDefaults(EvaluatedExpressionInputSchema)` from `@objectstack/spec/shared` and restates no rule of its own. The const moved above `SelectOptionSchema`, its first reader, to avoid the temporal dead zone. It keeps its name, so the `fieldRules.ts` docblock that cites it stays true. The spec import is the same one PR objectui#11233 added, so `imported-defaults-8317` and `terminal-unknown-key-refusal-11073` need no new crossing row. Both suites are green. 5. **Docs**: `content/docs/guide/metadata-diagnostics.md` now covers blank gates. The two `declaredPredicate.ts` docblock passages that quoted the removed `if (!trimmed) return true` / `if (!source.trim())` lines are corrected. These are comment-only edits, made because this diff made the quotes false. ## Premise check (card body read against `origin/main` `f61dab169`) - All three silencers hold. They are narrower than the card said: - Path 1: `{ dialect: 'cel', source: '' }` was already diagnosed. Silent were a bare `''`, whitespace, and a dialect-less blank envelope. - Path 2: `{ source: '' }` was already diagnosed. Only the bare-string spelling was silent. - Path 3: `attachVisibility` drops a blank on two chains, `meta.visible_on ?? meta.visibleOn` and a form-view entry's `fd.visibleWhen ?? fd.visibleOn`. It does **not** drop one on the runtime-field chain (`fd.visibleOn`). There the field is the output, the blank stays on it, and `form.tsx` reports it at render (pinned by the existing `form.tsx` control "a blank view-level visibleOn is a layout GATE: no gate plus a diagnostic"). `attachVisibility` therefore reports only when the output does not carry the blank. The new pin records the runtime-field chain as "carried, not reported a second time". - **The page control:** `FormPage` does not use `sectionFields`. It carries the view predicate onto its row and judges it in `isFieldVisible` through `evalFieldPredicate`, so a blank view-level predicate was already reported there on `origin/main`. Measured: the diagnostic assertion passes on its own (`-t` on that row only, with `FormPage` untouched by this diff). In the full file the line was swallowed, because the earlier stored-blank row put `''` on the same field (`notes`) under the same locator ("visibleWhen of field 'notes'"), and the one-time dedupe is module state per file. The restated control moves the view-level blank to `status` and asserts the `[blank]` line naming it. Its comment, which said the page normalizes through `sectionFields`, is corrected. ## Census before narrowing (the triage's "no silent narrowing" clause) - A literal-blank search over every tracked file for `visibleOn` / `visibleWhen` found no stored or example fixture carrying a blank value, in either the string form or the `source` form. The matches are test inputs to renderers or to the triad, plus the two control rows below. - Measured: the `origin/main` copy of `base-schema-predicate-envelope-7530.test.ts` was run against the narrowed `form.zod.ts`. Result: **2 failed / 60 passed**. The two are exactly the rows that recorded PR objectui#11233's wider reading: "the form GATE legs carry the same object" and "FormFieldSchema.visibleOn (the view-level gate) and an option's visibleWhen still parse a blank". Those two rows are restated below. No stored or first-party fixture turned red, so the narrowing went ahead. - The whole `packages/types/src/` suite is green with the narrowing (see Verification). That includes the parity ledgers, `select-option-spec-parity`, `imported-defaults-8317`, `terminal-unknown-key-refusal-11073` and `detail-view-field-options-10296`. ## Pins - `packages/core/src/evaluator/__tests__/blankGateDiagnosed-11262.test.ts` covers paths 1 and 2 for `''`, whitespace and `{ source: '' }`. Each row checks the verdict and the report. Path 1 also covers `onFault` and `throwOnError`. Path 2 covers both routes and both fallback directions, plus rowless. There is a one-diagnosis-point row per path: a legacy/bare blank and its CEL/envelope twin print ONE line between them. Controls: `undefined` / `null` and written predicates are silent. Each case gets a fresh module graph. - `packages/plugin-form/src/sectionFields.blankGate-11262.test.ts` covers the four dropping chains × the three spellings (no gate, plus one `[blank]` line naming the field). It also pins that the runtime-field chain is carried and not reported again. Controls: written predicates are carried silently, and junk is dropped silently. - PR objectui#11233's **wizard** control (`wizardVisibleWhenFault-8069`) and **page** control (`FormPage.visibleWhenFault-8069`) are restated with the diagnostic added. - `base-schema-predicate-envelope-7530.test.ts`: - Both gate mirrors refuse the four blank spellings at the key with the spec's sentence. - They accept a non-blank string, a CEL envelope and a dialect-less envelope with the value unchanged, and still refuse junk. - They carry the triad's refined wire as one object (identity). - Control: `BaseSchema`'s three gates still parse a blank. ## Ablation (each leg run with `ablation-replace.mjs` from `objectstack`, at `adaa4072b`) The mutation was proven on disk each time: anchor count went from 1 to 0 and the blob changed. The restore was proven each time: blob equals HEAD and `git diff HEAD` is empty. Every mutated file is imported relatively by its pins, so no `dist/` is on the path. Baseline for all five pin files: **153 passed**. | leg | mutation | red rows | where | |---|---|---|---| | path 1 | legacy blank branch returns `true` without `answerBlankGate` | 10 / 153 | exactly path 1's rows in the core pin | | path 2 | bare-blank `return fallback` restored | 7 / 153 | exactly path 2's `''` / whitespace rows, both one-diagnosis-point rows, and rowless. The `{ source: '' }` rows stay green, since they were already diagnosed | | path 3 | `attachVisibility`'s report removed | 13 / 153 | the 12 dropping-chain rows plus the restated wizard control | | mirror: option | `SelectOptionSchema.visibleWhen` back to `ExpressionWireSchema` | 5 / 153 | its 4 blank rows plus the identity row | | mirror: visibleOn | `FormFieldSchema.visibleOn` back to `ExpressionWireSchema` | 5 / 153 | its 4 blank rows plus the identity row | The first path 2 attempt was a null op, and the tool refused it (the replacement contained the anchor). It was re-anchored and re-run, and only the re-run is reported above. ## Verification All heavy runs went through `os-verify-lock.sh` (slot `issue-11262`). Verdicts are read from pass counts and the lock's `VERDICT` line. Base is `f61dab169`. `fac88b93e` is the implementation commit. `adaa4072b` (HEAD) adds only a type annotation to the new plugin-form pin, which the plugin-form type-check asked for. - At HEAD `adaa4072b`: - the five pin files: 5 files, 153 passed; - `scripts/__tests__/`: 177 files passed (2 skipped), 5371 tests passed; - `@object-ui/plugin-form` type-check EXIT 0; - `@object-ui/console` type-check EXIT 0, after `turbo run build --filter='@object-ui/console^...'`: 34 tasks successful; - eslint on the 10 changed TS files: 0 errors (warning counts equal base on `ExpressionEvaluator.ts`, one fewer on `sectionFields.ts`); - `check-changeset-presence`, `check-changeset-no-major`, `check:control-bytes`, `check:new-line-citations` (0 new), `markdown-test-inputs --audit`: all EXIT 0. - At `fac88b93e`: - `packages/core/src/` plus 46 consumer test files: 240 files, 4712 passed. The consumers are every test outside core that names `evaluateCondition` / `evalRowPredicate` / `useCondition` / `useRowPredicate` or authors a blank gate literal, across react, app-shell, components, plugin-grid, plugin-detail, permissions, types, plugin-form and console; - `packages/plugin-form/src/`: 156 files in three chunks (27 / 65 / 64), 1819 passed, 1 skipped; - `apps/console/src/components/`: 36 files, 437 passed; - `@object-ui/types` and `@object-ui/core` type-check EXIT 0. The four packages' type-check programs list every changed test file (checked with `--listFiles`). - `packages/types/src/` together with `packages/core/src/evaluator/` and the sectionFields tests: 317 files, 7956 passed. This ran on the tree before the comment-only `declaredPredicate.ts` / docs / changeset edits that went into `fac88b93e`. - NOT MEASURED, and superseded by the runs above: - the whole-`plugin-form` single run hit its 300 s timeout under contention (exit 124, 0 failures before the kill); - one attempt took the lock's queue-timeout (99); - one invocation was refused by `vitest-invocation-guard` (overlapping filters). - Declared narrowing: the full `pnpm test` / `pnpm lint` farm is CI's. ## Acceptance notes (observations, not filed) - **Same-family blank-gate silencers outside this card's three paths** (source reads only, no public-door measurement): - callers that pre-screen `pred === ''` before `evalRowPredicate`: `useRowPredicate` (`packages/react`), `evalRowActionVisibility` in both `plugin-grid`'s `RowActionMenu` and `components`' `data-table`, and `evalCreatePredicate` in `app-shell`'s `RelatedRecordActionsBridge`; - `partitionRowsByPredicate`'s `pred === ''` returns every row in silence, while `' '` reaches `evalRowPredicate` with fallback `false` and excludes every row (on `origin/main` too; diagnosed since this diff). That is a verdict split between two blank spellings, which needs a ruling rather than a mechanical change; - conditional formatting's blank `expression` falls through `ruleToPredicate` silently; - `ObjectForm`'s flat path drops `visible_on: ''` by truthiness. Named in the report for the seat to route to this family. - On `FormPage`, a field's object-level `visibleWhen` and its view-level `visibleWhen` share one locator, so a blank in both slots prints one line. - The flat Modal/Drawer builder (`flatFields.ts`) does not carry an object field's `visible_on` at all. This is a drift observation that was not exercised. --- _Generated by [Claude Code](https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1b30c0f commit 58da8ae

12 files changed

Lines changed: 612 additions & 117 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@object-ui/types': minor
3+
'@object-ui/core': patch
4+
'@object-ui/plugin-form': patch
5+
---
6+
7+
A blank gate predicate is diagnosed on the three paths that still drew it in
8+
silence, and objectui's two gate mirrors whose protocol key refuses a blank now
9+
refuse it too (objectui#11262, ADR-0137 D4 and D1). **No drawn verdict moves:** a
10+
blank gate is still "no gate".
11+
12+
**Diagnosed, verdict unchanged (`@object-ui/core`, `@object-ui/plugin-form`).**
13+
ADR-0137 D4 says a blank gate predicate is "diagnosed, never a silent `true`".
14+
objectui#8069 diagnosed the `{ dialect: 'cel' }` route; three paths stayed silent,
15+
and each now reports through that same guard (`evalFieldPredicate`'s `[blank]`
16+
report, deduped per blank text and locator):
17+
18+
- `ExpressionEvaluator.evaluateCondition`'s legacy path: a bare `''`, a
19+
whitespace-only string, and an envelope without `dialect` whose `source` is
20+
blank. These are the values `SchemaRenderer`'s visibility legs pass through
21+
raw. The answer is still `true` in every mode, `throwOnError` included. A
22+
caller that passes `onFault` receives the `[blank]` reason there; every other
23+
caller gets the built-in warning. A blank on either route lands on one dedupe
24+
key, so `''` and `{ dialect: 'cel', source: '' }` print one line.
25+
- `evalRowPredicate`: a bare blank string returned the caller's fallback before
26+
anything could say so. It now takes the route its blank-envelope twin already
27+
took, with the same fallback and the same report (labelled on the
28+
`warnOnError` route).
29+
- `sectionFields`' `attachVisibility` (every sectioned `object-form` arm): a
30+
blank view-level predicate is still dropped, so the field draws with no gate,
31+
and the drop is reported, naming the field. A runtime field that already
32+
carries its own blank `visibleOn` keeps it. The form renderer reports that one
33+
when it evaluates it.
34+
35+
**Refused at authoring (`@object-ui/types`, minor, a narrowing).** The protocol
36+
declares an option's `visibleWhen` and a form view field's `visibleWhen` /
37+
`visibleOn` as `EvaluatedExpressionInputSchema`, which refuses a blank predicate
38+
(since `@objectstack/spec` 17.5.0). objectui's `SelectOptionSchema.visibleWhen`
39+
and `FormFieldSchema.visibleOn` override those keys to keep objectui's wire, and
40+
they still accepted `''`, whitespace and a blank envelope `source`. Both now
41+
refuse them at the key, with the protocol's own sentence, through the check the
42+
field-rule triad already carries. The accepted shape is unchanged: the same wire
43+
options, by reference, and the parsed value is the authored one.
44+
45+
`BaseSchema`'s `visible` / `hidden` / `disabled` have no protocol counterpart,
46+
so a blank there still parses and is only diagnosed.

‎apps/console/src/components/FormPage.visibleWhenFault-8069.test.tsx‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,16 +159,27 @@ describe('objectui#8069 — FormPage refuses a faulted visibleWhen at submit', (
159159
expect(warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('[blank]'))).toBe(true);
160160
});
161161

162-
it('control — a blank VIEW-level visibleWhen is a layout gate, not a field rule: it submits', async () => {
163-
// The view's section entry is normalized before this page builds its rows,
164-
// and a blank view-level predicate does not survive that step — "no gate",
165-
// and nothing on this path refuses it.
162+
it('control — a blank VIEW-level visibleWhen is a layout gate, not a field rule: it submits, and the blank is said', async () => {
163+
// This page carries the view's predicate onto its row verbatim and judges
164+
// it at render (`isFieldVisible`, through `evalFieldPredicate`), so a blank
165+
// one reads as "no gate" there — the field is drawn and nothing on this
166+
// path refuses it — and it is REPORTED there, on the `[blank]` channel,
167+
// naming the field (ADR-0137 D4; restated with the diagnostic by
168+
// objectui#11262). It does not pass through `sectionFields`.
169+
//
170+
// On `status`, not `notes`: the stored-blank row above already printed the
171+
// one line for `''` under the locator both of a field's `visibleWhen`
172+
// slots share on this page ("visibleWhen of field 'notes'"), and the
173+
// one-time dedupe is module state for the whole file.
166174
renderForm(objectWith({}), {
167-
sections: [{ label: 'Basics', fields: ['title', 'priority', { field: 'notes', visibleWhen: '' }, 'status'] }],
175+
sections: [{ label: 'Basics', fields: ['title', 'priority', 'notes', { field: 'status', visibleWhen: '' }] }],
168176
});
169-
await waitFor(() => expect(screen.getByLabelText('Notes')).toBeInTheDocument());
177+
await waitFor(() => expect(screen.getByLabelText('Status')).toBeInTheDocument());
170178
await submit();
171179
await waitFor(() => expect(writes()).toHaveLength(1));
180+
expect(
181+
warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('[blank]') && String(c[0]).includes("'status'")),
182+
).toBe(true);
172183
});
173184

174185
it('a faulted requiredWhen is NOT refused on the client — it is the server’s to refuse (D2)', async () => {

‎content/docs/guide/metadata-diagnostics.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,15 @@ blank) is not "no rule" — it is refused, at the first place that can see it
186186
parse, as the protocol's field schema does. One already stored is refused at
187187
submit, like any rule that cannot be evaluated: the client refuses a blank
188188
`visibleWhen`, the server a blank `requiredWhen` / `readonlyWhen`. A blank
189-
**gate** is different: a blank `visible`, `hidden` or `disabled` is still read
190-
as "no gate", with a one-time `[blank]` warning in the console, and a form
191-
view's own blank field `visibleWhen` is likewise read as no gate, never refused.
189+
**gate** is different: at runtime it is read as "no gate" and never refused —
190+
a blank `visible`, `hidden` or `disabled`, a page node's `visibleWhen`, or a
191+
form view's own field `visibleWhen` — but it is not silent either: it is
192+
reported once in the console with the `[blank]` reason (ADR-0137 D4). Where the
193+
protocol already refuses a blank gate at authoring, the form schema does too: a
194+
form field's view-level `visibleOn` and a select option's `visibleWhen` reject
195+
a blank predicate at parse, as their protocol counterparts do. `visible`,
196+
`hidden` and `disabled` have no protocol counterpart, so a blank there parses
197+
and is only diagnosed.
192198

193199
The same is now true of a **component node's own gate** — `visibleWhen` on a
194200
page component (and its `visible` / `visibleOn` / `visibility` / `hidden` /

‎packages/core/src/evaluator/ExpressionEvaluator.ts‎

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,21 @@ export class ExpressionEvaluator {
362362
condition = (condition as any).source as string;
363363
}
364364

365-
// No condition → default to visible/enabled (undefined, null, '').
365+
// A BLANK gate — `''`, whitespace-only text, or an envelope whose `source`
366+
// is either (unwrapped just above) — is "no gate": visible/enabled, as it
367+
// always was here. It is answered by the SAME diagnosed guard the
368+
// `{ dialect: 'cel' }` route above uses (objectui#11262, ADR-0137 D4: a
369+
// blank gate predicate is "diagnosed, never a silent `true`"), so a bare
370+
// blank, a dialect-less blank envelope and a blank CEL envelope are one
371+
// report on one dedupe key. This used to be two silent returns — an
372+
// `if (!condition)` and a whitespace-only `trim()` — on the path
373+
// `SchemaRenderer`'s visibility legs reach with the raw authored value.
374+
if (isBlankPredicateText(condition)) {
375+
return this.answerBlankGate(condition as string, options);
376+
}
377+
378+
// No condition at all → default to visible/enabled (`undefined`, `null`).
379+
// Not blank TEXT, so nothing was declared here to report.
366380
if (!condition) {
367381
return true;
368382
}
@@ -372,9 +386,6 @@ export class ExpressionEvaluator {
372386
}
373387

374388
const trimmed = condition.trim();
375-
if (!trimmed) {
376-
return true; // Whitespace-only → treat as "no condition".
377-
}
378389

379390
// A condition is semantically a single boolean expression. When it's a
380391
// `${...}` template, evaluate via the template path. Otherwise treat the
@@ -420,29 +431,7 @@ export class ExpressionEvaluator {
420431
* syntax) throws; a genuine `false` never throws.
421432
*/
422433
private evaluateCelCondition(source: string, options: EvaluationOptions): boolean {
423-
if (isBlankPredicateText(source)) {
424-
// A BLANK gate: no predicate → visible/enabled, exactly as before
425-
// (objectui#3850 / #3960's verdict), in EVERY mode — `throwOnError`
426-
// included, since the author wrote nothing that could fault. What
427-
// changed is that it is no longer SILENT (ADR-0137 D4: a blank gate
428-
// predicate is "diagnosed, never a silent `true`"; objectui#8069). The
429-
// report is `evalFieldPredicate`'s own `[blank]` one — the channel every
430-
// other predicate fault already uses — so a caller that passed `onFault`
431-
// receives it there (and prints its own, node-named line), and every
432-
// other caller gets the deduped built-in warning. No engine call is made:
433-
// a blank predicate never reaches the engine.
434-
evalFieldPredicate(
435-
source,
436-
{},
437-
true,
438-
undefined,
439-
undefined,
440-
options.onFault
441-
? { warn: false, onFault: options.onFault }
442-
: { context: 'a CEL gate predicate, read as no gate' },
443-
);
444-
return true;
445-
}
434+
if (isBlankPredicateText(source)) return this.answerBlankGate(source, options);
446435
const bag = this.context.toObject();
447436
const rec = bag.record;
448437
const record = (rec && typeof rec === 'object' && !Array.isArray(rec))
@@ -480,6 +469,41 @@ export class ExpressionEvaluator {
480469
return asTrue;
481470
}
482471

472+
/**
473+
* Answer a BLANK gate predicate — the ONE diagnosed guard of
474+
* {@link evaluateCondition}, on both of its routes (objectui#8069 put it on
475+
* the `{ dialect: 'cel' }` route; objectui#11262 routes the legacy path's
476+
* blank here too, so the two are one report rather than two).
477+
*
478+
* The verdict is `true` — no predicate → visible/enabled, exactly as before
479+
* (objectui#3850 / #3960's verdict) — in EVERY mode, `throwOnError` included,
480+
* since the author wrote nothing that could fault. What it is not is SILENT
481+
* (ADR-0137 D4: a blank gate predicate is "diagnosed, never a silent
482+
* `true`"). The report is `evalFieldPredicate`'s own `[blank]` one — the
483+
* channel every other predicate fault already uses — so a caller that passed
484+
* `onFault` receives it there (and prints its own, node-named line), and
485+
* every other caller gets the deduped built-in warning. No engine call is
486+
* made: a blank predicate never reaches the engine.
487+
*
488+
* The locator names a CEL gate on both routes: a blank has no syntax to put
489+
* it in either dialect, and a bare-string predicate is CEL by the spec's
490+
* contract. One locator is also what keeps `''` and `{ dialect: 'cel',
491+
* source: '' }` on one dedupe key.
492+
*/
493+
private answerBlankGate(text: string, options: EvaluationOptions): true {
494+
evalFieldPredicate(
495+
text,
496+
{},
497+
true,
498+
undefined,
499+
undefined,
500+
options.onFault
501+
? { warn: false, onFault: options.onFault }
502+
: { context: 'a CEL gate predicate, read as no gate' },
503+
);
504+
return true;
505+
}
506+
483507
/**
484508
* Update the context with new data
485509
*/
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#11262 — ADR-0137 D4 on the two CORE paths that still answered a
11+
* blank gate in silence after objectui#8069: a gate predicate that is blank is
12+
* "diagnosed, never a silent `true`".
13+
*
14+
* 1. `ExpressionEvaluator.evaluateCondition`'s LEGACY path. objectui#8069
15+
* diagnosed the `{ dialect: 'cel' }` route only; a bare string and an
16+
* envelope without `dialect` took the legacy path, whose `if (!condition)`
17+
* and whitespace-only `trim()` returned `true` before anything could say
18+
* so. `SchemaRenderer`'s `visibleWhen` / `visible` / `visibleOn` /
19+
* `visibility` legs hand it the raw authored value, so this is the path a
20+
* page author reaches.
21+
* 2. `evalRowPredicate`'s bare-blank fallback (`listConditional.ts`). A blank
22+
* ENVELOPE already reached `evalFieldPredicate`'s `[blank]` report; a
23+
* blank STRING returned the caller's fallback one line earlier.
24+
*
25+
* ## What each row pins
26+
*
27+
* The VERDICT does not move (D3/D4: a blank gate is "no gate") and the silence
28+
* ends, so every row asserts both halves. The report is the one channel
29+
* objectui#8069 put on the `dialect: 'cel'` route — `evalFieldPredicate`'s
30+
* `[blank]` reason — which the rows read by its tag, never by its prose.
31+
*
32+
* The "one diagnosis point" rows are what distinguish ONE guard from a second
33+
* blank test with its own reporter: a blank spelled on the legacy path and the
34+
* same blank spelled on the CEL route land on the same dedupe key, so they
35+
* print one line between them. A second reporter prints two.
36+
*
37+
* ## Fresh module graph per case
38+
*
39+
* The one-time warning dedupe is MODULE state and the `unit` project runs with
40+
* `isolate: false`, so a blank spelling another file already warned about
41+
* would read as silence here. `vi.resetModules()` + fresh imports give every
42+
* case its own dedupe `Set` — the shape `fieldRuleFaults-8069.test.ts` uses.
43+
*/
44+
45+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
46+
47+
let ExpressionEvaluator: typeof import('../ExpressionEvaluator.js').ExpressionEvaluator;
48+
let evalRowPredicate: typeof import('../listConditional.js').evalRowPredicate;
49+
let warn: ReturnType<typeof vi.spyOn>;
50+
51+
beforeEach(async () => {
52+
vi.resetModules();
53+
({ ExpressionEvaluator } = await import('../ExpressionEvaluator.js'));
54+
({ evalRowPredicate } = await import('../listConditional.js'));
55+
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
56+
});
57+
afterEach(() => warn.mockRestore());
58+
59+
const warnings = (): string[] => warn.mock.calls.map((call: unknown[]) => String(call[0]));
60+
const blankLines = (): string[] => warnings().filter((w) => w.includes('[blank]'));
61+
62+
/** The three blank spellings the triage names, on every path. */
63+
const BLANKS = [
64+
['an empty string', ''],
65+
['a whitespace string', ' \t '],
66+
["{ source: '' }", { source: '' }],
67+
] as const;
68+
69+
describe('path 1 — evaluateCondition’s legacy path diagnoses a blank gate (objectui#11262, ADR-0137 D4)', () => {
70+
it.each(BLANKS)('%s: still true ("no gate"), and now said once', (_label, gate) => {
71+
const ev = new ExpressionEvaluator({ record: {} });
72+
expect(ev.evaluateCondition(gate)).toBe(true);
73+
expect(ev.evaluateCondition(gate)).toBe(true);
74+
expect(blankLines()).toHaveLength(1);
75+
});
76+
77+
it.each(BLANKS)('%s: the [blank] reason goes to onFault instead, when the caller supplies one', (_label, gate) => {
78+
const reasons: string[] = [];
79+
const ev = new ExpressionEvaluator({ record: {} });
80+
expect(ev.evaluateCondition(gate, { onFault: (r) => reasons.push(r) })).toBe(true);
81+
expect(reasons).toHaveLength(1);
82+
expect(reasons[0]).toMatch(/^\[blank\]/);
83+
expect(warn).not.toHaveBeenCalled();
84+
});
85+
86+
it.each(BLANKS)('%s: throwOnError does not throw on it — nothing was authored that could fault — and it is still said', (_label, gate) => {
87+
const ev = new ExpressionEvaluator({ record: {} });
88+
expect(ev.evaluateCondition(gate, { throwOnError: true })).toBe(true);
89+
expect(blankLines()).toHaveLength(1);
90+
});
91+
92+
it('one diagnosis point: a blank on the legacy path and its dialect: cel twin print ONE line between them', () => {
93+
const ev = new ExpressionEvaluator({ record: {} });
94+
expect(ev.evaluateCondition('')).toBe(true);
95+
expect(blankLines()).toHaveLength(1);
96+
expect(ev.evaluateCondition({ dialect: 'cel', source: '' })).toBe(true);
97+
expect(ev.evaluateCondition({ source: '' })).toBe(true);
98+
expect(blankLines()).toHaveLength(1);
99+
});
100+
101+
it.each([
102+
['undefined', undefined],
103+
['null', null],
104+
] as const)('control — %s is no gate at all, not blank TEXT: true and silent', (_label, gate) => {
105+
const ev = new ExpressionEvaluator({ record: {} });
106+
expect(ev.evaluateCondition(gate as never)).toBe(true);
107+
expect(warn).not.toHaveBeenCalled();
108+
});
109+
110+
it('control — a written legacy gate answers its own verdict and is silent', () => {
111+
const ev = new ExpressionEvaluator({ data: { stage: 'open' } });
112+
expect(ev.evaluateCondition("${data.stage === 'open'}")).toBe(true);
113+
expect(ev.evaluateCondition("${data.stage === 'won'}")).toBe(false);
114+
expect(ev.evaluateCondition('1 > 2')).toBe(false);
115+
expect(warn).not.toHaveBeenCalled();
116+
});
117+
});
118+
119+
describe('path 2 — evalRowPredicate diagnoses a bare blank instead of returning its fallback in silence (objectui#11262)', () => {
120+
const ROUTES = [
121+
['the single-eval route', false],
122+
['the labelled fail-closed route (warnOnError)', true],
123+
] as const;
124+
125+
describe.each(ROUTES)('%s', (_route, warnOnError) => {
126+
it.each(BLANKS)('%s: the caller’s fallback, both directions, and a [blank] report', (_label, pred) => {
127+
const opts = { warnOnError, label: 'row action "archive_11262"' };
128+
expect(evalRowPredicate(pred as never, { a: 1 }, { ...opts, fallback: true })).toBe(true);
129+
expect(evalRowPredicate(pred as never, { a: 1 }, { ...opts, fallback: false })).toBe(false);
130+
// One line for both calls: the dedupe keys on the text and the label,
131+
// never on the fallback.
132+
const lines = blankLines();
133+
expect(lines).toHaveLength(1);
134+
expect(lines[0]).toContain('archive_11262');
135+
});
136+
137+
it('one diagnosis point: the bare blank takes its envelope twin’s route — one line between them', () => {
138+
const opts = { warnOnError, fallback: false, label: 'formatting rule 11262' };
139+
evalRowPredicate('', { a: 1 }, opts);
140+
expect(blankLines()).toHaveLength(1);
141+
evalRowPredicate({ source: '' } as never, { a: 1 }, opts);
142+
evalRowPredicate('', { a: 2 }, opts);
143+
expect(blankLines()).toHaveLength(1);
144+
});
145+
146+
it.each([
147+
['undefined', undefined],
148+
['null', null],
149+
] as const)('control — %s is no predicate: the fallback, silent', (_label, pred) => {
150+
expect(evalRowPredicate(pred, { a: 1 }, { warnOnError, fallback: true })).toBe(true);
151+
expect(evalRowPredicate(pred, { a: 1 }, { warnOnError, fallback: false })).toBe(false);
152+
expect(warn).not.toHaveBeenCalled();
153+
});
154+
155+
it('control — a written predicate answers its own verdict and is silent', () => {
156+
expect(evalRowPredicate('record.a == 1', { a: 1 }, { warnOnError, fallback: false })).toBe(true);
157+
expect(evalRowPredicate('record.a == 1', { a: 2 }, { warnOnError, fallback: true })).toBe(false);
158+
expect(warn).not.toHaveBeenCalled();
159+
});
160+
});
161+
162+
it('rowless (a param dialog) takes the same guard — the fallback, and the report', () => {
163+
expect(evalRowPredicate(' ', null, { rowless: true, fallback: true, warnOnError: true, label: 'param "p_11262"' })).toBe(true);
164+
expect(blankLines()).toHaveLength(1);
165+
});
166+
});

0 commit comments

Comments
 (0)