fix(formula): grade CEL faults by error class + code, not by the message (#6223) - #6677
Merged
Merged
Conversation
…age (#6223) `EvalResult.error.kind` is author-facing: `@objectstack/objectql`'s `cel-fault` puts it in front of the author as `${kind}: ${first line}` and `packages/rest` re-emits it as the HTTP body's `reason`. cel-js embeds the author's own source line in `message` (`formatErrorWithHighlight`), so a classifier that regexes that text is matching text the author writes. PR #6202 closed the ParseError arm structurally and deliberately left `type` / `runtime` on the keyword table pending a per-code audit. This is that audit; its verdict is that the table goes entirely. Measured on cel-js 8.0.0 — one `no such overload` EVALUATION fault, four field names, three wrong answers: record.status > 1 -> runtime (right) record.parse_status > 1 -> parse (wrong) record.syntax_mode > 1 -> parse (wrong) record.type_code > 1 -> type (wrong) `classifyError` now reads only structured contract: ParseError -> `bounds` when `code === 'limit_exceeded'` else `parse`; EvaluationError -> `type` for the one declaration-class code (`unknown_variable`) else `runtime`; anything that is not a cel-js error -> `runtime`. Two audit findings recorded in the code: - The residual keyword arm was NOT dormant. `matches()` is an ObjectStack stdlib binding over `new RegExp(...)`, so an uncompilable pattern escapes as a native SyntaxError echoing the pattern — and the pattern can come off the ROW. `matches(record.name, record.re)` with `re = "(?<type>"` was graded `type`; `"Exceeded maxAstNodes("` was graded `bounds`. - There is deliberately no TypeError arm: cel-js raises that class only from its non-evaluating TypeChecker, which runs only inside `Environment#check`, and that method catches it and RETURNS `{ valid: false, error }`. The check-time TypeError -> `type` mapping already lives in `celEngine.compile`. Every evaluate-time cel-js code the engine can reach now carries a fixture pinning its `kind`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MwoubC3jL271FYt9rGXwxb
… arm (#6223) `(?<type>` is unquotable in a GitHub issue or PR body: the sanitizer strips `<` followed by a letter as an HTML tag at rest, so the fixture that carries this PR's argument would be destroyed the moment anyone pasted it. `type(` is the same defect with the same native `SyntaxError` (`Invalid regular expression: /type(/: Unterminated group`) and survives the round trip. Adds a `syntax[` fixture for the third keyword while there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MwoubC3jL271FYt9rGXwxb
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 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:
|
This was referenced Aug 8, 2026
os-zhuang
marked this pull request as ready for review
August 8, 2026 12:14
This was referenced Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6223
The defect
EvalResult.error.kindis author-facing.@objectstack/objectql'scel-faultputs it in front of the author as`${kind}: ${first line}`(faultSummary), andpackages/restre-emits the same value as the HTTP body'sreason. cel-js embeds the author's own source line inmessage(formatErrorWithHighlight,lib/errors.js), so a classifier that regex-matches that text is matching text the author writes.PR #6202 (#6133) closed the
ParseErrorarm structurally and deliberately lefttype/runtimeon the keyword table pending a per-code audit. This PR is that audit.Measured on
origin/mainthrough the real engine — oneno such overloadevaluation fault, four field names, three wrong answers:All four carry the identical first line; they differ only in the echoed source.
parseis the inverse of #6133's misdirection: the expression is syntactically perfect and failed on the data, and the author is told to go fix an expression that has nothing wrong with it. ADR-0032 D1d is unmet either way.The fix
classifyError(packages/formula/src/cel-engine.ts) now reads only structured contract:ParseError,code === 'limit_exceeded'boundsParseError, otherwiseparseEvaluationError,codeinCEL_DECLARATION_CODEStypeEvaluationError, otherwiseruntimeruntimeThe keyword table is deleted. No branch of the function reads text an author — or a row — can write.
CEL_DECLARATION_CODESholds exactly one code,unknown_variable: the root identifier the expression names is not bound in this scope at all, which is a property of the expression against the call site's contract rather than of any row.cel-faultalready gives that fault its own author advice. The set is documented per rejected candidate in the source.Answering the PM's mechanism assumption: the table goes outright, and here is why the partial route was not enough
The dispatch asked whether a by-code mapping could replace the keyword table outright, and invited falsification. Two measurements, both in the direction of "remove more, not less":
1. The residual arm was never dormant — and it reads data, not just author text. The issue's "未主张" section wondered whether the
boundsbranch was reachable only for non-cel-js errors. It is reachable, and so is the whole residual arm:matches()is an ObjectStack stdlib binding overnew RegExp(...), so an uncompilable pattern escapes cel-js unwrapped as a nativeSyntaxErrorwhose message echoes the pattern. Measured onorigin/main:The last row is the sharp one: a value on a record was choosing the error kind. Keeping a "smaller non-author-text table" was therefore not available — the residual arm was the largest author-text reader left, not the safe remainder.
2. There is no reachable cel-js
TypeError, so half the suggested route would have been dead code. The issue and the triage both proposedEvaluationError -> runtime,TypeError -> type. cel-js raisesTypeErroronly from its non-evaluatingTypeChecker(createError = isEvaluating ? evaluationError : typeError,lib/type-checker.js:16), and that instance runs only insideEnvironment#check— which catches it and returns{ valid: false, error }rather than throwing (lib/evaluator.js#checkAST). At evaluate time cel-js uses#evalTypeChecker = new TypeChecker(opts, true), so every evaluate-time fault, checker-raised ones included, arrives as anEvaluationError. ATypeErrorarm inclassifyErrorcould never fire. The check-timeTypeError -> typemapping already exists and is untouched:celEngine.compilereads that returned object (#1877). This is recorded in the source so the next reader does not "restore" the missing arm.Consequence for the by-code table: the phase cannot separate declaration faults from data faults, so the code has to — which is what the table does.
Verdicts that change, and why each is a fix
Six evaluate-time codes move from
typetoruntime. Every one of them was ontypeonly because cel-js happens to use the word "type" in its prose:int_conversion_errorint() type error: cannot convert to inttyperuntimeuint_conversion_erroruint() type error: …typeruntimedouble_conversion_errordouble() type error: …typeruntimeinvalid_index_typeCannot index type 'bytes' …typeruntimeheterogeneous_list_elementList elements must have the same type …typeruntimeinvalid_comprehension_rangeExpression of type 'double' cannot be range …typeruntimeEach is decided against the row, so
runtimeis the honest verdict.Deliberately unchanged:
no_matching_overloadstaysruntime. It conflates an unknown function (PRIOR(x)) with a known one called on the wrong runtime types (size(record.x)on a scalar), and underunlistedVariablesAreDynthe second is data-dependent. Its unknown-function half is already caught earlier and louder, at build time, bycompile()'scheck()read (#1877). No re-grade, and the rationale is in the source.Tests
packages/formula/src/cel-error-classification.test.tsgains a#6223block: the four field names from the issue plustype_code, each asserted to compile clean and then evaluate toruntime; one fixture per evaluate-time cel-js code the engine can reach, each pinning itskindand a message fragment naming the fault; theunknown_variableexception plus arecord.unknown_variablefield-name case proving it is held back by code and not by phrase; the five residual-arm fixtures above; and a pin thatboundscannot be forged from prose.Reverse verification. Direction predicted before running (the ordinary one — red): restoring
origin/main'scel-engine.tsunder the new tests turns the#6223block red and leaves the#6133pins green. Result — 9 failed / 40 passed, and the reds are exactly the headline case (expected 'parse' to be 'runtime'), the six re-graded codes, the residual arm (expected 'type' to be 'runtime') and the forged bounds (expected 'bounds' to be 'runtime'). One honest deviation from the prediction: therecord.unknown_variable > 1sub-assertion stayed green on the old code too — the old regex was/unknown variable/iwith a space, which an underscored field name never matched. That sub-case is a pin of behaviour that was already right, not a regression fixture, and is reported as such rather than claimed as a catch.Suites (real output):
@objectstack/formula450/450,@objectstack/lint1599/1599,@objectstack/objectql2519/2519,@objectstack/rest1027/1027;pnpm --filter @objectstack/formula typecheckclean; workspaceturbo run typecheckclean. Everycheck:*step enumerated from.github/workflows/lint.ymlwas run one by one and passes.Scope
Classifier only.
packages/formula/src/cel-to-filter.tsandgetParseEnvare untouched — frozen pending the #6132 maintainer ruling.isNumericOverloadError(the ADR-0032 §1c hydration-retry trigger, same file) still matches on/no such overload/i; it is a retry trigger rather than a classification and a false positive is harmless (the retry rethrows the original), but it is the same family and is filed separately rather than fixed here.Generated by Claude Code