From e026506d0b0f7d960c88d77b53e0ac49d8882a1b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 15:05:13 +0000 Subject: [PATCH 1/2] feat(app-shell): warn at typing time on a record-scope `data.*` predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@objectstack/formula`'s `SCOPE_ROOTS` carries `data`, so at `scope: 'record'` the authoring lint accepts `data.status == 'x'` with zero findings — measured on `@objectstack/formula@17.4.0`. objectui#5741 retired that spelling on runtime record surfaces and objectui#8166 stopped this tier binding an ambient `data`, so the predicate now faults at runtime with `Unknown variable: data` while the author still sees a green lint. The diagnostic arrives at misbehaviour time instead of at typing time. Wire `@object-ui/core`'s already-exported `detectNonCanonicalRowSpelling` into `celAuthoring.lintCelPredicate` as a WARNING, gated on `scope === 'record'`. - WARNING, never error: every save gate on this tier counts `severity === 'error'`, so the accepted set is unchanged. Narrowing the accept set stays the producer-side half in `@objectstack/formula`. - Only the `metadata-layer-root` arm is consulted (`row = null` disables the bare-shorthand arm): at record scope the engine already errors on a bare identifier, and at flattened scope a bare identifier is the CORRECT RLS spelling — all three genuine RLS predicates in this repo fire that arm. - Loaded lazily, feature-detected and error-swallowing, mirroring the engine loader, so the CEL parser stays out of the eager bundle. Not a re-run of the warning objectui#5741 deleted: that removed the runtime warning from the hot path and kept the export "as the offline instrument". This is the editor bridge classifying authored text, not a render. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../8972-cel-authoring-data-root-advisory.md | 19 +++ .../ConditionalFormattingEditor.test.tsx | 36 ++++-- .../views/metadata-admin/celAuthoring.test.ts | 59 +++++++++ .../src/views/metadata-admin/celAuthoring.ts | 120 ++++++++++++++++++ 4 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 .changeset/8972-cel-authoring-data-root-advisory.md diff --git a/.changeset/8972-cel-authoring-data-root-advisory.md b/.changeset/8972-cel-authoring-data-root-advisory.md new file mode 100644 index 0000000000..0516466ad9 --- /dev/null +++ b/.changeset/8972-cel-authoring-data-root-advisory.md @@ -0,0 +1,19 @@ +--- +'@object-ui/app-shell': minor +--- + +Warn at typing time when a record-scope CEL predicate is rooted on `data`. + +`@objectstack/formula`'s scope vocabulary still accepts `data`, so a +`visibleWhen` / `readonlyWhen` / `requiredWhen`, a formula `expression` or a +conditional-formatting `condition` written as `data.status == 'x'` used to lint +green and then fault at runtime with `Unknown variable: data`, because the row +is bound as `record.*` and nothing else (objectui#5741, objectui#8166). The +metadata editors now surface `@object-ui/core`'s `detectNonCanonicalRowSpelling` +as an inline warning naming `record` as the fix. + +The accepted set is unchanged: this is advisory only, so it does not block save +and does not mark the field invalid. It applies to `scope: 'record'` authoring +sites only — flattened RLS `USING` / `CHECK` predicates, where a bare field +reference is the correct spelling, are untouched, as is the metadata-editing +layer where `data` is canonical. diff --git a/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.test.tsx b/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.test.tsx index a16e83f6ba..da636f36e4 100644 --- a/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.test.tsx @@ -177,23 +177,33 @@ describe('ConditionalFormattingEditor · CEL authoring scope (#2571 follow-up)', expect(await screen.findByText('perm.cel.valid', {}, { timeout: 3000 })).toBeTruthy(); }); - it('KNOWN GAP — a `data.*` condition still lints CLEAN although the row is not bound under it', async () => { - // NOT desired behaviour, and it is the half of the retirement this card - // does NOT close. Dropping `'data'` from ROW_PREDICATE_ROOTS stops - // RECOMMENDING it; it does not stop the lint ACCEPTING it, because - // `@objectstack/formula`'s `SCOPE_ROOTS` lists `data` and so the - // record-scope bare-reference check waves it through. `rowPredicateCanon.ts` - // already records exactly this for the server oracle: `data.status` is - // "⚠️ silently accepted" while the runtime faults on it. + it('a `data.*` condition is ACCEPTED but no longer SILENT — the author gets a warning (objectui#8972)', async () => { + // This pin used to assert an unbroken silence, and said it would redden + // "when the acceptance is fixed". Read that literally: the acceptance is + // NOT fixed here. `@objectstack/formula`'s `SCOPE_ROOTS` still lists + // `data`, so the engine still waves this through with zero findings, and + // narrowing that set remains the producer-side half (objectui#8166's + // ruling). What objectui#8972 changed is the other half of the defect — + // the ABSENCE of any diagnostic — by wiring `@object-ui/core`'s + // `detectNonCanonicalRowSpelling` into `celAuthoring` as a WARNING. // - // The runtime half is pinned in the contract suite below, where the same - // predicate against the same host bag evaluates to FALSE. Green here plus - // false there IS the defect. This test REDDENS when the acceptance is - // fixed, at which point objectui#8166 can be closed. + // So the two halves are now asserted separately, and the split is the + // point: the accept set is untouched (no error, `aria-invalid` unset, the + // editor's own error count unchanged, save open), while the author is told + // at typing time instead of at misbehaviour time. The runtime half is + // pinned in the contract suite below, where the same predicate against the + // same host bag evaluates to FALSE. render(); - expect(await screen.findByText('perm.cel.valid', {}, { timeout: 3000 })).toBeTruthy(); + expect( + await screen.findByText(/Re-root the reference on/, {}, { timeout: 3000 }), + ).toBeTruthy(); + // ACCEPT SET UNCHANGED — the falsifiable half. Promoting the advisory to + // an error reddens both of these. const ta = document.getElementById('cf-condition-0') as HTMLTextAreaElement; expect(ta.getAttribute('aria-invalid')).not.toBe('true'); + // `border-destructive` is applied from the same `errors.length > 0` the + // Save gate counts, so its absence is the editor's own "no blocking issue". + expect(ta.className).not.toMatch(/border-destructive/); }); it('ALIGNED (objectui#8155) — `app` is neither advertised nor bound, and the lint refuses it', async () => { diff --git a/packages/app-shell/src/views/metadata-admin/celAuthoring.test.ts b/packages/app-shell/src/views/metadata-admin/celAuthoring.test.ts index 78b64db4b4..1064f57a73 100644 --- a/packages/app-shell/src/views/metadata-admin/celAuthoring.test.ts +++ b/packages/app-shell/src/views/metadata-admin/celAuthoring.test.ts @@ -83,6 +83,65 @@ describe('celAuthoring · lintCelPredicate in record scope (field conditional ru }); }); +/** + * objectui#8972 — the wrong-layer `data.*` advisory. + * + * The engine's own `SCOPE_ROOTS` carries `data`, so every assertion in here + * that a finding EXISTS is an assertion about this module, not about + * `@objectstack/formula`: measured on `@objectstack/formula@17.4.0`, + * `validateExpression('predicate', "data.status == 'x'", { scope: 'record' })` + * answers `{ ok: true, errors: [], warnings: [] }`. + * + * Three of the five pins below are LIVE CONTROLS rather than true-positive + * pins, and they are the reason this is a WARNING and not an error. Each one + * describes a world the advisory must NOT create, so each one stays green + * across both legs of the ablation. + */ +describe('celAuthoring · the wrong-layer `data.*` advisory (objectui#8972)', () => { + const RULE_HINT = { ...HINT, scope: 'record' as const }; + + it('TRUE POSITIVE — a record-scope `data.*` predicate now warns, naming `record` as the fix', async () => { + const issues = await lintCelPredicate("data.status == 'x'", RULE_HINT); + const advisory = issues.filter((i) => /\bdata\b/.test(i.message) && /Re-root/.test(i.message)); + expect(advisory).toHaveLength(1); + expect(advisory[0].severity).toBe('warning'); + expect(advisory[0].message).toMatch(/`record`/); + }); + + it('LIVE CONTROL — the ACCEPT SET is not narrowed: the same predicate raises no error', async () => { + // Every save gate on this tier counts `severity === 'error'` and nothing + // else, so "zero errors" IS "still accepted". This is the falsifiable form + // of WARN-not-REFUSE: promote the advisory to `error` and this reddens. + const issues = await lintCelPredicate("data.status == 'x'", RULE_HINT); + expect(issues.filter((i) => i.severity === 'error')).toEqual([]); + expect(issues.some((i) => i.severity === 'warning')).toBe(true); + }); + + it('LIVE CONTROL — the canonical spelling stays completely clean', async () => { + // `rowPredicateCanon.test.ts` pins the detector returning null for this; + // this pins that the wiring does not turn it into a finding anyway. + expect(await lintCelPredicate("record.status == 'x'", RULE_HINT)).toEqual([]); + }); + + it('LIVE CONTROL — a FLATTENED (RLS) predicate is untouched, bare identifiers included', async () => { + // The detector's other arm would fire on all three genuine RLS predicates + // in this repo. The advisory is gated on `scope: 'record'` and passes + // `row = null`, so neither arm can reach this tier. + expect(await lintCelPredicate('organization_id == current_user.organization_id', HINT)).toEqual([]); + expect(await lintCelPredicate("data.status == 'x'", HINT)).toEqual([]); + }); + + it('adds nothing on top of a parse error, and stands down on a non-CEL dialect', async () => { + const broken = await lintCelPredicate('data.status ==', RULE_HINT); + expect(broken.some((i) => i.severity === 'error')).toBe(true); + expect(broken.some((i) => /Re-root/.test(i.message))).toBe(false); + // A legacy `${…}` string is not CEL; the detector stands down and so must + // this — classifying it belongs to its own dialect's rules. + const legacy = await lintCelPredicate('${data.status}', RULE_HINT); + expect(legacy.some((i) => /Re-root/.test(i.message))).toBe(false); + }); +}); + describe('celAuthoring · lintCelPredicate role "value" (formula expressions, #1582 follow-up)', () => { const FORMULA_HINT = { ...HINT, scope: 'record' as const, role: 'value' as const }; diff --git a/packages/app-shell/src/views/metadata-admin/celAuthoring.ts b/packages/app-shell/src/views/metadata-admin/celAuthoring.ts index 1df376e775..94d8e4d6ac 100644 --- a/packages/app-shell/src/views/metadata-admin/celAuthoring.ts +++ b/packages/app-shell/src/views/metadata-admin/celAuthoring.ts @@ -181,6 +181,110 @@ const PUSHDOWN_FIELD_ROOTS = ['record', ''] as const; /** Roots resolved as scope VALUES for pushdown analysis. */ const PUSHDOWN_VARIABLE_ROOTS = ['current_user', 'user'] as const; +/* ── Lazy row-canon detector (objectui#8972) ─────────────────────────── */ + +/** The shape of `@object-ui/core`'s offline row-spelling instrument. */ +interface RowCanonModule { + detectNonCanonicalRowSpelling?: ( + source: string, + row: Record | null | undefined, + dataNamesRow: boolean, + ) => { kind: string; identifier: string; canonical: string } | null; +} + +let rowCanonCached: Promise | null = null; + +/** + * Load `@object-ui/core`'s row-spelling detector the same way the engine is + * loaded: lazily, feature-detected, swallowing every failure. The detector + * imports `@objectstack/formula` at module scope, so a STATIC import here + * would drag the CEL parser into whatever chunk holds this module and undo the + * bundle split the header describes. + */ +function loadRowCanon(): Promise { + if (!rowCanonCached) { + rowCanonCached = import('@object-ui/core') + .then((m) => m as unknown as RowCanonModule) + .catch(() => null); + } + return rowCanonCached; +} + +/** + * The wrong-layer `data.*` advisory — why it is wired HERE, and why it is only + * an advisory (objectui#8972). + * + * ## What it closes + * + * `@objectstack/formula`'s `SCOPE_ROOTS` carries `data`, so at `scope: 'record'` + * the engine lint ACCEPTS `data.status == 'x'` with zero findings (measured on + * `@objectstack/formula@17.4.0`). objectui#5741 retired that spelling on runtime + * record surfaces and objectui#8166 stopped this tier binding an ambient `data`, + * so the predicate now faults at runtime with `Unknown variable: data` — but the + * author still gets a GREEN lint while typing it. That gap is the whole card: + * the diagnostic arrives at misbehaviour time instead of at typing time. + * + * ## Why this is not a re-run of the warning objectui#5741 deleted + * + * That ruling removed the Phase-1 warning from the runtime hot path and kept + * the export, in its own words, "as the offline instrument". `listConditional.ts` + * records the same split: the detector "stays exported for OFFLINE sweeps of + * authored metadata, not for this hot path". This call site is neither the hot + * path nor a render — it is the editor bridge, classifying authored text as the + * author types it, which is the sweep case one document at a time. Nothing is + * added back to `evalRowPredicate` / `listConditional.ts`. + * + * ## Why WARNING and never ERROR + * + * An `error` here would narrow the accepted set: every save gate on this tier + * counts `severity === 'error'` (`ConditionalFormattingEditor`, + * `ObjectFieldInspector`, `ConditionBuilder`, `PermissionAdvancedFacets`, + * `clientValidation.validateObjectFieldRules`), so promoting this would refuse + * predicates already stored in customer metadata. A `warning` leaves + * `aria-invalid` unset and every gate open. Narrowing the ACCEPT SET is the + * producer-side half and lives in `@objectstack/formula` (objectui#8166's + * ruling); adding an advisory the engine never had is not that change. + * + * ## Why only ONE of the detector's two arms is consulted + * + * The detector also reports the bare shorthand, and that arm is deliberately + * disabled here by passing `row = null` (the arm requires an own key of the + * row). Measured, both directions: + * + * - at `scope: 'record'` the engine ALREADY errors on a bare identifier and + * names the `record.` fix, so the arm can only duplicate it; + * - at `scope: 'flattened'` — RLS `USING` / `CHECK` — a bare identifier is the + * CORRECT spelling (`organization_id == current_user.organization_id` is the + * editor's own placeholder). All three genuine RLS predicates in this repo + * fire that arm, i.e. it is a 100% false positive rate on that tier. + * + * `dataNamesRow` is passed `true` because what that guard actually decides — in + * its own words, "a surface whose `data` is the host scope's own … is a + * legitimate `data.*` site and is left alone" — is whether `data` is legitimate + * here. On a record-scope authoring site it is not: `ROW_PREDICATE_ROOTS`, + * `FIELD_RULE_ROOTS` and `FORMULA_ROOTS` all exclude it and `buildExpressionScope` + * binds nothing under it. The metadata-editing layer where `data` IS canonical + * (ADR-0089 D3) is `views/metadata-admin/SchemaForm.tsx`, which evaluates through + * `views/metadata-admin/predicate.ts` and never reaches this function — which is + * why the gate below is `scope === 'record'` and not a source pattern. + */ +function rowCanonAdvisory(finding: { + kind: string; + identifier: string; + canonical: string; +}): CelLintIssue | null { + if (finding.kind !== 'metadata-layer-root') return null; + return { + severity: 'warning', + message: + `\`${finding.identifier}\` is not the row on this surface: a row predicate binds the ` + + `record as \`${finding.canonical}\` and nothing else (objectui#5741). The CEL scope ` + + `vocabulary still accepts \`${finding.identifier}\`, so nothing here blocks the save, but ` + + `at runtime the expression faults with \`Unknown variable: ${finding.identifier}\` and the ` + + `rule never fires. Re-root the reference on \`${finding.canonical}\`.`, + }; +} + /* ── 1. Lint ─────────────────────────────────────────────────────────── */ /** @@ -198,6 +302,10 @@ const PUSHDOWN_VARIABLE_ROOTS = ['current_user', 'user'] as const; * expression (usually paired with `scope: 'record'`, where a bare field ref IS * a hard error — it silently evaluates to null at runtime). * + * At `scope: 'record'` one finding comes from outside the engine: the + * wrong-layer `data.*` advisory described on {@link rowCanonAdvisory}. It is + * always a `warning`, so it never narrows what this surface accepts. + * * Empty input is always clean. */ export async function lintCelPredicate( @@ -244,6 +352,18 @@ export async function lintCelPredicate( /* advisory only — never let it break the lint */ } } + // Wrong-layer `data.*` advisory (objectui#8972) — see `rowCanonAdvisory`. + // Only in `record` scope, only once the predicate parses, only a WARNING. + if (issues.every((i) => i.severity !== 'error') && hint.scope === 'record') { + try { + const canon = await loadRowCanon(); + const finding = canon?.detectNonCanonicalRowSpelling?.(source, null, true); + const advisory = finding ? rowCanonAdvisory(finding) : null; + if (advisory) issues.push(advisory); + } catch { + /* advisory only — never let it break the lint */ + } + } return issues; } catch { return []; From 9222a0f7b72ee459edf68b7542fa7b99f7eaca84 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 15:07:24 +0000 Subject: [PATCH 2/2] test(app-shell): make the accept-set control green on BOTH ablation legs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live control must describe a world the change does not create, so it has to survive the ablation. Asserting the warning's presence inside the accept-set control made it a second true-positive pin: it went red with the wiring removed, for a reason that has nothing to do with the claim it exists to make. It now asserts only that the predicate raises no `error` — true with and without the advisory — so it reddens on exactly one change: promoting the advisory to `error`, which is the narrowing this card must not do. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../src/views/metadata-admin/celAuthoring.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/celAuthoring.test.ts b/packages/app-shell/src/views/metadata-admin/celAuthoring.test.ts index 1064f57a73..dbfc210822 100644 --- a/packages/app-shell/src/views/metadata-admin/celAuthoring.test.ts +++ b/packages/app-shell/src/views/metadata-admin/celAuthoring.test.ts @@ -110,11 +110,13 @@ describe('celAuthoring · the wrong-layer `data.*` advisory (objectui#8972)', () it('LIVE CONTROL — the ACCEPT SET is not narrowed: the same predicate raises no error', async () => { // Every save gate on this tier counts `severity === 'error'` and nothing - // else, so "zero errors" IS "still accepted". This is the falsifiable form - // of WARN-not-REFUSE: promote the advisory to `error` and this reddens. + // else, so "zero errors" IS "still accepted". Deliberately asserts ONLY + // that: it is green with the advisory and green without it, and reddens on + // exactly one change — promoting the advisory to `error`. That is the + // falsifiable form of WARN-not-REFUSE, and mixing the presence of the + // warning into it would turn the control into a second true-positive pin. const issues = await lintCelPredicate("data.status == 'x'", RULE_HINT); expect(issues.filter((i) => i.severity === 'error')).toEqual([]); - expect(issues.some((i) => i.severity === 'warning')).toBe(true); }); it('LIVE CONTROL — the canonical spelling stays completely clean', async () => {