Skip to content

refactor(formula,lint): parseCelToAst 成为唯一的 CEL 解析入口 (#4812) - #6130

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-4812-formula-canonical-parse
Aug 7, 2026
Merged

refactor(formula,lint): parseCelToAst 成为唯一的 CEL 解析入口 (#4812)#6130
baozhoutao merged 2 commits into
mainfrom
claude/issue-4812-formula-canonical-parse

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes#4812

packages/lint 绕过 @objectstack/formula 直接 parse CEL,两个解析入口对「什么能解析」给出两个答案。本 PR 把答案收敛成一个。


一、前提复核(逐条对 origin/main 重验)

#单据主张结论证据
1validate-null-guards.ts 直接 import { Environment } + import type { ASTNode },getParseEnv().parse(source).ast,失败 catch { return [] }✅ 成立validate-null-guards.ts:122-123160-168343-349
2cel-engine.ts 解析前做 rewriteNullableTernary,注释明说为了 "what parses agrees"✅ 成立cel-engine.ts:159-161(collectCelRootIdentifiers)、:750(compile)、:802(evaluate)
3两包都是 @marcbachmann/cel-js@^8.0.0✅ 成立两份 package.json;lockfile 实解析 8.0.0,全仓单实例
4存在两个解析入口,且对「什么能解析」答案不同✅ 成立见下表
5分歧方向 = 「formula 会重写并接受、lint 解析不了 → 静默逃过闸门(欠强制)」不成立,且不可构造见 §二

packages/lintpackages/formula 之外全仓唯一的 cel-js 直接 import 方 —— 收敛只需动一个文件:

packages/formula/src/stdlib.ts:14 import type { Environment }
packages/formula/src/cel-engine.ts:16 import { Environment, serialize }
packages/formula/src/cel-to-filter.ts:45 import { Environment }
packages/lint/src/validate-null-guards.ts:122 ← 唯一的外部消费方

二、前提 5 不成立:分歧方向是反的

单据(以及验收标准第 3 条)预设的洞是「formula 会重写、裸 cel-js 解析不了的形状,在 lint 侧静默跳过」。实测:这样的形状不存在,而且由构造决定不可能存在。

rewriteNullableTernary 自己先 parse,parse 失败就原样返回:

let ast: unknown;
try {
ast = (recordScopeEnv ??= buildScopedEnv([])).parse(source).ast;
} catch {
rememberNullableRewrite(source, source);
return source; // ← 解析不了 → 什么都不改
}

所以重写永远无法把「解析不了」变成「解析得了」;它只改变解析成功之后拿到的 AST。这条已写成断言钉住(cannot change WHETHER a source parses — only the AST it yields)。

真实分歧在 bounds,方向相反 —— lint 解析得比平台更宽: lint 自建的 env 不带 limits,formula 每条入口都带 DEFAULT_LIMITS。实测:

源码形状lint 旧 envformula compile()
300 项连加解析通过Exceeded maxAstNodes (256)
60 层括号解析通过Exceeded maxDepth (32)
200 元素列表解析通过Exceeded maxListElements (64)

即:闸门此前会去判定平台自己直接拒绝的谓词 —— 不是欠强制,是过强制。架构诉求(必须只有一个答案)完全成立,修法也完全成立;只是钉洞测试钉的是实测方向,而不是单据预设的方向。

三、处置

新入口(签名与摆放)

packages/formula/src/cel-engine.ts,紧跟 collectCelRootIdentifiers:

export type CelAstNode = ASTNode; // re-export
export function parseCelToAst(source: string): CelAstNode | null

packages/formula/src/index.tslowerCelAst / collectCelRootIdentifiers / isPushdownableCel 同层导出。

三点设计取舍:

  1. 只做 parse,不做 check。compile() 才是 parse + check。解析成功但类型检查不过的表达式(大量 dyn 操作数的谓词即是)必须仍能拿到 AST,否则 null-guard pass 会因为 cel-js 推不出类型而整片失明。这条不对称是故意的,已双向钉住,免得后人把它「收紧」成第二个 compile。
  2. 失败返回 null,不抛。 消费方的职责不是裁决语法,一行 if (!ast) return [] 就能把裁决权交还给真正拥有它的闸门。
  3. 命名 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-jspnpm installpackages/lint/node_modules/@marcbachmannsymlink 随之消失 —— 依赖切断是结构性的,不只是声明上的。

「解析失败静默跳过」的姿态按单据要求保留,但现在这个集合与平台一致。超界表达式交还给同一批调用点上本就在跑的 validateExpression(validate-expressions.ts 在每个 null-guard 面上 check()checkNullGuards() 是并列调用的),它以 blocking error 报 Exceeded max…;作者修好边界问题后 null-guard 判定自然回来。没有覆盖被删除,只是搬到了正确的闸门 —— 这一条也钉了断言,不是口头声明。

四、测试与反向验证(方向先写死,再运行)

新增

对拍用例有一处必须如实说明:不能用裸 deep-equalcompile() 会跑 check(),而 check()就地给每个节点挂上整套求值计划 —— 实测 record.amount > 1000 的根节点多出 left / right / candidates / handle(函数)/ rightStaticType / checkedType,且 candidates.registry 指回 Environment,树是循环的(第一版 helper 直接爆栈)。故对拍投影到 op + args —— 这恰好也是全仓每个 AST 消费方实际走的面。

反向验证

预测写在 predictions.md,运行前定稿。

摘除的肢预测实测符合
A lint 改回自建 limitless env钉洞测试翻红,1 条翻红,恰 1 条:expected [ { operand: 'record.budget', … } ] to deeply equal []
B 新入口去掉 rewriteNullableTernaryAST 对拍半边翻红翻红 4 条,全部是可空三元形状
B' 同上,accept/reject 半边保持绿(重写不可能改变「能否解析」)保持绿,28 passed

B' 是预先声明的绿,不是漏网:先写下「这里绿才是对的」,绿了才算确认。

A 第一次跑没翻红 —— 这是本 PR 最该被读到的一段。 第一版 fixture 写成 record.budget != null && (300 项连加) > 0,唯一的可空操作数 record.budget!= null 守住了,于是无论解析成功与否都返回 [] —— 断言通过的原因是「什么都没产出」,不是「边界移动了」。反向验证抓到了它。改成 record.budget > 100 && …(超界 含一个真正无守卫的可空操作数)后按预测翻红。fixture 里已把这段经过写成注释,并补了一条「同形状缩到界内 IS reported」的对照断言,让红/绿线两头都被断言,而不是只断言一头。

命令与真实输出

$ pnpm --filter @objectstack/formula test
Test Files 17 passed (17)
Tests 399 passed (399)
$ pnpm --filter @objectstack/lint test
Test Files 61 passed (61)
Tests 1466 passed (1466)
$ pnpm --filter @objectstack/formula --filter @objectstack/lint typecheck
packages/formula typecheck: Done
packages/lint typecheck: Done

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 面,与改动相邻):

packages/objectql test: Test Files 130 passed (130) | Tests 2155 passed (2155)
packages/plugins/plugin-sharing test: Test Files 13 passed (13) | Tests 347 passed (347)
packages/plugins/plugin-security test: Test Files 35 passed (35) | Tests 768 passed (768)

门禁:

$ node scripts/check-engine-double-contract.mjs
check-engine-double-contract: OK — 73 pinned, 133 in the DEBT ledger, 2 exempt.
$ node scripts/check-nul-bytes.mjs
check-nul-bytes: OK (scanned 5867 tracked text file(s); … no raw ASCII control bytes).
$ node scripts/check-empty-changeset.mjs
✓ No empty-frontmatter changeset introduced by this diff (2 declaring changeset(s) added).
$ pnpm --filter @objectstack/spec check:generated
✓ All 10 generated artifacts are up to date.

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 个答案」。但两者的分歧轴不同,这决定了修法不同:

所以:可照抄的是「抽 canonical 入口 + 消费方改调 + 对拍测试钉住两者一致 + 反向验证摘肢」这套骨架 —— 但那是 #5298 / #5299裁决落地之后的动作。在裁决之前照抄本 PR,等于把五个答案里随便一个提升成 canonical,那是把语义裁决伪装成重构。另有一处不对称值得记:having-filter 面没有任何 conformance 表覆盖(FILTER_LOGIC_CASES 不驱动 HAVING 路径),而本 PR 的收敛一落地就有对拍测试兜底 —— 收敛前先补覆盖,顺序不能反。

cel-js 版本约束现状,formula 侧是否需要收紧?

现状: 改前 formulalint 各声明 "@marcbachmann/cel-js": "^8.0.0";lockfile 实解析 8.0.0,node_modules/.pnpm单一实例,两包共享 —— 所以「版本没漂」这一点复核成立,已存在的是语义漂移而非版本漂移。改后只剩 formula 一处声明,lint 的 deps 与 symlink 双清。

是否需要收紧:不需要,且本 PR 不动它。 三点理由:

  1. 收敛后声明点从 2 个降到 1 个,双声明漂移的风险已被结构性消除 —— 这正是「唯一入口」要买的东西。再收紧 range 是在解决一个已经不存在的问题。
  2. ^8.0.0 允许 8.x,而本 PR 新增的对拍测试恰好就是 8.x 内部行为漂移的探针:cel-js 若在某个 8.x 改了 parse 接受集或 AST 形状,parse-cel-to-ast.test.ts 会红。版本闸门换成了行为闸门,后者更准。
  3. 收紧上界属于依赖策略变更,应当走依赖 PR 并遵守本仓已有的「上界不写成互斥的 fixed version」纪律(Validate Package Dependencies is red on main — 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

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
@vercel

vercelBot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 7, 2026 3:05am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/formula, @objectstack/lint.

9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via @objectstack/lint)
  • content/docs/data-modeling/formulas.mdx(via @objectstack/formula)
  • content/docs/data-modeling/validation.mdx(via @objectstack/formula)
  • content/docs/permissions/authorization.mdx(via @objectstack/lint)
  • content/docs/plugins/packages.mdx(via @objectstack/formula)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/formula)
  • content/docs/releases/v15.mdx(via @objectstack/formula)
  • content/docs/releases/v16.mdx(via @objectstack/formula)
  • content/docs/releases/v17.mdx(via @objectstack/lint)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@baozhoutaoClaude

