From 073aac7e5238aac5f0d46c0dd01265aefaceb615 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 10:24:21 +0000 Subject: [PATCH 1/3] fix(react): let the node gates read the legacy `props` config bag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hoist copies `properties.*` onto the node and nothing copies `props.*`, while both node gates read the post-hoist node — so a visibility or enablement predicate authored under the legacy alias was never one of the keys either gate could see. Measured at node level, four rows: `props: { visible: false }` and `props: { hidden: true }` both RENDERED, while the `properties` spelling of either hid correctly. Fail-open and silent by construction. The gates now consult the alias as a LAST resort, reusing the existing `propsWithoutCanonicalKeys` subtraction so `properties` still wins on both channels and that rule keeps its single declaration. Nothing is hoisted: the bag is read, not copied, so `schema.` is still undefined for a renderer declared as `({ schema })` and the dropped-props-bag dev warning still says exactly what it said. `winningVisibilityKey` grows the same third leg, because its docblock states that it must agree bit-for-bit with the gate about which key decides. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .changeset/9108-props-bag-node-gate.md | 39 ++++ packages/react/src/SchemaRenderer.tsx | 113 ++++++++-- ...nderer.predicateEnvelopeConfigBag.test.tsx | 23 ++- .../SchemaRenderer.propsBagNodeGate.test.tsx | 193 ++++++++++++++++++ 4 files changed, 339 insertions(+), 29 deletions(-) create mode 100644 .changeset/9108-props-bag-node-gate.md create mode 100644 packages/react/src/__tests__/SchemaRenderer.propsBagNodeGate.test.tsx diff --git a/.changeset/9108-props-bag-node-gate.md b/.changeset/9108-props-bag-node-gate.md new file mode 100644 index 0000000000..9e8837c08b --- /dev/null +++ b/.changeset/9108-props-bag-node-gate.md @@ -0,0 +1,39 @@ +--- +'@object-ui/react': minor +--- + +`SchemaRenderer`'s node gates now read the legacy `props` config bag as a last +resort, so a visibility or enablement predicate authored under that spelling is +finally honoured (objectui#9108). + +**What was wrong.** A node may spell its config bag `properties` (the spec +spelling) or `props` (the annotated legacy alias). The hoist in the evaluation +memo copies `properties.*` onto the node; nothing copies `props.*`. Both node +gates read the post-hoist node, so a predicate that arrived under the alias was +never one of the keys either gate could see. Measured at node level, four rows: +`props: { visible: false }` rendered and `props: { hidden: true }` rendered, +while `properties: { visible: false }` and `properties: { hidden: true }` each +hid correctly. Fail-**open** and silent by construction — a gate that never bit +renders exactly like a gate that said yes — so it could not be found by looking +at a page, only by counting. + +**Breaking, deliberately, and narrowly.** A node whose author wrote a falsy +visibility predicate (or a truthy `disabled`) inside a `props` bag rendered +before and is now gated. That is the whole point of the repair, and it is the +only verdict that moves: the alias is consulted **only** where the post-hoist +node holds nothing, so a key the node itself declares — or one the canonical +bag hoisted onto it — still decides exactly as before. Marked `minor` rather +than `patch` for that reason. + +**Precedence is unchanged in both directions.** `properties` still wins on both +channels (objectui#5123, maintainer ruling 2026-08-18); the subtraction that +enforces it keeps its single declaration and is reused here rather than +restated. Nothing is hoisted: `props` is still not copied onto the node, +`schema.` is still undefined for a renderer declared as `({ schema })`, +and the dropped-props-bag dev warning still says exactly what it said. + +**Migration.** A producer census over every tracked document in this repository +found **zero** authoring a predicate key inside a `props` bag, so nothing here +changes. If your own metadata authors one, it was silently doing nothing before +and now takes effect: check that the predicate says what you meant, or move the +key to `properties`, which is the spec spelling and has always worked. diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index 67a602afec..fa068cd2d1 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -538,10 +538,20 @@ const visibilityGateKind = (key: VisibilityChainKey): PredicateGateKind => function winningVisibilityKey(node: Record): VisibilityChainKey | undefined { const propertiesBag = node.properties; const hasPropertiesBag = isConfigBag(propertiesBag); - const effective = (key: string): unknown => - hasPropertiesBag && Object.prototype.hasOwnProperty.call(propertiesBag, key) - ? (propertiesBag as Record)[key] - : node[key]; + // The legacy `props` alias, as the LAST resort only — the same third leg the + // node gates grew at objectui#9108, read from the same declaration + // ({@link propsWithoutCanonicalKeys}) so the canonical bag still wins here + // exactly as it wins there. Without this leg the diagnostic and `shouldHide` + // would disagree about which key decides whenever a predicate arrived under + // the alias, and the agreement stated above is what this function is for. + const aliasBag = propsWithoutCanonicalKeys(node.props, propertiesBag); + const effective = (key: string): unknown => { + if (hasPropertiesBag && Object.prototype.hasOwnProperty.call(propertiesBag, key)) { + return (propertiesBag as Record)[key]; + } + const own = node[key]; + return own !== undefined ? own : aliasBag[key]; + }; for (const key of VISIBILITY_SHOW_KEYS) { if (effective(key) !== undefined) return key; } @@ -1382,6 +1392,61 @@ export const SchemaRenderer: ForwardRefExoticComponent< newSchema.props = newProps; } + /** + * What a PREDICATE-CHAIN key resolves to for the two node gates below, with + * the legacy `props` alias as the LAST resort (objectui#9108). + * + * ## The gap this closes + * + * A node may spell its config bag `properties` (the spec spelling) or + * `props` (the annotated legacy alias). The hoist above copies + * `properties.*` onto the node; NOTHING copies `props.*`. Both gates below + * read the post-hoist node, so a predicate that arrived under the alias was + * never one of the keys either gate could see. Measured at node level on + * `1e0e46af9`, four rows, both spellings and both polarities: + * `props: { visible: false }` RENDERED and `props: { hidden: true }` + * RENDERED, while `properties: { visible: false }` and + * `properties: { hidden: true }` each hid correctly. Fail-OPEN and silent + * by construction: a gate that never bit renders exactly like a gate that + * said yes, so nobody can find it by looking at a page. + * + * ## Why the alias is HONOURED here rather than refused + * + * The cheaper-looking repair — make a predicate under `props` refuse + * loudly — would overturn the maintainer ruling of 2026-08-18 recorded on + * {@link propsWithoutCanonicalKeys}, whose scope paragraph states that "a + * key only `props` declares is untouched (the alias keeps working)". A + * predicate authored only under `props` is exactly such a key. The producer + * census run for objectui#9108 swept every tracked document and found ZERO + * authoring a predicate key inside a `props` bag, so no document in this + * repository changes verdict in either direction; the standing ruling is + * what picks the arm, not the count. + * + * ## Why this is NOT a second hoist + * + * Nothing is written onto the node. The bag is READ, as a last resort, so + * every other statement this tree makes about the alias stays true: `props` + * is still not hoisted, `schema.` is still undefined for a renderer + * declared as `({ schema })`, and the objectui#6708 dropped-bag warning + * still says exactly what it said. A renderer sees no key it did not see + * before. + * + * ## Precedence is unchanged in BOTH directions, and declared once + * + * {@link propsWithoutCanonicalKeys} already subtracts every key the + * canonical bag declares, so `properties` still wins (objectui#5123) and + * that rule keeps its single declaration. The reader consults the alias + * only where the post-hoist node holds `undefined`, so a key the node + * itself declares — or one the canonical bag hoisted onto it — still + * decides, unchanged. The chain ORDER below is untouched. + */ + const aliasGateBag = propsWithoutCanonicalKeys(newSchema.props, newSchema.properties); + // Typed as the predicate evaluators' own parameter, which is what every + // caller below hands it — and as wide as the bare `newSchema.` read it + // replaces, since `BaseSchema`'s index signature admits anything. + const gateValue = (key: VisibilityChainKey | EnablementNodeGateKey): VisibilityPredicate => + newSchema[key] !== undefined ? newSchema[key] : aliasGateBag[key]; + // Evaluate visibility: visibleWhen / visible / visibleOn / visibility / hidden / hiddenOn const shouldHide = (() => { // `visibleWhen` is the single canonical conditional-visibility predicate @@ -1403,26 +1468,30 @@ export const SchemaRenderer: ForwardRefExoticComponent< // the one key the spec tells authors to write was the one key that could // be silently ignored. A declared node predicate now outranks a hoisted // renderer prop; when both resolve to "show", both still have to. - if (newSchema.visibleWhen !== undefined) { - return !evaluateVisibilityPredicate(newSchema.visibleWhen, 'visibleWhen'); + const visibleWhen = gateValue('visibleWhen'); + if (visibleWhen !== undefined) { + return !evaluateVisibilityPredicate(visibleWhen, 'visibleWhen'); } // `visible` — objectui's own `BaseSchema` tier (`@object-ui/types`), and // the landing spot of a hoisted `properties.visible`. Kept ABOVE the two // deprecated aliases: they normalize into `visibleWhen` at parse, so a // spec-parsed page never reaches them, and re-ranking them would move // verdicts for raw metadata that objectui#5454 did not rule on. - if (newSchema.visible !== undefined) { - return !evaluateVisibilityPredicate(newSchema.visible, 'visible'); + const visible = gateValue('visible'); + if (visible !== undefined) { + return !evaluateVisibilityPredicate(visible, 'visible'); } // @deprecated ADR-0089 → `visibleWhen`. Defensive read for raw / // un-normalized metadata reaching the renderer. - if (newSchema.visibleOn !== undefined) { - return !evaluateVisibilityPredicate(newSchema.visibleOn, 'visibleOn'); + const visibleOn = gateValue('visibleOn'); + if (visibleOn !== undefined) { + return !evaluateVisibilityPredicate(visibleOn, 'visibleOn'); } // @deprecated ADR-0089 → `visibleWhen` (was PageNodeSchema.visibility, // an ExpressionInput) — show-when-truthy, same semantics as `visibleOn`. - if (newSchema.visibility !== undefined) { - return !evaluateVisibilityPredicate(newSchema.visibility, 'visibility'); + const visibility = gateValue('visibility'); + if (visibility !== undefined) { + return !evaluateVisibilityPredicate(visibility, 'visibility'); } // Ask "is a `hidden` gate DECLARED?" — not "is the key present?" // (objectui#3955). These two legs are the only ones in this chain whose @@ -1442,11 +1511,13 @@ export const SchemaRenderer: ForwardRefExoticComponent< // the RAW value; only the gate in front of it narrowed. Not an // equivalence, and pinned as a behaviour change: an UNDECLARED `hidden` no // longer short-circuits, so a declared `hiddenOn` is finally consulted. - if (hasDeclaredPredicate(newSchema.hidden)) { - return evaluateVisibilityPredicate(newSchema.hidden, 'hidden'); + const hidden = gateValue('hidden'); + if (hasDeclaredPredicate(hidden)) { + return evaluateVisibilityPredicate(hidden, 'hidden'); } - if (hasDeclaredPredicate(newSchema.hiddenOn)) { - return evaluateVisibilityPredicate(newSchema.hiddenOn, 'hiddenOn'); + const hiddenOn = gateValue('hiddenOn'); + if (hasDeclaredPredicate(hiddenOn)) { + return evaluateVisibilityPredicate(hiddenOn, 'hiddenOn'); } return false; })(); @@ -1495,11 +1566,13 @@ export const SchemaRenderer: ForwardRefExoticComponent< // earlier, which is what keeps the objectui#3862 empty-shape rows silent // as well as enabled. const isDisabled = (() => { - if (hasDeclaredPredicate(newSchema.disabled)) { - return evaluateEnablementPredicate(newSchema.disabled, 'disabled'); + const disabled = gateValue('disabled'); + if (hasDeclaredPredicate(disabled)) { + return evaluateEnablementPredicate(disabled, 'disabled'); } - if (hasDeclaredPredicate(newSchema.disabledOn)) { - return evaluateEnablementPredicate(newSchema.disabledOn, 'disabledOn'); + const disabledOn = gateValue('disabledOn'); + if (hasDeclaredPredicate(disabledOn)) { + return evaluateEnablementPredicate(disabledOn, 'disabledOn'); } return false; })(); diff --git a/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx index 58896ba1f1..40e6f8ce1b 100644 --- a/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx @@ -62,8 +62,9 @@ const Probe = (props: { schema?: { props?: Record } }) => ( data-testid="probe" // What the `props`-bag value still IS by the time a renderer reads it. // `@object-ui/components`' `readProps` merges `{ ...schema.props, - // ...schema.properties }`, so this bag is a real consumer surface even - // though the node gate never consults it (see the `props` group below). + // ...schema.properties }`, so this bag is a real consumer surface — and + // since objectui#9108 the node gate consults it as a last resort too, + // which is a verdict rather than a value and is pinned in its own file. data-props-visible-kind={ props.schema?.props?.visible && typeof props.schema.props.visible === 'object' ? `envelope:${String((props.schema.props.visible as { dialect?: unknown }).dialect)}` @@ -138,14 +139,18 @@ describe('#9100 — a CEL envelope in the config bag reaches the CEL engine', () * to: objectui#5123 ruled "one answer per key, whichever channel reads it", * and `@object-ui/components`' `readProps` merges `{ ...schema.props, * ...schema.properties }`, so a renderer really can read a predicate from - * this bag. What it CANNOT do is drive the node gate — the hoist copies - * `properties` onto the node and nothing copies `props` — so the assertion - * here is on the value a renderer receives, not on a verdict. + * this bag. The assertion HERE is on the value such a renderer receives, not + * on a verdict — this file is about the envelope surviving the config-bag + * channel, and that is a different question from which bag the gate reads. * - * ⚠️ That gap is PRE-EXISTING and independent of this card: measured on the - * same tree, a plain `props: { visible: false }` renders and a plain - * `props: { hidden: true }` renders too, while the `properties` spelling of - * either decides correctly. Filed separately; ⛔ not repaired here. + * ⚠️ When this was written the alias could not drive the node gate at all — + * the hoist copies `properties` onto the node and nothing copies `props` — + * and this docblock recorded that gap as PRE-EXISTING and filed separately. + * objectui#9108 closed it: the gate now consults the alias as a last resort, + * with `properties` still winning. ⛔ Still nothing is hoisted, so the value + * this test reads off the bag is unchanged and this assertion is unaffected. + * The four-row verdict table lives in + * `SchemaRenderer.propsBagNodeGate.test.tsx`, not here. */ it('props.visible keeps its envelope for the renderer that reads that bag', () => { mount({ type: 'probe-9100', props: { visible: HOLDS } }); diff --git a/packages/react/src/__tests__/SchemaRenderer.propsBagNodeGate.test.tsx b/packages/react/src/__tests__/SchemaRenderer.propsBagNodeGate.test.tsx new file mode 100644 index 0000000000..364bce95c0 --- /dev/null +++ b/packages/react/src/__tests__/SchemaRenderer.propsBagNodeGate.test.tsx @@ -0,0 +1,193 @@ +/** + * 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#9108 - the `props` spelling of a node's config bag must reach the + * node gates, and `properties` must still win. + * + * ## What was measured, and why it could not be seen + * + * A node may spell its config bag `properties` (the spec spelling) or `props` + * (the annotated legacy alias). The hoist in `SchemaRenderer`'s evaluation memo + * copies `properties.*` onto the node; nothing copies `props.*`. Both node + * gates read the post-hoist node, so a predicate authored under the alias was + * never one of the keys either gate could see: + * + * | authored | before objectui#9108 | + * |--------------------------------|----------------------| + * | `props: { visible: false }` | RENDERED | + * | `props: { hidden: true }` | RENDERED | + * | `properties: { visible: false }` | correctly hidden | + * | `properties: { hidden: true }` | correctly hidden | + * + * Fail-OPEN and silent: a gate that never bit renders exactly like a gate that + * said yes, so no user and no screenshot can find it. + * + * ## Why the two `properties` rows are in THIS file and not another + * + * They are the live control, and a control in another file is not a control: + * "the node is hidden" is equally satisfied by a renderer that hides + * everything, by a broken registry, and by a probe that never mounted. Every + * row below therefore runs through the SAME harness in the SAME suite, and + * every row is measured in BOTH directions - a truthy predicate and a falsy + * one. A pair of EQUAL verdicts is the signature of a gate that was never + * consulted, whichever way it landed, and that pair is exactly what the broken + * tree produced on the `props` rows. + * + * ## Plain booleans on purpose + * + * objectui#9100 and objectui#9107 are about a CEL envelope being flattened on + * the way to the engine. This defect is present with a plain boolean and no + * expression anywhere, and it predates both repairs, so nothing here carries an + * envelope: an envelope would make a failure ambiguous between the two causes. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer } from '../SchemaRenderer'; +import { SchemaRendererContext } from '../context/SchemaRendererContext'; + +const TYPE = 'probe-9108'; + +/** + * Reports three things a `schema`-reading renderer can see, so one probe can + * answer both "did the gate bite?" and "was anything hoisted?". + */ +const Probe = (props: { schema?: Record; disabled?: unknown }) => ( +
+); + +function mount(schema: unknown) { + return render( + + + , + ); +} + +const rendered = (): boolean => screen.queryByTestId('probe') !== null; + +/** One authored bag, mounted twice - predicate `true` then `false`. */ +function pair(bag: 'props' | 'properties', key: string): { truthy: boolean; falsy: boolean } { + const once = (value: boolean): boolean => { + mount({ type: TYPE, [bag]: { [key]: value } }); + const r = rendered(); + cleanup(); + return r; + }; + return { truthy: once(true), falsy: once(false) }; +} + +describe('objectui#9108 - a predicate in the `props` bag reaches the node gate', () => { + beforeEach(() => { + ComponentRegistry.register(TYPE, Probe as never); + }); + afterEach(() => { + cleanup(); + ComponentRegistry.unregister?.(TYPE); + }); + + // SHOW polarity: `true` renders, `false` hides. Both spellings, one table - + // the `properties` rows are the control and they were already correct. + it.each([ + ['props', 'visible'], + ['properties', 'visible'], + ['props', 'visibleWhen'], + ['properties', 'visibleWhen'], + ] as const)('%s.%s (SHOW polarity): truthy renders, falsy hides', (bag, key) => { + expect(pair(bag, key)).toEqual({ truthy: true, falsy: false }); + }); + + // HIDE polarity: `true` hides, `false` renders - the opposite direction, and + // the reason a one-polarity suite would have passed on half the defect. + it.each([ + ['props', 'hidden'], + ['properties', 'hidden'], + ['props', 'hiddenOn'], + ['properties', 'hiddenOn'], + ] as const)('%s.%s (HIDE polarity): truthy hides, falsy renders', (bag, key) => { + expect(pair(bag, key)).toEqual({ truthy: false, falsy: true }); + }); + + // The enablement chain is the same hoist gap with a quieter symptom: a greyed + // control is still on screen. `disabled` under `props` reached the element as + // a React prop and was then overwritten by the gate's own `disabled`, which + // was `undefined` because the gate never saw the key. + it.each([ + ['props'], + ['properties'], + ] as const)('%s.disabled drives the enablement gate, both directions', (bag) => { + mount({ type: TYPE, [bag]: { disabled: true } }); + expect(screen.getByTestId('probe').getAttribute('data-disabled')).toBe('true'); + cleanup(); + mount({ type: TYPE, [bag]: { disabled: false } }); + expect(screen.getByTestId('probe').getAttribute('data-disabled')).toBe('false'); + }); +}); + +describe('objectui#9108 - what the repair deliberately does NOT change', () => { + beforeEach(() => { + ComponentRegistry.register(TYPE, Probe as never); + }); + afterEach(() => { + cleanup(); + ComponentRegistry.unregister?.(TYPE); + }); + + // objectui#5123, maintainer ruling 2026-08-18: `properties` wins on BOTH + // channels. The alias is consulted only where the post-hoist node holds + // nothing, so a key BOTH bags declare is still answered by `properties` - + // measured in both directions so a "hides everything" renderer cannot pass. + it('`properties` still wins when both bags declare the same key', () => { + mount({ type: TYPE, props: { visible: true }, properties: { visible: false } }); + expect(rendered()).toBe(false); + cleanup(); + mount({ type: TYPE, props: { visible: false }, properties: { visible: true } }); + expect(rendered()).toBe(true); + }); + + // A key the node itself declares at top level is not the alias's to answer. + it('a node-level key still outranks the alias', () => { + mount({ type: TYPE, visible: false, props: { visible: true } }); + expect(rendered()).toBe(false); + cleanup(); + mount({ type: TYPE, visible: true, props: { visible: false } }); + expect(rendered()).toBe(true); + }); + + // The fence this repair was written to respect: the gate READS the bag, it + // does not hoist it. If this row ever flips, `props` has become a mirror of + // `properties` and the objectui#6708 dropped-bag warning has started lying. + it('nothing is hoisted - `schema.` stays undefined for the renderer', () => { + mount({ type: TYPE, props: { visible: true } }); + expect(screen.getByTestId('probe').getAttribute('data-node-visible')).toBe('undefined'); + cleanup(); + // The canonical spelling IS hoisted, as it always was - the control that + // proves the assertion above is reading a real attribute. + mount({ type: TYPE, properties: { visible: true } }); + expect(screen.getByTestId('probe').getAttribute('data-node-visible')).toBe('true'); + }); + + // objectui#6752 / objectui#6760: a degenerate bag declares no key for either + // spelling, and it must not have its shape reinterpreted here either. + it('a degenerate `props` gates nothing', () => { + mount({ type: TYPE, props: 'not-a-bag' }); + expect(rendered()).toBe(true); + }); +}); From eb31f5caf4c7be6781c6600113ce9cbae80fca3e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 03:59:14 +0000 Subject: [PATCH 2/3] fix(scripts): derive the condition keys from the literal each call site declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deriveChannels` read the eight condition keys off the EXPRESSION each predicate call site passed first — `evaluateVisibilityPredicate(newSchema.hidden, 'hidden')`. This branch moved exactly that expression: a predicate value now resolves through a reader that consults the legacy `props` bag as a last resort, so the sites read `evaluateVisibilityPredicate(hidden, 'hidden')` and the derivation matched nothing. It refused rather than censusing against a universe that had silently shrunk to zero, which took `Test (shard 2/4)` and `Build Docs` red on the same assertion. Measured, which is what decides whose red this is: the old pattern finds all eight keys on `origin/main` without this branch and zero with it, so the shapes moved HERE and teaching the derivation is the repair the refusal asks for. The anchor moves to the key literal the call site declares — its second argument. That is the key's own identity rather than one incidental way of fetching its value: it is what the renderer types as `VisibilityChainKey` / `EnablementNodeGateKey` and what it reports in its diagnostics. It is a strict generalization, not a different answer — the same eight keys on `origin/main`, where the old pattern also found eight. Still tight, and deliberately not widened to "match anything", which would be the silent shrink's mirror image: a second argument that is not a single-quoted identifier still does not count, so the diagnostic leg's computed `winningKey` stays out of the universe exactly as before, and the first argument stays fenced off parentheses, commas and newlines so a match cannot leap between calls. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../check-doc-expression-carriage.test.ts | 4 +- scripts/check-doc-expression-carriage.mjs | 40 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/scripts/__tests__/check-doc-expression-carriage.test.ts b/scripts/__tests__/check-doc-expression-carriage.test.ts index 75431269c2..569d1478f6 100644 --- a/scripts/__tests__/check-doc-expression-carriage.test.ts +++ b/scripts/__tests__/check-doc-expression-carriage.test.ts @@ -64,7 +64,9 @@ describe('check-doc-expression-carriage: the evaluated-channel universe is deriv /** * The enumeration and the reading come from DIFFERENT places on purpose * (AGENTS.md §9's rule for exactly this): the gate derives the condition keys - * from the `evaluate*Predicate(newSchema., …)` CALL SITES, and this test + * from the KEY LITERAL each `evaluate*Predicate(VALUE, 'KEY')` CALL SITE + * declares — objectui#9108 moved VALUE, which is why the anchor is the literal + * and not the expression — and this test * checks that answer against the two `const` ARRAYS the same file declares for * the visibility chain. A single source read twice proves nothing. */ diff --git a/scripts/check-doc-expression-carriage.mjs b/scripts/check-doc-expression-carriage.mjs index 124e36311c..9ecb39f6d7 100644 --- a/scripts/check-doc-expression-carriage.mjs +++ b/scripts/check-doc-expression-carriage.mjs @@ -81,8 +81,11 @@ * own call sites — `evaluator.evaluate(newSchema.)` (the * `content` leg), `isConfigBag(newSchema.)` (the `properties` * and `props` bags), and - * `evaluate{Visibility,Enablement}Predicate(newSchema., …)` - * (the eight condition keys). Read from `run`-shaped call sites, + * `evaluate{Visibility,Enablement}Predicate(VALUE, 'KEY')` + * (the eight condition keys, read off the declared KEY literal + * rather than off VALUE, which objectui#9108 moved — see the + * pattern's own note in `deriveChannels`). Read from `run`-shaped + * call sites, * never from the surrounding comments: that file's prose names * `visibleOn`, `disabled` and the bags many times over, and a scan * of the raw text would be describing its own docblocks. @@ -291,8 +294,39 @@ export function deriveChannels(root = repoRoot) { const direct = collect(/evaluator\.evaluate\(newSchema\.([A-Za-z_$][\w$]*)\)/g, 'direct-evaluate'); const bags = collect(/isConfigBag\(newSchema\.([A-Za-z_$][\w$]*)\)/g, 'config-bag'); + // Anchored on the key literal each call site DECLARES — its SECOND argument — + // not on whatever expression its first argument happens to be spelled with. + // + // objectui#9108 moved that first argument and nothing else: a predicate value + // now resolves through a reader that consults the legacy `props` bag as a last + // resort, so the eight sites read `evaluateVisibilityPredicate(hidden, + // 'hidden')` where they used to read `…(newSchema.hidden, 'hidden')`. A + // derivation anchored on `newSchema.` therefore matched NOTHING the day that + // landed — the refusal above fired, correctly, and this is the "teach it the + // new shape" it asks for. + // + // The literal is the stabler anchor because it is the key's own identity, not + // one incidental way of fetching its value: it is what the renderer types as + // `VisibilityChainKey` / `EnablementNodeGateKey` and what it reports in its + // diagnostics, so it cannot drift from the key without the gate's meaning + // drifting with it. Where the old anchor tracked the plumbing, this one tracks + // the contract. + // + // ⛔ Still TIGHT, not "match anything" — that would be the silent shrink's + // mirror image, a universe that grows on noise. A call whose second argument + // is not a single-quoted identifier does not count, which is exactly what + // keeps the diagnostic leg's computed + // `evaluateVisibilityPredicate(rawWinningValue as VisibilityPredicate, winningKey)` + // out of the universe — as it was before, since it never had a `newSchema.` + // first argument either. The first argument stays fenced off `(` `)` `,` and + // newlines so a match cannot leap out of one call into the next. + // + // Measured across the change rather than asserted: the same eight keys on + // `origin/main` (where the old pattern also found eight — so this is a strict + // generalization, not a different answer) and on the objectui#9108 head (where + // the old pattern found zero). const conditions = collect( - /evaluate(?:Visibility|Enablement)Predicate\(newSchema\.([A-Za-z_$][\w$]*)\s*,/g, + /evaluate(?:Visibility|Enablement)Predicate\([ \t]*[^,()\n]+,[ \t]*'([A-Za-z_$][\w$]*)'[ \t]*\)/g, 'condition-predicate', ); From b601fb4b547cd7895207cfb5151c23bebf6a3b84 Mon Sep 17 00:00:00 2001 From: os-tesla Date: Sun, 13 Sep 2026 04:52:33 +0000 Subject: [PATCH 3/3] docs(changeset): lead the 9108 note with the repo's BREAKING carrier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note already said "Breaking, deliberately, and narrowly" and explained the scope exactly. It said it in a spelling nothing scans: `grep -c BREAKING` over the file returned 0, because the word was written with only its first letter capitalised and sat four paragraphs in. `.changeset/7742-kanban-arm-batch70.md` is the convention — a leading `**BREAKING (scored minor per this repo's version-alignment convention)**` — and `.changeset/7804-object-kanban-handler-keys-judged.md` was corrected to it in the same round for the same reason. Prose only: the grade, the scope and every measurement are unchanged. Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .changeset/9108-props-bag-node-gate.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/9108-props-bag-node-gate.md b/.changeset/9108-props-bag-node-gate.md index 9e8837c08b..20ecb4ce06 100644 --- a/.changeset/9108-props-bag-node-gate.md +++ b/.changeset/9108-props-bag-node-gate.md @@ -17,7 +17,8 @@ hid correctly. Fail-**open** and silent by construction — a gate that never bi renders exactly like a gate that said yes — so it could not be found by looking at a page, only by counting. -**Breaking, deliberately, and narrowly.** A node whose author wrote a falsy +**BREAKING (scored `minor` per this repo's version-alignment convention)** — deliberately, +and narrowly. A node whose author wrote a falsy visibility predicate (or a truthy `disabled`) inside a `props` bag rendered before and is now gated. That is the whole point of the repair, and it is the only verdict that moves: the alias is consulted **only** where the post-hoist