Skip to content

Commit 862b6ce

Browse files
feat(formula,spec): stable refusal codes and params beside every expression refusal message (#20352)
Fixes #20291 Clause-②: yes ## Rework round 1 Order of record: the seat's comment `5861789336` on #20291. Its instruction is to fold in the file's second refusal producer, `structuralConditionRefusal`, which round 0 reported as a same-family finding. Claim `5860074154` is unchanged, and the file is already in its surface. The round adds two commits on top of `2a6dee312c` (`658a8637f5`, `5581ed26ee`); no rebase, amend or force-push. - `structuralConditionRefusal` now returns `StructuralConditionRefusal`: its previous `{ message, source }` plus `code: 'structural-condition-shape'` and `params: { found }`. `found` is one of `array`, `ast-without-source`, `object-without-source`, `number`, `boolean`, `bigint`, `symbol` and `function`. The message is byte-identical, `STRUCTURAL_CONDITION_SHAPE_REFUSAL` included. - The spec code set is now **one** closed table for both of the file's producers, the way the formula set is laid out. `FlowSlotRefusalParams` and the frozen `FLOW_SLOT_REFUSAL_CODES` (4 codes) replace round 0's `PredicateSlotRefusalParams` and `PREDICATE_SLOT_REFUSAL_CODES`. Per-producer code types are `PredicateSlotRefusalCode` and `StructuralConditionRefusalCode`. The two replaced names never reached `main`. - Census confirmed: the file has exactly **two** exported refusal producers, `predicateSlotRefusal` and `structuralConditionRefusal`. Its other exported functions are `isExpressionEnvelopeShaped` (a boolean guard), `resolveFlowNodeExpressions` and `resolveFlowNodeValueSlots` (resolvers returning `{ entry, path, value }`). The only `message:` sites in the file sit inside those two producers. - The byte-identity corpus now has **64 cases**: round 0's 43 plus 21 for `structuralConditionRefusal` (6 admitted, 15 refused, every `found` kind). `base` is `origin/main` `de091b50e6` for the 43; for the 21 it is `2a6dee312c`, where that function was still untouched. The result is **0 diffs**. - The pin file became `flow-slot-refusal-codes.test.ts`. It has one pin per code (4 codes; `structural-condition-shape` carries 10 cases, every kind) and pins `STRUCTURAL_CONDITION_SHAPE_REFUSAL` against a literal. The exhaustiveness pin covers both producers: a sweep per producer, each reaching all of its codes, and the two producers' code sets partitioning the table exactly. `@ts-expect-error` covers a code-less `StructuralConditionRefusal` and a structural refusal carrying a predicate-slot code. Expression refusals now carry a stable `code` and typed `params` beside their English `message`. This covers `validateExpression` (every entry in `errors[]` and `warnings[]`), `collectCelRootIdentifiers` (its `ok: false` arm), `predicateSlotRefusal` and `structuralConditionRefusal`. A localized author surface, such as objectui's flow designer, can now key its own catalogue rows to the code instead of showing our English sentence. This is direction (a) from triage `5859522970`. **The English `message` is unchanged byte for byte.** Nothing is removed or renamed, so it is a widening (`minor` for both packages). ## The closed set Codes are kebab-case and never change once published. Each code names one message template, and an optional clause (a did-you-mean, an object name) is an optional param that is present exactly when the English message carries that clause. Narrowing on `code` narrows `params`. The keys of `ExpressionRefusalParams` and `FlowSlotRefusalParams` are the closed sets. The frozen value lists `EXPRESSION_REFUSAL_CODES` and `FLOW_SLOT_REFUSAL_CODES` are held equal to those keys at compile time: each list is read off a code table that `satisfies` a record keyed by every code, so a missing code or an extra one does not compile. These are authoring diagnostics returned as values, not ADR-0112 request error codes, which is why they are kebab-case. `check:error-code-casing` and `check:dispatcher-error-vocabulary` both pass. ### Inventory: every refusal each producer emits (at head `5581ed26ee`) | Producer site | Code | Params | |---|---|---| | `validate.ts:820` non-string envelope `source` | `envelope-source-not-text` | `role`, `found` (`array`/`object`/`number`/`boolean`/`bigint`/`symbol`/`function`) | | `validate.ts:837` template slot, other dialect | `template-dialect-mismatch` | `dialect` | | `validate.ts:847` template engine refused | `invalid-template` | `detail` | | `validate.ts:856` single brace in a template | `template-single-brace` | `ref` | | `validate.ts:862` CEL slot, other dialect | `cel-dialect-mismatch` | `dialect` | | `validate.ts:911` CEL compile failure (the "bare CEL" trailer) | `invalid-cel` | `role`, `detail` | | `validate.ts:901` over a parse budget | `cel-too-large` | `role`, `detail`, `limit`?, `limitValue`? (both or neither) | | `validate.ts:904` bare function called as a method | `cel-method-call` | `role`, `detail`, `name`, `receiver`? | | `validate.ts:907` name not callable here | `cel-unknown-function` | `role`, `detail`, `name`, `suggestion`? | | `validate.ts:909` template brace in CEL | `cel-template-brace` | `role`, `detail`, `ref` | | `validate.ts:636` unknown `record.` field | `unknown-field` | `field`, `objectName`?, `suggestion`? | | `validate.ts:786` role outside the catalog | `unknown-role` | `name`, `suggestion`?, `catalog` (list) | | `validate.ts:701` typo of a declared root | `unbound-root` | `name`, `roots` (list), `suggestion` | | `validate.ts:934` bare identifier, record scope | `bare-reference` | `name` | | `validate.ts:277` date arithmetic (error) | `date-arithmetic` | `operands`, `reference`? | | `validate.ts:293` text/boolean against a number (**warning**) | `type-mismatch` | `operands`, `operator`, `held` (`text`/`boolean`), `reference`? | | `validate.ts:966` near-miss of a field, flattened (**warning**) | `field-near-miss` | `name`, `suggestion`, `objectName`? | | `validate.ts:1011` traversal conflict `bare-and-traversed` | `traversal-bare-and-traversed` | `root`, `field` | | `validate.ts:1011` traversal conflict `multi-hop` | `traversal-multi-hop` | `root`, `field` | | `cel-engine.ts:343` `collectCelRootIdentifiers`, empty | `empty-expression` | none | | `cel-engine.ts:364` `collectCelRootIdentifiers`, parse fault (bounds included) | `cel-parse-failed` | `detail` | | `flow-node-expression-paths.ts:630` absent or `null`, required slot | `predicate-slot-missing` | `found` (`absent`/`null`) | | `flow-node-expression-paths.ts:647` blank after trim | `predicate-slot-blank` | none | | `flow-node-expression-paths.ts:672` not text | `predicate-slot-not-text` | `found` (`envelope`/`array`/`object`/`number`/`boolean`/`bigint`/`symbol`/`function`) | | `flow-node-expression-paths.ts:858` `structuralConditionRefusal`, neither text nor an envelope with a string `source` | `structural-condition-shape` | `found` (`array`/`ast-without-source`/`object-without-source`/`number`/`boolean`/`bigint`/`symbol`/`function`) | `detail` is the CEL or template engine's own diagnostic. It is English and, for CEL, includes a source excerpt. It is passed through as the message carries it. ### Carriers: the least-breaking option on each producer - **`validateExpression`**: `ExprValidationError` gains `code` and `params`. It becomes a type alias (`{ message, source }` intersected with the correlated code/params union), no longer an interface. No repo consumer `extends` it or constructs one (`git grep ExprValidationError` hits only `index.ts` and two test comments). All construction goes through one internal `refusal(code, params, source, message)` helper, which is type-checked against its own code's params. - **`collectCelRootIdentifiers`**: its refusal was a **plain string** (`{ ok: false; error: string }`). `error` stays, byte for byte, and the failure arm gains `code` and `params` beside it (`CelRootIdentifiersResult`). Every consumer reads `.ok`, `.roots` and `.error`, and all of those are untouched. - **`predicateSlotRefusal`**: returns `PredicateSlotRefusal`, its previous `{ message, source }` plus `code` and `params`. - **`structuralConditionRefusal`**: returns `StructuralConditionRefusal`, its previous `{ message, source }` plus `code` and `params` (round 1). ## The control: the English message is unchanged - **One-time proof.** A 43-case corpus (64 after round 1; see above) touches every arm of the three producers, including every optional-param variant and every `found` kind. It was run through `origin/main` `de091b50e6` **before** the edit and through the branch **after** it. `message`, `source`, `error` and `ok` are identical on all 43 cases (**0 diffs**). - **Kept as a pin.** There is one test per code (`expression-refusal-codes.test.ts`, 21 codes; `flow-slot-refusal-codes.test.ts`, 4 codes). Each asserts the code, the params exactly, and the message with an exact-string `toBe`. Where a message embeds an engine diagnostic, the expected string takes `detail` from the engine itself (`celEngine.compile` / `templateEngine.compile`). That way the pin covers our words and the pass-through, never cel-js's wording. ## Exhaustiveness pin, and the ablation - `PINS` is typed `{ [C in Code]: [Pin, ...Pin[]] }`, so a code without a pin is a type error. At runtime, `Object.keys(PINS)` equals the code list. - A sweep of 2,457 `validateExpression` calls (3 roles × 117 inputs × 7 schema hints) and the `collectCelRootIdentifiers` sweep make assertions about every refusal they provoke. Each one carries a code from its producer's set. Each param value (list elements included) appears in the message, so code/params and message cannot drift apart. Each producer's sweep reaches **every** code that producer owns, a positive control against a vacuous sweep. The spec side sweeps 25 slot values through each of its two producers the same way. - Type level: in both test layers, a refusal literal without `code`/`params`, and one with another code's params, carry `@ts-expect-error`. Both layers are compiled (`tsconfig.test.json`, `check:test-typecheck` OK), so an unused directive would be red. - **Ablation** (committed first, done through `scripts/ablation-replace.mjs` with its restore armed, plus an outer `trap … EXIT INT TERM`): The `refusal()` helper was mutated to drop `code` for `bare-reference` alone. It simulates one refusal path shipping without a code. - **Landed on disk:** anchor `return { source, message, code, params } as ExprValidationError;` went from 1 hit to 0, the replacement from 0 to 1, and the blob from `2e753a6e8a15` to `b90d3d475acd`. The marker `grep -c` read 1 inside the run. - **Run:** `expression-refusal-codes.test.ts` gave **2 failed, 24 passed (26)**. The two reds are the `bare-reference` pin (`expected undefined to be 'bare-reference'`) and the sweep (`undefined from "amount * 2"`). The other 20 per-code pins stayed green, so the red is specific. - **Restored:** blob `2e753a6e8a15` equals the HEAD blob, `git diff HEAD` is empty, and the marker count is 0. - **Why no build leg:** the test imports `./validate` relatively, so the subject resolves from `src/`. No package `exports` hop and no vitest alias are involved, so no build or `dist/` preflight applies. ## Every consumer, and why it still compiles and behaves `git grep` over the repo for each producer: - `validateExpression`: `packages/lint` (`validate-expressions.ts`, `validate-null-guards.ts`, `validate-visibility-predicates.ts`, `scripts/check-doc-formula-expressions.mjs`), `packages/mcp` (`mcp-http-tools.ts`), `packages/services/service-automation` (`engine.ts`). Each reads `.ok`, `.errors[].message`, `.errors[].source` and `.warnings[]`, and none constructs an error object. The `validate_expression` MCP tool forwards `errors`/`warnings` as they are, so its answer entries now also carry `code` and `params`, an additive widening of that tool's payload that the changeset states. `mcp-validate-expression.test.ts` asserts `toHaveLength` and message regexes only. - `collectCelRootIdentifiers`: `packages/lint` (4 sites), `packages/objectql` (`rule-validator.ts` ×2), `packages/plugins/plugin-approvals` (`approval-service.ts`). They read `.ok`, `.roots` and `.error` only. - `predicateSlotRefusal`: `packages/lint` (`validate-expressions.ts:1422`) and `packages/services/service-automation` (`engine.ts:9539`) read `.message` and `.source`. - `structuralConditionRefusal`: `packages/lint` (`validate-expressions.ts:1582`) and `packages/services/service-automation` (`engine.ts:9458`, `engine.ts:10766`) read `.message` and `.source`. Existing tests assert `toBeUndefined` / `toBeDefined` / `.message` / `.source` only. The tests that compare against it do so relationally (`toBe(predicateSlotRefusal(v)!.message)`). - objectui at the pinned `.objectui-sha` (`f8a9d0fb05`) and at its `main`: `screen-spec.ts` and `flow-sim-validate.ts` call the producers and read `.message` / `.error`. `flow-sim-validate.ts:162` reads `structuralConditionRefusal(condition).message`. No objectui test deep-equals a refusal object. A widening cannot break the Console Pin Gate's build. - No test anywhere deep-equals a refusal object (the grep for `toEqual`/`toStrictEqual` on these results finds only `toEqual([])`). **Measured at head `2a6dee312c`:** - `@objectstack/lint`, whose closure is spec, formula and sdui-parser (all rebuilt): - `pnpm --filter @objectstack/lint typecheck`: OK (tsc and the test layer). - The 7 lint test files that exercise the three producers passed, **7 files, 657 tests** (`flow-variable-scope`, `validate-expressions`, `validate-expressions-nonstring-source`, `validate-expressions.fields-value-slot`, `validate-null-guards`, `validate-visibility-predicates`, `validate-approval-approvers`). - `check:doc-formula-expressions`, which runs `validateExpression` over every documented formula, passed. **NOT MEASURED locally, declared to CI:** typecheck and tests for `@objectstack/mcp`, `@objectstack/service-automation`, `@objectstack/objectql` and `@objectstack/plugin-approvals`. Each needs a closure of about 20 packages rebuilt (every one a turbo cache miss after the spec change), and the shared verify lock was contended for more than an hour. What they see is additive fields on values they read only `.message`, `.source` or `.error` from (grep above). ## For objectui#10835 This is the hand-off for the consumer half. ⛔ Nothing was posted there from this PR. - Import `EXPRESSION_REFUSAL_CODES` / `ExpressionRefusalCode` / `ExpressionRefusalParams` from `@objectstack/formula`, and `FLOW_SLOT_REFUSAL_CODES` / `FlowSlotRefusalCode` / `FlowSlotRefusalParams` from `@objectstack/spec/automation`. `PredicateSlotRefusalCode` and `StructuralConditionRefusalCode` narrow the spec set to each producer. A catalogue-coverage test can iterate the two frozen lists. - Params are values, not renderings. A list (`catalog`, `roots`) is an array that the row joins. A reference is bare text that the row quotes. `found` and `held` are tokens that the row phrases. `limitValue` is a number. Every other param is a string. `tFormat`'s `vars` takes `string | number`, so only the two list params need a join before the call. - `detail` stays English (engine-owned). The row wraps it and does not translate it. - `structuralConditionRefusal` (round 1): the flow simulator's guard evaluation calls it directly at `flow-sim-validate.ts:162` (`evalGuard`, objectui `main`) and shows `shape.message` as the step error. That is the site for the `structural-condition-shape` row. Its test `flow-simulator.guardsInterpolation-10615.test.ts:128` asserts the step error contains the English `STRUCTURAL_CONDITION_SHAPE_REFUSAL`, so that assertion moves with the row. - The rows the designer surfaces reach today, with no schema hint (screen `visibleWhen`, the simulator): `predicate-slot-missing`, `predicate-slot-blank`, `predicate-slot-not-text`, `structural-condition-shape`, `invalid-cel`, `cel-too-large`, `cel-method-call`, `cel-unknown-function`, `cel-template-brace`, `cel-dialect-mismatch`, `envelope-source-not-text`, `empty-expression`, `cel-parse-failed`. The other codes need a schema hint (`fields`, `fieldTypes`, `roleCatalog`, `roots`, `traversalHydration`) or the `template` role. - Code → params: | Code | Params | |---|---| | `envelope-source-not-text` | `role`, `found` | | `template-dialect-mismatch` | `dialect` | | `invalid-template` | `detail` | | `template-single-brace` | `ref` | | `cel-dialect-mismatch` | `dialect` | | `invalid-cel` | `role`, `detail` | | `cel-too-large` | `role`, `detail`, `limit`?, `limitValue`? | | `cel-method-call` | `role`, `detail`, `name`, `receiver`? | | `cel-unknown-function` | `role`, `detail`, `name`, `suggestion`? | | `cel-template-brace` | `role`, `detail`, `ref` | | `unknown-field` | `field`, `objectName`?, `suggestion`? | | `unknown-role` | `name`, `suggestion`?, `catalog` | | `unbound-root` | `name`, `roots`, `suggestion` | | `bare-reference` | `name` | | `date-arithmetic` | `operands`, `reference`? | | `type-mismatch` | `operands`, `operator`, `held`, `reference`? | | `field-near-miss` | `name`, `suggestion`, `objectName`? | | `traversal-bare-and-traversed` | `root`, `field` | | `traversal-multi-hop` | `root`, `field` | | `empty-expression` | none | | `cel-parse-failed` | `detail` | | `predicate-slot-missing` | `found` | | `predicate-slot-blank` | none | | `predicate-slot-not-text` | `found` (`envelope`, `array`, `object`, `number`, `boolean`, `bigint`, `symbol`, `function`) | | `structural-condition-shape` | `found` (`array`, `ast-without-source`, `object-without-source`, `number`, `boolean`, `bigint`, `symbol`, `function`) | ## Acceptance notes - **`structuralConditionRefusal`, folded in round 1:** round 0 reported it as a same-family finding. The seat's order `5861789336` folded it in here, and the family is now closed on the producer side: the file has no third refusal producer. - `predicateSlotRefusal`'s not-text arm had an unreachable `value === null` branch, because `null` returns from the first arm. The kind token is now derived first and the phrase rendered from it, so that branch is gone. Output is identical on every reachable input (the 43-case control and the pins cover every kind). - `check:dispatcher-error-vocabulary`'s object-literal-constant shape (a `code:` followed by a SCREAMING identifier) matches a single-letter **type parameter** in a type literal (`readonly code: K;`) and reports it as an unresolvable code constant. The type parameters are named `Code` / `Codes` here, and the gate is green. Not filed: it is a precision note on a gate that failed loudly, with no author-facing reach. - The per-code message pins assert English copy exactly. That is deliberate for this card, whose declared control is byte-identical messages (triage `5859522970`, claim `5860074154`). A later rewording of a prescription updates its pin and keeps its code. ## Local verification Heavy runs went through `os-verify-lock.sh`, and each verdict is the lock's `VERDICT command-exit 0` line. **Round 1, at head `5581ed26ee`:** - **spec:** - Targeted refusal files (`flow-slot-refusal-codes`, `flow-node-expression-paths`, `flow-decision-branch-expression-absent`): **3 files, 63 tests passed**, run at `658a8637f5`. `5581ed26ee` only adds generated JSON and the changeset. - `typecheck`: OK (tsc, scripts and the test layer, ledger unchanged at 53 files / 255 errors). - `check:generated`: all 15 artifacts up to date. It first reported `api-surface/` and `export-origins/` stale; both were regenerated, and against the base `de091b50e6` they are additive only (0 removed lines). - **formula:** the pin file passed, **26 tests** (formula is untouched in round 1). - **lint** (consumer; `validate-expressions.ts:1582` reads `structuralConditionRefusal`): - `typecheck`: OK. - The 7 producer-exercising files: **7 files, 657 tests passed**. - `check:doc-formula-expressions`: pass. - **Byte-identity control:** **64 cases, 0 diffs**. - **Derived gates:** re-derived at `5581ed26ee` (the same 86 families as round 0) and reconciled with `--ran`: **83 run and green**, 3 NOT MEASURED, 0 unrun. - The three NOT MEASURED are `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`. Each exits 3, `PREREQUISITE NOT MET`, because it reads a whole-repo build. Declared to CI. **Round 0, at head `2a6dee312c`:** - **formula:** - `typecheck`: OK. - Pin file with five producer suites: **6 files, 319 passed**. - Full `pnpm test`: **40 files, 1191 passed** at `b0b9a29b40`. - **spec:** - `typecheck`: OK. - Full `pnpm test` (local project): **554 files, 16333 passed, 1 todo**. - **Gate notes:** `check:dispatcher-error-vocabulary` was red once, on single-letter type parameters (see Acceptance notes), and is green after the rename. `check:error-code-casing`: pass. - **NOT MEASURED locally, declared to CI:** typecheck and tests for `@objectstack/mcp`, `@objectstack/service-automation`, `@objectstack/objectql` and `@objectstack/plugin-approvals` (about a 20-package closure rebuild under a contended lock). They read only `.message`, `.source` or `.error` (grep above). - **Not merged with `main`:** the branch is behind `origin/main`, and none of the commits it lacks touches `packages/formula`, `packages/spec/src/automation`, `api-surface/` or `export-origins/`. CI's merge ref covers the combination. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 07bcbf8 commit 862b6ce

10 files changed

Lines changed: 1451 additions & 129 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/formula': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
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)
7+
8+
Clause-②: yes
9+
10+
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.
11+
12+
- `@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`.
13+
- `@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`.
14+
- 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.
15+
- A `detail` param is the CEL or template engine's own diagnostic, in English, passed through as the message carries it.
16+
- These are authoring diagnostics returned as values, not ADR-0112 request error codes, which is why they are kebab-case.
17+
- 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`.

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

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

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

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

0 commit comments

Comments
 (0)