Skip to content

Commit 3073b72

Browse files
fix(spec/automation): refuse a text-slot {{ $… }} hole whose root the flow engine does not bind (#22499)
Fixes #22477 Clause-②: no (narrowing) ## What this does A `{{ }}` hole in a flow text slot (a `notify` `title` / `message`, a `screen` `title` / `description`, a refusing `end` `message`) whose root is a `$` name the flow engine does not bind is now refused by the one text-slot judge, `textSlotTemplateRefusal` in `packages/spec/src/automation/flow-text-slot-template.ts`. Every door that already calls that judge refuses it with no edit of its own: `NotifyConfigSchema`, `ScreenConfigSchema`, `EndConfigSchema`, `AutomationEngine.registerFlow`, and `objectstack validate` (`expression-invalid`, `error`). - `'By {{ $User.Id }}'` is refused with the very remedy `'By {$User.Id}'` already gets ("compute it into a variable with an `assignment` node, whose value slot still reads it (`assignments: { v: '{$User.Id}' }`), and write `{{ v }}` here"). The test asserts the hole refusal ends with the single-brace remedy byte for byte, so the two spellings answer alike. - Any other `$` root (`{{ $User }}`, `{{ $Error.message }}`, `{{ $org.id }}`) gets a remedy that names the root and the variables the engine does bind. - A single-brace path token over such a root (`'Failed: {$caught.message}'`) used to be told to write `{{ $caught.message }}`, which would now be refused in turn. It gets the same remedy instead of a rewrite. - Controls hold: `{{ $error.message }}`, `{{ record.name }}`, a node output `{{ lookup.result }}`, and every hole over an engine-bound `$` variable are unchanged. - ⛔ The template engine binds no new variable (triage ruling). ## The `$` roots are one enumerated list (H1, measured) The runtime has no single declaration of its `$` variables. They are bound by literal name in three places: | root | where `service-automation` binds it (at `dee7692f0b`) | |:--|:--| | `$record`, `$runId`, `$flowName`, `$flowLabel` | `engine.ts` `seedRunVariables` (every run attempt; `$record` when there is a trigger record) | | `$error` | `engine.ts`, the throw arm and the returned-failure arm of node execution (also the `try_catch` `errorVariable` default) | | `$loopItems`, `$loopIndex` | `builtin/loop-node.ts`, the legacy flat-graph `loop` with no `body` | The spec has no home for this either. `contracts/automation-service.ts` states only that `$` names are reserved, in its `INVALID_SIGNAL` prose. So this follows H1's fallback. The list `FLOW_ENGINE_VARIABLES` sits beside the judge in `packages/spec`. A parity pin in `service-automation`'s `text-slot-template.test.ts` scans that package's non-test sources for every `.set('$name'` literal and asserts the public judge admits a hole over each one. Today the scan finds exactly those seven, listed with their files. The scan also carries a floor that fails if one of the seven stops being bound, so a removal is caught too. The list is **module-private**, not exported. A new public export would enlarge the public surface, and that is the question `Clause-②` answers; it is ruled `no`. The parity pin reaches the list through `textSlotTemplateRefusal`, which is already public. `check:api-surface` and `check:export-origins` are unchanged and green. **H2 (node outputs):** a node output is addressed by its node id (`{{ lookup.result }}`). The engine writes it as `NODEID.KEY`. `git grep` finds no node id starting with `$`. The map node's `.$mapState` / `.$mapItemDone` are `.$` segments under a node-id root, not `$` roots. So no node output joins the list. **H3 (the validate door):** `objectstack validate`'s text-slot check is `validate-expressions.ts`, and it calls `textSlotTemplateRefusal(slot.source)` before compiling the slot. The refusal therefore reaches `objectstack validate` through the same judge, and **no lint source edit** is needed. `validate-flow-template-paths.ts` does skip `$` roots, but that is the record-field-path warning rule, not the text-slot door. The validate-door pin is in `validate-expressions.text-slot.test.ts`. **H4:** no `skills/**` edit. ## Boundary, stated The triage ruling says nothing outside the list is admitted. So a variable an author binds under a `$` name, such as `try_catch` `errorVariable: '$caught'` read as `{{ $caught.message }}`, is refused in a text slot, and the remedy says to name it without the `$`. Measured: `$`-named `errorVariable`s other than `$error` occur only in tests (`$caught` in `throw-arm-error-refresh.test.ts`, `$err` in two spec tests). None of them is read in a text slot. ## Changeset: `@objectstack/spec` minor, `@objectstack/lint` patch, `Clause-②: no (narrowing)`, **BREAKING** Patch round 1 (REWORK `6083820886` on #22477, after contract review FAIL `6083797903`) corrected the grade: - **A published accept set narrows.** At tag `@objectstack/spec@17.7.0` (npm `latest`), `NotifyConfigSchema.title` / `.message`, `ScreenConfigSchema.title` / `.description` and `EndConfigSchema.message` are plain `z.string()`, and no text-slot judge exists. So `'By {{ $User.Id }}'` is accepted by the published contract and refused after this PR. The unreleased part is the hole semantics of #22110, not the acceptance of the string. - **The disposition:** a new step-18 D3 semantic entry, `flow-text-slot-unbound-dollar-root-refused` (`504b61ad21`), with the changeset's marker `adr-0087: registered flow-text-slot-unbound-dollar-root-refused`. After PR #22215 (protocol 18) landed, the re-sync regenerated `spec-changes.json` and the upgrade guide in their own commit (`52c005e2b1`): step 18 has 330 semantic entries, up from 329, the one added being this entry. - `@objectstack/lint` patch: the `flow-bare-dollar-reference` hint now asks the judge, so for a `$` root the engine does not bind it prescribes the judge's remedy, not a refused hole (`d9a5ebbb6f`). ## Re-sync after PR #22215 (head `52c005e2b1`) - `origin/main` `e148ca9842` merged in patch round 1 (`b3482cb096`), and `origin/main` `4e9fe9ff6a` (PR #22215) merged in the re-sync (`6e65fb1747`). Both went through `os-regen-merge.sh`, and neither merge commit adds content of its own. - At `52c005e2b1`: spec `local` 630 files / 18,810, spec `repo` 54 / 915; lint 131 / 5,983; service-automation and the three packages' typechecks exit 0. 94 derived gate families run, 0 not measured. - The lint-hint ablation: restoring the old static hint turns exactly the `$User.Id` pin and the mixed-slot pin red, and the `$error.message` control stays green. ## Tests, round 0 (head `f0b39b02de`) - `packages/spec` `flow-text-slot-template.test.ts`: 26 passed. It holds the card's pins at the judge and at the three node contracts (`NotifyConfigSchema` with a bare string and with a template envelope, `ScreenConfigSchema`, `EndConfigSchema`; each refused at the key with `code: custom`), plus the controls. - `packages/services/service-automation` `text-slot-template.test.ts`: 18 passed. It holds the `registerFlow` pin, the parity scan, and a run that renders `{{ $flowName }} / {{ $flowLabel }} / {{ $record.name }}` as `roots / roots / Acme Corp`. - `packages/lint` `validate-expressions.text-slot.test.ts`: 6 passed. It holds `os validate`'s own sequence (normalize, parse, `runAuthoringRules('validate')`): `By {{ $User.Id }}` in a notify `message` and a screen `title` is one `error` finding carrying the remedy. The control `{{ $error.message }}` / `{{ record.name }}` gives `[]`. - Package suites and typechecks: every run below is on head `f0b39b02de` and went through `os-verify-lock.sh`: - `@objectstack/spec` `vitest run --project local`: 630 files, 18805 passed, 1 todo; - `@objectstack/spec` `vitest run --project repo` (the corpus walk over other packages' sources): 54 files, 915 passed; - `@objectstack/service-automation` `test`: 179 files, 2197 passed; - `@objectstack/lint` `vitest run`: 130 files, 5944 passed; - `typecheck` (`tsc --noEmit` plus `check:test-typecheck`) is green on spec, service-automation and lint. ## Ablation (one-shot, nothing left in the tree) Every leg ran through `scripts/ablation-replace.mjs` (anchor must hit, blob hash proven, restore trap armed). The fix was committed first. Service-automation and lint resolve `@objectstack/spec` through `dist/`, so spec was rebuilt inside each leg, and `scripts/ablation-dist-preflight.mjs` proved the mutation reached `dist/`. - **Leg A**, the hole refusal removed. `[singleBraceRefusal(text), unboundRootHoleRefusal(text)]` became `[singleBraceRefusal(text)]`: anchor 1 → 0, blob `b022c9d7bcda` → `1d140ae58225`, marker absent from all 98 built files. - spec: **7 failed** / 19 passed (every hole pin and every schema pin); - service-automation: **2 failed** / 16 passed (the parity control and `registerFlow`); - lint: **1 failed** / 5 passed (the validate door). - **Leg B**, `'$loopIndex'` deleted from the list: anchor 1 → 0, blob → `8679f7dab66e`, `"$loopIndex"` absent from dist. - spec: **1 failed** (engine-bound roots admitted); - service-automation: **1 failed**, with the message `$loopIndex, bound in builtin/loop-node.ts`. This is the parity pin firing; - lint: 6 passed (not its subject). - **Restore**: each leg's blob equals HEAD `b022c9d7bcda` and `git diff HEAD` is empty. After a full spec rebuild, the preflight in default mode finds both markers back in `dist/`, `git status --porcelain` is empty before and after the build, and the three files show 26 / 18 / 6 passed. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived **88** families from this diff: the dispatch list plus 6 the test edits added (`check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt`, `check:where-matcher`). The derivation was re-run on a throwaway tree at `origin/main` `35ef501e13` with this diff applied, and it printed the identical 88. `--ran` reconciliation: 88 accounted for, **87 run with exit 0**, 0 unrun, 1 NOT MEASURED. Among the 87: `check:api-surface`, `check:export-origins`, `check:authorable-surface`, `check:docs`, `check-adr-0087-registration` ("1 non-breaking changeset"), `check-changeset-no-major`, `check-empty-changeset`, `check:published-files`, `check:nul-bytes`, `check:doc-authoring` and `check:cross-package-test-inputs`. - NOT MEASURED: `pnpm check:dual-build-cjs-loads`. Reason: PREREQUISITE NOT MET (exit 3). The gate reads every package's built `dist/`, and 36 packages are unbuilt in this worktree. This diff changes no build config, no `exports` and no entry point. Left to CI. ## Acceptance notes - ~~The `flow-bare-dollar-reference` hint prescribed a refused hole for a bare `$User.Id`.~~ Fixed in patch round 1 (`d9a5ebbb6f`). - The published skill `skills/objectstack-automation` on `main` says "`{{ $User.Id }}` renders blank: assign them to a variable first". Once this lands, the parenthetical is outdated: the hole is refused at `os validate` / `registerFlow` / the node contract. The instruction itself is still right. `skills/**` is governed and outside this card. Carrier: the skills seat. - `content/docs/automation/flows.mdx`'s "you wrote / write instead" table could gain a `{{ $User.Id }}` row. Nothing on the page is made false by this change: it already says holes read "the engine-set `$`-named ones". Carrier: none. - `try_catch`'s `errorVariable` / `outputVariable` still accept a `$`-named variable that a text slot now refuses to read: filed as #22502. --- _Generated by [Claude Code](https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent db9cf80 commit 3073b72

11 files changed

Lines changed: 632 additions & 23 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': patch
4+
---
5+
6+
A `{{ }}` hole in a flow text slot (a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`) whose root is a `$` name the flow engine does not bind is refused. It is refused at the same doors, and by the same judge (`textSlotTemplateRefusal`), as a single-brace token: the node contracts, `registerFlow` and `objectstack validate`. `'By {{ $User.Id }}'` used to pass all three and send `'By '`.
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: registered flow-text-slot-unbound-dollar-root-refused -->
11+
12+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.
13+
14+
**Why.** The hole grammar admits `$` in a name so that the engine's own variables have a spelling (`{{ $error.message }}`). That also makes `{{ $User.Id }}` a well-formed hole, but over a root no flow variable answers to. The text went out with the fragment missing, the run reported success, and nothing warned. Its single-brace spelling, `{$User.Id}`, was already refused with a remedy. The `$` names are reserved for the engine: a resume signal may not write one.
15+
16+
**What is refused.**
17+
18+
- A hole whose root is a `$` name other than the variables the engine binds: `$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex`.
19+
- `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` raise a `custom` issue at the slot's key.
20+
- `registerFlow` refuses the flow, and a stored flow carrying such a hole is skipped at boot with a warn naming it.
21+
- `objectstack validate` reports `expression-invalid` at `error`.
22+
- The remedy for `{{ $User.<path> }}` is the sentence `{$User.<path>}` gets: compute the value into a variable with an `assignment` node, whose value slot still reads that spelling, then write the variable as a hole. Any other root is named in the refusal, beside the variables the engine does bind.
23+
- A single-brace path token over such a root (`'Failed: {$caught.message}'`) is no longer prescribed the `{{ }}` spelling, which would be refused in turn; it gets the same remedy.
24+
25+
**Unchanged.** `{{ $error.message }}`, `{{ record.name }}`, a node output `{{ lookup.result }}` and every hole over an engine-bound `$` variable. The template engine binds no new variable.
26+
27+
**`@objectstack/lint`.** In a text slot, `flow-bare-dollar-reference` prescribes the hole for a bare `$X.y` written outside the holes only when the judge admits that hole. A bare `$User.Id` gets the judge's refusal and remedy instead of a `{{ $User.Id }}` the judge refuses.
28+
29+
## FROM → TO
30+
31+
| you wrote | write instead |
32+
|:--|:--|
33+
| `message: 'By {{ $User.Id }}'` | an `assignment` node first, `assignments: { by: '{$User.Id}' }`, then `message: 'By {{ by }}'` |
34+
| `errorVariable: '$caught'` with `message: 'Failed: {{ $caught.message }}'` | `errorVariable: 'caught'` with `'Failed: {{ caught.message }}'`, or keep the default `$error` and write `{{ $error.message }}` |
35+
36+
**The one-line fix: compute a run-user value into a variable first, and name a variable the flow binds itself without the `$`.**
37+
38+
**Who is affected, measured.** The last published spec, `@objectstack/spec@17.7.0` (npm `latest`), has no text-slot judge. Its `NotifyConfigSchema.title` / `.message`, `ScreenConfigSchema.title` / `.description` and `EndConfigSchema.message` are plain strings, so it accepts `'By {{ $User.Id }}'` in every one of these slots. Its single-brace interpolator substituted the inner `{ $User.Id }` token and left a literal brace on each side. This repository was measured with `git grep` over `examples`, `packages`, `skills`, `apps` and `content`: no flow text slot outside tests carries a `{{ $… }}` hole other than `{{ $error.… }}`. Deployed metadata and other repositories were not measured.
39+
40+
### The kit
41+
42+
- **The refusal.** `textSlotTemplateRefusal` in `automation/flow-text-slot-template.ts` reads one package-internal list of the `$` variables the engine binds. `@objectstack/service-automation`'s `text-slot-template.test.ts` scans that package's sources for every `$` variable they bind by name, and fails when the list misses one.
43+
- **The ledger.** The D3 semantic entry `flow-text-slot-unbound-dollar-root-refused` (protocol 18). There is no D2 conversion: what the hole was meant to read is not in the flow.

‎docs/protocol-upgrade-guide.md‎

Lines changed: 4 additions & 1 deletion
Large diffs are not rendered by default.

‎packages/lint/src/lint-flow-patterns.test.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { TimeRelativeTriggerSchema, LoopConfigSchema, ParallelConfigSchema, TryCatchConfigSchema, HttpConfigSchema, FlowSchema, NotifyConfigSchema } from '@objectstack/spec/automation';
4+
import { TimeRelativeTriggerSchema, LoopConfigSchema, ParallelConfigSchema, TryCatchConfigSchema, HttpConfigSchema, FlowSchema, NotifyConfigSchema, textSlotTemplateRefusal } from '@objectstack/spec/automation';
55
import { TYPED_EXPRESSION_DIALECT_ONLY, TYPED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared';
66
// [#5659] The shared identity reduction, asserted beside the rule that consumes
77
// it — the rule's verdict and the drivers' verdict are one object now.
@@ -2533,6 +2533,34 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule
25332533
expect(fnds[0].message).toContain('notify title');
25342534
expect(fnds[0].hint).toContain('{{ $error.message }}');
25352535
});
2536+
2537+
// [#22477] The hole a bare `$User.Id` would become is refused by the
2538+
// spec's text-slot judge (the engine binds no `$User`), so the hint gives
2539+
// the judge's own remedy — asked of the judge, not re-listed here.
2540+
it('gives the judge\'s remedy, not a refused hole, for a bare `$User.Id`', () => {
2541+
const fnds = notifyText('Closed by $User.Id');
2542+
expect(fnds.map((f) => f.rule)).toEqual([FLOW_BARE_DOLLAR_REF]);
2543+
const refusal = textSlotTemplateRefusal('{{ $User.Id }}');
2544+
expect(refusal).toBeDefined();
2545+
expect(fnds[0].hint).toContain(refusal!);
2546+
expect(fnds[0].hint).toContain("assignments: { v: '{$User.Id}' }");
2547+
expect(fnds[0].hint).not.toContain('{{ $User.Id }}');
2548+
});
2549+
2550+
it('control: a bare engine-bound `$error.message` keeps the hole prescription and no remedy', () => {
2551+
const fnds = notifyText('Failed: $error.message');
2552+
expect(fnds.map((f) => f.rule)).toEqual([FLOW_BARE_DOLLAR_REF]);
2553+
expect(fnds[0].hint).toContain('`{{ $error.message }}`');
2554+
expect(fnds[0].hint).not.toContain('assignments:');
2555+
});
2556+
2557+
it('answers each bare reference on its own when one slot carries both kinds', () => {
2558+
const fnds = notifyText('Failed: $error.message, closed by $User.Id');
2559+
expect(fnds).toHaveLength(1);
2560+
expect(fnds[0].hint).toContain('`{{ $error.message }}`');
2561+
expect(fnds[0].hint).toContain(textSlotTemplateRefusal('{{ $User.Id }}')!);
2562+
expect(fnds[0].hint).not.toContain('{{ $User.Id }}');
2563+
});
25362564
});
25372565
});
25382566

‎packages/lint/src/lint-flow-patterns.ts‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ import {
159159
collectFlowGraphs,
160160
FLOW_NODE_TEXT_SLOTS,
161161
flowNodeTextSlotSources,
162+
textSlotTemplateRefusal,
162163
} from '@objectstack/spec/automation';
163164
import type { FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automation';
164165
// [#15429] The decision's `mode` contract, parsed here so `os validate` and
@@ -669,6 +670,33 @@ const DOUBLE_BRACE = /\{\{\s*[\w$][\w$.\s]*\}\}/;
669670
// A `$Ident.field` not immediately inside a `{` (so `{$User.Id}` is NOT flagged).
670671
// Require a letter/_ after `$` so currency like `$5.00` is never matched.
671672
const BARE_DOLLAR_REF = /(?:^|[^{])\$[A-Za-z_]\w*\.[A-Za-z_]/;
673+
// Every such reference, whole (`$error.message`), at the same anchor — what a
674+
// text slot's hint names, one hole or one remedy per reference.
675+
const BARE_DOLLAR_REFS = /(?:^|[^{])(\$[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+)/g;
676+
677+
/**
678+
* [#22477] The hint for bare `$name.path` references in a text slot's text
679+
* outside its holes. A reference whose hole the spec's text-slot judge admits
680+
* is prescribed that hole; one whose `$` root the flow engine does not bind
681+
* (`$User.Id`) would be refused as a hole too, so it gets the judge's own
682+
* refusal and remedy instead. The judge is ASKED, never re-listed here: which
683+
* `$` roots the engine binds is answered in one place
684+
* (`flow-text-slot-template.ts`), and a second list would drift from it.
685+
*/
686+
function textSlotBareDollarHint(outsideHoles: string): string {
687+
const refs = [...new Set([...outsideHoles.matchAll(BARE_DOLLAR_REFS)].map((m) => m[1]!))];
688+
const holes: string[] = [];
689+
const refusals: string[] = [];
690+
for (const ref of refs) {
691+
const refusal = textSlotTemplateRefusal(`{{ ${ref} }}`);
692+
if (refusal === undefined) holes.push(`\`{{ ${ref} }}\``);
693+
else refusals.push(`\`${ref}\` has no hole either: ${refusal}`);
694+
}
695+
const parts = ['A text slot renders only `{{ }}` holes, and all other text is literal.'];
696+
if (holes.length > 0) parts.push(`Write it as a hole: ${holes.join(', ')}.`);
697+
parts.push(...refusals);
698+
return parts.join(' ');
699+
}
672700

