Skip to content

fix(formula): classifyError 把 cel-js 的 parse 错误按错误类归为 syntax/parse,不再误报 runtime (#6133) - #6202

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-6133-classify-parse-errors
Aug 7, 2026
Merged

fix(formula): classifyError 把 cel-js 的 parse 错误按错误类归为 syntax/parse,不再误报 runtime (#6133)#6202
baozhoutao merged 2 commits into
mainfrom
claude/issue-6133-classify-parse-errors

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #6133

前提重验(origin/main 80f7dc6a3)

单据成立,且比单据描述的更大packages/formula/src/cel-engine.ts:803classifyError 与单据引用逐字一致(默认 runtime,三条关键词分支)。

词表核查结论(必答项 1)

从 cel-js 8.0.0 源码枚举全部 parseError(...) 抛出点,再逐条实测各措辞在改前分类器下的判定。共 20 类 parse 期错误 code,只有 3 类命中关键词:

cel-js code 首行文案 改前 kind 改后
unexpected_character Unexpected character: $ parse parse
unexpected_token Unexpected token: EOF / : QUESTION parse parse
limit_exceeded Exceeded maxAstNodes (256) bounds bounds
expected_token Expected RPAREN, got EOF(括号) runtime parse
expected_token Expected RBRACKET, got EOF(方括号) runtime parse
expected_token Expected RBRACE, got EOF(花括号) runtime parse
expected_token Expected COLON, got EOF(三元缺冒号) runtime parse
unterminated_string Unterminated string runtime parse
unterminated_triple_quoted_string Unterminated triple-quoted string runtime parse
newline_in_string Newlines not allowed in single-quoted strings runtime parse
invalid_hex_integer Invalid hex integer: 0x runtime parse
invalid_escape_sequence Invalid escape sequence: \q runtime parse
invalid_unicode_escape Invalid Unicode escape: \u12 runtime parse
invalid_hex_escape Invalid hex escape: \xZZ runtime parse
invalid_octal_escape Octal escape must be 3 digits runtime parse
reserved_identifier Reserved identifier: package runtime parse
invalid_number Invalid number: … 无关键词 -> runtime parse
invalid_integer / invalid_exponent Invalid integer: … / Invalid exponent 无关键词 -> runtime parse
octal_escape_out_of_range Octal escape out of range: \… 无关键词 -> runtime parse
invalid_unicode_surrogate Invalid Unicode surrogate: \… 无关键词 -> runtime parse
bytes_unicode_escape \u not allowed in bytes literals 无关键词 -> runtime parse
expression_must_be_string Expression must be a string 无关键词 -> runtime parse
invalid_macro_argument (宏参数,文案未取到) 未测 parse

† = 从 cel-js 源码枚举得到、但未实测复现(构造触发它的源码不划算)。上半 16 行是逐条实测的(见新增测试)。带 † 的行"改前"一列是按现有关键词表推演,不是量测;"改后"一列则是确定的 —— 结构化判定覆盖 ParseError 全类,不依赖逐条枚举,这正是换掉关键词表的收益,也是"补关键词"永远做不到的:下一次 cel-js 改措辞或加一个 code,关键词表就再破一次,类判定不会。

关键词补表补不完,也补不对 —— 这是本 PR 偏离派单的原因。 cel-js 把作者自己的源码行嵌进 message(lib/errors.jsformatErrorWithHighlight),所以关键词匹配的是作者可控的文本。实测:

((record.type_id)   -> 改前 kind = 'type'      (普通括号不配对,只因回显源码含子串 "type")
((record.parsed_at) -> 改前 kind = 'parse'     (碰巧对,理由是错的)

字段名能决定错误分类。这不是"表里有洞",这就是洞。所以本 PR 按单据与分诊双方都点名的根治方向改:读 cel-js 抛出的错误(ParseError / EvaluationError / TypeError 均为 cel-js 的 public export,lib/index.d.ts:188-225 有类型声明),不读文案。

拿不准 / 刻意不改的一格(留报告,未猜):typeruntime 两支仍走原关键词表。原因是实测出的一条契约事实 —— cel-js 的 TypeChecker阶段而非按故障选择错误类:

// node_modules/@marcbachmann/cel-js/lib/type-checker.js:16
this.createError = isEvaluating ? evaluationError : typeError

同一个 unknown_variable 故障,check 期抛 TypeError、eval 期抛 EvaluationError。若把 EvaluationError 整体判为 runtime,Unknown variable: x 会从今天正确的 type 变成 runtime —— 这是本单据没要求、也没量化过的判定迁移。逐 code 的映射(18 个 evaluation code)需要单独定价,已写进代码注释与 changeset。

改动

packages/formula/src/cel-engine.ts 一处:

  • 新增 classifyCelParseFault(err):err instanceof ParseError 时返回 parse,但 code === 'limit_exceeded' 先判为 bounds —— cel-js 的越界一律经 parser 抛出(Parser#limitExceeded,每个 limit key 一个调用点),顺序反了会把所有 maxAstNodes 超限报成语法错;
  • classifyError 先问结构化判定,未命中(非 cel-js 抛出的错误,如自家 stdlib / 原生 JS throw)才落回原关键词表,原表逐字未动。

kind 词表本身未变(仍是 parse / type / runtime / bounds / dialect);⛔ 消费方(rule-validator / cel-fault / rest-server)一行未改;⛔ cel-to-filter 未触。

派单写的目标 kind 是 'syntax',但仓库现有词表里没有这一格(cel-engine.ts:805 声明为 'parse' | 'type' | 'runtime' | 'bounds'),且派单同时禁止改词表。按单据正文与既有词表,正确目标是 parse;本 PR 取 parse

测试

新增 packages/formula/src/cel-error-classification.test.ts(23 例),每类措辞一条 fixture,并钉住"不许丢"的另一半:bounds(limit_exceededParseError,必须先判)、type(未知函数 #1877、eval 期 Unknown variable)、runtime(除零、overload)、dialect。每条 parse 用例都断言 compile()evaluate() 给出同一个 kind —— build 期与运行期不许对"作者错在哪"有分歧。

新增 packages/objectql/src/validation/rule-unevaluable-fault-kind.test.ts(4 例,只读消费方,未改一行源码):走真实 evaluateValidationRules 路径,断言作者读到的句子已是 parse: Expected RPARENconstraint.fault 同步,且 #4649 的 fail-closed 未被削弱。

pnpm --filter @objectstack/formula test        Test Files 18 passed (18)  Tests 422 passed (422)
pnpm --filter @objectstack/formula typecheck   tsc --noEmit (clean)
pnpm --filter @objectstack/objectql test       Test Files 136 passed (136) Tests 2218 passed (2218)
pnpm --filter @objectstack/objectql typecheck  tsc --noEmit (clean)
pnpm --filter @objectstack/lint test           Test Files 61 passed (61)  Tests 1466 passed (1466)
node scripts/check-nul-bytes.mjs               OK (scanned 5920 tracked text file(s))

消费半径已 grep 扫过:全仓无第二处测试对 parse 故障断言 runtime

反向验证(方向先写死,再跑)

肢 A:移除结构化判定(classifyCelParseFault(err) -> undefined),回落纯关键词表。

预测(跑之前写下):formula 侧 23 例中 14 例翻红 —— 4 条分隔符 + 9 条其余措辞 + 1 条源码污染;3 条本就命中关键词的、bounds/type/runtime/dialect/parseCelToAst 共 9 例保持绿。源码污染那条应特异性地退化为 type(不是 runtime)。消费面 4 例中 2 例翻红

实测:

 Tests  14 failed | 9 passed (23)
AssertionError: expected 'runtime' to be 'parse'
AssertionError: expected 'runtime' to be 'parse'
AssertionError: expected 'type' to be 'parse'      (源码污染那条,方向如预测)

消费面(肢 A 下重建 formula dist 后):

 Tests  2 failed | 2 passed (4)
Received: "Validation rule 'broken_predicate' could not be evaluated (runtime: Expected RPAREN, got EOF) — write rejected."

逐条吻合,含那条特异的 type 退化。最后一行正是单据主张的作者可见字符串,原样复现。

一处记录:消费面测试读的是 formula 的 dist,不是 src —— 第一次只改 src 跑肢 A 时消费面 4 例全绿(AGENTS.md §9 的陈旧产物陷阱),重建 dist 后才现红。

必答项 2 —— #6132(cel-to-filter 第三入口)

未触其面。 cel-to-filter.ts 从不调用 classifyError:它自建 new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true })(:93,注意没有 limits、没有 stdlib —— 那正是 #6132 的问题),parse 失败在 :125 / :145 就地 catch,产出自己的 reason: 'parse-error' 词表(与 kind 是两套词)。本 PR 与它零交集。

#6132 定价的参考价值:本次审计给出了一条可复用的事实 —— cel-js 的错误类与 code 是 public 契约(ParseError / EvaluationError / TypeError 均在 lib/index.d.ts 导出,带 readonly code: string),而文案不是,因为文案里嵌着作者源码。#6132 若要把第三入口并回 parseCelToAst,其 reason: 'parse-error' 与本文的 kind: 'parse' 就是同一件事的两种拼写,合并时可以共用同一个结构化判定,不必各自维护一张关键词表。另:#6132 那个 env 缺 limits,意味着它今天对超限表达式不产生 limit_exceeded —— 合并时这条会从"不报"变成"报 bounds",属于行为变更,值得单独钉一条 fixture。

必答项 3 —— #4812 / PR #6130parseCelToAst

不在 classifyError 路径上,实测确认。 parseCelToAst(cel-engine.ts:241)的 catch 是空的,直接 return null,从不构造 EvalResult、也从不读 kind:

} catch {
  return null;
}

已在 cel-error-classification.test.ts 最后一条用例把这个事实钉住(parseCelToAst('((record.a)') 返回 null,合法源码返回非 null)。所以本 PR 对 #6130 新增的入口零影响 —— 它把语法裁决留给 compile() / validateExpression,而后者拿到的正是被本 PR 修正的 kind。

风险

低。改动是一个函数内的一次前置判定,关键词表逐字保留作兜底;全部行为变化都在"改前判为 runtime/type、改后判为 parse"这一格,且该格改前的判定是错的。词表未扩、消费方未改、公开 API 签名未变。


Generated by Claude Code

…6133)

`classifyError` decided between `parse` / `type` / `runtime` / `bounds` by
regex-matching the error text. cel-js 8.0.0 has ~19 distinct parse-time
wordings and only three contain `parse` / `unexpected` / `syntax`, so the rest
— unbalanced parens/brackets/braces (`Expected RPAREN, got EOF`), unterminated
strings, every escape-sequence fault, reserved identifiers — fell through to
the default `runtime`.

`kind` is author-facing: it is interpolated verbatim into the write-rejection
sentence (`@objectstack/objectql`'s `rule-validator` / `cel-fault`) and into
the REST error body's `reason`. A missing closing paren was reported as
`runtime`, pointing the author at their data instead of their expression —
the opposite of ADR-0032 D1d's self-correcting messages.

Classify off the error CLASS instead: `ParseError` -> `parse`, with
`code: 'limit_exceeded'` -> `bounds` read first (cel-js raises every bounds
violation through the parser). This also closes a hole no keyword table could:
cel-js embeds the author's own source line in `message`
(`formatErrorWithHighlight`), so a field name could pick the error kind —
measured, `((record.type_id)` was graded `type` purely because the echoed
source contains "type".

The `type` / `runtime` arms deliberately stay on the keyword table: cel-js's
`TypeChecker` picks its error class by PHASE, not by fault
(`isEvaluating ? evaluationError : typeError`), so `unknown_variable` is a
`TypeError` at check time and an `EvaluationError` at evaluate time; routing
`EvaluationError` wholesale would silently re-grade faults the table gets
right today. Audit recorded in #6133.

No change to the `kind` vocabulary and no consumer changed. The objectql test
is a read-only pin on the author-visible string.

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 11:00am

Request Review

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/formula.

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

  • content/docs/data-modeling/formulas.mdx (via @objectstack/formula)
  • content/docs/data-modeling/validation.mdx (via @objectstack/formula)
  • 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)

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.

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 7, 2026
…sion)

`await import('./cel-engine')` needs an explicit `.js` extension under
`moduleResolution: nodenext`, so the new test added one TS2835 to the package's
TEST_DEBT ledger (17 -> 18) and tripped `check:type-check-debt`. The ledger is a
shrink-only ratchet (#5278), and the error is trivially removable rather than
irreducible: `parseCelToAst` is exported from the module the file already
imports statically. Re-measured back to 17, the recorded value, with zero
errors attributable to this PR's files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We

Copy link
Copy Markdown
Contributor Author

CI 全绿 + 一条范围外发现已另立单据。

CI(head 3b2edfca1)

23 个 check run 全部 success/skipped,零失败 —— 含 Check Changeset(本 PR 带 .changeset/cel-parse-fault-kind.md,故不需要 skip-changeset 标签)、TypeScript Type CheckTest Core (1..3/3)Build CoreESLintDogfood Regression Gate (1..3/3)Temporal Conformance (live PG + MySQL)。当前标签:documentation / size/m / tests / tooling

第一次推送(fb7cabf6f)的 TypeScript Type Check 是红的,原因与本改动的逻辑无关,记录下来免得下一位重踩:新测试里写了 await import('./cel-engine'),在 moduleResolution: nodenext 下相对导入必须带 .js 扩展名,于是多出一条 TS2835,把 @objectstack/formulaTEST_DEBT 从 17 顶到 18,触发 check:type-check-debt 的只减不增棘轮(#5278)。没有抬账本 —— 该错误可直接消除:parseCelToAst 就在这个测试已经静态 import 的模块里。改用静态导入后本地按 gate 同样的方式重测(tsc -p 一个 extends 本包 tsconfig、去掉 test 排除的 sibling config)得回 17,且本 PR 两个文件贡献的错误数为 0

范围外发现 -> #6223(未指派,未打 pm:queue,留 PM 分诊)

本 PR 只把 ParseError 一支改成结构化判定,type / runtime 两支按单据要求保留原关键词表。审计过程中实测到:#6133 的根因在那两支上原封未动,而且有一个反向的、今天就能踩到的表现。同一个求值期 no such overload 故障,只因字段名不同就换分类:

record.status > 1         -> kind = 'runtime'   ✅
record.parse_status > 1   -> kind = 'parse'     ❌
record.syntax_mode > 1    -> kind = 'parse'     ❌
record.unexpected_at > 1  -> kind = 'parse'     ❌

四条首行文案一模一样,差别只在 message 尾部回显的源码行。方向与 #6133 相反:#6133 是语法错被答成运行期错,这条是语法完全正确、在数据上求值失败,却被告知 parse —— 作者会去检查一个没有问题的表达式。

刻意没有在本 PR 里顺手修:那需要为 cel-js 的 18 个 evaluation code 逐条定价(已实测:现关键词表对其中 17 个判定正确,唯一必须留在 type 的是 unknown_variable),属于单据没要求、也没量化过的判定迁移。细节与复现都写在 #6223


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants