发现于 #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 里修
两条路都不通:
所以直接引 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 真正要消灭的洞。
关联
发现于 #4763(PR #4810)的 PM 复核。未认领;落点在
packages/formula+packages/lint,不在本 PM 车道内,按 Prime Directive #10 只记录不修。现象
#4763 新增的
packages/lint/src/validate-null-guards.ts直接从第三方库取 CEL 解析器:而
@objectstack/formula是本仓唯一的 CEL 封装(其 package.json 自述:「ObjectStack canonical expression engine — CEL (cel-js) + ObjectStack stdlib + dialect registry」),packages/lint本来就依赖它。为什么这不只是「多一个依赖」
packages/formula/src/cel-engine.ts在解析之前会重写源码,而且注释明确写了这么做的理由:也就是说 formula 认为裸 cel-js 的可解析集与平台实际接受的可解析集不一致,并用
rewriteNullableTernary把两者对齐。packages/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;关联
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) #4810materializeDeclaredFields从两处收敛成一处共享 helper)