Skip to content

Commit b230e5e

Browse files
os-zhuangclaude
andauthored
fix(formula): grade CEL faults by error class + code, not by the message (#6223) (#6677)
* fix(formula): grade CEL faults by error class + code, not by the message (#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 * test(formula): use angle-bracket-free regex fixtures for the residual 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d6d1a50 commit b230e5e

3 files changed

Lines changed: 307 additions & 44 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
'@objectstack/formula': patch
3+
---
4+
5+
fix(formula): `classifyError` grades a CEL fault by error class + code, never by the message (#6223)
6+
7+
`EvalResult.error.kind` is author-facing — `@objectstack/objectql`'s `cel-fault`
8+
puts it in front of the author as `` `${kind}: ${first line}` `` and
9+
`packages/rest` re-emits it as the HTTP body's `reason`. cel-js embeds the
10+
author's own **source line** in `message` (`formatErrorWithHighlight`), so a
11+
classifier that regex-matches that text is matching text the author writes.
12+
PR #6202 closed the `ParseError` arm this way and left `type` / `runtime` on the
13+
keyword table pending a per-code audit. This is that audit, and its verdict is
14+
that the table goes entirely.
15+
16+
Measured on cel-js 8.0.0 — one `no such overload` **evaluation** fault, four
17+
field names, three wrong answers:
18+
19+
```text
20+
record.status > 1 -> runtime (right)
21+
record.parse_status > 1 -> parse (wrong)
22+
record.syntax_mode > 1 -> parse (wrong)
23+
record.type_code > 1 -> type (wrong)
24+
```
25+
26+
`parse` is the inverse of the #6133 misdirection: the expression is
27+
syntactically perfect and failed on the data, and the author was told to go fix
28+
an expression that has nothing wrong with it.
29+
30+
`classifyError` now reads only structured contract:
31+
32+
- `ParseError` -> `bounds` when `code === 'limit_exceeded'`, else `parse`
33+
(unchanged, from #6202);
34+
- `EvaluationError` -> `type` for the one declaration-class code
35+
(`unknown_variable`, the root identifier is not bound in this scope at all),
36+
else `runtime`;
37+
- anything that is not a cel-js error -> `runtime`.
38+
39+
Two findings from the audit worth recording. First, the residual keyword arm was
40+
**not** dormant: `matches()` is an ObjectStack stdlib binding over `new
41+
RegExp(...)`, so an uncompilable pattern escapes as a native `SyntaxError` whose
42+
message echoes the pattern — and the pattern can come off the row, not just out
43+
of the source. `matches(record.name, record.re)` with `re = "type("` was
44+
graded `type`; with `"Exceeded maxAstNodes("` it was graded `bounds`. A data
45+
value was picking the error kind. Second, there is deliberately no `TypeError`
46+
arm: cel-js raises that class only from its non-evaluating `TypeChecker`, which
47+
runs only inside `Environment#check`, and that method catches it and *returns*
48+
`{ valid: false, error }`. The check-time `TypeError -> type` mapping already
49+
lives in `celEngine.compile`, which reads that object.
50+
51+
Six evaluate-time codes change verdict from `type` to `runtime`
52+
(`int_conversion_error`, `uint_conversion_error`, `double_conversion_error`,
53+
`invalid_index_type`, `heterogeneous_list_element`,
54+
`invalid_comprehension_range`). Each is a fault decided against the row; every
55+
one of them was graded `type` only because cel-js happens to use the word "type"
56+
in its prose (`int() type error: cannot convert to int`). Every evaluate-time
57+
code the engine can reach now has a fixture pinning its `kind`.

packages/formula/src/cel-engine.ts

Lines changed: 104 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* third-party plugins can't ship runaway predicates.
1414
*/
1515

16-
import { Environment, ParseError, serialize } from '@marcbachmann/cel-js';
16+
import { Environment, EvaluationError, ParseError, serialize } from '@marcbachmann/cel-js';
1717
import type { ASTNode } from '@marcbachmann/cel-js';
1818
import type { Expression } from '@objectstack/spec';
1919

@@ -842,53 +842,117 @@ function hydrateOverloadStrings(value: unknown): unknown {
842842
const CEL_LIMIT_EXCEEDED_CODE = 'limit_exceeded';
843843

844844
/**
845-
* Grade a cel-js fault off the error **class** the parser threw, not off its
846-
* prose. Returns `undefined` for anything that is not a cel-js error, so the
847-
* caller can fall back to the legacy keyword table.
845+
* The evaluate-time cel-js codes that describe the EXPRESSION rather than the
846+
* DATA, and therefore stay `type` instead of falling to `runtime`.
848847
*
849-
* Why the class and not the message (#6133): `classifyError` used to decide
850-
* between `parse` / `type` / `runtime` by regex-matching the error text, and
851-
* cel-js has ~19 distinct parse-time wordings of which only three contain
852-
* `parse` / `unexpected` / `syntax`. Everything else — `Expected RPAREN, got
853-
* EOF` (unbalanced parens), `Expected RBRACKET, got EOF`, `Unterminated
854-
* string`, `Reserved identifier: package`, the seven escape-sequence faults —
855-
* fell through to the default `runtime`, and `kind` is not an internal field:
856-
* it is interpolated verbatim into the author-facing rejection text
857-
* (`objectql`'s `rule-validator` / `cel-fault`) and into the REST `reason`.
858-
* An author who forgot a closing paren was told their *data* was at fault.
848+
* The membership test is "would this fault reproduce on every input?". At
849+
* evaluate time cel-js runs its checker with `isEvaluating: true`
850+
* (`Environment#evaluate` → `#evalTypeChecker`, built as
851+
* `new TypeChecker(opts, true)`), so *every* fault — including the ones the
852+
* checker raises — arrives as an {@link EvaluationError}. The phase therefore
853+
* cannot separate the two, and the code has to.
859854
*
860-
* Topping the keyword list up cannot fix this, because cel-js embeds the
861-
* **author's own source line** in `message` (see `formatErrorWithHighlight` in
862-
* `lib/errors.js`), so the author controls the text being matched. Measured on
863-
* cel-js 8.0.0: `((record.type_id)` — a plain unbalanced paren — classified as
864-
* `type`, purely because the echoed source contains the substring "type".
865-
* Classifying on prose is not a table with holes in it; it is the hole.
855+
* Exactly one code qualifies on cel-js 8.0.0, measured per code (#6223):
856+
* `unknown_variable` means the ROOT identifier the expression names is not
857+
* bound in this scope at all — a property of the expression against the call
858+
* site's contract, not of any row. `@objectstack/objectql`'s `cel-fault`
859+
* already gives it its own author advice ("the field is fine; the thing you
860+
* hung it off isn't available here").
866861
*
867-
* Scope note, deliberate: only the ParseError arm is structural here. cel-js's
868-
* `TypeChecker` picks its error class **by phase**, not by fault
869-
* (`this.createError = isEvaluating ? evaluationError : typeError`), so the same
870-
* `unknown_variable` fault is a `TypeError` at check time and an
871-
* `EvaluationError` at evaluate time. Routing `EvaluationError` → `runtime`
872-
* wholesale would therefore silently re-grade faults the keyword table gets
873-
* right today (`Unknown variable: x` → `type`). Those arms stay on the keyword
874-
* table until that mapping is measured per code — see #6133 for the audit.
862+
* Deliberately NOT here, each with the reason:
863+
* - `no_such_key` — a record may carry a key on one row and not the next, so
864+
* it is a fact about the data. `cel-fault` gives it its own sentence too.
865+
* - `no_such_overload` — this is ADR-0032 §1c, the string-serialized numeric /
866+
* date field (`record.rating >= 4` where `rating` is `"5.0"`). Data.
867+
* - `int_conversion_error` / `uint_conversion_error` /
868+
* `double_conversion_error` — cel-js phrases these as `int() type error: …`,
869+
* which is what put them on `type` before #6223. Converting a value that
870+
* cannot convert is a data fault; the word "type" in the prose was the only
871+
* reason they were graded otherwise.
872+
* - `no_matching_overload` — genuinely ambiguous: it covers both an unknown
873+
* function (`PRIOR(x)`) and a known one called with the wrong runtime types
874+
* (`size(record.x)` on a scalar). Under `unlistedVariablesAreDyn` the second
875+
* is data-dependent, so it stays `runtime` — which is also its verdict
876+
* before #6223, i.e. this is not a re-grade. The unknown-function case is
877+
* already caught earlier and louder: {@link celEngine.compile} reads
878+
* `check()`'s `{ valid: false }` and answers `type` at build time (#1877).
879+
* - `heterogeneous_list_element`, `invalid_index_type`,
880+
* `invalid_comprehension_range`, `invalid_condition_type`,
881+
* `invalid_logical_operand` — all "this VALUE has the wrong type", decided
882+
* against the row, not against the source.
875883
*/
876-
function classifyCelParseFault(err: unknown): 'parse' | 'bounds' | undefined {
877-
if (!(err instanceof ParseError)) return undefined;
878-
return err.code === CEL_LIMIT_EXCEEDED_CODE ? 'bounds' : 'parse';
884+
const CEL_DECLARATION_CODES: ReadonlySet<string> = new Set(['unknown_variable']);
885+
886+
/**
887+
* Grade a cel-js fault off the error **class** it was thrown as and its
888+
* structured `code` — never off its prose. Returns `undefined` for anything
889+
* that is not a cel-js error.
890+
*
891+
* Why not the message (#6133, #6223): `classifyError` used to decide between
892+
* `parse` / `type` / `runtime` / `bounds` by regex-matching the error text, and
893+
* cel-js embeds the **author's own source line** in `message` (see
894+
* `formatErrorWithHighlight` in `lib/errors.js`). The text being matched is
895+
* therefore text the author writes. `kind` is not an internal field — it is
896+
* interpolated verbatim into the author-facing rejection text (`objectql`'s
897+
* `rule-validator` / `cel-fault`) and into the REST error body's `reason` — so
898+
* the author is told which of *their* mistakes this was, by a rule their own
899+
* field names can flip. Measured on cel-js 8.0.0, one `no such overload`
900+
* evaluation fault, four field names, one wrong answer per polluted name:
901+
*
902+
* ```text
903+
* record.status > 1 -> runtime (right)
904+
* record.parse_status > 1 -> parse (wrong — "your expression is broken")
905+
* record.syntax_mode > 1 -> parse (wrong)
906+
* record.type_code > 1 -> type (wrong)
907+
* ```
908+
*
909+
* #6133 / PR #6202 closed the ParseError arm this way and left `type` /
910+
* `runtime` on the keyword table pending a per-code audit. #6223 is that audit,
911+
* and it deletes the table outright: see {@link CEL_DECLARATION_CODES} for the
912+
* evaluate-time verdicts and {@link classifyError} for why nothing is left for
913+
* a keyword to decide.
914+
*
915+
* There is deliberately no `TypeError` arm. cel-js's `TypeError` is raised only
916+
* by the non-evaluating `TypeChecker` (`createError = isEvaluating ?
917+
* evaluationError : typeError`), which runs only inside `Environment#check` —
918+
* and that method catches it and *returns* `{ valid: false, error }` rather
919+
* than throwing. So a cel-js `TypeError` can never reach a `catch` block here;
920+
* an arm for it would be dead code. The check-time `TypeError → type` mapping
921+
* does exist, in {@link celEngine.compile}, where that returned object is read.
922+
*/
923+
function classifyCelFault(err: unknown): 'parse' | 'bounds' | 'type' | 'runtime' | undefined {
924+
if (err instanceof ParseError) {
925+
return err.code === CEL_LIMIT_EXCEEDED_CODE ? 'bounds' : 'parse';
926+
}
927+
if (err instanceof EvaluationError) {
928+
return CEL_DECLARATION_CODES.has(err.code) ? 'type' : 'runtime';
929+
}
930+
return undefined;
879931
}
880932

933+
/**
934+
* Resolve a thrown fault into the {@link EvalError} the caller reports.
935+
*
936+
* Everything that is not a cel-js error faulted *while evaluating* and carries
937+
* no structured contract at all — our own stdlib bindings, a caller-supplied
938+
* `os.*` API, a native JS throw — so `runtime` is the honest answer and the
939+
* only one. It is not a fallback worth "improving" with a keyword table: the
940+
* residual arm was never dormant, and its prose is author- and *data*-
941+
* controlled through our own `matches()` binding, which hands cel-js a native
942+
* `SyntaxError` whose message echoes the pattern (measured, #6223):
943+
*
944+
* ```text
945+
* matches(record.name, "type(") -> type (was)
946+
* matches(record.name, "Exceeded maxAstNodes(") -> bounds (was)
947+
* matches(record.name, "unexpected(") -> parse (was)
948+
* matches(record.name, record.re) -> type (was) — from a ROW
949+
* ```
950+
*
951+
* All four are one native regex-compilation failure, i.e. `runtime`.
952+
*/
881953
function classifyError(err: unknown): EvalResult<never> {
882954
const message = err instanceof Error ? err.message : String(err);
883-
let kind: 'parse' | 'type' | 'runtime' | 'bounds' | undefined = classifyCelParseFault(err);
884-
if (kind === undefined) {
885-
// Legacy keyword table — the residual path for faults that carry no
886-
// structured contract at all (our own stdlib, a native JS throw).
887-
kind = 'runtime';
888-
if (/Exceeded max/i.test(message)) kind = 'bounds';
889-
else if (/parse|unexpected|syntax/i.test(message)) kind = 'parse';
890-
else if (/type|unknown variable|undeclared/i.test(message)) kind = 'type';
891-
}
955+
const kind = classifyCelFault(err) ?? 'runtime';
892956
return { ok: false, error: { kind, message } };
893957
}
894958

0 commit comments

Comments
 (0)