@barefootjs/jsx 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/adapters/env-signal.d.ts +73 -15
  2. package/dist/adapters/env-signal.d.ts.map +1 -1
  3. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  4. package/dist/adapters/parsed-expr-emitter.d.ts +7 -6
  5. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  6. package/dist/analyzer-context.d.ts +29 -1
  7. package/dist/analyzer-context.d.ts.map +1 -1
  8. package/dist/analyzer.d.ts.map +1 -1
  9. package/dist/builtin-lowering-plugins.d.ts +34 -0
  10. package/dist/builtin-lowering-plugins.d.ts.map +1 -0
  11. package/dist/compiler.d.ts.map +1 -1
  12. package/dist/expression-parser.d.ts +264 -163
  13. package/dist/expression-parser.d.ts.map +1 -1
  14. package/dist/index.d.ts +10 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +7839 -7019
  17. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/plan/build-declaration-emit.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +9 -0
  20. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  21. package/dist/jsx-to-ir.d.ts.map +1 -1
  22. package/dist/lowering-registry.d.ts +122 -0
  23. package/dist/lowering-registry.d.ts.map +1 -0
  24. package/dist/query-href-lowering.d.ts +63 -0
  25. package/dist/query-href-lowering.d.ts.map +1 -0
  26. package/dist/ssr-defaults.d.ts.map +1 -1
  27. package/dist/ssr-seed-plan.d.ts +84 -0
  28. package/dist/ssr-seed-plan.d.ts.map +1 -0
  29. package/dist/types.d.ts +180 -11
  30. package/dist/types.d.ts.map +1 -1
  31. package/package.json +2 -2
  32. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +68 -3
  33. package/src/__tests__/analyzer.test.ts +53 -0
  34. package/src/__tests__/expression-parser.test.ts +714 -392
  35. package/src/__tests__/free-identifiers.test.ts +55 -0
  36. package/src/__tests__/ir-reduce-op.test.ts +18 -21
  37. package/src/__tests__/ir-sort-comparator.test.ts +19 -20
  38. package/src/__tests__/lowering-registry.test.ts +141 -0
  39. package/src/__tests__/materialize-getter-calls.test.ts +58 -0
  40. package/src/__tests__/primitive-resolver-alias.test.ts +23 -0
  41. package/src/__tests__/query-href-recognition.test.ts +58 -0
  42. package/src/__tests__/serialize-parsed-expr.test.ts +223 -0
  43. package/src/__tests__/ssr-seed-plan.test.ts +212 -0
  44. package/src/__tests__/unsupported-expression.test.ts +98 -4
  45. package/src/adapters/env-signal.ts +108 -21
  46. package/src/adapters/jsx-adapter.ts +17 -0
  47. package/src/adapters/parsed-expr-emitter.ts +39 -41
  48. package/src/analyzer-context.ts +72 -27
  49. package/src/analyzer.ts +226 -9
  50. package/src/builtin-lowering-plugins.ts +54 -0
  51. package/src/compiler.ts +6 -1
  52. package/src/expression-parser.ts +1375 -929
  53. package/src/index.ts +31 -3
  54. package/src/ir-to-client-js/csr-substitute.ts +5 -0
  55. package/src/ir-to-client-js/plan/build-declaration-emit.ts +16 -0
  56. package/src/ir-to-client-js/plan/declaration-emit.ts +9 -0
  57. package/src/ir-to-client-js/stringify/declaration-emit.ts +11 -0
  58. package/src/jsx-to-ir.ts +182 -43
  59. package/src/lowering-registry.ts +160 -0
  60. package/src/query-href-lowering.ts +147 -0
  61. package/src/ssr-defaults.ts +5 -1
  62. package/src/ssr-seed-plan.ts +146 -0
  63. package/src/types.ts +182 -12
  64. package/src/__tests__/flatmap-support.test.ts +0 -218
  65. package/src/__tests__/reduce-op.test.ts +0 -201
@@ -14,7 +14,16 @@ import ts from 'typescript'
14
14
 
15
15
  export type ParsedExpr =
16
16
  | { kind: 'identifier'; name: string }
17
- | { kind: 'literal'; value: string | number | boolean | null; literalType: 'string' | 'number' | 'boolean' | 'null' }
17
+ // `raw` is the numeric literal's `ts.NumericLiteral.text` the token TS
18
+ // itself normalises (separators stripped, radix / exponent folded to
19
+ // decimal: `1_000`/`0x10`/`1e3` → `1000`/`16`/`1000`). It is NOT the
20
+ // verbatim source spelling. Its value is that it equals the exact string an
21
+ // adapter's literal lowering already emits, so a structured lowering matches
22
+ // byte-for-byte — which the lossy `parseFloat` `value` can't guarantee (e.g.
23
+ // `parseFloat('1_000')` is 1, and large integers lose precision). Only
24
+ // populated for numeric literals; string / boolean / null carry their
25
+ // canonical form in `value`.
26
+ | { kind: 'literal'; value: string | number | boolean | null; literalType: 'string' | 'number' | 'boolean' | 'null'; raw?: string }
18
27
  | { kind: 'call'; callee: ParsedExpr; args: ParsedExpr[] }
19
28
  | { kind: 'member'; object: ParsedExpr; property: string; computed: boolean }
20
29
  // Element access with a NON-literal index (`selected()[index]`,
@@ -30,9 +39,34 @@ export type ParsedExpr =
30
39
  | { kind: 'conditional'; test: ParsedExpr; consequent: ParsedExpr; alternate: ParsedExpr }
31
40
  | { kind: 'logical'; op: '&&' | '||' | '??'; left: ParsedExpr; right: ParsedExpr }
32
41
  | { kind: 'template-literal'; parts: TemplatePart[] }
33
- | { kind: 'arrow-fn'; param: string; body: ParsedExpr }
34
- | { kind: 'higher-order'; method: 'filter' | 'every' | 'some' | 'find' | 'findIndex' | 'findLast' | 'findLastIndex'; object: ParsedExpr; param: string; predicate: ParsedExpr }
42
+ // Expression-bodied arrow (`(a, b) => …`), multi-parameter. A
43
+ // single-`return` block body is normalised to its returned expression;
44
+ // a single object-binding-pattern param (`({done}) => done`) is rewritten
45
+ // to a synthetic identifier param with dotted-access body. Higher-order
46
+ // callbacks (`.filter`/`.sort`/`.reduce`/`.flatMap`) arrive as a generic
47
+ // `call` whose argument is this kind; the adapter serializes `body` to the
48
+ // runtime evaluator (#2018). Block bodies with locals / multiple statements,
49
+ // and array-binding-pattern params, stay `unsupported`.
50
+ | { kind: 'arrow'; params: string[]; body: ParsedExpr }
51
+ // A regex literal carried as its exact source text (`/\/+$/`), so the Go
52
+ // ctor lowering matches the one trailing-slash-strip pattern it recognises
53
+ // (`String.replace`). Outside that narrow surface a regex resolves to
54
+ // `unsupported`.
55
+ | { kind: 'regex'; raw: string }
35
56
  | { kind: 'array-literal'; elements: ParsedExpr[] }
57
+ // Object literal `{ a: 1, b: x }` / shorthand `{ a }`. Carried so an
58
+ // adapter that lowers an object *value* (Go `map[string]interface{}`,
59
+ // Perl hashref) can emit from structure instead of re-parsing the
60
+ // source with `ts.createSourceFile`. Only produced for plain literals:
61
+ // every property is a non-computed `key: value` or shorthand `{ key }`.
62
+ // Spreads, computed keys, methods, and getters/setters fall through to
63
+ // `unsupported` (unchanged). `raw` is the original expression string —
64
+ // the same value the old `unsupported` fallback carried — so an adapter
65
+ // that does not yet consume `properties` stays byte-identical by
66
+ // emitting it exactly as it emits `unsupported`. Extending the type
67
+ // adds a TS compile error in every exhaustive `ParsedExpr` switch, the
68
+ // same drift defence used for `array-literal` / `array-method`.
69
+ | { kind: 'object-literal'; properties: ObjectLiteralProperty[]; raw: string }
36
70
  // Non-higher-order array methods. Discriminated by `method` so each
37
71
  // adapter handles the full set via one exhaustive switch instead of
38
72
  // sprinkling per-method branches across the call / member emitters.
@@ -65,39 +99,6 @@ export type ParsedExpr =
65
99
  object: ParsedExpr
66
100
  args: ParsedExpr[]
67
101
  }
68
- // `.sort(cmp)` / `.toSorted(cmp)` (#1448 Tier B). The comparator is
69
- // extracted into a structured `SortComparator` at parse time — the
70
- // arrow function never reaches `args`, so adapters don't have to
71
- // re-walk the arrow-fn ParsedExpr to recover the key / direction
72
- // (and the same shape feeds both standalone position and the
73
- // `.sort().map()` chained-loop hoist in `jsx-to-ir.ts`). If the
74
- // comparator doesn't match the supported catalogue
75
- // (`extractSortComparatorFromTS` below), parsing falls
76
- // through to `unsupported` so adapters surface BF101 with an
77
- // @client suggestion.
78
- | {
79
- kind: 'array-method'
80
- method: 'sort' | 'toSorted'
81
- object: ParsedExpr
82
- args: []
83
- comparator: SortComparator
84
- }
85
- // `.reduce(fn, init)` (#1448 Tier C). Like sort, the reducer is
86
- // extracted into a structured `ReduceOp` at parse time — the
87
- // two-param arrow never reaches `args`, so adapters fold via a
88
- // runtime helper instead of re-walking the callback. The accepted
89
- // catalogue is the arithmetic-fold family only (`acc + key` /
90
- // `acc * key`, numeric or string-concat); any other reducer body,
91
- // or a missing initial value, falls through to `unsupported` so
92
- // adapters surface BF101 with an @client suggestion. See
93
- // `extractReduceOpFromTS` below.
94
- | {
95
- kind: 'array-method'
96
- method: 'reduce' | 'reduceRight'
97
- object: ParsedExpr
98
- args: []
99
- reduceOp: ReduceOp
100
- }
101
102
  // `.flat(depth?)` (#1448 Tier C). The flatten depth is validated and
102
103
  // normalised into a structured `FlatDepth` at parse time — the literal
103
104
  // never reaches `args`, so adapters fold via a runtime helper instead of
@@ -111,22 +112,29 @@ export type ParsedExpr =
111
112
  args: []
112
113
  flatDepth: FlatDepth
113
114
  }
114
- // `.flatMap(fn)` value-returning field projection (#1448 Tier C). The
115
- // callback is extracted into a structured `FlatMapOp` (self / field
116
- // projection) at parse time, mirroring sort / reduce. The projected
117
- // per-item value is flattened one level (flatMap = map + flat(1)).
118
- // Array-literal / complex callbacks fall through to `unsupported`; the
119
- // JSX-returning `.flatMap` is handled as an `IRLoop` upstream and never
120
- // reaches here. See `extractFlatMapOpFromTS` below.
121
- | {
122
- kind: 'array-method'
123
- method: 'flatMap'
124
- object: ParsedExpr
125
- args: []
126
- flatMapOp: FlatMapOp
127
- }
128
115
  | { kind: 'unsupported'; raw: string; reason: string }
129
116
 
117
+ /**
118
+ * One property of an `object-literal` `ParsedExpr`. The key is the
119
+ * resolved (non-computed) property name — for `{ a: 1 }` and shorthand
120
+ * `{ a }` it is `a`; for `{ 'a-b': 1 }` it is `a-b`. Computed keys
121
+ * (`{ [k]: 1 }`) are not represented; such literals fall through to
122
+ * `unsupported` at parse time.
123
+ */
124
+ export type ObjectLiteralProperty = {
125
+ key: string
126
+ // The syntactic kind of the key, since `key` normalises all three to a
127
+ // string and so loses the distinction. A consumer that must treat a numeric
128
+ // key (`{ 1: 'a' }`) differently from a same-text string key (`{ '1': 'a' }`)
129
+ // reads this; most consumers ignore it. `identifier` for shorthand.
130
+ keyKind?: 'identifier' | 'string' | 'numeric'
131
+ // Shorthand `{ a }` (the value is the identifier `a`) vs explicit
132
+ // `{ a: <value> }`. The `value` already carries the resolved tree
133
+ // either way; this flag is kept for re-stringification fidelity.
134
+ shorthand: boolean
135
+ value: ParsedExpr
136
+ }
137
+
130
138
  /**
131
139
  * One comparison key inside a sort comparator. A simple
132
140
  * `(a, b) => a.f - b.f` produces a single key; a multi-key
@@ -152,83 +160,19 @@ export type SortKey = {
152
160
  }
153
161
 
154
162
  /**
155
- * Structured form of a JS `(a, b) => …` sort comparator. Built once
156
- * at parse time and consumed by both adapters' arrayMethod emit and
157
- * (when chained directly before `.map()`) the loop-hoist path in
158
- * `jsx-to-ir.ts`. The shape is intentionally finite — see
159
- * `extractSortComparatorFromTS` for the accepted catalogue.
163
+ * Structured form of a JS `(a, b) => …` sort comparator. Recovered from
164
+ * the generic `arrow` callback body by {@link sortComparatorFromArrow} as
165
+ * the LEGACY fallback for a comparator the runtime evaluator can't model
166
+ * (`localeCompare` string sorts `serializeParsedExpr` refuses them).
167
+ * Consumed by the adapters' `bf_sort` / `bf->sort` emit. The shape is
168
+ * intentionally finite — see {@link sortComparatorFromArrow} for the
169
+ * accepted catalogue.
160
170
  */
161
171
  export type SortComparator = {
162
172
  // Comparison keys in priority order. A simple comparator has one
163
173
  // key; a `||`-chained multi-key comparator has one per operand.
164
174
  // Always length >= 1.
165
175
  keys: SortKey[]
166
- // Original JS source of the comparator body; preserved so `@client`
167
- // fallback can re-emit the user's exact expression if the call site
168
- // ever gets relocated to the runtime. For block-body comparators
169
- // this is the returned expression, not the `{ … }` block — so the
170
- // client fallback's synthetic `(a, b) => raw` arrow stays valid.
171
- raw: string
172
- // The two parameter names the user wrote (e.g. `a`/`b`, or
173
- // `lhs`/`rhs`). Only consumed by the client-side `@client`
174
- // fallback path that ships the raw comparator body to JS — it
175
- // needs to bind these names in a closure so `raw` evaluates
176
- // against the right operands. Server-side lowering doesn't read
177
- // them.
178
- paramA: string
179
- paramB: string
180
- // Which JS method name the user wrote — both shapes share the same
181
- // lowering (templates render a snapshot, so the JS mutate vs new
182
- // distinction is moot) but we preserve the original for source maps
183
- // and error messages.
184
- method: 'sort' | 'toSorted'
185
- }
186
-
187
- /**
188
- * Structured form of a JS `.reduce((acc, item) => …, init)` call,
189
- * built once at parse time and consumed by both template adapters'
190
- * `reduceMethod()` emit (#1448 Tier C). The shape is intentionally
191
- * finite — only the arithmetic-fold family is lowerable in a
192
- * declarative template; arbitrary accumulator bodies are not. See
193
- * `extractReduceOpFromTS` for the accepted catalogue.
194
- */
195
- export type ReduceOp = {
196
- // The fold operator between accumulator and per-item value. `+`
197
- // covers numeric sums and (with a string init) string concatenation;
198
- // `*` covers numeric products. Subtraction / division are excluded —
199
- // they're order-sensitive and rarely written as a `.reduce`.
200
- op: '+' | '*'
201
- // What value each item contributes to the fold:
202
- // { kind: 'self' } → `acc + item` (primitive array)
203
- // { kind: 'field', field } → `acc + item.field` (struct-field accessor)
204
- key: { kind: 'self' } | { kind: 'field'; field: string }
205
- // Numeric fold vs string concatenation. Determined by the init
206
- // literal's type: a number init folds numerically; a string init
207
- // (only valid with `+`) concatenates. Both template runtimes apply
208
- // the same coercion so their output stays byte-equal; this can
209
- // diverge from JS for floating-point sums whose decimal expansion
210
- // differs by runtime (rare in SSR data — integer sums agree).
211
- type: 'numeric' | 'string'
212
- // Decoded initial-accumulator value (never raw source). For a numeric
213
- // fold this is TypeScript's canonical decimal form (`1_000` -> `1000`,
214
- // `0x10` -> `16`) so `strconv.ParseFloat` / Perl agree; for a concat
215
- // fold it's the contents of a quoted string literal (only single- or
216
- // double-quoted `ts.StringLiteral` seeds are accepted — template
217
- // literals and escape-carrying literals are refused at parse time, so
218
- // the value is an escape-free single-line string, e.g. the empty
219
- // string "" or a separator like ", "). Round-trip emitters re-quote a
220
- // string init via `JSON.stringify`; a numeric init re-emits as-is.
221
- init: string
222
- // Original JS source of the reducer body (the returned expression
223
- // for block bodies). Lets the `@client` fallback ship the user's
224
- // exact arrow to the JS runtime.
225
- raw: string
226
- // The two parameter names the user wrote (e.g. `acc`/`item`, or
227
- // `sum`/`t`). Only the `@client` fallback reads them — it binds them
228
- // in a synthetic `(acc, item) => raw` arrow. Server-side lowering
229
- // works off `op` / `key` / `init` and ignores them.
230
- paramAcc: string
231
- paramItem: string
232
176
  }
233
177
 
234
178
  /**
@@ -241,38 +185,6 @@ export type ReduceOp = {
241
185
  */
242
186
  export type FlatDepth = number | 'infinity'
243
187
 
244
- /**
245
- * A single non-computed projection leaf on the flatMap callback param —
246
- * the item itself (`i`) or one of its fields (`i.field`). Shared by the
247
- * scalar and tuple `FlatMapOp` projections.
248
- */
249
- export type FlatMapLeaf = { kind: 'self' } | { kind: 'field'; field: string }
250
-
251
- /**
252
- * Structured form of a value-returning `.flatMap(fn)` callback (#1448
253
- * Tier C). The accepted catalogue:
254
- *
255
- * i => i → self projection (flatten one level)
256
- * i => i.field → field projection (flatten a per-item array field)
257
- * i => [i.a, i.b] → tuple projection (gather per-item leaves)
258
- *
259
- * The scalar `self` / `field` projections return a value that is then
260
- * flattened one level (flatMap = map + flat(1)) — a non-array value is
261
- * kept as-is, matching JS. The `tuple` projection returns an array
262
- * literal; flat(1) removes only that literal's wrapper, so each leaf is
263
- * appended verbatim (an array-valued leaf is NOT spread). Leaves outside
264
- * self / field (literals, `i.a + 1`, calls, deep access) refuse with
265
- * BF101. See `extractFlatMapOpFromTS`.
266
- */
267
- export type FlatMapOp = {
268
- // What each item projects to before the one-level flatten.
269
- projection: FlatMapLeaf | { kind: 'tuple'; elements: FlatMapLeaf[] }
270
- // The callback param name the user wrote (for the `@client` round-trip).
271
- param: string
272
- // Original JS source of the callback body (for the `@client` fallback).
273
- raw: string
274
- }
275
-
276
188
  export type TemplatePart =
277
189
  | { type: 'string'; value: string }
278
190
  | { type: 'expression'; expr: ParsedExpr }
