|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * @module flow-template-grammar |
| 5 | + * |
| 6 | + * **Which `{…}` dialect owns a whole-string token in a FLOW node's filter, and |
| 7 | + * which spellings neither dialect can resolve** (#16096). |
| 8 | + * |
| 9 | + * A filter value position inside a flow node is the one place two `{…}` |
| 10 | + * vocabularies meet, and `interpolateFilter` |
| 11 | + * (`@objectstack/service-automation`, `src/builtin/template.ts`, #3810) is the |
| 12 | + * function that arbitrates them. Its own header states the split: |
| 13 | + * |
| 14 | + * > A whole-string token that (a) no flow variable resolves and (b) IS a |
| 15 | + * > recognised filter placeholder is passed through **verbatim** for the engine |
| 16 | + * > to expand. That is a transfer of ownership, not a lenient fallback. |
| 17 | + * |
| 18 | + * So a token in this position falls in exactly one of three classes: |
| 19 | + * |
| 20 | + * | class | resolved by | example | judged here? | |
| 21 | + * |---|---|---|---| |
| 22 | + * | flow template dialect | the automation template evaluator, BEFORE the query | `{TODAY() - 45}`, `{record.id}`, `{$User.Id}`, `{round(x)}` | ⛔ no | |
| 23 | + * | filter placeholder dialect | ObjectQL, after hand-off (`isKnownFilterToken`) | `{current_user_id}`, `{30_days_ago}` | ⛔ no — `filter-token-unknown` owns it | |
| 24 | + * | **neither** | nothing — the run fails or the condition collapses | `{TOMORROW()}`, `{ROUND(x)}` | ✅ the third class, and only it | |
| 25 | + * |
| 26 | + * ## Why only the CALL-POSITION half of the third class is decidable here |
| 27 | + * |
| 28 | + * The flow dialect's vocabulary is closed in three of its four arms and OPEN in |
| 29 | + * the fourth: |
| 30 | + * |
| 31 | + * - `NOW()` / `TODAY()` with an optional `± N` day offset — closed, two names. |
| 32 | + * - `$User.<path>` — closed prefix. |
| 33 | + * - `round` / `floor` / `ceil` / `abs` / `min` / `max` in CALL position — |
| 34 | + * closed by maintainer ruling on #11060 ("exactly … every name and semantic |
| 35 | + * mirrored **1:1 from the CEL stdlib**, ⛔ no second semantics invented"). |
| 36 | + * - a bare or dotted identifier (`{recordId}`, `{record.id}`, `{status}`) — |
| 37 | + * **OPEN**: it addresses the run's `VariableMap`, which holds the flow's |
| 38 | + * declared variables, every node's `outputVariable`, and — via |
| 39 | + * `seedRunVariables` — the trigger record's own fields flattened to top |
| 40 | + * level. None of that is decidable from authored metadata alone, and a flow |
| 41 | + * bound to an object another package defines cannot be resolved here at all. |
| 42 | + * |
| 43 | + * That asymmetry is the whole reason this module reports the call-position arm |
| 44 | + * and nothing else. Measured on this repo's own examples, judging the OPEN arm |
| 45 | + * against the ObjectQL vocabulary — the shape #16096 calls "the obvious fix" — |
| 46 | + * reports **7 findings at `error`, all 7 false positives** (`{recordId}` ×3, |
| 47 | + * `{record.id}` ×3, `{currentTask.id}` ×1, across app-todo / app-crm / |
| 48 | + * app-showcase). Every one is a legitimate flow variable that resolves at run |
| 49 | + * time. A reference set that reds working sweeps is worse than the silence |
| 50 | + * #16096 reports, so the open arm stays unjudged and says so. |
| 51 | + * |
| 52 | + * ## Dispatch ORDER is load-bearing, not incidental |
| 53 | + * |
| 54 | + * `resolveToken` tries the date-function form BEFORE it scans for call |
| 55 | + * positions. `{TODAY() - 45}` therefore never reaches the scan — which is the |
| 56 | + * only reason the legitimate spelling stays silent, because `TODAY` sitting in |
| 57 | + * front of a `(` is otherwise indistinguishable from `TOMORROW`. This module |
| 58 | + * mirrors that order exactly and `flow-template-grammar.test.ts` pins the |
| 59 | + * negative control against it. |
| 60 | + * |
| 61 | + * ## This is a MIRROR, and the drift is pinned |
| 62 | + * |
| 63 | + * `@objectstack/lint` depends on `@objectstack/spec` and never on a runtime |
| 64 | + * (its own package description), so the dialect cannot be imported from the |
| 65 | + * package that owns it. The five regexes and the function table below are |
| 66 | + * therefore copied, and `flow-template-grammar.test.ts` reads |
| 67 | + * `packages/services/service-automation/src/builtin/template.ts` from disk and |
| 68 | + * fails when any of them stops matching the original — a cross-package test |
| 69 | + * input already declared on `@objectstack/lint#test` in `turbo.json`, so the |
| 70 | + * graph can see it. ⛔ Do not "simplify" a regex here: it is not this module's |
| 71 | + * to choose, and an equivalent-looking rewrite breaks the pin that keeps the |
| 72 | + * two readers honest. |
| 73 | + */ |
| 74 | + |
| 75 | +/** |
| 76 | + * The two whole-token date functions, with their `± N day` offset grammar. |
| 77 | + * Verbatim from `resolveToken`'s `dateFnMatch`. |
| 78 | + */ |
| 79 | +export const DATE_FUNCTION_RE = /^(NOW|TODAY)\s*\(\s*\)\s*(?:([+\-])\s*(\S+))?$/; |
| 80 | + |
| 81 | +/** Direct variable / dotted-path lookup, numeric segments included (#1872). */ |
| 82 | +export const VARIABLE_PATH_RE = /^[A-Za-z_$][\w$]*(?:\.(?:[A-Za-z_$][\w$]*|\d+))*$/; |
| 83 | + |
| 84 | +/** |
| 85 | + * The character set `resolveToken` will attempt arithmetic on. A token outside |
| 86 | + * it resolves to `undefined` without ever reaching the call-position scan. |
| 87 | + */ |
| 88 | +export const SAFE_EXPRESSION_RE = /^[\w\s+\-*/%().,?:<>=!&|"'$]+$/; |
| 89 | + |
| 90 | +/** Identifier / dotted-identifier occurrences inside a mixed expression. */ |
| 91 | +export const IDENTIFIER_SCAN_RE = /([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g; |
| 92 | + |
| 93 | +/** An identifier is in CALL position when a `(` follows it. */ |
| 94 | +export const CALL_POSITION_RE = /^\s*\(/; |
| 95 | + |
| 96 | +/** Literals `resolveToken` never substitutes, checked BEFORE call position. */ |
| 97 | +const RESERVED_LITERALS: ReadonlySet<string> = new Set(['true', 'false', 'null', 'undefined']); |
| 98 | + |
| 99 | +/** The two names legal only as a whole token (`{TODAY() + 7}`), never in a call. */ |
| 100 | +export const FLOW_TEMPLATE_DATE_FUNCTIONS: readonly string[] = ['NOW', 'TODAY']; |
| 101 | + |
| 102 | +/** |
| 103 | + * The value-expression function table — the CEL stdlib's numeric six, by the |
| 104 | + * #11060 ruling. Mirrors `EXPRESSION_FUNCTION_ARITY`'s key set. |
| 105 | + */ |
| 106 | +export const FLOW_TEMPLATE_VALUE_FUNCTIONS: readonly string[] = [ |
| 107 | + 'round', 'floor', 'ceil', 'abs', 'min', 'max', |
| 108 | +]; |
| 109 | + |
| 110 | +const VALUE_FUNCTION_SET: ReadonlySet<string> = new Set(FLOW_TEMPLATE_VALUE_FUNCTIONS); |
| 111 | + |
| 112 | +/** What the flow template dialect does with one whole-string `{…}` token. */ |
| 113 | +export type FlowTemplateTokenVerdict = |
| 114 | + /** `{NOW()}` / `{TODAY() - 45}` — the evaluator resolves it. Legitimate. */ |
| 115 | + | { kind: 'date-function'; name: string } |
| 116 | + /** `{$User.Id}` — the evaluator resolves it from the run context. */ |
| 117 | + | { kind: 'user-context' } |
| 118 | + /** |
| 119 | + * `{recordId}` / `{record.id}` — a `VariableMap` lookup, and the position |
| 120 | + * from which an unresolved name is handed to the filter dialect. OPEN: not |
| 121 | + * decidable from authored metadata, so never a finding. |
| 122 | + */ |
| 123 | + | { kind: 'variable-path'; head: string } |
| 124 | + /** |
| 125 | + * A call to a name in NEITHER table. `resolveToken` throws |
| 126 | + * `FlowExpressionFunctionError` here (a guard refusal — a `fault` edge must |
| 127 | + * not swallow it), so the node cannot run. THIS is the finding. |
| 128 | + */ |
| 129 | + | { kind: 'unknown-function'; name: string } |
| 130 | + /** |
| 131 | + * Anything else — junk shapes (`{30 days ago}`) and arithmetic over names |
| 132 | + * this module cannot resolve. `resolveToken` answers `undefined` and the |
| 133 | + * CRUD collapse guard (#3810) reports it at run time. Open, not judged. |
| 134 | + */ |
| 135 | + | { kind: 'unresolvable-shape' }; |
| 136 | + |
| 137 | +/** |
| 138 | + * Classify the INSIDE of one whole-string `{…}` filter token — `inner` is the |
| 139 | + * text between the braces, exactly as authored. |
| 140 | + * |
| 141 | + * Mirrors `resolveToken`'s dispatch order (see the module header). Holds no |
| 142 | + * severity and knows nothing about where the token was found. |
| 143 | + */ |
| 144 | +export function classifyFlowTemplateToken(inner: string): FlowTemplateTokenVerdict { |
| 145 | + const trimmed = inner.trim(); |
| 146 | + if (!trimmed) return { kind: 'unresolvable-shape' }; |
| 147 | + |
| 148 | + // 1. Whole-token date functions, BEFORE any call-position reasoning. |
| 149 | + const dateMatch = DATE_FUNCTION_RE.exec(trimmed); |
| 150 | + if (dateMatch) return { kind: 'date-function', name: dateMatch[1] }; |
| 151 | + |
| 152 | + // 2. `$User.*` shortcuts. |
| 153 | + if (trimmed.startsWith('$User.')) return { kind: 'user-context' }; |
| 154 | + |
| 155 | + // 3. Direct variable / dotted path — the open arm. |
| 156 | + if (VARIABLE_PATH_RE.test(trimmed)) { |
| 157 | + return { kind: 'variable-path', head: trimmed.split('.')[0] }; |
| 158 | + } |
| 159 | + |
| 160 | + // 4. Outside the arithmetic character set: `undefined`, no throw. |
| 161 | + if (!SAFE_EXPRESSION_RE.test(trimmed)) return { kind: 'unresolvable-shape' }; |
| 162 | + |
| 163 | + // 5. The call-position scan. `resolveToken` throws on the FIRST unknown name |
| 164 | + // it reaches, so the first is what an author sees and what is reported. |
| 165 | + for (const match of trimmed.matchAll(IDENTIFIER_SCAN_RE)) { |
| 166 | + const name = match[0]; |
| 167 | + if (RESERVED_LITERALS.has(name)) continue; |
| 168 | + const rest = trimmed.slice((match.index ?? 0) + name.length); |
| 169 | + if (!CALL_POSITION_RE.test(rest)) continue; |
| 170 | + if (VALUE_FUNCTION_SET.has(name)) continue; |
| 171 | + return { kind: 'unknown-function', name }; |
| 172 | + } |
| 173 | + |
| 174 | + return { kind: 'unresolvable-shape' }; |
| 175 | +} |
0 commit comments