Skip to content

Commit 40a6ee5

Browse files
fix(spec): a value-slot remedy reads a CEL-claimed head through vars (list.0 → vars["list"][0]) (#22524)
Fixes #22290 Clause-②: no (the remedy a refusal prints, and anything else that shares `celPath`, now evaluates; nothing is newly accepted or refused) ## What was wrong A value-slot `{…}` refusal prints the CEL spelling of a path token through `celPath`. That function passed every head through bare except a `$`-named one. When the head variable is named like an identifier CEL binds for itself, the bare name reads CEL's binding and not the variable. Measured at `4e9fe9ff6` through the built `@objectstack/spec` and `@objectstack/formula`, with a flow variable `list = ['first', 'second']` in the flow CEL scope: | template | remedy printed before | `objectstack validate` on that remedy | evaluation | |:--|:--|:--|:--| | `{list.0}` | `list[0]` | refused, `expression-invalid`: `invalid CEL value: Cannot index type 'type' with type 'int'` | `Cannot index type 'type' with type 'int'` | | `{list}` | `list` | accepted | the CEL type `list` (a cel-js `Type` object), not the variable. Nothing reports it. | | `{list.tags}` | `list.tags` | not probed | fails on the type | **The card's open question.** The `objectstack validate` reading is from the built CLI, on a scratch stack with three flows: the old remedy as an envelope, the new remedy as an envelope, and the `{list.0}` template. `validate` refuses `source: 'list[0]'` before run time, because the envelope's own CEL check catches it. So the door that refuses `{list.0}` was telling the author to write a spelling the same door then refused. The bare `{list}` → `list` remedy is worse: it passes `validate` and writes the wrong value. After the fix, the same run prints `Write {list.0} as { dialect: 'cel', source: 'vars["list"][0]' }`, and the flow carrying `vars["list"][0]` draws no issue. ## The set, read off the engine this repo builds `@objectstack/formula` resolves `@marcbachmann/cel-js` 8.0.0. One set, `CEL_CLAIMED_IDENTIFIERS`, now sits beside `celPath` in `flow-template-token.ts`, in four groups: - **Type identifiers**: `bool`, `bytes`, `double`, `int`, `list`, `map`, `null_type`, `string`, `type`, `uint`. These are `lib/registry.js` `TYPES`, bound as constants when an environment is built (`for (const n in TYPES) this.registerConstant(n, 'type', TYPES[n])`). - **Namespace constants**: `google` (`lib/functions.js`, `registerConstant('google', …)`), `cel` (`lib/macros.js`) and `optional` (`lib/optional.js`, bound because the engine sets `enableOptionalTypes: true`). - **Reserved words**: `lib/globals.js` `RESERVED`, refused by the parser as `Reserved identifier: …`. They are `as`, `break`, `const`, `continue`, `else`, `for`, `function`, `if`, `import`, `let`, `loop`, `namespace`, `package`, `return`, `var`, `void`, `while`, `__proto__` and `prototype`. - **Keywords**: `true`, `false`, `null` (literals) and `in` (operator), as the lexer reads them. The first two groups equal `getDefinitions().variables` of an environment built with the engine's options plus the exported `registerStdLib` (13 names, measured). `timestamp`, `duration` and `dyn` are **not** in the set. The card expected `timestamp` and `duration` to collide, but here they are functions, not bindings, and a variable of either name already reads correctly (control rows). The card's list also missed `cel`, `google` and `optional`. ## The fix - `celPath` reads a claimed head through `vars`, the route it already took for a `$`-named head: `{list.0}` → `vars["list"][0]`, `{list}` → `vars["list"]`. - **In-place, same defect class, same file surface:** 1. A later keyword segment is indexed by name: `{record.in}` printed `record.in`, which CEL refuses (`Expected IDENTIFIER, got IN`). It now prints `record["in"]`. 2. `guardOf` prints the `has()` guard only where `has()` accepts it. CEL refuses `has()` over an index when it runs (`has() invalid argument`, measured). Before, `{items.1.key}` printed `has(items[1].key) ? …`, which `evaluateValueEnvelope`'s author-time check refused on the stale-dist run of the new pins. A path with an index anywhere, a `$` head or a keyword segment now gets no guard. A claimed head is guarded as `has(vars.list.tags) ? vars.list.tags : null`. - Ordinary heads print what they printed before. - **The same example swap elsewhere** (`list[0]` → `items[0]`, the claim amended in `6087190357`): - `content/docs/automation/flows.mdx`, the template table row; - the semantic migration prose `18.flow-value-slot-template-dialect-refused.ts`; - that prose's generated copies (`registry.ts`, `spec-changes.json`, `docs/protocol-upgrade-guide.md`), regenerated through `check:generated --fix`. **Callers of `celPath`** (`git grep`): only `flow-value-slot-template.ts`, in `guardOf`, in `remedyFor`'s whole-path branch and in its text-with-holes branch. `flow-text-slot-template.ts` imports `celExpression` and `templateTokensOf`, not `celPath`. No ADR-0087 conversion or migration entry calls it, so `Clause-②: no` stands. The semantic entry `18.flow-value-slot-template-dialect-refused` carries `list[0]` as prose (see Acceptance notes), not as `celPath` output. ## Pins - `packages/spec/src/automation/flow-value-slot-template.test.ts`: the set equals the enumeration; one row per claimed name (36 rows), covering `celPath` for a bare head, an indexed head and an index-then-key head, plus the printed remedy for `{NAME.0}`; guard rows; keyword segments; an index in the middle; text with holes; controls. The controls are the ordinary heads `items`, `record`, `timestamp`, `duration`, `dyn`, `lists`, `map_of` and `vars`, and the `$error` head. - `packages/services/service-automation/src/builtin/value-slot-template-grammar.test.ts` (declared on #6021, test only). The spec cannot import the engine, so this file holds the evaluation pins. It already pins the judge's reading against the interpolator's. The new describe adds a third reading: every CEL spelling the refusal prints (the envelope source, and the guard when one is printed) goes through `AutomationEngine.evaluateValueEnvelope`, meaning the author-time envelope check and then the built formula engine over the real flow CEL scope. It must give the value `interpolateString` read from the template. The rows: - 35 name rows, each over `{NAME}`, `{NAME.0}`, `{NAME.1.key}` and `{NAME.tags}`. `__proto__` is claimed but left out: the flow CEL scope is a plain object, where `__proto__` names the prototype rather than a key, so no CEL spelling reads a variable of that name. - One row that pins the guard being read off the message. - One row for keyword segments and an index in the middle of a path. - Four ordinary-head controls (`items`, `timestamp`, `duration`, `dyn`). - One `$`-head control. **Ablation.** Run at `347d12026` (fix committed) through `scripts/ablation-replace.mjs`: the `celPath` head branch was reverted to the `$`-only test. Anchor ×1 → ×0, marker ×0 → ×1, blob `9b18b790d96f` → `26df4bf13885`. The spec was rebuilt, and `ablation-dist-preflight` found the marker in 20 built files. Spec pin file: **37 failed** | 46 passed (83). Grammar pin file: **35 failed** | 26 passed (61), for example `list: expected {} to deeply equal [ 'first', { key: 'second' } ]`. Restore: blob after restore `9b18b790d96f` equals HEAD, `git diff HEAD` is empty and the whole-tree porcelain is clean. The spec was rebuilt, and `--absent` found the marker in none of 232 built files. Spec 83 passed, grammar 61 passed. After the ablation, `07a6a4dc0` added one line to each name row (`{NAME.tags}`) and the guard-read row. Those were not ablated. ## Changesets - `.changeset/22290-value-slot-remedy-cel-claimed-head.md`: `@objectstack/spec` `patch`. - `.changeset/19939-flow-value-slot-template-dialect-refused.md`: one row corrected, `'{list.0}'` / `list[0]` → `'{items.0}'` / `items[0]`. Nothing else in the file changed. **This edits another card's pending release note on purpose**: the DELIBERATE CORRECTION class. `Check Changeset`'s step `Reject an empty-frontmatter changeset added by this PR` (`scripts/check-empty-changeset.mjs`, the foreign-changeset rule) is red by design on this edit, and stays red. Locally, `node scripts/check-empty-changeset.mjs --base origin/main` exits 1 and names `.changeset/19939-flow-value-slot-template-dialect-refused.md`. `pr-automation.yml` runs on `pull_request` only, not `merge_group`, and `Check Changeset` is not one of the seven required contexts. The correction needs confirming on this PR. Confirmed as a DELIBERATE CORRECTION in `6087200290`. ## Local verification At head `b828131c6`. This is the patch round on `07a6a4dc0`, with no merge of `main`; the PR stays mergeable. - **Spec `local` tier**, `vitest run --project local`, under the shared lock: Test Files 630 passed (630); Tests 18840 passed, 1 todo. Lock verdict `command-exit 0`. - **Spec `repo` tier**, `vitest run --project repo`: 54 files / 915 tests. `src/api/error-catalog-docs.test.ts` first failed to load on a transient file that a concurrent `check:skill-examples` run writes and deletes. Re-run alone under the lock, it passed 5/5. - **Generated artifacts:** `check:generated` (15 up to date), `check:migration-registry`, `check:spec-changes` and `check:upgrade-guide` all exit 0. - **The pins are unchanged this round.** At `07a6a4dc0`: `value-slot-template-grammar.test.ts` 62 passed; `flow-value-slot-template.test.ts` and `flow-text-slot-template.test.ts` 97 passed. The ablation turned the spec pin file red (37 failed) and the grammar pin file red (35 failed), and the restore was blob == HEAD. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `b828131c6` derived 115 commands: round 1's 87 plus the docs and migration-registry families. - 114 exit 0, after a full workspace build that cleared seven prerequisite refusals on the fresh worktree. - 1 exits 1 by design: `node scripts/check-empty-changeset.mjs --base origin/main`, the confirmed deliberate correction. - `--ran`: `115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN`. - `check:api-surface`: `public API surface + factory signatures unchanged`. - **CI on `b828131c6`:** all seven required contexts are green. `Check Changeset` is red by design (confirmed in `6087200290`). There is no other red. ## Acceptance notes - The three other texts that taught `list[0]` (the docs table row, the semantic migration prose and its generated copies) are corrected in this PR, in commits `650e87277` and `b828131c6`. - **Expression tokens are not `celPath`.** `celExpression` rewrites divisors only. `{int * 2}` prints `int * 2`, refused (`no such overload`), and `{items.0 * 2}` prints `items.0 * 2`, refused (`Expected IDENTIFIER`); both measured through `evaluateValueEnvelope`. Fixing them means tokenising the expression, which is not a mechanical change. They are reported to the seat. - A flow variable named `vars` is shadowed by the scope's own `vars` namespace (`vars[0]` → `No such key: 0`; `vars["vars"][0]` reads it). That binding belongs to the flow runtime, not CEL, so it is not in this set. - An envelope an author writes directly as `source: 'list'` still reads the CEL type. That is CEL's meaning of the identifier, not a remedy this module prints. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5910b5e commit 40a6ee5

11 files changed

Lines changed: 240 additions & 18 deletions

‎.changeset/19939-flow-value-slot-template-dialect-refused.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ A string whose tokens include one of these is not refused. Text slots (`notify`
3030
| you wrote | write instead | what changes |
3131
|:--|:--|:--|
3232
| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an absent variable or key where the template wrote nothing — guard one that may be absent: `has(record.owner) ? record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) |
33-
| `'{list.0}'` | `{ dialect: 'cel', source: 'list[0]' }` | an empty list fails the run |
33+
| `'{items.0}'` | `{ dialect: 'cel', source: 'items[0]' }` | an empty list fails the run |
3434
| `'{$error.message}'` | `{ dialect: 'cel', source: 'vars["$error"].message' }` | a `$`-named variable is read through `vars` |
3535
| `'{round(x * 100) / 100}'` | `{ dialect: 'cel', source: 'round(x * 100) / 100.0' }` | CEL divides two integers as integers: keep a decimal operand on every division, or `123.46` becomes `123` |
3636
| `'Renewal — {contract.number}'` | `{ dialect: 'cel', source: "'Renewal — ' + contract.number" }` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` |
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
A value-slot `{…}` refusal now names a CEL spelling that evaluates when the path's head variable is named like a CEL type or keyword (`{list.0}` → `vars["list"][0]`)
6+
7+
Clause-②: no
8+
9+
The refusal for a `{…}` path token in a flow value slot (`valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals`, shown by `objectstack validate`, `registerFlow` and the executors) printed the path as bare CEL. For a head variable named `list`, that remedy was `list[0]`, and CEL read `list` as its own type, not the variable: `objectstack validate` refused the remedy it had just suggested (`Cannot index type 'type' with type 'int'`), and the bare `{list}` remedy `list` passed validation and evaluated to the type instead of the variable's value. A head that CEL claims for itself is now read through the flow scope's `vars` map, the route a `$`-named head already took: `{list.0}` → `vars["list"][0]`, `{list}` → `vars["list"]`.
10+
11+
The claimed names are read off the CEL implementation the formula engine builds (cel-js 8.0.0): the type identifiers `bool`, `bytes`, `double`, `int`, `list`, `map`, `null_type`, `string`, `type` and `uint`; the namespaces `cel`, `google` and `optional`; the reserved words, such as `for`, `if` and `var`; and the keywords `true`, `false`, `null` and `in`. `timestamp`, `duration` and `dyn` are functions there, not bindings, so a variable with one of those names already read correctly and is unchanged. A later path segment that is a keyword is indexed by name (`{record.in}` → `record["in"]`). The `has()` guard the refusal suggests is now printed only where `has()` accepts it. CEL refuses `has()` over an index at run time (`has(rows[0].name)`, `has(vars["list"].tags)`), so a path with an index gets no guard, and a claimed head is guarded as `has(vars.list.tags) ? vars.list.tags : null`.
12+
13+
Ordinary heads (`record.owner`, `items[0]`) print the same remedy as before. Which strings are refused and which are kept is unchanged; only the remedy text changes.

‎content/docs/automation/flows.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ for some input, so the rewrite is yours to judge.
266266
| you wrote | write instead | what changes |
267267
|:---|:---|:---|
268268
| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an **absent** variable or key where the template wrote nothing — guard one that may be absent: `has(record.owner) ? record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) |
269-
| `'{list.0}'` | `source: 'list[0]'` | an empty list fails the run |
269+
| `'{items.0}'` | `source: 'items[0]'` | an empty list fails the run |
270270
| `'{$error.message}'` | `source: 'vars["$error"].message'` | a `$`-named variable is read through `vars` |
271271
| `'{round(x * 100) / 100}'` | `source: 'round(x * 100) / 100.0'` | CEL divides two integers as integers: keep a decimal operand on every division |
272272
| `'Follow up on {record.name}'` | `source: "'Follow up on ' + record.name"` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` |

‎docs/protocol-upgrade-guide.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -976,7 +976,7 @@ AUTHOR-REACHABLE SURFACES: a saved report's `query.filter` (`sys_saved_report`)
976976
- **`flow-trigger-record-credential-masked`** — `the record and previous roots a record-change flow receives — a password or secret field, and an internal field, of the triggering record, on every object` → read a credential through a privileged binder — the flow credential channel for an http node's signing secret, or a privileged server-side read such as the engine's resolveSecretField — never off `record` or `previous`; on those roots a set credential-class field now reads as the mask `SECRET_MASK`, an unset one as null, and an `internal: true` field is absent
977977
- Why not automatic: ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged.
978978
- Done when: No flow reads a password, secret or internal field off its trigger record or previous values expecting the stored value; a flow that needs a credential obtains it through a privileged binder; a start or edge condition that compared such a field against a literal is rewritten to test whether it is set (not null).
979-
- **`flow-value-slot-template-dialect-refused`** — `flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token` → a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, list[0]; a variable whose name starts with $ is read through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal
979+
- **`flow-value-slot-template-dialect-refused`** — `flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token` → a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal
980980
- Why not automatic: The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it.
981981
- Done when: Run objectstack validate: it reports each refused value as expression-invalid at the node and the value's path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or route around the node. Re-run the flow paths that write those fields and compare the stored values with the ones the template wrote.
982982
- **`flow-write-node-stored-metadata-target-refused`** — `a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body` → Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its provenance is recorded. Delete the node, or point its `objectName` at the object the flow really means to write. Elevation (`runAs`, a system context) does not change this.

‎packages/services/service-automation/src/builtin/value-slot-template-grammar.test.ts‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,18 @@
1818
* - every REFUSED spelling resolves through the variables (a path), computes
1919
* (an expression), or resolves to nothing — and the judge refuses each,
2020
* including the dispatch-order edges (`{$User}` with no path, `{NOW}` with
21-
* no call, a padded `{ amount }`).
21+
* no call, a padded `{ amount }`);
22+
* - for a refused PATH, the remedy the judge prints reads the same value
23+
* through this package's CEL evaluator (`evaluateValueEnvelope`: the
24+
* author-time envelope check, then the built `@objectstack/formula` engine
25+
* over the flow's real CEL scope) as the interpolator read from the
26+
* template — including a head variable named like an identifier CEL claims
27+
* for itself (`{list.0}` → `vars["list"][0]`, #22290).
2228
*/
2329

2430
import { describe, expect, it } from 'vitest';
2531
import { valueSlotTemplateRefusals } from '@objectstack/spec/automation';
32+
import { AutomationEngine } from '../engine.js';
2633
import { interpolateString } from './template.js';
2734

2835
const VARIABLES = new Map<string, unknown>([
@@ -100,3 +107,73 @@ describe('a spelling the retirement REFUSES is one the interpolator reads as tem
100107
expect(valueSlotTemplateRefusals('a {} b')).toEqual([]);
101108
});
102109
});
110+
111+
describe('the remedy a refused path prints reads, through CEL, the value the interpolator read', () => {
112+
const quiet = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, child: () => quiet } as never;
113+
const engine = new AutomationEngine(quiet);
114+
115+
/** The CEL spellings the one refusal of `token` names: the envelope's source, and the `has()` guard when it prints one. */
116+
function printedSpellings(token: string): string[] {
117+
const refusals = valueSlotTemplateRefusals(token);
118+
expect(refusals, `exactly one refusal for ${token}`).toHaveLength(1);
119+
const message = refusals[0]!.message;
120+
const envelope = /Write `[^`]+` as \{ dialect: 'cel', source: (?:'([^']*)'|("(?:[^"\\]|\\.)*")) \}/.exec(message);
121+
expect(envelope, `an envelope remedy in: ${message}`).not.toBeNull();
122+
const guard = /: `(has\([^`]*)` \(the guarded form writes `null`\)/.exec(message);
123+
return [envelope![1] ?? (JSON.parse(envelope![2]!) as string), ...(guard ? [guard[1]!] : [])];
124+
}
125+
126+
/** Both readings of `token` over `variables`, and every printed spelling evaluated to the interpolator's value. */
127+
function expectReadingsAgree(token: string, variables: Map<string, unknown>, value: unknown): void {
128+
expect(interpolateString(token, variables, CONTEXT)).toEqual(value);
129+
for (const source of printedSpellings(token)) {
130+
expect(engine.evaluateValueEnvelope({ dialect: 'cel', source }, variables, token), source).toEqual(value);
131+
}
132+
}
133+
134+
// Every identifier CEL claims before a flow variable can (`flow-template-token.ts`
135+
// in the spec reads them off cel-js 8.0.0): the type identifiers, the namespace
136+
// constants, the reserved words and the keywords. `__proto__` is claimed there
137+
// too, and is left out of this list: the flow CEL scope is a plain object, where
138+
// `__proto__` names the prototype rather than a key, so no CEL spelling reads a
139+
// variable of that name.
140+
it.each([
141+
'bool', 'bytes', 'double', 'int', 'list', 'map', 'null_type', 'string', 'type', 'uint',
142+
'cel', 'google', 'optional',
143+
'as', 'break', 'const', 'continue', 'else', 'for', 'function', 'if', 'import', 'let', 'loop', 'namespace',
144+
'package', 'return', 'var', 'void', 'while', 'prototype',
145+
'true', 'false', 'null', 'in',
146+
])('a head variable named `%s`', (name) => {
147+
const value = ['first', { key: 'second' }];
148+
const variables = new Map<string, unknown>([[name, value]]);
149+
expectReadingsAgree(`{${name}}`, variables, value);
150+
expectReadingsAgree(`{${name}.0}`, variables, 'first');
151+
expectReadingsAgree(`{${name}.1.key}`, variables, 'second');
152+
expectReadingsAgree(`{${name}.tags}`, new Map<string, unknown>([[name, { tags: 'T' }]]), 'T');
153+
});
154+
155+
it('the guard is read off the refusal too, so each row above evaluates it where one is printed', () => {
156+
expect(printedSpellings('{list.tags}')).toEqual(['vars["list"].tags', 'has(vars.list.tags) ? vars.list.tags : null']);
157+
expect(printedSpellings('{list}')).toEqual(['vars["list"]', 'has(vars.list) ? vars.list : null']);
158+
expect(printedSpellings('{null.tags}')).toEqual(['vars["null"].tags']);
159+
});
160+
161+
it('a later keyword segment, and an index in the middle of a path', () => {
162+
const variables = new Map<string, unknown>([['record', { in: 'x', tags: { null: 'y' } }], ['rows', [{ name: 'r0' }]]]);
163+
expectReadingsAgree('{record.in}', variables, 'x');
164+
expectReadingsAgree('{record.tags.null}', variables, 'y');
165+
expectReadingsAgree('{rows.0.name}', variables, 'r0');
166+
});
167+
168+
it.each(['items', 'timestamp', 'duration', 'dyn'])('control: an ordinary head `%s` is read bare, unchanged', (name) => {
169+
const variables = new Map<string, unknown>([[name, ['first', { key: 'second' }]]]);
170+
expect(printedSpellings(`{${name}.0}`)).toEqual([`${name}[0]`]);
171+
expectReadingsAgree(`{${name}.0}`, variables, 'first');
172+
expectReadingsAgree(`{${name}.1.key}`, variables, 'second');
173+
});
174+
175+
it('control: a `$`-named head is still read through `vars`', () => {
176+
expect(printedSpellings('{$error.message}')).toEqual(['vars["$error"].message']);
177+
expectReadingsAgree('{$error.message}', VARIABLES, 'boom');
178+
});
179+
});

0 commit comments

Comments
 (0)