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 重验)

# 单据主张 结论 证据
1 validate-null-guards.ts 直接 import { Environment } + import type { ASTNode },getParseEnv().parse(source).ast,失败 catch { return [] } ✅ 成立 validate-null-guards.ts:122-123160-168343-349
2 cel-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 旧 env formula 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/@marcbachmann symlink 随之消失 —— 依赖切断是结构性的,不只是声明上的。

「解析失败静默跳过」的姿态按单据要求保留,但现在这个集合与平台一致。超界表达式交还给同一批调用点上本就在跑的 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 新入口去掉 rewriteNullableTernary AST 对拍半边翻红 翻红 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

claude added 2 commits August 7, 2026 02:38
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

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 3:05am

Request Review

@github-actions github-actions Bot added the size/m label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

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.

Copy link
Copy Markdown
Contributor Author

范围外发现(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

Copy link
Copy Markdown
Contributor Author

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 queue Aug 7, 2026
@claude

claude Bot 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
1 pr-5827-811c30c1(base = 当时的 main) eacd73de
2 pr-6102-eacd73de 47be063d
3 pr-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 free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants