|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Read a CEL fault the way the AUTHOR needs to read it. |
| 5 | + * |
| 6 | + * Shared by the two surfaces that evaluate a CEL expression "against the |
| 7 | + * record" and reject the write when they cannot: object-level validation |
| 8 | + * predicates (`validation/rule-validator.ts`, #4649) and declarative hook |
| 9 | + * `condition`s (`hook-wrappers.ts`, #4775). Both were told to reuse ONE error |
| 10 | + * shape, and the only durable way to keep two messages worded alike is to stop |
| 11 | + * writing them twice — the same argument that put {@link |
| 12 | + * ./declared-fields.js#materializeDeclaredFields} in front of both evaluators. |
| 13 | + * |
| 14 | + * This module has no opinion about what a caller does with a fault. It answers |
| 15 | + * three questions and hands back a sentence: |
| 16 | + * |
| 17 | + * 1. What broke, in one line? (`summary`) |
| 18 | + * 2. Did the expression read a key the record does not carry, and which? |
| 19 | + * (`missingKey` — after materialisation this can only mean an UNDECLARED |
| 20 | + * key, i.e. an author typo or a retired field) |
| 21 | + * 3. Did it name a ROOT that is not in scope at all? (`unknownVariable` — |
| 22 | + * the fault an unbound `previous` produces, which is a different |
| 23 | + * diagnosis from a missing key on a bound root) |
| 24 | + * 4. Did it compare/order a `null`? (`nullOverload` — the other way a |
| 25 | + * predicate over a TOTAL record still faults) |
| 26 | + */ |
| 27 | + |
| 28 | +/** The `{ kind, message }` failure `@objectstack/formula` resolves. */ |
| 29 | +export interface CelFault { |
| 30 | + kind: string; |
| 31 | + message: string; |
| 32 | +} |
| 33 | + |
| 34 | +/** `No such key: <key>` is cel-js's word for "the expression read something the |
| 35 | + * record does not carry" — the single most useful fact to put in front of the |
| 36 | + * author, since after materialisation it can only mean an UNDECLARED key. */ |
| 37 | +const NO_SUCH_KEY_RE = /No such key:\s*([A-Za-z_$][\w$]*)/; |
| 38 | + |
| 39 | +/** |
| 40 | + * The OTHER way an expression written against a total record still faults: an |
| 41 | + * ordering comparison (`<`, `>`, `<=`, `>=`) or arithmetic over a value that is |
| 42 | + * `null`. CEL has no overload for it, so the whole expression aborts. |
| 43 | + * |
| 44 | + * This one deserves its own sentence because the obvious guard does not work: |
| 45 | + * `has(x)` is TRUE for a declared field holding `null` (CEL asks whether the key |
| 46 | + * is PRESENT, not whether it has a usable value), so `has(a) && has(b) && a < b` |
| 47 | + * still faults the moment either is null — on any driver that returns its NULL |
| 48 | + * columns, which is most of them. Such a rule never enforced anything on those |
| 49 | + * rows; #4649 is what makes that visible instead of silent. |
| 50 | + */ |
| 51 | +const NULL_OVERLOAD_RE = /no such overload/i; |
| 52 | + |
| 53 | +/** |
| 54 | + * `Unknown variable: <name>` is what cel-js says when a ROOT identifier is not |
| 55 | + * in the scope at all — as opposed to `No such key`, which means the root |
| 56 | + * resolved and the key under it did not. |
| 57 | + * |
| 58 | + * Both evaluators bind exactly two roots, `record` and `previous`, and bind |
| 59 | + * `previous` only when the record's prior state is actually in hand. So this |
| 60 | + * fault has one meaning worth spelling out: the expression asked for a binding |
| 61 | + * that this operation does not have. Kept apart from {@link missingKeyOf} |
| 62 | + * because the two need OPPOSITE advice — a missing key says "you named a field |
| 63 | + * that isn't declared", an unknown variable says "the field is fine; the thing |
| 64 | + * you hung it off isn't available here". |
| 65 | + */ |
| 66 | +const UNKNOWN_VARIABLE_RE = /Unknown variable:\s*([A-Za-z_$][\w$]*)/; |
| 67 | + |
| 68 | +/** |
| 69 | + * One-line summary of a CEL fault. The engine appends a source excerpt and a |
| 70 | + * caret line to `message`, which is right for a log and wrong for an API error, |
| 71 | + * so only the first line travels. |
| 72 | + */ |
| 73 | +export function faultSummary(error: CelFault): string { |
| 74 | + const first = String(error.message ?? '').split('\n')[0]!.trim(); |
| 75 | + return `${error.kind}: ${first || 'unknown error'}`; |
| 76 | +} |
| 77 | + |
| 78 | +/** The key a `No such key: <key>` fault names, or `undefined`. */ |
| 79 | +export function missingKeyOf(error: CelFault): string | undefined { |
| 80 | + return NO_SUCH_KEY_RE.exec(String(error.message ?? ''))?.[1]; |
| 81 | +} |
| 82 | + |
| 83 | +/** The root identifier an `Unknown variable: <name>` fault names, or `undefined`. */ |
| 84 | +export function unknownVariableOf(error: CelFault): string | undefined { |
| 85 | + return UNKNOWN_VARIABLE_RE.exec(String(error.message ?? ''))?.[1]; |
| 86 | +} |
| 87 | + |
| 88 | +/** True when the fault is the null-comparison overload fault (and not a |
| 89 | + * missing key, which is always the more specific diagnosis). */ |
| 90 | +export function isNullOverloadFault(error: CelFault): boolean { |
| 91 | + const raw = String(error.message ?? ''); |
| 92 | + return !missingKeyOf(error) && NULL_OVERLOAD_RE.test(raw) && /null/.test(raw); |
| 93 | +} |
| 94 | + |
| 95 | +/** What the two surfaces call the thing that faulted, so one helper can write |
| 96 | + * both sentences without either side inventing its own phrasing. */ |
| 97 | +export interface CelFaultSubject { |
| 98 | + /** How the expression is named in prose: `'predicate'`, `'condition'`, … */ |
| 99 | + what: string; |
| 100 | + /** How to fix an undeclared key, e.g. `"fix the rule's condition, or declare the field"`. */ |
| 101 | + undeclaredKeyFix: string; |
| 102 | +} |
| 103 | + |
| 104 | +export interface CelFaultDescription { |
| 105 | + /** `kind: first line` — safe to put in an API error. */ |
| 106 | + summary: string; |
| 107 | + /** The undeclared key the expression read, when that is the fault. */ |
| 108 | + missingKey?: string; |
| 109 | + /** The unbound ROOT the expression named, when that is the fault. */ |
| 110 | + unknownVariable?: string; |
| 111 | + /** True when the fault is the `null` ordering/arithmetic overload. */ |
| 112 | + nullOverload: boolean; |
| 113 | + /** A trailing sentence explaining the fault, or `''` when we have nothing |
| 114 | + * more specific to say than {@link CelFaultDescription.summary}. Starts with |
| 115 | + * a leading space so it appends directly onto a message. */ |
| 116 | + detail: string; |
| 117 | +} |
| 118 | + |
| 119 | +/** |
| 120 | + * Turn a raw CEL fault into the facts + the sentence an author can act on. |
| 121 | + * The wording is deliberately identical across surfaces; only `what` and the |
| 122 | + * fix clause differ. |
| 123 | + */ |
| 124 | +export function describeCelFault(error: CelFault, subject: CelFaultSubject): CelFaultDescription { |
| 125 | + const summary = faultSummary(error); |
| 126 | + const missingKey = missingKeyOf(error); |
| 127 | + const unknownVariable = missingKey ? undefined : unknownVariableOf(error); |
| 128 | + const nullOverload = isNullOverloadFault(error); |
| 129 | + let detail = ''; |
| 130 | + if (missingKey) { |
| 131 | + detail = |
| 132 | + ` The ${subject.what} reads '${missingKey}', which this object does not declare` + |
| 133 | + ` — ${subject.undeclaredKeyFix}.`; |
| 134 | + } else if (unknownVariable) { |
| 135 | + detail = |
| 136 | + ` The ${subject.what} reads '${unknownVariable}', which is not bound for this operation` + |
| 137 | + ` — the scope holds 'record', plus 'previous' only when the record's prior state is in hand.`; |
| 138 | + } else if (nullOverload) { |
| 139 | + detail = |
| 140 | + ` The ${subject.what} compares a value that is null. Guard it with '!= null'` + |
| 141 | + ` — 'has(x)' does NOT do that: a declared field holding null is still PRESENT, so has(x) is true.`; |
| 142 | + } |
| 143 | + return { |
| 144 | + summary, |
| 145 | + ...(missingKey ? { missingKey } : {}), |
| 146 | + ...(unknownVariable ? { unknownVariable } : {}), |
| 147 | + nullOverload, |
| 148 | + detail, |
| 149 | + }; |
| 150 | +} |
0 commit comments