Repository navigation
fix(formula): has() over an indexed or call receiver evaluates as the CEL spec defines it - #25275
Conversation
… CEL spec defines it cel-js 8.0.0's has() macro refuses any selection chain not rooted in an identifier, so has(data.environments[0].storage.usedMb) faulted "has() invalid argument" at every check and every evaluation, while a page visibleWhen (whose publish gate only parses) published it and failed open. The engine lowers such a call to the spec's equivalent e.?f.hasValue() wherever cel-js type-checks: evaluate, compile, firstUndeclaredReference, inferCelType and the overload-fault probe. Arguments the spec refuses (has(status), has(a[0])) are untouched and still refused. Claude-Session: https://claude.ai/code/session_019RfszUM1D9pJhyWiBkucZP Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfszUM1D9pJhyWiBkucZP Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check10 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f2d1cc99836aa52fd190e48f140d7b9fda9f2944 && git checkout f2d1cc99836aa52fd190e48f140d7b9fda9f2944
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8dfd1265f006ea0a6f727fa966e980ede57b4214 baf8e225450a844e7c7e0401c5efea54b2f9d7ad && git checkout -B drift-repro 8dfd1265f006ea0a6f727fa966e980ede57b4214 && git merge --no-ff baf8e225450a844e7c7e0401c5efea54b2f9d7ad
node scripts/docs-audit/affected-docs.mjs --json 8dfd1265f006ea0a6f727fa966e980ede57b4214 |
Contract reviewServed-tier: Read-only, at 2026-10-22T19:42Z: card #25267 (body and both comments, the claim 6348364574 and the os-dev-report 6349466484), PR #25275 (body, file list, net diff against Served-tier note. The brief asked for ① Derived judgmentsCheck-runs on the head at 2026-10-22T19:42Z:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT ·
Generated by Claude Code |
Requested by Bill · project thread
Fixes #25267
Clause-②: yes (widening)
Metadata-protocol: added
What was wrong
The CEL spec defines
has(e.f)for any expressione: a presence test on fieldfof whatevereevaluates to. cel-js 8.0.0 (the latest release) implementshas()as a macro whose type-check walks the selection chain back to its root and refuses the argument withhas() invalid argumentunless that root is an identifier. Sohas(data.environments[0].storage.usedMb),has(record.items[0].name)orhas(dyn(x).y)faulted at every type-check and every evaluation.Measured on
origin/main17f3c65 before the change, through@objectstack/formula's public entries:has(data.environments[0].storage.usedMb)has(record.a.b)(control)celEngine.evaluateok: false,has() invalid argumentok: truecelEngine.compilevalidateExpression('predicate', …)(object / flow / action / sharing-rule / hook slots)validateVisibilityPredicates(page and viewvisibleWhen)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(incel-engine.ts, next to the #3306rewriteNullableTernary, built the same way: a parse, an AST walk, a reserialize only when something changed, a bounded memo) lowers such a call toe.?f.hasValue(). That is the spec's definition of optional field selection (e.?fisoptional.of(e.f)whenhas(e.f), elseoptional.none()), so the two are the same test. cel-js evaluates the receiver exactly as itshas()does: an absent key before the last step still faultsNo such key, and only the last step answersfalse.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 faulthas() invalid argumenteverywhere.evaluate,compile,firstUndeclaredReference,inferCelTypeand the overload-fault probe. The parse-only front end (parseCelToAst) keeps the authoredhas(…)call. It already parsed, and AST consumers such as the lint'shas(…)span mask keep finding the call they key on.@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:true, absent answersfalse), and agreement with the'usedMb' in …form the filing worked around with;has()over ten value kinds (map with or without the key, key holdingnull/false, empty map,null, list, int, string, bool) and two path depths, with faults compared by kind and first line;compile,validateExpression,evaluate,inferCelType(bool), andfirstUndeclaredReference, which now reports a barestatusbehind an indexedhas()instead of being masked by the macro's fault;has(status)andhas(record.items[0])are still refused bycompile,validateExpressionandevaluate;has()nested inside a lowered receiver.Ablation at baf8e22, with an early
return source;injected intorewriteIndexedHasthroughscripts/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) andgit diff HEADempty. Nodist/is involved: the suite imports./cel-enginefrom 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/objectstackderived 67 commands at baf8e22. All 67 were run, with exit codes captured before any pipe, and--ranreconciles 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-testafter fetching its shallow-clone fixture commit, andcheck:lean-entry-closureafter building@objectstack/objectql. The NOT-MEASURED one ischeck:dual-build-cjs-loads(exit 3, needs every package'sdist), which is left to CI.Dogfood: n/a. The title carries no
!and theClause-②arm is(widening).Acceptance notes
Clause-②: yes (widening)/added. The claim expectedno. Measured from the diff,validateExpressionused 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 isminoron@objectstack/formulaaccordingly.packages/spec/src/automation/flow-value-slot-template.ts(guardOf) and the test title atflow-value-slot-template.test.ts:201sayhas()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 byhas(), and an out-of-range index faults instead of answeringfalse.has(status)andhas(record.items[0])on a page or viewvisibleWhenstill publish with 0 findings (measured:validateVisibilityPredicatesagainst a page component, withrecord.x === 1as the firing control), and still fault at runtime. Closing that means adopting one morecheck()verdict on the page and view rule, which the lint:validate-visibility-predicatespasses unknown CEL functions clean (thevalidateExpressionpremise is falsified) — ruled: extend to function-existence ERROR, scoped supersession of the parse-only ruling #13594 ruling ("只拒未知函数裁定,⛔ 不搬运check()的其他抱怨") reserves for the maintainer. It is not done here.has()macro,rewriteIndexedHasand its five call sites can go, together with its own describe block; the evaluation and every-door pins keep holding.Generated by Claude Code