|
13 | 13 | * third-party plugins can't ship runaway predicates. |
14 | 14 | */ |
15 | 15 |
|
16 | | -import { Environment, ParseError, serialize } from '@marcbachmann/cel-js'; |
| 16 | +import { Environment, EvaluationError, ParseError, serialize } from '@marcbachmann/cel-js'; |
17 | 17 | import type { ASTNode } from '@marcbachmann/cel-js'; |
18 | 18 | import type { Expression } from '@objectstack/spec'; |
19 | 19 |
|
@@ -842,53 +842,117 @@ function hydrateOverloadStrings(value: unknown): unknown { |
842 | 842 | const CEL_LIMIT_EXCEEDED_CODE = 'limit_exceeded'; |
843 | 843 |
|
844 | 844 | /** |
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`. |
848 | 847 | * |
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. |
859 | 854 | * |
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"). |
866 | 861 | * |
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. |
875 | 883 | */ |
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; |
879 | 931 | } |
880 | 932 |
|
| 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 | + */ |
881 | 953 | function classifyError(err: unknown): EvalResult<never> { |
882 | 954 | 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'; |
892 | 956 | return { ok: false, error: { kind, message } }; |
893 | 957 | } |
894 | 958 |
|
|
0 commit comments