@@ -335,8 +247,12 @@ export interface SupportResult {
335
247
  const UNSUPPORTED_METHODS = new Set([
336
248
  // Higher-order array methods. Seven of these (`filter`, `every`,
337
249
  // `some`, `find`, `findIndex`, `findLast`, `findLastIndex`) are
338
- // intercepted as `higher-order` IR before reaching this gate;
339
- // `map` is intercepted as an IRLoop. `reduce` / `reduceRight` stay
250
+ // intercepted as `higher-order` IR before reaching this gate.
251
+ // `map` is intercepted as an IRLoop when its callback returns JSX,
252
+ // and as a `CALLBACK_METHODS` evaluator lowering (`map_eval`, #2073)
253
+ // when it returns a value — it stays listed here so the fall-throughs
254
+ // (a bare `arr.map` reference, a function-reference callback) still
255
+ // refuse loudly. `reduce` / `reduceRight` stay
340
256
  // listed here so the shapes the Tier C catalogue can't lower still
341
257
  // refuse loudly: the `convertNode` call branch intercepts a matching
342
258
  // `.reduce(fn, init)` / `.reduceRight(fn, init)` into the structured
@@ -669,6 +585,73 @@ export function parseExpression(expr: string): ParsedExpr {
669
585
  return convertNode(firstStmt.expression, expr)
670
586
  }
671
587
 
588
+ /**
589
+ * Convert an already-parsed TypeScript node directly into a {@link ParsedExpr},
590
+ * for a consumer that already holds a `ts.Node` (e.g. a `.sort()` callback at
591
+ * the loop-hoist site) and wants the structured conversion without re-parsing
592
+ * source via `ts.createSourceFile`. An `unsupported` result still carries the
593
+ * node's text in `raw` for debugging (a synthetic node with no source file
594
+ * falls back to '').
595
+ */
596
+ export function tsNodeToParsedExpr(node: ts.Node): ParsedExpr {
597
+ let raw = ''
598
+ try {
599
+ raw = node.getText()
600
+ } catch {
601
+ /* synthetic node without a source file */
602
+ }
603
+ return convertNode(node, raw)
604
+ }
605
+
606
+ /**
607
+ * Higher-order array methods whose callback body the runtime evaluator drives
608
+ * (#2018). Recognised generically as a `call` whose callee is `<recv>.<method>`
609
+ * and whose first argument is an `arrow`; the adapter serializes the arrow body
610
+ * to the evaluator. A JSX-returning `.map` / `.flatMap` is an IRLoop upstream
611
+ * and never reaches this recognition; the value-returning `.map(cb)` form
612
+ * (e.g. `tags.map(t => \`#${t}\`).join(' ')`) lowers via `map_eval` (#2073).
613
+ */
614
+ export const CALLBACK_METHODS: ReadonlySet<string> = new Set([
615
+ 'filter', 'map', 'every', 'some', 'find', 'findIndex', 'findLast', 'findLastIndex',
616
+ 'sort', 'toSorted', 'reduce', 'reduceRight', 'flatMap',
617
+ ])
618
+
619
+ /**
620
+ * A recognised higher-order callback method call: `<object>.<method>(<arrow>,
621
+ * …rest)` where `method ∈` {@link CALLBACK_METHODS} and the first argument is a
622
+ * generic `arrow`. Returns the receiver, the callback arrow, and any trailing
623
+ * args (e.g. the `.reduce` init), or `null` if the shape doesn't match. The
624
+ * single recognition point shared by the support gate and the adapter dispatch.
625
+ */
626
+ export function asCallbackMethodCall(expr: ParsedExpr): {
627
+ method: string
628
+ object: ParsedExpr
629
+ arrow: Extract<ParsedExpr, { kind: 'arrow' }>
630
+ args: ParsedExpr[]
631
+ } | null {
632
+ if (expr.kind !== 'call') return null
633
+ if (expr.callee.kind !== 'member' || expr.callee.computed) return null
634
+ if (!CALLBACK_METHODS.has(expr.callee.property)) return null
635
+ const arrow = expr.args[0]
636
+ if (!arrow || arrow.kind !== 'arrow') return null
637
+ return { method: expr.callee.property, object: expr.callee.object, arrow, args: expr.args.slice(1) }
638
+ }
639
+
640
+ /**
641
+ * Resolve a non-computed object-literal property key to its string name.
642
+ * Identifier / string / numeric names resolve to their text; a computed
643
+ * (`[expr]`) or otherwise non-plain key returns null so the caller treats
644
+ * the whole literal as `unsupported`.
645
+ */
646
+ function objectLiteralKeyName(
647
+ name: ts.PropertyName,
648
+ ): { key: string; keyKind: 'identifier' | 'string' | 'numeric' } | null {
649
+ if (ts.isIdentifier(name)) return { key: name.text, keyKind: 'identifier' }
650
+ if (ts.isStringLiteral(name)) return { key: name.text, keyKind: 'string' }
651
+ if (ts.isNumericLiteral(name)) return { key: name.text, keyKind: 'numeric' }
652
+ return null
653
+ }
654
+
672
655
  /**
673
656
  * Convert a TypeScript AST node to ParsedExpr.
674
657
  */
@@ -683,10 +666,13 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
683
666
  return { kind: 'literal', value: node.text, literalType: 'string' }
684
667
  }
685
668
 
686
- // Numeric literal: 0, 5, 3.14
669
+ // Numeric literal: 0, 5, 3.14. Keep `ts.NumericLiteral.text` in `raw` (TS's
670
+ // normalised token — `1_000`/`0x10`/`1e3` → `1000`/`16`/`1000`) so an adapter
671
+ // emits the exact string its own literal lowering already produces; `value`
672
+ // is the parsed number for structural reasoning.
687
673
  if (ts.isNumericLiteral(node)) {
688
674
  const value = parseFloat(node.text)
689
- return { kind: 'literal', value, literalType: 'number' }
675
+ return { kind: 'literal', value, literalType: 'number', raw: node.text }
690
676
  }
691
677
 
692
678
  // Boolean literals and null
@@ -705,37 +691,26 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
705
691
  const callee = convertNode(node.expression, raw)
706
692
  const args = node.arguments.map(arg => convertNode(arg, raw))
707
693
 
708
- // Detect higher-order methods: arr.filter(x => pred), arr.every(x => pred), arr.some(x => pred)
709
- if (callee.kind === 'member' && ['filter', 'every', 'some', 'find', 'findIndex', 'findLast', 'findLastIndex'].includes(callee.property)) {
710
- if (args.length === 1 && args[0].kind === 'arrow-fn') {
711
- const arrowFn = args[0] as { kind: 'arrow-fn'; param: string; body: ParsedExpr }
712
- return {
713
- kind: 'higher-order',
714
- method: callee.property as 'filter' | 'every' | 'some' | 'find' | 'findIndex' | 'findLast' | 'findLastIndex',
715
- object: callee.object,
716
- param: arrowFn.param,
717
- predicate: arrowFn.body,
718
- }
719
- }
720
- // .filter(Boolean) non-arrow callable with a fixed JS semantic
721
- // (the identity-truthy predicate). Synthesise the equivalent
722
- // `x => x` so adapters can re-use their existing higher-order
723
- // lowering instead of needing a separate callee-resolution path.
724
- // Other identifier-callable predicates would need #1389-style
725
- // user-supplied callee resolution; out of scope here.
726
- if (
727
- callee.property === 'filter' &&
728
- args.length === 1 &&
729
- args[0].kind === 'identifier' &&
730
- args[0].name === 'Boolean'
731
- ) {
732
- return {
733
- kind: 'higher-order',
734
- method: 'filter',
735
- object: callee.object,
736
- param: '_',
737
- predicate: { kind: 'identifier', name: '_' },
738
- }
694
+ // Higher-order callback methods (`.filter`/`.find`/`.every`/`.some`/
695
+ // `.sort`/`.reduce`/`.flatMap`/…) are NOT folded here (#2018 P5): they
696
+ // flow through as a generic `call` whose argument is a generic `arrow`,
697
+ // and the adapter recognises the callback shape at dispatch and serializes
698
+ // the arrow body to the runtime evaluator. The one exception is the
699
+ // non-arrow `.filter(Boolean)` callable: synthesise the equivalent
700
+ // identity arrow `_ => _` so it flows through the same callback lowering
701
+ // (the adapter keeps a dedicated truthiness fallback for the identity body).
702
+ if (
703
+ callee.kind === 'member' &&
704
+ !callee.computed &&
705
+ callee.property === 'filter' &&
706
+ args.length === 1 &&
707
+ args[0].kind === 'identifier' &&
708
+ args[0].name === 'Boolean'
709
+ ) {
710
+ return {
711
+ kind: 'call',
712
+ callee,
713
+ args: [{ kind: 'arrow', params: ['_'], body: { kind: 'identifier', name: '_' } }],
739
714
  }
740
715
  }
741
716
 
@@ -931,9 +906,12 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
931
906
  // replacing the FIRST occurrence (JS semantics for a string
932
907
  // pattern). Go uses `bf_replace` (`strings.Replace` with n=1);
933
908
  // Mojo uses `bf->replace` (index/substr splice, no regex). A
934
- // regex-literal pattern parses as `unsupported` (convertNode has
935
- // no regex arm), so it's refused explicitly here rather than
936
- // emitting a broken `.Replace` the Perl `s///` vs Go
909
+ // regex-literal pattern is the deferred form — its `regex` first
910
+ // arg is carried STRUCTURALLY (not collapsed to `unsupported`) so
911
+ // the Go ctor lowering can recover the one trailing-slash pattern
912
+ // it supports without re-parsing (#2039). Template use stays
913
+ // refused: `isSupported` maps a regex-pattern `.replace` to the
914
+ // deferred-form BF101 reason — the Perl `s///` vs Go
937
915
  // `regexp.ReplaceAllString` flavour gap is the open design
938
916
  // question in #1448. `replaceAll` stays refused entirely.
939
917
  //
@@ -951,28 +929,30 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
951
929
  }
952
930
  }
953
931
  // A regex-literal pattern is the deferred form (the Perl `s///`
954
- // vs Go `regexp.ReplaceAllString` flavour gap, #1448) detect it
955
- // on the TS node so the message is accurate. Any OTHER unsupported
956
- // pattern/replacement (an object literal, an unsupported call, …)
957
- // surfaces ITS OWN reason rather than being mislabelled as the
958
- // regex form.
932
+ // vs Go `regexp.ReplaceAllString` flavour gap, #1448). Its shape
933
+ // is carried structurally `args[0]` is a `regex` node (convertNode
934
+ // line ~1127) so a consumer that recognises one fixed pattern (the
935
+ // Go ctor's `/\/+$/` trailing-slash strip `strings.TrimRight`) reads
936
+ // it from the tree instead of re-parsing the raw (#2039). Returned
937
+ // before the object-literal `badArg` check below so the regex form
938
+ // keeps its dedicated diagnostic precedence; `isSupported` then refuses
939
+ // any template use with the deferred-form reason.
959
940
  const patternNode = node.arguments[0]
960
941
  if (patternNode && ts.isRegularExpressionLiteral(patternNode)) {
961
- return {
962
- kind: 'unsupported',
963
- raw,
964
- reason:
965
- 'String.prototype.replace supports only a string pattern + string replacement (the regex form is deferred); use a string pattern or wrap the expression in /* @client */',
966
- }
942
+ return { kind: 'array-method', method: 'replace', object: callee.object, args }
967
943
  }
944
+ // Treat an object-literal argument like `unsupported` — a `.replace`
945
+ // with an object pattern/replacement isn't lowerable, same as before
946
+ // the `object-literal` kind existed (byte-identical; Roadmap A-1).
968
947
  const badArg =
969
- args[0].kind === 'unsupported'
948
+ args[0].kind === 'unsupported' || args[0].kind === 'object-literal'
970
949
  ? args[0]
971
- : args[1].kind === 'unsupported'
950
+ : args[1].kind === 'unsupported' || args[1].kind === 'object-literal'
972
951
  ? args[1]
973
952
  : undefined
974
- if (badArg && badArg.kind === 'unsupported') {
975
- return { kind: 'unsupported', raw, reason: badArg.reason }
953
+ if (badArg) {
954
+ const reason = badArg.kind === 'unsupported' ? badArg.reason : 'Unsupported syntax: ObjectLiteralExpression'
955
+ return { kind: 'unsupported', raw, reason }
976
956
  }
977
957
  return { kind: 'array-method', method: 'replace', object: callee.object, args }
978
958
  }
@@ -1004,123 +984,13 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1004
984
  if (callee.property === 'padStart' || callee.property === 'padEnd') {
1005
985
  return { kind: 'array-method', method: callee.property, object: callee.object, args }
1006
986
  }
1007
- // `.sort(cmp)` / `.toSorted(cmp)` (#1448 Tier B). The comparator
1008
- // is extracted into a structured `SortComparator` at parse time;
1009
- // unrecognised shapes fall through to `unsupported` so adapters
1010
- // surface BF101 (with `@client` as the escape hatch). Supported:
1011
- // subtraction / localeCompare / relational-ternary leaves, any
1012
- // of them `||`-chained (multi-key), and single-`return` block
1013
- // bodies. Function-reference comparators and `localeCompare`
1014
- // locale/options args stay out of scope — see #1448 Tier B
1015
- // follow-up.
1016
- if ((callee.property === 'sort' || callee.property === 'toSorted') && node.arguments.length === 1) {
1017
- // Extract from the raw TS AST (not args[0] ParsedExpr) — the
1018
- // standard arrow-fn convertNode path refuses two-param arrows,
1019
- // so the comparator would otherwise reach us as `unsupported`.
1020
- const comparator = extractSortComparatorFromTS(node.arguments[0], callee.property)
1021
- if (comparator) {
1022
- return {
1023
- kind: 'array-method',
1024
- method: callee.property,
1025
- object: callee.object,
1026
- args: [],
1027
- comparator,
1028
- }
1029
- }
1030
- return {
1031
- kind: 'unsupported',
1032
- raw,
1033
- reason:
1034
- `Sort comparator shape not supported. Accepted:\n` +
1035
- ` (a, b) => a - b\n` +
1036
- ` (a, b) => a.field - b.field\n` +
1037
- ` (a, b) => a.localeCompare(b)\n` +
1038
- ` (a, b) => a.field.localeCompare(b.field)\n` +
1039
- ` (a, b) => a.field > b.field ? 1 : -1 (relational ternary)\n` +
1040
- ` any of the above ||-chained for multi-key tie-breaks\n` +
1041
- `(reverse the operands for descending order). ` +
1042
- `Wrap the call in /* @client */ to evaluate at hydration.`,
1043
- }
1044
- }
1045
-
1046
- // `.reduce(fn, init)` / `.reduceRight(fn, init)` (#1448 Tier C).
1047
- // The reducer + init are extracted into a structured `ReduceOp` at
1048
- // parse time; the two-param arrow never reaches the standard
1049
- // convertNode path (which refuses it), so we read the raw TS AST.
1050
- // Only the arithmetic-fold catalogue lowers — anything else, or a
1051
- // missing init, falls through to `unsupported` (BF101 + @client
1052
- // hint). `reduceRight` shares the catalogue; the method name is
1053
- // preserved so adapters fold right-to-left (only observable for
1054
- // string concatenation — numeric sum / product are commutative).
1055
- if (
1056
- (callee.property === 'reduce' || callee.property === 'reduceRight') &&
1057
- node.arguments.length === 2
1058
- ) {
1059
- const reduceOp = extractReduceOpFromTS(node.arguments[0], node.arguments[1])
1060
- if (reduceOp) {
1061
- return {
1062
- kind: 'array-method',
1063
- method: callee.property,
1064
- object: callee.object,
1065
- args: [],
1066
- reduceOp,
1067
- }
1068
- }
1069
- const m = callee.property
1070
- return {
1071
- kind: 'unsupported',
1072
- raw,
1073
- reason:
1074
- `Reduce shape not supported. Accepted (arithmetic fold, explicit init):\n` +
1075
- ` arr.${m}((acc, x) => acc + x, 0)\n` +
1076
- ` arr.${m}((acc, x) => acc + x.field, 0)\n` +
1077
- ` arr.${m}((acc, x) => acc * x.field, 1)\n` +
1078
- ` arr.${m}((acc, x) => acc + x.field, '') (string concat)\n` +
1079
- `The accumulator must be the left operand and the initial ` +
1080
- `value a number / string literal. ` +
1081
- `Wrap the call in /* @client */ to evaluate at hydration.`,
1082
- }
1083
- }
1084
- // A `.reduce(fn)` without an initial value can't be lowered: JS
1085
- // throws on an empty array, which a template can't mirror. Fall
1086
- // through to the BF101 gate with the @client escape hatch.
1087
-
1088
- // `.flatMap(fn)` value-returning projection (#1448 Tier C). The
1089
- // callback is extracted into a structured `FlatMapOp` (self / field
1090
- // scalar projection, or an array-literal tuple of self / field
1091
- // leaves) from the raw TS AST. The JSX-returning form is handled as
1092
- // an `IRLoop` upstream and never reaches here; richer callbacks
1093
- // refuse with BF101 + the @client hint. Go uses `bf_flat_map` /
1094
- // `bf_flat_map_tuple`; Mojo uses `bf->flat_map` / `bf->flat_map_tuple`.
1095
- // Intercept EVERY `.flatMap(...)` call (not just the 1-arg form) so
1096
- // the off-catalogue and wrong-arity shapes get this tailored reason
1097
- // rather than the generic "flatMap has no template lowering" gate
1098
- // message, which now misleads (the field-projection form does lower).
1099
- if (callee.property === 'flatMap') {
1100
- const flatMapOp =
1101
- node.arguments.length === 1 ? extractFlatMapOpFromTS(node.arguments[0]) : null
1102
- if (flatMapOp) {
1103
- return {
1104
- kind: 'array-method',
1105
- method: 'flatMap',
1106
- object: callee.object,
1107
- args: [],
1108
- flatMapOp,
1109
- }
1110
- }
1111
- return {
1112
- kind: 'unsupported',
1113
- raw,
1114
- reason:
1115
- `flatMap shape not supported. Accepted (self / field leaves, no thisArg):\n` +
1116
- ` arr.flatMap(i => i) (flatten one level)\n` +
1117
- ` arr.flatMap(i => i.field) (flatten a per-item array field)\n` +
1118
- ` arr.flatMap(i => [i.a, i.b]) (gather per-item fields)\n` +
1119
- `Richer callbacks (computed / nested access, arithmetic, calls, ` +
1120
- `literal elements) and the 2-arg \`flatMap(fn, thisArg)\` form ` +
1121
- `aren't lowered. Wrap the call in /* @client */ to evaluate at hydration.`,
1122
- }
1123
- }
987
+ // `.sort` / `.toSorted` / `.reduce` / `.reduceRight` / `.flatMap`
988
+ // (callback methods) are NOT folded here (#2018 P5): they fall through
989
+ // to the generic `call` construction below (callee = member, arg =
990
+ // generic `arrow`), and the adapter serializes the arrow body to the
991
+ // runtime evaluator. The localeCompare-sort fallback recovers a
992
+ // structured comparator from the generic arrow via
993
+ // `sortComparatorFromArrow`.
1124
994
  }
1125
995
 
1126
996
  return { kind: 'call', callee, args }
@@ -1132,6 +1002,29 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1132
1002
  return { kind: 'array-literal', elements }
1133
1003
  }