673701
/** Config keys whose string values are CEL predicates, not interpolated templates. */
674702
const CEL_KEYS = new Set(['condition', 'expression', 'conditions']);
@@ -1759,16 +1787,16 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] {
17591787
}
17601788
}
17611789
// [#22110] The text slots' own bare-`$` check: read OUTSIDE their
1762-
// `{{ }}` holes, where a `$name.path` is the hole's correct content.
1790+
// `{{ }}` holes, where a `$name.path` is the hole's correct content —
1791+
// [#22477] when the engine binds its root; otherwise the hint carries
1792+
// the judge's remedy, never a hole the judge refuses.
17631793
for (const slot of flowNodeTextSlotSources(String(node.type), node.config)) {
17641794
const outsideHoles = slot.source.replace(/\{\{[^}]*\}\}/g, '');
17651795
if (BARE_DOLLAR_REF.test(outsideHoles)) {
17661796
findings.push({
17671797
where: nodeWhere,
17681798
message: `\`${slot.source.trim().slice(0, 80)}\` looks like a reference written as a literal — a bare \`$ref.field\` in the ${slot.label} is NOT rendered.`,
1769-
hint:
1770-
`Write it as a hole: \`{{ $ref.field }}\` (e.g. \`{{ $error.message }}\`) — a text slot renders only ` +
1771-
`\`{{ }}\` holes, and all other text is literal.`,
1799+
hint: textSlotBareDollarHint(outsideHoles),
17721800
rule: FLOW_BARE_DOLLAR_REF,
17731801
});
17741802
}

