diff --git a/.changeset/9108-props-bag-node-gate.md b/.changeset/9108-props-bag-node-gate.md new file mode 100644 index 0000000000..20ecb4ce06 --- /dev/null +++ b/.changeset/9108-props-bag-node-gate.md @@ -0,0 +1,40 @@ +--- +'@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 (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 +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 8fe4e76ba0..840aff9988 100644 --- a/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx @@ -75,8 +75,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)}` @@ -151,14 +152,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); + }); +}); 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', );