@@ -188,13 +188,40 @@ export type FieldCelType = 'string' | 'bool' | 'dyn';
188188const UNSOUND_OVERLOAD_RE = / n o s u c h o v e r l o a d : \s * ( [ \w . ] + ) \s * ( < = | > = | < | > | \+ | - | \* | \/ | % ) \s * ( [ \w . ] + ) / ;
189189
190190/**
191- * A `record`-typed environment where each field carries a concrete CEL type
192- * (`string`/`bool`) or `dyn`. Member access (`record.<field>`) then resolves to
193- * the field's type, so cel-js's checker faults an arithmetic/ordering operator
194- * applied across incompatible types. Built per call — cheap, and only used at
195- * build time.
191+ * A typed environment for the soundness check. Each field carries a concrete
192+ * CEL type (`string`/`bool`) or `dyn`, so cel-js's checker faults an
193+ * arithmetic/ordering operator applied across incompatible types. The `scope`
194+ * mirrors how the authoring site binds fields:
195+ * - `'record'` → `record.<field>` member access, via a typed struct on the
196+ * `record`/`previous`/`input` namespaces (formula fields,
197+ * validations, action/hook/sharing predicates).
198+ * - `'flattened'` → bare `<field>` top-level variables (flow / automation
199+ * conditions). Unlisted identifiers stay `dyn`
200+ * (`unlistedVariablesAreDyn: true`) so a flow variable never
201+ * faults — only a typed field misused does.
202+ * Built per call — cheap, and only used at build time.
196203 */
197- function buildTypedRecordEnv ( fieldCelTypes : Readonly < Record < string , FieldCelType > > ) : Environment {
204+ function buildTypedEnv (
205+ fieldCelTypes : Readonly < Record < string , FieldCelType > > ,
206+ scope : 'record' | 'flattened' ,
207+ ) : Environment {
208+ if ( scope === 'flattened' ) {
209+ const env = new Environment ( {
210+ unlistedVariablesAreDyn : true ,
211+ enableOptionalTypes : true ,
212+ limits : DEFAULT_LIMITS ,
213+ } ) ;
214+ registerStdLib ( env , ( ) => new Date ( 0 ) ) ;
215+ for ( const root of SCOPE_ROOTS ) {
216+ try { env . registerVariable ( root , 'map' ) ; } catch { /* duplicate — ignore */ }
217+ }
218+ // Fields are bound bare at top level; a name that collides with a root
219+ // (unlikely) is skipped by the duplicate guard.
220+ for ( const [ name , t ] of Object . entries ( fieldCelTypes ) ) {
221+ try { env . registerVariable ( name , t ) ; } catch { /* duplicate / reserved — ignore */ }
222+ }
223+ return env ;
224+ }
198225 const env = new Environment ( {
199226 unlistedVariablesAreDyn : false ,
200227 enableOptionalTypes : true ,
@@ -216,19 +243,26 @@ function buildTypedRecordEnv(fieldCelTypes: Readonly<Record<string, FieldCelType
216243}
217244
218245/**
219- * The first `record.<field>` (or `previous.`/`input.`) reference in `source`
220- * whose declared CEL type matches `celType` — best-effort attribution of an
221- * overload fault to the offending field. Returns `null` if none is found.
246+ * The first field reference in `source` whose declared CEL type matches
247+ * `celType` — best-effort attribution of an overload fault to the offending
248+ * field. In `'record'` scope it looks for `record.<field>` (or `previous.`/
249+ * `input.`); in `'flattened'` scope for a bare `<field>` not preceded by a dot.
250+ * Returns `null` if none is found.
222251 */
223252function offendingField (
224253 source : string ,
225254 fieldCelTypes : Readonly < Record < string , FieldCelType > > ,
226255 celType : FieldCelType ,
256+ scope : 'record' | 'flattened' ,
227257) : string | null {
228258 for ( const [ name , t ] of Object . entries ( fieldCelTypes ) ) {
229259 if ( t !== celType ) continue ;
230- // Word-bounded so `amount` does not match `amount_total`.
231- if ( new RegExp ( `(?:record|previous|input)\\.${ name } (?![\\w$])` ) . test ( source ) ) return name ;
260+ // Word-bounded so `amount` does not match `amount_total`; in flattened
261+ // scope the leading lookbehind excludes a member ref like `previous.amount`.
262+ const re = scope === 'flattened'
263+ ? new RegExp ( `(?<![\\w$.])${ name } (?![\\w$])` )
264+ : new RegExp ( `(?:record|previous|input)\\.${ name } (?![\\w$])` ) ;
265+ if ( re . test ( source ) ) return name ;
232266 }
233267 return null ;
234268}
@@ -251,16 +285,21 @@ function offendingField(
251285 *
252286 * Returns the operand types, the faulting operator, the concrete operand CEL
253287 * type, and (best-effort) the offending field — or `null` when type-sound.
288+ *
289+ * `scope` selects how fields are bound: `'record'` (default) for
290+ * `record.<field>` sites; `'flattened'` for bare-field flow/automation
291+ * conditions.
254292 */
255293export function firstTypeMismatch (
256294 source : string ,
257295 fieldCelTypes : Readonly < Record < string , FieldCelType > > ,
296+ scope : 'record' | 'flattened' = 'record' ,
258297) : { operator : string ; operands : string ; celType : FieldCelType ; field : string | null } | null {
259298 if ( typeof source !== 'string' || ! source . trim ( ) ) return null ;
260299 // An all-`dyn` record can never fault an overload — skip the parse entirely.
261300 if ( ! Object . values ( fieldCelTypes ) . some ( ( t ) => t === 'string' || t === 'bool' ) ) return null ;
262301 try {
263- const env = buildTypedRecordEnv ( fieldCelTypes ) ;
302+ const env = buildTypedEnv ( fieldCelTypes , scope ) ;
264303 const result = env . parse ( source ) . check ?.( ) as
265304 | { valid ?: boolean ; error ?: { message ?: string } }
266305 | undefined ;
@@ -277,7 +316,7 @@ export function firstTypeMismatch(
277316 operator,
278317 operands : `${ m [ 1 ] } ${ operator } ${ m [ 3 ] } ` ,
279318 celType,
280- field : offendingField ( source , fieldCelTypes , celType ) ,
319+ field : offendingField ( source , fieldCelTypes , celType , scope ) ,
281320 } ;
282321 } catch {
283322 // A parse/other fault is the syntax checker's job (celEngine.compile); this
0 commit comments