Repository navigation
Description
发现于 #4763(PR #4810)的 PM 复核。未认领;落点在 packages/formula + packages/lint,不在本 PM 车道内,按 Prime Directive #10 只记录不修。
现象
#4763 新增的 packages/lint/src/validate-null-guards.ts 直接从第三方库取 CEL 解析器:
import { Environment } from '@marcbachmann/cel-js';
import type { ASTNode } from '@marcbachmann/cel-js';
// …
ast = getParseEnv().parse(source).ast;而 @objectstack/formula 是本仓唯一的 CEL 封装(其 package.json 自述:「ObjectStack canonical expression engine — CEL (cel-js) + ObjectStack stdlib + dialect registry」),packages/lint 本来就依赖它。
为什么这不只是「多一个依赖」
packages/formula/src/cel-engine.ts 在解析之前会重写源码,而且注释明确写了这么做的理由:
// Same nullable-ternary rewrite as compile/evaluate so "what parses" agrees
const compiled = buildEnv(() => new Date(0)).parse(rewriteNullableTernary(source));
也就是说 formula 认为裸 cel-js 的可解析集与平台实际接受的可解析集不一致,并用 rewriteNullableTernary 把两者对齐。packages/lint 这条新路径没有这层重写,于是:
- 一个 formula 会重写后成功解析、运行时正常接受的谓词,
- 在 lint 这边可能直接解析失败。
而新闸门对解析失败的处理是静默跳过(catch { return [] },注释写的是「syntax is another gate's verdict」)。两者叠加的后果:某些形状的谓词会悄无声息地逃过 null-guard 闸门 —— 闸门以为自己检查过了,实际一条都没查。
方向上这是欠强制而非误拒(不会把合法元数据拒掉),所以 #4810 不因此阻塞;但它正是本仓这两天反复在修的那个形状:两条路径对同一个概念各自持有一份实现,迟早给出不同答案(参见 #4770 把 materializeDeclaredFields 提取成共享 helper 的理由 —— 「record.done == true 不能因为谁在求值而有两种含义」)。这里是同一件事,换成了「什么算可解析的 CEL」。
版本目前没有漂:两边都是 @marcbachmann/cel-js@^8.0.0。但这只是今天为真,而语义漂移(rewriteNullableTernary)现在就存在。
为什么当时没在 #4810 里修
两条路都不通:
- 扩
@objectstack/formula的导出面 → 那是另一个 PM 车道的包([PM] 分片分工登记表 — 谁在管哪个仓库的队列 #4604 分片裁定),feat(lint,docs):has(x)不是 null 守卫 —— 发布期拒绝未守卫的可空比较 (#4763) #4810 的作者被明确禁止改; - 在
packages/lint里复制一份rewriteNullableTernary→ 那是把一份漂移换成两份,更糟。
所以直接引 cel-js 是当时约束下的正确选择,问题记在这里。
建议方向
让 @objectstack/formula 导出一个规范的 parse-to-AST 入口(带它自己的重写与 limits),packages/lint 改用它。这样「什么能解析」在全仓只有一个答案,顺带也让 lint 免于直接依赖第三方解析器。
packages/formula 已经导出了 lowerCelAst / collectCelRootIdentifiers / isPushdownableCel 等 AST 层能力,说明这个入口在概念上已经存在,只是没有以「给我 AST,我自己走」的形式暴露出来。
验收建议
packages/lint不再直接依赖@marcbachmann/cel-js(从其package.json移除);validate-null-guards.ts经由 formula 的规范入口取 AST;- 一条测试钉住:formula 会重写、裸 cel-js 解析不了的那一类形状,在 null-guard 闸门里能被正常判定(而不是静默跳过)—— 这是本 issue 真正要消灭的洞。
关联
- 来源:
has(x)reads as a null guard and is not one — a publish-time lint should reject un-guarded nullable comparisons in CEL predicates #4763 / PR feat(lint,docs):has(x)不是 null 守卫 —— 发布期拒绝未守卫的可空比较 (#4763) #4810 - 同类形状先例:bug(objectql): hook 条件对着「只含本次更新字段」的残缺 record 求值,取不到的字段被吞成 false —— 审计 hook 静默不触发 #4770(
materializeDeclaredFields从两处收敛成一处共享 helper) - 相邻:null-guard 闸门(#4763)只覆盖了校验规则与 hook 条件 —— action / flow 条件两面待定,formula 面待判 #4811(同一 PR 记录的未覆盖求值面)
Activity
os-zhuang commentedon Aug 6, 2026
存量裁决轮(维护者 2026-08-06 委托,session_01LeEfA7CFwbJb7JJmXm2KM3):裁定入队。前提经 origin/main 核实仍成立:packages/lint 仍直接依赖
@marcbachmann/cel-js绕过 formula 的rewriteNullableTernary,null-guard 闸门对「formula 可解析而裸 cel-js 不可解析」的形状仍静默跳过——两个解析入口答案不一致,欠强制。修法与验收正文已写清。维护者可否决。Generated by Claude Code
baozhoutao commentedon Aug 7, 2026
认领(执行席 PM,engine-core 车道,第 8 轮)
session_019Q7oc7ASjh8yxyS3Yz78Weclaude/issue-4812-formula-canonical-parse../objectstack-issue-4812(worktree-first)packages/formula(导出规范 parse-to-AST 入口,自带rewriteNullableTernary与 limits)+packages/lint(validate-null-guards.ts改走该入口;package.json移除对@marcbachmann/cel-js的直接依赖)+ 钉洞测试 + changeset按 2026-08-06 存量裁决轮的入队裁定执行,验收以正文三条为准(重点:formula 会重写、裸 cel-js 解析不了的形状,在 null-guard 闸门里能被正常判定,不再静默跳过)。
Generated by Claude Code