‎packages/lint/src/validate-expressions.text-slot.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
* 2. a slot with none is compiled as a `template` — a hole holding logic or an
1717
* unknown formatter is an `error` too.
1818
*
19+
* And #22477: a `{{ }}` hole whose root is a `$` name the flow engine does not
20+
* bind (`{{ $User.Id }}`, which rendered a blank fragment) is refused by the
21+
* same judge — same door, same finding — with the remedy `{$User.Id}` gets.
22+
*
1923
* Every other notify / screen string keeps the single-brace dialect and gets
2024
* nothing here. The `end` message is refused one door earlier, at the flow
2125
* parse (`EndConfigSchema`), so it is pinned on `validateStackExpressions`
@@ -117,6 +121,22 @@ describe('`objectstack validate` — a flow text slot reads `{{ }}` holes (#2211
117121
expect(validate('screen', { objectName: 'deal', mode: 'edit', recordId: '{record.id}', title: 'Edit {{ record.name }}' })).toEqual([]);
118122
});
119123

124+
it('refuses `By {{ $User.Id }}` in a text slot at `error`, with the remedy `{$User.Id}` gets (#22477)', () => {
125+
for (const [nodeType, config, where] of [
126+
['notify', { recipients: ['u1'], title: 'Closed', message: 'By {{ $User.Id }}' }, 'notify message at config.message'],
127+
['screen', { waitForInput: true, title: 'By {{ $User.Id }}' }, 'screen title at config.title'],
128+
] as const) {
129+
const findings = validate(nodeType, config);
130+
expect(findings, JSON.stringify(config)).toHaveLength(1);
131+
expect(findings[0]!.severity).toBe('error');
132+
expect(findings[0]!.where).toContain(where);
133+
expect(findings[0]!.message).toContain("assignments: { v: '{$User.Id}' }");
134+
expect(findings[0]!.message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(false);
135+
}
136+
// Control: the engine-bound `$error` and an ordinary hole stay clean at the same door.
137+
expect(validate('notify', { recipients: ['u1'], title: 'Deal {{ record.name }}', message: 'Failed: {{ $error.message }}' })).toEqual([]);
138+
});
139+
120140
it('judges an `end` message too, for a stack handed to `validateStackExpressions` with no parse in front of it', () => {
121141
const issues = validateStackExpressions(stackWith('end', { outcome: 'refused', message: 'No: {record.name}' }) as never)
122142
.filter((i) => i.where.includes("node 'w'"));

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

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,17 @@
1212
* `Date` rendered JSON-quoted, a whole-slot object `[object Object]`), which
1313
* the renderer pins below hold.
1414
*
15-
* The card's three pins are the first describe block.
15+
* The card's three pins are the first describe block. #22477's — a hole over
16+
* a `$` root the engine does not bind is refused, and the spec judge's list of
17+
* the roots it does bind misses none of them — are the last.
1618
*/
1719

20+
import { readFileSync, readdirSync } from 'node:fs';
21+
import { dirname, join } from 'node:path';
22+
import { fileURLToPath } from 'node:url';
1823
import { describe, expect, it } from 'vitest';
1924
import type { AutomationContext } from '@objectstack/spec/contracts';
20-
import { TEXT_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation';
25+
import { TEXT_SLOT_TEMPLATE_REFUSAL, textSlotTemplateRefusal } from '@objectstack/spec/automation';
2126

2227
import { AutomationEngine } from '../engine.js';
2328
import { InMemorySuspendedRunStore } from '../suspended-run-store.js';
@@ -262,3 +267,70 @@ describe('#22110 — renderTextSlot, the one text renderer', () => {
262267
expect(renderTextSlot('Hello {record.name}', vars({ record: ACME }))).toBe('Hello {record.name}');
263268
});
264269
});
270+
271+
/** This package's `src` — the runtime whose `$` variables the spec judge lists. */
272+
const SRC = join(dirname(fileURLToPath(import.meta.url)), '..');
273+
274+
/** A `$`-named variable bound by its literal name: `variables.set('$error', …)`. */
275+
const DOLLAR_BINDING = /\.set\(\s*(['"`])(\$[A-Za-z_][\w$]*)\1/g;
276+
277+
/** Every `$`-named variable this package's runtime sources bind by literal name, with the file binding it. */
278+
function engineBoundDollarVariables(): Map<string, string> {
279+
const out = new Map<string, string>();
280+
const walk = (dir: string) => {
281+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
282+
const path = join(dir, entry.name);
283+
if (entry.isDirectory()) walk(path);
284+
else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) {
285+
for (const match of readFileSync(path, 'utf8').matchAll(DOLLAR_BINDING)) {
286+
if (!out.has(match[2]!)) out.set(match[2]!, path.slice(SRC.length + 1));
287+
}
288+
}
289+
}
290+
};
291+
walk(SRC);
292+
return out;
293+
}
294+
295+
describe('#22477 — a text-slot hole may root only at a `$` variable the engine binds', () => {
296+
const bound = engineBoundDollarVariables();
297+
298+
// A `$`-named variable this runtime starts binding must be admitted by the
299+
// spec's one list (`FLOW_ENGINE_VARIABLES` in `@objectstack/spec`'s
300+
// `flow-text-slot-template.ts`), or a text slot could not name it — add it
301+
// THERE. And one it stops binding must leave that list too, or a hole over
302+
// it would be admitted and render blank: that is what the floor below is
303+
// for — it fails on a removal, so delete the name from both places.
304+
it('the scan is not vacuous: it finds every `$` variable bound today', () => {
305+
expect([...bound.keys()].sort()).toEqual(
306+
expect.arrayContaining(['$error', '$flowLabel', '$flowName', '$loopIndex', '$loopItems', '$record', '$runId']),
307+
);
308+
});
309+
310+
it('the spec judge admits a hole over every `$` variable this runtime binds — its list misses none', () => {
311+
for (const [name, file] of bound) {
312+
expect(textSlotTemplateRefusal(`{{ ${name} }}`), `${name}, bound in ${file}`).toBeUndefined();
313+
}
314+
// Control: the judge is not admitting every `$` hole.
315+
expect(bound.has('$User')).toBe(false);
316+
expect(textSlotTemplateRefusal('{{ $User.Id }}')).toBeDefined();
317+
});
318+
319+
it('an admitted root renders its value — `$flowName`, `$flowLabel`, `$record` are bound for every run', async () => {
320+
const { engine, emitted } = harness();
321+
engine.registerFlow('roots', notifyFlow('roots', { title: '{{ $flowName }} / {{ $flowLabel }} / {{ $record.name }}' }) as never);
322+
const result = await engine.execute('roots', ctx());
323+
expect(result.success, JSON.stringify(result)).toBe(true);
324+
expect(emitted[0]!.payload).toMatchObject({ title: 'roots / roots / Acme Corp' });
325+
});
326+
327+
it('registerFlow refuses `By {{ $User.Id }}` in a text slot with the remedy `{$User.Id}` gets — it would render `By `', () => {
328+
const { engine } = harness();
329+
const refusal = registrationRefusal(engine, 'by_user', notifyFlow('by_user', { title: 'Closed', message: 'By {{ $User.Id }}' }));
330+
expect(refusal).toBeDefined();
331+
expect(refusal).toContain("node 'notify' (notify) notify message at config.message");
332+
expect(refusal).toContain("assignments: { v: '{$User.Id}' }");
333+
// The renderer it no longer reaches: the hole resolves to nothing.
334+
expect(renderTextSlot('By {{ $User.Id }}', new Map([['userId', 'usr_7']]))).toBe('By ');
335+
});
336+
});

0 commit comments

Comments
 (0)