Skip to content

Commit 9d96b2b

Browse files
fix(formula): has() over an indexed or call receiver evaluates as the CEL spec defines it (#25275)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg8b7MP77ty5yyBo5R7ks3hC)_ Fixes #25267 Clause-②: yes (widening) Metadata-protocol: added ## What was wrong The CEL spec defines `has(e.f)` for any expression `e`: a presence test on field `f` of whatever `e` evaluates to. cel-js 8.0.0 (the latest release) implements `has()` as a macro whose type-check walks the selection chain back to its root and refuses the argument with `has() invalid argument` unless that root is an identifier. So `has(data.environments[0].storage.usedMb)`, `has(record.items[0].name)` or `has(dyn(x).y)` faulted at every type-check and every evaluation. Measured on `origin/main` 17f3c65 before the change, through `@objectstack/formula`'s public entries: | door | `has(data.environments[0].storage.usedMb)` | `has(record.a.b)` (control) | |---|---|---| | `celEngine.evaluate` | `ok: false`, `has() invalid argument` | `ok: true` | | `celEngine.compile` | refused | accepted | | `validateExpression('predicate', …)` (object / flow / action / sharing-rule / hook slots) | refused | accepted | | `validateVisibilityPredicates` (page and view `visibleWhen`) | **0 findings** | 0 findings | The page and view gate checks syntax, unknown functions and bare identifiers, never types (the #6253 / #13594 rulings), so on that surface the predicate published clean and then failed open in the console. The fault is cel-js's own (no ObjectStack wrapping is involved), and `Environment.registerFunction('has(ast): bool', …)` cannot replace the built-in macro: cel-js refuses the overlapping signature. ## The fix: the engine evaluates it, as the spec says The route is the "evaluate" branch of the card. `has()` over an indexed or call receiver is valid CEL, and the engine can honour it without forking cel-js. `rewriteIndexedHas` (in `cel-engine.ts`, next to the #3306 `rewriteNullableTernary`, built the same way: a parse, an AST walk, a reserialize only when something changed, a bounded memo) lowers such a call to `e.?f.hasValue()`. That is the spec's definition of optional field selection (`e.?f` is `optional.of(e.f)` when `has(e.f)`, else `optional.none()`), so the two are the same test. cel-js evaluates the receiver exactly as its `has()` does: an absent key before the last step still faults `No such key`, and only the last step answers `false`. - It fires only on a `has()` whose argument is a `.` selection rooted in something other than an identifier. `has(record.a.b)` is left to cel-js. So is every argument the spec itself refuses: a bare name (`has(status)`) and an index as the last step (`has(record.items[0])`) still fault `has() invalid argument` everywhere. - It is applied at every place cel-js type-checks: `evaluate`, `compile`, `firstUndeclaredReference`, `inferCelType` and the overload-fault probe. The parse-only front end (`parseCelToAst`) keeps the authored `has(…)` call. It already parsed, and AST consumers such as the lint's `has(…)` span mask keep finding the call they key on. - No lint change is needed. Every rule that judges a CEL predicate reaches the type verdict through `@objectstack/formula` (`validateExpression`, `celEngine.compile`), or does not judge types at all (the page and view rules). They now agree with the runtime. ## Tests New `packages/formula/src/cel-has-indexed-receiver.test.ts`, 9 tests: - the filed reading (present answers `true`, absent answers `false`), and agreement with the `'usedMb' in …` form the filing worked around with; - parity with cel-js's own identifier-rooted `has()` over ten value kinds (map with or without the key, key holding `null` / `false`, empty map, `null`, list, int, string, bool) and two path depths, with faults compared by kind and first line; - every door gives one verdict: `compile`, `validateExpression`, `evaluate`, `inferCelType` (`bool`), and `firstUndeclaredReference`, which now reports a bare `status` behind an indexed `has()` instead of being masked by the macro's fault; - controls: `has(status)` and `has(record.items[0])` are still refused by `compile`, `validateExpression` and `evaluate`; - the rewrite is narrow (five untouched spellings), idempotent, and lowers a `has()` nested inside a lowered receiver. Ablation at baf8e22, with an early `return source;` injected into `rewriteIndexedHas` through `scripts/ablation-replace.mjs`: the suite went 8 failed / 1 passed (the controls test is the one that stays green, as it should). The restore was verified as blob == HEAD (`f4f10ad31d5c`) and `git diff HEAD` empty. No `dist/` is involved: the suite imports `./cel-engine` from source. Runs at head baf8e22 (under `os-verify-lock.sh`): - `pnpm --filter @objectstack/formula build && … test && … typecheck`: exit 0. Test Files 52 passed (52), Tests 1376 passed (1376). - `pnpm --filter @objectstack/lint test`: exit 0. Test Files 153 passed (153), Tests 7076 passed (7076). ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands at baf8e22. All 67 were run, with exit codes captured before any pipe, and `--ran` reconciles them: 66 run, 1 NOT-MEASURED, 0 unrun. 66 exited 0. Two of those first exited 3 on missing prerequisites and were 0 on a rerun: `check-plugin-teardown-shape --self-test` after fetching its shallow-clone fixture commit, and `check:lean-entry-closure` after building `@objectstack/objectql`. The NOT-MEASURED one is `check:dual-build-cjs-loads` (exit 3, needs every package's `dist`), which is left to CI. Dogfood: n/a. The title carries no `!` and the `Clause-②` arm is `(widening)`. ## Acceptance notes - **Why `Clause-②: yes (widening)` / `added`.** The claim expected `no`. Measured from the diff, `validateExpression` used to refuse this argument on object, flow, action, sharing-rule and hook predicates, and now accepts it. That is a wider publish accept set, and a newly accepted value for an author. The changeset is `minor` on `@objectstack/formula` accordingly. - **A docblock this makes stale, outside the claim's file surface and left alone.** `packages/spec/src/automation/flow-value-slot-template.ts` (`guardOf`) and the test title at `flow-value-slot-template.test.ts:201` say `has()` refuses an index anywhere in its argument at run time. That reason is now false. The behaviour it explains (no guard is printed for a path with a numeric segment) is still right, because a path ending in an index cannot be guarded by `has()`, and an out-of-range index faults instead of answering `false`. - **The sibling class stays open.** `has(status)` and `has(record.items[0])` on a page or view `visibleWhen` still publish with 0 findings (measured: `validateVisibilityPredicates` against a page component, with `record.x === 1` as the firing control), and still fault at runtime. Closing that means adopting one more `check()` verdict on the page and view rule, which the #13594 ruling ("只拒未知函数裁定,⛔ 不搬运 `check()` 的其他抱怨") reserves for the maintainer. It is not done here. - **Error excerpts.** A runtime fault inside a lowered call (an absent intermediate key) quotes the lowered source in cel-js's excerpt, the same property the #3306 rewrite already has. - **Removal.** When cel-js lifts the identifier-root restriction from its `has()` macro, `rewriteIndexedHas` and its five call sites can go, together with its own describe block; the evaluation and every-door pins keep holding. --- _Generated by [Claude Code](https://claude.ai/code/session_019RfszUM1D9pJhyWiBkucZP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1eb1afa commit 9d96b2b

3 files changed

Lines changed: 254 additions & 6 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/formula': minor
3+
---
4+
5+
fix(formula): `has()` over a selection whose receiver is an index or a call result evaluates as a presence test, as the CEL spec defines it
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
The CEL spec defines `has(e.f)` for any expression `e`. The engine used to refuse every such argument whose selection chain was not rooted in a plain name — `has(data.environments[0].storage.usedMb)`, `has(record.items[0].name)`, `has(dyn(x).y)` — with `has() invalid argument`, at evaluation and at `compile` / `validateExpression` alike. A page or view `visibleWhen`, whose publish gate checks syntax but not types, published such a predicate and then failed open in the console.
11+
12+
- **What now works.** The call is a presence test on the last field: `true` when the receiver has the key, `false` when it does not, and a fault (`No such key`) when a step before the last one is absent, exactly like `has(record.a.b)`. `evaluate`, `compile`, `validateExpression`, `firstUndeclaredReference` and `inferCelType` all give that one answer.
13+
- **What widens.** Object, flow, action, sharing-rule and hook predicates that `validateExpression` used to refuse for this argument now pass. They evaluate as written.
14+
- **What is still refused.** An argument the spec itself refuses: a bare name (`has(status)`) or an index as the last step (`has(record.items[0])`). Test a key with `'key' in m`, or a list position with `size(list) > n`.
15+
- **Nothing to migrate.** A predicate already written in the `'key' in e` form keeps working and answers the same.

‎packages/formula/src/cel-engine.ts‎

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,9 @@ let recordScopeEnv: Environment | undefined;
404404
* faults `has() invalid argument`. `@objectstack/lint`'s
405405
* `validate-visibility-predicates` masks `has(…)` spans at its own call site
406406
* (#16118) and that mask stays load-bearing; no other consumer has one.
407+
* A select whose receiver is an index or a call (`has(a[0].b)`) is NOT in
408+
* this class: the spec admits it, and {@link rewriteIndexedHas} lowers it
409+
* before this check runs (#25267).
407410
*
408411
* ⇒ A `null` from this helper is still "nothing was reported", never "the
409412
* source is clean". ⛔ Do not close the remaining classes by widening the regex
@@ -423,7 +426,9 @@ export function firstUndeclaredReference(
423426
const env = knownFields.length === 0
424427
? (recordScopeEnv ??= buildScopedEnv([]))
425428
: buildScopedEnv(knownFields);
426-
const result = env.parse(source).check?.() as
429+
// #25267 — judged in the form the engine checks, so an indexed `has()`
430+
// receiver is not a first error that masks every reference behind it.
431+
const result = env.parse(rewriteIndexedHas(source)).check?.() as
427432
| { valid: boolean; error?: { message?: string } }
428433
| undefined;
429434
if (result && result.valid === false) {
@@ -862,7 +867,8 @@ export function inferCelType(source: string, knownFields: readonly string[] = []
862867
const env = knownFields.length === 0
863868
? (recordScopeEnv ??= buildScopedEnv([]))
864869
: buildScopedEnv(knownFields);
865-
const result = env.parse(source).check?.() as
870+
// #25267 — typed in the form the engine checks (an indexed `has()` is `bool`).
871+
const result = env.parse(rewriteIndexedHas(source)).check?.() as
866872
| { valid?: boolean; type?: unknown }
867873
| undefined;
868874
if (!result || result.valid === false) return null;
@@ -1066,7 +1072,7 @@ export function firstTypeMismatch(
10661072
// unifier *before* the check reaches an inner date-arith overload; rewrite it
10671073
// first (same pass the runtime uses) so the real fault surfaces (#3306).
10681074
const env = buildTypedEnv(fieldCelTypes, scope);
1069-
const result = env.parse(rewriteNullableTernary(source)).check?.() as
1075+
const result = env.parse(rewriteNullableTernary(rewriteIndexedHas(source))).check?.() as
10701076
| { valid?: boolean; error?: { message?: string } }
10711077
| undefined;
10721078
if (!result || result.valid !== false) return null;
@@ -1463,6 +1469,111 @@ function rememberNullableRewrite(source: string, rewritten: string): void {
14631469
nullableTernaryCache.set(source, rewritten);
14641470
}
14651471

1472+
/**
1473+
* #25267 — make `has()` over a selection whose receiver is not a plain name
1474+
* (`has(data.environments[0].storage.usedMb)`, `has(lookup(x).y)`) type-check
1475+
* and evaluate, as the CEL spec defines it: `has(e.f)` is a presence test on
1476+
* field `f` of whatever `e` evaluates to, and `e` may be any expression.
1477+
*
1478+
* cel-js 8.0.0's `has()` macro walks the selection chain back to its root and
1479+
* refuses the argument (`has() invalid argument`) unless that root is an
1480+
* identifier — so an index or a call anywhere in the chain faulted every time
1481+
* it was checked or run, while a page's `visibleWhen` (whose publish gate
1482+
* parses but does not type-check) published clean and failed open.
1483+
*
1484+
* The call is lowered to `e.?f.hasValue()`. That is the CEL spec's own
1485+
* definition of optional field selection (`e.?f` is `optional.of(e.f)` when
1486+
* `has(e.f)`, else `optional.none()`), so the two are the same test, and cel-js
1487+
* evaluates the receiver `e` exactly as `has()` does: an absent key on the way
1488+
* to the last selection still faults (`No such key`), only the last one answers
1489+
* `false`. `cel-has-indexed-receiver.test.ts` pins the two forms against each
1490+
* other over every value kind.
1491+
*
1492+
* The rewrite is:
1493+
* - **narrow** — it fires ONLY on a `has()` whose argument is a `.` selection
1494+
* rooted in something other than an identifier. `has(record.a.b)` is left to
1495+
* cel-js, and so is every argument the spec itself refuses: a bare name
1496+
* (`has(status)`) or an index as the LAST step (`has(a[0])`) still faults
1497+
* `has() invalid argument`;
1498+
* - **applied where cel-js type-checks** — `evaluate`, `compile` and the
1499+
* checker-backed helpers ({@link firstUndeclaredReference},
1500+
* {@link inferCelType}, the overload-fault probe), which are the only places
1501+
* the macro's refusal can arise. The parse-only front end
1502+
* ({@link parseCelToAst}) keeps the authored `has(…)` call, which parses
1503+
* already, so an AST consumer that keys on a `has()` call still finds it;
1504+
* - **idempotent** — the lowered form carries no `has()` call to lower again.
1505+
*
1506+
* Returns the (possibly rewritten) source; only reserializes when a rewrite
1507+
* actually happened. Memoized; a parse fault returns the source unchanged.
1508+
*/
1509+
export function rewriteIndexedHas(source: string): string {
1510+
if (typeof source !== 'string' || !source.trim()) return source;
1511+
const cached = indexedHasCache.get(source);
1512+
if (cached !== undefined) return cached;
1513+
// Cheap gate: a rewrite needs a `has(` call.
1514+
if (!HAS_CALL_RE.test(source)) {
1515+
rememberIndexedHasRewrite(source, source);
1516+
return source;
1517+
}
1518+
let ast: unknown;
1519+
try {
1520+
ast = (recordScopeEnv ??= buildScopedEnv([])).parse(source).ast;
1521+
} catch {
1522+
rememberIndexedHasRewrite(source, source);
1523+
return source;
1524+
}
1525+
let changed = false;
1526+
// Children first, so a `has()` nested inside a lowered receiver is lowered too.
1527+
const lower = (value: unknown): unknown => {
1528+
if (Array.isArray(value)) {
1529+
for (let i = 0; i < value.length; i++) value[i] = lower(value[i]);
1530+
return value;
1531+
}
1532+
if (!isCelNode(value)) return value;
1533+
if (Array.isArray(value.args)) lower(value.args);
1534+
const selection = indexedHasSelection(value);
1535+
if (!selection) return value;
1536+
changed = true;
1537+
return { op: 'rcall', args: ['hasValue', { op: '.?', args: selection.args }, []] };
1538+
};
1539+
const lowered = lower(ast);
1540+
const out = changed ? serialize(lowered as Parameters<typeof serialize>[0]) : source;
1541+
rememberIndexedHasRewrite(source, out);
1542+
return out;
1543+
}
1544+
1545+
/** A `has(` call, spaced or not, not the tail of a longer name or a member call. */
1546+
const HAS_CALL_RE = /(^|[^\w$.])has\s*\(/;
1547+
1548+
/**
1549+
* The argument of `node` when it is a `has()` call {@link rewriteIndexedHas}
1550+
* lowers — a `.` selection whose chain of `.` / `.?` steps ends in something
1551+
* other than an identifier — or `null`.
1552+
*/
1553+
function indexedHasSelection(node: CelNode): CelNode | null {
1554+
if (node.op !== 'call' || !Array.isArray(node.args) || node.args[0] !== 'has') return null;
1555+
const callArgs: unknown = node.args[1];
1556+
if (!Array.isArray(callArgs) || callArgs.length !== 1) return null;
1557+
const selection: unknown = callArgs[0];
1558+
if (!isCelNode(selection) || selection.op !== '.' || !Array.isArray(selection.args)) return null;
1559+
let root: unknown = selection.args[0];
1560+
while (isCelNode(root) && (root.op === '.' || root.op === '.?') && Array.isArray(root.args)) {
1561+
root = root.args[0];
1562+
}
1563+
return isCelNode(root) && root.op !== 'id' ? selection : null;
1564+
}
1565+
1566+
/** Bounded memo of source → indexed-`has()`-lowered source (#25267). */
1567+
const indexedHasCache = new Map<string, string>();
1568+
const INDEXED_HAS_CACHE_MAX = 500;
1569+
function rememberIndexedHasRewrite(source: string, rewritten: string): void {
1570+
if (indexedHasCache.size >= INDEXED_HAS_CACHE_MAX) {
1571+
const first = indexedHasCache.keys().next().value;
1572+
if (first !== undefined) indexedHasCache.delete(first);
1573+
}
1574+
indexedHasCache.set(source, rewritten);
1575+
}
1576+
14661577
/** Coerce cel-js's BigInt-flavored return into spec-friendly JS values. */
14671578
function coerce(value: unknown): unknown {
14681579
if (typeof value === 'bigint') {
@@ -1870,7 +1981,8 @@ export const celEngine: DialectEngine = {
18701981
// #3306 — accept the null-guard idiom `cond ? <value> : null`, which cel-js's
18711982
// ternary unifier otherwise rejects (`int`/`double`/`string` ≠ `null`). Same
18721983
// rewrite the runtime uses, so build and eval agree on what is valid.
1873-
const compiled = env.parse(rewriteNullableTernary(source));
1984+
// #25267 — and `has()` over an indexed receiver, lowered the same way.
1985+
const compiled = env.parse(rewriteNullableTernary(rewriteIndexedHas(source)));
18741986
// Surface check errors eagerly. cel-js's `check()` returns a
18751987
// `TypeCheckResult` object (`{ valid, type?, error? }`) — NOT an array —
18761988
// so the type fault (including `found no matching overload for 'PRIOR(dyn)'`
@@ -1942,8 +2054,10 @@ export const celEngine: DialectEngine = {
19422054
// row against the values in hand. Non-date operands are untouched.
19432055
// #3306 — then relax the null-guard idiom `cond ? <value> : null` so a
19442056
// nullable numeric/string formula evaluates instead of faulting cel-js's
1945-
// ternary unifier. All rewrites are no-ops for sources that don't need them.
1946-
const evalSource = rewriteNullableTernary(rewriteTemporalEquality(source, scope));
2057+
// ternary unifier. #25267 — and lower `has()` over an indexed receiver,
2058+
// which cel-js's macro refuses, to the spec-equivalent `e.?f.hasValue()`.
2059+
// All rewrites are no-ops for sources that don't need them.
2060+
const evalSource = rewriteNullableTernary(rewriteTemporalEquality(rewriteIndexedHas(source), scope));
19472061
try {
19482062
const raw = runProgram(env, evalSource, scope);
19492063
return { ok: true, value: coerce(raw) as T };
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
import { celEngine, firstUndeclaredReference, inferCelType, rewriteIndexedHas } from './cel-engine';
5+
import { validateExpression } from './validate';
6+
7+
/**
8+
* #25267 — `has()` over a selection whose receiver is not a plain name.
9+
*
10+
* The CEL spec defines `has(e.f)` for any expression `e`; cel-js 8.0.0's macro
11+
* refused every receiver chain not rooted in an identifier with `has() invalid
12+
* argument`. Measured on `origin/main` 17f3c65a1d before the change:
13+
* `has(data.environments[0].storage.usedMb)` answered `ok: false` from
14+
* `evaluate`, `compile` and `validateExpression` alike, while a page's
15+
* `visibleWhen` (parse-only gate) published it and the console failed open.
16+
*
17+
* The engine now lowers that call to the spec's equivalent `e.?f.hasValue()`
18+
* ({@link rewriteIndexedHas}); these pins hold every door to one answer.
19+
*/
20+
21+
const ENVIRONMENTS = { environments: [{ storage: { usedMb: 5 } }, { storage: {} }] };
22+
23+
function evaluate(source: string, extra: Record<string, unknown>) {
24+
return celEngine.evaluate({ dialect: 'cel', source }, { extra });
25+
}
26+
27+
describe('has() over an indexed receiver evaluates as a presence test (#25267)', () => {
28+
it('the filed reading: present answers true, absent answers false', () => {
29+
expect(evaluate('has(data.environments[0].storage.usedMb)', { data: ENVIRONMENTS }))
30+
.toEqual({ ok: true, value: true });
31+
expect(evaluate('has(data.environments[1].storage.usedMb)', { data: ENVIRONMENTS }))
32+
.toEqual({ ok: true, value: false });
33+
});
34+
35+
it('agrees with the `in` form the filing worked around with', () => {
36+
for (const at of [0, 1]) {
37+
const viaHas = evaluate(`has(data.environments[${at}].storage.usedMb)`, { data: ENVIRONMENTS });
38+
const viaIn = evaluate(`'usedMb' in data.environments[${at}].storage`, { data: ENVIRONMENTS });
39+
expect(viaHas).toEqual(viaIn);
40+
}
41+
});
42+
43+
it('answers exactly what the identifier-rooted form answers, over every value kind', () => {
44+
// `has(v.…)` is cel-js's own macro (identifier root, untouched); `has(w[0].…)`
45+
// is the lowered form over the same value. Faults compare by kind and the
46+
// macro's first line, since the lowered source is echoed in the excerpt.
47+
const kinds: Record<string, unknown> = {
48+
'map with the key': { f: { g: 1 } },
49+
'map with the key null': { f: null },
50+
'map with the key false': { f: false },
51+
'map without the key': { h: 1 },
52+
'empty map': {},
53+
null: null,
54+
list: [1],
55+
int: 5,
56+
string: 's',
57+
bool: true,
58+
};
59+
const shape = (r: ReturnType<typeof evaluate>) =>
60+
r.ok ? { ok: true, value: r.value } : { ok: false, kind: r.error.kind, first: r.error.message.split('\n')[0] };
61+
for (const [kind, v] of Object.entries(kinds)) {
62+
for (const path of ['f', 'f.g']) {
63+
const native = shape(evaluate(`has(v.${path})`, { v, w: [v] }));
64+
const lowered = shape(evaluate(`has(w[0].${path})`, { v, w: [v] }));
65+
expect({ kind, path, ...lowered }).toEqual({ kind, path, ...native });
66+
}
67+
}
68+
});
69+
70+
it('a receiver that is a call result is a receiver too', () => {
71+
expect(evaluate("has(dyn(m).a)", { m: { a: 1 } })).toEqual({ ok: true, value: true });
72+
expect(evaluate("has(dyn(m).b)", { m: { a: 1 } })).toEqual({ ok: true, value: false });
73+
});
74+
});
75+
76+
describe('every door gives the same verdict (#25267)', () => {
77+
const lowered = 'has(record.items[0].name)';
78+
79+
it('compile and validateExpression accept what evaluate runs', () => {
80+
expect(celEngine.compile(lowered).ok).toBe(true);
81+
expect(validateExpression('predicate', { dialect: 'cel', source: lowered }).ok).toBe(true);
82+
expect(celEngine.evaluate({ dialect: 'cel', source: lowered }, { record: { items: [{ name: 'x' }] } }))
83+
.toEqual({ ok: true, value: true });
84+
});
85+
86+
it('the checker-backed helpers judge the lowered form', () => {
87+
expect(inferCelType(lowered)).toBe('bool');
88+
// The `has()` fault no longer takes the checker's one error slot, so a bare
89+
// reference behind it is reported instead of masked.
90+
expect(firstUndeclaredReference(`${lowered} && status == 'x'`)).toBe('status');
91+
});
92+
93+
it('controls: an argument the spec refuses is still refused at every door', () => {
94+
for (const source of ['has(status)', 'has(record.items[0])']) {
95+
const compiled = celEngine.compile(source);
96+
expect(compiled.ok).toBe(false);
97+
expect(compiled.ok ? '' : compiled.error.message).toContain('has() invalid argument');
98+
expect(validateExpression('predicate', { dialect: 'cel', source }).ok).toBe(false);
99+
const ran = celEngine.evaluate({ dialect: 'cel', source }, { record: { items: [{ name: 'x' }] } });
100+
expect(ran.ok).toBe(false);
101+
}
102+
});
103+
});
104+
105+
describe('rewriteIndexedHas (#25267)', () => {
106+
it('lowers only a `has()` whose selection is rooted in something other than a name', () => {
107+
expect(rewriteIndexedHas('has(a[0].b.c)')).toBe('a[0].b.?c.hasValue()');
108+
expect(rewriteIndexedHas('has(a[0].b) && has(c.d)')).toBe('a[0].?b.hasValue() && has(c.d)');
109+
for (const untouched of ['has(record.a.b)', 'has(status)', 'has(a[0])', "x.has('y')", 'record.has_items']) {
110+
expect(rewriteIndexedHas(untouched)).toBe(untouched);
111+
}
112+
});
113+
114+
it('is idempotent, and lowers a `has()` nested in a lowered receiver', () => {
115+
const once = rewriteIndexedHas('has(a[has(b[0].c) ? 0 : 1].d)');
116+
expect(once).not.toContain('has(');
117+
expect(rewriteIndexedHas(once)).toBe(once);
118+
});
119+
});

0 commit comments

Comments
 (0)