1134
1004
 
1005
+ // Object literal: { a: 1, b: x, shorthand }. Only plain literals are
1006
+ // structured — every property must be a non-computed `key: value` or a
1007
+ // shorthand `{ key }`. Anything else (spread, computed key, method,
1008
+ // getter/setter) falls through to the generic `unsupported` fallback
1009
+ // below, exactly as before this kind existed.
1010
+ if (ts.isObjectLiteralExpression(node)) {
1011
+ const properties: ObjectLiteralProperty[] = []
1012
+ for (const prop of node.properties) {
1013
+ if (ts.isPropertyAssignment(prop)) {
1014
+ const k = objectLiteralKeyName(prop.name)
1015
+ if (k === null) return { kind: 'unsupported', raw, reason: `Unsupported syntax: ${ts.SyntaxKind[node.kind]}` }
1016
+ properties.push({ key: k.key, keyKind: k.keyKind, shorthand: false, value: convertNode(prop.initializer, raw) })
1017
+ } else if (ts.isShorthandPropertyAssignment(prop)) {
1018
+ const key = prop.name.text
1019
+ properties.push({ key, keyKind: 'identifier', shorthand: true, value: { kind: 'identifier', name: key } })
1020
+ } else {
1021
+ // Spread assignment, method, getter/setter — not a plain map.
1022
+ return { kind: 'unsupported', raw, reason: `Unsupported syntax: ${ts.SyntaxKind[node.kind]}` }
1023
+ }
1024
+ }
1025
+ return { kind: 'object-literal', properties, raw }
1026
+ }
1027
+
1135
1028
  // Property access: user.name, items().length
1136
1029
  if (ts.isPropertyAccessExpression(node)) {
1137
1030
  const object = convertNode(node.expression, raw)
@@ -1164,7 +1057,10 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1164
1057
  // (the literal forms above fold into a static property path; this
1165
1058
  // one can't). #1897 (data-table).
1166
1059
  const index = convertNode(argNode, raw)
1167
- if (index.kind === 'unsupported') return index
1060
+ // An object-literal index (`arr[{…}]`) isn't lowerable — surface it
1061
+ // as the whole expression, exactly as an `unsupported` index did
1062
+ // before the kind existed (byte-identical; Roadmap A-1).
1063
+ if (index.kind === 'unsupported' || index.kind === 'object-literal') return index
1168
1064
  return { kind: 'index-access', object, index }
1169
1065
  }
1170
1066
 
@@ -1232,24 +1128,63 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1232
1128
  return { kind: 'literal', value: node.text, literalType: 'string' }
1233
1129
  }
1234
1130
 
1235
- // Arrow function: x => expr / ({field}) => field / (x) => expr
1236
- if (ts.isArrowFunction(node)) {
1237
- // Only support single parameter
1238
- if (node.parameters.length !== 1) {
1239
- return { kind: 'unsupported', raw, reason: 'Only single parameter arrow functions are supported' }
1240
- }
1241
- const param = node.parameters[0]
1242
-
1243
- // Only expression body is supported (not block body). Block bodies
1244
- // route through `parseBlockBody` at the higher-order recognition
1245
- // call site so the adapter's filter-block path handles them
1246
- // separately.
1247
- if (ts.isBlock(node.body)) {
1248
- return { kind: 'unsupported', raw, reason: 'Block body arrow functions are not supported' }
1131
+ // Regex literal: `/\/+$/`. Carried as exact source text for the Go ctor
1132
+ // trailing-slash-strip lowering (`String.replace`).
1133
+ if (ts.isRegularExpressionLiteral(node)) {
1134
+ return { kind: 'regex', raw: node.getText() }
1135
+ }
1136
+
1137
+ // Arrow function / function expression: `x => expr`, `(a, b) => expr`,
1138
+ // `({field}) => field`, `function (x) { return … }`. Produces a generic
1139
+ // multi-parameter `arrow` (#2018 P5). A single-`return` block body
1140
+ // normalises to its returned expression; a single object-binding-pattern
1141
+ // param is rewritten to a synthetic identifier param with dotted-access
1142
+ // body (destructure support). Higher-order callbacks reach the adapter as
1143
+ // a generic `call` whose argument is this kind; the adapter serializes
1144
+ // `body` to the runtime evaluator.
1145
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
1146
+ // Resolve the body expression: an expression-bodied arrow carries it
1147
+ // directly; a block body (arrow `=> { … }` or a function expression)
1148
+ // must reduce to exactly one `return <expr>;`. Multi-statement /
1149
+ // local-var bodies stay refused.
1150
+ let body: ParsedExpr
1151
+ if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) {
1152
+ body = convertNode(node.body, raw)
1153
+ } else {
1154
+ const block = node.body as ts.Block
1155
+ const stmts = block.statements
1156
+ // Fast path: a single `return <expr>` keeps the pre-#2040 conversion
1157
+ // (convertNode on the returned node), byte-identical for the existing
1158
+ // corpus.
1159
+ if (stmts.length === 1 && ts.isReturnStatement(stmts[0]) && stmts[0].expression) {
1160
+ body = convertNode(stmts[0].expression, raw)
1161
+ } else {
1162
+ // General value-producing block: normalize `let`-inline + value `if` /
1163
+ // early `return` into a single expression (#2040). Imperative shapes
1164
+ // (raw `for` / `while`, `break`, mutation, side effects) don't parse
1165
+ // into ParsedStatement, or fall through without a value — either way we
1166
+ // refuse with an actionable reason and adapters surface BF101.
1167
+ let sf: ts.SourceFile | undefined
1168
+ try {
1169
+ sf = block.getSourceFile()
1170
+ } catch {
1171
+ sf = undefined
1172
+ }
1173
+ const parsed = sf
1174
+ ? parseBlockBody(block, sf, n => n.getText(sf))
1175
+ : null
1176
+ if (!parsed) {
1177
+ return { kind: 'unsupported', raw, reason: IMPERATIVE_BLOCK_REASON }
1178
+ }
1179
+ const folded = foldBlockToExpr(parsed)
1180
+ if (!folded.ok) {
1181
+ return { kind: 'unsupported', raw, reason: folded.reason }
1182
+ }
1183
+ body = folded.expr
1184
+ }
1249
1185
  }
1250
- const body = convertNode(node.body, raw)
1251
1186
 
1252
- // Destructured-object param: `({done}) => done` (#1443),
1187
+ // Single object-binding-pattern param: `({done}) => done` (#1443),
1253
1188
  // `({user: {name}}) => name` (#1530), `({done = false}) => done`
1254
1189
  // (#1531), `({done, ...rest}) => rest.priority` (#1532). We
1255
1190
  // synthesise the equivalent dotted-access form so adapters can
@@ -1262,7 +1197,8 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1262
1197
  // shapes refuse with BF021 (#1532). Array binding patterns,
1263
1198
  // nested rest, and defaults at non-leaf (nested-pattern) slots
1264
1199
  // stay unsupported.
1265
- if (ts.isObjectBindingPattern(param.name)) {
1200
+ if (node.parameters.length === 1 && ts.isObjectBindingPattern(node.parameters[0].name)) {
1201
+ const bindingPattern = node.parameters[0].name
1266
1202
  const fieldMap = new Map<string, DestructureBinding>()
1267
1203
  // `excludedTopKeys` mirrors the JS rest-binding exclusion set:
1268
1204
  // the source-object keys explicitly consumed at the top level
@@ -1271,7 +1207,7 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1271
1207
  // keys on local rename / leaf names and would miss
1272
1208
  // `({done: d, ...rest}) => rest.done` or `({user: {name}, ...rest}) => rest.user`.
1273
1209
  const excludedTopKeys = new Set<string>()
1274
- const collect = collectDestructureBindings(param.name, [], fieldMap, raw, excludedTopKeys)
1210
+ const collect = collectDestructureBindings(bindingPattern, [], fieldMap, raw, excludedTopKeys)
1275
1211
  if (!collect.ok) {
1276
1212
  return { kind: 'unsupported', raw, reason: collect.reason }
1277
1213
  }
@@ -1335,37 +1271,20 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1335
1271
  }
1336
1272
  const syntheticParam = pickSyntheticParam(fieldMap, body)
1337
1273
  const rewritten = substituteDestructuredFields(body, fieldMap, syntheticParam, restName)
1338
- return { kind: 'arrow-fn', param: syntheticParam, body: rewritten }
1339
- }
1340
-
1341
- if (!ts.isIdentifier(param.name)) {
1342
- return { kind: 'unsupported', raw, reason: 'Destructuring parameters are not supported' }
1274
+ return { kind: 'arrow', params: [syntheticParam], body: rewritten }
1343
1275
  }
1344
- return { kind: 'arrow-fn', param: param.name.text, body }
1345
- }
1346
1276
 
1347
- // Function expression: `function (x) { return x.done }` (#1443).
1348
- // Normalise the single-param + single-return shape into the arrow
1349
- // form so the higher-order detector at the call site recognises it
1350
- // alongside `(x) => x.done`. Multi-statement / multi-param / nested-
1351
- // destructure shapes stay unsupported.
1352
- if (ts.isFunctionExpression(node)) {
1353
- if (node.parameters.length !== 1) {
1354
- return { kind: 'unsupported', raw, reason: 'Only single-parameter function expressions are supported' }
1355
- }
1356
- const param = node.parameters[0]
1357
- if (!ts.isIdentifier(param.name)) {
1358
- return { kind: 'unsupported', raw, reason: 'Destructured params in function expressions are not supported' }
1359
- }
1360
- const stmts = node.body.statements
1361
- if (stmts.length !== 1 || !ts.isReturnStatement(stmts[0]) || !stmts[0].expression) {
1362
- return { kind: 'unsupported', raw, reason: 'Function expressions must be `function (x) { return <expr> }`' }
1363
- }
1364
- return {
1365
- kind: 'arrow-fn',
1366
- param: param.name.text,
1367
- body: convertNode(stmts[0].expression, raw),
1277
+ // Every (remaining) parameter must be a plain identifier. Multi-param
1278
+ // arrows (sort comparators `(a, b) => …`) and single-param identifier
1279
+ // arrows (`x => …`) both land here.
1280
+ const params: string[] = []
1281
+ for (const p of node.parameters) {
1282
+ if (!ts.isIdentifier(p.name)) {
1283
+ return { kind: 'unsupported', raw, reason: 'Only identifier (or a single object-destructure) function parameters are supported' }
1284
+ }
1285
+ params.push(p.name.text)
1368
1286
  }
1287
+ return { kind: 'arrow', params, body }
1369
1288
  }
1370
1289
 
1371
1290
  // Default: unsupported
@@ -1373,114 +1292,56 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1373
1292
  }
1374
1293
 
1375
1294
  /**
1376
- * Recover a `SortComparator` from the comparator arg of `.sort(cmp)` /
1377
- * `.toSorted(cmp)` (#1448 Tier B). Operates on the raw TS AST rather
1378
- * than the converted ParsedExpr because the standard `convertNode`
1379
- * arrow-fn path rejects two-param arrows (it was built for the
1380
- * single-param higher-order shape `.filter(x => )`); for sort we
1381
- * need both param names to decide direction (`a-b` asc vs `b-a` desc).
1382
- *
1383
- * The accepted catalogue is finite so the walker stays shallow — no
1384
- * constant folding, no symbol resolution, no inference of "this looks
1385
- * like it might sort numerically". Returns null if the shape doesn't
1386
- * match exactly, in which case the caller emits an `unsupported` IR
1387
- * node and adapters surface BF101.
1295
+ * Recover a {@link SortComparator} from a generic `(a, b) => …` sort callback
1296
+ * arrow (#2018 P5). The LEGACY fallback for a comparator the runtime evaluator
1297
+ * can't model (`localeCompare` string sorts `serializeParsedExpr` refuses
1298
+ * them); the adapter calls this only when the eval path returns null. Operates
1299
+ * on the generic `arrow` ParsedExpr (params + body subtree) no `ts` re-parse.
1388
1300
  *
1389
- * Body shapes:
1390
- * - expression-bodied arrow (`(a, b) => …`)
1391
- * - single-`return` block body both arrow (`(a, b) => { return …; }`)
1392
- * and function expression. Multi-statement / local-var bodies stay
1393
- * refused (deferred follow-up).
1301
+ * The accepted catalogue is finite so the walker stays shallow — no constant
1302
+ * folding, no symbol resolution. Returns null if the shape doesn't match
1303
+ * exactly, in which case the adapter surfaces BF101.
1394
1304
  *
1395
- * A body is split on top-level `||` into one leaf per operand, giving
1396
- * a multi-key comparator (`a.x - b.x || a.y - b.y` → sort by x, then y).
1397
- * Accepted leaf shapes (each paired ascending / descending by operand
1398
- * order):
1305
+ * A body is split on top-level `||` into one leaf per operand, giving a
1306
+ * multi-key comparator (`a.x - b.x || a.y - b.y` → sort by x, then y). Accepted
1307
+ * leaf shapes (each paired ascending / descending by operand order):
1399
1308
  *
1400
1309
  * a.field - b.field → field, numeric
1401
1310
  * a - b → self, numeric
1402
1311
  * a.field.localeCompare(b.field) → field, string
1403
1312
  * a.localeCompare(b) → self, string
1404
1313
  * a.field > b.field ? 1 : -1 → field, auto (relational ternary)
1405
- * a.field < b.field ? -1 : 1 → field, auto
1406
1314
  * a < b ? -1 : a > b ? 1 : 0 → self/field, auto (3-way)
1407
1315
  * a === b ? 0 : <relational ternary> → leading-tie 3-way
1408
1316
  *
1409
- * Function-reference comparators and `localeCompare(b, locale, opts)`
1410
- * (the multi-arg form) return null — deferred follow-ups.
1317
+ * Function-reference comparators and `localeCompare(b, locale, opts)` (the
1318
+ * multi-arg form) return null — deferred follow-ups.
1411
1319
  */
