From b0b9a29b40d2efad6b8bbe41acc4fccca305d2ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:09:48 +0000 Subject: [PATCH 1/5] 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 --- packages/formula/src/cel-engine.ts | 22 +- .../src/expression-refusal-codes.test.ts | 538 ++++++++++++++++++ packages/formula/src/expression-refusal.ts | 189 ++++++ packages/formula/src/index.ts | 15 + packages/formula/src/validate.ts | 337 +++++++---- .../automation/flow-node-expression-paths.ts | 95 +++- .../predicate-slot-refusal-codes.test.ts | 186 ++++++ 7 files changed, 1259 insertions(+), 123 deletions(-) create mode 100644 packages/formula/src/expression-refusal-codes.test.ts create mode 100644 packages/formula/src/expression-refusal.ts create mode 100644 packages/spec/src/automation/predicate-slot-refusal-codes.test.ts diff --git a/packages/formula/src/cel-engine.ts b/packages/formula/src/cel-engine.ts index 5ce06b021e5..667f87448ee 100644 --- a/packages/formula/src/cel-engine.ts +++ b/packages/formula/src/cel-engine.ts @@ -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 @@ -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 @@ -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); + /** * 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. diff --git a/packages/formula/src/expression-refusal-codes.test.ts b/packages/formula/src/expression-refusal-codes.test.ts new file mode 100644 index 00000000000..45f230a58e8 --- /dev/null +++ b/packages/formula/src/expression-refusal-codes.test.ts @@ -0,0 +1,538 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The refusal codes `validateExpression` and `collectCelRootIdentifiers` carry + * beside their English message (`expression-refusal.ts`). + * + * Two kinds of pin, and they fail for different reasons: + * + * - **One test per code** — the code, its params exactly, and the English + * message exactly. The message pins are the control for adding the code: a + * code rides BESIDE the sentence API callers, the CLI and the agent tool + * already read, and must not change a byte of it. Where a message embeds a + * diagnostic the CEL or template engine wrote (`detail`), the expected text + * takes that diagnostic from the engine itself — the pin is on our words and + * on the pass-through, never on the engine's wording. + * - **The closed set** — every code has a pin and nothing else does; every + * refusal a broad sweep of inputs provokes carries a code from the set, with + * params the message actually interpolates; and at the type level a refusal + * without a code does not compile. A new refusal path without a code goes + * red at the type check, and a code nobody pinned goes red here. + */ + +import { describe, expect, it } from 'vitest'; +import { celEngine, collectCelRootIdentifiers, type CelRootIdentifiersResult } from './cel-engine'; +import { templateEngine } from './template-engine'; +import { validateExpression, type ExprInput, type ExprSchemaHint, type ExprValidationError, type FieldRole } from './validate'; +import { + EXPRESSION_REFUSAL_CODES, + type CelRootsRefusalCode, + type ExpressionRefusalCode, + type ExpressionRefusalParams, +} from './expression-refusal'; +import * as formula from './index'; + +/** What one refusal says, whichever producer said it. */ +interface Said { + readonly code: string; + readonly params: unknown; + readonly message: string; +} + +/** The CEL engine's own diagnostic for `source` — what a CEL code's `detail` passes through. */ +function celDetail(source: string): string { + const compiled = celEngine.compile(source); + if (compiled.ok) throw new Error(`expected \`${source}\` not to compile`); + return compiled.error.message; +} + +/** The template engine's own diagnostic for `source`. */ +function templateDetail(source: string): string { + const compiled = templateEngine.compile(source); + if (compiled.ok) throw new Error(`expected \`${source}\` not to compile as a template`); + return compiled.error.message; +} + +/** The one refusal `validateExpression` reports, as an error, with nothing beside it. */ +function onlyError(role: FieldRole, input: ExprInput, schema?: ExprSchemaHint): Said { + const r = validateExpression(role, input, schema); + expect(r.ok).toBe(false); + expect(r.warnings).toHaveLength(0); + expect(r.errors).toHaveLength(1); + return r.errors[0]; +} + +/** The one advisory `validateExpression` reports, with no error beside it. */ +function onlyWarning(role: FieldRole, input: ExprInput, schema?: ExprSchemaHint): Said { + const r = validateExpression(role, input, schema); + expect(r.ok).toBe(true); + expect(r.errors).toHaveLength(0); + expect(r.warnings).toHaveLength(1); + return r.warnings[0]; +} + +/** `collectCelRootIdentifiers`'s refusal, with `error` read as the message. */ +function rootsRefusal(source: string): Said { + const r = collectCelRootIdentifiers(source); + if (r.ok) throw new Error(`expected \`${source}\` to be refused`); + return { code: r.code, params: r.params, message: r.error }; +} + +interface Pin { + readonly produce: () => Said; + readonly params: ExpressionRefusalParams[C]; + readonly message: string; +} + +/** Over `maxListElements` (64) and under every other bound. */ +const OVERSIZED_LIST = `size([${Array.from({ length: 65 }, () => '0').join(', ')}]) > 0`; + +const NON_STRING_ENVELOPE_TAIL = (example: string, dialect: string): string => + `Write the expression as bare text (e.g. ${example}), or as an envelope whose \`source\` is that text ` + + `(e.g. \`{ dialect: '${dialect}', source: '…' }\`).`; + +const METHOD_CALL_TAIL = + ' The callable names this platform advertises for authoring (the `functions` list `introspectScope` returns, ' + + '`CEL_STDLIB_FUNCTIONS`) take their subject as an argument; only cel-js\'s own receiver methods ' + + '(`record.name.split(\',\')`) are written after a dot.'; + +const UNKNOWN_FUNCTION_TAIL = + ' The callable names this platform advertises for authoring are the `functions` list `introspectScope` returns ' + + '(`CEL_STDLIB_FUNCTIONS`) — pick one of those, or precompute the value in a stored field and reference that field instead.'; + +/** + * Every code, its primary case first. The mapped type makes a code without a + * pin a type error in this file; the first test below makes it a red run. + */ +const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin, ...Pin[]] } = { + 'envelope-source-not-text': [ + { + produce: () => onlyError('predicate', { dialect: 'cel', source: 42 } as unknown as ExprInput), + params: { role: 'predicate', found: 'number' }, + message: + 'invalid predicate envelope: an expression envelope carries its expression as a string `source` — found a number. ' + + NON_STRING_ENVELOPE_TAIL('`record.rating >= 4`', 'cel'), + }, + { + produce: () => onlyError('template', { source: ['a'] } as unknown as ExprInput), + params: { role: 'template', found: 'array' }, + message: + 'invalid template envelope: an expression envelope carries its expression as a string `source` — found an array. ' + + NON_STRING_ENVELOPE_TAIL('`Hi {{ record.name }}`', 'template'), + }, + { + produce: () => onlyError('value', { source: { a: 1 } } as unknown as ExprInput), + params: { role: 'value', found: 'object' }, + message: + 'invalid value envelope: an expression envelope carries its expression as a string `source` — found an object. ' + + NON_STRING_ENVELOPE_TAIL('`record.rating >= 4`', 'cel'), + }, + ], + 'template-dialect-mismatch': [ + { + produce: () => onlyError('template', { dialect: 'cel', source: 'x' }), + params: { dialect: 'cel' }, + message: 'expected a text template but got a `cel` expression.', + }, + ], + 'invalid-template': [ + { + produce: () => onlyError('template', 'Hi {{ record.name '), + params: { detail: templateDetail('Hi {{ record.name ') }, + message: `invalid template: ${templateDetail('Hi {{ record.name ')} (holes use \`{{ path }}\`).`, + }, + ], + 'template-single-brace': [ + { + produce: () => onlyError('template', 'Hi {name}'), + params: { ref: 'name' }, + message: 'single-brace `{name}` is not a valid template hole — use double braces: `{{ name }}`.', + }, + ], + 'cel-dialect-mismatch': [ + { + produce: () => onlyError('predicate', { dialect: 'js', source: 'x' }), + params: { dialect: 'js' }, + message: 'expected a CEL expression but got a `js` dialect.', + }, + ], + 'invalid-cel': [ + { + produce: () => onlyError('predicate', 'record.rating >='), + params: { role: 'predicate', detail: celDetail('record.rating >=') }, + message: `invalid CEL predicate: ${celDetail('record.rating >=')} — predicates are bare CEL (e.g. \`record.rating >= 4\`).`, + }, + { + produce: () => onlyError('value', "1 + 'a'"), + params: { role: 'value', detail: celDetail("1 + 'a'") }, + message: `invalid CEL value: ${celDetail("1 + 'a'")} — values are bare CEL (e.g. \`record.rating >= 4\`).`, + }, + ], + 'cel-too-large': [ + { + produce: () => onlyError('predicate', OVERSIZED_LIST), + params: { role: 'predicate', detail: celDetail(OVERSIZED_LIST), limit: 'maxListElements', limitValue: 64 }, + message: + `invalid CEL predicate: ${celDetail(OVERSIZED_LIST)} — this is valid CEL that exceeds the \`maxListElements\` ` + + 'budget (limit 64) — a SIZE fault, not a dialect mistake, so re-spelling the expression will not fix it. ' + + 'Shrink it (fewer clauses, shallower nesting, fewer list elements), or precompute the heavy part into a stored ' + + 'field and reference that field instead. Splitting it into several expressions changes how they combine at ' + + 'this authoring site, so check that site\'s semantics before doing that.', + }, + ], + 'cel-method-call': [ + { + produce: () => onlyError('predicate', "record.name.upper() == 'X'"), + params: { role: 'predicate', detail: celDetail("record.name.upper() == 'X'"), name: 'upper', receiver: 'record.name' }, + message: + `invalid CEL predicate: ${celDetail("record.name.upper() == 'X'")} — \`upper\` is callable bare, not as a method — ` + + 'a CALL-SHAPE fault, not a dialect mistake, so re-spelling the expression will not fix it. ' + + 'Write `upper(record.name)` instead.' + METHOD_CALL_TAIL, + }, + { + produce: () => onlyError('predicate', "record.tags[0].upper() == 'X'"), + params: { role: 'predicate', detail: celDetail("record.tags[0].upper() == 'X'"), name: 'upper' }, + message: + `invalid CEL predicate: ${celDetail("record.tags[0].upper() == 'X'")} — \`upper\` is callable bare, not as a method — ` + + 'a CALL-SHAPE fault, not a dialect mistake, so re-spelling the expression will not fix it. ' + + 'Write `upper(…)` with the receiver as its first argument instead.' + METHOD_CALL_TAIL, + }, + ], + 'cel-unknown-function': [ + { + produce: () => onlyError('predicate', "upperr(record.name) == 'X'"), + params: { role: 'predicate', detail: celDetail("upperr(record.name) == 'X'"), name: 'upperr', suggestion: 'upper' }, + message: + `invalid CEL predicate: ${celDetail("upperr(record.name) == 'X'")} — \`upperr\` is not a callable name here — ` + + 'a NAME fault, not a dialect mistake, so re-spelling the expression will not fix it. Did you mean `upper`?' + + UNKNOWN_FUNCTION_TAIL, + }, + { + produce: () => onlyError('value', 'frobnicate(record.x)'), + params: { role: 'value', detail: celDetail('frobnicate(record.x)'), name: 'frobnicate' }, + message: + `invalid CEL value: ${celDetail('frobnicate(record.x)')} — \`frobnicate\` is not a callable name here — ` + + 'a NAME fault, not a dialect mistake, so re-spelling the expression will not fix it.' + UNKNOWN_FUNCTION_TAIL, + }, + ], + 'cel-template-brace': [ + { + produce: () => onlyError('predicate', "{status} == 'x'"), + params: { role: 'predicate', detail: celDetail("{status} == 'x'"), ref: 'status' }, + message: + `invalid CEL predicate: ${celDetail("{status} == 'x'")} — it looks like a \`{status}\` template brace was used ` + + 'inside a CEL expression — `{…}` parses as a CEL map literal and fails. Write the bare reference instead, e.g. `status`.', + }, + ], + 'unknown-field': [ + { + produce: () => onlyError('predicate', 'record.amout > 1', { fields: ['amount'], objectName: 'deal' }), + params: { field: 'amout', objectName: 'deal', suggestion: 'amount' }, + message: 'unknown field `amout` on `deal` — did you mean `amount`?', + }, + { + produce: () => onlyError('predicate', 'record.zzz > 1', { fields: ['amount'] }), + params: { field: 'zzz' }, + message: 'unknown field `zzz`', + }, + ], + 'unknown-role': [ + { + produce: () => onlyError('predicate', "'org_admni' in current_user.positions", { roleCatalog: ['org_admin', 'sales'] }), + params: { name: 'org_admni', suggestion: 'org_admin', catalog: ['org_admin', 'sales'] }, + message: 'unknown role `org_admni` — not a defined role; did you mean `org_admin`? Valid roles: org_admin, sales.', + }, + { + produce: () => onlyError('predicate', "'zzzzzzzz' in current_user.positions", { roleCatalog: ['org_admin', 'sales'] }), + params: { name: 'zzzzzzzz', catalog: ['org_admin', 'sales'] }, + message: 'unknown role `zzzzzzzz` — not a defined role. Valid roles: org_admin, sales.', + }, + ], + 'unbound-root': [ + { + produce: () => onlyError('predicate', "pge.selectedProjectId != ''", { scope: 'record', roots: ['page'] }), + params: { name: 'pge', roots: ['page'], suggestion: 'page' }, + message: + 'unbound root `pge` — beyond the record this authoring surface binds `page`, and `pge` is none of them, ' + + 'so `pge.…` resolves to nothing and the expression silently evaluates to null. Did you mean `page`?', + }, + ], + 'bare-reference': [ + { + produce: () => onlyError('value', 'amount * 2', { scope: 'record' }), + params: { name: 'amount' }, + message: + 'bare reference `amount` — a formula/validation expression binds the record as the `record` namespace, ' + + 'not at top level, so `amount` resolves to nothing and the expression silently evaluates to null. Write `record.amount`.', + }, + ], + 'date-arithmetic': [ + { + produce: () => onlyError('value', 'record.close_date + 1', { scope: 'record', fieldTypes: { close_date: 'date' } }), + params: { operands: 'google.protobuf.Timestamp + int', reference: 'record.close_date' }, + message: + 'date arithmetic `google.protobuf.Timestamp + int` — `record.close_date` is a date, and CEL can\'t do arithmetic ' + + 'on dates: this faults at runtime, so the field silently evaluates to null. Use `daysBetween(a, b)` for the span ' + + 'in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.', + }, + { + produce: () => + onlyError('predicate', 'close_date + 1 > 3', { fields: ['close_date'], fieldTypes: { close_date: 'date' } }), + params: { operands: 'google.protobuf.Timestamp + int', reference: 'close_date' }, + message: + 'date arithmetic `google.protobuf.Timestamp + int` — `close_date` is a date, and CEL can\'t do arithmetic ' + + 'on dates: this faults at runtime, so the field silently evaluates to null. Use `daysBetween(a, b)` for the span ' + + 'in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.', + }, + ], + 'type-mismatch': [ + { + produce: () => onlyWarning('predicate', 'record.name > 5', { scope: 'record', fieldTypes: { name: 'text' } }), + params: { operands: 'string > int', operator: '>', held: 'text', reference: 'record.name' }, + message: + 'type mismatch `string > int` — `record.name` holds text but is used with `>` against a number. This faults at ' + + 'runtime, so the expression silently evaluates to null (unless the value happens to be numeric). Use a number ' + + 'field, or drop the arithmetic/comparison.', + }, + { + produce: () => + onlyWarning('predicate', 'record.active + 1 > 5', { scope: 'record', fieldTypes: { active: 'boolean' } }), + params: { operands: 'bool + int', operator: '+', held: 'boolean', reference: 'record.active' }, + message: + 'type mismatch `bool + int` — `record.active` holds a boolean but is used with `+` against a number. This faults ' + + 'at runtime, so the expression silently evaluates to null (unless the value happens to be numeric). Use a number ' + + 'field, or drop the arithmetic/comparison.', + }, + ], + 'field-near-miss': [ + { + produce: () => onlyWarning('predicate', "stauts == 'x'", { fields: ['status'], objectName: 'deal' }), + params: { name: 'stauts', suggestion: 'status', objectName: 'deal' }, + message: + '`stauts` is not a field of `deal` — did you mean `status`? (flow conditions reference fields bare, e.g. ' + + '`status == …`). If `stauts` is a flow variable this is safe to ignore.', + }, + { + produce: () => onlyWarning('predicate', "stauts == 'x'", { fields: ['status'] }), + params: { name: 'stauts', suggestion: 'status' }, + message: + '`stauts` is not a field of `the trigger object` — did you mean `status`? (flow conditions reference fields ' + + 'bare, e.g. `status == …`). If `stauts` is a flow variable this is safe to ignore.', + }, + ], + 'traversal-bare-and-traversed': [ + { + produce: () => + onlyError('predicate', "record.account == 'a1' && record.account.name == 'x'", { + fieldTypes: { account: 'lookup' }, + traversalHydration: true, + }), + params: { root: 'record', field: 'account' }, + message: + '`record.account` is read BOTH through the relationship (`record.account.`) and as a plain value ' + + '(`record.account`) in the same expression. Reading through the relationship resolves `record.account` to the ' + + 'related RECORD, so the plain-value comparison would stop matching the stored id — silently. To compare the id, ' + + 'write `record.account.id` for the value comparison, and keep `record.account.` for the ' + + 'traversal. `record.account.id` is not a null guard: it reads through `account` too, and a rule that reads ' + + 'through an empty `account` rejects the write instead of being skipped. If the plain value tests for empty, ' + + 'take that test out of this expression. To skip the rule while `account` is empty, guard it on `account` being ' + + 'set: make it the `then` of a `conditional` rule whose `when` is `record.account != null`. To refuse an empty ' + + '`account`, make `account` required (`required: true`).', + }, + ], + 'traversal-multi-hop': [ + { + produce: () => + onlyError('predicate', "record.account.owner.name == 'x'", { + fieldTypes: { account: 'lookup' }, + traversalHydration: true, + }), + params: { root: 'record', field: 'account' }, + message: + '`record.account` is read through more than one relationship hop. Predicates resolve ONE hop ' + + '(`record.account.`); a second hop is not loaded, so the expression would fault at evaluation ' + + 'time and reject the write. Denormalise the value you need onto `account`\'s object, or read it in a hook instead.', + }, + ], + 'empty-expression': [ + { + produce: () => rootsRefusal(' '), + params: {}, + message: 'expression is empty', + }, + ], + 'cel-parse-failed': [ + { + produce: () => rootsRefusal('a =='), + params: { detail: celDetail('a ==') }, + message: celDetail('a =='), + }, + { + produce: () => rootsRefusal(OVERSIZED_LIST), + params: { detail: celDetail(OVERSIZED_LIST) }, + message: celDetail(OVERSIZED_LIST), + }, + ], +}; + +describe('expression refusal codes — one pin per code (code, params, unchanged message)', () => { + for (const code of EXPRESSION_REFUSAL_CODES) { + it(code, () => { + const cases = PINS[code] as readonly Pin[]; + for (const pin of cases) { + const said = pin.produce(); + expect(said.code).toBe(code); + expect(said.params).toEqual(pin.params); + expect(said.message).toBe(pin.message); + } + }); + } +}); + +/** + * Inputs chosen to walk every arm of both producers, and a good many inputs + * that are refused by none — the sweep asserts about whatever it provokes, so + * a clean input costs nothing and a new arm reached by an old input is judged. + */ +const SWEEP_SOURCES: readonly string[] = [ + '', + ' ', + 'record.rating >= 4', + 'record.rating >=', + "1 + 'a'", + 'a ==', + OVERSIZED_LIST, + "record.name.upper() == 'X'", + "record.tags[0].upper() == 'X'", + "upperr(record.name) == 'X'", + 'frobnicate(record.x)', + "upper(1, 2) == 'X'", + "record.name.contains() == true", + "{status} == 'x'", + 'Hi {name}', + 'Hi {{ record.name }}', + 'Hi {{ record.name ', + 'Hi {{ }}', + 'record.amout > 1', + 'record.amount > 1', + 'amount * 2', + "stauts == 'x'", + "status == 'x'", + "pge.selectedProjectId != ''", + "page.selectedProjectId != ''", + "'org_admni' in current_user.positions", + "current_user.positions.contains('zzz')", + 'record.close_date + 1', + 'close_date + 1 > 3', + 'record.name > 5', + 'record.active + 1 > 5', + "record.account == 'a1' && record.account.name == 'x'", + "record.account.owner.name == 'x'", + "type == 'grid' && status == 'q'", + 'has(status) && other == 1', + 'daysBetween(record.a, record.b) > 3', +]; + +const SWEEP_INPUTS: readonly ExprInput[] = [ + ...SWEEP_SOURCES, + ...SWEEP_SOURCES.map((source) => ({ dialect: 'cel', source })), + ...SWEEP_SOURCES.map((source) => ({ dialect: 'template', source })), + { dialect: 'js', source: 'x' }, + { source: 42 } as unknown as ExprInput, + { source: ['a'] } as unknown as ExprInput, + { source: { a: 1 } } as unknown as ExprInput, + { source: true } as unknown as ExprInput, + { dialect: 'cel' }, + null, + undefined, +]; + +const SWEEP_SCHEMAS: readonly (ExprSchemaHint | undefined)[] = [ + undefined, + { scope: 'record' }, + { scope: 'record', roots: ['page'] }, + { + scope: 'record', + objectName: 'deal', + fields: ['amount', 'close_date', 'name', 'active', 'account', 'status'], + fieldTypes: { close_date: 'date', name: 'text', active: 'boolean', account: 'lookup' }, + roleCatalog: ['org_admin', 'sales'], + }, + { fields: ['status', 'close_date'], objectName: 'deal', fieldTypes: { close_date: 'date' } }, + { fields: ['status'] }, + { fieldTypes: { account: 'lookup' }, traversalHydration: true }, +]; + +const VALIDATE_CODES: ReadonlySet = new Set( + EXPRESSION_REFUSAL_CODES.filter((c) => c !== 'empty-expression' && c !== 'cel-parse-failed'), +); +const ROOTS_CODES: ReadonlySet = new Set(['empty-expression', 'cel-parse-failed']); + +/** Every string a param carries, flattened — lists element by element, numbers as text. */ +function paramStrings(params: unknown): string[] { + const out: string[] = []; + for (const value of Object.values(params as Record)) { + if (Array.isArray(value)) out.push(...value.map(String)); + else out.push(String(value)); + } + return out; +} + +describe('expression refusal codes — the closed set', () => { + it('has a pin for every code, and no pin for a code outside the set', () => { + expect(Object.keys(PINS).sort()).toEqual([...EXPRESSION_REFUSAL_CODES].sort()); + expect(new Set(EXPRESSION_REFUSAL_CODES).size).toBe(EXPRESSION_REFUSAL_CODES.length); + for (const code of EXPRESSION_REFUSAL_CODES) expect(code).toMatch(/^[a-z]+(?:-[a-z]+)+$/); + }); + + it('is published from the package entry, frozen', () => { + expect(formula.EXPRESSION_REFUSAL_CODES).toBe(EXPRESSION_REFUSAL_CODES); + expect(Object.isFrozen(EXPRESSION_REFUSAL_CODES)).toBe(true); + }); + + it('every refusal validateExpression reports on the sweep carries a code from its set, with params the message interpolates', () => { + const provoked = new Set(); + let refusals = 0; + for (const role of ['predicate', 'value', 'template'] as const) { + for (const input of SWEEP_INPUTS) { + for (const schema of SWEEP_SCHEMAS) { + const r = validateExpression(role, input, schema); + for (const said of [...r.errors, ...r.warnings]) { + refusals++; + expect(VALIDATE_CODES.has(said.code), `${said.code} from ${JSON.stringify(input)}`).toBe(true); + expect(typeof said.params).toBe('object'); + expect(said.params).not.toBeNull(); + for (const part of paramStrings(said.params)) expect(said.message).toContain(part); + provoked.add(said.code); + } + } + } + } + // Positive control: the sweep is not vacuous — it reaches every code this + // producer owns, so a code whose arm stopped being reachable shows up too. + expect(refusals).toBeGreaterThan(100); + expect([...provoked].sort()).toEqual([...VALIDATE_CODES].sort()); + }); + + it('every refusal collectCelRootIdentifiers reports on the sweep carries a code from its set', () => { + const provoked = new Set(); + for (const source of SWEEP_SOURCES) { + const r = collectCelRootIdentifiers(source); + if (r.ok) continue; + expect(ROOTS_CODES.has(r.code)).toBe(true); + for (const part of paramStrings(r.params)) expect(r.error).toContain(part); + provoked.add(r.code); + } + expect([...provoked].sort()).toEqual([...ROOTS_CODES].sort()); + }); + + it('a refusal without a code does not type-check, on either producer', () => { + // @ts-expect-error — `code` and `params` are required on every validateExpression refusal. + const noCode: ExprValidationError = { source: 'x', message: 'y' }; + // @ts-expect-error — `code` and `params` are required on collectCelRootIdentifiers' failure arm. + const noRootsCode: CelRootIdentifiersResult = { ok: false, error: 'y' }; + // @ts-expect-error — a code's params are its own: `invalid-cel` carries no `ref`. + const wrongParams: ExprValidationError = { source: 'x', message: 'y', code: 'invalid-cel', params: { ref: 'r' } }; + expect([noCode, noRootsCode, wrongParams]).toHaveLength(3); + }); +}); diff --git a/packages/formula/src/expression-refusal.ts b/packages/formula/src/expression-refusal.ts new file mode 100644 index 00000000000..a4b77d93f1d --- /dev/null +++ b/packages/formula/src/expression-refusal.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The closed set of refusal CODES this package's expression producers emit — + * `validateExpression`'s `errors[]` / `warnings[]` and + * `collectCelRootIdentifiers`'s failure arm — each with the typed PARAMS its + * English `message` interpolates. + * + * ## Why a code beside the message + * + * The `message` is written for self-correction (ADR-0032 §Decision 1d) and it + * is English: an API caller, the CLI and the `validate_expression` agent tool + * read it as it is. A localized author surface cannot translate a sentence it + * does not own, and matching the English with a pattern would bind the consumer + * to wording this package rewrites whenever a prescription improves. So every + * refusal also carries a stable `code` and the variable parts as `params`, and + * a consumer keys its own catalogue row to the code and fills the row from the + * params. The `message` is unchanged by this: it is the same sentence, byte for + * byte, that the refusal carried before the code existed. + * + * ## The contract + * + * - A code names ONE message template. Two sentences that differ in more than + * an optional clause are two codes, so a catalogue row never has to branch + * on a param to pick its wording. Optional clauses (a did-you-mean, an + * object name) are optional params: present exactly when the English + * message carries that clause. + * - Codes are kebab-case and never change once published. A new refusal is a + * new code; a reworded message keeps its code. + * - Params are the interpolated values, not their rendering: a list is an + * array (the consumer joins it), a quoted reference is the bare text (the + * consumer quotes it), and a phrase chosen from a closed set is a token. + * - `detail` is a diagnostic the CEL or template engine wrote — English, and + * for CEL with an excerpt of the source. It is passed through as it is; + * this package does not own its wording. + * + * ⛔ Not an ADR-0112 error code. These are authoring diagnostics delivered as a + * function's return value, never a failing request's `error.code`, which is + * why they are kebab-case and not SCREAMING_SNAKE. + */ + +import type { CelLimitKey } from './cel-engine'; +import type { FieldRole } from './validate'; + +/** The CEL-dialect field roles — every role but `template`. */ +export type CelFieldRole = Exclude; + +/** + * What a non-text envelope `source` was, as a token. The message renders it + * with its article (`an array`, `a number`). + */ +export type ExpressionSourceKind = 'array' | 'object' | 'number' | 'boolean' | 'bigint' | 'symbol' | 'function'; + +/** Code → the params its message interpolates. The keys ARE the closed set. */ +export interface ExpressionRefusalParams { + /** An envelope whose `source` is present but is not text. */ + 'envelope-source-not-text': { readonly role: FieldRole; readonly found: ExpressionSourceKind }; + /** A `template` slot holding an envelope of another dialect. */ + 'template-dialect-mismatch': { readonly dialect: string }; + /** The template engine refused the text; `detail` is its reason. */ + 'invalid-template': { readonly detail: string }; + /** A single-brace `{ref}` where a template hole needs `{{ ref }}`. */ + 'template-single-brace': { readonly ref: string }; + /** A CEL slot holding an envelope of another dialect. */ + 'cel-dialect-mismatch': { readonly dialect: string }; + /** The CEL engine refused the source; the prescription is "write bare CEL". */ + 'invalid-cel': { readonly role: CelFieldRole; readonly detail: string }; + /** + * Valid CEL over one of the platform's parse budgets. `limit` and + * `limitValue` are present together when the bound is known, and absent + * together when it is not. + */ + 'cel-too-large': { + readonly role: CelFieldRole; + readonly detail: string; + readonly limit?: CelLimitKey; + readonly limitValue?: number; + }; + /** + * A bare-callable function written as a method (`record.name.upper()`). + * `receiver` is the plain dotted chain it was called on, when one can be + * lifted from the source. + */ + 'cel-method-call': { + readonly role: CelFieldRole; + readonly detail: string; + readonly name: string; + readonly receiver?: string; + }; + /** A call to a name that is not callable here; `suggestion` is the nearest advertised one. */ + 'cel-unknown-function': { + readonly role: CelFieldRole; + readonly detail: string; + readonly name: string; + readonly suggestion?: string; + }; + /** The CEL engine refused a source that holds a template brace `{ref}`. */ + 'cel-template-brace': { readonly role: CelFieldRole; readonly detail: string; readonly ref: string }; + /** `record.` naming no field of the object. */ + 'unknown-field': { readonly field: string; readonly objectName?: string; readonly suggestion?: string }; + /** A role-membership test naming a role outside the catalog; `catalog` is every valid role. */ + 'unknown-role': { readonly name: string; readonly suggestion?: string; readonly catalog: readonly string[] }; + /** `name.…` one typo away from a root the surface binds; `roots` are the surface's declared roots. */ + 'unbound-root': { readonly name: string; readonly roots: readonly string[]; readonly suggestion: string }; + /** A bare identifier in a `record`-scoped expression. */ + 'bare-reference': { readonly name: string }; + /** + * Arithmetic on a date field against a number. `operands` are the CEL + * operand types; `reference` is the field as written, when it is known. + */ + 'date-arithmetic': { readonly operands: string; readonly reference?: string }; + /** + * WARNING, never an error: a text or boolean field used with an arithmetic + * or ordering `operator` against a number. + */ + 'type-mismatch': { + readonly operands: string; + readonly operator: string; + readonly held: 'text' | 'boolean'; + readonly reference?: string; + }; + /** + * WARNING, never an error: a bare identifier in a flattened condition that + * is one typo away from a field. Absent `objectName` reads "the trigger object". + */ + 'field-near-miss': { readonly name: string; readonly suggestion: string; readonly objectName?: string }; + /** A reference field read both through the relationship and as a plain value. */ + 'traversal-bare-and-traversed': { readonly root: string; readonly field: string }; + /** A reference field read through more than one relationship hop. */ + 'traversal-multi-hop': { readonly root: string; readonly field: string }; + /** `collectCelRootIdentifiers` was handed no expression. */ + 'empty-expression': Readonly>; + /** `collectCelRootIdentifiers` could not parse the source; `detail` is the parser's reason. */ + 'cel-parse-failed': { readonly detail: string }; +} + +/** Every refusal code this package emits. */ +export type ExpressionRefusalCode = keyof ExpressionRefusalParams; + +/** + * One refusal's `code` and `params`, correlated: narrowing on `code` narrows + * `params`. `C` restricts it to the codes one producer emits. + */ +export type ExpressionRefusal = { + [K in C]: { readonly code: K; readonly params: ExpressionRefusalParams[K] }; +}[C]; + +/** The codes `collectCelRootIdentifiers` emits. */ +export type CelRootsRefusalCode = 'empty-expression' | 'cel-parse-failed'; + +/** The codes `validateExpression` emits, in `errors[]` and `warnings[]`. */ +export type ExprValidationCode = Exclude; + +/** + * Keyed by code so the compiler holds this list equal to + * {@link ExpressionRefusalParams}: a code missing here, or one that is not a + * key there, does not compile. + */ +const CODE_TABLE = { + 'envelope-source-not-text': true, + 'template-dialect-mismatch': true, + 'invalid-template': true, + 'template-single-brace': true, + 'cel-dialect-mismatch': true, + 'invalid-cel': true, + 'cel-too-large': true, + 'cel-method-call': true, + 'cel-unknown-function': true, + 'cel-template-brace': true, + 'unknown-field': true, + 'unknown-role': true, + 'unbound-root': true, + 'bare-reference': true, + 'date-arithmetic': true, + 'type-mismatch': true, + 'field-near-miss': true, + 'traversal-bare-and-traversed': true, + 'traversal-multi-hop': true, + 'empty-expression': true, + 'cel-parse-failed': true, +} as const satisfies Record; + +/** + * The closed set, as a value — for a consumer that must prove it has a + * catalogue row for every code, and for this package's own exhaustiveness pin. + */ +export const EXPRESSION_REFUSAL_CODES: readonly ExpressionRefusalCode[] = Object.freeze( + Object.keys(CODE_TABLE) as ExpressionRefusalCode[], +); diff --git a/packages/formula/src/index.ts b/packages/formula/src/index.ts index d5adab3b9eb..38034fafa0f 100644 --- a/packages/formula/src/index.ts +++ b/packages/formula/src/index.ts @@ -15,6 +15,7 @@ export { celEngine, DEFAULT_LIMITS } from './cel-engine'; // (approval `expression` approvers): lint and the runtime pre-check share this // one helper so what they accept can never drift. export { collectCelRootIdentifiers } from './cel-engine'; +export type { CelRootIdentifiersResult } from './cel-engine'; // #6128 — the strict-environment "does this identifier resolve?" oracle, the // same one `validateExpression` gives its `record`-scoped bare-ref verdict from. // Published for the same reason as `collectCelRootIdentifiers` above: a lint @@ -131,5 +132,19 @@ export type { UnknownFunctionCall } from './unknown-function'; // registration, and the agent-callable validate_expression tool). export { validateExpression, introspectScope, expectedDialect, inferExpressionType, nearestName, CEL_STDLIB_FUNCTIONS } from './validate'; export type { FieldRole, ExprInput, ExprSchemaHint, ExprValidationError, ExprValidationResult, InferredValueType } from './validate'; +// The closed set of refusal codes `validateExpression` and +// `collectCelRootIdentifiers` carry beside their English message, with the +// typed params each message interpolates — so a localized author surface keys +// its own catalogue rows to a code instead of translating our sentence. +export { EXPRESSION_REFUSAL_CODES } from './expression-refusal'; +export type { + CelFieldRole, + CelRootsRefusalCode, + ExprValidationCode, + ExpressionRefusal, + ExpressionRefusalCode, + ExpressionRefusalParams, + ExpressionSourceKind, +} from './expression-refusal'; export type { SeedValue, SeedPrimitive } from './seed-eval'; export type { DialectEngine, EvalContext, EvalResult, EvalError, EvalPermissions } from './types'; diff --git a/packages/formula/src/validate.ts b/packages/formula/src/validate.ts index 1255188ad6c..1858fc50640 100644 --- a/packages/formula/src/validate.ts +++ b/packages/formula/src/validate.ts @@ -39,6 +39,15 @@ import { isReceiverRegistered, receiverCallNameFromNoOverload, } from './unknown-function'; +import { DEFAULT_TRAVERSAL_ROOT, type TraversalConflictKind } from './relationship-traversal'; +import type { + CelFieldRole, + ExprValidationCode, + ExpressionRefusal, + ExpressionRefusalParams, + ExpressionSourceKind, +} from './expression-refusal'; +import type { CelLimitKey } from './cel-engine'; export type FieldRole = 'predicate' | 'value' | 'template'; @@ -163,13 +172,41 @@ export interface ExprSchemaHint { traversalHydration?: boolean; } -export interface ExprValidationError { +/** + * One refusal (in `errors[]`) or advisory (in `warnings[]`). + * + * `code` and `params` say the same thing as `message`, for a consumer that + * renders its own words: `code` is one of the closed set in + * `expression-refusal.ts` and `params` are the values `message` interpolates. + * `message` is the English sentence, unchanged. + */ +export type ExprValidationError = { /** Self-correcting message: what is wrong + the correct form. */ message: string; /** The offending source, echoed for location. */ source: string; +} & ExpressionRefusal; + +/** + * Build one {@link ExprValidationError}. The single place the correlation + * between `code` and `params` is asserted; every call site is checked against + * {@link ExpressionRefusalParams} for its own code. + */ +function refusal( + code: C, + params: ExpressionRefusalParams[C], + source: string, + message: string, +): ExprValidationError { + return { source, message, code, params } as ExprValidationError; } +/** A traversal conflict's kind → the refusal code it is reported under. */ +const TRAVERSAL_CONFLICT_CODE: Readonly> = { + 'bare-and-traversed': 'traversal-bare-and-traversed', + 'multi-hop': 'traversal-multi-hop', +}; + export interface ExprValidationResult { ok: boolean; errors: ExprValidationError[]; @@ -229,34 +266,43 @@ function typeSoundnessIssue( ): { issue: ExprValidationError; severity: 'error' | 'warning' } | null { const mismatch = firstTypeMismatch(source, toCelFieldTypes(fieldTypes), scope); if (!mismatch) return null; - const ref = mismatch.field - ? (scope === 'record' ? `\`record.${mismatch.field}\`` : `\`${mismatch.field}\``) - : null; + const reference = mismatch.field + ? (scope === 'record' ? `record.${mismatch.field}` : mismatch.field) + : undefined; + const ref = reference !== undefined ? `\`${reference}\`` : null; if (mismatch.category === 'date-arith') { const subject = ref ? `${ref} is a date` : 'a date field'; return { severity: 'error', - issue: { + issue: refusal( + 'date-arithmetic', + { operands: mismatch.operands, ...(reference !== undefined ? { reference } : {}) }, source, - message: - `date arithmetic \`${mismatch.operands}\` — ${subject}, and CEL can't do arithmetic on ` + + `date arithmetic \`${mismatch.operands}\` — ${subject}, and CEL can't do arithmetic on ` + `dates: this faults at runtime, so the field silently evaluates to null. Use ` + `\`daysBetween(a, b)\` for the span in whole days, and \`daysFromNow(n)\` / ` + `\`addDays(d, n)\` / \`addMonths(d, n)\` to shift a date.`, - }, + ), }; } - const held = mismatch.celType === 'bool' ? 'a boolean' : 'text'; + const heldKind = mismatch.celType === 'bool' ? 'boolean' : 'text'; + const held = heldKind === 'boolean' ? 'a boolean' : 'text'; const subject = ref ? `${ref} holds ${held}` : `${held === 'a boolean' ? 'a boolean' : 'a text'} field`; return { severity: 'warning', - issue: { + issue: refusal( + 'type-mismatch', + { + operands: mismatch.operands, + operator: mismatch.operator, + held: heldKind, + ...(reference !== undefined ? { reference } : {}), + }, source, - message: - `type mismatch \`${mismatch.operands}\` — ${subject} but is used with \`${mismatch.operator}\` ` + + `type mismatch \`${mismatch.operands}\` — ${subject} but is used with \`${mismatch.operator}\` ` + `against a number. This faults at runtime, so the expression silently evaluates to null ` + `(unless the value happens to be numeric). Use a number field, or drop the arithmetic/comparison.`, - }, + ), }; } @@ -270,11 +316,17 @@ export function expectedDialect(role: FieldRole): 'cel' | 'template' { return role === 'template' ? 'template' : 'cel'; } -/** `an array` / `an object` / `a number` — never called with a string or nullish. */ -function describeNonStringSource(value: unknown): string { - if (Array.isArray(value)) return 'an array'; - if (typeof value === 'object') return 'an object'; - return `a ${typeof value}`; +/** `array` / `object` / `number` — never called with a string or nullish. */ +function nonStringSourceKind(value: unknown): ExpressionSourceKind { + if (Array.isArray(value)) return 'array'; + return typeof value as ExpressionSourceKind; +} + +/** `an array` / `an object` / `a number` — the kind as the message renders it. */ +function describeNonStringSource(kind: ExpressionSourceKind): string { + if (kind === 'array') return 'an array'; + if (kind === 'object') return 'an object'; + return `a ${kind}`; } /** @@ -302,13 +354,13 @@ function describeNonStringSource(value: unknown): string { * unchanged — including the `{ ast }` envelope, which carries no `source` at all * and whose admission is `ExpressionSchema`'s rule, not this function's. */ -function toSource(input: ExprInput): { dialect?: string; source: string; nonStringSource?: string } { +function toSource(input: ExprInput): { dialect?: string; source: string; nonStringSource?: ExpressionSourceKind } { if (input == null) return { source: '' }; if (typeof input === 'string') return { source: input }; const raw = (input as { source?: unknown }).source; if (raw == null) return { dialect: input.dialect, source: '' }; if (typeof raw !== 'string') { - return { dialect: input.dialect, source: '', nonStringSource: describeNonStringSource(raw) }; + return { dialect: input.dialect, source: '', nonStringSource: nonStringSourceKind(raw) }; } return { dialect: input.dialect, source: raw }; } @@ -323,14 +375,16 @@ function toSource(input: ExprInput): { dialect?: string; source: string; nonStri const NON_STRING_SOURCE_REFUSAL = 'an expression envelope carries its expression as a string `source`'; -function bracesHint(source: string): string | null { +function bracesHint(source: string): { text: string; ref: string } | null { const m = SINGLE_BRACE_RE.exec(source); if (!m) return null; const ref = m[1]; - return ( - `it looks like a \`{${ref}}\` template brace was used inside a CEL expression — ` + - `\`{…}\` parses as a CEL map literal and fails. Write the bare reference instead, e.g. \`${ref}\`.` - ); + return { + ref, + text: + `it looks like a \`{${ref}}\` template brace was used inside a CEL expression — ` + + `\`{…}\` parses as a CEL map literal and fails. Write the bare reference instead, e.g. \`${ref}\`.`, + }; } /** @@ -365,24 +419,26 @@ function bracesHint(source: string): string | null { * formula value. Shrinking and denormalising are safe everywhere; splitting is * offered only with the caveat that the site decides what splitting means. */ -function boundsHint(source: string): string | null { +function boundsHint(source: string): { text: string; limit?: CelLimitKey; limitValue?: number } | null { const parsed = parseCelToAstWithReason(source); // `celEngine.compile` said `bounds`, and both verdicts are graded by the same // `classifyCelFault`, so this holds — but a narrowing that ever stopped // holding must degrade to the old trailer, never to a wrong bound name. if (parsed.ok || parsed.kind !== 'bounds') return null; const { limit, limitValue } = parsed.overrun; - const bound = - limit && limitValue != null - ? `the \`${limit}\` budget (limit ${limitValue})` - : "one of the platform's parse budgets"; - return ( - `this is valid CEL that exceeds ${bound} — a SIZE fault, not a dialect mistake, so ` + - `re-spelling the expression will not fix it. Shrink it (fewer clauses, shallower nesting, ` + - `fewer list elements), or precompute the heavy part into a stored field and reference that ` + - `field instead. Splitting it into several expressions changes how they combine at this ` + - `authoring site, so check that site's semantics before doing that.` - ); + const known = limit && limitValue != null ? { limit, limitValue } : null; + const bound = known + ? `the \`${known.limit}\` budget (limit ${known.limitValue})` + : "one of the platform's parse budgets"; + return { + ...(known ?? {}), + text: + `this is valid CEL that exceeds ${bound} — a SIZE fault, not a dialect mistake, so ` + + `re-spelling the expression will not fix it. Shrink it (fewer clauses, shallower nesting, ` + + `fewer list elements), or precompute the heavy part into a stored field and reference that ` + + `field instead. Splitting it into several expressions changes how they combine at this ` + + `authoring site, so check that site's semantics before doing that.`, + }; } /** @@ -458,18 +514,21 @@ function nearestCallable(name: string): string | undefined { * contains is being adjudicated separately, and a message asserting a count * would be falsified by that ruling. */ -function unknownFunctionHint(celMessage: string): string | null { +function unknownFunctionHint(celMessage: string): { text: string; name: string; suggestion?: string } | null { const name = callNameFromNoOverload(celMessage); if (!name || CEL_STDLIB_FUNCTIONS.includes(name)) return null; const suggestion = nearestCallable(name); - return ( - `\`${name}\` is not a callable name here — a NAME fault, not a dialect mistake, so ` + - `re-spelling the expression will not fix it.` + - (suggestion ? ` Did you mean \`${suggestion}\`?` : '') + - ` The callable names this platform advertises for authoring are the \`functions\` list ` + - `\`introspectScope\` returns (\`CEL_STDLIB_FUNCTIONS\`) — pick one of those, or precompute ` + - `the value in a stored field and reference that field instead.` - ); + return { + name, + ...(suggestion ? { suggestion } : {}), + text: + `\`${name}\` is not a callable name here — a NAME fault, not a dialect mistake, so ` + + `re-spelling the expression will not fix it.` + + (suggestion ? ` Did you mean \`${suggestion}\`?` : '') + + ` The callable names this platform advertises for authoring are the \`functions\` list ` + + `\`introspectScope\` returns (\`CEL_STDLIB_FUNCTIONS\`) — pick one of those, or precompute ` + + `the value in a stored field and reference that field instead.`, + }; } /** @@ -542,22 +601,25 @@ function receiverChainInSource(source: string, name: string): string | undefined * class keeps the existing trailer: its fault is the ARGUMENTS, and grading * those is the blind spot #13594 deliberately keeps blind. */ -function receiverCallHint(celMessage: string, source: string): string | null { +function receiverCallHint(celMessage: string, source: string): { text: string; name: string; receiver?: string } | null { const name = receiverCallNameFromNoOverload(celMessage); if (!name || !CEL_STDLIB_FUNCTIONS.includes(name)) return null; if (isReceiverRegistered(name)) return null; const receiver = receiverChainInSource(source, name); - return ( - `\`${name}\` is callable bare, not as a method — a CALL-SHAPE fault, not a dialect ` + - `mistake, so re-spelling the expression will not fix it. ` + - (receiver - ? `Write \`${name}(${receiver})\` instead.` - : `Write \`${name}(…)\` with the receiver as its first argument instead.`) + - ` The callable names this platform advertises for authoring (the \`functions\` list ` + - `\`introspectScope\` returns, \`CEL_STDLIB_FUNCTIONS\`) take their subject as an ` + - `argument; only cel-js's own receiver methods (\`record.name.split(',')\`) are written ` + - `after a dot.` - ); + return { + name, + ...(receiver ? { receiver } : {}), + text: + `\`${name}\` is callable bare, not as a method — a CALL-SHAPE fault, not a dialect ` + + `mistake, so re-spelling the expression will not fix it. ` + + (receiver + ? `Write \`${name}(${receiver})\` instead.` + : `Write \`${name}(…)\` with the receiver as its first argument instead.`) + + ` The callable names this platform advertises for authoring (the \`functions\` list ` + + `\`introspectScope\` returns, \`CEL_STDLIB_FUNCTIONS\`) take their subject as an ` + + `argument; only cel-js's own receiver methods (\`record.name.split(',')\`) are written ` + + `after a dot.`, + }; } function checkFieldExistence(source: string, schema: ExprSchemaHint | undefined, errors: ExprValidationError[]): void { @@ -571,12 +633,17 @@ function checkFieldExistence(source: string, schema: ExprSchemaHint | undefined, if (seen.has(field) || known.has(field)) continue; seen.add(field); const suggestion = nearest(field, schema.fields); - errors.push({ + errors.push(refusal( + 'unknown-field', + { + field, + ...(schema.objectName ? { objectName: schema.objectName } : {}), + ...(suggestion ? { suggestion } : {}), + }, source, - message: - `unknown field \`${field}\`${schema.objectName ? ` on \`${schema.objectName}\`` : ''}` + + `unknown field \`${field}\`${schema.objectName ? ` on \`${schema.objectName}\`` : ''}` + (suggestion ? ` — did you mean \`${suggestion}\`?` : ''), - }); + )); } } @@ -620,22 +687,25 @@ function isNamespaceUse(name: string, source: string): boolean { * prescriptions at once: two findings pointing opposite ways is the shape * the author cannot act on. */ -function mistypedRootMessage( +function mistypedRootRefusal( bare: string, source: string, surfaceRoots: readonly string[], fields: readonly string[] | undefined, -): string | null { +): ExprValidationError | null { if (surfaceRoots.length === 0) return null; if (fields?.includes(bare)) return null; if (!isNamespaceUse(bare, source)) return null; const suggestion = nearest(bare, surfaceRoots); if (!suggestion) return null; - return ( + return refusal( + 'unbound-root', + { name: bare, roots: [...surfaceRoots], suggestion }, + source, `unbound root \`${bare}\` — beyond the record this authoring surface binds ` + - `\`${surfaceRoots.join('`, `')}\`, and \`${bare}\` is none of them, so \`${bare}.…\` ` + - `resolves to nothing and the expression silently evaluates to null. ` + - `Did you mean \`${suggestion}\`?` + `\`${surfaceRoots.join('`, `')}\`, and \`${bare}\` is none of them, so \`${bare}.…\` ` + + `resolves to nothing and the expression silently evaluates to null. ` + + `Did you mean \`${suggestion}\`?`, ); } @@ -713,13 +783,14 @@ function checkRoleCatalog( if (known.has(name) || seen.has(name)) continue; seen.add(name); const suggestion = nearest(name, catalog); - errors.push({ + errors.push(refusal( + 'unknown-role', + { name, ...(suggestion ? { suggestion } : {}), catalog: [...catalog] }, source, - message: - `unknown role \`${name}\` — not a defined role` + + `unknown role \`${name}\` — not a defined role` + (suggestion ? `; did you mean \`${suggestion}\`?` : '.') + ` Valid roles: ${catalog.join(', ')}.`, - }); + )); } } } @@ -746,14 +817,15 @@ export function validateExpression( // Attributed to the empty string, deliberately: the value that would be the // location IS the value being refused, so echoing it would put a non-string // into a `source: string` slot every caller renders. - errors.push({ - source: '', - message: - `invalid ${role} envelope: ${NON_STRING_SOURCE_REFUSAL} — found ${nonStringSource}. ` + + errors.push(refusal( + 'envelope-source-not-text', + { role, found: nonStringSource }, + '', + `invalid ${role} envelope: ${NON_STRING_SOURCE_REFUSAL} — found ${describeNonStringSource(nonStringSource)}. ` + `Write the expression as bare text (e.g. ${role === 'template' ? '`Hi {{ record.name }}`' : '`record.rating >= 4`'}), ` + `or as an envelope whose \`source\` is that text ` + `(e.g. \`{ dialect: '${expectedDialect(role)}', source: '…' }\`).`, - }); + )); return { ok: false, errors, warnings }; } if (!source.trim()) return { ok: true, errors, warnings }; @@ -762,22 +834,37 @@ export function validateExpression( // Templates must be the `template` dialect (or untyped string). Reject a // CEL envelope mistakenly placed in a text field. if (dialect && dialect !== 'template') { - errors.push({ source, message: `expected a text template but got a \`${dialect}\` expression.` }); + errors.push(refusal( + 'template-dialect-mismatch', + { dialect }, + source, + `expected a text template but got a \`${dialect}\` expression.`, + )); return { ok: false, errors, warnings }; } const compiled = templateEngine.compile(source); if (!compiled.ok) { - errors.push({ source, message: `invalid template: ${compiled.error.message} (holes use \`{{ path }}\`).` }); + errors.push(refusal( + 'invalid-template', + { detail: compiled.error.message }, + source, + `invalid template: ${compiled.error.message} (holes use \`{{ path }}\`).`, + )); } // A single `{x}` in a template is the legacy/deprecated form (ADR-0032 §3). const hint = SINGLE_BRACE_RE.test(source) ? bracesHintForTemplate(source) : null; - if (hint) errors.push({ source, message: hint }); + if (hint) errors.push(refusal('template-single-brace', { ref: hint.ref }, source, hint.text)); return { ok: errors.length === 0, errors, warnings }; } // predicate | value → CEL if (dialect && dialect !== 'cel') { - errors.push({ source, message: `expected a CEL expression but got a \`${dialect}\` dialect.` }); + errors.push(refusal( + 'cel-dialect-mismatch', + { dialect }, + source, + `expected a CEL expression but got a \`${dialect}\` dialect.`, + )); return { ok: false, errors, warnings }; } const compiled = celEngine.compile(source); @@ -798,19 +885,36 @@ export function validateExpression( // mismatch (`1 + 'a'`, `no such overload: int + string`), or a real function // handed wrong arguments (`upper(1, 2)`, `record.name.contains()`) — gets // null from both and keeps the existing trailer. - const classHint = - compiled.error.kind === 'bounds' - ? boundsHint(source) - : compiled.error.kind === 'type' - ? (receiverCallHint(compiled.error.message, source) ?? unknownFunctionHint(compiled.error.message)) - : null; - const hint = classHint ?? bracesHint(source); - errors.push({ - source, - message: - `invalid CEL ${role}: ${compiled.error.message}` + - (hint ? ` — ${hint}` : ` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`), - }); + // + // Each arm is its own refusal code, so the one that wrote the prescription + // is the one that names the code — the message and the code cannot + // disagree about which fault this was. + const celRole: CelFieldRole = role; + const detail = compiled.error.message; + const lead = `invalid CEL ${role}: ${detail}`; + const bounds = compiled.error.kind === 'bounds' ? boundsHint(source) : null; + const method = !bounds && compiled.error.kind === 'type' ? receiverCallHint(detail, source) : null; + const unknownFn = !bounds && !method && compiled.error.kind === 'type' ? unknownFunctionHint(detail) : null; + const braces = !bounds && !method && !unknownFn ? bracesHint(source) : null; + if (bounds) { + const { text, ...budget } = bounds; + errors.push(refusal('cel-too-large', { role: celRole, detail, ...budget }, source, `${lead} — ${text}`)); + } else if (method) { + const { text, ...call } = method; + errors.push(refusal('cel-method-call', { role: celRole, detail, ...call }, source, `${lead} — ${text}`)); + } else if (unknownFn) { + const { text, ...call } = unknownFn; + errors.push(refusal('cel-unknown-function', { role: celRole, detail, ...call }, source, `${lead} — ${text}`)); + } else if (braces) { + errors.push(refusal('cel-template-brace', { role: celRole, detail, ref: braces.ref }, source, `${lead} — ${braces.text}`)); + } else { + errors.push(refusal( + 'invalid-cel', + { role: celRole, detail }, + source, + `${lead} — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`, + )); + } } else { checkFieldExistence(source, schema, errors); checkRoleCatalog(source, schema, errors); @@ -825,14 +929,17 @@ export function validateExpression( const surfaceRoots = schema.roots ?? []; const bare = firstUndeclaredReference(source, surfaceRoots); if (bare) { - errors.push({ - source, - message: - mistypedRootMessage(bare, source, surfaceRoots, schema.fields) ?? - `bare reference \`${bare}\` — a formula/validation expression binds the record as the ` + - `\`record\` namespace, not at top level, so \`${bare}\` resolves to nothing and the ` + - `expression silently evaluates to null. Write \`record.${bare}\`.`, - }); + errors.push( + mistypedRootRefusal(bare, source, surfaceRoots, schema.fields) ?? + refusal( + 'bare-reference', + { name: bare }, + source, + `bare reference \`${bare}\` — a formula/validation expression binds the record as the ` + + `\`record\` namespace, not at top level, so \`${bare}\` resolves to nothing and the ` + + `expression silently evaluates to null. Write \`record.${bare}\`.`, + ), + ); } else if (schema.fieldTypes) { // #1928 / #3306 — with per-field types in hand, flag a type-unsound // operator use that faults at runtime and silently nulls: a text/boolean @@ -856,13 +963,18 @@ export function validateExpression( if (unknown) { const suggestion = nearest(unknown, schema.fields); if (suggestion) { - warnings.push({ + warnings.push(refusal( + 'field-near-miss', + { + name: unknown, + suggestion, + ...(schema.objectName != null ? { objectName: schema.objectName } : {}), + }, source, - message: - `\`${unknown}\` is not a field of \`${schema.objectName ?? 'the trigger object'}\` — ` + + `\`${unknown}\` is not a field of \`${schema.objectName ?? 'the trigger object'}\` — ` + `did you mean \`${suggestion}\`? (flow conditions reference fields bare, e.g. \`${suggestion} == …\`). ` + `If \`${unknown}\` is a flow variable this is safe to ignore.`, - }); + )); } } // #1928 / #3306 — the same type-soundness check, for bare-field conditions: @@ -895,16 +1007,23 @@ export function validateExpression( analysis, (field) => REFERENCE_VALUE_TYPES.has(schema.fieldTypes![field] ?? ''), ); - for (const conflict of conflicts) errors.push({ source, message: conflict.message }); + for (const conflict of conflicts) { + errors.push(refusal( + TRAVERSAL_CONFLICT_CODE[conflict.kind], + { root: DEFAULT_TRAVERSAL_ROOT, field: conflict.field }, + source, + conflict.message, + )); + } } } return { ok: errors.length === 0, errors, warnings }; } -function bracesHintForTemplate(source: string): string { +function bracesHintForTemplate(source: string): { text: string; ref: string } { const m = SINGLE_BRACE_RE.exec(source); const ref = m?.[1] ?? 'field'; - return `single-brace \`{${ref}}\` is not a valid template hole — use double braces: \`{{ ${ref} }}\`.`; + return { ref, text: `single-brace \`{${ref}}\` is not a valid template hole — use double braces: \`{{ ${ref} }}\`.` }; } /** diff --git a/packages/spec/src/automation/flow-node-expression-paths.ts b/packages/spec/src/automation/flow-node-expression-paths.ts index 44394d220f6..23abb2c4cef 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.ts @@ -451,6 +451,77 @@ export const PREDICATE_SLOT_STRING_REFUSAL = 'A predicate slot holds BARE CEL TEXT that states a rule — it is declared `z.string()` — so an expression ' + 'envelope, any other non-string, or a string that is blank after trimming is not authorable there.'; +/** + * What a non-text value in a predicate slot was, as a token. The message + * renders it as a phrase (`an expression envelope (…)`, `an array`, `a number`). + */ +export type PredicateSlotValueKind = + | 'envelope' + | 'array' + | 'object' + | 'number' + | 'boolean' + | 'bigint' + | 'symbol' + | 'function'; + +/** + * Refusal code → the params its message interpolates, for + * {@link predicateSlotRefusal}. The keys ARE the closed set. + * + * A consumer that renders its own words — a localized designer — keys its + * catalogue row to the `code` and fills it from the `params`; the English + * `message` beside them is unchanged. Codes are kebab-case and never change + * once published: a new refusal is a new code, a reworded message keeps its + * code. ⛔ Not an ADR-0112 error code — an authoring diagnostic returned as a + * value, never a failing request's `error.code`. + */ +export interface PredicateSlotRefusalParams { + /** No value where the slot is required: the key is absent, or holds `null`. */ + 'predicate-slot-missing': { readonly found: 'absent' | 'null' }; + /** A string that is blank after trimming. */ + 'predicate-slot-blank': Readonly>; + /** A value that is not text — an expression envelope, or any other non-string. */ + 'predicate-slot-not-text': { readonly found: PredicateSlotValueKind }; +} + +/** Every refusal code {@link predicateSlotRefusal} emits. */ +export type PredicateSlotRefusalCode = keyof PredicateSlotRefusalParams; + +/** + * Why a predicate slot's value is not authorable: the English `message`, the + * `source` to attribute it to, and the same refusal as a `code` with its + * `params` — narrowing on `code` narrows `params`. + */ +export type PredicateSlotRefusal = { message: string; source: string } & { + [C in PredicateSlotRefusalCode]: { readonly code: C; readonly params: PredicateSlotRefusalParams[C] }; +}[PredicateSlotRefusalCode]; + +/** + * Keyed by code so the compiler holds {@link PREDICATE_SLOT_REFUSAL_CODES} + * equal to {@link PredicateSlotRefusalParams}. + */ +const PREDICATE_SLOT_REFUSAL_CODE_TABLE = { + 'predicate-slot-missing': true, + 'predicate-slot-blank': true, + 'predicate-slot-not-text': true, +} as const satisfies Record; + +/** + * The closed set of {@link predicateSlotRefusal}'s codes, as a value — for a + * consumer that must prove it has a catalogue row for every code. + */ +export const PREDICATE_SLOT_REFUSAL_CODES: readonly PredicateSlotRefusalCode[] = Object.freeze( + Object.keys(PREDICATE_SLOT_REFUSAL_CODE_TABLE) as PredicateSlotRefusalCode[], +); + +/** A non-string, non-nullish slot value's kind. */ +function predicateSlotValueKind(value: object | number | boolean | bigint | symbol): PredicateSlotValueKind { + if (isExpressionEnvelopeShaped(value)) return 'envelope'; + if (Array.isArray(value)) return 'array'; + return typeof value as PredicateSlotValueKind; +} + /** * Why a value sitting in a `predicate`-role slot is not authorable at all — * the SINGLE notion every door applies, derived once (#15572, #17493). @@ -516,9 +587,11 @@ export const PREDICATE_SLOT_STRING_REFUSAL = * the value is a non-blank string and therefore this function's business is * done. */ -export function predicateSlotRefusal(value: unknown): { message: string; source: string } | undefined { +export function predicateSlotRefusal(value: unknown): PredicateSlotRefusal | undefined { if (value === undefined || value === null) { return { + code: 'predicate-slot-missing', + params: { found: value === null ? 'null' : 'absent' }, message: `${PREDICATE_SLOT_STRING_REFUSAL} Found ${value === null ? '`null`' : 'nothing — the key is absent'} ` + 'where the slot is required: a decision branch is `{ label, expression }` and its `expression` is not ' @@ -534,6 +607,8 @@ export function predicateSlotRefusal(value: unknown): { message: string; source: if (typeof value === 'string') { if (NON_BLANK_STRING(value)) return undefined; return { + code: 'predicate-slot-blank', + params: {}, message: `${PREDICATE_SLOT_STRING_REFUSAL} Found a string that is blank after trimming, which states no rule. ` + 'Write the predicate the branch or field was meant to test (e.g. `record.rating >= 4`), or keep what ' @@ -543,20 +618,22 @@ export function predicateSlotRefusal(value: unknown): { message: string; source: source: value, }; } - const envelope = isExpressionEnvelopeShaped(value); - const found = envelope + // `value` is neither nullish (the first arm) nor a string (the second), so + // its kind is one of the non-text tokens; the phrase is rendered from it. + const kind = predicateSlotValueKind(value as object | number | boolean | bigint | symbol); + const found = kind === 'envelope' ? 'an expression envelope (an object naming a `dialect`)' - : Array.isArray(value) + : kind === 'array' ? 'an array' - : value === null - ? '`null`' - : typeof value === 'object' - ? 'an object' - : `a ${typeof value}`; + : kind === 'object' + ? 'an object' + : `a ${kind}`; // The envelope's own `source`, when it has one, so the finding still points // at the text the author wrote rather than at an empty string. const rawSource = (value as { source?: unknown } | null | undefined)?.source; return { + code: 'predicate-slot-not-text', + params: { found: kind }, message: `${PREDICATE_SLOT_STRING_REFUSAL} Found ${found}. Write the predicate as bare CEL text ` + '(e.g. `record.rating >= 4`); the `{ dialect, source }` envelope is the `value`-role spelling ' diff --git a/packages/spec/src/automation/predicate-slot-refusal-codes.test.ts b/packages/spec/src/automation/predicate-slot-refusal-codes.test.ts new file mode 100644 index 00000000000..61425f7e89b --- /dev/null +++ b/packages/spec/src/automation/predicate-slot-refusal-codes.test.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The refusal codes `predicateSlotRefusal` carries beside its English message. + * + * A localized designer keys its own catalogue row to the `code` and fills it + * from the `params`; API callers, `registerFlow` and `objectstack validate` + * keep reading the English `message`. Two kinds of pin: + * + * - **One test per code** — the code, its params exactly, and the message + * exactly. The message pins are the control for adding the code: it rides + * beside the sentence every door already shows, and must not change a byte + * of it. + * - **The closed set** — every code has a pin and nothing else does; every + * refusal a sweep of slot values provokes carries a code from the set; and + * at the type level a refusal without a code does not compile. + */ + +import { describe, expect, it } from 'vitest'; + +import { + PREDICATE_SLOT_REFUSAL_CODES, + PREDICATE_SLOT_STRING_REFUSAL, + predicateSlotRefusal, + type PredicateSlotRefusal, + type PredicateSlotRefusalCode, + type PredicateSlotRefusalParams, +} from './flow-node-expression-paths.js'; +import * as automation from './index.js'; + +interface Pin { + readonly value: unknown; + readonly params: PredicateSlotRefusalParams[C]; + readonly message: string; + readonly source: string; +} + +const MISSING_TAIL = + ' where the slot is required: a decision branch is `{ label, expression }` and its `expression` is not optional, ' + + 'so a branch without one states no rule. Write the predicate the branch was meant to test (e.g. ' + + '`record.rating >= 4`); a predicate written under another key — `condition` is the edge\'s spelling — belongs ' + + 'in `expression`. There is no run to keep: the executor evaluates every branch it reaches, and a branch with no ' + + '`expression` failed the run there. To keep the branch and its label but never take it, write ' + + '`expression: \'false\'`. Not by dropping a decision\'s only branch: the node then routes by its out-edges ' + + 'alone, and the out-edge that branch labelled is no longer held back.'; + +const BLANK_MESSAGE = + `${PREDICATE_SLOT_STRING_REFUSAL} Found a string that is blank after trimming, which states no rule. Write the ` + + 'predicate the branch or field was meant to test (e.g. `record.rating >= 4`), or keep what the blank did: on a ' + + 'screen field, drop the `visibleWhen` key; on a decision branch, write `expression: \'false\'`, the value the ' + + 'blank evaluated to. Not by dropping a decision\'s only branch: the node then routes by its out-edges alone, and ' + + 'the out-edge that branch labelled is no longer held back.'; + +const notText = (found: string): string => + `${PREDICATE_SLOT_STRING_REFUSAL} Found ${found}. Write the predicate as bare CEL text (e.g. ` + + '`record.rating >= 4`); the `{ dialect, source }` envelope is the `value`-role spelling (the `assignment` ' + + 'node\'s `assignments` map, a `create_record` / `update_record` node\'s `fields` map), and in a predicate slot ' + + 'it is read by the evaluator but by neither validator.'; + +/** + * Every code, its primary case first. The mapped type makes a code without a + * pin a type error in this file; the first closed-set test makes it a red run. + */ +const PINS: { readonly [C in PredicateSlotRefusalCode]: readonly [Pin, ...Pin[]] } = { + 'predicate-slot-missing': [ + { + value: undefined, + params: { found: 'absent' }, + message: `${PREDICATE_SLOT_STRING_REFUSAL} Found nothing — the key is absent${MISSING_TAIL}`, + source: '', + }, + { + value: null, + params: { found: 'null' }, + message: `${PREDICATE_SLOT_STRING_REFUSAL} Found \`null\`${MISSING_TAIL}`, + source: '', + }, + ], + 'predicate-slot-blank': [ + { value: '', params: {}, message: BLANK_MESSAGE, source: '' }, + { value: ' \t\n', params: {}, message: BLANK_MESSAGE, source: ' \t\n' }, + ], + 'predicate-slot-not-text': [ + { + value: { dialect: 'cel', source: 'record.x > 1' }, + params: { found: 'envelope' }, + message: notText('an expression envelope (an object naming a `dialect`)'), + source: 'record.x > 1', + }, + { + value: { dialect: 'cel' }, + params: { found: 'envelope' }, + message: notText('an expression envelope (an object naming a `dialect`)'), + source: '', + }, + { value: ['a'], params: { found: 'array' }, message: notText('an array'), source: '' }, + { value: { a: 1 }, params: { found: 'object' }, message: notText('an object'), source: '' }, + { value: 42, params: { found: 'number' }, message: notText('a number'), source: '' }, + { value: true, params: { found: 'boolean' }, message: notText('a boolean'), source: '' }, + { value: 10n, params: { found: 'bigint' }, message: notText('a bigint'), source: '' }, + { value: Symbol('s'), params: { found: 'symbol' }, message: notText('a symbol'), source: '' }, + { value: () => 1, params: { found: 'function' }, message: notText('a function'), source: '' }, + ], +}; + +describe('predicateSlotRefusal codes — one pin per code (code, params, unchanged message)', () => { + for (const code of PREDICATE_SLOT_REFUSAL_CODES) { + it(code, () => { + const cases = PINS[code] as readonly Pin[]; + for (const pin of cases) { + const refusal = predicateSlotRefusal(pin.value); + expect(refusal).toBeDefined(); + expect(refusal!.code).toBe(code); + expect(refusal!.params).toEqual(pin.params); + expect(refusal!.message).toBe(pin.message); + expect(refusal!.source).toBe(pin.source); + } + }); + } +}); + +/** Slot values of every shape — the sweep judges whatever each one provokes. */ +const SWEEP: readonly unknown[] = [ + undefined, + null, + '', + ' ', + '\t\n', + 'record.rating >= 4', + 'x', + { dialect: 'cel', source: 'x' }, + { dialect: 'cel' }, + { dialect: 'cel', source: 1 }, + { source: 'x' }, + { ast: { kind: 'const' } }, + {}, + [], + ['x'], + 0, + 42, + Number.NaN, + true, + false, + 10n, + Symbol('s'), + () => 1, + new Date(0), +]; + +describe('predicateSlotRefusal codes — the closed set', () => { + it('has a pin for every code, and no pin for a code outside the set', () => { + expect(Object.keys(PINS).sort()).toEqual([...PREDICATE_SLOT_REFUSAL_CODES].sort()); + expect(new Set(PREDICATE_SLOT_REFUSAL_CODES).size).toBe(PREDICATE_SLOT_REFUSAL_CODES.length); + for (const code of PREDICATE_SLOT_REFUSAL_CODES) expect(code).toMatch(/^[a-z]+(?:-[a-z]+)+$/); + }); + + it('is published from the automation entry, frozen', () => { + expect(automation.PREDICATE_SLOT_REFUSAL_CODES).toBe(PREDICATE_SLOT_REFUSAL_CODES); + expect(Object.isFrozen(PREDICATE_SLOT_REFUSAL_CODES)).toBe(true); + }); + + it('every refusal on the sweep carries a code from the set, and every code is reached', () => { + const provoked = new Set(); + for (const value of SWEEP) { + const refusal = predicateSlotRefusal(value); + if (refusal === undefined) { + // Only a non-blank string is admitted; its CEL is another validator's business. + expect(typeof value === 'string' && value.trim() !== '').toBe(true); + continue; + } + expect((PREDICATE_SLOT_REFUSAL_CODES as readonly string[]).includes(refusal.code)).toBe(true); + expect(typeof refusal.params).toBe('object'); + expect(refusal.message.startsWith(PREDICATE_SLOT_STRING_REFUSAL)).toBe(true); + provoked.add(refusal.code); + } + expect([...provoked].sort()).toEqual([...PREDICATE_SLOT_REFUSAL_CODES].sort()); + }); + + it('a refusal without a code does not type-check', () => { + // @ts-expect-error — `code` and `params` are required on every predicate slot refusal. + const noCode: PredicateSlotRefusal = { message: 'm', source: '' }; + // @ts-expect-error — a code's params are its own: `predicate-slot-blank` carries no `found`. + const wrongParams: PredicateSlotRefusal = { message: 'm', source: '', code: 'predicate-slot-blank', params: { found: 'null' } }; + expect([noCode, wrongParams]).toHaveLength(2); + }); +}); From 72b47b18874e9fe74672bacea8e0035884f33461 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:45:56 +0000 Subject: [PATCH 2/5] chore(spec): regenerate api-surface and export-origins; add the changeset for the refusal codes Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude --- .changeset/20291-expression-refusal-codes.md | 17 +++++++++++++++++ packages/spec/api-surface/automation.json | 5 +++++ packages/spec/export-origins/automation.json | 5 +++++ 3 files changed, 27 insertions(+) create mode 100644 .changeset/20291-expression-refusal-codes.md diff --git a/.changeset/20291-expression-refusal-codes.md b/.changeset/20291-expression-refusal-codes.md new file mode 100644 index 00000000000..fcf3d08dd5d --- /dev/null +++ b/.changeset/20291-expression-refusal-codes.md @@ -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) and `predicateSlotRefusal` (#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 `PREDICATE_SLOT_REFUSAL_CODES` and the types `PredicateSlotRefusalCode`, `PredicateSlotRefusalParams`, `PredicateSlotRefusal` and `PredicateSlotValueKind`. `predicateSlotRefusal` returns `PredicateSlotRefusal`, which 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`. diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 442963f4e48..7927d23acf7 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -193,6 +193,7 @@ "ORG_MEMBERSHIP_LEVELS (const)", "OS_CONSTRUCT_EXT (const)", "PARALLEL_NODE_TYPE (const)", + "PREDICATE_SLOT_REFUSAL_CODES (const)", "PREDICATE_SLOT_STRING_REFUSAL (const)", "ParallelBranch (type)", "ParallelBranchParsed (type)", @@ -200,6 +201,10 @@ "ParallelConfig (type)", "ParallelConfigParsed (type)", "ParallelConfigSchema (const)", + "PredicateSlotRefusal (type)", + "PredicateSlotRefusalCode (type)", + "PredicateSlotRefusalParams (interface)", + "PredicateSlotValueKind (type)", "ReconciledNodeConfigType (type)", "RegionAnalysis (interface)", "ResolvedFlowNodeExpression (interface)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 0f7d1d19263..64d45c7c1ad 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -188,6 +188,7 @@ "ORG_MEMBERSHIP_LEVELS": "src/automation/approval.zod.ts#ORG_MEMBERSHIP_LEVELS (const)", "OS_CONSTRUCT_EXT": "src/automation/bpmn-mapping.ts#OS_CONSTRUCT_EXT (const)", "PARALLEL_NODE_TYPE": "src/automation/control-flow.zod.ts#PARALLEL_NODE_TYPE (const)", + "PREDICATE_SLOT_REFUSAL_CODES": "src/automation/flow-node-expression-paths.ts#PREDICATE_SLOT_REFUSAL_CODES (const)", "PREDICATE_SLOT_STRING_REFUSAL": "src/automation/flow-node-expression-paths.ts#PREDICATE_SLOT_STRING_REFUSAL (const)", "ParallelBranch": "src/automation/control-flow.zod.ts#ParallelBranch (type)", "ParallelBranchParsed": "src/automation/control-flow.zod.ts#ParallelBranchParsed (type)", @@ -195,6 +196,10 @@ "ParallelConfig": "src/automation/control-flow.zod.ts#ParallelConfig (type)", "ParallelConfigParsed": "src/automation/control-flow.zod.ts#ParallelConfigParsed (type)", "ParallelConfigSchema": "src/automation/control-flow.zod.ts#ParallelConfigSchema (const)", + "PredicateSlotRefusal": "src/automation/flow-node-expression-paths.ts#PredicateSlotRefusal (type)", + "PredicateSlotRefusalCode": "src/automation/flow-node-expression-paths.ts#PredicateSlotRefusalCode (type)", + "PredicateSlotRefusalParams": "src/automation/flow-node-expression-paths.ts#PredicateSlotRefusalParams (interface)", + "PredicateSlotValueKind": "src/automation/flow-node-expression-paths.ts#PredicateSlotValueKind (type)", "ReconciledNodeConfigType": "src/automation/schemaless-node-config.zod.ts#ReconciledNodeConfigType (type)", "RegionAnalysis": "src/automation/control-flow.zod.ts#RegionAnalysis (interface)", "ResolvedFlowNodeExpression": "src/automation/flow-node-expression-paths.ts#ResolvedFlowNodeExpression (interface)", From 2a6dee312c9871ce460f2f86df9fae384207ca08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:15:54 +0000 Subject: [PATCH 3/5] refactor(formula,spec): name the refusal type parameters so the vocabulary gate does not read them as code constants `check:dispatcher-error-vocabulary`'s object-literal constant shape matches `code: K;` in a type literal, so a single-letter type parameter reads as an unresolvable SCREAMING code constant. `Code` / `Codes` say the same thing and stay out of that grammar. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude --- packages/formula/src/expression-refusal.ts | 8 ++++---- packages/formula/src/validate.ts | 6 +++--- .../spec/src/automation/flow-node-expression-paths.ts | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/formula/src/expression-refusal.ts b/packages/formula/src/expression-refusal.ts index a4b77d93f1d..5b22eb6880e 100644 --- a/packages/formula/src/expression-refusal.ts +++ b/packages/formula/src/expression-refusal.ts @@ -139,11 +139,11 @@ export type ExpressionRefusalCode = keyof ExpressionRefusalParams; /** * One refusal's `code` and `params`, correlated: narrowing on `code` narrows - * `params`. `C` restricts it to the codes one producer emits. + * `params`. `Codes` restricts it to the codes one producer emits. */ -export type ExpressionRefusal = { - [K in C]: { readonly code: K; readonly params: ExpressionRefusalParams[K] }; -}[C]; +export type ExpressionRefusal = { + [Code in Codes]: { readonly code: Code; readonly params: ExpressionRefusalParams[Code] }; +}[Codes]; /** The codes `collectCelRootIdentifiers` emits. */ export type CelRootsRefusalCode = 'empty-expression' | 'cel-parse-failed'; diff --git a/packages/formula/src/validate.ts b/packages/formula/src/validate.ts index 1858fc50640..2e753a6e8a1 100644 --- a/packages/formula/src/validate.ts +++ b/packages/formula/src/validate.ts @@ -192,9 +192,9 @@ export type ExprValidationError = { * between `code` and `params` is asserted; every call site is checked against * {@link ExpressionRefusalParams} for its own code. */ -function refusal( - code: C, - params: ExpressionRefusalParams[C], +function refusal( + code: Code, + params: ExpressionRefusalParams[Code], source: string, message: string, ): ExprValidationError { diff --git a/packages/spec/src/automation/flow-node-expression-paths.ts b/packages/spec/src/automation/flow-node-expression-paths.ts index 23abb2c4cef..7dc4f91dd10 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.ts @@ -494,7 +494,7 @@ export type PredicateSlotRefusalCode = keyof PredicateSlotRefusalParams; * `params` — narrowing on `code` narrows `params`. */ export type PredicateSlotRefusal = { message: string; source: string } & { - [C in PredicateSlotRefusalCode]: { readonly code: C; readonly params: PredicateSlotRefusalParams[C] }; + [Code in PredicateSlotRefusalCode]: { readonly code: Code; readonly params: PredicateSlotRefusalParams[Code] }; }[PredicateSlotRefusalCode]; /** From 658a8637f5aacaa6886f696a632d808c5e232d94 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:41:54 +0000 Subject: [PATCH 4/5] feat(spec): carry a refusal code and params on structuralConditionRefusal too The file's second refusal producer gets the shape predicateSlotRefusal has: `code` ('structural-condition-shape') and `params` ({ found }) beside the unchanged English message. The spec's code set is now one closed table for both producers (FlowSlotRefusalParams / FLOW_SLOT_REFUSAL_CODES), with per-producer code types, the way the formula set is laid out. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude --- .../automation/flow-node-expression-paths.ts | 98 ++++-- .../flow-slot-refusal-codes.test.ts | 289 ++++++++++++++++++ .../predicate-slot-refusal-codes.test.ts | 186 ----------- 3 files changed, 362 insertions(+), 211 deletions(-) create mode 100644 packages/spec/src/automation/flow-slot-refusal-codes.test.ts delete mode 100644 packages/spec/src/automation/predicate-slot-refusal-codes.test.ts diff --git a/packages/spec/src/automation/flow-node-expression-paths.ts b/packages/spec/src/automation/flow-node-expression-paths.ts index 7dc4f91dd10..c9369640daf 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.ts @@ -466,8 +466,26 @@ export type PredicateSlotValueKind = | 'function'; /** - * Refusal code → the params its message interpolates, for - * {@link predicateSlotRefusal}. The keys ARE the closed set. + * What a value in a structural condition slot was, when it is neither text nor + * an envelope carrying a string `source`, as a token. The message renders it as + * a phrase: `ast-without-source` is an object carrying an `ast` but no string + * `source`, `object-without-source` any other object carrying no string + * `source`, and the rest read `an array`, `a number`, and so on. + */ +export type StructuralConditionValueKind = + | 'array' + | 'ast-without-source' + | 'object-without-source' + | 'number' + | 'boolean' + | 'bigint' + | 'symbol' + | 'function'; + +/** + * Refusal code → the params its message interpolates, for this file's two + * refusal producers, {@link predicateSlotRefusal} and + * {@link structuralConditionRefusal}. The keys ARE the closed set. * * A consumer that renders its own words — a localized designer — keys its * catalogue row to the `code` and fills it from the `params`; the English @@ -476,43 +494,62 @@ export type PredicateSlotValueKind = * code. ⛔ Not an ADR-0112 error code — an authoring diagnostic returned as a * value, never a failing request's `error.code`. */ -export interface PredicateSlotRefusalParams { - /** No value where the slot is required: the key is absent, or holds `null`. */ +export interface FlowSlotRefusalParams { + /** No value where a predicate slot is required: the key is absent, or holds `null`. */ 'predicate-slot-missing': { readonly found: 'absent' | 'null' }; - /** A string that is blank after trimming. */ + /** A predicate slot holding a string that is blank after trimming. */ 'predicate-slot-blank': Readonly>; - /** A value that is not text — an expression envelope, or any other non-string. */ + /** A predicate slot holding a value that is not text — an expression envelope, or any other non-string. */ 'predicate-slot-not-text': { readonly found: PredicateSlotValueKind }; + /** A structural condition holding neither text nor an envelope carrying a string `source`. */ + 'structural-condition-shape': { readonly found: StructuralConditionValueKind }; } -/** Every refusal code {@link predicateSlotRefusal} emits. */ -export type PredicateSlotRefusalCode = keyof PredicateSlotRefusalParams; +/** Every refusal code this file's producers emit. */ +export type FlowSlotRefusalCode = keyof FlowSlotRefusalParams; + +/** The codes {@link predicateSlotRefusal} emits. */ +export type PredicateSlotRefusalCode = 'predicate-slot-missing' | 'predicate-slot-blank' | 'predicate-slot-not-text'; + +/** The codes {@link structuralConditionRefusal} emits. */ +export type StructuralConditionRefusalCode = 'structural-condition-shape'; + +/** One refusal's `code` and `params`, correlated: narrowing on `code` narrows `params`. */ +type FlowSlotRefusalOf = { message: string; source: string } & { + [Code in Codes]: { readonly code: Code; readonly params: FlowSlotRefusalParams[Code] }; +}[Codes]; /** * Why a predicate slot's value is not authorable: the English `message`, the * `source` to attribute it to, and the same refusal as a `code` with its * `params` — narrowing on `code` narrows `params`. */ -export type PredicateSlotRefusal = { message: string; source: string } & { - [Code in PredicateSlotRefusalCode]: { readonly code: Code; readonly params: PredicateSlotRefusalParams[Code] }; -}[PredicateSlotRefusalCode]; +export type PredicateSlotRefusal = FlowSlotRefusalOf; + +/** + * Why a structural condition's value is not authorable: the English `message`, + * the `source` to attribute it to, and the same refusal as a `code` with its + * `params`. + */ +export type StructuralConditionRefusal = FlowSlotRefusalOf; /** - * Keyed by code so the compiler holds {@link PREDICATE_SLOT_REFUSAL_CODES} - * equal to {@link PredicateSlotRefusalParams}. + * Keyed by code so the compiler holds {@link FLOW_SLOT_REFUSAL_CODES} equal to + * {@link FlowSlotRefusalParams}. */ -const PREDICATE_SLOT_REFUSAL_CODE_TABLE = { +const FLOW_SLOT_REFUSAL_CODE_TABLE = { 'predicate-slot-missing': true, 'predicate-slot-blank': true, 'predicate-slot-not-text': true, -} as const satisfies Record; + 'structural-condition-shape': true, +} as const satisfies Record; /** - * The closed set of {@link predicateSlotRefusal}'s codes, as a value — for a - * consumer that must prove it has a catalogue row for every code. + * The closed set of this file's refusal codes, as a value — for a consumer + * that must prove it has a catalogue row for every code. */ -export const PREDICATE_SLOT_REFUSAL_CODES: readonly PredicateSlotRefusalCode[] = Object.freeze( - Object.keys(PREDICATE_SLOT_REFUSAL_CODE_TABLE) as PredicateSlotRefusalCode[], +export const FLOW_SLOT_REFUSAL_CODES: readonly FlowSlotRefusalCode[] = Object.freeze( + Object.keys(FLOW_SLOT_REFUSAL_CODE_TABLE) as FlowSlotRefusalCode[], ); /** A non-string, non-nullish slot value's kind. */ @@ -789,26 +826,37 @@ export const STRUCTURAL_CONDITION_SHAPE_REFUSAL = */ export function structuralConditionRefusal( value: unknown, -): { message: string; source: string } | undefined { +): StructuralConditionRefusal | undefined { if (value == null) return undefined; if (typeof value === 'string') return undefined; if (typeof value === 'object' && !Array.isArray(value)) { const rec = value as { source?: unknown }; if (typeof rec.source === 'string') return undefined; } - const found = Array.isArray(value) - ? 'an array' + // The kind is decided first and the phrase rendered from it, so the code's + // params and the message cannot name two different things. + const kind: StructuralConditionValueKind = Array.isArray(value) + ? 'array' : typeof value === 'object' ? (value as { ast?: unknown }).ast !== undefined - ? 'an object carrying an `ast` but no string `source` — the engine evaluates `source`, never `ast`' - : 'an object carrying no string `source`' - : `a ${typeof value}`; + ? 'ast-without-source' + : 'object-without-source' + : (typeof value as StructuralConditionValueKind); + const found = kind === 'array' + ? 'an array' + : kind === 'ast-without-source' + ? 'an object carrying an `ast` but no string `source` — the engine evaluates `source`, never `ast`' + : kind === 'object-without-source' + ? 'an object carrying no string `source`' + : `a ${kind}`; // The envelope's own `source`, when it has one, so the finding still points at // the text the author wrote rather than at an empty string. A non-string // `source` (the `{ source: 1 }` case) is exactly what is being refused, so it // cannot be the attribution. const rawSource = (value as { source?: unknown }).source; return { + code: 'structural-condition-shape', + params: { found: kind }, message: `${STRUCTURAL_CONDITION_SHAPE_REFUSAL} Found ${found}. Write the condition as bare CEL text ` + '(e.g. `record.rating >= 4`), or as an expression envelope (`{ dialect: \'cel\', source: \'…\' }`). ' diff --git a/packages/spec/src/automation/flow-slot-refusal-codes.test.ts b/packages/spec/src/automation/flow-slot-refusal-codes.test.ts new file mode 100644 index 00000000000..84a1e359527 --- /dev/null +++ b/packages/spec/src/automation/flow-slot-refusal-codes.test.ts @@ -0,0 +1,289 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The refusal codes this file's two refusal producers — `predicateSlotRefusal` + * and `structuralConditionRefusal` — carry beside their English message. + * + * A localized designer keys its own catalogue row to the `code` and fills it + * from the `params`; API callers, `registerFlow` and `objectstack validate` + * keep reading the English `message`. Two kinds of pin: + * + * - **One test per code** — the code, its params exactly, and the message + * exactly. The message pins are the control for adding the code: it rides + * beside the sentence every door already shows, and must not change a byte + * of it. + * - **The closed set** — every code has a pin and nothing else does; every + * refusal a sweep of slot values provokes, from either producer, carries a + * code from that producer's share of the set; and at the type level a + * refusal without a code does not compile. + */ + +import { describe, expect, it } from 'vitest'; + +import { + FLOW_SLOT_REFUSAL_CODES, + PREDICATE_SLOT_STRING_REFUSAL, + STRUCTURAL_CONDITION_SHAPE_REFUSAL, + predicateSlotRefusal, + structuralConditionRefusal, + type FlowSlotRefusalCode, + type FlowSlotRefusalParams, + type PredicateSlotRefusal, + type PredicateSlotRefusalCode, + type StructuralConditionRefusal, + type StructuralConditionRefusalCode, +} from './flow-node-expression-paths.js'; +import * as automation from './index.js'; + +/** What one refusal says, whichever producer said it. */ +interface Said { + readonly code: string; + readonly params: unknown; + readonly message: string; + readonly source: string; +} + +interface Pin { + readonly produce: () => Said | undefined; + readonly params: FlowSlotRefusalParams[C]; + readonly message: string; + readonly source: string; +} + +const slot = (value: unknown) => (): Said | undefined => predicateSlotRefusal(value); +const structural = (value: unknown) => (): Said | undefined => structuralConditionRefusal(value); + +const MISSING_TAIL = + ' where the slot is required: a decision branch is `{ label, expression }` and its `expression` is not optional, ' + + 'so a branch without one states no rule. Write the predicate the branch was meant to test (e.g. ' + + '`record.rating >= 4`); a predicate written under another key — `condition` is the edge\'s spelling — belongs ' + + 'in `expression`. There is no run to keep: the executor evaluates every branch it reaches, and a branch with no ' + + '`expression` failed the run there. To keep the branch and its label but never take it, write ' + + '`expression: \'false\'`. Not by dropping a decision\'s only branch: the node then routes by its out-edges ' + + 'alone, and the out-edge that branch labelled is no longer held back.'; + +const BLANK_MESSAGE = + `${PREDICATE_SLOT_STRING_REFUSAL} Found a string that is blank after trimming, which states no rule. Write the ` + + 'predicate the branch or field was meant to test (e.g. `record.rating >= 4`), or keep what the blank did: on a ' + + 'screen field, drop the `visibleWhen` key; on a decision branch, write `expression: \'false\'`, the value the ' + + 'blank evaluated to. Not by dropping a decision\'s only branch: the node then routes by its out-edges alone, and ' + + 'the out-edge that branch labelled is no longer held back.'; + +const notText = (found: string): string => + `${PREDICATE_SLOT_STRING_REFUSAL} Found ${found}. Write the predicate as bare CEL text (e.g. ` + + '`record.rating >= 4`); the `{ dialect, source }` envelope is the `value`-role spelling (the `assignment` ' + + 'node\'s `assignments` map, a `create_record` / `update_record` node\'s `fields` map), and in a predicate slot ' + + 'it is read by the evaluator but by neither validator.'; + +/** The structural refusal, spelled out in full: its lead sentence is part of what is pinned. */ +const STRUCTURAL_LEAD = + 'A structural condition (`config.condition` on a node, `edge.condition`) holds either BARE CEL TEXT or an ' + + 'expression envelope carrying a string `source`. No other shape is authorable there: the engine evaluates ' + + '`source`, so an envelope carrying only an `ast` is not evaluable.'; + +const shape = (found: string): string => + `${STRUCTURAL_LEAD} Found ${found}. Write the condition as bare CEL text (e.g. \`record.rating >= 4\`), or as an ` + + 'expression envelope (`{ dialect: \'cel\', source: \'…\' }`). A value that is neither is read by the evaluator ' + + 'as an EMPTY condition, which answers `false` without saying anything — and on a start node that is the ' + + 'trigger gate.'; + +/** + * Every code, its primary case first. The mapped type makes a code without a + * pin a type error in this file; the first closed-set test makes it a red run. + */ +const PINS: { readonly [C in FlowSlotRefusalCode]: readonly [Pin, ...Pin[]] } = { + 'predicate-slot-missing': [ + { + produce: slot(undefined), + params: { found: 'absent' }, + message: `${PREDICATE_SLOT_STRING_REFUSAL} Found nothing — the key is absent${MISSING_TAIL}`, + source: '', + }, + { + produce: slot(null), + params: { found: 'null' }, + message: `${PREDICATE_SLOT_STRING_REFUSAL} Found \`null\`${MISSING_TAIL}`, + source: '', + }, + ], + 'predicate-slot-blank': [ + { produce: slot(''), params: {}, message: BLANK_MESSAGE, source: '' }, + { produce: slot(' \t\n'), params: {}, message: BLANK_MESSAGE, source: ' \t\n' }, + ], + 'predicate-slot-not-text': [ + { + produce: slot({ dialect: 'cel', source: 'record.x > 1' }), + params: { found: 'envelope' }, + message: notText('an expression envelope (an object naming a `dialect`)'), + source: 'record.x > 1', + }, + { + produce: slot({ dialect: 'cel' }), + params: { found: 'envelope' }, + message: notText('an expression envelope (an object naming a `dialect`)'), + source: '', + }, + { produce: slot(['a']), params: { found: 'array' }, message: notText('an array'), source: '' }, + { produce: slot({ a: 1 }), params: { found: 'object' }, message: notText('an object'), source: '' }, + { produce: slot(42), params: { found: 'number' }, message: notText('a number'), source: '' }, + { produce: slot(true), params: { found: 'boolean' }, message: notText('a boolean'), source: '' }, + { produce: slot(10n), params: { found: 'bigint' }, message: notText('a bigint'), source: '' }, + { produce: slot(Symbol('s')), params: { found: 'symbol' }, message: notText('a symbol'), source: '' }, + { produce: slot(() => 1), params: { found: 'function' }, message: notText('a function'), source: '' }, + ], + 'structural-condition-shape': [ + { + produce: structural({ dialect: 'cel', ast: { kind: 'const', value: true } }), + params: { found: 'ast-without-source' }, + message: shape('an object carrying an `ast` but no string `source` — the engine evaluates `source`, never `ast`'), + source: '', + }, + { + produce: structural({ ast: { kind: 'const' }, source: 2 }), + params: { found: 'ast-without-source' }, + message: shape('an object carrying an `ast` but no string `source` — the engine evaluates `source`, never `ast`'), + source: '', + }, + { + produce: structural({ dialect: 'cel', source: 1 }), + params: { found: 'object-without-source' }, + message: shape('an object carrying no string `source`'), + source: '', + }, + { produce: structural({}), params: { found: 'object-without-source' }, message: shape('an object carrying no string `source`'), source: '' }, + { produce: structural(['a']), params: { found: 'array' }, message: shape('an array'), source: '' }, + { produce: structural(42), params: { found: 'number' }, message: shape('a number'), source: '' }, + { produce: structural(false), params: { found: 'boolean' }, message: shape('a boolean'), source: '' }, + { produce: structural(10n), params: { found: 'bigint' }, message: shape('a bigint'), source: '' }, + { produce: structural(Symbol('s')), params: { found: 'symbol' }, message: shape('a symbol'), source: '' }, + { produce: structural(() => 1), params: { found: 'function' }, message: shape('a function'), source: '' }, + ], +}; + +describe('flow slot refusal codes — one pin per code (code, params, unchanged message)', () => { + for (const code of FLOW_SLOT_REFUSAL_CODES) { + it(code, () => { + const cases = PINS[code] as readonly Pin[]; + for (const pin of cases) { + const refusal = pin.produce(); + expect(refusal).toBeDefined(); + expect(refusal!.code).toBe(code); + expect(refusal!.params).toEqual(pin.params); + expect(refusal!.message).toBe(pin.message); + expect(refusal!.source).toBe(pin.source); + } + }); + } + + it('the structural lead sentence is the published constant, byte for byte', () => { + expect(STRUCTURAL_CONDITION_SHAPE_REFUSAL).toBe(STRUCTURAL_LEAD); + }); +}); + +/** Slot values of every shape — the sweep judges whatever each one provokes. */ +const SWEEP: readonly unknown[] = [ + undefined, + null, + '', + ' ', + '\t\n', + 'record.rating >= 4', + 'x', + { dialect: 'cel', source: 'x' }, + { dialect: 'cel' }, + { dialect: 'cel', source: 1 }, + { source: 'x' }, + { ast: { kind: 'const' } }, + { dialect: 'cel', ast: { kind: 'const' }, source: 'x' }, + {}, + [], + ['x'], + 0, + 42, + Number.NaN, + true, + false, + 10n, + Symbol('s'), + () => 1, + new Date(0), +]; + +const PREDICATE_SLOT_CODES: ReadonlySet = new Set([ + 'predicate-slot-missing', + 'predicate-slot-blank', + 'predicate-slot-not-text', +]); +const STRUCTURAL_CODES: ReadonlySet = new Set([ + 'structural-condition-shape', +]); + +describe('flow slot refusal codes — the closed set', () => { + it('has a pin for every code, and no pin for a code outside the set', () => { + expect(Object.keys(PINS).sort()).toEqual([...FLOW_SLOT_REFUSAL_CODES].sort()); + expect(new Set(FLOW_SLOT_REFUSAL_CODES).size).toBe(FLOW_SLOT_REFUSAL_CODES.length); + for (const code of FLOW_SLOT_REFUSAL_CODES) expect(code).toMatch(/^[a-z]+(?:-[a-z]+)+$/); + }); + + it('splits between the two producers with nothing left over', () => { + expect([...PREDICATE_SLOT_CODES, ...STRUCTURAL_CODES].sort()).toEqual([...FLOW_SLOT_REFUSAL_CODES].sort()); + }); + + it('is published from the automation entry, frozen', () => { + expect(automation.FLOW_SLOT_REFUSAL_CODES).toBe(FLOW_SLOT_REFUSAL_CODES); + expect(Object.isFrozen(FLOW_SLOT_REFUSAL_CODES)).toBe(true); + }); + + it('every predicateSlotRefusal on the sweep carries a predicate-slot code, and every one is reached', () => { + const provoked = new Set(); + for (const value of SWEEP) { + const refusal = predicateSlotRefusal(value); + if (refusal === undefined) { + // Only a non-blank string is admitted; its CEL is another validator's business. + expect(typeof value === 'string' && value.trim() !== '').toBe(true); + continue; + } + expect(PREDICATE_SLOT_CODES.has(refusal.code as PredicateSlotRefusalCode)).toBe(true); + expect(typeof refusal.params).toBe('object'); + expect(refusal.message.startsWith(PREDICATE_SLOT_STRING_REFUSAL)).toBe(true); + provoked.add(refusal.code); + } + expect([...provoked].sort()).toEqual([...PREDICATE_SLOT_CODES].sort()); + }); + + it('every structuralConditionRefusal on the sweep carries a structural code, and every one is reached', () => { + const provoked = new Set(); + let refused = 0; + for (const value of SWEEP) { + const refusal = structuralConditionRefusal(value); + if (refusal === undefined) { + // Admitted: absent, any string, or an object carrying a string `source`. + const admitted = + value == null + || typeof value === 'string' + || (typeof value === 'object' && !Array.isArray(value) && typeof (value as { source?: unknown }).source === 'string'); + expect(admitted).toBe(true); + continue; + } + refused++; + expect(STRUCTURAL_CODES.has(refusal.code as StructuralConditionRefusalCode)).toBe(true); + expect(typeof refusal.params).toBe('object'); + expect(refusal.message.startsWith(STRUCTURAL_CONDITION_SHAPE_REFUSAL)).toBe(true); + provoked.add(refusal.code); + } + expect(refused).toBeGreaterThan(10); + expect([...provoked].sort()).toEqual([...STRUCTURAL_CODES].sort()); + }); + + it('a refusal without a code does not type-check, on either producer', () => { + // @ts-expect-error — `code` and `params` are required on every predicate slot refusal. + const noCode: PredicateSlotRefusal = { message: 'm', source: '' }; + // @ts-expect-error — a code's params are its own: `predicate-slot-blank` carries no `found`. + const wrongParams: PredicateSlotRefusal = { message: 'm', source: '', code: 'predicate-slot-blank', params: { found: 'null' } }; + // @ts-expect-error — `code` and `params` are required on every structural condition refusal. + const noStructuralCode: StructuralConditionRefusal = { message: 'm', source: '' }; + // @ts-expect-error — each producer emits only its own codes: a structural refusal is never a predicate-slot code. + const foreignCode: StructuralConditionRefusal = { message: 'm', source: '', code: 'predicate-slot-blank', params: {} }; + expect([noCode, wrongParams, noStructuralCode, foreignCode]).toHaveLength(4); + }); +}); diff --git a/packages/spec/src/automation/predicate-slot-refusal-codes.test.ts b/packages/spec/src/automation/predicate-slot-refusal-codes.test.ts deleted file mode 100644 index 61425f7e89b..00000000000 --- a/packages/spec/src/automation/predicate-slot-refusal-codes.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * The refusal codes `predicateSlotRefusal` carries beside its English message. - * - * A localized designer keys its own catalogue row to the `code` and fills it - * from the `params`; API callers, `registerFlow` and `objectstack validate` - * keep reading the English `message`. Two kinds of pin: - * - * - **One test per code** — the code, its params exactly, and the message - * exactly. The message pins are the control for adding the code: it rides - * beside the sentence every door already shows, and must not change a byte - * of it. - * - **The closed set** — every code has a pin and nothing else does; every - * refusal a sweep of slot values provokes carries a code from the set; and - * at the type level a refusal without a code does not compile. - */ - -import { describe, expect, it } from 'vitest'; - -import { - PREDICATE_SLOT_REFUSAL_CODES, - PREDICATE_SLOT_STRING_REFUSAL, - predicateSlotRefusal, - type PredicateSlotRefusal, - type PredicateSlotRefusalCode, - type PredicateSlotRefusalParams, -} from './flow-node-expression-paths.js'; -import * as automation from './index.js'; - -interface Pin { - readonly value: unknown; - readonly params: PredicateSlotRefusalParams[C]; - readonly message: string; - readonly source: string; -} - -const MISSING_TAIL = - ' where the slot is required: a decision branch is `{ label, expression }` and its `expression` is not optional, ' - + 'so a branch without one states no rule. Write the predicate the branch was meant to test (e.g. ' - + '`record.rating >= 4`); a predicate written under another key — `condition` is the edge\'s spelling — belongs ' - + 'in `expression`. There is no run to keep: the executor evaluates every branch it reaches, and a branch with no ' - + '`expression` failed the run there. To keep the branch and its label but never take it, write ' - + '`expression: \'false\'`. Not by dropping a decision\'s only branch: the node then routes by its out-edges ' - + 'alone, and the out-edge that branch labelled is no longer held back.'; - -const BLANK_MESSAGE = - `${PREDICATE_SLOT_STRING_REFUSAL} Found a string that is blank after trimming, which states no rule. Write the ` - + 'predicate the branch or field was meant to test (e.g. `record.rating >= 4`), or keep what the blank did: on a ' - + 'screen field, drop the `visibleWhen` key; on a decision branch, write `expression: \'false\'`, the value the ' - + 'blank evaluated to. Not by dropping a decision\'s only branch: the node then routes by its out-edges alone, and ' - + 'the out-edge that branch labelled is no longer held back.'; - -const notText = (found: string): string => - `${PREDICATE_SLOT_STRING_REFUSAL} Found ${found}. Write the predicate as bare CEL text (e.g. ` - + '`record.rating >= 4`); the `{ dialect, source }` envelope is the `value`-role spelling (the `assignment` ' - + 'node\'s `assignments` map, a `create_record` / `update_record` node\'s `fields` map), and in a predicate slot ' - + 'it is read by the evaluator but by neither validator.'; - -/** - * Every code, its primary case first. The mapped type makes a code without a - * pin a type error in this file; the first closed-set test makes it a red run. - */ -const PINS: { readonly [C in PredicateSlotRefusalCode]: readonly [Pin, ...Pin[]] } = { - 'predicate-slot-missing': [ - { - value: undefined, - params: { found: 'absent' }, - message: `${PREDICATE_SLOT_STRING_REFUSAL} Found nothing — the key is absent${MISSING_TAIL}`, - source: '', - }, - { - value: null, - params: { found: 'null' }, - message: `${PREDICATE_SLOT_STRING_REFUSAL} Found \`null\`${MISSING_TAIL}`, - source: '', - }, - ], - 'predicate-slot-blank': [ - { value: '', params: {}, message: BLANK_MESSAGE, source: '' }, - { value: ' \t\n', params: {}, message: BLANK_MESSAGE, source: ' \t\n' }, - ], - 'predicate-slot-not-text': [ - { - value: { dialect: 'cel', source: 'record.x > 1' }, - params: { found: 'envelope' }, - message: notText('an expression envelope (an object naming a `dialect`)'), - source: 'record.x > 1', - }, - { - value: { dialect: 'cel' }, - params: { found: 'envelope' }, - message: notText('an expression envelope (an object naming a `dialect`)'), - source: '', - }, - { value: ['a'], params: { found: 'array' }, message: notText('an array'), source: '' }, - { value: { a: 1 }, params: { found: 'object' }, message: notText('an object'), source: '' }, - { value: 42, params: { found: 'number' }, message: notText('a number'), source: '' }, - { value: true, params: { found: 'boolean' }, message: notText('a boolean'), source: '' }, - { value: 10n, params: { found: 'bigint' }, message: notText('a bigint'), source: '' }, - { value: Symbol('s'), params: { found: 'symbol' }, message: notText('a symbol'), source: '' }, - { value: () => 1, params: { found: 'function' }, message: notText('a function'), source: '' }, - ], -}; - -describe('predicateSlotRefusal codes — one pin per code (code, params, unchanged message)', () => { - for (const code of PREDICATE_SLOT_REFUSAL_CODES) { - it(code, () => { - const cases = PINS[code] as readonly Pin[]; - for (const pin of cases) { - const refusal = predicateSlotRefusal(pin.value); - expect(refusal).toBeDefined(); - expect(refusal!.code).toBe(code); - expect(refusal!.params).toEqual(pin.params); - expect(refusal!.message).toBe(pin.message); - expect(refusal!.source).toBe(pin.source); - } - }); - } -}); - -/** Slot values of every shape — the sweep judges whatever each one provokes. */ -const SWEEP: readonly unknown[] = [ - undefined, - null, - '', - ' ', - '\t\n', - 'record.rating >= 4', - 'x', - { dialect: 'cel', source: 'x' }, - { dialect: 'cel' }, - { dialect: 'cel', source: 1 }, - { source: 'x' }, - { ast: { kind: 'const' } }, - {}, - [], - ['x'], - 0, - 42, - Number.NaN, - true, - false, - 10n, - Symbol('s'), - () => 1, - new Date(0), -]; - -describe('predicateSlotRefusal codes — the closed set', () => { - it('has a pin for every code, and no pin for a code outside the set', () => { - expect(Object.keys(PINS).sort()).toEqual([...PREDICATE_SLOT_REFUSAL_CODES].sort()); - expect(new Set(PREDICATE_SLOT_REFUSAL_CODES).size).toBe(PREDICATE_SLOT_REFUSAL_CODES.length); - for (const code of PREDICATE_SLOT_REFUSAL_CODES) expect(code).toMatch(/^[a-z]+(?:-[a-z]+)+$/); - }); - - it('is published from the automation entry, frozen', () => { - expect(automation.PREDICATE_SLOT_REFUSAL_CODES).toBe(PREDICATE_SLOT_REFUSAL_CODES); - expect(Object.isFrozen(PREDICATE_SLOT_REFUSAL_CODES)).toBe(true); - }); - - it('every refusal on the sweep carries a code from the set, and every code is reached', () => { - const provoked = new Set(); - for (const value of SWEEP) { - const refusal = predicateSlotRefusal(value); - if (refusal === undefined) { - // Only a non-blank string is admitted; its CEL is another validator's business. - expect(typeof value === 'string' && value.trim() !== '').toBe(true); - continue; - } - expect((PREDICATE_SLOT_REFUSAL_CODES as readonly string[]).includes(refusal.code)).toBe(true); - expect(typeof refusal.params).toBe('object'); - expect(refusal.message.startsWith(PREDICATE_SLOT_STRING_REFUSAL)).toBe(true); - provoked.add(refusal.code); - } - expect([...provoked].sort()).toEqual([...PREDICATE_SLOT_REFUSAL_CODES].sort()); - }); - - it('a refusal without a code does not type-check', () => { - // @ts-expect-error — `code` and `params` are required on every predicate slot refusal. - const noCode: PredicateSlotRefusal = { message: 'm', source: '' }; - // @ts-expect-error — a code's params are its own: `predicate-slot-blank` carries no `found`. - const wrongParams: PredicateSlotRefusal = { message: 'm', source: '', code: 'predicate-slot-blank', params: { found: 'null' } }; - expect([noCode, wrongParams]).toHaveLength(2); - }); -}); From 5581ed26ee2e338a563eabf3b23f1ec1137f82be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:03:58 +0000 Subject: [PATCH 5/5] chore(spec): regenerate api-surface and export-origins for the flow-slot refusal set; update the changeset Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude --- .changeset/20291-expression-refusal-codes.md | 4 ++-- packages/spec/api-surface/automation.json | 8 ++++++-- packages/spec/export-origins/automation.json | 8 ++++++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.changeset/20291-expression-refusal-codes.md b/.changeset/20291-expression-refusal-codes.md index fcf3d08dd5d..688ba14f18f 100644 --- a/.changeset/20291-expression-refusal-codes.md +++ b/.changeset/20291-expression-refusal-codes.md @@ -3,14 +3,14 @@ '@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) and `predicateSlotRefusal` (#20291) +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 `PREDICATE_SLOT_REFUSAL_CODES` and the types `PredicateSlotRefusalCode`, `PredicateSlotRefusalParams`, `PredicateSlotRefusal` and `PredicateSlotValueKind`. `predicateSlotRefusal` returns `PredicateSlotRefusal`, which is its previous `{ message, source }` plus `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. diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 7927d23acf7..ba1ca8ba349 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -115,6 +115,7 @@ "FLOW_REGION_CONFIG_KEYS (const)", "FLOW_REGION_SLOTS (const)", "FLOW_REGION_SLOTS_BY_TYPE (const)", + "FLOW_SLOT_REFUSAL_CODES (const)", "FLOW_STRUCTURAL_NODE_TYPES (const)", "FLOW_TRIGGER_KINDS (const)", "Flow (type)", @@ -155,6 +156,8 @@ "FlowRunSummaryParsed (type)", "FlowRunSummarySchema (const)", "FlowSchema (const)", + "FlowSlotRefusalCode (type)", + "FlowSlotRefusalParams (interface)", "FlowTriggerKind (type)", "FlowValueSlot (type)", "FlowValueSlotParsed (type)", @@ -193,7 +196,6 @@ "ORG_MEMBERSHIP_LEVELS (const)", "OS_CONSTRUCT_EXT (const)", "PARALLEL_NODE_TYPE (const)", - "PREDICATE_SLOT_REFUSAL_CODES (const)", "PREDICATE_SLOT_STRING_REFUSAL (const)", "ParallelBranch (type)", "ParallelBranchParsed (type)", @@ -203,7 +205,6 @@ "ParallelConfigSchema (const)", "PredicateSlotRefusal (type)", "PredicateSlotRefusalCode (type)", - "PredicateSlotRefusalParams (interface)", "PredicateSlotValueKind (type)", "ReconciledNodeConfigType (type)", "RegionAnalysis (interface)", @@ -231,6 +232,9 @@ "StateNode (type)", "StateNodeConfig (type)", "StateNodeSchema (const)", + "StructuralConditionRefusal (type)", + "StructuralConditionRefusalCode (type)", + "StructuralConditionValueKind (type)", "SubflowConfig (type)", "SubflowConfigParsed (type)", "SubflowConfigSchema (const)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 64d45c7c1ad..78087ab9fae 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -111,6 +111,7 @@ "FLOW_REGION_CONFIG_KEYS": "src/automation/region-slots.ts#FLOW_REGION_CONFIG_KEYS (const)", "FLOW_REGION_SLOTS": "src/automation/region-slots.ts#FLOW_REGION_SLOTS (const)", "FLOW_REGION_SLOTS_BY_TYPE": "src/automation/region-slots.ts#FLOW_REGION_SLOTS_BY_TYPE (const)", + "FLOW_SLOT_REFUSAL_CODES": "src/automation/flow-node-expression-paths.ts#FLOW_SLOT_REFUSAL_CODES (const)", "FLOW_STRUCTURAL_NODE_TYPES": "src/automation/flow.zod.ts#FLOW_STRUCTURAL_NODE_TYPES (const)", "FLOW_TRIGGER_KINDS": "src/automation/flow-trigger-kind.ts#FLOW_TRIGGER_KINDS (const)", "Flow": "src/automation/flow.zod.ts#Flow (type)", @@ -150,6 +151,8 @@ "FlowRunSummaryParsed": "src/automation/execution.zod.ts#FlowRunSummaryParsed (type)", "FlowRunSummarySchema": "src/automation/execution.zod.ts#FlowRunSummarySchema (const)", "FlowSchema": "src/automation/flow.zod.ts#FlowSchema (const)", + "FlowSlotRefusalCode": "src/automation/flow-node-expression-paths.ts#FlowSlotRefusalCode (type)", + "FlowSlotRefusalParams": "src/automation/flow-node-expression-paths.ts#FlowSlotRefusalParams (interface)", "FlowTriggerKind": "src/automation/flow-trigger-kind.ts#FlowTriggerKind (type)", "FlowValueSlot": "src/automation/builtin-node-config.zod.ts#FlowValueSlot (type)", "FlowValueSlotParsed": "src/automation/builtin-node-config.zod.ts#FlowValueSlotParsed (type)", @@ -188,7 +191,6 @@ "ORG_MEMBERSHIP_LEVELS": "src/automation/approval.zod.ts#ORG_MEMBERSHIP_LEVELS (const)", "OS_CONSTRUCT_EXT": "src/automation/bpmn-mapping.ts#OS_CONSTRUCT_EXT (const)", "PARALLEL_NODE_TYPE": "src/automation/control-flow.zod.ts#PARALLEL_NODE_TYPE (const)", - "PREDICATE_SLOT_REFUSAL_CODES": "src/automation/flow-node-expression-paths.ts#PREDICATE_SLOT_REFUSAL_CODES (const)", "PREDICATE_SLOT_STRING_REFUSAL": "src/automation/flow-node-expression-paths.ts#PREDICATE_SLOT_STRING_REFUSAL (const)", "ParallelBranch": "src/automation/control-flow.zod.ts#ParallelBranch (type)", "ParallelBranchParsed": "src/automation/control-flow.zod.ts#ParallelBranchParsed (type)", @@ -198,7 +200,6 @@ "ParallelConfigSchema": "src/automation/control-flow.zod.ts#ParallelConfigSchema (const)", "PredicateSlotRefusal": "src/automation/flow-node-expression-paths.ts#PredicateSlotRefusal (type)", "PredicateSlotRefusalCode": "src/automation/flow-node-expression-paths.ts#PredicateSlotRefusalCode (type)", - "PredicateSlotRefusalParams": "src/automation/flow-node-expression-paths.ts#PredicateSlotRefusalParams (interface)", "PredicateSlotValueKind": "src/automation/flow-node-expression-paths.ts#PredicateSlotValueKind (type)", "ReconciledNodeConfigType": "src/automation/schemaless-node-config.zod.ts#ReconciledNodeConfigType (type)", "RegionAnalysis": "src/automation/control-flow.zod.ts#RegionAnalysis (interface)", @@ -226,6 +227,9 @@ "StateNode": "src/automation/state-machine.zod.ts#StateNode (type)", "StateNodeConfig": "src/automation/state-machine.zod.ts#StateNodeConfig (type)", "StateNodeSchema": "src/automation/state-machine.zod.ts#StateNodeSchema (const)", + "StructuralConditionRefusal": "src/automation/flow-node-expression-paths.ts#StructuralConditionRefusal (type)", + "StructuralConditionRefusalCode": "src/automation/flow-node-expression-paths.ts#StructuralConditionRefusalCode (type)", + "StructuralConditionValueKind": "src/automation/flow-node-expression-paths.ts#StructuralConditionValueKind (type)", "SubflowConfig": "src/automation/schemaless-node-config.zod.ts#SubflowConfig (type)", "SubflowConfigParsed": "src/automation/schemaless-node-config.zod.ts#SubflowConfigParsed (type)", "SubflowConfigSchema": "src/automation/schemaless-node-config.zod.ts#SubflowConfigSchema (const)",