From 20c52cedfd50ffd5df5c667b4501bcc68277ad72 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:49:41 +0000 Subject: [PATCH] fix(react): extend the adapter-only `data.*` constant-predicate diagnostic to `disabled` / `disabledOn` `reportAdapterOnlyDataPredicate` (objectui#5687) reports a node-gate predicate that evaluates perfectly, against the wrong object: at the node tier `data` is the data-source adapter, not the row, so `data.status == 'locked'` is a constant for every row. It was wired only into the visibility chain's dev leg. The `disabled` / `disabledOn` gate was not covered: a predicate like `data.status == 'locked'` evaluates cleanly (no fault, so objectui#6445's fault reporter correctly stays silent), and on the constant's other polarity (`data.locked == null`, `!data.assignee`, or an adapter answering nothing) hands the gate a constant `true` that greys the control out on every row, in every build, with nothing on the console. Per the 2026-08-27 maintainer ruling (option A) on objectui#6504: extend the diagnostic to the enablement gate, dev-only (option C, always-on, was excluded, outside the #5687 precedent), with its own copy for the constant-`true` direction rather than reusing the visibility leg's "hides the node" sentence. Both legs carry the same objectui#5330 dissolution pointer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_8ca04858-ea8e-5b85-9182-de59aa49e00c --- .../6504-disabled-gate-constant-predicate.md | 28 +++ packages/react/src/SchemaRenderer.tsx | 60 ++++-- ...derer.disabledGateFaultDiagnostic.test.tsx | 190 ++++++++++++++++-- .../react/src/utils/visibilityDiagnostic.ts | 143 +++++++++++-- 4 files changed, 378 insertions(+), 43 deletions(-) create mode 100644 .changeset/6504-disabled-gate-constant-predicate.md diff --git a/.changeset/6504-disabled-gate-constant-predicate.md b/.changeset/6504-disabled-gate-constant-predicate.md new file mode 100644 index 0000000000..66d93cb63f --- /dev/null +++ b/.changeset/6504-disabled-gate-constant-predicate.md @@ -0,0 +1,28 @@ +--- +'@object-ui/react': patch +--- + +Extends objectui#5687's adapter-only `data.*` constant-predicate diagnostic — a node-gate +predicate that evaluates perfectly, against the wrong object, because at the node tier +`data` is the data-source adapter, not the row — to the `disabled` / `disabledOn` gate +(objectui#6504, maintainer ruling 2026-08-27 option A). + +A node written `{ "type": "button", "disabled": "data.status == 'locked'" }` evaluates +cleanly (no fault, so objectui#6445's fault reporter correctly stays silent), and on the +constant's other polarity (`data.locked == null`, `!data.assignee`, or an adapter that +answers nothing) hands the gate a constant `true` that greys the control out on every row, +in every build, with nothing on the console. This leg now names it, in development only — +option C (always-on) was excluded, outside the #5687 precedent. + +The copy is new, not reused: the visibility leg's sentence ("a constant `false` hides the +node on every row") is written about the opposite polarity and would be false on this gate. +The enablement leg's own sentence names the constant-`true` direction — the control renders +DISABLED, greyed out, indistinguishable from a gate the author meant to close. + +Both legs carry the same dissolution pointer: this diagnostic — visibility AND enablement +together — dissolves when objectui#5330's `data.*` deprecation window closes. + +Dev-only, no verdict change, no interpolation change, no published type widened (the new +`AdapterOnlyPredicateGateKind` type and the new prefix constant are module-internal, not +re-exported from the package entry — matching objectui#5687's own symbols, which never were +either). diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index e92c91f09d..57c0beb3fb 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -802,24 +802,46 @@ export const SchemaRenderer: ForwardRefExoticComponent< * is PRESERVED deliberately — flipping it is a shipped-behaviour change on * a live surface and is not this card's to make. * - * ## One engine call, both builds — no `__DEV__` split here + * ## One engine call, both builds — still true after objectui#6504 * - * The visibility helper keeps a `__DEV__` branch because its dev leg needs - * a CLEANLY-evaluated verdict to run objectui#5687's adapter-only reporter - * on. This gate reports faults only, so `onFault` alone covers both builds: - * one engine call, one code path, and dev and production print the same - * line by construction rather than by keeping two branches in step. + * The FAULT report above is unconditional either way: `onFault` alone + * covers both builds, one engine call, one code path, dev and production + * print the same line by construction. objectui#6504 adds a SECOND, + * dev-only check after that same call returns — not a second evaluation, + * a lexical scan of the already-available predicate source and the + * already-available `dataSource` — so production keeps its one-call, + * one-path shape exactly as this docblock described before this leg + * existed; only development pays for the extra check, and only after a + * clean (non-faulting) evaluation. * - * objectui#5687's `reportAdapterOnlyDataPredicate` is deliberately NOT - * called here. Its ruling (2026-08-22, option A) is scoped to the - * visibility gate and its message text is written about one — "a constant - * `false` hides the node on every row" is not what a constant does to a - * `disabled` gate. Wiring it would need its own copy decision and its own - * ruling; filed rather than smuggled in (objectui#6504). + * ## objectui#5687's diagnostic, extended (objectui#6504, maintainer + * ruling 2026-08-27 option A) + * + * Was deliberately NOT called here — see the card this replaces filed at + * objectui#6504: the 2026-08-22 ruling was scoped to the visibility gate, + * and its message text ("a constant `false` hides the node on every row") + * is not what a constant does to a `disabled` gate. The 2026-08-27 ruling + * answers both gaps: extend to `disabled` / `disabledOn`, dev-only exactly + * as the visibility leg is (⛔ always-on was excluded, outside the #5687 + * precedent), with its OWN copy — the constant-`true` direction, since on + * THIS gate that is the polarity that greys the control out in silence. + * `ADAPTER_ONLY_GATE_COPY['enablement']` (`visibilityDiagnostic.ts`) is + * that copy, and it carries the same #5330 dissolution pointer #5687's + * entry does: both legs retire together when that window closes. + * + * Reachable only on the branch where the predicate evaluated CLEANLY — + * `faulted` mirrors {@link evaluateVisibilityPredicate}'s try/catch split + * without paying for a second (`throwOnError`) engine call: `onFault` is + * already told exactly when the single call it wraps did not produce a + * clean verdict, so a local flag reuses that same signal instead of + * re-deriving it. A predicate that faults is the OTHER reporter's case, + * immediately above, in the same way it already is on the visibility leg. */ - const evaluateEnablementPredicate = (raw: VisibilityPredicate, key: string): boolean => - evaluator.evaluateCondition(raw, { - onFault: (reason) => + const evaluateEnablementPredicate = (raw: VisibilityPredicate, key: string): boolean => { + let faulted = false; + const verdict = evaluator.evaluateCondition(raw, { + onFault: (reason) => { + faulted = true; reportUnresolvableVisibilityPredicate( newSchema.type, newSchema.id, @@ -831,8 +853,14 @@ export const SchemaRenderer: ForwardRefExoticComponent< // declares, and the gate whose safe default disables the control. 'page-component', 'enablement', - ), + ); + }, }); + if (__DEV__ && !faulted) { + reportAdapterOnlyDataPredicate(newSchema.type, newSchema.id, key, raw, dataSource, 'enablement'); + } + return verdict; + }; // Evaluate 'properties' — the SPEC spelling of a node's config bag, of // which `props` (evaluated below) is the legacy alias. diff --git a/packages/react/src/__tests__/SchemaRenderer.disabledGateFaultDiagnostic.test.tsx b/packages/react/src/__tests__/SchemaRenderer.disabledGateFaultDiagnostic.test.tsx index 079a6f947b..204c76eb5e 100644 --- a/packages/react/src/__tests__/SchemaRenderer.disabledGateFaultDiagnostic.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.disabledGateFaultDiagnostic.test.tsx @@ -64,6 +64,7 @@ import { render, screen, cleanup } from '@testing-library/react'; import React from 'react'; import { ADAPTER_ONLY_DATA_PREDICATE_PREFIX, + ADAPTER_ONLY_ENABLEMENT_PREDICATE_PREFIX, UNRESOLVABLE_ENABLEMENT_PREFIX, UNRESOLVABLE_VISIBILITY_PREFIX, } from '../utils/visibilityDiagnostic'; @@ -137,6 +138,17 @@ const reports = (warn: WarnSpy): string[] => /** The sibling gate's view, for the cross-gate cases. */ const visibilityReports = (warn: WarnSpy): string[] => warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes(UNRESOLVABLE_VISIBILITY_PREFIX)); +/** + * The objectui#6504 leg's view — the enablement-gate extension of #5687's + * adapter-only constant-predicate diagnostic. A DIFFERENT fault class than + * `reports` above: this one names a predicate that evaluated PERFECTLY, + * against the wrong object, not one that could not be evaluated at all. + */ +const adapterOnlyReports = (warn: WarnSpy): string[] => + warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes(ADAPTER_ONLY_ENABLEMENT_PREDICATE_PREFIX)); +/** The sibling (objectui#5687, visibility-gate) leg's view of the SAME diagnostic family. */ +const adapterOnlyVisibilityReports = (warn: WarnSpy): string[] => + warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes(ADAPTER_ONLY_DATA_PREDICATE_PREFIX)); /** Everything the console was asked to print, filtered by nothing. */ const allWarnings = (warn: WarnSpy): string[] => warn.mock.calls.map((c) => String(c[0])); @@ -539,28 +551,178 @@ describe('#6445 group 3 — nothing else became loud', () => { }); }); - it('objectui#5687 is NOT extended to this gate: an adapter-only `data.*` `disabled` predicate stays silent (filed as objectui#6504)', async () => { - // Deliberately out of scope. That leg reports a predicate that evaluated - // PERFECTLY against the wrong object — not a fault — and its ruling - // (2026-08-22 option A) is scoped to the visibility gate, with copy written - // about hiding. Pinned so the boundary is a stated decision rather than - // something a later reader discovers by surprise. + it('SUPERSEDED by objectui#6504: an adapter-only `data.*` `disabled` predicate now reports, on the enablement leg — not this fault one', async () => { + // Was pinned SILENT until the 2026-08-27 ruling (option A) extended + // objectui#5687's diagnostic to this gate. This predicate never FAULTS — + // it evaluates perfectly, against the wrong object — so it must never + // appear on THIS file's `reports()` (the #6445 fault leg); see group 6 + // below for the positive coverage of the new leg. await inDevelopment((mount) => { const warn = spyWarn(); mount([{ id: 'n1', disabled: "data.status == 'locked'" }]); - // The verdict is the documented one: a constant, here constant-false. expect(disabledProp()).toBe('absent'); - // No predicate diagnostic of ANY kind: not this card's (the predicate - // did not fault), not objectui#5687's (not wired to this gate), not the - // visibility one. expect(reports(warn)).toHaveLength(0); expect(visibilityReports(warn)).toHaveLength(0); + // It DOES now report — on its own leg and its own prefix. + expect(adapterOnlyReports(warn)).toHaveLength(1); + }); + }); +}); + +/* -------------------------------------------------------------------------- * + * Group 6 — objectui#6504: the enablement leg of #5687's adapter-only + * constant-predicate diagnostic. A DIFFERENT fault class than groups 0-5 + * above: nothing here FAULTS (the predicate evaluates perfectly, against the + * wrong object), so none of it belongs on `reports()` / `UNRESOLVABLE_ENABLEMENT_PREFIX`. + * -------------------------------------------------------------------------- */ + +describe('#6504 group 6 — the adapter-only diagnostic, extended to `disabled` / `disabledOn`', () => { + it('THE acceptance criterion: the card\'s repro shape reports, dev-only, and the verdict is untouched (constant-FALSE direction)', async () => { + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', disabled: "data.status == 'locked'" }]); + // Verdict UNCHANGED — `undefined == 'locked'` is `false`, so the control + // is NOT disabled here. This diagnostic never moves a verdict. + expect(disabledProp()).toBe('absent'); + const lines = adapterOnlyReports(warn); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain(TYPE); + expect(lines[0]).toContain('n1'); + expect(lines[0]).toContain('disabled'); + expect(lines[0]).toContain("data.status == 'locked'"); + expect(lines[0]).toContain('data.status'); + expect(lines[0]).toContain('CONSTANT'); + // No fault of any kind was reported alongside it. + expect(reports(warn)).toHaveLength(0); + }); + }); + + it('the DANGEROUS direction the ruling named: a constant `true` greys the control out, silently, on every row', async () => { + // `data.locked` is undefined on the adapter — one of the deliberate-absence + // idioms `unresolvedDataPaths`'s own docblock names — so `== null` is + // `true`: the exact "hands the gate a constant `true`" shape objectui#6504 + // was filed about. This is the case that was previously unreported in + // EVERY build, with nothing on the console. + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', disabled: 'data.locked == null' }]); + expect(disabledProp()).toBe('true'); + expect(adapterOnlyReports(warn)).toHaveLength(1); + }); + }); + + it('the copy is the constant-`true` sentence, NOT a reuse of the visibility copy', async () => { + // The card's whole design content. "A constant `false` hides the node" is + // written about a different gate and would be false here; the correct + // sentence for THIS gate is about a constant `true` greying it out. + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', disabled: 'data.locked == null' }]); + const line = adapterOnlyReports(warn)[0]; + expect(line).toContain('constant `true`'); + expect(line).toContain('DISABLED'); + expect(line).toContain('greyed out'); + expect(line).not.toContain('hides the'); + expect(line).not.toContain(ADAPTER_ONLY_DATA_PREDICATE_PREFIX); + // Still names the deprecated spelling and the fix, same as the sibling. + expect(line).toContain('record.status'); + expect(line).toContain('objectui#5330'); + }); + }); + + it('and the sibling (visibility) leg still prints ITS OWN copy, unchanged — the SAME source, two gates, two different sentences', async () => { + // The card's other correctness requirement, restated as a same-mount + // control: `evaluateVisibilityPredicate`'s call site (objectui#5687, + // untouched by this card) and `evaluateEnablementPredicate`'s (new) fire + // side by side on the literal same predicate TEXT, and must not cross-talk + // — each keeps its own prefix and its own consequence sentence. + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([ + { id: 'nA', visibleWhen: "data.status == 'locked'" }, + { id: 'nB', disabled: "data.status == 'locked'" }, + ]); + const visLine = adapterOnlyVisibilityReports(warn); + const enLine = adapterOnlyReports(warn); + expect(visLine).toHaveLength(1); + expect(enLine).toHaveLength(1); + expect(visLine[0]).toContain('nA'); + expect(visLine[0]).toContain('hides the'); + expect(visLine[0]).not.toContain('DISABLED'); + expect(enLine[0]).toContain('nB'); + expect(enLine[0]).toContain('DISABLED'); + expect(enLine[0]).not.toContain('hides the'); + }); + }); + + it('FALSE-POSITIVE CONTROL: a genuinely row-dependent `record.*` predicate on `disabled` never fires this leg', async () => { + // The direction the ruling refuses: a diagnostic that accuses correct + // usage is worse than none. `record.status` is BOUND at this tier + // (unlike `data.*`), so this reaches opposite verdicts on the two rows — + // structurally incapable of being a `data.*` constant. + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', disabled: HEALTHY_DISABLING }]); + expect(disabledProp()).toBe('true'); // ROW.status === 'open' + expect(adapterOnlyReports(warn)).toHaveLength(0); + expect(reports(warn)).toHaveLength(0); + }); + }); + + it('a GENUINE adapter read on `disabled` stays silent — the half that makes the noise mean something', async () => { + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', disabled: 'data.total > 0' }]); // ADAPTER.total === 99 + expect(disabledProp()).toBe('true'); // a REAL verdict, from a real read + expect(adapterOnlyReports(warn)).toHaveLength(0); + }); + }); + + it('`disabledOn` reports too — the same wiring as `disabled`, not a copy of it', async () => { + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', disabledOn: "data.status == 'locked'" }]); + expect(adapterOnlyReports(warn)[0]).toContain('disabledOn'); + }); + }); + + it('⛔ C excluded: a PRODUCTION build reaches the SAME verdict and stays silent — this leg is dev-only, exactly as its sibling is', async () => { + await inProduction((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', disabled: 'data.locked == null' }]); + // Same verdict as the dev case above — this diagnostic never moves one. + expect(disabledProp()).toBe('true'); + expect(adapterOnlyReports(warn)).toHaveLength(0); expect( - allWarnings(warn).filter((m) => m.includes(ADAPTER_ONLY_DATA_PREDICATE_PREFIX)), + allWarnings(warn).filter((m) => m.includes(ADAPTER_ONLY_ENABLEMENT_PREDICATE_PREFIX)), ).toHaveLength(0); - // The only thing the console saw is the dev validator's pre-existing - // false positive on a string-valued `disabled` (objectui#6505). - expect(nonValidatorWarnings(warn)).toHaveLength(0); + }); + }); + + it('COLLAPSING HALF: two nodes with different ids and the SAME source produce exactly ONE line', async () => { + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([ + { id: 'n1', disabled: "data.status == 'locked'" }, + { id: 'n2', disabled: "data.status == 'locked'" }, + ]); + expect(adapterOnlyReports(warn)).toHaveLength(1); + }); + }); + + it('SEPARATING HALF, across GATES: the SAME source on `disabled` and on `visibleWhen` is two lines, one per gate', async () => { + // `key` is part of the dedupe tuple, so the two legs of this diagnostic + // family cannot silence each other for the identical predicate source — + // the direction objectui#6445's own dedupe suite pins for the FAULT + // diagnostic, restated here for the adapter-only one this card adds. + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([ + { id: 'n1', disabled: "data.status == 'locked'" }, + { id: 'n2', visibleWhen: "data.status == 'locked'" }, + ]); + expect(adapterOnlyReports(warn)).toHaveLength(1); + expect(adapterOnlyVisibilityReports(warn)).toHaveLength(1); }); }); }); diff --git a/packages/react/src/utils/visibilityDiagnostic.ts b/packages/react/src/utils/visibilityDiagnostic.ts index 03e71a4e82..40e466e7d5 100644 --- a/packages/react/src/utils/visibilityDiagnostic.ts +++ b/packages/react/src/utils/visibilityDiagnostic.ts @@ -423,6 +423,100 @@ export function __resetVisibilityPredicateWarnings(): void { export const ADAPTER_ONLY_DATA_PREDICATE_PREFIX = '[ObjectUI] A visibility predicate resolved `data.*` against the data-source adapter'; +/** + * The `disabled` / `disabledOn` sibling of the prefix above (objectui#6504, + * maintainer ruling 2026-08-27 option A). Same reason + * {@link UNRESOLVABLE_ENABLEMENT_PREFIX} is a sibling of + * {@link UNRESOLVABLE_VISIBILITY_PREFIX} rather than a reuse: calling a + * `disabled` predicate "a visibility predicate" would send an author to the + * wrong gate on the first line — the one line a console filter and a `grep` + * both read. + */ +export const ADAPTER_ONLY_ENABLEMENT_PREDICATE_PREFIX = + '[ObjectUI] An enablement predicate resolved `data.*` against the data-source adapter'; + +/** + * Which gates the objectui#5687 constant-predicate diagnostic is wired to + * (objectui#6504, maintainer ruling 2026-08-27 option A: extend to the + * enablement gate, dev-only; option C — always-on — was excluded, outside + * the #5687 precedent). + * + * A NARROWER union than {@link PredicateGateKind} deliberately, not an + * oversight: `'concealment'` (`hidden` / `hiddenOn`) is not one of them. Both + * of this diagnostic's call sites are unchanged by this card — the visibility + * one (`SchemaRenderer.tsx`'s `evaluateVisibilityPredicate`) still calls + * {@link reportAdapterOnlyDataPredicate} with no `gate` argument at all, for + * the six-leg chain exactly as objectui#5687 shipped it, so `'concealment'` + * was never a value this table had to answer for. Giving it an entry nobody + * can reach would be a decision this ruling did not make; widening this type + * later is a decision for whichever card makes that call, not a `??` for this + * table to paper over now. + */ +export type AdapterOnlyPredicateGateKind = Extract; + +/** + * The two parts of the message that vary with the gate, mirroring + * {@link GATE_KIND_COPY}'s machinery (#6445) for this SIBLING diagnostic — + * same discipline (indexed without a `??` fallback; the default lives on the + * parameter), a separate table because the message TEMPLATE differs (this one + * carries an "Undefined on the adapter:" line and no {@link SCOPE_TIER_ADVICE} + * tail; see {@link formatAdapterOnlyDataMessage}). + * + * ⛔⛔ BINDING INHERITANCE CLAUSE (2026-08-27 ruling, objectui#6504, verbatim + * from the card thread): "the extension inherits #5687's dissolution note + * verbatim — when #5330's deprecation window closes, both legs dissolve + * together; the new copy entry must carry the same pointer so the teardown + * finds it." The pointer, carried from this module's own docblock (see + * {@link unresolvedDataPaths}'s "why this discriminator" section): objectui#5687 + * is "a diagnostic on a card that dissolves when objectui#5330's deprecation + * window closes." BOTH entries below dissolve on that same clock, together — + * this is not an independently-scheduled diagnostic. When that window closes, + * delete this table, {@link ADAPTER_ONLY_ENABLEMENT_PREDICATE_PREFIX}, + * {@link ADAPTER_ONLY_DATA_PREDICATE_PREFIX}, {@link unresolvedDataPaths}, + * {@link formatAdapterOnlyDataMessage} and {@link reportAdapterOnlyDataPredicate} + * in this file, and BOTH call sites in `SchemaRenderer.tsx` + * (`evaluateVisibilityPredicate` and `evaluateEnablementPredicate`), in the + * same stroke. + */ +const ADAPTER_ONLY_GATE_COPY: Record = { + visibility: { + prefix: ADAPTER_ONLY_DATA_PREDICATE_PREFIX, + consequence: + 'At the node tier `data` is the DATA-SOURCE ADAPTER - the object\n' + + '`${data.total}` in a props bag reads - and it is NOT the row. The reads\n' + + 'above are undefined on it, so this predicate is a CONSTANT: it does not\n' + + 'depend on the row at all, and on this surface a constant `false` hides the\n' + + 'node on every row while looking exactly like a gate that said no.\n' + + 'Write the row as `record.*` (e.g. `record.status`), which page-component\n' + + 'predicates bind alongside `current_user` and page state as `page.`.\n' + + '`data.*` as a spelling for the row is deprecated (objectui#5330) and was\n' + + 'never bound to the row on this tier.', + }, + // The constant-`true` direction, not a reuse of the sentence above: on THIS + // gate the fail-soft default that BITES is `true` (a control that greys + // out), the mirror image of the negated visibility legs where it is + // `false` (a node that hides). Written to describe that direction, since it + // is the one that leaves nothing on screen for an author to notice — + // "greyed out" looks exactly like a gate that closed on purpose, the same + // way "not on screen" looks exactly like a gate that hid on purpose. + enablement: { + prefix: ADAPTER_ONLY_ENABLEMENT_PREDICATE_PREFIX, + consequence: + 'At the node tier `data` is the DATA-SOURCE ADAPTER - the object\n' + + '`${data.total}` in a props bag reads - and it is NOT the row. The reads\n' + + 'above are undefined on it, so this predicate is a CONSTANT: it does not\n' + + 'depend on the row at all, and on THIS gate the dangerous polarity is a\n' + + 'constant `true`: the node renders DISABLED - on screen, greyed out,\n' + + 'refusing input - on every row, in every build, while looking exactly\n' + + 'like a gate that said no. No pixel says the predicate is a constant, so\n' + + 'this line is the only thing that will ever name it.\n' + + 'Write the row as `record.*` (e.g. `record.status`), which page-component\n' + + 'predicates bind alongside `current_user` and page state as `page.`.\n' + + '`data.*` as a spelling for the row is deprecated (objectui#5330) and was\n' + + 'never bound to the row on this tier.', + }, +}; + /** * Strip string literals before scanning for `data.*` reads. * @@ -473,7 +567,9 @@ const DATA_ROOT_PATH_RE = * * "the comparison OPERAND is undefined" — the most precise reading, and it * needs the expression engine to expose its operands. That is a change to * `@object-ui/core`'s evaluator for a diagnostic on a card that dissolves - * when objectui#5330's deprecation window closes. + * when objectui#5330's deprecation window closes — BOTH legs together, + * the enablement one objectui#6504 added included; see + * {@link ADAPTER_ONLY_GATE_COPY}'s binding inheritance clause. * * "a `data.*` read the bound object does not answer" — this one. On the * card's repro (`data.status` against the adapter) it fires; on * `${data.total}` against `{ total: 99 }` it does not; and it is @@ -517,29 +613,33 @@ function unresolvedDataPaths(source: string, boundData: unknown): string[] { return out; } -/** Built separately from the emit, for the same reason as the message above. */ +/** + * Built separately from the emit, for the same reason as the message above. + * + * `gate` defaults to `'visibility'` so the historical four-argument call + * (objectui#5687) keeps printing the exact bytes it printed before — + * objectui#6504's own call site in `evaluateVisibilityPredicate` + * (`SchemaRenderer.tsx`) is one of those unchanged callers, passing no `gate` + * at all. `evaluateEnablementPredicate` is the one caller that states + * `'enablement'` explicitly, matching {@link formatUnresolvableVisibilityMessage}'s + * own compatibility-default pattern. + */ export function formatAdapterOnlyDataMessage( type: unknown, id: unknown, key: string, raw: unknown, unresolved: string[], + gate: AdapterOnlyPredicateGateKind = 'visibility', ): string { const node = typeof type === 'string' && type ? '"' + type + '"' : '(untyped node)'; const where = typeof id === 'string' && id ? ' (id: "' + id + '")' : ''; + const copy = ADAPTER_ONLY_GATE_COPY[gate]; return ( - ADAPTER_ONLY_DATA_PREDICATE_PREFIX + ' - node ' + node + where + '\n' + + copy.prefix + ' - node ' + node + where + '\n' + ' ' + key + ': ' + JSON.stringify(predicateSourceText(raw)) + '\n' + ' Undefined on the adapter: ' + unresolved.join(', ') + '\n' + - 'At the node tier `data` is the DATA-SOURCE ADAPTER - the object\n' + - '`${data.total}` in a props bag reads - and it is NOT the row. The reads\n' + - 'above are undefined on it, so this predicate is a CONSTANT: it does not\n' + - 'depend on the row at all, and on this surface a constant `false` hides the\n' + - 'node on every row while looking exactly like a gate that said no.\n' + - 'Write the row as `record.*` (e.g. `record.status`), which page-component\n' + - 'predicates bind alongside `current_user` and page state as `page.`.\n' + - '`data.*` as a spelling for the row is deprecated (objectui#5330) and was\n' + - 'never bound to the row on this tier.' + copy.consequence ); } @@ -574,6 +674,22 @@ export function formatAdapterOnlyDataMessage( * key is tagged with this leg's name so the two diagnostics cannot silence each * other for the same (type, key, source) triple — they are different faults, * and a node that faults one way is not evidence about the other. + * + * ## `gate` (objectui#6504) — NOT in the dedupe key, for the reason + * {@link reportUnresolvableVisibilityPredicate}'s own docblock gives for the + * same omission + * + * `gate` is a FUNCTION of `key`: the enablement gate is exactly `disabled` / + * `disabledOn` and the visibility gate this leg is called for is the six-leg + * chain, two disjoint sets. Adding it to the dedupe key could not separate two + * entries `key` does not already separate — and the cross-gate direction is + * the one that would matter if it could: the SAME predicate source authored on + * `disabled` and on (say) `visibleWhen` must produce TWO lines, which it does + * today because `key` differs, with or without `gate` in the tuple. + * + * Defaults to `'visibility'` — the objectui#5687 call site + * (`evaluateVisibilityPredicate`) is unchanged by this card and still calls + * this function with five arguments. */ export function reportAdapterOnlyDataPredicate( type: unknown, @@ -581,6 +697,7 @@ export function reportAdapterOnlyDataPredicate( key: string, raw: unknown, boundData: unknown, + gate: AdapterOnlyPredicateGateKind = 'visibility', ): void { const source = predicateSourceText(raw); // Fast reject: the overwhelming majority of predicates never mention `data.` @@ -594,5 +711,5 @@ export function reportAdapterOnlyDataPredicate( const dedupeKey = JSON.stringify(['adapter-only-data', type, key, source]); if (_warnedVisibilityPredicates.has(dedupeKey)) return; _warnedVisibilityPredicates.add(dedupeKey); - console.warn(formatAdapterOnlyDataMessage(type, id, key, raw, unresolved)); + console.warn(formatAdapterOnlyDataMessage(type, id, key, raw, unresolved, gate)); }