fix(formula): classifyError 把 cel-js 的 parse 错误按错误类归为 syntax/parse,不再误报 runtime (#6133) - #6202
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…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
|
CI 全绿 + 一条范围外发现已另立单据。 CI(head
|
Fixes #6133
前提重验(
origin/main80f7dc6a3)单据成立,且比单据描述的更大。
packages/formula/src/cel-engine.ts:803的classifyError与单据引用逐字一致(默认runtime,三条关键词分支)。词表核查结论(必答项 1)
从 cel-js 8.0.0 源码枚举全部
parseError(...)抛出点,再逐条实测各措辞在改前分类器下的判定。共 20 类 parse 期错误 code,只有 3 类命中关键词:unexpected_characterUnexpected character: $parse✅parseunexpected_tokenUnexpected token: EOF/: QUESTIONparse✅parselimit_exceededExceeded maxAstNodes (256)bounds✅boundsexpected_tokenExpected RPAREN, got EOF(括号)runtime❌parseexpected_tokenExpected RBRACKET, got EOF(方括号)runtime❌parseexpected_tokenExpected RBRACE, got EOF(花括号)runtime❌parseexpected_tokenExpected COLON, got EOF(三元缺冒号)runtime❌parseunterminated_stringUnterminated stringruntime❌parseunterminated_triple_quoted_stringUnterminated triple-quoted stringruntime❌parsenewline_in_stringNewlines not allowed in single-quoted stringsruntime❌parseinvalid_hex_integerInvalid hex integer: 0xruntime❌parseinvalid_escape_sequenceInvalid escape sequence: \qruntime❌parseinvalid_unicode_escapeInvalid Unicode escape: \u12runtime❌parseinvalid_hex_escapeInvalid hex escape: \xZZruntime❌parseinvalid_octal_escapeOctal escape must be 3 digitsruntime❌parsereserved_identifierReserved identifier: packageruntime❌parseinvalid_number†Invalid number: …runtimeparseinvalid_integer/invalid_exponent†Invalid integer: …/Invalid exponentruntimeparseoctal_escape_out_of_range†Octal escape out of range: \…runtimeparseinvalid_unicode_surrogate†Invalid Unicode surrogate: \…runtimeparsebytes_unicode_escape†\u not allowed in bytes literalsruntimeparseexpression_must_be_string†Expression must be a stringruntimeparseinvalid_macro_argument†parse† = 从 cel-js 源码枚举得到、但未实测复现(构造触发它的源码不划算)。上半 16 行是逐条实测的(见新增测试)。带 † 的行"改前"一列是按现有关键词表推演,不是量测;"改后"一列则是确定的 —— 结构化判定覆盖
ParseError全类,不依赖逐条枚举,这正是换掉关键词表的收益,也是"补关键词"永远做不到的:下一次 cel-js 改措辞或加一个 code,关键词表就再破一次,类判定不会。关键词补表补不完,也补不对 —— 这是本 PR 偏离派单的原因。 cel-js 把作者自己的源码行嵌进
message(lib/errors.js的formatErrorWithHighlight),所以关键词匹配的是作者可控的文本。实测:字段名能决定错误分类。这不是"表里有洞",这就是洞。所以本 PR 按单据与分诊双方都点名的根治方向改:读 cel-js 抛出的错误类(
ParseError/EvaluationError/TypeError均为 cel-js 的 public export,lib/index.d.ts:188-225有类型声明),不读文案。拿不准 / 刻意不改的一格(留报告,未猜):
type与runtime两支仍走原关键词表。原因是实测出的一条契约事实 —— cel-js 的TypeChecker按阶段而非按故障选择错误类:同一个
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未触。测试
新增
packages/formula/src/cel-error-classification.test.ts(23 例),每类措辞一条 fixture,并钉住"不许丢"的另一半:bounds(limit_exceeded是ParseError,必须先判)、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 RPAREN、constraint.fault同步,且 #4649 的 fail-closed 未被削弱。消费半径已 grep 扫过:全仓无第二处测试对 parse 故障断言
runtime。反向验证(方向先写死,再跑)
肢 A:移除结构化判定(
classifyCelParseFault(err)->undefined),回落纯关键词表。预测(跑之前写下):formula 侧 23 例中 14 例翻红 —— 4 条分隔符 + 9 条其余措辞 + 1 条源码污染;3 条本就命中关键词的、
bounds/type/runtime/dialect/parseCelToAst共 9 例保持绿。源码污染那条应特异性地退化为type(不是runtime)。消费面 4 例中 2 例翻红。实测:
消费面(肢 A 下重建 formula dist 后):
逐条吻合,含那条特异的
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 #6130 的
parseCelToAst不在
classifyError路径上,实测确认。parseCelToAst(cel-engine.ts:241)的 catch 是空的,直接return null,从不构造EvalResult、也从不读 kind:已在
cel-error-classification.test.ts最后一条用例把这个事实钉住(parseCelToAst('((record.a)')返回null,合法源码返回非 null)。所以本 PR 对 #6130 新增的入口零影响 —— 它把语法裁决留给compile()/validateExpression,而后者拿到的正是被本 PR 修正的 kind。风险
低。改动是一个函数内的一次前置判定,关键词表逐字保留作兜底;全部行为变化都在"改前判为
runtime/type、改后判为parse"这一格,且该格改前的判定是错的。词表未扩、消费方未改、公开 API 签名未变。Generated by Claude Code