|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectui#9318 — the wrong-layer verdict comes from `@objectstack/lint`, not |
| 5 | + * from a second hand-maintained copy of it. |
| 6 | + * |
| 7 | + * ## What moved |
| 8 | + * |
| 9 | + * `rowCanonAdvisory` used to answer "is this root bound on this surface?" from |
| 10 | + * objectui's own knowledge: `@object-ui/core`'s `detectNonCanonicalRowSpelling` |
| 11 | + * hard-codes ONE root (`data`) and objectui's docblock justified that from |
| 12 | + * `ROW_PREDICATE_ROOTS` / `FIELD_RULE_ROOTS` / `FORMULA_ROOTS`. The platform |
| 13 | + * publishes the same judgement as `fieldRuleRootIssue` / `FIELD_RULE_BOUND_ROOTS`. |
| 14 | + * Two copies of one judgement agree today and nothing keeps them agreeing. |
| 15 | + * |
| 16 | + * ## What did NOT move, and is pinned here as such |
| 17 | + * |
| 18 | + * The helper judges against `FIELD_RULE_BOUND_ROOTS` = `record` / `previous` / |
| 19 | + * `parent` — whatever slot name it is handed. It has no vocabulary gate of its |
| 20 | + * own (measured: an unrecognised slot still returns a finding, not `null`), so |
| 21 | + * WHICH surfaces it may answer for is objectui's call, not the platform's. Two |
| 22 | + * of the surfaces `rowCanonAdvisory` guards bind a set that is not that one, and |
| 23 | + * neither is comparable to it — they overlap on `record` alone: |
| 24 | + * |
| 25 | + * - the conditional-formatting `condition` binds `ROW_PREDICATE_ROOTS` |
| 26 | + * (`record`, `current_user`, `user`, `features`, `os`, `ctx`) — five roots |
| 27 | + * the field tier does not bind, but NOT a superset: no `previous`, no |
| 28 | + * `parent`; |
| 29 | + * - a `formula` field's `expression` binds `FORMULA_ROOTS` (`record`) — a |
| 30 | + * proper subset. |
| 31 | + * |
| 32 | + * So the helper's verdict is adopted exactly on the three slots whose bound set |
| 33 | + * IS the field-rule set, and the local fallback is KEPT for the rest. The two |
| 34 | + * "uncovered surface" pins below are live controls for the failure this card |
| 35 | + * exists to stop, in miniature: routing every surface through the helper to |
| 36 | + * make the code tidier would redden them. |
| 37 | + * |
| 38 | + * Severity stays objectui's own mapping (`warning`, never `error`) on both |
| 39 | + * paths — every save gate on this tier counts `severity === 'error'`. |
| 40 | + */ |
| 41 | + |
| 42 | +import { describe, it, expect } from 'vitest'; |
| 43 | +import { fieldRuleRootIssue, FIELD_RULE_BOUND_ROOTS } from '@objectstack/lint'; |
| 44 | +import { SCOPE_ROOTS } from '@objectstack/formula'; |
| 45 | +import { lintCelPredicate } from './celAuthoring'; |
| 46 | + |
| 47 | +const HINT = { objectName: 'account', fields: ['organization_id', 'owner_id', 'status', 'amount'] }; |
| 48 | +/** A field conditional rule — a slot the published helper's vocabulary covers. */ |
| 49 | +const RULE_SLOT_HINT = { ...HINT, scope: 'record' as const, slot: 'visibleWhen' as const }; |
| 50 | +/** The same tier with no slot named — the local fallback, unchanged. */ |
| 51 | +const UNSLOTTED_HINT = { ...HINT, scope: 'record' as const }; |
| 52 | + |
| 53 | +/** objectui's own advisory sentence; the engine's never contains it. */ |
| 54 | +const OBJECTUI_MESSAGE_MARKER = /Re-root/; |
| 55 | + |
| 56 | +describe('celAuthoring · the field-rule verdict is the published one (objectui#9318)', () => { |
| 57 | + it('TRUE POSITIVE — a covered slot takes BOTH the verdict and the message from `fieldRuleRootIssue`', async () => { |
| 58 | + const source = "data.status == 'x'"; |
| 59 | + const issues = await lintCelPredicate(source, RULE_SLOT_HINT); |
| 60 | + const engine = fieldRuleRootIssue('visibleWhen', source); |
| 61 | + // The helper has something to say about this source — if it ever stops, |
| 62 | + // this pin is measuring nothing and must be re-derived, not relaxed. |
| 63 | + expect(engine).not.toBeNull(); |
| 64 | + const advisory = issues.filter((i) => i.severity === 'warning' && i.message === engine!.message); |
| 65 | + // Verbatim equality against the helper's OWN output, evaluated here rather |
| 66 | + // than transcribed: this asserts "objectui ships the engine's message" and |
| 67 | + // can never drift with upstream wording the way a quoted string would. |
| 68 | + expect(advisory).toHaveLength(1); |
| 69 | + }); |
| 70 | + |
| 71 | + it('ships ONE message, never both — objectui\'s sentence is gone from the covered path', async () => { |
| 72 | + const issues = await lintCelPredicate("data.status == 'x'", RULE_SLOT_HINT); |
| 73 | + expect(issues.filter((i) => OBJECTUI_MESSAGE_MARKER.test(i.message))).toEqual([]); |
| 74 | + expect(issues.filter((i) => i.severity === 'warning')).toHaveLength(1); |
| 75 | + }); |
| 76 | + |
| 77 | + it('LIVE CONTROL — the ACCEPT SET is not narrowed: the covered path raises no error', async () => { |
| 78 | + // Every save gate on this tier counts `severity === 'error'` and nothing |
| 79 | + // else, so "zero errors" IS "still accepted". Reddens on exactly one |
| 80 | + // change — promoting the advisory to `error`. |
| 81 | + const issues = await lintCelPredicate("data.status == 'x'", RULE_SLOT_HINT); |
| 82 | + expect(issues.filter((i) => i.severity === 'error')).toEqual([]); |
| 83 | + }); |
| 84 | + |
| 85 | + it('a root the platform DOES bind comes back clean THROUGH the new path', async () => { |
| 86 | + // The ablation target: `previous` and `parent` are clean here because |
| 87 | + // `FIELD_RULE_BOUND_ROOTS` says the field level binds them, not because |
| 88 | + // objectui's detector only ever looked at `data`. |
| 89 | + expect(await lintCelPredicate("previous.status == 'x'", RULE_SLOT_HINT)).toEqual([]); |
| 90 | + expect(await lintCelPredicate('parent.status == "paid"', RULE_SLOT_HINT)).toEqual([]); |
| 91 | + expect(await lintCelPredicate("record.status == 'x'", RULE_SLOT_HINT)).toEqual([]); |
| 92 | + }); |
| 93 | + |
| 94 | + it('takes the WIDER verdict on a covered slot: a root no field rule binds is advised too', async () => { |
| 95 | + // A declared behaviour change, and the substance of adopting the published |
| 96 | + // verdict: `current_user` is in the engine's baseline `SCOPE_ROOTS`, so |
| 97 | + // `validateExpression` says nothing about it, while the FIELD level does |
| 98 | + // not bind it. objectui's one-root detector could never reach this. |
| 99 | + // "WIDER" in the name is this verdict against the ONE-ROOT detector it |
| 100 | + // replaces — ⛔ not a set relation between the two surfaces' bound roots, |
| 101 | + // which are incomparable (see the header). |
| 102 | + const issues = await lintCelPredicate('current_user.isAdmin', RULE_SLOT_HINT); |
| 103 | + const advisory = issues.filter((i) => i.severity === 'warning' && /current_user/.test(i.message)); |
| 104 | + expect(advisory).toHaveLength(1); |
| 105 | + expect(issues.filter((i) => i.severity === 'error')).toEqual([]); |
| 106 | + }); |
| 107 | + |
| 108 | + it('stands down on a parse error and on a non-CEL dialect, like the path it replaces', async () => { |
| 109 | + const broken = await lintCelPredicate('data.status ==', RULE_SLOT_HINT); |
| 110 | + expect(broken.some((i) => i.severity === 'error')).toBe(true); |
| 111 | + expect(broken.filter((i) => i.severity === 'warning' && /\bdata\b/.test(i.message))).toEqual([]); |
| 112 | + const legacy = await lintCelPredicate('${data.status}', RULE_SLOT_HINT); |
| 113 | + expect(legacy.filter((i) => i.severity === 'warning' && /\bdata\b/.test(i.message))).toEqual([]); |
| 114 | + }); |
| 115 | +}); |
| 116 | + |
| 117 | +describe('celAuthoring · the UNCOVERED surfaces keep the local fallback (objectui#9318 part 3)', () => { |
| 118 | + it('LIVE CONTROL — a formula `expression` still gets objectui\'s message, not the engine\'s', async () => { |
| 119 | + // `FORMULA_ROOTS` is `['record']` — a proper SUBSET of |
| 120 | + // `FIELD_RULE_BOUND_ROOTS`. Routing this through the helper would not shrink |
| 121 | + // coverage: measured, `previous.*` / `parent.*` report nothing on this |
| 122 | + // surface today either. It would report them CLEAN on the helper's own |
| 123 | + // authority — asserting they are bound where this surface binds only |
| 124 | + // `record`. A false green, which is worse than the silence it replaces. |
| 125 | + const issues = await lintCelPredicate('data.amount * 0.2', { ...UNSLOTTED_HINT, role: 'value' as const }); |
| 126 | + const advisory = issues.filter((i) => i.severity === 'warning' && OBJECTUI_MESSAGE_MARKER.test(i.message)); |
| 127 | + expect(advisory).toHaveLength(1); |
| 128 | + expect(issues.filter((i) => i.severity === 'error')).toEqual([]); |
| 129 | + }); |
| 130 | + |
| 131 | + it('LIVE CONTROL — a conditional-formatting condition is NOT advised for the roots it binds', async () => { |
| 132 | + // `ROW_PREDICATE_ROOTS` carries `current_user`, `user`, `features`, `os`, |
| 133 | + // `ctx` on top of `record` — five roots the field tier does not bind. It is |
| 134 | + // NOT a superset of `FIELD_RULE_BOUND_ROOTS` though: it lacks `previous` and |
| 135 | + // `parent`, so the two sets overlap on `record` alone. This is the pin that |
| 136 | + // reddens if someone routes every `scope: 'record'` surface through the |
| 137 | + // helper: the author would be told to rewrite five roots that work here. |
| 138 | + expect(await lintCelPredicate('current_user.isAdmin', UNSLOTTED_HINT)).toEqual([]); |
| 139 | + expect(await lintCelPredicate("os.name == 'x'", UNSLOTTED_HINT)).toEqual([]); |
| 140 | + }); |
| 141 | + |
| 142 | + it('an unknown slot name falls back rather than guessing', async () => { |
| 143 | + const issues = await lintCelPredicate('current_user.isAdmin', { ...UNSLOTTED_HINT, slot: 'someFutureWhen' }); |
| 144 | + expect(issues).toEqual([]); |
| 145 | + }); |
| 146 | +}); |
| 147 | + |
| 148 | +describe('celAuthoring · platform drift tripwire (objectui#9318)', () => { |
| 149 | + it('the published bound set is still the one objectui measured its coverage answer against', async () => { |
| 150 | + // NOT a second copy of the verdict — the verdict is read from the helper at |
| 151 | + // runtime and this assertion is never consulted by product code. It exists |
| 152 | + // so that the next root the platform binds or unbinds arrives as a RED test |
| 153 | + // in objectui, with the instruction to re-derive which of this repo's |
| 154 | + // surfaces the helper's vocabulary still covers (PR objectui#9318 part 3). |
| 155 | + expect([...FIELD_RULE_BOUND_ROOTS]).toEqual(['record', 'previous', 'parent']); |
| 156 | + }); |
| 157 | + |
| 158 | + it('SWEEPS the whole unbound population: every root is advised EXCEPT `app`', async () => { |
| 159 | + // The instrument behind the docblock's universal. The published prose says |
| 160 | + // "every root the field level leaves unbound is advised, except `app`" and |
| 161 | + // ⛔ names no others — because any written-down list goes stale the next |
| 162 | + // time the platform moves a root. This sweep re-derives the population on |
| 163 | + // every run instead, so the claim cannot rot silently: it reddens when a |
| 164 | + // root stops being advised, when a SECOND root starts being blocked, or |
| 165 | + // when any root goes silent. |
| 166 | + // |
| 167 | + // ⚠️ The universe is RECONSTRUCTED, and that is a declared limit, not an |
| 168 | + // oversight: `@objectstack/lint` keeps both `FIELD_RULE_JUDGED_ROOTS` and |
| 169 | + // `FIELD_RULE_AMBIENT_ROOTS` module-private (neither is in its export list |
| 170 | + // at 17.4.0), so the judged set is rebuilt here as `SCOPE_ROOTS` plus the |
| 171 | + // one ambient root. A NEW ambient root added upstream would be invisible to |
| 172 | + // this sweep until that literal is updated — nothing in this repo can see |
| 173 | + // it. The `app` leg below pins the reconstruction itself. |
| 174 | + const judged: readonly string[] = [...SCOPE_ROOTS, 'app']; |
| 175 | + const bound: readonly string[] = FIELD_RULE_BOUND_ROOTS; |
| 176 | + const candidates = judged.filter((r) => !bound.includes(r)); |
| 177 | + expect(candidates.length).toBeGreaterThan(1); // the sweep is measuring something |
| 178 | + |
| 179 | + const advised: string[] = []; |
| 180 | + const blocked: string[] = []; |
| 181 | + const silent: string[] = []; |
| 182 | + for (const root of candidates) { |
| 183 | + const source = `${root}.x == 1`; |
| 184 | + const issues = await lintCelPredicate(source, RULE_SLOT_HINT); |
| 185 | + const engine = fieldRuleRootIssue('visibleWhen', source); |
| 186 | + // Verbatim equality with the helper's own output, as elsewhere in this |
| 187 | + // file — never a transcribed string. |
| 188 | + if (engine && issues.some((i) => i.severity === 'warning' && i.message === engine.message)) advised.push(root); |
| 189 | + else if (issues.some((i) => i.severity === 'error')) blocked.push(root); |
| 190 | + else silent.push(root); |
| 191 | + } |
| 192 | + |
| 193 | + // The universal, and the single documented exception. |
| 194 | + expect(silent).toEqual([]); |
| 195 | + expect(blocked).toEqual(['app']); |
| 196 | + expect(advised).toEqual(candidates.filter((r) => r !== 'app')); |
| 197 | + }); |
| 198 | + |
| 199 | + it('WHY `app` is the exception: the helper judges it, the platform does not DECLARE it', async () => { |
| 200 | + // The exception is a mechanism, not a special case: the advisory is gated |
| 201 | + // behind `issues.every((i) => i.severity !== 'error')`, and a root the |
| 202 | + // engine does not declare raises a bare-reference ERROR first. `app` is the |
| 203 | + // only judged root in that position — it is AMBIENT (renderer-mounted), |
| 204 | + // which is exactly why the engine's own message for it refuses the |
| 205 | + // `record.app` rewrite by name. |
| 206 | + expect(SCOPE_ROOTS).not.toContain('app'); |
| 207 | + expect(fieldRuleRootIssue('visibleWhen', 'app.theme == "dark"')).not.toBeNull(); |
| 208 | + const issues = await lintCelPredicate('app.theme == "dark"', RULE_SLOT_HINT); |
| 209 | + expect(issues.some((i) => i.severity === 'error')).toBe(true); |
| 210 | + expect(issues.filter((i) => i.severity === 'warning')).toEqual([]); |
| 211 | + }); |
| 212 | +}); |
0 commit comments