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

@baozhoutao
baozhoutao marked this pull request as ready for review August 7, 2026 11:16
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 07c68b0 Aug 7, 2026
24 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-6133-classify-parse-errors branch August 7, 2026 11:31
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