Skip to content

Commit b0b9a29

Browse files
committed
feat(formula,spec): carry a stable refusal code and params beside every expression refusal message
validateExpression (errors and warnings), collectCelRootIdentifiers and predicateSlotRefusal now return `code` (a closed kebab-case set) and `params` (the values the English message interpolates) beside `message`, so a localized author surface can key its own catalogue rows to the code. The English message is unchanged byte for byte. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
1 parent de091b5 commit b0b9a29

7 files changed

Lines changed: 1259 additions & 123 deletions

File tree

‎packages/formula/src/cel-engine.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { Expression } from '@objectstack/spec';
2020
import { buildScope, registerNumericCoercions, registerStdLib } from './stdlib';
2121
import type { PermissionBinding } from './stdlib';
2222
import type { DialectEngine, EvalContext, EvalResult } from './types';
23+
import type { CelRootsRefusalCode, ExpressionRefusal } from './expression-refusal';
2324

2425
/**
2526
* Default execution bounds. Picked conservatively — every metadata-authored
@@ -333,12 +334,13 @@ export function firstUndeclaredReference(
333334
*
334335
* Returns `{ ok: false }` with the classifier's message when the source does
335336
* not parse — callers surface that as a config error, not an empty root set.
337+
* The failure also carries a refusal `code` and its `params`
338+
* (`expression-refusal.ts`), for a consumer that renders its own words
339+
* instead of the English `error`.
336340
*/
337-
export function collectCelRootIdentifiers(
338-
source: string,
339-
): { ok: true; roots: string[] } | { ok: false; error: string } {
341+
export function collectCelRootIdentifiers(source: string): CelRootIdentifiersResult {
340342
if (typeof source !== 'string' || !source.trim()) {
341-
return { ok: false, error: 'expression is empty' };
343+
return { ok: false, error: 'expression is empty', code: 'empty-expression', params: {} };
342344
}
343345
try {
344346
// Same nullable-ternary rewrite as compile/evaluate so "what parses" agrees
@@ -358,10 +360,20 @@ export function collectCelRootIdentifiers(
358360
return { ok: true, roots: [...roots] };
359361
} catch (err) {
360362
const classified = classifyError(err);
361-
return { ok: false, error: classified.ok === false ? classified.error.message : String(err) };
363+
const detail = classified.ok === false ? classified.error.message : String(err);
364+
return { ok: false, error: detail, code: 'cel-parse-failed', params: { detail } };
362365
}
363366
}
364367

368+
/**
369+
* What {@link collectCelRootIdentifiers} answers: the roots, or a refusal
370+
* whose `error` is the English reason and whose `code` / `params` say the
371+
* same thing for a consumer that renders its own words.
372+
*/
373+
export type CelRootIdentifiersResult =
374+
| { ok: true; roots: string[] }
375+
| ({ ok: false; error: string } & ExpressionRefusal<CelRootsRefusalCode>);
376+
365377
/**
366378
* A parsed CEL AST node, re-exported so a consumer can name the type this
367379
* package already hands it without importing `@marcbachmann/cel-js` itself.

‎packages/formula/src/expression-refusal-codes.test.ts‎

Lines changed: 538 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The closed set of refusal CODES this package's expression producers emit —
5+
* `validateExpression`'s `errors[]` / `warnings[]` and
6+
* `collectCelRootIdentifiers`'s failure arm — each with the typed PARAMS its
7+
* English `message` interpolates.
8+
*
9+
* ## Why a code beside the message
10+
*
11+
* The `message` is written for self-correction (ADR-0032 §Decision 1d) and it
12+
* is English: an API caller, the CLI and the `validate_expression` agent tool
13+
* read it as it is. A localized author surface cannot translate a sentence it
14+
* does not own, and matching the English with a pattern would bind the consumer
15+
* to wording this package rewrites whenever a prescription improves. So every
16+
* refusal also carries a stable `code` and the variable parts as `params`, and
17+
* a consumer keys its own catalogue row to the code and fills the row from the
18+
* params. The `message` is unchanged by this: it is the same sentence, byte for
19+
* byte, that the refusal carried before the code existed.
20+
*
21+
* ## The contract
22+
*
23+
* - A code names ONE message template. Two sentences that differ in more than
24+
* an optional clause are two codes, so a catalogue row never has to branch
25+
* on a param to pick its wording. Optional clauses (a did-you-mean, an
26+
* object name) are optional params: present exactly when the English
27+
* message carries that clause.
28+
* - Codes are kebab-case and never change once published. A new refusal is a
29+
* new code; a reworded message keeps its code.
30+
* - Params are the interpolated values, not their rendering: a list is an
31+
* array (the consumer joins it), a quoted reference is the bare text (the
32+
* consumer quotes it), and a phrase chosen from a closed set is a token.
33+
* - `detail` is a diagnostic the CEL or template engine wrote — English, and
34+
* for CEL with an excerpt of the source. It is passed through as it is;
35+
* this package does not own its wording.
36+
*
37+
* ⛔ Not an ADR-0112 error code. These are authoring diagnostics delivered as a
38+
* function's return value, never a failing request's `error.code`, which is
39+
* why they are kebab-case and not SCREAMING_SNAKE.
40+
*/
41+
42+
import type { CelLimitKey } from './cel-engine';
43+
import type { FieldRole } from './validate';
44+
45+
/** The CEL-dialect field roles — every role but `template`. */
46+
export type CelFieldRole = Exclude<FieldRole, 'template'>;
47+
48+
/**
49+
* What a non-text envelope `source` was, as a token. The message renders it
50+
* with its article (`an array`, `a number`).
51+
*/
52+
export type ExpressionSourceKind = 'array' | 'object' | 'number' | 'boolean' | 'bigint' | 'symbol' | 'function';
53+
54+
/** Code → the params its message interpolates. The keys ARE the closed set. */
55+
export interface ExpressionRefusalParams {
56+
/** An envelope whose `source` is present but is not text. */
57+
'envelope-source-not-text': { readonly role: FieldRole; readonly found: ExpressionSourceKind };
58+
/** A `template` slot holding an envelope of another dialect. */
59+
'template-dialect-mismatch': { readonly dialect: string };
60+
/** The template engine refused the text; `detail` is its reason. */
61+
'invalid-template': { readonly detail: string };
62+
/** A single-brace `{ref}` where a template hole needs `{{ ref }}`. */
63+
'template-single-brace': { readonly ref: string };
64+
/** A CEL slot holding an envelope of another dialect. */
65+
'cel-dialect-mismatch': { readonly dialect: string };
66+
/** The CEL engine refused the source; the prescription is "write bare CEL". */
67+
'invalid-cel': { readonly role: CelFieldRole; readonly detail: string };
68+
/**
69+
* Valid CEL over one of the platform's parse budgets. `limit` and
70+
* `limitValue` are present together when the bound is known, and absent
71+
* together when it is not.
72+
*/
73+
'cel-too-large': {
74+
readonly role: CelFieldRole;
75+
readonly detail: string;
76+
readonly limit?: CelLimitKey;
77+
readonly limitValue?: number;
78+
};
79+
/**
80+
* A bare-callable function written as a method (`record.name.upper()`).
81+
* `receiver` is the plain dotted chain it was called on, when one can be
82+
* lifted from the source.
83+
*/
84+
'cel-method-call': {
85+
readonly role: CelFieldRole;
86+
readonly detail: string;
87+
readonly name: string;
88+
readonly receiver?: string;
89+
};
90+
/** A call to a name that is not callable here; `suggestion` is the nearest advertised one. */
91+
'cel-unknown-function': {
92+
readonly role: CelFieldRole;
93+
readonly detail: string;
94+
readonly name: string;
95+
readonly suggestion?: string;
96+
};
97+
/** The CEL engine refused a source that holds a template brace `{ref}`. */
98+
'cel-template-brace': { readonly role: CelFieldRole; readonly detail: string; readonly ref: string };
99+
/** `record.<field>` naming no field of the object. */
100+
'unknown-field': { readonly field: string; readonly objectName?: string; readonly suggestion?: string };
101+
/** A role-membership test naming a role outside the catalog; `catalog` is every valid role. */
102+
'unknown-role': { readonly name: string; readonly suggestion?: string; readonly catalog: readonly string[] };
103+
/** `name.…` one typo away from a root the surface binds; `roots` are the surface's declared roots. */
104+
'unbound-root': { readonly name: string; readonly roots: readonly string[]; readonly suggestion: string };
105+
/** A bare identifier in a `record`-scoped expression. */
106+
'bare-reference': { readonly name: string };
107+
/**
108+
* Arithmetic on a date field against a number. `operands` are the CEL
109+
* operand types; `reference` is the field as written, when it is known.
110+
*/
111+
'date-arithmetic': { readonly operands: string; readonly reference?: string };
112+
/**
113+
* WARNING, never an error: a text or boolean field used with an arithmetic
114+
* or ordering `operator` against a number.
115+
*/
116+
'type-mismatch': {
117+
readonly operands: string;
118+
readonly operator: string;
119+
readonly held: 'text' | 'boolean';
120+
readonly reference?: string;
121+
};
122+
/**
123+
* WARNING, never an error: a bare identifier in a flattened condition that
124+
* is one typo away from a field. Absent `objectName` reads "the trigger object".
125+
*/
126+
'field-near-miss': { readonly name: string; readonly suggestion: string; readonly objectName?: string };
127+
/** A reference field read both through the relationship and as a plain value. */
128+
'traversal-bare-and-traversed': { readonly root: string; readonly field: string };
129+
/** A reference field read through more than one relationship hop. */
130+
'traversal-multi-hop': { readonly root: string; readonly field: string };
131+
/** `collectCelRootIdentifiers` was handed no expression. */
132+
'empty-expression': Readonly<Record<string, never>>;
133+
/** `collectCelRootIdentifiers` could not parse the source; `detail` is the parser's reason. */
134+
'cel-parse-failed': { readonly detail: string };
135+
}
136+
137+
/** Every refusal code this package emits. */
138+
export type ExpressionRefusalCode = keyof ExpressionRefusalParams;
139+
140+
/**
141+
* One refusal's `code` and `params`, correlated: narrowing on `code` narrows
142+
* `params`. `C` restricts it to the codes one producer emits.
143+
*/
144+
export type ExpressionRefusal<C extends ExpressionRefusalCode = ExpressionRefusalCode> = {
145+
[K in C]: { readonly code: K; readonly params: ExpressionRefusalParams[K] };
146+
}[C];
147+
148+
/** The codes `collectCelRootIdentifiers` emits. */
149+
export type CelRootsRefusalCode = 'empty-expression' | 'cel-parse-failed';
150+
151+
/** The codes `validateExpression` emits, in `errors[]` and `warnings[]`. */
152+
export type ExprValidationCode = Exclude<ExpressionRefusalCode, CelRootsRefusalCode>;
153+
154+
/**
155+
* Keyed by code so the compiler holds this list equal to
156+
* {@link ExpressionRefusalParams}: a code missing here, or one that is not a
157+
* key there, does not compile.
158+
*/
159+
const CODE_TABLE = {
160+
'envelope-source-not-text': true,
161+
'template-dialect-mismatch': true,
162+
'invalid-template': true,
163+
'template-single-brace': true,
164+
'cel-dialect-mismatch': true,
165+
'invalid-cel': true,
166+
'cel-too-large': true,
167+
'cel-method-call': true,
168+
'cel-unknown-function': true,
169+
'cel-template-brace': true,
170+
'unknown-field': true,
171+
'unknown-role': true,
172+
'unbound-root': true,
173+
'bare-reference': true,
174+
'date-arithmetic': true,
175+
'type-mismatch': true,
176+
'field-near-miss': true,
177+
'traversal-bare-and-traversed': true,
178+
'traversal-multi-hop': true,
179+
'empty-expression': true,
180+
'cel-parse-failed': true,
181+
} as const satisfies Record<ExpressionRefusalCode, true>;
182+
183+
/**
184+
* The closed set, as a value — for a consumer that must prove it has a
185+
* catalogue row for every code, and for this package's own exhaustiveness pin.
186+
*/
187+
export const EXPRESSION_REFUSAL_CODES: readonly ExpressionRefusalCode[] = Object.freeze(
188+
Object.keys(CODE_TABLE) as ExpressionRefusalCode[],
189+
);

‎packages/formula/src/index.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export { celEngine, DEFAULT_LIMITS } from './cel-engine';
1515
// (approval `expression` approvers): lint and the runtime pre-check share this
1616
// one helper so what they accept can never drift.
1717
export { collectCelRootIdentifiers } from './cel-engine';
18+
export type { CelRootIdentifiersResult } from './cel-engine';
1819
// #6128 — the strict-environment "does this identifier resolve?" oracle, the
1920
// same one `validateExpression` gives its `record`-scoped bare-ref verdict from.
2021
// Published for the same reason as `collectCelRootIdentifiers` above: a lint
@@ -131,5 +132,19 @@ export type { UnknownFunctionCall } from './unknown-function';
131132
// registration, and the agent-callable validate_expression tool).
132133
export { validateExpression, introspectScope, expectedDialect, inferExpressionType, nearestName, CEL_STDLIB_FUNCTIONS } from './validate';
133134
export type { FieldRole, ExprInput, ExprSchemaHint, ExprValidationError, ExprValidationResult, InferredValueType } from './validate';
135+
// The closed set of refusal codes `validateExpression` and
136+
// `collectCelRootIdentifiers` carry beside their English message, with the
137+
// typed params each message interpolates — so a localized author surface keys
138+
// its own catalogue rows to a code instead of translating our sentence.
139+
export { EXPRESSION_REFUSAL_CODES } from './expression-refusal';
140+
export type {
141+
CelFieldRole,
142+
CelRootsRefusalCode,
143+
ExprValidationCode,
144+
ExpressionRefusal,
145+
ExpressionRefusalCode,
146+
ExpressionRefusalParams,
147+
ExpressionSourceKind,
148+
} from './expression-refusal';
134149
export type { SeedValue, SeedPrimitive } from './seed-eval';
135150
export type { DialectEngine, EvalContext, EvalResult, EvalError, EvalPermissions } from './types';

0 commit comments

Comments
 (0)