Copy link
Copy Markdown
ContributorAuthor

范围外发现(Prime Directive #10,均已另开 issue,未指派,本 PR 一行未改)

两条都是做本 PR 时撞到的,与 #4812 同源但不在其 scope 内:

第二条正是本 PR 对拍测试里那条 classifies the common syntax fault as 'parse' 只断言 record.budget >、并在注释里写明为什么刻意不把 ((record.a) 断言进去的原因 —— 把现状断言下来等于把这个 bug 钉成契约。


Generated by Claude Code

@baozhoutaoClaude

Copy link
Copy Markdown
ContributorAuthor

ACCEPT(执行席 PM 验收)—— 含一条公开的前提修正,请维护者留意

前提修正(issue 与入队裁决的「欠强制」半句被实测证伪,收敛修法不受影响):「formula 会重写、裸 cel-js 解析不了 → 静默逃过闸门」的形状由构造决定不可构造 —— rewriteNullableTernary 自己先 parse、失败即原样返回(cel-engine.ts:602-608),永远无法把不可解析变为可解析。真实分歧在 bounds 且方向相反:lint 旧环境无 limits,一直在判定平台 DEFAULT_LIMITS 会直接拒绝的超界谓词(过强制)。dev 未静默改道:架构诉求(全仓一个解析答案)与修法(canonical 入口 + 改调 + 断依赖)原样成立,验收第 3 条按实测方向改钉,并把「超界谓词的覆盖交还给 bounds 闸门(blocking error)而非 null-guard(finding)」用断言双向钉住。2026-08-06 入队裁决声明过维护者可否决 —— 本条即否决窗口的材料。

其余核过:① 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

@baozhoutao
baozhoutao marked this pull request as ready for review August 7, 2026 03:10
@baozhoutao
baozhoutao enabled auto-merge August 7, 2026 03:10
@baozhoutao
baozhoutao added this pull request to the merge queueAug 7, 2026
@claude

claudeBot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

队列管家:本 PR 是链上连坐**(同一条链的第二例),自身无问题 ⇒ ⛔ 未重投、无需改动**

03:16Z 的队列世代 pr-6130-47be063d 判红:失败 job TypeScript Type Check,致命 step 26 Re-measure the type-check DEBT / TEST_DEBT ledger,签名 @objectstack/mcp: TEST_DEBT records 52 … now reports 53 (+1)

packages/mcp 不在本 PR 的改动面内(本 PR 动 packages/formulapackages/lint、两个 changeset、lockfile)。

链序铁证(逐跳核过,非推测):

分支head_sha
1pr-5827-811c30c1(base = 当时的 main)eacd73de
2pr-6102-eacd73de47be063d
3pr-6130-47be063d ← 本 PR 的红世代

⇒ 链序 #5827#6102 → 本 PR;真因是链首 #5827(把 DEBT/TEST_DEBT 台账改成「每次重测的真棘轮」,新门禁上线即照出 mcp 台账 52 与实测 53 的既存差)。完整签名与修法见 #5827 的拦截评论

现状与预期:#5827 已于 03:25:18Z 被踢出,链已重建为 pr-6102-811c30c1 → pr-6130-ce9c1859 → pr-6134-58f3220a,#5827 不在链上 ⇒ 本 PR 当前世代预计自动转绿。⛔ 本座位未重投(无必要)、未撤队、未改代码。

已核让行:本 PR 最近 30 分钟无车道 PM 动作。


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

packages/lint 绕过 @objectstack/formula 直接 parse CEL —— 两个解析入口对「什么能解析」会给出不同答案

2 participants

@baozhoutao@claude