Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/20291-expression-refusal-codes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/formula': minor
'@objectstack/spec': minor
---

Expression refusals now carry a stable `code` and typed `params` beside their English `message`, so a localized author surface can render its own words: `validateExpression` (every entry in `errors[]` and `warnings[]`), `collectCelRootIdentifiers` (its `ok: false` arm), `predicateSlotRefusal` and `structuralConditionRefusal` (#20291)

Clause-②: yes

Until now the only thing a refusal said was an English sentence, and a designer running in another locale could only show it verbatim, beside its own translated headings. Each refusal now also names its code — one of a closed, kebab-case set — and the values its sentence interpolates, so a consumer keys a catalogue row to the code and fills it from the params. The `message` is the same sentence, byte for byte; nothing is removed or renamed, so no existing reader changes.

- `@objectstack/formula` exports `EXPRESSION_REFUSAL_CODES` (the closed set as a frozen list) and the types `ExpressionRefusalCode`, `ExpressionRefusalParams` (code → params), `ExpressionRefusal`, `ExprValidationCode`, `CelRootsRefusalCode`, `CelFieldRole`, `ExpressionSourceKind` and `CelRootIdentifiersResult`. `ExprValidationError` gains `code` and `params`.
- `@objectstack/spec/automation` exports `FLOW_SLOT_REFUSAL_CODES` (the closed set of both flow-slot refusal producers, as a frozen list) and the types `FlowSlotRefusalCode`, `FlowSlotRefusalParams` (code → params), `PredicateSlotRefusalCode`, `PredicateSlotRefusal`, `PredicateSlotValueKind`, `StructuralConditionRefusalCode`, `StructuralConditionRefusal` and `StructuralConditionValueKind`. `predicateSlotRefusal` returns `PredicateSlotRefusal` and `structuralConditionRefusal` returns `StructuralConditionRefusal`; each is its previous `{ message, source }` plus `code` and `params`.
- Narrowing on `code` narrows `params`. A code never changes once published: a reworded message keeps its code, and a new refusal gets a new one.
- A `detail` param is the CEL or template engine's own diagnostic, in English, passed through as the message carries it.
- These are authoring diagnostics returned as values, not ADR-0112 request error codes, which is why they are kebab-case.
- The `validate_expression` MCP tool forwards `validateExpression`'s `errors` and `warnings` as they are, so each entry in its answer now also carries `code` and `params`.
22 changes: 17 additions & 5 deletions packages/formula/src/cel-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { Expression } from '@objectstack/spec';
import { buildScope, registerNumericCoercions, registerStdLib } from './stdlib';
import type { PermissionBinding } from './stdlib';
import type { DialectEngine, EvalContext, EvalResult } from './types';
import type { CelRootsRefusalCode, ExpressionRefusal } from './expression-refusal';

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

/**
* What {@link collectCelRootIdentifiers} answers: the roots, or a refusal
* whose `error` is the English reason and whose `code` / `params` say the
* same thing for a consumer that renders its own words.
*/
export type CelRootIdentifiersResult =
| { ok: true; roots: string[] }
| ({ ok: false; error: string } & ExpressionRefusal<CelRootsRefusalCode>);

/**
* A parsed CEL AST node, re-exported so a consumer can name the type this
* package already hands it without importing `@marcbachmann/cel-js` itself.
Expand Down
Loading
Loading