1412
- export function extractSortComparatorFromTS(
1413
- node: ts.Node,
1414
- method: 'sort' | 'toSorted',
1415
- ): SortComparator | null {
1416
- if (!ts.isArrowFunction(node) && !ts.isFunctionExpression(node)) return null
1417
- if (node.parameters.length !== 2) return null
1418
-
1419
- const pA = node.parameters[0]
1420
- const pB = node.parameters[1]
1421
- if (!ts.isIdentifier(pA.name) || !ts.isIdentifier(pB.name)) return null
1422
- const paramA = pA.name.text
1423
- const paramB = pB.name.text
1424
-
1425
- // Resolve the comparator body. Expression-bodied arrows carry it
1426
- // directly; block bodies (both arrow `=> { … }` and function
1427
- // expressions) must reduce to exactly one `return <expr>;`. Anything
1428
- // with locals or multiple statements stays refused — a deferred
1429
- // follow-up.
1430
- let body: ts.Expression
1431
- if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) {
1432
- body = node.body
1433
- } else {
1434
- const block = node.body as ts.Block
1435
- const stmts = block.statements
1436
- if (stmts.length !== 1 || !ts.isReturnStatement(stmts[0]) || !stmts[0].expression) return null
1437
- body = stmts[0].expression
1438
- }
1439
-
1440
- // Normalise the comparator body source so consumers of
1441
- // `SortComparator.raw` get the same string regardless of whether
1442
- // the user wrote an arrow expression (`(a, b) => a.x - b.x`) or a
1443
- // block body (`(a, b) => { return a.x - b.x }`). For block bodies
1444
- // this is the returned expression, not the `{ … }` block — so the
1445
- // `@client` fallback's synthetic `(a, b) => raw` arrow stays valid.
1446
- //
1447
- // `body.getText()` resolves against the node's source file via the
1448
- // parent chain — `ts.createSourceFile`-parsed nodes (the only
1449
- // shape this helper accepts) carry that wiring.
1450
- const raw = body.getText()
1451
-
1452
- // A `||`-chain is a multi-key comparator: each operand is an
1453
- // independent leaf applied as the next tie-breaker. A non-`||` body
1454
- // is a single-key comparator (one-element chain).
1320
+ export function sortComparatorFromArrow(arrow: ParsedExpr): SortComparator | null {
1321
+ if (arrow.kind !== 'arrow' || arrow.params.length !== 2) return null
1322
+ const [paramA, paramB] = arrow.params
1323
+
1324
+ // A `||`-chain is a multi-key comparator: each operand is an independent
1325
+ // leaf applied as the next tie-breaker. A non-`||` body is single-key.
1455
1326
  const keys: SortKey[] = []
1456
- for (const operand of flattenLogicalOr(body)) {
1327
+ for (const operand of flattenLogicalOr(arrow.body)) {
1457
1328
  const key = classifyLeafComparator(operand, paramA, paramB)
1458
1329
  if (!key) return null
1459
1330
  keys.push(key)
1460
1331
  }
1461
1332
  if (keys.length === 0) return null
1462
-
1463
- return { keys, raw, paramA, paramB, method }
1464
- }
1465
-
1466
- /** Strip redundant parentheses so the classifiers see the real node. */
1467
- function unwrapParens(expr: ts.Expression): ts.Expression {
1468
- let e = expr
1469
- while (ts.isParenthesizedExpression(e)) e = e.expression
1470
- return e
1333
+ return { keys }
1471
1334
  }
1472
1335
 
1473
1336
  /**
1474
- * Flatten a left-associative top-level `||` chain into its operands.
1475
- * `a || b || c` parses as `((a || b) || c)`; this returns `[a, b, c]`.
1337
+ * Flatten a top-level `||` chain into its operands (`a || b || c` → `[a, b, c]`).
1476
1338
  * A non-`||` expression returns a single-element list.
1477
1339
  */
1478
- function flattenLogicalOr(expr: ts.Expression): ts.Expression[] {
1479
- const inner = unwrapParens(expr)
1480
- if (ts.isBinaryExpression(inner) && inner.operatorToken.kind === ts.SyntaxKind.BarBarToken) {
1481
- return [...flattenLogicalOr(inner.left), ...flattenLogicalOr(inner.right)]
1340
+ function flattenLogicalOr(expr: ParsedExpr): ParsedExpr[] {
1341
+ if (expr.kind === 'logical' && expr.op === '||') {
1342
+ return [...flattenLogicalOr(expr.left), ...flattenLogicalOr(expr.right)]
1482
1343
  }
1483
- return [inner]
1344
+ return [expr]
1484
1345
  }
1485
1346
 
1486
1347
  /**
@@ -1488,60 +1349,40 @@ function flattenLogicalOr(expr: ts.Expression): ts.Expression[] {
1488
1349
  * Accepts subtraction (numeric), `localeCompare` (string), and
1489
1350
  * relational-ternary (auto) shapes; returns null otherwise.
1490
1351
  */
1491
- function classifyLeafComparator(
1492
- expr: ts.Expression,
1493
- paramA: string,
1494
- paramB: string,
1495
- ): SortKey | null {
1496
- const body = unwrapParens(expr)
1497
-
1352
+ function classifyLeafComparator(expr: ParsedExpr, paramA: string, paramB: string): SortKey | null {
1498
1353
  // Subtraction: `a.field - b.field` / `a - b` → numeric.
1499
- if (ts.isBinaryExpression(body) && body.operatorToken.kind === ts.SyntaxKind.MinusToken) {
1500
- return classifyComparatorOperands(body.left, body.right, paramA, paramB, 'numeric')
1354
+ if (expr.kind === 'binary' && expr.op === '-') {
1355
+ return classifyComparatorOperands(expr.left, expr.right, paramA, paramB, 'numeric')
1501
1356
  }
1502
1357
 
1503
- // localeCompare (zero-arg form): `<lhs>.localeCompare(<rhs>)` →
1504
- // string. The locale/options form (2–3 args) stays refused — it
1505
- // needs per-adapter collation plumbing (deferred follow-up).
1358
+ // localeCompare (zero-arg form): `<lhs>.localeCompare(<rhs>)` → string. The
1359
+ // locale/options form (2–3 args) stays refused.
1506
1360
  if (
1507
- ts.isCallExpression(body) &&
1508
- ts.isPropertyAccessExpression(body.expression) &&
1509
- body.expression.name.text === 'localeCompare' &&
1510
- body.arguments.length === 1
1361
+ expr.kind === 'call' &&
1362
+ expr.callee.kind === 'member' &&
1363
+ expr.callee.property === 'localeCompare' &&
1364
+ expr.args.length === 1
1511
1365
  ) {
1512
- return classifyComparatorOperands(
1513
- body.expression.expression, // receiver of .localeCompare
1514
- body.arguments[0],
1515
- paramA,
1516
- paramB,
1517
- 'string',
1518
- )
1366
+ return classifyComparatorOperands(expr.callee.object, expr.args[0], paramA, paramB, 'string')
1519
1367
  }
1520
1368
 
1521
1369
  // Relational-ternary sign comparator → auto.
1522
- if (ts.isConditionalExpression(body)) {
1523
- return classifyTernaryComparator(body, paramA, paramB)
1370
+ if (expr.kind === 'conditional') {
1371
+ return classifyTernaryComparator(expr, paramA, paramB)
1524
1372
  }
1525
1373
 
1526
1374
  return null
1527
1375
  }
1528
1376
 
1529
1377
  /**
1530
- * Classify two operands against the comparator's two param names.
1531
- * Both operands must resolve to either:
1532
- * - the param identifier itself → `key.kind === 'self'`
1533
- * - a single-level field access on the param `key.kind === 'field'`
1534
- * The two operands must reference different params (one paramA, one
1535
- * paramB) and match on key shape + field name. Order of the params
1536
- * determines `direction`: `paramA` first is ascending, reversed is
1537
- * descending.
1538
- *
1539
- * Anything deeper (chained `.x.y`, computed `.[i]`, calls, literals)
1540
- * or mismatched keys returns null.
1378
+ * Classify two operands against the comparator's two param names. Both must
1379
+ * resolve to either the param identifier itself (`self`) or a single-level
1380
+ * field access on it (`field`), reference different params, and match on key
1381
+ * shape + field name. `paramA` first is ascending, reversed is descending.
1541
1382
  */
1542
1383
  function classifyComparatorOperands(
1543
- left: ts.Expression,
1544
- right: ts.Expression,
1384
+ left: ParsedExpr,
1385
+ right: ParsedExpr,
1545
1386
  paramA: string,
1546
1387
  paramB: string,
1547
1388
  type: 'numeric' | 'string',
@@ -1560,44 +1401,35 @@ function classifyComparatorOperands(
1560
1401
 
1561
1402
  /**
1562
1403
  * Classify a relational-ternary comparator leaf into an `auto` SortKey.
1563
- * Handles the 2-way sign form (`a.f > b.f ? 1 : -1`), the canonical
1564
- * 3-way (`a.f < b.f ? -1 : a.f > b.f ? 1 : 0`), and a leading
1565
- * equality tie (`a.f === b.f ? 0 : <relational ternary>`).
1566
- *
1567
- * Direction is derived from (relational op, operand order, sign of the
1568
- * `whenTrue` branch); the `whenFalse` branch only needs to be a bounded
1569
- * shape (sign literal or a nested ternary on the same key) so we don't
1570
- * silently accept arbitrary expressions.
1404
+ * Handles the 2-way sign form (`a.f > b.f ? 1 : -1`), the canonical 3-way
1405
+ * (`a.f < b.f ? -1 : a.f > b.f ? 1 : 0`), and a leading equality tie
1406
+ * (`a.f === b.f ? 0 : <relational ternary>`).
1571
1407
  */
1572
1408
  function classifyTernaryComparator(
1573
- node: ts.ConditionalExpression,
1409
+ node: Extract<ParsedExpr, { kind: 'conditional' }>,
1574
1410
  paramA: string,
1575
1411
  paramB: string,
1576
1412
  ): SortKey | null {
1577
- const cond = unwrapParens(node.condition)
1413
+ const cond = node.test
1578
1414
 
1579
- // Leading equality tie: `a.f === b.f ? 0 : <ternary>`. The equality
1580
- // arm returns 0 (tie); the real ordering lives in the else branch.
1415
+ // Leading equality tie: `a.f === b.f ? 0 : <ternary>`.
1581
1416
  if (
1582
- ts.isBinaryExpression(cond) &&
1583
- (cond.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken ||
1584
- cond.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken) &&
1417
+ cond.kind === 'binary' &&
1418
+ (cond.op === '===' || cond.op === '==') &&
1585
1419
  sameKeyOperands(cond.left, cond.right, paramA, paramB) &&
1586
- numericSign(node.whenTrue) === 0
1420
+ numericSign(node.consequent) === 0
1587
1421
  ) {
1588
- const elseBranch = unwrapParens(node.whenFalse)
1589
- if (ts.isConditionalExpression(elseBranch)) {
1422
+ const elseBranch = node.alternate
1423
+ if (elseBranch.kind === 'conditional') {
1590
1424
  return classifyTernaryComparator(elseBranch, paramA, paramB)
1591
1425
  }
1592
1426
  return null
1593
1427
  }
1594
1428
 
1595
1429
  // Relational condition: `<left> <op> <right>` with op ∈ {<,>,<=,>=}.
1596
- if (!ts.isBinaryExpression(cond)) return null
1597
- const op = cond.operatorToken.kind
1598
- const isGreater =
1599
- op === ts.SyntaxKind.GreaterThanToken || op === ts.SyntaxKind.GreaterThanEqualsToken
1600
- const isLess = op === ts.SyntaxKind.LessThanToken || op === ts.SyntaxKind.LessThanEqualsToken
1430
+ if (cond.kind !== 'binary') return null
1431
+ const isGreater = cond.op === '>' || cond.op === '>='
1432
+ const isLess = cond.op === '<' || cond.op === '<='
1601
1433
  if (!isGreater && !isLess) return null
1602
1434
 
1603
1435
  const leftRef = classifySortOperand(cond.left, paramA, paramB)
@@ -1609,37 +1441,21 @@ function classifyTernaryComparator(
1609
1441
  return null
1610
1442
  }
1611
1443
 
1612
- // whenTrue must be a non-zero sign literal (±1); whenFalse a bounded
1613
- // shape (sign literal or a nested ternary on the same key).
1614
- //
1615
- // Direction is derived solely from this outer comparison — the nested
1616
- // whenFalse branch is only validated for key agreement, not direction
1617
- // consistency. A contradictory hand-written 3-way (e.g.
1618
- // `a.f < b.f ? -1 : a.f < b.f ? 1 : 0`) is therefore lowered per the
1619
- // outer comparison; the JS-runtime (Hono/CSR) path runs the literal
1620
- // body, so such a degenerate comparator could order differently
1621
- // there. The canonical asc/desc 3-way forms agree on both paths.
1622
- const trueSign = numericSign(node.whenTrue)
1444
+ // whenTrue must be a non-zero sign literal (±1); whenFalse a bounded shape
1445
+ // (sign literal or a nested ternary on the same key). Direction is derived
1446
+ // solely from this outer comparison.
1447
+ const trueSign = numericSign(node.consequent)
1623
1448
  if (trueSign === null || trueSign === 0) return null
1624
- if (!isBoundedTernaryElse(node.whenFalse, leftRef.key, paramA, paramB)) return null
1449
+ if (!isBoundedTernaryElse(node.alternate, leftRef.key, paramA, paramB)) return null
1625
1450
 
1626
1451
  // Rewrite so the condition reads as `aKey <op> bKey` (paramA left).
1627
- // `b.f > a.f` ⇔ `a.f < b.f`, so a paramB-on-left operand flips it.
1628
1452
  const greaterForA = leftRef.param === 'A' ? isGreater : !isGreater
1629
-
1630
- // `a.f > b.f ? +n` → bigger sorts later → ascending
1631
- // `a.f < b.f ? +n` → bigger sorts earlier → descending
1632
1453
  const asc = greaterForA ? trueSign > 0 : trueSign < 0
1633
1454
  return { key: leftRef.key, type: 'auto', direction: asc ? 'asc' : 'desc' }
1634
1455
  }
1635
1456
 
1636
1457
  /** True when both operands resolve to the same key on opposite params. */
1637
- function sameKeyOperands(
1638
- left: ts.Expression,
1639
- right: ts.Expression,
1640
- paramA: string,
1641
- paramB: string,
1642
- ): boolean {
1458
+ function sameKeyOperands(left: ParsedExpr, right: ParsedExpr, paramA: string, paramB: string): boolean {
1643
1459
  const l = classifySortOperand(left, paramA, paramB)
1644
1460
  const r = classifySortOperand(right, paramA, paramB)
1645
1461
  if (!l || !r) return false
@@ -1650,17 +1466,16 @@ function sameKeyOperands(
1650
1466
  }
1651
1467
 
1652
1468
  /**
1653
- * Sign of a numeric literal with optional unary minus: 1, -1, or 0.
1654
- * Returns null for anything that isn't a (signed) numeric literal.
1469
+ * Sign of a numeric literal with optional unary minus: 1, -1, or 0. Returns
1470
+ * null for anything that isn't a (signed) numeric literal.
1655
1471
  */
1656
- function numericSign(expr: ts.Expression): number | null {
1657
- const e = unwrapParens(expr)
1658
- if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken) {
1659
- const inner = numericSign(e.operand)
1472
+ function numericSign(expr: ParsedExpr): number | null {
1473
+ if (expr.kind === 'unary' && expr.op === '-') {
1474
+ const inner = numericSign(expr.argument)
1660
1475
  return inner === null ? null : -inner
1661
1476
  }
1662
- if (ts.isNumericLiteral(e)) {
1663
- const n = Number(e.text)
1477
+ if (expr.kind === 'literal' && expr.literalType === 'number' && typeof expr.value === 'number') {
1478
+ const n = expr.value
1664
1479
  if (Number.isNaN(n)) return null
1665
1480
  if (n === 0) return 0
1666
1481
  return n > 0 ? 1 : -1
@@ -1669,21 +1484,18 @@ function numericSign(expr: ts.Expression): number | null {
1669
1484
  }
1670
1485
 
1671
1486
  /**
1672
- * The `whenFalse` arm of a relational ternary is bounded if it's a
1673
- * sign literal (±1 / 0) or a nested ternary on the same key (the
1674
- * canonical 3-way form). The outer comparison already fixes direction,
1675
- * so the nested branch only needs to agree on which key it compares.
1487
+ * The `whenFalse` arm of a relational ternary is bounded if it's a sign
1488
+ * literal (±1 / 0) or a nested ternary on the same key (the canonical 3-way).
1676
1489
  */
1677
1490
  function isBoundedTernaryElse(
1678
- expr: ts.Expression,
1491
+ expr: ParsedExpr,
1679
1492
  key: { kind: 'self' } | { kind: 'field'; field: string },
1680
1493
  paramA: string,
1681
1494
  paramB: string,
1682
1495
  ): boolean {
1683
- const e = unwrapParens(expr)
1684
- if (numericSign(e) !== null) return true
1685
- if (ts.isConditionalExpression(e)) {
1686
- const nested = classifyTernaryComparator(e, paramA, paramB)
1496
+ if (numericSign(expr) !== null) return true
1497
+ if (expr.kind === 'conditional') {
1498
+ const nested = classifyTernaryComparator(expr, paramA, paramB)
1687
1499
  return nested !== null && sortKeyEquals(nested.key, key)
1688
1500
  }
1689
1501
  return false
@@ -1698,238 +1510,28 @@ function sortKeyEquals(
1698
1510
  return true
1699
1511
  }
1700
1512
 
1513
+ /**
1514
+ * Resolve a sort operand to a `self` / `field` key on param A or B. A bare
1515
+ * param identifier is `self`; a single-level non-computed field access on a
1516
+ * param is `field`. Anything deeper returns null.
1517
+ */
1701
1518
  function classifySortOperand(
1702
- expr: ts.Expression,
1519
+ expr: ParsedExpr,
1703
1520
  paramA: string,
1704
1521
  paramB: string,
1705
1522
  ): { key: { kind: 'self' } | { kind: 'field'; field: string }; param: 'A' | 'B' } | null {
1706
- if (ts.isIdentifier(expr)) {
1707
- if (expr.text === paramA) return { key: { kind: 'self' }, param: 'A' }
1708
- if (expr.text === paramB) return { key: { kind: 'self' }, param: 'B' }
1523
+ if (expr.kind === 'identifier') {
1524
+ if (expr.name === paramA) return { key: { kind: 'self' }, param: 'A' }
1525
+ if (expr.name === paramB) return { key: { kind: 'self' }, param: 'B' }
1709
1526
  return null
1710
1527
  }
1711
- if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression)) {
1712
- if (expr.expression.text === paramA) {
1713
- return { key: { kind: 'field', field: expr.name.text }, param: 'A' }
1714
- }
1715
- if (expr.expression.text === paramB) {
1716
- return { key: { kind: 'field', field: expr.name.text }, param: 'B' }
1717
- }
1528
+ if (expr.kind === 'member' && !expr.computed && expr.object.kind === 'identifier') {
1529
+ if (expr.object.name === paramA) return { key: { kind: 'field', field: expr.property }, param: 'A' }
1530
+ if (expr.object.name === paramB) return { key: { kind: 'field', field: expr.property }, param: 'B' }
1718
1531
  }
1719
1532
  return null
1720
1533
  }
1721
1534
 
1722
- /**
1723
- * Recover a `ReduceOp` from the `(reducer, init)` args of
1724
- * `.reduce(...)` (#1448 Tier C). Operates on the raw TS AST because the
1725
- * standard `convertNode` arrow-fn path rejects two-param arrows.
1726
- *
1727
- * The accepted catalogue is intentionally finite — only the
1728
- * arithmetic-fold family lowers to a declarative template:
1729
- *
1730
- * (acc, x) => acc + x → self, numeric (init: number)
1731
- * (acc, x) => acc + x.field → field, numeric (init: number)
1732
- * (acc, x) => acc * x → self, numeric (init: number)
1733
- * (acc, x) => acc * x.field → field, numeric (init: number)
1734
- * (acc, x) => acc + x → self, string (init: string → concat)
1735
- * (acc, x) => acc + x.field → field, string (init: string → concat)
1736
- *
1737
- * The accumulator must be the binary expression's *left* operand
1738
- * (canonical reduce form; reversed operands change string-concat
1739
- * order), the per-item value must be the item param itself or a
1740
- * single non-computed field access on it, and the init must be a
1741
- * number or string literal (negative numbers via prefix `-` allowed).
1742
- * String concatenation requires `+`. Block bodies reduce to a single
1743
- * `return`, mirroring the sort extractor. Anything else returns null
1744
- * and the caller emits `unsupported` (BF101).
1745
- */
1746
- export function extractReduceOpFromTS(
1747
- reducerNode: ts.Node,
1748
- initNode: ts.Node,
1749
- ): ReduceOp | null {
1750
- const init = classifyReduceInit(initNode)
1751
- if (!init) return null
1752
-
1753
- if (!ts.isArrowFunction(reducerNode) && !ts.isFunctionExpression(reducerNode)) return null
1754
- // Exactly `(acc, item)` — the index / array reducer params can't be
1755
- // expressed in a template fold, so refuse the 3- / 4-param forms.
1756
- if (reducerNode.parameters.length !== 2) return null
1757
- const pAcc = reducerNode.parameters[0]
1758
- const pItem = reducerNode.parameters[1]
1759
- if (!ts.isIdentifier(pAcc.name) || !ts.isIdentifier(pItem.name)) return null
1760
- const paramAcc = pAcc.name.text
1761
- const paramItem = pItem.name.text
1762
-
1763
- // Resolve the reducer body: expression-bodied arrow directly; block
1764
- // bodies (arrow `=> { … }` and function expressions) must reduce to
1765
- // exactly one `return <expr>;` — mirrors `extractSortComparatorFromTS`.
1766
- let body: ts.Expression
1767
- if (ts.isArrowFunction(reducerNode) && !ts.isBlock(reducerNode.body)) {
1768
- body = reducerNode.body
1769
- } else {
1770
- const block = reducerNode.body as ts.Block
1771
- const stmts = block.statements
1772
- if (stmts.length !== 1 || !ts.isReturnStatement(stmts[0]) || !stmts[0].expression) return null
1773
- body = stmts[0].expression
1774
- }
1775
- const raw = body.getText()
1776
-
1777
- const expr = unwrapParens(body)
1778
- if (!ts.isBinaryExpression(expr)) return null
1779
- let op: '+' | '*'
1780
- if (expr.operatorToken.kind === ts.SyntaxKind.PlusToken) op = '+'
1781
- else if (expr.operatorToken.kind === ts.SyntaxKind.AsteriskToken) op = '*'
1782
- else return null
1783
-
1784
- // The accumulator must be the left operand (`acc + x`, not `x + acc`).
1785
- const left = unwrapParens(expr.left)
1786
- if (!ts.isIdentifier(left) || left.text !== paramAcc) return null
1787
-
1788
- const key = classifyReduceKey(unwrapParens(expr.right), paramItem)
1789
- if (!key) return null
1790
-
1791
- // String concatenation only makes sense with `+`.
1792
- const type: 'numeric' | 'string' = init.type
1793
- if (type === 'string' && op !== '+') return null
1794
-
1795
- return { op, key, type, init: init.value, raw, paramAcc, paramItem }
1796
- }
1797
-
1798
- /**
1799
- * Recover a `FlatMapOp` from the single-argument callback of a
1800
- * value-returning `.flatMap(fn)` (#1448 Tier C). Operates on the raw TS
1801
- * AST, mirroring `extractReduceOpFromTS`.
1802
- *
1803
- * The accepted catalogue:
1804
- *
1805
- * i => i → self (flatMap(identity) === flat(1))
1806
- * i => i.field → field (flatten a per-item array field)
1807
- * i => [i.a, i.b] → tuple (gather per-item self / field leaves)
1808
- *
1809
- * The callback must take exactly one identifier param (the index / array
1810
- * params can't be expressed in a template projection), and the body must
1811
- * be the param itself, a single non-computed field access on it, or an
1812
- * array literal whose every element is one of those leaves. Block bodies
1813
- * reduce to a single `return`, like the reduce / sort extractors. Any
1814
- * other body (deep access, computed members, calls, arithmetic, a
1815
- * literal element) returns null and the caller emits `unsupported`
1816
- * (BF101).
1817
- */
1818
- export function extractFlatMapOpFromTS(cbNode: ts.Node): FlatMapOp | null {
1819
- if (!ts.isArrowFunction(cbNode) && !ts.isFunctionExpression(cbNode)) return null
1820
- // Exactly `(item)` — a `(item, index)` / `(item, index, array)` callback
1821
- // can't be lowered to a declarative projection.
1822
- if (cbNode.parameters.length !== 1) return null
1823
- const p = cbNode.parameters[0]
1824
- if (!ts.isIdentifier(p.name)) return null
1825
- const param = p.name.text
1826
-
1827
- let body: ts.Expression
1828
- if (ts.isArrowFunction(cbNode) && !ts.isBlock(cbNode.body)) {
1829
- body = cbNode.body
1830
- } else {
1831
- const block = cbNode.body as ts.Block
1832
- const stmts = block.statements
1833
- if (stmts.length !== 1 || !ts.isReturnStatement(stmts[0]) || !stmts[0].expression) return null
1834
- body = stmts[0].expression
1835
- }
1836
- const raw = body.getText()
1837
- const inner = unwrapParens(body)
1838
-
1839
- // Array-literal body → tuple projection. Every element must be a
1840
- // self / field leaf; a literal / computed / nested element refuses the
1841
- // whole shape (the per-item evaluation of richer expressions isn't
1842
- // lowered). flat(1) removes only the literal's wrapper, so each leaf is
1843
- // appended verbatim — handled by the `bf_flat_map_tuple` runtime.
1844
- if (ts.isArrayLiteralExpression(inner)) {
1845
- // An empty tuple (`i => []`) is a degenerate no-op projection (always
1846
- // yields nothing). Refuse it so the emitters never produce a
1847
- // zero-arg `bf_flat_map_tuple` / `bf->flat_map_tuple(...,)` call.
1848
- if (inner.elements.length === 0) return null
1849
- const elements: FlatMapLeaf[] = []
1850
- for (const el of inner.elements) {
1851
- // Spread / holes (`[...xs]`, `[, x]`) aren't leaves.
1852
- if (ts.isSpreadElement(el) || ts.isOmittedExpression(el)) return null
1853
- const leaf = classifyReduceKey(unwrapParens(el), param)
1854
- if (!leaf) return null
1855
- elements.push(leaf)
1856
- }
1857
- return { projection: { kind: 'tuple', elements }, param, raw }
1858
- }
1859
-
1860
- // Scalar body. Reuse the reduce key classifier — `i` → self,
1861
- // `i.field` → field, null for anything deeper (`i.a.b`, `i[k]`, a call).
1862
- const leaf = classifyReduceKey(inner, param)
1863
- if (!leaf) return null
1864
-
1865
- return { projection: leaf, param, raw }
1866
- }
1867
-
1868
- /**
1869
- * Classify a reduce per-item operand into a `ReduceOp` key. Accepts
1870
- * the bare item param (`x` → self) and a single non-computed field
1871
- * access (`x.field` → field); returns null for anything deeper
1872
- * (`x.a.b`, `x[k]`, a literal, a call, …).
1873
- */
1874
- function classifyReduceKey(
1875
- expr: ts.Expression,
1876
- paramItem: string,
1877
- ): { kind: 'self' } | { kind: 'field'; field: string } | null {
1878
- if (ts.isIdentifier(expr)) {
1879
- return expr.text === paramItem ? { kind: 'self' } : null
1880
- }
1881
- if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression)) {
1882
- if (expr.expression.text === paramItem) return { kind: 'field', field: expr.name.text }
1883
- }
1884
- return null
1885
- }
1886
-
1887
- /**
1888
- * Classify a reduce initial-value node into the *decoded* fold seed.
1889
- * Accepts a numeric literal (optionally prefixed with `-`) and a string
1890
- * literal; returns `{ type, value }` where `value` is the canonical
1891
- * value — never the raw source text. Any other init (a variable, a
1892
- * call, an object) returns null — the fold start value must be
1893
- * statically known.
1894
- *
1895
- * Numeric: `node.text` is TypeScript's canonical decimal form, so
1896
- * separators and non-decimal radices fold uniformly across adapters
1897
- * (`1_000` → `1000`, `0x10` → `16`, `1e3` → `1000`). The Go runtime's
1898
- * `strconv.ParseFloat` and Perl both accept that decimal string —
1899
- * passing the raw source (`0x10`, `1_000`) would silently fold to 0 on
1900
- * Go while Perl accepted it (#1728 review).
1901
- *
1902
- * String: `node.text` is the *unescaped* contents. To keep the three
1903
- * adapters byte-equal without teaching each one to re-decode JS escapes
1904
- * (`\n`, `\u{…}`, `\\`, an escaped quote), we refuse any string literal
1905
- * whose contents differ from its raw inner source — i.e. any literal
1906
- * carrying an escape sequence. Accepted seeds are therefore escape-free
1907
- * single-line strings (`''`, `', '`, `'-'`), which embed safely in both
1908
- * the Go-template `"…"` operand and the Perl single-quoted literal. The
1909
- * realistic concat seed (`''`) is unaffected; richer seeds fall back to
1910
- * the `@client` escape hatch.
1911
- */
1912
- function classifyReduceInit(
1913
- node: ts.Node,
1914
- ): { type: 'numeric' | 'string'; value: string } | null {
1915
- // Unwrap redundant parens (`(0)` / `(-1)`) so they classify like the
1916
- // bare literal — matches the extractor's `unwrapParens` use elsewhere.
1917
- let n: ts.Node = unwrapParens(node as ts.Expression)
1918
- // `-1` parses as a prefix-minus over a numeric literal.
1919
- if (ts.isPrefixUnaryExpression(n) && n.operator === ts.SyntaxKind.MinusToken) {
1920
- if (ts.isNumericLiteral(n.operand)) return { type: 'numeric', value: '-' + n.operand.text }
1921
- return null
1922
- }
1923
- if (ts.isNumericLiteral(n)) return { type: 'numeric', value: n.text }
1924
- if (ts.isStringLiteral(n)) {
1925
- // Refuse literals carrying escapes so the decoded value equals its
1926
- // raw inner source (and thus embeds safely + byte-equal everywhere).
1927
- const raw = n.getText()
1928
- if (raw.length < 2 || raw.slice(1, -1) !== n.text) return null
1929
- return { type: 'string', value: n.text }
1930
- }
1931
- return null
1932
- }
1933
1535
 
1934
1536
  /**
1935
1537
  * Per-binding entry stored in `fieldMap`: the dotted path from the
@@ -2070,8 +1672,12 @@ function collectDestructureBindings(
2070
1672
  let defaultExpr: ParsedExpr | undefined
2071
1673
  if (el.initializer) {
2072
1674
  const parsed = convertNode(el.initializer, raw)
2073
- if (parsed.kind === 'unsupported') {
2074
- return { ok: false, reason: `Default value in destructured filter param failed to parse: ${parsed.reason}` }
1675
+ // An object-literal default isn't lowered into a destructured filter
1676
+ // predicate yet refuse it exactly as before the kind existed, with
1677
+ // the same reason text the `unsupported` fallback produced (A-1).
1678
+ if (parsed.kind === 'unsupported' || parsed.kind === 'object-literal') {
1679
+ const reason = parsed.kind === 'unsupported' ? parsed.reason : 'Unsupported syntax: ObjectLiteralExpression'
1680
+ return { ok: false, reason: `Default value in destructured filter param failed to parse: ${reason}` }
2075
1681
  }
2076
1682
  defaultExpr = parsed
2077
1683
  }
@@ -2114,6 +1720,7 @@ function findImpureDefaultNode(expr: ParsedExpr): string | null {
2114
1720
  case 'literal':
2115
1721
  case 'identifier':
2116
1722
  case 'unsupported':
1723
+ case 'object-literal':
2117
1724
  return null
2118
1725
  case 'member':
2119
1726
  return findImpureDefaultNode(expr.object)
@@ -2144,8 +1751,8 @@ function findImpureDefaultNode(expr: ParsedExpr): string | null {
2144
1751
  return null
2145
1752
  case 'call':
2146
1753
  case 'array-method':
2147
- case 'higher-order':
2148
- case 'arrow-fn':
1754
+ case 'arrow':
1755
+ case 'regex':
2149
1756
  return expr.kind
2150
1757
  }
2151
1758
  }
@@ -2249,19 +1856,13 @@ function validateRestUsage(
2249
1856
  if (part.type === 'expression') walk(part.expr)
2250
1857
  }
2251
1858
  return
2252
- case 'arrow-fn':
2253
- // Inner arrow that re-uses `restName` as its own parameter
2254
- // shadows the outer rest binding — references inside its
2255
- // body belong to the inner param, not us (#1532 review).
2256
- if (e.param === restName) return
1859
+ case 'arrow':
1860
+ // Inner arrow that re-uses `restName` as one of its own
1861
+ // parameters shadows the outer rest binding — references inside
1862
+ // its body belong to the inner param, not us (#1532 review).
1863
+ if (e.params.includes(restName)) return
2257
1864
  walk(e.body)
2258
1865
  return
2259
- case 'higher-order':
2260
- walk(e.object)
2261
- // The predicate is the inner-callback body; its `param`
2262
- // shadows the outer rest binding when names match.
2263
- if (e.param !== restName) walk(e.predicate)
2264
- return
2265
1866
  case 'array-literal':
2266
1867
  for (const el of e.elements) walk(el)
2267
1868
  return
@@ -2271,6 +1872,7 @@ function validateRestUsage(
2271
1872
  return
2272
1873
  case 'literal':
2273
1874
  case 'unsupported':
1875
+ case 'object-literal':
2274
1876
  return
2275
1877
  }
2276
1878
  }
@@ -2370,13 +1972,9 @@ function collectIdentifiers(expr: ParsedExpr, out: Set<string>): void {
2370
1972
  if (part.type === 'expression') collectIdentifiers(part.expr, out)
2371
1973
  }
2372
1974
  return
2373
- case 'arrow-fn':
1975
+ case 'arrow':
2374
1976
  collectIdentifiers(expr.body, out)
2375
1977
  return
2376
- case 'higher-order':
2377
- collectIdentifiers(expr.object, out)
2378
- collectIdentifiers(expr.predicate, out)
2379
- return
2380
1978
  case 'array-literal':
2381
1979
  expr.elements.forEach(e => collectIdentifiers(e, out))
2382
1980
  return
@@ -2385,7 +1983,11 @@ function collectIdentifiers(expr: ParsedExpr, out: Set<string>): void {
2385
1983
  expr.args.forEach(e => collectIdentifiers(e, out))
2386
1984
  return
2387
1985
  case 'literal':
1986
+ case 'regex':
2388
1987
  case 'unsupported':
1988
+ // Mirror `unsupported`: an object literal was not carried before this
1989
+ // kind existed, so it collects no identifiers (byte-identical A-1).
1990
+ case 'object-literal':
2389
1991
  return
2390
1992
  }
2391
1993
  }
@@ -2463,49 +2065,29 @@ function substituteDestructuredFields(
2463
2065
  p.type === 'expression' ? { type: 'expression', expr: walk(p.expr) } : p,
2464
2066
  ),
2465
2067
  }
2466
- case 'arrow-fn':
2068
+ case 'arrow':
2467
2069
  // A nested arrow inside the predicate body shadows the outer
2468
- // param. Leave its body alone its own param-resolution path
2469
- // handles closure references against the outer fieldMap on its
2470
- // own terms. This matches how JS scope rules treat the outer
2471
- // destructured `done` once a shadowing inner arrow declares a
2472
- // different name (the inner arrow's body sees the outer
2473
- // `done` only if it doesn't shadow it, which we can't know
2474
- // without per-scope tracking). Skipping the rewrite is the
2475
- // conservative choice — worst case the resulting predicate
2476
- // doesn't lower and the adapter emits BF101.
2477
- return e
2478
- case 'higher-order':
2070
+ // param (a higher-order callback like `.sort(cmp)` / `.filter(p)`
2071
+ // reaches here as the arrow argument of a generic `call`). Leave
2072
+ // its body alone its comparator/predicate references its own
2073
+ // params, never the enclosing destructure. Skipping the rewrite is
2074
+ // the conservative choice; worst case the adapter emits BF101.
2479
2075
  return e
2480
2076
  case 'array-literal':
2481
2077
  return { kind: 'array-literal', elements: e.elements.map(walk) }
2482
2078
  case 'array-method':
2483
- if (e.method === 'sort' || e.method === 'toSorted') {
2484
- // Sort comparator is a structured value, not a ParsedExpr —
2485
- // destructured-field substitution doesn't apply (the
2486
- // comparator references its own paramA / paramB, never the
2487
- // enclosing destructure). Preserve verbatim.
2488
- return { kind: 'array-method', method: e.method, object: walk(e.object), args: [], comparator: e.comparator }
2489
- }
2490
- if (e.method === 'reduce' || e.method === 'reduceRight') {
2491
- // `ReduceOp` is a structured value referencing its own
2492
- // paramAcc / paramItem, never the enclosing destructure —
2493
- // preserve verbatim, same as the sort comparator above.
2494
- return { kind: 'array-method', method: e.method, object: walk(e.object), args: [], reduceOp: e.reduceOp }
2495
- }
2496
2079
  if (e.method === 'flat') {
2497
2080
  // `flatDepth` is a normalised literal — no destructure refs to
2498
- // substitute. Preserve verbatim, same as sort / reduce above.
2081
+ // substitute. Preserve verbatim.
2499
2082
  return { kind: 'array-method', method: 'flat', object: walk(e.object), args: [], flatDepth: e.flatDepth }
2500
2083
  }
2501
- if (e.method === 'flatMap') {
2502
- // `FlatMapOp` references its own callback param, never the
2503
- // enclosing destructure — preserve verbatim, like reduce / sort.
2504
- return { kind: 'array-method', method: 'flatMap', object: walk(e.object), args: [], flatMapOp: e.flatMapOp }
2505
- }
2506
2084
  return { kind: 'array-method', method: e.method, object: walk(e.object), args: e.args.map(walk) }
2507
2085
  case 'literal':
2086
+ case 'regex':
2508
2087
  case 'unsupported':
2088
+ // Mirror `unsupported`: object literals were not substituted into
2089
+ // before this kind existed — return verbatim (byte-identical A-1).
2090
+ case 'object-literal':
2509
2091
  return e
2510
2092
  }
2511
2093
  }
@@ -2567,57 +2149,27 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2567
2149
  case 'unsupported':
2568
2150
  return { supported: false, reason: expr.reason }
2569
2151
 
2152
+ // A bare object literal is still refused as a standalone template
2153
+ // expression — adapters that lower one as a *value* (Go map / Perl
2154
+ // hashref) do so in their own emitter, like `array-literal`, not
2155
+ // through this support gate. The reason string is the exact text the
2156
+ // `unsupported` fallback produced before the `object-literal` kind
2157
+ // existed, so diagnostics stay byte-identical (Roadmap A-1).
2158
+ case 'object-literal':
2159
+ return { supported: false, reason: 'Unsupported syntax: ObjectLiteralExpression' }
2160
+
2570
2161
  case 'identifier':
2571
2162
  return { supported: true, level: 'L1' }
2572
2163
 
2573
2164
  case 'literal':
2574
2165
  return { supported: true, level: 'L1' }
2575
2166
 
2576
- case 'arrow-fn': {
2577
- // Arrow functions are only supported as arguments to higher-order methods
2578
- // They shouldn't appear standalone in supported contexts
2579
- return { supported: false, reason: 'Standalone arrow functions are not supported' }
2580
- }
2581
-
2582
- case 'higher-order': {
2583
- // Check if predicate uses L1-L4 features
2584
- const predSupport = checkSupport(expr.predicate)
2585
- if (!predSupport.supported) {
2586
- return {
2587
- supported: false,
2588
- level: 'L5_UNSUPPORTED',
2589
- reason: `Higher-order method '${expr.method}()' with complex predicate. ${predSupport.reason || 'Simplify the predicate.'}`,
2590
- }
2591
- }
2592
- // Nested higher-order INSIDE the predicate body (e.g.
2593
- // `x => x.tags.filter(t => t.active).length > 0`) was refused
2594
- // here historically because adapter emitters would produce
2595
- // broken output for `[grep ...]->{length}` style chains. Note
2596
- // this check is intentionally NOT extended to `expr.object`:
2597
- // chained-receiver forms like `arr.filter(p).filter(q)` lower
2598
- // correctly via the emitter's recursive `emit(object)` (which
2599
- // wraps the inner result in another `grep`). The Copilot
2600
- // review on #1444 asked us to either update this comment or
2601
- // also reject chained receivers — preserving the chained
2602
- // case is the right move because it already works.
2603
- if (containsHigherOrder(expr.predicate)) {
2604
- return {
2605
- supported: false,
2606
- level: 'L5_UNSUPPORTED',
2607
- reason: `Nested higher-order methods inside a predicate body are not supported. Use @client directive.`,
2608
- }
2609
- }
2610
- // The source array also has to be lowerable. Skipping this check
2611
- // (matching the pre-#1443 behaviour) silently let `array-literal`
2612
- // sources fall through to the adapter's `unsupported` arm and
2613
- // through the regex pipeline — the recursion that #1421 / #1427
2614
- // worked around.
2615
- const objSupport = checkSupport(expr.object)
2616
- if (!objSupport.supported) {
2617
- return objSupport
2618
- }
2619
- return { supported: true, level: 'L5' }
2620
- }
2167
+ case 'arrow':
2168
+ case 'regex':
2169
+ // Arrow functions / regex literals are only supported as the
2170
+ // argument of a recognised higher-order callback call (handled in
2171
+ // the `call` arm below); they're unsupported standalone.
2172
+ return { supported: false, reason: 'Standalone arrow functions / regex literals are not supported' }
2621
2173
 
2622
2174
  case 'array-literal': {
2623
2175
  // Array literal is lowerable iff every element is. Adapters that
@@ -2633,6 +2185,17 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2633
2185
  }
2634
2186
 
2635
2187
  case 'array-method': {
2188
+ // A regex-pattern `.replace` is carried structurally (a `regex` first
2189
+ // arg) but is the deferred form (#1448) — no template language lowers it.
2190
+ // Refuse with the dedicated reason rather than the generic standalone-regex
2191
+ // message, preserving the diagnostic the parser used to emit directly.
2192
+ if (expr.method === 'replace' && expr.args[0]?.kind === 'regex') {
2193
+ return {
2194
+ supported: false,
2195
+ reason:
2196
+ 'String.prototype.replace supports only a string pattern + string replacement (the regex form is deferred); use a string pattern or wrap the expression in /* @client */',
2197
+ }
2198
+ }
2636
2199
  const objSupport = checkSupport(expr.object)
2637
2200
  if (!objSupport.supported) return objSupport
2638
2201
  for (const arg of expr.args) {
@@ -2644,6 +2207,36 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2644
2207
 
2645
2208
 
2646
2209
  case 'call': {
2210
+ // Higher-order callback methods (`.filter`/`.sort`/`.reduce`/… with an
2211
+ // arrow argument) lower via the runtime evaluator (#2018). Supported iff
2212
+ // the receiver and the callback BODY are supported. Recognised before the
2213
+ // `UNSUPPORTED_METHODS` gate so the eval-lowered shapes aren't refused
2214
+ // (a BARE method reference — `arr.filter` uncalled, no arrow arg — still
2215
+ // falls through to the gate). A nested callback inside the body is NOT
2216
+ // refused here: the evaluator refuses it (`serializeParsedExpr` → null)
2217
+ // and each adapter then either lowers it faithfully (Mojo's inline
2218
+ // `grep`, Go's `len (bf_filter_eval …)`) or surfaces BF101 at its
2219
+ // predicate fallback's exact degrade points (#2038) — a blanket refusal
2220
+ // here would break the faithful shapes (#1443 PR4).
2221
+ const cb = asCallbackMethodCall(expr)
2222
+ if (cb) {
2223
+ const objSupport = checkSupport(cb.object)
2224
+ if (!objSupport.supported) return objSupport
2225
+ const bodySupport = checkSupport(cb.arrow.body)
2226
+ if (!bodySupport.supported) {
2227
+ return {
2228
+ supported: false,
2229
+ level: 'L5_UNSUPPORTED',
2230
+ reason: `Higher-order method '.${cb.method}()' with complex callback. ${bodySupport.reason || 'Simplify the callback.'}`,
2231
+ }
2232
+ }
2233
+ for (const rest of cb.args) {
2234
+ const restSupport = checkSupport(rest)
2235
+ if (!restSupport.supported) return restSupport
2236
+ }
2237
+ return { supported: true, level: 'L5' }
2238
+ }
2239
+
2647
2240
  // Check if callee is supported
2648
2241
  const calleeSupport = checkSupport(expr.callee)
2649
2242
  if (!calleeSupport.supported) {
@@ -2778,12 +2371,13 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2778
2371
  }
2779
2372
 
2780
2373
  /**
2781
- * Check if expression contains any higher-order method calls.
2374
+ * Check if expression contains any higher-order callback method call
2375
+ * (`.filter`/`.sort`/`.reduce`/… with an arrow argument — see
2376
+ * {@link asCallbackMethodCall}) anywhere in the tree.
2782
2377
  */
2783
2378
  export function containsHigherOrder(expr: ParsedExpr): boolean {
2379
+ if (asCallbackMethodCall(expr) !== null) return true
2784
2380
  switch (expr.kind) {
2785
- case 'higher-order':
2786
- return true
2787
2381
  case 'call':
2788
2382
  return expr.args.some(containsHigherOrder) || containsHigherOrder(expr.callee)
2789
2383
  case 'member':
@@ -2798,7 +2392,7 @@ export function containsHigherOrder(expr: ParsedExpr): boolean {
2798
2392
  return containsHigherOrder(expr.left) || containsHigherOrder(expr.right)
2799
2393
  case 'conditional':
2800
2394
  return containsHigherOrder(expr.test) || containsHigherOrder(expr.consequent) || containsHigherOrder(expr.alternate)
2801
- case 'arrow-fn':
2395
+ case 'arrow':
2802
2396
  return containsHigherOrder(expr.body)
2803
2397
  case 'array-literal':
2804
2398
  return expr.elements.some(containsHigherOrder)
@@ -2847,6 +2441,29 @@ export function parseBlockBody(
2847
2441
  return statements
2848
2442
  }
2849
2443
 
2444
+ /**
2445
+ * Like {@link parseBlockBody} but tolerant: a statement `parseStatement` can't
2446
+ * represent is **skipped** rather than failing the whole block. Used to carry a
2447
+ * block-body memo's structure on the IR for adapters that only pattern-match a
2448
+ * recognised prefix of statements (e.g. a `const k = getter(); if (!k) return
2449
+ * CONST` guard) and ignore the rest — including a trailing client-directive
2450
+ * (`@client`) return that the strict parser would reject. Mirrors the tolerant
2451
+ * `continue`-on-unrecognised walks those adapters previously ran over a
2452
+ * re-parsed source string, so it never carries a *more* permissive result.
2453
+ */
2454
+ export function parseBlockBodyTolerant(
2455
+ block: ts.Block,
2456
+ sourceFile: ts.SourceFile,
2457
+ getJS: (node: ts.Node) => string
2458
+ ): ParsedStatement[] {
2459
+ const statements: ParsedStatement[] = []
2460
+ for (const stmt of block.statements) {
2461
+ const parsed = parseStatement(stmt, sourceFile, getJS)
2462
+ if (parsed !== null) statements.push(parsed)
2463
+ }
2464
+ return statements
2465
+ }
2466
+
2850
2467
  /**
2851
2468
  * Parse a single statement into ParsedStatement.
2852
2469
  */
@@ -2876,8 +2493,17 @@ function parseStatement(
2876
2493
  // return; (no value) -> return undefined, treat as return true
2877
2494
  return { kind: 'return', value: { kind: 'literal', value: true, literalType: 'boolean' } }
2878
2495
  }
2879
- const valueText = getJS(stmt.expression)
2880
- const value = parseExpression(valueText)
2496
+ // A bare object-literal return (`return { a: 1 }`) re-parses as a *block*
2497
+ // statement if its braces lead the source, yielding `unsupported`. Unwrap
2498
+ // any parens, and when the returned expression is an object literal, wrap
2499
+ // the text in parens to force expression context so it parses as an
2500
+ // `object-literal` ParsedExpr (consumed by the Go object-memo lowering).
2501
+ let retExpr: ts.Expression = stmt.expression
2502
+ while (ts.isParenthesizedExpression(retExpr)) retExpr = retExpr.expression
2503
+ const valueText = getJS(retExpr)
2504
+ const value = parseExpression(
2505
+ ts.isObjectLiteralExpression(retExpr) ? `(${valueText})` : valueText,
2506
+ )
2881
2507
  if (value.kind === 'unsupported') {
2882
2508
  return null
2883
2509
  }
@@ -2888,7 +2514,7 @@ function parseStatement(
2888
2514
  if (ts.isIfStatement(stmt)) {
2889
2515
  const conditionText = getJS(stmt.expression)
2890
2516
  const condition = parseExpression(conditionText)
2891
- if (condition.kind === 'unsupported') {
2517
+ if (condition.kind === 'unsupported' || condition.kind === 'object-literal') {
2892
2518
  return null
2893
2519
  }
2894
2520
 
@@ -2925,6 +2551,435 @@ function parseStatement(
2925
2551
  return null
2926
2552
  }
2927
2553
 
2554
+ // =============================================================================
2555
+ // Block → Expression Normalization (#2040)
2556
+ // =============================================================================
2557
+
2558
+ /**
2559
+ * The actionable refusal reason for a block body that is not purely-functionally
2560
+ * expressible. Carried on the `unsupported` ParsedExpr so adapters surface it as
2561
+ * the BF101 message. A loop that mutates a local to accumulate a value is a fold
2562
+ * (already expressible via `.reduce`); a loop that does anything else, a `break`,
2563
+ * a re-assignment, or a side-effecting/I-O call is genuinely imperative and has
2564
+ * no value-position lowering.
2565
+ */
2566
+ export const IMPERATIVE_BLOCK_REASON =
2567
+ 'Block body cannot be normalized to a value expression. Only pure ' +
2568
+ '`const` bindings, value-producing `if` / early `return`, and a final ' +
2569
+ '`return` are supported. Imperative shapes (raw `for` / `while` loops, ' +
2570
+ '`break`, local re-assignment, side-effecting or I/O calls) are not. ' +
2571
+ 'Rewrite an accumulation loop as `.reduce(...)`, or move the imperative ' +
2572
+ 'body to a `/* @client */` value so it runs natively on the client.'
2573
+
2574
+ /**
2575
+ * Whether a statement sequence always reaches a `return` on every control-flow
2576
+ * path (so anything textually after it is dead). A bare `return` terminates; an
2577
+ * `if` terminates only when it has an `else` and both branches terminate. Used
2578
+ * by {@link foldBlockToExpr} to decide whether the statements following an `if`
2579
+ * belong to the fall-through (else) path.
2580
+ */
2581
+ function statementsTerminate(stmts: ParsedStatement[]): boolean {
2582
+ for (const s of stmts) {
2583
+ if (s.kind === 'return') return true
2584
+ if (
2585
+ s.kind === 'if' &&
2586
+ s.alternate !== undefined &&
2587
+ statementsTerminate(s.consequent) &&
2588
+ statementsTerminate(s.alternate)
2589
+ ) {
2590
+ return true
2591
+ }
2592
+ }
2593
+ return false
2594
+ }
2595
+
2596
+ /**
2597
+ * Refusal reason when inlining a `const` binding would capture a free variable
2598
+ * of its initializer under a nested callback parameter of the same name. The
2599
+ * let-inline substitution is not a hygienic (alpha-renaming) substitution, so
2600
+ * rather than silently miscompile the callback we refuse and adapters surface
2601
+ * BF101.
2602
+ */
2603
+ export const CAPTURE_BLOCK_REASON =
2604
+ 'Block body cannot be normalized: inlining a `const` binding would capture ' +
2605
+ 'one of its free variables under a nested callback parameter of the same ' +
2606
+ 'name (e.g. `const x = a; … list.map(a => a + x)`). Rename the inner ' +
2607
+ 'parameter, or move the body to a `/* @client */` value so it runs natively ' +
2608
+ 'on the client.'
2609
+
2610
+ /**
2611
+ * Refusal reason when a `const` initializer may have side effects (it contains
2612
+ * a function/method call) and the binding is NOT used exactly once on a single
2613
+ * runtime path. Let-inline substitutes the initializer at each use site, which
2614
+ * would drop the effect (zero uses) or duplicate it (multiple uses / a use
2615
+ * inside a callback that runs per element) — exactly the side-effecting shape
2616
+ * the fold must refuse rather than miscompile.
2617
+ */
2618
+ export const IMPURE_INLINE_BLOCK_REASON =
2619
+ 'Block body cannot be normalized: a `const` whose initializer may have side ' +
2620
+ 'effects (a function or method call) is not used exactly once on every path, ' +
2621
+ 'so inlining it would drop the effect on some path or duplicate it on ' +
2622
+ 'another. Bind a pure value, use the binding exactly once unconditionally, ' +
2623
+ 'or move the body to a `/* @client */` value so it runs natively on the client.'
2624
+
2625
+ /**
2626
+ * Options for {@link foldBlockToExpr}.
2627
+ */
2628
+ export interface FoldBlockOptions {
2629
+ /**
2630
+ * Names of zero-argument calls that are idempotent reads with no observable
2631
+ * side effect — chiefly reactive getters (signal / memo accessors), which
2632
+ * return the same value each time within a render. Inlining such a read at
2633
+ * multiple sites is evaluation-count-neutral, so the fold may treat
2634
+ * `getter()` as pure. The caller (e.g. `jsx-to-ir`) supplies the set from its
2635
+ * analyzer-collected signal/memo names; callers without that context (the
2636
+ * plain `convertNode` callback path) omit it and every call stays "possibly
2637
+ * impure". A non-empty arg list or a member-call (`a.b()`) is never treated as
2638
+ * pure by this set.
2639
+ */
2640
+ pureCallNames?: ReadonlySet<string>
2641
+ }
2642
+
2643
+ /**
2644
+ * Whether an expression is provably free of side effects, so it is safe to
2645
+ * inline at any number of use sites. Conservative: a function / method call is
2646
+ * possibly impure, EXCEPT a zero-arg call to a name in `pureCallNames` (an
2647
+ * idempotent reactive getter read). Member access is treated as pure, matching
2648
+ * `substituteDestructuredFields` and the rest of the compiler's expression
2649
+ * handling.
2650
+ */
2651
+ function isPureInit(e: ParsedExpr, pureCallNames?: ReadonlySet<string>): boolean {
2652
+ const pure = (x: ParsedExpr) => isPureInit(x, pureCallNames)
2653
+ switch (e.kind) {
2654
+ case 'identifier':
2655
+ case 'literal':
2656
+ case 'regex':
2657
+ return true
2658
+ case 'member':
2659
+ return pure(e.object)
2660
+ case 'index-access':
2661
+ return pure(e.object) && pure(e.index)
2662
+ case 'binary':
2663
+ case 'logical':
2664
+ return pure(e.left) && pure(e.right)
2665
+ case 'unary':
2666
+ return pure(e.argument)
2667
+ case 'conditional':
2668
+ return pure(e.test) && pure(e.consequent) && pure(e.alternate)
2669
+ case 'template-literal':
2670
+ return e.parts.every(p => p.type !== 'expression' || pure(p.expr))
2671
+ case 'array-literal':
2672
+ return e.elements.every(pure)
2673
+ case 'object-literal':
2674
+ return e.properties.every(p => pure(p.value))
2675
+ case 'call':
2676
+ // A zero-arg reactive getter read (`filter()`, `count()`) is idempotent;
2677
+ // any other call may be effectful or non-deterministic.
2678
+ return (
2679
+ e.callee.kind === 'identifier' &&
2680
+ e.args.length === 0 &&
2681
+ pureCallNames !== undefined &&
2682
+ pureCallNames.has(e.callee.name)
2683
+ )
2684
+ // A method call may be effectful; an arrow value can capture impurity;
2685
+ // `unsupported` is opaque. Treat all as possibly impure.
2686
+ case 'array-method':
2687
+ case 'arrow':
2688
+ case 'unsupported':
2689
+ return false
2690
+ }
2691
+ }
2692
+
2693
+ /**
2694
+ * The `{ min, max }` number of times `name` is evaluated on a single runtime
2695
+ * path through `expr` — the minimum-cost path and the maximum-cost path. Used to
2696
+ * decide whether inlining a possibly-impure init is evaluation-count-preserving:
2697
+ * sound only when it is evaluated **exactly once on every path** (`min === 1 &&
2698
+ * max === 1`), so the substituted call neither drops nor duplicates its effect.
2699
+ *
2700
+ * Path semantics:
2701
+ * - `conditional` evaluates the test then exactly one arm → test + the min/max
2702
+ * of the two arms (a binding used in only one arm has `min` 0: it is skipped
2703
+ * on the other path).
2704
+ * - `logical` (`&&` / `||` / `??`) evaluates the left, then the right only on
2705
+ * some paths (short-circuit) → the right contributes to `max` but not `min`.
2706
+ * - a nested `arrow` body may run any number of times (a callback invoked per
2707
+ * element / comparison, or never) → a use inside has `min` 0 and `max`
2708
+ * `Infinity`, forcing an impure binding referenced from a callback to be
2709
+ * refused.
2710
+ */
2711
+ function usesPerPath(name: string, expr: ParsedExpr): { min: number; max: number } {
2712
+ const add = (a: { min: number; max: number }, b: { min: number; max: number }) => ({
2713
+ min: a.min + b.min,
2714
+ max: a.max + b.max,
2715
+ })
2716
+ const sum = (xs: ParsedExpr[]) => xs.reduce((acc, x) => add(acc, walk(x)), { min: 0, max: 0 })
2717
+ const walk = (e: ParsedExpr): { min: number; max: number } => {
2718
+ switch (e.kind) {
2719
+ case 'identifier':
2720
+ return e.name === name ? { min: 1, max: 1 } : { min: 0, max: 0 }
2721
+ case 'literal':
2722
+ case 'regex':
2723
+ case 'unsupported':
2724
+ return { min: 0, max: 0 }
2725
+ case 'member':
2726
+ return walk(e.object)
2727
+ case 'index-access':
2728
+ return add(walk(e.object), walk(e.index))
2729
+ case 'binary':
2730
+ return add(walk(e.left), walk(e.right))
2731
+ case 'logical': {
2732
+ // The right operand is only evaluated on some paths (short-circuit), so
2733
+ // it contributes to the max but never to the guaranteed min.
2734
+ const l = walk(e.left)
2735
+ const r = walk(e.right)
2736
+ return { min: l.min, max: l.max + r.max }
2737
+ }
2738
+ case 'unary':
2739
+ return walk(e.argument)
2740
+ case 'conditional': {
2741
+ const t = walk(e.test)
2742
+ const c = walk(e.consequent)
2743
+ const a = walk(e.alternate)
2744
+ return { min: t.min + Math.min(c.min, a.min), max: t.max + Math.max(c.max, a.max) }
2745
+ }
2746
+ case 'template-literal':
2747
+ return sum(e.parts.flatMap(p => (p.type === 'expression' ? [p.expr] : [])))
2748
+ case 'call':
2749
+ return add(walk(e.callee), sum(e.args))
2750
+ case 'array-literal':
2751
+ return sum(e.elements)
2752
+ case 'array-method':
2753
+ return add(walk(e.object), e.method === 'flat' ? { min: 0, max: 0 } : sum(e.args))
2754
+ case 'object-literal':
2755
+ return sum(e.properties.map(p => p.value))
2756
+ case 'arrow':
2757
+ // A callback body may run any number of times (per element, or never).
2758
+ return walk(e.body).max > 0 ? { min: 0, max: Number.POSITIVE_INFINITY } : { min: 0, max: 0 }
2759
+ }
2760
+ }
2761
+ return walk(expr)
2762
+ }
2763
+
2764
+ /**
2765
+ * Inline `name → value` everywhere it appears free in `expr` (the let-inline
2766
+ * step). Returns `null` if the substitution would capture a free variable of
2767
+ * `value` under a nested callback parameter of the same name — that shape is
2768
+ * unsound to inline non-hygienically, so the caller refuses with
2769
+ * {@link CAPTURE_BLOCK_REASON}. A nested arrow parameter that shadows `name`
2770
+ * leaves that inner reference untouched (it is the parameter, not the binding).
2771
+ * Mirrors the structural walk of `substituteDestructuredFields`; every
2772
+ * `ParsedExpr` kind is handled so a new kind surfaces as a compile error here.
2773
+ */
2774
+ function inlineBinding(
2775
+ expr: ParsedExpr,
2776
+ name: string,
2777
+ value: ParsedExpr,
2778
+ ): ParsedExpr | null {
2779
+ // Free variables of `value` that an enclosing callback parameter could capture.
2780
+ const valueFree = new Set<string>()
2781
+ collectIdentifiers(value, valueFree)
2782
+ let captured = false
2783
+
2784
+ const walk = (e: ParsedExpr, enclosing: ReadonlySet<string>): ParsedExpr => {
2785
+ switch (e.kind) {
2786
+ case 'identifier': {
2787
+ if (e.name !== name) return e
2788
+ // Shadowed by an enclosing callback param → this is the parameter, not
2789
+ // the binding; leave it.
2790
+ if (enclosing.has(e.name)) return e
2791
+ // Inlining here: does any enclosing param capture a free var of `value`?
2792
+ for (const p of enclosing) {
2793
+ if (valueFree.has(p)) {
2794
+ captured = true
2795
+ return e
2796
+ }
2797
+ }
2798
+ return value
2799
+ }
2800
+ case 'call':
2801
+ return { kind: 'call', callee: walk(e.callee, enclosing), args: e.args.map(a => walk(a, enclosing)) }
2802
+ case 'member':
2803
+ return { kind: 'member', object: walk(e.object, enclosing), property: e.property, computed: e.computed }
2804
+ case 'index-access':
2805
+ return { kind: 'index-access', object: walk(e.object, enclosing), index: walk(e.index, enclosing) }
2806
+ case 'binary':
2807
+ return { kind: 'binary', op: e.op, left: walk(e.left, enclosing), right: walk(e.right, enclosing) }
2808
+ case 'logical':
2809
+ return { kind: 'logical', op: e.op, left: walk(e.left, enclosing), right: walk(e.right, enclosing) }
2810
+ case 'unary':
2811
+ return { kind: 'unary', op: e.op, argument: walk(e.argument, enclosing) }
2812
+ case 'conditional':
2813
+ return { kind: 'conditional', test: walk(e.test, enclosing), consequent: walk(e.consequent, enclosing), alternate: walk(e.alternate, enclosing) }
2814
+ case 'template-literal':
2815
+ return {
2816
+ kind: 'template-literal',
2817
+ parts: e.parts.map(p =>
2818
+ p.type === 'expression' ? { type: 'expression', expr: walk(p.expr, enclosing) } : p,
2819
+ ),
2820
+ }
2821
+ case 'arrow': {
2822
+ const innerEnclosing = e.params.length === 0 ? enclosing : new Set([...enclosing, ...e.params])
2823
+ return { kind: 'arrow', params: e.params, body: walk(e.body, innerEnclosing) }
2824
+ }
2825
+ case 'array-literal':
2826
+ return { kind: 'array-literal', elements: e.elements.map(el => walk(el, enclosing)) }
2827
+ case 'array-method':
2828
+ if (e.method === 'flat') {
2829
+ return { kind: 'array-method', method: 'flat', object: walk(e.object, enclosing), args: [], flatDepth: e.flatDepth }
2830
+ }
2831
+ return { kind: 'array-method', method: e.method, object: walk(e.object, enclosing), args: e.args.map(a => walk(a, enclosing)) }
2832
+ case 'object-literal':
2833
+ return {
2834
+ kind: 'object-literal',
2835
+ properties: e.properties.map(p => ({ ...p, value: walk(p.value, enclosing) })),
2836
+ raw: e.raw,
2837
+ }
2838
+ case 'literal':
2839
+ case 'regex':
2840
+ case 'unsupported':
2841
+ return e
2842
+ }
2843
+ }
2844
+
2845
+ const result = walk(expr, new Set())
2846
+ return captured ? null : result
2847
+ }
2848
+
2849
+ /**
2850
+ * Fold a value-producing block body — a {@link ParsedStatement} sequence of
2851
+ * `const` bindings, value-producing `if` / early `return`, and a terminal
2852
+ * `return` — into a single {@link ParsedExpr}, so block-bodied memos / derived /
2853
+ * callbacks flow through the same expression surface as expression-bodied ones
2854
+ * (#2040, carved from #2018 stage 5). This generalizes the per-idiom block-memo
2855
+ * recognizers (#1897 / #1945 / #2015) the same way the evaluator replaced the
2856
+ * `bf_sort` / `bf_reduce` catalogue: one normalization, no growing pattern list.
2857
+ *
2858
+ * Transformations:
2859
+ * - `const x = <init>; …` → inline `x`'s init into the rest (let-inline).
2860
+ * - `if (c) <then> [else <else>] …` → `c ? fold(then-path) : fold(else-path)`,
2861
+ * where a branch that does not itself terminate continues into the
2862
+ * statements following the `if` (the early-return idiom).
2863
+ * - `return <v>` → `<v>`.
2864
+ *
2865
+ * The rest is folded first, leaving each binding as a free identifier, so its
2866
+ * use count can be measured before inlining. Inlining is refused (→ `ok: false`)
2867
+ * when it would be unsound:
2868
+ * - a possibly-impure init (one containing a call) used zero or more than once
2869
+ * on a path would drop or duplicate the side effect (a pure init is always
2870
+ * safe to inline any number of times);
2871
+ * - a substitution would capture a free variable of the init under a nested
2872
+ * callback parameter of the same name (substitution is not hygienic).
2873
+ *
2874
+ * Returns `{ ok: false }` for a sequence that cannot produce a value on some
2875
+ * path (falls through with no `return`) — the genuinely-imperative residue that
2876
+ * {@link IMPERATIVE_BLOCK_REASON} describes. The input is assumed to be the
2877
+ * STRICT parse ({@link parseBlockBody}, not the tolerant variant): every source
2878
+ * statement is represented, so a `false` here reflects the real shape rather
2879
+ * than a silently-dropped statement.
2880
+ */
2881
+ export function foldBlockToExpr(
2882
+ stmts: ParsedStatement[],
2883
+ opts?: FoldBlockOptions,
2884
+ ): { ok: true; expr: ParsedExpr } | { ok: false; reason: string } {
2885
+ if (stmts.length === 0) {
2886
+ return { ok: false, reason: IMPERATIVE_BLOCK_REASON }
2887
+ }
2888
+ const [head, ...rest] = stmts
2889
+ switch (head.kind) {
2890
+ case 'var-decl': {
2891
+ // Fold the remaining statements first, leaving `head.name` free so its use
2892
+ // count can drive the soundness check. Any earlier-binding references in
2893
+ // the rest are inlined by the enclosing `var-decl` frames; references to
2894
+ // `head.name` inside `head.init` cannot occur (a `const` can't read itself).
2895
+ const restFold = foldBlockToExpr(rest, opts)
2896
+ if (!restFold.ok) return restFold
2897
+ const uses = usesPerPath(head.name, restFold.expr)
2898
+ // A possibly-impure init is only safe to inline when it is evaluated
2899
+ // exactly once on EVERY path — same as the original block, which runs the
2900
+ // `const` initializer unconditionally once. `min !== 1` catches an effect
2901
+ // dropped on some path (unused, or used in only one ternary arm / a
2902
+ // short-circuited operand / a callback); `max !== 1` catches duplication.
2903
+ // A pure init is safe at any count (drop / duplicate is unobservable);
2904
+ // idempotent reactive getter reads in `pureCallNames` count as pure.
2905
+ if (!isPureInit(head.init, opts?.pureCallNames) && !(uses.min === 1 && uses.max === 1)) {
2906
+ return { ok: false, reason: IMPURE_INLINE_BLOCK_REASON }
2907
+ }
2908
+ const inlined = inlineBinding(restFold.expr, head.name, head.init)
2909
+ if (inlined === null) {
2910
+ return { ok: false, reason: CAPTURE_BLOCK_REASON }
2911
+ }
2912
+ return { ok: true, expr: inlined }
2913
+ }
2914
+ case 'return':
2915
+ return { ok: true, expr: head.value }
2916
+ case 'if': {
2917
+ // A branch that doesn't return falls through to the statements after the
2918
+ // `if` (early-return idiom). A branch that returns makes `rest` dead for
2919
+ // that path, so it is not appended. `rest` is intentionally duplicated
2920
+ // into both fall-through paths; because each path is a separate ternary
2921
+ // arm, a binding used once per arm is still evaluated at most once per
2922
+ // runtime path.
2923
+ const thenPath = statementsTerminate(head.consequent)
2924
+ ? head.consequent
2925
+ : [...head.consequent, ...rest]
2926
+ const elseBase = head.alternate ?? []
2927
+ const elsePath = statementsTerminate(elseBase)
2928
+ ? elseBase
2929
+ : [...elseBase, ...rest]
2930
+ const consequent = foldBlockToExpr(thenPath, opts)
2931
+ if (!consequent.ok) return consequent
2932
+ const alternate = foldBlockToExpr(elsePath, opts)
2933
+ if (!alternate.ok) return alternate
2934
+ return {
2935
+ ok: true,
2936
+ expr: {
2937
+ kind: 'conditional',
2938
+ test: head.condition,
2939
+ consequent: consequent.expr,
2940
+ alternate: alternate.expr,
2941
+ },
2942
+ }
2943
+ }
2944
+ }
2945
+ }
2946
+
2947
+ /**
2948
+ * Rewrite a ternary whose arms are used in BOOLEAN context — e.g. the result of
2949
+ * folding a block-bodied filter predicate (`if (c) return A; return B` →
2950
+ * `c ? A : B`) — into an equivalent `&&` / `||` expression, so it flows through
2951
+ * the ordinary boolean-expression lowering instead of needing a dedicated
2952
+ * block-condition renderer per adapter (#2040). Boolean-literal arms collapse:
2953
+ *
2954
+ * c ? true : false → c
2955
+ * c ? true : f → c || f
2956
+ * c ? t : false → c && t
2957
+ * c ? false : f → !c && f
2958
+ * c ? t : true → !c || t
2959
+ * c ? t : f → (c && t) || (!c && f)
2960
+ *
2961
+ * Arms are flattened recursively (an `else if` chain is a nested ternary); the
2962
+ * test is left as-is. Only valid where the consumer interprets the value as a
2963
+ * boolean (a filter predicate). Non-conditional input is returned unchanged.
2964
+ */
2965
+ export function predicateTernaryToLogical(expr: ParsedExpr): ParsedExpr {
2966
+ if (expr.kind !== 'conditional') return expr
2967
+ const cond = expr.test
2968
+ const t = predicateTernaryToLogical(expr.consequent)
2969
+ const f = predicateTernaryToLogical(expr.alternate)
2970
+ const isTrue = (x: ParsedExpr) => x.kind === 'literal' && x.literalType === 'boolean' && x.value === true
2971
+ const isFalse = (x: ParsedExpr) => x.kind === 'literal' && x.literalType === 'boolean' && x.value === false
2972
+ const not = (x: ParsedExpr): ParsedExpr => ({ kind: 'unary', op: '!', argument: x })
2973
+ const and = (a: ParsedExpr, b: ParsedExpr): ParsedExpr => ({ kind: 'logical', op: '&&', left: a, right: b })
2974
+ const or = (a: ParsedExpr, b: ParsedExpr): ParsedExpr => ({ kind: 'logical', op: '||', left: a, right: b })
2975
+ if (isTrue(t) && isFalse(f)) return cond
2976
+ if (isTrue(t)) return or(cond, f)
2977
+ if (isFalse(f)) return and(cond, t)
2978
+ if (isFalse(t)) return and(not(cond), f)
2979
+ if (isTrue(f)) return or(not(cond), t)
2980
+ return or(and(cond, t), and(not(cond), f))
2981
+ }
2982
+
2928
2983
  /**
2929
2984
  * Parse an if branch (then or else) into ParsedStatement array.
2930
2985
  */
@@ -2975,28 +3030,17 @@ export function exprToString(expr: ParsedExpr): string {
2975
3030
  return '`' + expr.parts.map(p =>
2976
3031
  p.type === 'string' ? p.value : `\${${exprToString(p.expr)}}`
2977
3032
  ).join('') + '`'
2978
- case 'arrow-fn':
2979
- return `${expr.param} => ${exprToString(expr.body)}`
2980
- case 'higher-order':
2981
- return `${exprToString(expr.object)}.${expr.method}(${expr.param} => ${exprToString(expr.predicate)})`
3033
+ case 'arrow': {
3034
+ // Single-param arrows round-trip without parens (`x => …`); multi-param
3035
+ // need them (`(a, b) => …`).
3036
+ const params = expr.params.length === 1 ? expr.params[0] : `(${expr.params.join(', ')})`
3037
+ return `${params} => ${exprToString(expr.body)}`
3038
+ }
3039
+ case 'regex':
3040
+ return expr.raw
2982
3041
  case 'array-literal':
2983
3042
  return `[${expr.elements.map(exprToString).join(', ')}]`
2984
3043
  case 'array-method':
2985
- if (expr.method === 'sort' || expr.method === 'toSorted') {
2986
- // Reconstruct against the user's actual param names — the
2987
- // comparator body in `raw` references them directly, so
2988
- // hardcoding `(a,b)` would produce un-re-parseable output
2989
- // for any user who wrote e.g. `(lhs, rhs) => lhs - rhs`.
2990
- const { paramA, paramB, raw } = expr.comparator
2991
- return `${exprToString(expr.object)}.${expr.method}((${paramA},${paramB}) => ${raw})`
2992
- }
2993
- if (expr.method === 'reduce' || expr.method === 'reduceRight') {
2994
- const { paramAcc, paramItem, raw, type, init } = expr.reduceOp
2995
- // `init` is the decoded value: re-quote a string seed, re-emit a
2996
- // numeric seed as-is (it's already a valid number literal).
2997
- const initSrc = type === 'string' ? JSON.stringify(init) : init
2998
- return `${exprToString(expr.object)}.${expr.method}((${paramAcc},${paramItem}) => ${raw}, ${initSrc})`
2999
- }
3000
3044
  if (expr.method === 'flat') {
3001
3045
  // Preserve the normalised depth so diagnostics don't misleadingly
3002
3046
  // print `.flat()` for a `.flat(2)` / `.flat(Infinity)` source.
@@ -3004,12 +3048,11 @@ export function exprToString(expr: ParsedExpr): string {
3004
3048
  const depthSrc = d === 'infinity' ? 'Infinity' : String(d)
3005
3049
  return `${exprToString(expr.object)}.flat(${d === 1 ? '' : depthSrc})`
3006
3050
  }
3007
- if (expr.method === 'flatMap') {
3008
- const { param, raw } = expr.flatMapOp
3009
- return `${exprToString(expr.object)}.flatMap(${param} => ${raw})`
3010
- }
3011
3051
  return `${exprToString(expr.object)}.${expr.method}(${expr.args.map(exprToString).join(', ')})`
3012
3052
  case 'unsupported':
3053
+ // `raw` holds the original expression string (same value the old
3054
+ // `unsupported` carried), so the round-trip stays byte-identical.
3055
+ case 'object-literal':
3013
3056
  return `[UNSUPPORTED: ${expr.raw}]`
3014
3057
  }
3015
3058
  }
@@ -3058,30 +3101,19 @@ export function stringifyParsedExpr(expr: ParsedExpr): string {
3058
3101
  return '`' + expr.parts.map(p =>
3059
3102
  p.type === 'string' ? p.value : `\${${stringifyParsedExpr(p.expr)}}`
3060
3103
  ).join('') + '`'
3061
- case 'arrow-fn':
3062
- return `${expr.param} => ${stringifyParsedExpr(expr.body)}`
3063
- case 'higher-order':
3064
- return `${stringifyParsedExpr(expr.object)}.${expr.method}(${expr.param} => ${stringifyParsedExpr(expr.predicate)})`
3104
+ case 'arrow': {
3105
+ // Round-trip to valid JS for the CSR / Hono path and downstream
3106
+ // re-parsers. A higher-order callback (`.sort`/`.filter`/…) reaches here
3107
+ // as the arrow argument of a generic `call`. Single-param arrows round-
3108
+ // trip without parens (`x => …`); multi-param need them (`(a, b) => …`).
3109
+ const params = expr.params.length === 1 ? expr.params[0] : `(${expr.params.join(', ')})`
3110
+ return `${params} => ${stringifyParsedExpr(expr.body)}`
3111
+ }
3112
+ case 'regex':
3113
+ return expr.raw
3065
3114
  case 'array-literal':
3066
3115
  return `[${expr.elements.map(stringifyParsedExpr).join(', ')}]`
3067
3116
  case 'array-method':
3068
- if (expr.method === 'sort' || expr.method === 'toSorted') {
3069
- // Round-trip the original param names so downstream
3070
- // re-parsers (templatePrimitive substitution etc.) see
3071
- // valid JS — `raw` references the user's names verbatim.
3072
- const { paramA, paramB, raw } = expr.comparator
3073
- return `${stringifyParsedExpr(expr.object)}.${expr.method}((${paramA},${paramB}) => ${raw})`
3074
- }
3075
- if (expr.method === 'reduce' || expr.method === 'reduceRight') {
3076
- // Round-trip the user's param names + init so downstream
3077
- // re-parsers (the CSR / Hono JS path, templatePrimitive
3078
- // substitution) see valid JS — `raw` references the names
3079
- // verbatim. `init` is the decoded value: re-quote a string
3080
- // seed via JSON.stringify, re-emit a numeric seed as-is.
3081
- const { paramAcc, paramItem, raw, type, init } = expr.reduceOp
3082
- const initSrc = type === 'string' ? JSON.stringify(init) : init
3083
- return `${stringifyParsedExpr(expr.object)}.${expr.method}((${paramAcc},${paramItem}) => ${raw}, ${initSrc})`
3084
- }
3085
3117
  if (expr.method === 'flat') {
3086
3118
  // Round-trip the normalised depth back to JS for the CSR / Hono
3087
3119
  // path: `'infinity'` → `Infinity`, `1` is left implicit (`.flat()`).
@@ -3089,18 +3121,432 @@ export function stringifyParsedExpr(expr: ParsedExpr): string {
3089
3121
  const depthSrc = d === 'infinity' ? 'Infinity' : String(d)
3090
3122
  return `${stringifyParsedExpr(expr.object)}.flat(${d === 1 ? '' : depthSrc})`
3091
3123
  }
3092
- if (expr.method === 'flatMap') {
3093
- // Round-trip the user's callback param + body so the CSR / Hono
3094
- // path re-parses valid JS (`raw` references the param verbatim).
3095
- const { param, raw } = expr.flatMapOp
3096
- return `${stringifyParsedExpr(expr.object)}.flatMap(${param} => ${raw})`
3097
- }
3098
3124
  return `${stringifyParsedExpr(expr.object)}.${expr.method}(${expr.args.map(stringifyParsedExpr).join(', ')})`
3099
3125
  case 'unsupported':
3126
+ // `raw` is the original expression string, so re-stringification is
3127
+ // byte-identical to the pre-`object-literal` behaviour (Roadmap A-1).
3128
+ case 'object-literal':
3100
3129
  return expr.raw
3101
3130
  }
3102
3131
  }
3103
3132
 
3133
+ /**
3134
+ * Rewrite every zero-arg `call` node whose callee is a bare identifier in
3135
+ * `names` into that identifier — `tag()` → `tag` — leaving everything else
3136
+ * untouched. Returns a new tree; the input is never mutated.
3137
+ *
3138
+ * Rationale: in an SSR seed/constructor context a signal/memo getter call
3139
+ * reads the already-computed SEEDED value, so `tag()` reduces to "the value
3140
+ * bound to `tag`". Materialising the call lets the runtime evaluator (which
3141
+ * refuses any non-builtin call, {@link toEvalNode}'s `evalBuiltinCalleeName`
3142
+ * gate) evaluate a predicate that reads sibling memos — e.g. a `.filter`
3143
+ * predicate `(p) => !tag() || p.tags.includes(tag())` — with the getter's
3144
+ * value supplied through the evaluator's `base_env` instead of an
3145
+ * unsupported call node. `names` is caller-supplied (typically the sibling
3146
+ * signals/memos seeded alongside the derived memo being lowered), so a call
3147
+ * to an unrelated function is left as a `call` node and still refused by the
3148
+ * evaluator's builtin gate if it reaches `serializeParsedExpr`.
3149
+ */
3150
+ export function materializeGetterCalls(expr: ParsedExpr, names: ReadonlySet<string>): ParsedExpr {
3151
+ const rw = (e: ParsedExpr): ParsedExpr => materializeGetterCalls(e, names)
3152
+ switch (expr.kind) {
3153
+ case 'call':
3154
+ if (
3155
+ expr.args.length === 0 &&
3156
+ expr.callee.kind === 'identifier' &&
3157
+ names.has(expr.callee.name)
3158
+ ) {
3159
+ return { kind: 'identifier', name: expr.callee.name }
3160
+ }
3161
+ return { kind: 'call', callee: rw(expr.callee), args: expr.args.map(rw) }
3162
+ case 'binary':
3163
+ return { kind: 'binary', op: expr.op, left: rw(expr.left), right: rw(expr.right) }
3164
+ case 'logical':
3165
+ return { kind: 'logical', op: expr.op, left: rw(expr.left), right: rw(expr.right) }
3166
+ case 'unary':
3167
+ return { kind: 'unary', op: expr.op, argument: rw(expr.argument) }
3168
+ case 'conditional':
3169
+ return {
3170
+ kind: 'conditional',
3171
+ test: rw(expr.test),
3172
+ consequent: rw(expr.consequent),
3173
+ alternate: rw(expr.alternate),
3174
+ }
3175
+ case 'member':
3176
+ return { kind: 'member', object: rw(expr.object), property: expr.property, computed: expr.computed }
3177
+ case 'index-access':
3178
+ return { kind: 'index-access', object: rw(expr.object), index: rw(expr.index) }
3179
+ case 'template-literal':
3180
+ return {
3181
+ kind: 'template-literal',
3182
+ parts: expr.parts.map(p => (p.type === 'string' ? p : { type: 'expression', expr: rw(p.expr) })),
3183
+ }
3184
+ case 'array-literal':
3185
+ return { kind: 'array-literal', elements: expr.elements.map(rw) }
3186
+ case 'array-method':
3187
+ // `flat`'s `args` is always `[]` (the depth is carried structurally in
3188
+ // `flatDepth`, not `args`) — still rewrite `object`, just skip the
3189
+ // `args.map` that every other method needs.
3190
+ if (expr.method === 'flat') return { ...expr, object: rw(expr.object) }
3191
+ return { ...expr, object: rw(expr.object), args: expr.args.map(rw) }
3192
+ case 'object-literal':
3193
+ return {
3194
+ kind: 'object-literal',
3195
+ raw: expr.raw,
3196
+ properties: expr.properties.map(p => ({ ...p, value: rw(p.value) })),
3197
+ }
3198
+ case 'arrow':
3199
+ return { kind: 'arrow', params: expr.params, body: rw(expr.body) }
3200
+ // Leaves / opaque shapes — nothing to rewrite.
3201
+ case 'identifier':
3202
+ case 'literal':
3203
+ case 'regex':
3204
+ case 'unsupported':
3205
+ return expr
3206
+ }
3207
+ }
3208
+
3209
+ /**
3210
+ * Serialize a pure-expression `ParsedExpr` (a higher-order callback body) into
3211
+ * the minimal JSON the runtime evaluator consumes — the format pinned by the
3212
+ * `eval-vectors` golden cases and read by Go `eval.go` `EvalNode` / Perl
3213
+ * `Evaluator.pm` `evaluate`. Only the evaluator-recognized fields are emitted
3214
+ * per kind (a literal carries just `value`; `literalType` / `raw` are dropped —
3215
+ * the evaluator never reads them; `member.computed` is kept when set so a
3216
+ * computed member stays distinguishable), keeping the embedded body blob small
3217
+ * and stable.
3218
+ *
3219
+ * Returns `null` when the tree contains a shape outside the evaluator's surface
3220
+ * — a folded `higher-order` / `array-method`, an `arrow-fn`, an `unsupported`
3221
+ * node, an operator the evaluator doesn't implement, or a `call` whose callee
3222
+ * isn't an allowlisted builtin (`Math.*` / `String` / `Number` / `Boolean`) — so
3223
+ * the caller refuses the body (BF101 / `@client`) instead of emitting a blob the
3224
+ * evaluator would read as nil. The evaluator's support criterion is
3225
+ * purely-functional expressibility; this is its compile-time gate. (#2018)
3226
+ */
3227
+ export function serializeParsedExpr(expr: ParsedExpr): string | null {
3228
+ const node = toEvalNode(expr)
3229
+ return node === null ? null : JSON.stringify(node)
3230
+ }
3231
+
3232
+ /**
3233
+ * The free variables a higher-order callback body references — every bare
3234
+ * identifier in a value position, minus the callback's own `params`. The
3235
+ * adapter materializes each into the evaluator's `base_env` (mapping the JS name
3236
+ * to its SSR value). Walks exactly the value positions {@link serializeParsedExpr}
3237
+ * serializes (so it sees object-literal *values* and template-expression parts,
3238
+ * and skips member property names / object keys, which are not references).
3239
+ * Returns a sorted, de-duplicated list for stable emit. (#2018)
3240
+ */
3241
+ export function freeVarsInBody(body: ParsedExpr, params: ReadonlySet<string>): string[] {
3242
+ const found = new Set<string>()
3243
+ const visit = (e: ParsedExpr): void => {
3244
+ switch (e.kind) {
3245
+ case 'identifier':
3246
+ if (!params.has(e.name)) found.add(e.name)
3247
+ return
3248
+ case 'binary':
3249
+ case 'logical':
3250
+ visit(e.left)
3251
+ visit(e.right)
3252
+ return
3253
+ case 'unary':
3254
+ visit(e.argument)
3255
+ return
3256
+ case 'conditional':
3257
+ visit(e.test)
3258
+ visit(e.consequent)
3259
+ visit(e.alternate)
3260
+ return
3261
+ case 'member':
3262
+ visit(e.object)
3263
+ return
3264
+ case 'index-access':
3265
+ visit(e.object)
3266
+ visit(e.index)
3267
+ return
3268
+ case 'call':
3269
+ // A builtin callee (`String`/`Number`/`Boolean`, or `Math.<fn>`) is
3270
+ // resolved syntactically by the evaluator — its identifier is NOT a
3271
+ // captured free var. Visiting it would add `Math` / `String` to the
3272
+ // env, making the adapter emit an undefined `$Math` / `.Math` base_env
3273
+ // entry (Copilot review #2031). Skip the callee identifier in that
3274
+ // case; the arguments are still real references and are visited.
3275
+ if (evalBuiltinCalleeName(e.callee) === null) visit(e.callee)
3276
+ e.args.forEach(visit)
3277
+ return
3278
+ case 'template-literal':
3279
+ for (const p of e.parts) if (p.type === 'expression') visit(p.expr)
3280
+ return
3281
+ case 'array-literal':
3282
+ e.elements.forEach(visit)
3283
+ return
3284
+ case 'object-literal':
3285
+ // Object *values* are references; keys are not. (Shorthand `{ x }`
3286
+ // carries the ref on its `value` identifier, which is visited here.)
3287
+ for (const p of e.properties) visit(p.value)
3288
+ return
3289
+ case 'array-method':
3290
+ // Only `.includes(x)` is serializable ({@link toEvalNode}); its
3291
+ // `object` (the receiver) and `args` (the needle) are the value
3292
+ // positions serialized, so visit both when the tree reaches here
3293
+ // with that method. Every other `array-method` is non-serializable
3294
+ // and doesn't occur in a serializable body.
3295
+ if (e.method === 'includes') {
3296
+ visit(e.object)
3297
+ e.args.forEach(visit)
3298
+ }
3299
+ return
3300
+ // Non-serializable kinds don't occur in a serializable body
3301
+ // (serializeParsedExpr returns null for them); nothing to collect.
3302
+ case 'literal':
3303
+ case 'arrow':
3304
+ case 'regex':
3305
+ case 'unsupported':
3306
+ return
3307
+ }
3308
+ }
3309
+ visit(body)
3310
+ return [...found].sort()
3311
+ }
3312
+
3313
+ /**
3314
+ * Every value-position identifier in `expr` NOT bound by an enclosing arrow's
3315
+ * own parameters — with proper lexical scoping: an arrow's params bind only
3316
+ * within that arrow's body, and nested arrows accumulate onto the enclosing
3317
+ * bound set. Unlike {@link freeVarsInBody} (which assumes a single flat param
3318
+ * set and never recurses into nested `arrow` nodes, since a serializable
3319
+ * evaluator body never contains one), this walks the full source-level tree —
3320
+ * including arrows — so a caller can ask "is this name free ANYWHERE in the
3321
+ * expression, honoring each arrow's own scope" rather than only within one
3322
+ * callback body.
3323
+ *
3324
+ * Walks the same value positions as {@link serializeParsedExpr} /
3325
+ * {@link freeVarsInBody}: call callee (skipped when it resolves to an
3326
+ * evaluator builtin — see {@link evalBuiltinCalleeName} — so `Math.floor(x)`
3327
+ * doesn't report `Math` as free) + args, binary/logical/unary operands,
3328
+ * conditional branches, a member's OBJECT only (the property name is not a
3329
+ * reference), an index-access's object + index, template-literal expression
3330
+ * parts, array-literal elements, array-method object + args, and an
3331
+ * object-literal's property VALUES (not keys). An `arrow` recurses into its
3332
+ * body with its own params added to the bound set.
3333
+ *
3334
+ * Returns `null` when the tree contains an `unsupported` node (or any other
3335
+ * shape this walk can't analyze) — the caller must fail safe rather than
3336
+ * assume nothing is free.
3337
+ */
3338
+ export function freeIdentifiers(expr: ParsedExpr): Set<string> | null {
3339
+ const free = new Set<string>()
3340
+
3341
+ function visit(e: ParsedExpr, bound: ReadonlySet<string>): boolean {
3342
+ switch (e.kind) {
3343
+ case 'literal':
3344
+ case 'regex':
3345
+ return true
3346
+ case 'identifier':
3347
+ if (!bound.has(e.name)) free.add(e.name)
3348
+ return true
3349
+ case 'call': {
3350
+ const isBuiltinCallee = evalBuiltinCalleeName(e.callee) !== null
3351
+ if (!isBuiltinCallee && !visit(e.callee, bound)) return false
3352
+ for (const a of e.args) if (!visit(a, bound)) return false
3353
+ return true
3354
+ }
3355
+ case 'member':
3356
+ return visit(e.object, bound)
3357
+ case 'index-access':
3358
+ return visit(e.object, bound) && visit(e.index, bound)
3359
+ case 'binary':
3360
+ case 'logical':
3361
+ return visit(e.left, bound) && visit(e.right, bound)
3362
+ case 'unary':
3363
+ return visit(e.argument, bound)
3364
+ case 'conditional':
3365
+ return visit(e.test, bound) && visit(e.consequent, bound) && visit(e.alternate, bound)
3366
+ case 'template-literal':
3367
+ for (const p of e.parts) {
3368
+ if (p.type === 'expression' && !visit(p.expr, bound)) return false
3369
+ }
3370
+ return true
3371
+ case 'array-literal':
3372
+ for (const el of e.elements) if (!visit(el, bound)) return false
3373
+ return true
3374
+ case 'array-method':
3375
+ if (!visit(e.object, bound)) return false
3376
+ for (const a of e.args) if (!visit(a, bound)) return false
3377
+ return true
3378
+ case 'object-literal':
3379
+ for (const p of e.properties) if (!visit(p.value, bound)) return false
3380
+ return true
3381
+ case 'arrow': {
3382
+ const inner = new Set(bound)
3383
+ for (const p of e.params) inner.add(p)
3384
+ return visit(e.body, inner)
3385
+ }
3386
+ case 'unsupported':
3387
+ return false
3388
+ }
3389
+ }
3390
+
3391
+ return visit(expr, new Set()) ? free : null
3392
+ }
3393
+
3394
+ // Operators the evaluator implements (Go `eval.go` evalBinary / evalUnary, Perl
3395
+ // `Evaluator.pm` _binary / _unary). An op outside these sets — loose `==`,
3396
+ // `instanceof`, `**`, bitwise/shift, or the parser's `'unknown'` sentinel — is
3397
+ // refused so the body falls back to BF101 rather than serializing an op the
3398
+ // evaluator would silently mis-handle.
3399
+ const EVAL_BINARY_OPS: ReadonlySet<string> = new Set([
3400
+ '+', '-', '*', '/', '%', '<', '<=', '>', '>=', '===', '!==',
3401
+ ])
3402
+ const EVAL_UNARY_OPS: ReadonlySet<string> = new Set(['!', '-', '+'])
3403
+
3404
+ // The only call shapes the evaluator executes (Go `eval.go` evalBuiltinName /
3405
+ // evalCallBuiltin, Perl `Evaluator.pm` _call_builtin): a bare `String` / `Number`
3406
+ // / `Boolean`, or a NON-computed `Math.<fn>` for a fixed `<fn>` set. Any other
3407
+ // callee — a bare function (`foo(x)`), a method (`x.bar(...)`), or a *computed*
3408
+ // builtin (`Math['max']`, which the evaluator rejects) — evaluates to nil at
3409
+ // runtime, so the gate refuses it at compile time instead (BF101 / `@client`).
3410
+ const EVAL_BUILTIN_IDENTS: ReadonlySet<string> = new Set(['String', 'Number', 'Boolean'])
3411
+ const EVAL_MATH_METHODS: ReadonlySet<string> = new Set([
3412
+ 'max', 'min', 'abs', 'floor', 'ceil', 'round',
3413
+ ])
3414
+
3415
+ /** The allowlisted builtin name a call callee resolves to (`Math.max` / `String`), or null. */
3416
+ function evalBuiltinCalleeName(callee: ParsedExpr): string | null {
3417
+ if (callee.kind === 'identifier') {
3418
+ return EVAL_BUILTIN_IDENTS.has(callee.name) ? callee.name : null
3419
+ }
3420
+ if (
3421
+ callee.kind === 'member' &&
3422
+ !callee.computed &&
3423
+ callee.object.kind === 'identifier' &&
3424
+ callee.object.name === 'Math' &&
3425
+ EVAL_MATH_METHODS.has(callee.property)
3426
+ ) {
3427
+ return `Math.${callee.property}`
3428
+ }
3429
+ return null
3430
+ }
3431
+
3432
+ /** Build the evaluator's minimal node object, or null for an out-of-surface kind. */
3433
+ function toEvalNode(e: ParsedExpr): Record<string, unknown> | null {
3434
+ switch (e.kind) {
3435
+ case 'literal':
3436
+ return { kind: 'literal', value: e.value }
3437
+ case 'identifier':
3438
+ return { kind: 'identifier', name: e.name }
3439
+ case 'binary': {
3440
+ if (!EVAL_BINARY_OPS.has(e.op)) return null
3441
+ const left = toEvalNode(e.left)
3442
+ const right = toEvalNode(e.right)
3443
+ return left && right ? { kind: 'binary', op: e.op, left, right } : null
3444
+ }
3445
+ case 'logical': {
3446
+ // `op` is the fixed `&&` | `||` | `??` union — all evaluator-supported.
3447
+ const left = toEvalNode(e.left)
3448
+ const right = toEvalNode(e.right)
3449
+ return left && right ? { kind: 'logical', op: e.op, left, right } : null
3450
+ }
3451
+ case 'unary': {
3452
+ if (!EVAL_UNARY_OPS.has(e.op)) return null
3453
+ const argument = toEvalNode(e.argument)
3454
+ return argument ? { kind: 'unary', op: e.op, argument } : null
3455
+ }
3456
+ case 'conditional': {
3457
+ const test = toEvalNode(e.test)
3458
+ const consequent = toEvalNode(e.consequent)
3459
+ const alternate = toEvalNode(e.alternate)
3460
+ return test && consequent && alternate
3461
+ ? { kind: 'conditional', test, consequent, alternate }
3462
+ : null
3463
+ }
3464
+ case 'member': {
3465
+ const object = toEvalNode(e.object)
3466
+ if (!object) return null
3467
+ // Carry `computed` only when set (absent reads as `false`): the evaluator
3468
+ // reads it to reject a computed builtin (`Math['max']`), so preserving it
3469
+ // keeps a computed member distinguishable from a plain `.prop` access. (A
3470
+ // computed builtin *call* is already refused by the callee gate above.)
3471
+ const node: Record<string, unknown> = { kind: 'member', object, property: e.property }
3472
+ if (e.computed) node.computed = true
3473
+ return node
3474
+ }
3475
+ case 'index-access': {
3476
+ const object = toEvalNode(e.object)
3477
+ const index = toEvalNode(e.index)
3478
+ return object && index ? { kind: 'index-access', object, index } : null
3479
+ }
3480
+ case 'call': {
3481
+ // The evaluator executes only the builtin allowlist; a non-builtin callee
3482
+ // would evaluate to nil at runtime, so refuse it here (the purity gate).
3483
+ if (evalBuiltinCalleeName(e.callee) === null) return null
3484
+ const callee = toEvalNode(e.callee)
3485
+ if (!callee) return null
3486
+ const args: Record<string, unknown>[] = []
3487
+ for (const a of e.args) {
3488
+ const c = toEvalNode(a)
3489
+ if (!c) return null
3490
+ args.push(c)
3491
+ }
3492
+ return { kind: 'call', callee, args }
3493
+ }
3494
+ case 'template-literal': {
3495
+ const parts: Record<string, unknown>[] = []
3496
+ for (const p of e.parts) {
3497
+ if (p.type === 'string') {
3498
+ parts.push({ type: 'string', value: p.value })
3499
+ } else {
3500
+ const expr = toEvalNode(p.expr)
3501
+ if (!expr) return null
3502
+ parts.push({ type: 'expression', expr })
3503
+ }
3504
+ }
3505
+ return { kind: 'template-literal', parts }
3506
+ }
3507
+ case 'array-literal': {
3508
+ const elements: Record<string, unknown>[] = []
3509
+ for (const el of e.elements) {
3510
+ const c = toEvalNode(el)
3511
+ if (!c) return null
3512
+ elements.push(c)
3513
+ }
3514
+ return { kind: 'array-literal', elements }
3515
+ }
3516
+ case 'object-literal': {
3517
+ const properties: Record<string, unknown>[] = []
3518
+ for (const p of e.properties) {
3519
+ const value = toEvalNode(p.value)
3520
+ if (!value) return null
3521
+ properties.push({ key: p.key, value })
3522
+ }
3523
+ return { kind: 'object-literal', properties }
3524
+ }
3525
+ case 'array-method': {
3526
+ // `.includes(x)` is the one `array-method` the evaluator executes
3527
+ // (Go `eval.go` / Perl `Evaluator.pm`, includes support): the
3528
+ // receiver-type dispatch (array SameValueZero membership vs string
3529
+ // substring) happens at evaluator runtime, same as the SSR template
3530
+ // lowering's `bf_includes` / `$bf->includes`. Every other
3531
+ // `array-method` (`join`, `slice`, `flat`, …) is outside the
3532
+ // evaluator's surface and refuses below.
3533
+ if (e.method === 'includes' && e.args.length === 1) {
3534
+ const object = toEvalNode(e.object)
3535
+ const arg = toEvalNode(e.args[0])
3536
+ return object && arg ? { kind: 'array-method', method: 'includes', object, args: [arg] } : null
3537
+ }
3538
+ return null
3539
+ }
3540
+ // Outside the evaluator's pure-expression surface — refuse so the caller
3541
+ // falls back to BF101 / `@client`. A nested `arrow` (a callback inside the
3542
+ // body) is refused here, keeping the evaluator non-recursive.
3543
+ case 'arrow':
3544
+ case 'regex':
3545
+ case 'unsupported':
3546
+ return null
3547
+ }
3548
+ }
3549
+
3104
3550
  /**
3105
3551
  * Extract the textual identifier path from a parsed expression's
3106
3552
  * callee — `{kind:'identifier', name:'String'}` → `"String"`,