From e76458c5d5db47e0e6c91b610f5ce6e5f9915dc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 07:42:45 +0000 Subject: [PATCH 1/2] fix(react): tell `hidden` / `hiddenOn` authors the gate DID bite and the node vanished `SchemaRenderer`'s visibility chain negates its verdict on four of its six legs. `evaluateCondition` answers an unevaluable predicate with `true` on every path, so on `visibleWhen` / `visible` / `visibleOn` / `visibility` that `true` means SHOWN and the reporter's consequence paragraph - "the gate did NOT bite" - is exactly right. `hidden` / `hiddenOn` return it UN-negated: the same `true` sets `_hidden` and the component returns `null`. The gate bit, and bit harder than on either sibling gate, and those two legs were handed the one sentence that says the opposite. The direction of the error is what it costs. A greyed-out control is still on screen; a node that never rendered is indistinguishable from metadata that meant it. An author whose block vanished, told the gate did not bite, is sent past the one line that was supposed to name their predicate to hunt a rendering bug that does not exist. `PredicateGateKind` gains a third member, `'concealment'` - the shape objectui#6445's docblock predicted, which then documented the defect and printed it anyway. The opening line is deliberately unchanged: these are visibility predicates, and objectui#6038's pin (plus any app console filter) reads through `UNRESOLVABLE_VISIBILITY_PREFIX`. Routing is derived inside `SchemaRenderer` from the same `VISIBILITY_HIDE_KEYS` declaration the chain itself consults, over a closed key union, so a seventh leg left unclassified is a type error rather than a line of false console copy. Copy only. No verdict moved; the node still vanishes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- .changeset/6503-concealment-gate-copy.md | 29 + packages/react/src/SchemaRenderer.tsx | 62 ++- ...er.concealmentGateFaultDiagnostic.test.tsx | 514 ++++++++++++++++++ packages/react/src/index.ts | 9 + .../react/src/utils/visibilityDiagnostic.ts | 54 +- 5 files changed, 656 insertions(+), 12 deletions(-) create mode 100644 .changeset/6503-concealment-gate-copy.md create mode 100644 packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx diff --git a/.changeset/6503-concealment-gate-copy.md b/.changeset/6503-concealment-gate-copy.md new file mode 100644 index 000000000..642f7aaa9 --- /dev/null +++ b/.changeset/6503-concealment-gate-copy.md @@ -0,0 +1,29 @@ +--- +'@object-ui/react': minor +--- + +The unresolvable-predicate diagnostic tells `hidden` / `hiddenOn` authors what actually +happened to their node (objectui#6503). Those two legs are the only ones in +`SchemaRenderer`'s visibility chain whose verdict is NOT negated: `evaluateCondition` +answers an unevaluable predicate with `true` on every path, the four negated legs turn that +into SHOWN, and these two return it as-is, so the same `true` sets `_hidden` and the +component returns `null`. Both were handed the consequence paragraph written for the negated +legs — "the gate did NOT bite - a predicate that cannot be evaluated reads on screen exactly +like one that said yes" — which is the opposite of what an author whose block VANISHED is +looking at. That line exists to name their predicate, and instead it sent them to hunt a +rendering bug that does not exist. + +The two legs now print their own paragraph: the safe default is the one that BITES here, the +node was REMOVED and is not on the page at all, an absent node is indistinguishable from +metadata that meant to hide it, and nothing is wrong with the renderer. + +Copy only — no verdict moved. The node still vanishes, which is the shipped fail-soft the +neighbouring family (objectui#3862 / #3955 / #6443 / #6487 / #6445) preserved deliberately, +and every case in the new suite pins the verdict beside the sentence. + +`PredicateGateKind` — re-exported from `@object-ui/react`'s entry — gains a third member, +`'concealment'`, joining `'visibility'` and `'enablement'`. The opening line is deliberately +UNCHANGED (`UNRESOLVABLE_VISIBILITY_PREFIX`): these are visibility predicates, and an app or +a test filtering the console by that constant must go on catching them. Consumers that +switch EXHAUSTIVELY over the union, or key a `Record` by it, gain a third case to answer; +no runtime signature moved and every value accepted before still is. diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index 87df2025b..1fe6b2a88 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -34,6 +34,7 @@ import { reportUnresolvableVisibilityPredicate, reportAdapterOnlyDataPredicate, } from './utils/visibilityDiagnostic.js'; +import type { PredicateGateKind } from './utils/visibilityDiagnostic.js'; /** * Dev-mode schema validation. @@ -232,6 +233,54 @@ function propsWithoutCanonicalKeys( const VISIBILITY_SHOW_KEYS = ['visibleWhen', 'visible', 'visibleOn', 'visibility'] as const; const VISIBILITY_HIDE_KEYS = ['hidden', 'hiddenOn'] as const; +/** + * The six legs, as ONE closed type — so a seventh cannot be added to the chain + * without also being classified below. + */ +type VisibilityChainKey = + | (typeof VISIBILITY_SHOW_KEYS)[number] + | (typeof VISIBILITY_HIDE_KEYS)[number]; + +/** + * Which CONSEQUENCE the diagnostic should print for a faulting predicate on + * this leg (objectui#6503). + * + * ## The defect this closes + * + * `evaluateCondition` answers an unevaluable predicate with `true` on every + * path. On the four SHOW legs the chain negates that answer, so the node is + * SHOWN and the reporter's "the gate did NOT bite" is true. On the two HIDE + * legs the chain returns it UN-negated (see `shouldHide` below), so the same + * `true` sets `_hidden` and this component returns `null`: the gate bit, and + * bit harder than on either sibling gate — the node is not on screen at all. + * Both were routed to `'visibility'`, so an author whose block VANISHED read a + * line telling them the gate did not bite and went looking for a rendering bug + * that does not exist. The polarity was documented in + * `visibilityDiagnostic.ts` and printed anyway; this is the routing that makes + * the documented split the one that is actually applied. + * + * ## Why derived from the two constants, when the reporter refuses to deduce + * + * `PredicateGateKind`'s docblock refuses to deduce the gate from `key` INSIDE + * the reporter, and that refusal stands: the reporter is exported and called + * from other packages, where an unheard-of spelling would silently inherit + * some other gate's sentence. Here the caller IS the chain. The two arrays + * above are the same declaration `shouldHide` and {@link winningVisibilityKey} + * consult, and {@link VisibilityChainKey} closes the parameter over them, so + * this is not a second table that can drift from the first — it is the same + * one, read for a second question. It also removes the failure mode that + * produced this card: a leg added to the chain and left with a consequence + * sentence written about a different polarity is now a TYPE error here, not a + * line of false console copy. + * + * The `disabled` / `disabledOn` gate is deliberately not reachable from this + * function: those legs are not in the visibility chain and route through + * `evaluateEnablementPredicate`, which states `'enablement'` at its own call + * site. + */ +const visibilityGateKind = (key: VisibilityChainKey): PredicateGateKind => + (VISIBILITY_HIDE_KEYS as readonly string[]).includes(key) ? 'concealment' : 'visibility'; + /** * Which ONE visibility key actually decides a node's fate, mirroring both the * `properties` hoist's precedence (same-named key: `properties.` @@ -278,7 +327,7 @@ const VISIBILITY_HIDE_KEYS = ['hidden', 'hiddenOn'] as const; * Read-only: decides nothing about visibility itself, only which key a * DIAGNOSTIC should look at. */ -function winningVisibilityKey(node: Record): string | undefined { +function winningVisibilityKey(node: Record): VisibilityChainKey | undefined { const propertiesBag = node.properties; const hasPropertiesBag = propertiesBag != null && typeof propertiesBag === 'object' && !Array.isArray(propertiesBag); @@ -647,7 +696,14 @@ export const SchemaRenderer: ForwardRefExoticComponent< * function changed; `shouldHide` below still calls it exactly as before, * on the POST-evaluation, POST-hoist schema, for the real verdict. */ - const evaluateVisibilityPredicate = (raw: VisibilityPredicate, key: string): boolean => { + const evaluateVisibilityPredicate = ( + raw: VisibilityPredicate, + key: VisibilityChainKey, + ): boolean => { + // WHICH consequence this leg's fail-soft default earns (objectui#6503). + // Computed once, used by both branches, so the production and the + // development report cannot disagree about what the default DID. + const gate = visibilityGateKind(key); // PRODUCTION STILL MAKES THE SINGLE CALL — it just no longer makes it // in silence (objectui#6038, maintainer ruling 2026-08-25, option B). // @@ -683,6 +739,7 @@ export const SchemaRenderer: ForwardRefExoticComponent< raw, reason, 'page-component', + gate, ), }); } @@ -705,6 +762,7 @@ export const SchemaRenderer: ForwardRefExoticComponent< raw, err, 'page-component', + gate, ); // The historical fail-soft answer, unchanged — and identical to what // the production branch above returns for the same input, which is what diff --git a/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx b/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx new file mode 100644 index 000000000..7a7df9d3d --- /dev/null +++ b/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx @@ -0,0 +1,514 @@ +/** + * 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#6503 — the fault diagnostic says something TRUE to an author whose + * node VANISHED. + * + * ## The defect this closes + * + * `evaluateCondition` answers an unevaluable predicate with `true` on every one + * of its paths. `SchemaRenderer`'s visibility chain NEGATES that answer on four + * of its six legs (`visibleWhen` / `visible` / `visibleOn` / `visibility`), so + * there the fail-soft default SHOWS the node and the reporter's sentence — "the + * gate did NOT bite" — is exactly right. The chain returns it UN-negated on the + * other two (`hidden` / `hiddenOn`): the same `true` sets `_hidden` and + * `SchemaRenderer` returns `null`. The gate bit, and bit harder than on either + * sibling gate — the node is not on screen at all — and those two legs were + * handed the sentence that says the opposite. + * + * Worse than a merely wrong sentence: it is wrong in the direction that costs + * the most. `SchemaRenderer`'s own #3862/#3955 comment names the asymmetry — "a + * greyed-out control is still on screen, a node that never rendered is + * indistinguishable from metadata that meant it". An author whose block + * vanished, told the gate did not bite, is sent to hunt a rendering bug that + * does not exist, past the one line that was supposed to name their predicate. + * + * ## ⭐ Why this file pins CONTENT, and why that is the whole point + * + * objectui#6038's suite already covers the `hidden` leg — "the `hidden` leg — + * the NON-negated polarity — reports too, and still hides". It pins that the + * line is EMITTED and that the line contains the KEY. It never pins what the + * line SAYS, which is precisely why this defect shipped through it, survived + * objectui#6487's rewrite of the same paragraph, and survived objectui#6510 + * writing the defect into the module's own docblock and printing it anyway. + * + * A pin that counts lines reproduces that blindness in new syntax. Every case + * below therefore asserts the CONSEQUENCE SENTENCE, byte for byte, in both + * directions: the sentence that must now appear, and the sentence that must no + * longer. And the emission pin is re-asserted here rather than assumed — the + * fix keeps `UNRESOLVABLE_VISIBILITY_PREFIX` on these legs deliberately, so + * objectui#6038's prefix-filtered view still catches them. + * + * ## The control that stops the cheap fix + * + * A fix that "improved" the copy for all six legs by making it vaguer would be + * green on the `hidden` cases alone. Group 2 pins the four NEGATED legs at + * their existing bytes, individually, so the only way to pass this file is to + * change the two legs that were wrong and leave the four that were right. + * + * ## Copy fix only — the verdict is NOT this card's to move + * + * The node still vanishes. That fail-soft is the shipped behaviour the + * neighbouring family (#3862 / #3955 / #6443 / #6487 / #6445) preserved + * deliberately. Every case here pins the VERDICT beside the line, so a run made + * green by flipping the gate fails this file. + * + * ## Reverse verification (direction predicted BEFORE running) + * + * Restoring `visibilityDiagnostic.ts` and `SchemaRenderer.tsx` to the pinned + * base commit turns groups 1, 3 and 5 RED — each on the CONTENT assertion, not + * on a count — and leaves groups 0, 2, 4 and 6 GREEN. That asymmetry is the + * card restated: two legs' copy moves, four legs' copy and every verdict in the + * file do not. Recorded in the PR body with the measured output. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import React from 'react'; +import { + UNRESOLVABLE_ENABLEMENT_PREFIX, + UNRESOLVABLE_VISIBILITY_PREFIX, +} from '../utils/visibilityDiagnostic'; + +const NAME = 'probe-6503'; +const TYPE = 'element:probe-6503'; + +/* -------------------------------------------------------------------------- * + * The two sentences, spelled out. Byte-for-byte copies of the `GATE_KIND_COPY` + * entries — deliberately duplicated rather than imported, because importing the + * table would make every assertion below a tautology that stays green no matter + * what the table is changed to say. + * -------------------------------------------------------------------------- */ + +/** What a VANISHED node's author must now read (objectui#6503). */ +const CONCEALMENT_CONSEQUENCE = + 'The node was treated as its safe default, which on THIS leg is the one\n' + + 'that BITES: `hidden` / `hiddenOn` are NOT negated, so that default\n' + + 'REMOVED the node - it is not on the page at all, and an absent node is\n' + + 'indistinguishable from metadata that meant to hide it. Nothing is broken\n' + + 'in the renderer: the block is missing because the predicate above could\n' + + 'not be evaluated.\n'; + +/** + * What the four NEGATED legs said before this card and must go on saying. The + * sentence is TRUE there, and vaguening it to cover all six would be the fix + * this file exists to refuse. + */ +const NEGATED_LEG_CONSEQUENCE = + 'The node was treated as its safe default, which on this surface means the\n' + + 'gate did NOT bite - a predicate that cannot be evaluated reads on screen\n' + + 'exactly like one that said yes.\n'; + +/** The clause the `hidden` legs used to carry, named so it can be pinned ABSENT. */ +const THE_FALSE_CLAUSE = 'gate did NOT bite'; + +/** A probe that renders only when the chain lets it — absence IS the verdict. */ +const Probe = () =>
; + +/** The ambient scope app-shell's `ExpressionProvider` really mounts. */ +const APP_SCOPE = { + current_user: { id: 'u1' }, + user: { id: 'u1' }, + app: {}, + data: {}, + features: {}, +}; +const ADAPTER = { total: 99 }; +const ROW = { id: 'r1', status: 'open' }; + +/** + * Faulting predicates — an unbound ROOT identifier, the shape objectstack#11254 + * hit and the shape an AI-authored predicate reaches for. + * + * A DISTINCT source per DEVELOPMENT case, for the reason objectui#6445's suite + * records: objectui#6444's `warnedEvaluationFaults` is module state on the + * STATIC graph, so a dev case reusing another's source can have the evaluator's + * own built-in line suppressed on its behalf, and a raw-total pin would then + * read one on a build that reported twice. `inProduction` gets a fresh module + * graph per case, so those may share. + */ +const FAULT_BARE = 'nosuchroot6503.locked == true'; +const FAULT_HIDDEN_DEV = 'devbadroot6503hidden.locked == true'; +const FAULT_HIDDENON_DEV = 'devbadroot6503hiddenon.locked == true'; +const FAULT_VISIBLEWHEN_DEV = 'devbadroot6503vw.locked == true'; +const FAULT_VISIBLE_DEV = 'devbadroot6503v.locked == true'; +const FAULT_VISIBLEON_DEV = 'devbadroot6503von.locked == true'; +const FAULT_VISIBILITY_DEV = 'devbadroot6503vy.locked == true'; +const FAULT_DISABLED_DEV = 'devbadroot6503dis.locked == true'; +const FAULT_TEMPLATE_HIDDEN = '${tmplbadroot6503.locked == true}'; +const FAULT_CEL_HIDDEN = { dialect: 'cel', source: 'record.bad6503(' }; + +type WarnSpy = { mock: { calls: unknown[][] } }; +const spyWarn = () => vi.spyOn(console, 'warn').mockImplementation(() => {}); +/** The VISIBILITY-prefix-filtered view — the same one objectui#6038 reads. */ +const reports = (warn: WarnSpy): string[] => + warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes(UNRESOLVABLE_VISIBILITY_PREFIX)); +/** The sibling gate's view, for the cross-gate control. */ +const enablementReports = (warn: WarnSpy): string[] => + warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes(UNRESOLVABLE_ENABLEMENT_PREFIX)); +const allWarnings = (warn: WarnSpy): string[] => warn.mock.calls.map((c) => String(c[0])); + +/** + * The one pre-existing line a DEVELOPMENT build prints beside these, and it is + * not ours: `validateSchemaOnce` runs in dev only and core's `BASE_SCHEMA_RULES` + * declares these gate keys booleans, so every EXPRESSION-valued one — the + * authoring form this whole family is about — is also reported as an invalid + * schema. A false positive predating this card, filed separately + * (objectui#6505). Named rather than absorbed into a loose assertion. + */ +const DEV_SCHEMA_VALIDATOR_NOISE = '[ObjectUI] Invalid schema detected:'; +const nonValidatorWarnings = (warn: WarnSpy): string[] => + allWarnings(warn).filter((m) => !m.includes(DEV_SCHEMA_VALIDATOR_NOISE)); + +/** + * Mount in a PRODUCTION module graph. + * + * `__DEV__` in `SchemaRenderer` is an IIFE evaluated at module load, so the env + * has to be stubbed before the import — hence `resetModules` and a dynamic + * import in the test BODY (the case `object-ui/no-dynamic-import-in-test-hook` + * exempts). The dedupe `Set` is module state of that same fresh graph, so the + * reset must come from the fresh graph too. + */ +async function inProduction( + fn: (mount: (schemas: Record[]) => void) => void | Promise, +): Promise { + vi.resetModules(); + vi.stubEnv('NODE_ENV', 'production'); + try { + const [core, prod, ctx, rec, expr, diag] = await Promise.all([ + import('@object-ui/core'), + import('../SchemaRenderer'), + import('../context/SchemaRendererContext'), + import('../context/RecordContext'), + import('../hooks/useExpression'), + import('../utils/visibilityDiagnostic'), + ]); + diag.__resetVisibilityPredicateWarnings(); + core.ComponentRegistry.register(NAME, Probe as never, { + namespace: 'element', + skipFallback: true, + } as never); + const mount = (schemas: Record[]) => + render( + + + + {schemas.map((s, i) => ( + + ))} + + + , + ); + await fn(mount); + core.ComponentRegistry.unregister?.(NAME, 'element'); + } finally { + cleanup(); + vi.unstubAllEnvs(); + vi.resetModules(); + } +} + +/** Mount in the ordinary (development) module graph. */ +async function inDevelopment( + fn: (mount: (schemas: Record[]) => void) => void | Promise, +): Promise { + const [core, dev, ctx, rec, expr, diag] = await Promise.all([ + import('@object-ui/core'), + import('../SchemaRenderer'), + import('../context/SchemaRendererContext'), + import('../context/RecordContext'), + import('../hooks/useExpression'), + import('../utils/visibilityDiagnostic'), + ]); + diag.__resetVisibilityPredicateWarnings(); + core.ComponentRegistry.register(NAME, Probe as never, { + namespace: 'element', + skipFallback: true, + } as never); + try { + const mount = (schemas: Record[]) => + render( + + + + {schemas.map((s, i) => ( + + ))} + + + , + ); + await fn(mount); + } finally { + core.ComponentRegistry.unregister?.(NAME, 'element'); + cleanup(); + } +} + +const shownCount = () => screen.queryAllByTestId('probe').length; + +beforeEach(() => { + vi.restoreAllMocks(); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +/* -------------------------------------------------------------------------- * + * Group 0 — the controls that decide whether the rest of the file means + * anything. Both are about the CAPTURE, not about the renderer. + * -------------------------------------------------------------------------- */ + +describe('#6503 group 0 — capture controls', () => { + it('POSITIVE CONTROL: the spy really observes a line carrying the visibility prefix', () => { + // Without this, every `toHaveLength(0)` below is equally green on a capture + // that observes nothing at all. + const warn = spyWarn(); + console.warn(`${UNRESOLVABLE_VISIBILITY_PREFIX} - synthetic control line 6503`); + expect(reports(warn)).toHaveLength(1); + }); + + it('DEGENERATE CONTROL: the two sentences are genuinely different strings', () => { + // A fix that made both entries identical would satisfy "the hidden leg + // prints X" and "the negated legs print X" at once. They must not be equal, + // and neither may contain the other. + expect(CONCEALMENT_CONSEQUENCE).not.toEqual(NEGATED_LEG_CONSEQUENCE); + expect(CONCEALMENT_CONSEQUENCE).not.toContain(THE_FALSE_CLAUSE); + expect(NEGATED_LEG_CONSEQUENCE).toContain(THE_FALSE_CLAUSE); + }); +}); + +/* -------------------------------------------------------------------------- * + * Group 1 — THE acceptance criterion. + * -------------------------------------------------------------------------- */ + +describe('#6503 group 1 — a faulting `hidden` says the node VANISHED, and it did', () => { + it('THE acceptance criterion: the consequence paragraph is the concealment one, NOT "the gate did NOT bite"', async () => { + await inProduction((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', hidden: FAULT_BARE }]); + + // The VERDICT, pinned beside the copy: the node is gone. This card moves + // bytes an author reads, never what the gate does. + expect(shownCount()).toBe(0); + + const lines = reports(warn); + expect(lines).toHaveLength(1); + const line = lines[0]; + // Both directions. The first alone would pass on a build that printed + // BOTH sentences; the second alone would pass on one that printed none. + expect(line).toContain(CONCEALMENT_CONSEQUENCE); + expect(line).not.toContain(THE_FALSE_CLAUSE); + }); + }); + + it('the report still names the node, the key and the predicate — the fix moves ONE paragraph', async () => { + await inProduction((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', hidden: FAULT_BARE }]); + const line = reports(warn)[0]; + expect(line).toContain(TYPE); + expect(line).toContain('hidden'); + expect(line).toContain(FAULT_BARE); + // And the tier advice below the consequence is untouched (objectui#6487). + expect(line).toContain('Page-component predicates bind `record`'); + }); + }); + + it('development prints the identical line — one message, not two that drift', async () => { + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', hidden: FAULT_HIDDEN_DEV }]); + expect(shownCount()).toBe(0); + const lines = reports(warn); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain(CONCEALMENT_CONSEQUENCE); + expect(lines[0]).not.toContain(THE_FALSE_CLAUSE); + // Nothing beyond our line and the known dev-only validator noise. + expect(nonValidatorWarnings(warn).length).toBeGreaterThan(0); + }); + }); + + it('the CEL dialect reaches the same paragraph — the copy is per GATE, not per dialect', async () => { + await inProduction((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', hidden: FAULT_CEL_HIDDEN }]); + expect(shownCount()).toBe(0); + const line = reports(warn)[0]; + expect(line).toBeDefined(); + expect(line).toContain(CONCEALMENT_CONSEQUENCE); + expect(line).not.toContain(THE_FALSE_CLAUSE); + }); + }); +}); + +/* -------------------------------------------------------------------------- * + * Group 2 — THE CONTROL. The four negated legs must be UNCHANGED. + * -------------------------------------------------------------------------- */ + +describe('#6503 group 2 — the four NEGATED legs keep their sentence, byte for byte', () => { + // Each leg individually, not "one of them": `shouldHide` is an early-return + // chain, so a single mount can only ever exercise the FIRST declared leg. A + // loop over one schema would have measured `visibleWhen` four times. + const NEGATED: Array<[string, string]> = [ + ['visibleWhen', FAULT_VISIBLEWHEN_DEV], + ['visible', FAULT_VISIBLE_DEV], + ['visibleOn', FAULT_VISIBLEON_DEV], + ['visibility', FAULT_VISIBILITY_DEV], + ]; + + for (const [key, source] of NEGATED) { + it(`\`${key}\` still says the gate did NOT bite — and the node is still SHOWN`, async () => { + await inProduction((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', [key]: source }]); + // The fail-soft answer these four legs have always given. + expect(shownCount()).toBe(1); + const lines = reports(warn); + expect(lines).toHaveLength(1); + const line = lines[0]; + expect(line).toContain(key); + // The sentence that is TRUE here, in full. A fix that made the copy + // vaguer to cover all six legs fails on this line. + expect(line).toContain(NEGATED_LEG_CONSEQUENCE); + // And the concealment copy must NOT leak onto a leg that shows. + expect(line).not.toContain(CONCEALMENT_CONSEQUENCE); + expect(line).not.toContain('REMOVED the node'); + }); + }); + } +}); + +/* -------------------------------------------------------------------------- * + * Group 3 — `hiddenOn`, the second non-negated leg. + * -------------------------------------------------------------------------- */ + +describe('#6503 group 3 — `hiddenOn` is the same gate and gets the same sentence', () => { + it('a faulting `hiddenOn` prints the concealment paragraph and the node vanishes', async () => { + await inProduction((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', hiddenOn: FAULT_BARE }]); + expect(shownCount()).toBe(0); + const line = reports(warn)[0]; + expect(line).toBeDefined(); + expect(line).toContain('hiddenOn'); + expect(line).toContain(CONCEALMENT_CONSEQUENCE); + expect(line).not.toContain(THE_FALSE_CLAUSE); + }); + }); + + it('development agrees on `hiddenOn` too', async () => { + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', hiddenOn: FAULT_HIDDENON_DEV }]); + expect(shownCount()).toBe(0); + expect(reports(warn)[0]).toContain(CONCEALMENT_CONSEQUENCE); + }); + }); +}); + +/* -------------------------------------------------------------------------- * + * Group 4 — the objectui#6038 pins this card must NOT break. + * -------------------------------------------------------------------------- */ + +describe('#6503 group 4 — emission and rate limit are unchanged', () => { + it('the `hidden` leg still carries the VISIBILITY prefix — objectui#6038 reads through that filter', async () => { + // The routing decision restated as a pin. A third gate kind with a third + // PREFIX would have been the tidy-looking change that silently emptied + // objectui#6038's prefix-filtered view (and every app console filter that + // hard-codes the constant). It is a visibility predicate; only the + // consequence sentence differs. + await inProduction((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', hidden: FAULT_BARE }]); + expect(shownCount()).toBe(0); + const lines = reports(warn); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('hidden'); + expect(lines[0].startsWith(UNRESOLVABLE_VISIBILITY_PREFIX)).toBe(true); + // No line escaped the prefix-filtered view. + expect(allWarnings(warn)).toHaveLength(1); + }); + }); + + it('still ONE line for many nodes sharing a faulting `hidden`, and a SECOND distinct source still reports', async () => { + // Both halves together — "a warning was emitted" is green on fifty, and + // "exactly one" is green on an implementation that suppresses everything. + await inProduction((mount) => { + const warn = spyWarn(); + mount([ + { id: 'a', hidden: FAULT_BARE }, + { id: 'b', hidden: FAULT_BARE }, + { id: 'c', hidden: FAULT_BARE }, + ]); + expect(shownCount()).toBe(0); + expect(reports(warn)).toHaveLength(1); + mount([{ id: 'd', hidden: 'anotherbadroot6503.frozen == true' }]); + expect(reports(warn)).toHaveLength(2); + expect(reports(warn)[1]).toContain(CONCEALMENT_CONSEQUENCE); + }); + }); + + it('a HEALTHY `hidden` predicate stays silent — the paired control', async () => { + // Without this, "always print the concealment paragraph" satisfies group 1. + await inProduction((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', hidden: "record.status == 'open'" }]); + expect(shownCount()).toBe(0); // a REAL hide, not a fault + expect(reports(warn)).toHaveLength(0); + }); + }); +}); + +/* -------------------------------------------------------------------------- * + * Group 5 — the DYNAMIC routing site (objectui#5756). + * -------------------------------------------------------------------------- */ + +describe('#6503 group 5 — the winning-key report site routes by polarity too', () => { + it('a `${…}` template in `properties.hidden` gets the concealment paragraph', async () => { + // This is the one call site that cannot state its gate as a literal: it + // passes whatever `winningVisibilityKey` returned. A fix that only touched + // the six literal legs in `shouldHide` would leave this path printing the + // old sentence for the same authoring mistake in a different spelling. + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', properties: { hidden: FAULT_TEMPLATE_HIDDEN } }]); + const line = reports(warn)[0]; + expect(line).toBeDefined(); + expect(line).toContain('hidden'); + expect(line).toContain(CONCEALMENT_CONSEQUENCE); + expect(line).not.toContain(THE_FALSE_CLAUSE); + }); + }); +}); + +/* -------------------------------------------------------------------------- * + * Group 6 — the sibling gate is untouched. + * -------------------------------------------------------------------------- */ + +describe('#6503 group 6 — the enablement gate keeps its own paragraph', () => { + it('a faulting `disabled` still gets the enablement copy, not the concealment one', async () => { + // Three gate kinds now share one reporter. This pins that widening the + // union did not re-route the gate that was already correct. + await inDevelopment((mount) => { + const warn = spyWarn(); + mount([{ id: 'n1', disabled: FAULT_DISABLED_DEV }]); + expect(shownCount()).toBe(1); // greyed out, not gone + const line = enablementReports(warn)[0]; + expect(line).toBeDefined(); + expect(line).toContain('the node renders DISABLED'); + expect(line).not.toContain(CONCEALMENT_CONSEQUENCE); + expect(line).not.toContain(THE_FALSE_CLAUSE); + // And no visibility-prefixed line was emitted for it. + expect(reports(warn)).toHaveLength(0); + }); + }); +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 5d48422cf..477f8e127 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -48,6 +48,15 @@ export type { PredicateScopeTier } from './utils/visibilityDiagnostic.js'; // decides whether the message says the safe default bit or did not. Exported as // a type for the same reason as the tier above: the reporter is public, so a // caller has to be able to spell its arguments. +// +// ⚠️ THREE members since objectui#6503, not two. `'concealment'` was added for +// the `hidden` / `hiddenOn` legs, whose verdict `SchemaRenderer` does NOT +// negate — the same fail-soft `true` that shows a node on the four negated legs +// REMOVES it there, so "the gate did NOT bite" was the one sentence that could +// not be true for them. Widening a union re-exported from this entry is a +// type-level change for any consumer that switches EXHAUSTIVELY over it or +// keys a `Record` by it; no runtime signature moved, and every value that was +// accepted before still is. export type { PredicateGateKind } from './utils/visibilityDiagnostic.js'; // Write-error surfacing utilities (shared by drag-write plugins so a failed diff --git a/packages/react/src/utils/visibilityDiagnostic.ts b/packages/react/src/utils/visibilityDiagnostic.ts index f58b16091..03e71a4e8 100644 --- a/packages/react/src/utils/visibilityDiagnostic.ts +++ b/packages/react/src/utils/visibilityDiagnostic.ts @@ -188,17 +188,34 @@ const SCOPE_TIER_ADVICE: Record = { * other gate. That is the exact defect class this card and objectui#6487 both * exist to remove, so it is not worth re-introducing to save an argument. * - * ## What this type does NOT fix, stated so it is not mistaken for done - * - * The `hidden` / `hiddenOn` legs are `'visibility'` here, and their `true` is - * NOT negated - a fault there makes the node VANISH, so "the gate did NOT bite" - * is wrong for them as well. That is PRE-EXISTING shipped copy on a surface - * objectui#6487 has just landed on, outside this card's face and filed - * separately (objectui#6503). The shape of its fix is a THIRD entry in the table - * below - visibility prefix, vanishing consequence - passed by those two call - * sites; not a signature change, and deliberately not smuggled in here. + * `'concealment'` - `hidden` / `hiddenOn` (objectui#6503). These are visibility + * legs, but the only two whose verdict `SchemaRenderer` does NOT negate, so the + * SAME `true` that SHOWS a node on the four negated legs REMOVES it here. They + * resolved to `'visibility'` until objectui#6503 and were therefore handed the + * one sentence that is false about them: the gate did not merely bite, it bit + * hardest of the three, and the node is not on screen to be inspected at all. + * This is the third member objectui#6445's docblock predicted, in the shape it + * predicted - a table entry and a routing decision, no signature change. + * + * ## Why a third MEMBER, and not `'visibility'` plus a `negated` flag + * + * What varies is the consequence sentence, and it varies per GATE - not along + * an independent axis a caller could set to contradict the gate it passed + * alongside. Three flat members cannot be spelled inconsistently; a + * `(kind, negated)` pair can, and `('enablement', negated: true)` would name a + * combination no renderer in this repo produces. The table stays the one place + * that decides what a fail-soft default DID. + * + * ## What widening this union costs, stated because it is a published surface + * + * `PredicateGateKind` is re-exported from `packages/react/src/index.ts` - the + * package entry, and the chain stops there (no sibling package re-exports it, + * and no file outside `packages/react` names it). A consumer switching + * EXHAUSTIVELY over the union, or holding a `Record`, + * gains a third case to answer. That is a type-level change only: no runtime + * signature moved, and every value that was accepted before still is. */ -export type PredicateGateKind = 'visibility' | 'enablement'; +export type PredicateGateKind = 'visibility' | 'enablement' | 'concealment'; /** * The two parts of the message that vary with the gate: the opening line (what @@ -221,6 +238,23 @@ const GATE_KIND_COPY: Record Date: Wed, 26 Aug 2026 07:57:19 +0000 Subject: [PATCH 2/2] test(react): correct the reverse-verification record to what was measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prediction was written per GROUP and one case spans two: group 4's dedupe case closes on a CONCEALMENT_CONSEQUENCE assertion — the deliberate refusal to let a rate-limit case pin only a count — so it fails against the base tree as well. Measured 7 failed / 10 passed, not the 6 the docblock claimed. Corrected rather than restated. A false claim about which cases guard what is the same class of defect this file exists to fix. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- ...er.concealmentGateFaultDiagnostic.test.tsx | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx b/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx index 7a7df9d3d..5c9ba8c49 100644 --- a/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx @@ -59,13 +59,33 @@ * deliberately. Every case here pins the VERDICT beside the line, so a run made * green by flipping the gate fails this file. * - * ## Reverse verification (direction predicted BEFORE running) + * ## Reverse verification, and where the prediction was WRONG * - * Restoring `visibilityDiagnostic.ts` and `SchemaRenderer.tsx` to the pinned - * base commit turns groups 1, 3 and 5 RED — each on the CONTENT assertion, not - * on a count — and leaves groups 0, 2, 4 and 6 GREEN. That asymmetry is the - * card restated: two legs' copy moves, four legs' copy and every verdict in the - * file do not. Recorded in the PR body with the measured output. + * Predicted before running: restoring `visibilityDiagnostic.ts` and + * `SchemaRenderer.tsx` to the pinned base commit (`d7acad69d`, hash-verified + * on disk, never `origin/main` — a shared pointer another agent's fetch moves) + * turns groups 1, 3 and 5 RED and leaves 0, 2, 4 and 6 GREEN. + * + * MEASURED: 7 failed / 10 passed. Six of the seven are where predicted. The + * seventh is group 4's dedupe case, which the prediction had in the GREEN set + * and which fails on its closing `CONCEALMENT_CONSEQUENCE` assertion — the + * deliberate refusal to let a rate-limit case pin only a COUNT. The prediction + * was written per group; that one case spans two. The record is corrected here + * rather than restated, because a false claim about which cases guard what is + * the same class of defect this file exists to fix. + * + * Every RED is on a CONTENT assertion; not one is on a count or an emission. + * The base tree printed, for a node that had VANISHED: + * + * [ObjectUI] A visibility predicate could not be evaluated - node "…" + * hidden: "nosuchroot6503.locked == true" + * Reason: … nosuchroot6503 is not defined + * The node was treated as its safe default, which on this surface means the + * gate did NOT bite - … + * + * Group 2 is GREEN in BOTH states, and that is what a control for "unchanged" + * must be: it asserts the four negated legs' bytes, which neither the base tree + * nor the fixed one moves. It fails only against a fix that touched them. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';