refactor(formula,lint): parseCelToAst 成为唯一的 CEL 解析入口 (#4812) - #6130
Conversation
lint 的 null-guard 闸门此前自建 cel-js Environment,而该 env 不带 limits: 它会解析、并进而判定 celEngine.compile() 直接以 Exceeded maxAstNodes (256) / maxDepth (32) / maxListElements (64) 拒绝的谓词。两个解析入口对「什么能解析」 给出两个答案,而这个闸门握着更宽松的那一个。 formula 新增 parseCelToAst(source): CelAstNode | null —— 与 compile/evaluate/ collectCelRootIdentifiers 共用同一条前端链路(#3306 rewriteNullableTernary 重写 + DEFAULT_LIMITS + 注册 stdlib 的 env)。只做 parse 不做 check:解析成功 但类型检查失败的表达式仍拿到 AST,类型裁决仍归 compile()。一并 re-export CelAstNode,补上 lowerCelAst 一直接收却从未导出的类型 —— 那正是消费方越过本包 直连 cel-js 的成因。 lint 改走该入口,并从 deps 移除 @marcbachmann/cel-js(import 与 package.json 双清,pnpm 侧 symlink 随之消失)。超界表达式不再由本闸门二次判定,交还给同一批 调用点上本就在跑的 validateExpression。 注:issue 正文所设想的洞(formula 会重写、裸 cel-js 解析不了 → lint 静默逃过) 经实测不成立,且不可构造 —— rewriteNullableTernary 先 parse,失败即原样返回, 故它永远无法把「解析不了」变成「解析得了」。真实分歧方向相反,且在 bounds 上。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…mula-canonical-parse
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
范围外发现(Prime Directive #10,均已另开 issue,未指派,本 PR 一行未改)两条都是做本 PR 时撞到的,与 #4812 同源但不在其 scope 内:
第二条正是本 PR 对拍测试里那条 Generated by Claude Code |
|
ACCEPT(执行席 PM 验收)—— 含一条公开的前提修正,请维护者留意 前提修正(issue 与入队裁决的「欠强制」半句被实测证伪,收敛修法不受影响):「formula 会重写、裸 cel-js 解析不了 → 静默逃过闸门」的形状由构造决定不可构造 —— 其余核过:① lint 依赖与 import 双清(定向抽查 + Validate Package Dependencies 绿);② 对拍 32 例 + lint 5 例,反向验证三肢全中 —— 肢 A 首跑抓到自己的空对 fixture 并公开修正,红/绿线两头都有断言;③ formula 导出面纯增量,三个直接消费方(objectql/plugin-security/plugin-sharing)重建后全绿;④ #5905 的必答项质量高:指出照抄收敛=把语义裁决伪装成重构,且 having-filter 面缺 conformance 覆盖、收敛前先补覆盖 —— 记入该单排批参考;⑤ 范围外发现两张:#6132(cel-to-filter 第三个解析入口,RLS 下推路径,刻意留单独裁决 —— 正确)、#6133(classifyError 关键词分类漏配对错误,交分诊)。 翻 ready + auto-merge,进队列。 Generated by Claude Code |
|
队列管家:本 PR 是链上连坐**(同一条链的第二例),自身无问题 ⇒ ⛔ 未重投、无需改动** 03:16Z 的队列世代
链序铁证(逐跳核过,非推测):
⇒ 链序 #5827 → #6102 → 本 PR;真因是链首 #5827(把 DEBT/TEST_DEBT 台账改成「每次重测的真棘轮」,新门禁上线即照出 现状与预期:#5827 已于 03:25:18Z 被踢出,链已重建为 已核让行:本 PR 最近 30 分钟无车道 PM 动作。 Generated by Claude Code |
Fixes #4812
packages/lint绕过@objectstack/formula直接 parse CEL,两个解析入口对「什么能解析」给出两个答案。本 PR 把答案收敛成一个。一、前提复核(逐条对
origin/main重验)validate-null-guards.ts直接import { Environment }+import type { ASTNode },getParseEnv().parse(source).ast,失败catch { return [] }validate-null-guards.ts:122-123、160-168、343-349cel-engine.ts解析前做rewriteNullableTernary,注释明说为了 "what parses agrees"cel-engine.ts:159-161(collectCelRootIdentifiers)、:750(compile)、:802(evaluate)@marcbachmann/cel-js@^8.0.0package.json;lockfile 实解析8.0.0,全仓单实例packages/lint是packages/formula之外全仓唯一的 cel-js 直接 import 方 —— 收敛只需动一个文件:二、前提 5 不成立:分歧方向是反的
单据(以及验收标准第 3 条)预设的洞是「formula 会重写、裸 cel-js 解析不了的形状,在 lint 侧静默跳过」。实测:这样的形状不存在,而且由构造决定不可能存在。
rewriteNullableTernary自己先 parse,parse 失败就原样返回:所以重写永远无法把「解析不了」变成「解析得了」;它只改变解析成功之后拿到的 AST。这条已写成断言钉住(
cannot change WHETHER a source parses — only the AST it yields)。真实分歧在 bounds,方向相反 —— lint 解析得比平台更宽: lint 自建的 env 不带
limits,formula 每条入口都带DEFAULT_LIMITS。实测:compile()Exceeded maxAstNodes (256)Exceeded maxDepth (32)Exceeded maxListElements (64)即:闸门此前会去判定平台自己直接拒绝的谓词 —— 不是欠强制,是过强制。架构诉求(必须只有一个答案)完全成立,修法也完全成立;只是钉洞测试钉的是实测方向,而不是单据预设的方向。
三、处置
新入口(签名与摆放)
packages/formula/src/cel-engine.ts,紧跟collectCelRootIdentifiers:packages/formula/src/index.ts与lowerCelAst/collectCelRootIdentifiers/isPushdownableCel同层导出。三点设计取舍:
compile()才是 parse + check。解析成功但类型检查不过的表达式(大量dyn操作数的谓词即是)必须仍能拿到 AST,否则 null-guard pass 会因为 cel-js 推不出类型而整片失明。这条不对称是故意的,已双向钉住,免得后人把它「收紧」成第二个 compile。null,不抛。 消费方的职责不是裁决语法,一行if (!ast) return []就能把裁决权交还给真正拥有它的闸门。CelAstNode而非裸ASTNode。 贴包内Cel*前缀惯例(CelFilterCompileResult/collectCelRootIdentifiers/isPushdownableCel);本包同时拥有 cron 与 template 方言,裸ASTNode有歧义。这个 re-export 顺带补上一个既有缺口:lowerCelAst一直接收 cel-js 的ASTNode,而该类型从未导出 —— 消费方想持有 AST 就只能越过本包直连 cel-js,这正是第二个解析入口的成因。消费方
validate-null-guards.ts改走该入口;packages/lint/package.json移除@marcbachmann/cel-js。pnpm install后packages/lint/node_modules/@marcbachmannsymlink 随之消失 —— 依赖切断是结构性的,不只是声明上的。「解析失败静默跳过」的姿态按单据要求保留,但现在这个集合与平台一致。超界表达式交还给同一批调用点上本就在跑的
validateExpression(validate-expressions.ts在每个 null-guard 面上check()与checkNullGuards()是并列调用的),它以 blocking error 报Exceeded max…;作者修好边界问题后 null-guard 判定自然回来。没有覆盖被删除,只是搬到了正确的闸门 —— 这一条也钉了断言,不是口头声明。四、测试与反向验证(方向先写死,再运行)
新增
packages/formula/src/parse-cel-to-ast.test.ts(新文件,32 例):与compile()的对拍 —— compile 成功 ⟹ 新入口成功且 AST 相同;新入口 null ⟹ compile 也失败;parse 过但 check 不过的故意不对称;Shipped template formula fields silently evaluate to null on @objectstack 15.1.1 — daysBetween / Timestamp−Timestamp / floor in stored formulas (hr tenure_years, time_off days) #3306 重写钉住;bounds 钉住(同时断言裸 env 确实解析得了,让测试自己陈述分歧而不只是受益于修复)。packages/lint/src/validate-null-guards.test.ts+5 例:超界谓词现在返回[];bounds 闸门确实在说话(kind: 'bounds'+Exceeded maxAstNodes);同形状缩到界内仍然照常判定;Shipped template formula fields silently evaluate to null on @objectstack 15.1.1 — daysBetween / Timestamp−Timestamp / floor in stored formulas (hr tenure_years, time_off days) #3306 重写对本 pass verdict-neutral(两个方向都断言)。对拍用例有一处必须如实说明:不能用裸 deep-equal。
compile()会跑check(),而check()就地给每个节点挂上整套求值计划 —— 实测record.amount > 1000的根节点多出left/right/candidates/handle(函数)/rightStaticType/checkedType,且candidates.registry指回 Environment,树是循环的(第一版 helper 直接爆栈)。故对拍投影到op+args—— 这恰好也是全仓每个 AST 消费方实际走的面。反向验证
预测写在
predictions.md,运行前定稿。expected [ { operand: 'record.budget', … } ] to deeply equal []rewriteNullableTernaryB' 是预先声明的绿,不是漏网:先写下「这里绿才是对的」,绿了才算确认。
A 第一次跑没翻红 —— 这是本 PR 最该被读到的一段。 第一版 fixture 写成
record.budget != null && (300 项连加) > 0,唯一的可空操作数record.budget被!= null守住了,于是无论解析成功与否都返回[]—— 断言通过的原因是「什么都没产出」,不是「边界移动了」。反向验证抓到了它。改成record.budget > 100 && …(超界 且 含一个真正无守卫的可空操作数)后按预测翻红。fixture 里已把这段经过写成注释,并补了一条「同形状缩到界内 IS reported」的对照断言,让红/绿线两头都被断言,而不是只断言一头。命令与真实输出
formula 是被广泛依赖的包(cli / mcp / metadata-protocol / objectql / runtime / plugin-approvals / plugin-email / plugin-security / plugin-sharing / service-automation / lint)。本次改动对既有导出面是纯增量 —— 唯一改到既有代码的是多加一行
import type,其余全是新增 —— 所以消费方在 API 层面不可能被破坏。仍按 AGENTS §10 重建依赖链后跑了三个直接消费方:objectql(celEngine 的主消费方,rule-validator / cel-fault 归它)与plugin-security/plugin-sharing(cel-to-filter 面,与改动相邻):门禁:
已
git merge origin/main(24 个提交,无冲突),合并后重装 + 重建依赖链 + 复跑上述全部,结论不变。五、必答项
#4811 —— 本 PR 是否改其定价?
不改。 #4811 已 closed(completed),其结论沉淀为
validate-null-guards.ts里那张 surface ledger(逐面 TOTAL/sparse + 证据 + 裁决)。本 PR 一个字都没动那张表:改的是「拿到 AST 的那一步」,不是「哪些面该被这个闸门覆盖」。两者正交 —— totality 判据决定接哪些面,parse 入口决定同一个面上能看懂多少源码。#4811 遗留的两项待判(action 谓词绑定是否该改成 total、扁平作用域下字段 vs flow 变量的判据)本 PR 未触及,定价不变。#5905 —— 你的收敛样本对它是否可照抄?
不触其面。答案:形状同源,但样本不可直接照抄,可照抄的是方法。
同源在「N 个实现对同一问题给出 N 个答案」。但两者的分歧轴不同,这决定了修法不同:
source → AST,无状态、无语义裁决余地。「哪个是对的」不需要产品判断:平台运行时用哪条,哪条就是对的。所以收敛 = 抽一个 canonical 入口,消费方改调,不需要任何裁决。having-filter.ts是「无值字段」语义的第五个求值面,且带着 #5299 同款的早退守卫($nin/$notContains不在豁免名单) #5905 是求值语义分歧($nin/$notContains对无值行判是还是判否)。这里没有「运行时用哪条」可诉诸 —— 五个面各自都是运行时,而且 非否定路径上的$ne/$nin/$notContains:driver-sql 排除 NULL 行,driver-memory / formula 返回它们(#5146 只裁定了$not) #5298 已经在裁「哪个答案是对的」。必须先有语义裁决,才谈得上收敛。所以:可照抄的是「抽 canonical 入口 + 消费方改调 + 对拍测试钉住两者一致 + 反向验证摘肢」这套骨架 —— 但那是 #5298 / #5299 裁决落地之后的动作。在裁决之前照抄本 PR,等于把五个答案里随便一个提升成 canonical,那是把语义裁决伪装成重构。另有一处不对称值得记:having-filter 面没有任何 conformance 表覆盖(
FILTER_LOGIC_CASES不驱动 HAVING 路径),而本 PR 的收敛一落地就有对拍测试兜底 —— 收敛前先补覆盖,顺序不能反。cel-js 版本约束现状,formula 侧是否需要收紧?
现状: 改前
formula与lint各声明"@marcbachmann/cel-js": "^8.0.0";lockfile 实解析8.0.0,node_modules/.pnpm下单一实例,两包共享 —— 所以「版本没漂」这一点复核成立,已存在的是语义漂移而非版本漂移。改后只剩formula一处声明,lint的 deps 与 symlink 双清。是否需要收紧:不需要,且本 PR 不动它。 三点理由:
^8.0.0允许 8.x,而本 PR 新增的对拍测试恰好就是 8.x 内部行为漂移的探针:cel-js 若在某个 8.x 改了 parse 接受集或 AST 形状,parse-cel-to-ast.test.ts会红。版本闸门换成了行为闸门,后者更准。Validate Package Dependenciesis red onmain— 8 FIXABLE OSV advisories (undici / hono / fast-uri), so every PR inherits a red required-ish check #5032 / ci(deps): pin brace-expansion to 5.0.9 for GHSA-rgw5-rvv9-x895 (#4945) #4961 的教训:上界写成< FIXED这种互斥固定版本,会在被钉版本自己出 advisory 那天自失效)。夹带进一个 refactor PR 不合适。只答不扩 scope,一行未改。
Generated by Claude Code