@barefootjs/jsx 0.15.2 → 0.17.0

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 (61) hide show
  1. package/dist/adapters/env-signal.d.ts +38 -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/expression-parser.d.ts +219 -163
  12. package/dist/expression-parser.d.ts.map +1 -1
  13. package/dist/index.d.ts +9 -6
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +6892 -6118
  16. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/plan/build-declaration-emit.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +9 -0
  19. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  20. package/dist/jsx-to-ir.d.ts.map +1 -1
  21. package/dist/lowering-registry.d.ts +122 -0
  22. package/dist/lowering-registry.d.ts.map +1 -0
  23. package/dist/profiler.d.ts +115 -0
  24. package/dist/profiler.d.ts.map +1 -1
  25. package/dist/query-href-lowering.d.ts +63 -0
  26. package/dist/query-href-lowering.d.ts.map +1 -0
  27. package/dist/ssr-defaults.d.ts.map +1 -1
  28. package/dist/types.d.ts +169 -11
  29. package/dist/types.d.ts.map +1 -1
  30. package/package.json +2 -2
  31. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +68 -3
  32. package/src/__tests__/analyzer.test.ts +53 -0
  33. package/src/__tests__/expression-parser.test.ts +703 -391
  34. package/src/__tests__/ir-reduce-op.test.ts +18 -21
  35. package/src/__tests__/ir-sort-comparator.test.ts +19 -20
  36. package/src/__tests__/lowering-registry.test.ts +141 -0
  37. package/src/__tests__/primitive-resolver-alias.test.ts +23 -0
  38. package/src/__tests__/profiler.test.ts +149 -0
  39. package/src/__tests__/query-href-recognition.test.ts +58 -0
  40. package/src/__tests__/serialize-parsed-expr.test.ts +204 -0
  41. package/src/__tests__/unsupported-expression.test.ts +98 -4
  42. package/src/adapters/env-signal.ts +60 -21
  43. package/src/adapters/jsx-adapter.ts +17 -0
  44. package/src/adapters/parsed-expr-emitter.ts +39 -41
  45. package/src/analyzer-context.ts +72 -27
  46. package/src/analyzer.ts +226 -9
  47. package/src/builtin-lowering-plugins.ts +54 -0
  48. package/src/expression-parser.ts +1183 -927
  49. package/src/index.ts +35 -3
  50. package/src/ir-to-client-js/csr-substitute.ts +5 -0
  51. package/src/ir-to-client-js/plan/build-declaration-emit.ts +16 -0
  52. package/src/ir-to-client-js/plan/declaration-emit.ts +9 -0
  53. package/src/ir-to-client-js/stringify/declaration-emit.ts +11 -0
  54. package/src/jsx-to-ir.ts +182 -43
  55. package/src/lowering-registry.ts +160 -0
  56. package/src/profiler.ts +328 -0
  57. package/src/query-href-lowering.ts +147 -0
  58. package/src/ssr-defaults.ts +5 -1
  59. package/src/types.ts +171 -12
  60. package/src/__tests__/flatmap-support.test.ts +0 -218
  61. 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 }
@@ -669,6 +581,72 @@ export function parseExpression(expr: string): ParsedExpr {
669
581
  return convertNode(firstStmt.expression, expr)
670
582
  }
671
583
 
584
+ /**
585
+ * Convert an already-parsed TypeScript node directly into a {@link ParsedExpr},
586
+ * for a consumer that already holds a `ts.Node` (e.g. a `.sort()` callback at
587
+ * the loop-hoist site) and wants the structured conversion without re-parsing
588
+ * source via `ts.createSourceFile`. An `unsupported` result still carries the
589
+ * node's text in `raw` for debugging (a synthetic node with no source file
590
+ * falls back to '').
591
+ */
592
+ export function tsNodeToParsedExpr(node: ts.Node): ParsedExpr {
593
+ let raw = ''
594
+ try {
595
+ raw = node.getText()
596
+ } catch {
597
+ /* synthetic node without a source file */
598
+ }
599
+ return convertNode(node, raw)
600
+ }
601
+
602
+ /**
603
+ * Higher-order array methods whose callback body the runtime evaluator drives
604
+ * (#2018). Recognised generically as a `call` whose callee is `<recv>.<method>`
605
+ * and whose first argument is an `arrow`; the adapter serializes the arrow body
606
+ * to the evaluator. `map` is excluded — a JSX-returning `.map` is an IRLoop
607
+ * upstream, and a value-returning `.map` has no template lowering.
608
+ */
609
+ export const CALLBACK_METHODS: ReadonlySet<string> = new Set([
610
+ 'filter', 'every', 'some', 'find', 'findIndex', 'findLast', 'findLastIndex',
611
+ 'sort', 'toSorted', 'reduce', 'reduceRight', 'flatMap',
612
+ ])
613
+
614
+ /**
615
+ * A recognised higher-order callback method call: `<object>.<method>(<arrow>,
616
+ * …rest)` where `method ∈` {@link CALLBACK_METHODS} and the first argument is a
617
+ * generic `arrow`. Returns the receiver, the callback arrow, and any trailing
618
+ * args (e.g. the `.reduce` init), or `null` if the shape doesn't match. The
619
+ * single recognition point shared by the support gate and the adapter dispatch.
620
+ */
621
+ export function asCallbackMethodCall(expr: ParsedExpr): {
622
+ method: string
623
+ object: ParsedExpr
624
+ arrow: Extract<ParsedExpr, { kind: 'arrow' }>
625
+ args: ParsedExpr[]
626
+ } | null {
627
+ if (expr.kind !== 'call') return null
628
+ if (expr.callee.kind !== 'member' || expr.callee.computed) return null
629
+ if (!CALLBACK_METHODS.has(expr.callee.property)) return null
630
+ const arrow = expr.args[0]
631
+ if (!arrow || arrow.kind !== 'arrow') return null
632
+ return { method: expr.callee.property, object: expr.callee.object, arrow, args: expr.args.slice(1) }
633
+ }
634
+
635
+ /**
636
+ * Resolve a non-computed object-literal property key to its string name.
637
+ * Identifier / string / numeric names resolve to their text; a computed
638
+ * (`[expr]`) or otherwise non-plain key returns null so the caller treats
639
+ * the whole literal as `unsupported`.
640
+ */
641
+ function objectLiteralKeyName(
642
+ name: ts.PropertyName,
643
+ ): { key: string; keyKind: 'identifier' | 'string' | 'numeric' } | null {
644
+ if (ts.isIdentifier(name)) return { key: name.text, keyKind: 'identifier' }
645
+ if (ts.isStringLiteral(name)) return { key: name.text, keyKind: 'string' }
646
+ if (ts.isNumericLiteral(name)) return { key: name.text, keyKind: 'numeric' }
647
+ return null
648
+ }
649
+
672
650
  /**
673
651
  * Convert a TypeScript AST node to ParsedExpr.
674
652
  */
@@ -683,10 +661,13 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
683
661
  return { kind: 'literal', value: node.text, literalType: 'string' }
684
662
  }
685
663
 
686
- // Numeric literal: 0, 5, 3.14
664
+ // Numeric literal: 0, 5, 3.14. Keep `ts.NumericLiteral.text` in `raw` (TS's
665
+ // normalised token — `1_000`/`0x10`/`1e3` → `1000`/`16`/`1000`) so an adapter
666
+ // emits the exact string its own literal lowering already produces; `value`
667
+ // is the parsed number for structural reasoning.
687
668
  if (ts.isNumericLiteral(node)) {
688
669
  const value = parseFloat(node.text)
689
- return { kind: 'literal', value, literalType: 'number' }
670
+ return { kind: 'literal', value, literalType: 'number', raw: node.text }
690
671
  }
691
672
 
692
673
  // Boolean literals and null
@@ -705,37 +686,26 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
705
686
  const callee = convertNode(node.expression, raw)
706
687
  const args = node.arguments.map(arg => convertNode(arg, raw))
707
688
 
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
- }
689
+ // Higher-order callback methods (`.filter`/`.find`/`.every`/`.some`/
690
+ // `.sort`/`.reduce`/`.flatMap`/…) are NOT folded here (#2018 P5): they
691
+ // flow through as a generic `call` whose argument is a generic `arrow`,
692
+ // and the adapter recognises the callback shape at dispatch and serializes
693
+ // the arrow body to the runtime evaluator. The one exception is the
694
+ // non-arrow `.filter(Boolean)` callable: synthesise the equivalent
695
+ // identity arrow `_ => _` so it flows through the same callback lowering
696
+ // (the adapter keeps a dedicated truthiness fallback for the identity body).
697
+ if (
698
+ callee.kind === 'member' &&
699
+ !callee.computed &&
700
+ callee.property === 'filter' &&
701
+ args.length === 1 &&
702
+ args[0].kind === 'identifier' &&
703
+ args[0].name === 'Boolean'
704
+ ) {
705
+ return {
706
+ kind: 'call',
707
+ callee,
708
+ args: [{ kind: 'arrow', params: ['_'], body: { kind: 'identifier', name: '_' } }],
739
709
  }
740
710
  }
741
711
 
@@ -931,9 +901,12 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
931
901
  // replacing the FIRST occurrence (JS semantics for a string
932
902
  // pattern). Go uses `bf_replace` (`strings.Replace` with n=1);
933
903
  // 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
904
+ // regex-literal pattern is the deferred form — its `regex` first
905
+ // arg is carried STRUCTURALLY (not collapsed to `unsupported`) so
906
+ // the Go ctor lowering can recover the one trailing-slash pattern
907
+ // it supports without re-parsing (#2039). Template use stays
908
+ // refused: `isSupported` maps a regex-pattern `.replace` to the
909
+ // deferred-form BF101 reason — the Perl `s///` vs Go
937
910
  // `regexp.ReplaceAllString` flavour gap is the open design
938
911
  // question in #1448. `replaceAll` stays refused entirely.
939
912
  //
@@ -951,28 +924,30 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
951
924
  }
952
925
  }
953
926
  // 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.
927
+ // vs Go `regexp.ReplaceAllString` flavour gap, #1448). Its shape
928
+ // is carried structurally `args[0]` is a `regex` node (convertNode
929
+ // line ~1127) so a consumer that recognises one fixed pattern (the
930
+ // Go ctor's `/\/+$/` trailing-slash strip `strings.TrimRight`) reads
931
+ // it from the tree instead of re-parsing the raw (#2039). Returned
932
+ // before the object-literal `badArg` check below so the regex form
933
+ // keeps its dedicated diagnostic precedence; `isSupported` then refuses
934
+ // any template use with the deferred-form reason.
959
935
  const patternNode = node.arguments[0]
960
936
  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
- }
937
+ return { kind: 'array-method', method: 'replace', object: callee.object, args }
967
938
  }
939
+ // Treat an object-literal argument like `unsupported` — a `.replace`
940
+ // with an object pattern/replacement isn't lowerable, same as before
941
+ // the `object-literal` kind existed (byte-identical; Roadmap A-1).
968
942
  const badArg =
969
- args[0].kind === 'unsupported'
943
+ args[0].kind === 'unsupported' || args[0].kind === 'object-literal'
970
944
  ? args[0]
971
- : args[1].kind === 'unsupported'
945
+ : args[1].kind === 'unsupported' || args[1].kind === 'object-literal'
972
946
  ? args[1]
973
947
  : undefined
974
- if (badArg && badArg.kind === 'unsupported') {
975
- return { kind: 'unsupported', raw, reason: badArg.reason }
948
+ if (badArg) {
949
+ const reason = badArg.kind === 'unsupported' ? badArg.reason : 'Unsupported syntax: ObjectLiteralExpression'
950
+ return { kind: 'unsupported', raw, reason }
976
951
  }
977
952
  return { kind: 'array-method', method: 'replace', object: callee.object, args }
978
953
  }
@@ -1004,123 +979,13 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1004
979
  if (callee.property === 'padStart' || callee.property === 'padEnd') {
1005
980
  return { kind: 'array-method', method: callee.property, object: callee.object, args }
1006
981
  }
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
- }
982
+ // `.sort` / `.toSorted` / `.reduce` / `.reduceRight` / `.flatMap`
983
+ // (callback methods) are NOT folded here (#2018 P5): they fall through
984
+ // to the generic `call` construction below (callee = member, arg =
985
+ // generic `arrow`), and the adapter serializes the arrow body to the
986
+ // runtime evaluator. The localeCompare-sort fallback recovers a
987
+ // structured comparator from the generic arrow via
988
+ // `sortComparatorFromArrow`.
1124
989
  }
1125
990
 
1126
991
  return { kind: 'call', callee, args }
@@ -1132,6 +997,29 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1132
997
  return { kind: 'array-literal', elements }
1133
998
  }
1134
999
 
1000
+ // Object literal: { a: 1, b: x, shorthand }. Only plain literals are
1001
+ // structured — every property must be a non-computed `key: value` or a
1002
+ // shorthand `{ key }`. Anything else (spread, computed key, method,
1003
+ // getter/setter) falls through to the generic `unsupported` fallback
1004
+ // below, exactly as before this kind existed.
1005
+ if (ts.isObjectLiteralExpression(node)) {
1006
+ const properties: ObjectLiteralProperty[] = []
1007
+ for (const prop of node.properties) {
1008
+ if (ts.isPropertyAssignment(prop)) {
1009
+ const k = objectLiteralKeyName(prop.name)
1010
+ if (k === null) return { kind: 'unsupported', raw, reason: `Unsupported syntax: ${ts.SyntaxKind[node.kind]}` }
1011
+ properties.push({ key: k.key, keyKind: k.keyKind, shorthand: false, value: convertNode(prop.initializer, raw) })
1012
+ } else if (ts.isShorthandPropertyAssignment(prop)) {
1013
+ const key = prop.name.text
1014
+ properties.push({ key, keyKind: 'identifier', shorthand: true, value: { kind: 'identifier', name: key } })
1015
+ } else {
1016
+ // Spread assignment, method, getter/setter — not a plain map.
1017
+ return { kind: 'unsupported', raw, reason: `Unsupported syntax: ${ts.SyntaxKind[node.kind]}` }
1018
+ }
1019
+ }
1020
+ return { kind: 'object-literal', properties, raw }
1021
+ }
1022
+
1135
1023
  // Property access: user.name, items().length
1136
1024
  if (ts.isPropertyAccessExpression(node)) {
1137
1025
  const object = convertNode(node.expression, raw)
@@ -1164,7 +1052,10 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1164
1052
  // (the literal forms above fold into a static property path; this
1165
1053
  // one can't). #1897 (data-table).
1166
1054
  const index = convertNode(argNode, raw)
1167
- if (index.kind === 'unsupported') return index
1055
+ // An object-literal index (`arr[{…}]`) isn't lowerable — surface it
1056
+ // as the whole expression, exactly as an `unsupported` index did
1057
+ // before the kind existed (byte-identical; Roadmap A-1).
1058
+ if (index.kind === 'unsupported' || index.kind === 'object-literal') return index
1168
1059
  return { kind: 'index-access', object, index }
1169
1060
  }
1170
1061
 
@@ -1232,24 +1123,63 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1232
1123
  return { kind: 'literal', value: node.text, literalType: 'string' }
1233
1124
  }
1234
1125
 
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' }
1126
+ // Regex literal: `/\/+$/`. Carried as exact source text for the Go ctor
1127
+ // trailing-slash-strip lowering (`String.replace`).
1128
+ if (ts.isRegularExpressionLiteral(node)) {
1129
+ return { kind: 'regex', raw: node.getText() }
1130
+ }
1131
+
1132
+ // Arrow function / function expression: `x => expr`, `(a, b) => expr`,
1133
+ // `({field}) => field`, `function (x) { return … }`. Produces a generic
1134
+ // multi-parameter `arrow` (#2018 P5). A single-`return` block body
1135
+ // normalises to its returned expression; a single object-binding-pattern
1136
+ // param is rewritten to a synthetic identifier param with dotted-access
1137
+ // body (destructure support). Higher-order callbacks reach the adapter as
1138
+ // a generic `call` whose argument is this kind; the adapter serializes
1139
+ // `body` to the runtime evaluator.
1140
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
1141
+ // Resolve the body expression: an expression-bodied arrow carries it
1142
+ // directly; a block body (arrow `=> { … }` or a function expression)
1143
+ // must reduce to exactly one `return <expr>;`. Multi-statement /
1144
+ // local-var bodies stay refused.
1145
+ let body: ParsedExpr
1146
+ if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) {
1147
+ body = convertNode(node.body, raw)
1148
+ } else {
1149
+ const block = node.body as ts.Block
1150
+ const stmts = block.statements
1151
+ // Fast path: a single `return <expr>` keeps the pre-#2040 conversion
1152
+ // (convertNode on the returned node), byte-identical for the existing
1153
+ // corpus.
1154
+ if (stmts.length === 1 && ts.isReturnStatement(stmts[0]) && stmts[0].expression) {
1155
+ body = convertNode(stmts[0].expression, raw)
1156
+ } else {
1157
+ // General value-producing block: normalize `let`-inline + value `if` /
1158
+ // early `return` into a single expression (#2040). Imperative shapes
1159
+ // (raw `for` / `while`, `break`, mutation, side effects) don't parse
1160
+ // into ParsedStatement, or fall through without a value — either way we
1161
+ // refuse with an actionable reason and adapters surface BF101.
1162
+ let sf: ts.SourceFile | undefined
1163
+ try {
1164
+ sf = block.getSourceFile()
1165
+ } catch {
1166
+ sf = undefined
1167
+ }
1168
+ const parsed = sf
1169
+ ? parseBlockBody(block, sf, n => n.getText(sf))
1170
+ : null
1171
+ if (!parsed) {
1172
+ return { kind: 'unsupported', raw, reason: IMPERATIVE_BLOCK_REASON }
1173
+ }
1174
+ const folded = foldBlockToExpr(parsed)
1175
+ if (!folded.ok) {
1176
+ return { kind: 'unsupported', raw, reason: folded.reason }
1177
+ }
1178
+ body = folded.expr
1179
+ }
1249
1180
  }
1250
- const body = convertNode(node.body, raw)
1251
1181
 
1252
- // Destructured-object param: `({done}) => done` (#1443),
1182
+ // Single object-binding-pattern param: `({done}) => done` (#1443),
1253
1183
  // `({user: {name}}) => name` (#1530), `({done = false}) => done`
1254
1184
  // (#1531), `({done, ...rest}) => rest.priority` (#1532). We
1255
1185
  // synthesise the equivalent dotted-access form so adapters can
@@ -1262,7 +1192,8 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1262
1192
  // shapes refuse with BF021 (#1532). Array binding patterns,
1263
1193
  // nested rest, and defaults at non-leaf (nested-pattern) slots
1264
1194
  // stay unsupported.
1265
- if (ts.isObjectBindingPattern(param.name)) {
1195
+ if (node.parameters.length === 1 && ts.isObjectBindingPattern(node.parameters[0].name)) {
1196
+ const bindingPattern = node.parameters[0].name
1266
1197
  const fieldMap = new Map<string, DestructureBinding>()
1267
1198
  // `excludedTopKeys` mirrors the JS rest-binding exclusion set:
1268
1199
  // the source-object keys explicitly consumed at the top level
@@ -1271,7 +1202,7 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1271
1202
  // keys on local rename / leaf names and would miss
1272
1203
  // `({done: d, ...rest}) => rest.done` or `({user: {name}, ...rest}) => rest.user`.
1273
1204
  const excludedTopKeys = new Set<string>()
1274
- const collect = collectDestructureBindings(param.name, [], fieldMap, raw, excludedTopKeys)
1205
+ const collect = collectDestructureBindings(bindingPattern, [], fieldMap, raw, excludedTopKeys)
1275
1206
  if (!collect.ok) {
1276
1207
  return { kind: 'unsupported', raw, reason: collect.reason }
1277
1208
  }
@@ -1335,37 +1266,20 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1335
1266
  }
1336
1267
  const syntheticParam = pickSyntheticParam(fieldMap, body)
1337
1268
  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' }
1269
+ return { kind: 'arrow', params: [syntheticParam], body: rewritten }
1343
1270
  }
1344
- return { kind: 'arrow-fn', param: param.name.text, body }
1345
- }
1346
1271
 
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),
1272
+ // Every (remaining) parameter must be a plain identifier. Multi-param
1273
+ // arrows (sort comparators `(a, b) => …`) and single-param identifier
1274
+ // arrows (`x => …`) both land here.
1275
+ const params: string[] = []
1276
+ for (const p of node.parameters) {
1277
+ if (!ts.isIdentifier(p.name)) {
1278
+ return { kind: 'unsupported', raw, reason: 'Only identifier (or a single object-destructure) function parameters are supported' }
1279
+ }
1280
+ params.push(p.name.text)
1368
1281
  }
1282
+ return { kind: 'arrow', params, body }
1369
1283
  }
1370
1284
 
1371
1285
  // Default: unsupported
@@ -1373,114 +1287,56 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1373
1287
  }
1374
1288
 
1375
1289
  /**
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.
1290
+ * Recover a {@link SortComparator} from a generic `(a, b) => …` sort callback
1291
+ * arrow (#2018 P5). The LEGACY fallback for a comparator the runtime evaluator
1292
+ * can't model (`localeCompare` string sorts `serializeParsedExpr` refuses
1293
+ * them); the adapter calls this only when the eval path returns null. Operates
1294
+ * on the generic `arrow` ParsedExpr (params + body subtree) no `ts` re-parse.
1388
1295
  *
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).
1296
+ * The accepted catalogue is finite so the walker stays shallow — no constant
1297
+ * folding, no symbol resolution. Returns null if the shape doesn't match
1298
+ * exactly, in which case the adapter surfaces BF101.
1394
1299
  *
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):
1300
+ * A body is split on top-level `||` into one leaf per operand, giving a
1301
+ * multi-key comparator (`a.x - b.x || a.y - b.y` → sort by x, then y). Accepted
1302
+ * leaf shapes (each paired ascending / descending by operand order):
1399
1303
  *
1400
1304
  * a.field - b.field → field, numeric
1401
1305
  * a - b → self, numeric
1402
1306
  * a.field.localeCompare(b.field) → field, string
1403
1307
  * a.localeCompare(b) → self, string
1404
1308
  * a.field > b.field ? 1 : -1 → field, auto (relational ternary)
1405
- * a.field < b.field ? -1 : 1 → field, auto
1406
1309
  * a < b ? -1 : a > b ? 1 : 0 → self/field, auto (3-way)
1407
1310
  * a === b ? 0 : <relational ternary> → leading-tie 3-way
1408
1311
  *
1409
- * Function-reference comparators and `localeCompare(b, locale, opts)`
1410
- * (the multi-arg form) return null — deferred follow-ups.
1312
+ * Function-reference comparators and `localeCompare(b, locale, opts)` (the
1313
+ * multi-arg form) return null — deferred follow-ups.
1411
1314
  */
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).
1315
+ export function sortComparatorFromArrow(arrow: ParsedExpr): SortComparator | null {
1316
+ if (arrow.kind !== 'arrow' || arrow.params.length !== 2) return null
1317
+ const [paramA, paramB] = arrow.params
1318
+
1319
+ // A `||`-chain is a multi-key comparator: each operand is an independent
1320
+ // leaf applied as the next tie-breaker. A non-`||` body is single-key.
1455
1321
  const keys: SortKey[] = []
1456
- for (const operand of flattenLogicalOr(body)) {
1322
+ for (const operand of flattenLogicalOr(arrow.body)) {
1457
1323
  const key = classifyLeafComparator(operand, paramA, paramB)
1458
1324
  if (!key) return null
1459
1325
  keys.push(key)
1460
1326
  }
1461
1327
  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
1328
+ return { keys }
1471
1329
  }
1472
1330
 
1473
1331
  /**
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]`.
1332
+ * Flatten a top-level `||` chain into its operands (`a || b || c` → `[a, b, c]`).
1476
1333
  * A non-`||` expression returns a single-element list.
1477
1334
  */
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)]
1335
+ function flattenLogicalOr(expr: ParsedExpr): ParsedExpr[] {
1336
+ if (expr.kind === 'logical' && expr.op === '||') {
1337
+ return [...flattenLogicalOr(expr.left), ...flattenLogicalOr(expr.right)]
1482
1338
  }
1483
- return [inner]
1339
+ return [expr]
1484
1340
  }
1485
1341
 
1486
1342
  /**
@@ -1488,60 +1344,40 @@ function flattenLogicalOr(expr: ts.Expression): ts.Expression[] {
1488
1344
  * Accepts subtraction (numeric), `localeCompare` (string), and
1489
1345
  * relational-ternary (auto) shapes; returns null otherwise.
1490
1346
  */
1491
- function classifyLeafComparator(
1492
- expr: ts.Expression,
1493
- paramA: string,
1494
- paramB: string,
1495
- ): SortKey | null {
1496
- const body = unwrapParens(expr)
1497
-
1347
+ function classifyLeafComparator(expr: ParsedExpr, paramA: string, paramB: string): SortKey | null {
1498
1348
  // 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')
1349
+ if (expr.kind === 'binary' && expr.op === '-') {
1350
+ return classifyComparatorOperands(expr.left, expr.right, paramA, paramB, 'numeric')
1501
1351
  }
1502
1352
 
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).
1353
+ // localeCompare (zero-arg form): `<lhs>.localeCompare(<rhs>)` → string. The
1354
+ // locale/options form (2–3 args) stays refused.
1506
1355
  if (
1507
- ts.isCallExpression(body) &&
1508
- ts.isPropertyAccessExpression(body.expression) &&
1509
- body.expression.name.text === 'localeCompare' &&
1510
- body.arguments.length === 1
1356
+ expr.kind === 'call' &&
1357
+ expr.callee.kind === 'member' &&
1358
+ expr.callee.property === 'localeCompare' &&
1359
+ expr.args.length === 1
1511
1360
  ) {
1512
- return classifyComparatorOperands(
1513
- body.expression.expression, // receiver of .localeCompare
1514
- body.arguments[0],
1515
- paramA,
1516
- paramB,
1517
- 'string',
1518
- )
1361
+ return classifyComparatorOperands(expr.callee.object, expr.args[0], paramA, paramB, 'string')
1519
1362
  }
1520
1363
 
1521
1364
  // Relational-ternary sign comparator → auto.
1522
- if (ts.isConditionalExpression(body)) {
1523
- return classifyTernaryComparator(body, paramA, paramB)
1365
+ if (expr.kind === 'conditional') {
1366
+ return classifyTernaryComparator(expr, paramA, paramB)
1524
1367
  }
1525
1368
 
1526
1369
  return null
1527
1370
  }
1528
1371
 
1529
1372
  /**
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.
1373
+ * Classify two operands against the comparator's two param names. Both must
1374
+ * resolve to either the param identifier itself (`self`) or a single-level
1375
+ * field access on it (`field`), reference different params, and match on key
1376
+ * shape + field name. `paramA` first is ascending, reversed is descending.
1541
1377
  */
1542
1378
  function classifyComparatorOperands(
1543
- left: ts.Expression,
1544
- right: ts.Expression,
1379
+ left: ParsedExpr,
1380
+ right: ParsedExpr,
1545
1381
  paramA: string,
1546
1382
  paramB: string,
1547
1383
  type: 'numeric' | 'string',
@@ -1560,44 +1396,35 @@ function classifyComparatorOperands(
1560
1396
 
1561
1397
  /**
1562
1398
  * 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.
1399
+ * Handles the 2-way sign form (`a.f > b.f ? 1 : -1`), the canonical 3-way
1400
+ * (`a.f < b.f ? -1 : a.f > b.f ? 1 : 0`), and a leading equality tie
1401
+ * (`a.f === b.f ? 0 : <relational ternary>`).
1571
1402
  */
1572
1403
  function classifyTernaryComparator(
1573
- node: ts.ConditionalExpression,
1404
+ node: Extract<ParsedExpr, { kind: 'conditional' }>,
1574
1405
  paramA: string,
1575
1406
  paramB: string,
1576
1407
  ): SortKey | null {
1577
- const cond = unwrapParens(node.condition)
1408
+ const cond = node.test
1578
1409
 
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.
1410
+ // Leading equality tie: `a.f === b.f ? 0 : <ternary>`.
1581
1411
  if (
1582
- ts.isBinaryExpression(cond) &&
1583
- (cond.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken ||
1584
- cond.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken) &&
1412
+ cond.kind === 'binary' &&
1413
+ (cond.op === '===' || cond.op === '==') &&
1585
1414
  sameKeyOperands(cond.left, cond.right, paramA, paramB) &&
1586
- numericSign(node.whenTrue) === 0
1415
+ numericSign(node.consequent) === 0
1587
1416
  ) {
1588
- const elseBranch = unwrapParens(node.whenFalse)
1589
- if (ts.isConditionalExpression(elseBranch)) {
1417
+ const elseBranch = node.alternate
1418
+ if (elseBranch.kind === 'conditional') {
1590
1419
  return classifyTernaryComparator(elseBranch, paramA, paramB)
1591
1420
  }
1592
1421
  return null
1593
1422
  }
1594
1423
 
1595
1424
  // 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
1425
+ if (cond.kind !== 'binary') return null
1426
+ const isGreater = cond.op === '>' || cond.op === '>='
1427
+ const isLess = cond.op === '<' || cond.op === '<='
1601
1428
  if (!isGreater && !isLess) return null
1602
1429
 
1603
1430
  const leftRef = classifySortOperand(cond.left, paramA, paramB)
@@ -1609,37 +1436,21 @@ function classifyTernaryComparator(
1609
1436
  return null
1610
1437
  }
1611
1438
 
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)
1439
+ // whenTrue must be a non-zero sign literal (±1); whenFalse a bounded shape
1440
+ // (sign literal or a nested ternary on the same key). Direction is derived
1441
+ // solely from this outer comparison.
1442
+ const trueSign = numericSign(node.consequent)
1623
1443
  if (trueSign === null || trueSign === 0) return null
1624
- if (!isBoundedTernaryElse(node.whenFalse, leftRef.key, paramA, paramB)) return null
1444
+ if (!isBoundedTernaryElse(node.alternate, leftRef.key, paramA, paramB)) return null
1625
1445
 
1626
1446
  // 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
1447
  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
1448
  const asc = greaterForA ? trueSign > 0 : trueSign < 0
1633
1449
  return { key: leftRef.key, type: 'auto', direction: asc ? 'asc' : 'desc' }
1634
1450
  }
1635
1451
 
1636
1452
  /** 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 {
1453
+ function sameKeyOperands(left: ParsedExpr, right: ParsedExpr, paramA: string, paramB: string): boolean {
1643
1454
  const l = classifySortOperand(left, paramA, paramB)
1644
1455
  const r = classifySortOperand(right, paramA, paramB)
1645
1456
  if (!l || !r) return false
@@ -1650,17 +1461,16 @@ function sameKeyOperands(
1650
1461
  }
1651
1462
 
1652
1463
  /**
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.
1464
+ * Sign of a numeric literal with optional unary minus: 1, -1, or 0. Returns
1465
+ * null for anything that isn't a (signed) numeric literal.
1655
1466
  */
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)
1467
+ function numericSign(expr: ParsedExpr): number | null {
1468
+ if (expr.kind === 'unary' && expr.op === '-') {
1469
+ const inner = numericSign(expr.argument)
1660
1470
  return inner === null ? null : -inner
1661
1471
  }
1662
- if (ts.isNumericLiteral(e)) {
1663
- const n = Number(e.text)
1472
+ if (expr.kind === 'literal' && expr.literalType === 'number' && typeof expr.value === 'number') {
1473
+ const n = expr.value
1664
1474
  if (Number.isNaN(n)) return null
1665
1475
  if (n === 0) return 0
1666
1476
  return n > 0 ? 1 : -1
@@ -1669,21 +1479,18 @@ function numericSign(expr: ts.Expression): number | null {
1669
1479
  }
1670
1480
 
1671
1481
  /**
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.
1482
+ * The `whenFalse` arm of a relational ternary is bounded if it's a sign
1483
+ * literal (±1 / 0) or a nested ternary on the same key (the canonical 3-way).
1676
1484
  */
1677
1485
  function isBoundedTernaryElse(
1678
- expr: ts.Expression,
1486
+ expr: ParsedExpr,
1679
1487
  key: { kind: 'self' } | { kind: 'field'; field: string },
1680
1488
  paramA: string,
1681
1489
  paramB: string,
1682
1490
  ): 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)
1491
+ if (numericSign(expr) !== null) return true
1492
+ if (expr.kind === 'conditional') {
1493
+ const nested = classifyTernaryComparator(expr, paramA, paramB)
1687
1494
  return nested !== null && sortKeyEquals(nested.key, key)
1688
1495
  }
1689
1496
  return false
@@ -1698,238 +1505,28 @@ function sortKeyEquals(
1698
1505
  return true
1699
1506
  }
1700
1507
 
1508
+ /**
1509
+ * Resolve a sort operand to a `self` / `field` key on param A or B. A bare
1510
+ * param identifier is `self`; a single-level non-computed field access on a
1511
+ * param is `field`. Anything deeper returns null.
1512
+ */
1701
1513
  function classifySortOperand(
1702
- expr: ts.Expression,
1514
+ expr: ParsedExpr,
1703
1515
  paramA: string,
1704
1516
  paramB: string,
1705
1517
  ): { 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' }
1518
+ if (expr.kind === 'identifier') {
1519
+ if (expr.name === paramA) return { key: { kind: 'self' }, param: 'A' }
1520
+ if (expr.name === paramB) return { key: { kind: 'self' }, param: 'B' }
1709
1521
  return null
1710
1522
  }
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
- }
1523
+ if (expr.kind === 'member' && !expr.computed && expr.object.kind === 'identifier') {
1524
+ if (expr.object.name === paramA) return { key: { kind: 'field', field: expr.property }, param: 'A' }
1525
+ if (expr.object.name === paramB) return { key: { kind: 'field', field: expr.property }, param: 'B' }
1718
1526
  }
1719
1527
  return null
1720
1528
  }
1721
1529
 
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
1530
 
1934
1531
  /**
1935
1532
  * Per-binding entry stored in `fieldMap`: the dotted path from the
@@ -2070,8 +1667,12 @@ function collectDestructureBindings(
2070
1667
  let defaultExpr: ParsedExpr | undefined
2071
1668
  if (el.initializer) {
2072
1669
  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}` }
1670
+ // An object-literal default isn't lowered into a destructured filter
1671
+ // predicate yet refuse it exactly as before the kind existed, with
1672
+ // the same reason text the `unsupported` fallback produced (A-1).
1673
+ if (parsed.kind === 'unsupported' || parsed.kind === 'object-literal') {
1674
+ const reason = parsed.kind === 'unsupported' ? parsed.reason : 'Unsupported syntax: ObjectLiteralExpression'
1675
+ return { ok: false, reason: `Default value in destructured filter param failed to parse: ${reason}` }
2075
1676
  }
2076
1677
  defaultExpr = parsed
2077
1678
  }
@@ -2114,6 +1715,7 @@ function findImpureDefaultNode(expr: ParsedExpr): string | null {
2114
1715
  case 'literal':
2115
1716
  case 'identifier':
2116
1717
  case 'unsupported':
1718
+ case 'object-literal':
2117
1719
  return null
2118
1720
  case 'member':
2119
1721
  return findImpureDefaultNode(expr.object)
@@ -2144,8 +1746,8 @@ function findImpureDefaultNode(expr: ParsedExpr): string | null {
2144
1746
  return null
2145
1747
  case 'call':
2146
1748
  case 'array-method':
2147
- case 'higher-order':
2148
- case 'arrow-fn':
1749
+ case 'arrow':
1750
+ case 'regex':
2149
1751
  return expr.kind
2150
1752
  }
2151
1753
  }
@@ -2249,19 +1851,13 @@ function validateRestUsage(
2249
1851
  if (part.type === 'expression') walk(part.expr)
2250
1852
  }
2251
1853
  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
1854
+ case 'arrow':
1855
+ // Inner arrow that re-uses `restName` as one of its own
1856
+ // parameters shadows the outer rest binding — references inside
1857
+ // its body belong to the inner param, not us (#1532 review).
1858
+ if (e.params.includes(restName)) return
2257
1859
  walk(e.body)
2258
1860
  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
1861
  case 'array-literal':
2266
1862
  for (const el of e.elements) walk(el)
2267
1863
  return
@@ -2271,6 +1867,7 @@ function validateRestUsage(
2271
1867
  return
2272
1868
  case 'literal':
2273
1869
  case 'unsupported':
1870
+ case 'object-literal':
2274
1871
  return
2275
1872
  }
2276
1873
  }
@@ -2370,13 +1967,9 @@ function collectIdentifiers(expr: ParsedExpr, out: Set<string>): void {
2370
1967
  if (part.type === 'expression') collectIdentifiers(part.expr, out)
2371
1968
  }
2372
1969
  return
2373
- case 'arrow-fn':
1970
+ case 'arrow':
2374
1971
  collectIdentifiers(expr.body, out)
2375
1972
  return
2376
- case 'higher-order':
2377
- collectIdentifiers(expr.object, out)
2378
- collectIdentifiers(expr.predicate, out)
2379
- return
2380
1973
  case 'array-literal':
2381
1974
  expr.elements.forEach(e => collectIdentifiers(e, out))
2382
1975
  return
@@ -2385,7 +1978,11 @@ function collectIdentifiers(expr: ParsedExpr, out: Set<string>): void {
2385
1978
  expr.args.forEach(e => collectIdentifiers(e, out))
2386
1979
  return
2387
1980
  case 'literal':
1981
+ case 'regex':
2388
1982
  case 'unsupported':
1983
+ // Mirror `unsupported`: an object literal was not carried before this
1984
+ // kind existed, so it collects no identifiers (byte-identical A-1).
1985
+ case 'object-literal':
2389
1986
  return
2390
1987
  }
2391
1988
  }
@@ -2463,49 +2060,29 @@ function substituteDestructuredFields(
2463
2060
  p.type === 'expression' ? { type: 'expression', expr: walk(p.expr) } : p,
2464
2061
  ),
2465
2062
  }
2466
- case 'arrow-fn':
2063
+ case 'arrow':
2467
2064
  // 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':
2065
+ // param (a higher-order callback like `.sort(cmp)` / `.filter(p)`
2066
+ // reaches here as the arrow argument of a generic `call`). Leave
2067
+ // its body alone its comparator/predicate references its own
2068
+ // params, never the enclosing destructure. Skipping the rewrite is
2069
+ // the conservative choice; worst case the adapter emits BF101.
2479
2070
  return e
2480
2071
  case 'array-literal':
2481
2072
  return { kind: 'array-literal', elements: e.elements.map(walk) }
2482
2073
  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
2074
  if (e.method === 'flat') {
2497
2075
  // `flatDepth` is a normalised literal — no destructure refs to
2498
- // substitute. Preserve verbatim, same as sort / reduce above.
2076
+ // substitute. Preserve verbatim.
2499
2077
  return { kind: 'array-method', method: 'flat', object: walk(e.object), args: [], flatDepth: e.flatDepth }
2500
2078
  }
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
2079
  return { kind: 'array-method', method: e.method, object: walk(e.object), args: e.args.map(walk) }
2507
2080
  case 'literal':
2081
+ case 'regex':
2508
2082
  case 'unsupported':
2083
+ // Mirror `unsupported`: object literals were not substituted into
2084
+ // before this kind existed — return verbatim (byte-identical A-1).
2085
+ case 'object-literal':
2509
2086
  return e
2510
2087
  }
2511
2088
  }
@@ -2567,57 +2144,27 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2567
2144
  case 'unsupported':
2568
2145
  return { supported: false, reason: expr.reason }
2569
2146
 
2147
+ // A bare object literal is still refused as a standalone template
2148
+ // expression — adapters that lower one as a *value* (Go map / Perl
2149
+ // hashref) do so in their own emitter, like `array-literal`, not
2150
+ // through this support gate. The reason string is the exact text the
2151
+ // `unsupported` fallback produced before the `object-literal` kind
2152
+ // existed, so diagnostics stay byte-identical (Roadmap A-1).
2153
+ case 'object-literal':
2154
+ return { supported: false, reason: 'Unsupported syntax: ObjectLiteralExpression' }
2155
+
2570
2156
  case 'identifier':
2571
2157
  return { supported: true, level: 'L1' }
2572
2158
 
2573
2159
  case 'literal':
2574
2160
  return { supported: true, level: 'L1' }
2575
2161
 
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
- }
2162
+ case 'arrow':
2163
+ case 'regex':
2164
+ // Arrow functions / regex literals are only supported as the
2165
+ // argument of a recognised higher-order callback call (handled in
2166
+ // the `call` arm below); they're unsupported standalone.
2167
+ return { supported: false, reason: 'Standalone arrow functions / regex literals are not supported' }
2621
2168
 
2622
2169
  case 'array-literal': {
2623
2170
  // Array literal is lowerable iff every element is. Adapters that
@@ -2633,6 +2180,17 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2633
2180
  }
2634
2181
 
2635
2182
  case 'array-method': {
2183
+ // A regex-pattern `.replace` is carried structurally (a `regex` first
2184
+ // arg) but is the deferred form (#1448) — no template language lowers it.
2185
+ // Refuse with the dedicated reason rather than the generic standalone-regex
2186
+ // message, preserving the diagnostic the parser used to emit directly.
2187
+ if (expr.method === 'replace' && expr.args[0]?.kind === 'regex') {
2188
+ return {
2189
+ supported: false,
2190
+ reason:
2191
+ '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 */',
2192
+ }
2193
+ }
2636
2194
  const objSupport = checkSupport(expr.object)
2637
2195
  if (!objSupport.supported) return objSupport
2638
2196
  for (const arg of expr.args) {
@@ -2644,6 +2202,32 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2644
2202
 
2645
2203
 
2646
2204
  case 'call': {
2205
+ // Higher-order callback methods (`.filter`/`.sort`/`.reduce`/… with an
2206
+ // arrow argument) lower via the runtime evaluator (#2018). Supported iff
2207
+ // the receiver and the callback BODY are supported. Recognised before the
2208
+ // `UNSUPPORTED_METHODS` gate so the eval-lowered shapes aren't refused
2209
+ // (a BARE method reference — `arr.filter` uncalled, no arrow arg — still
2210
+ // falls through to the gate). A nested callback inside the body refuses
2211
+ // at the adapter's `serializeParsedExpr` purity gate, not here.
2212
+ const cb = asCallbackMethodCall(expr)
2213
+ if (cb) {
2214
+ const objSupport = checkSupport(cb.object)
2215
+ if (!objSupport.supported) return objSupport
2216
+ const bodySupport = checkSupport(cb.arrow.body)
2217
+ if (!bodySupport.supported) {
2218
+ return {
2219
+ supported: false,
2220
+ level: 'L5_UNSUPPORTED',
2221
+ reason: `Higher-order method '.${cb.method}()' with complex callback. ${bodySupport.reason || 'Simplify the callback.'}`,
2222
+ }
2223
+ }
2224
+ for (const rest of cb.args) {
2225
+ const restSupport = checkSupport(rest)
2226
+ if (!restSupport.supported) return restSupport
2227
+ }
2228
+ return { supported: true, level: 'L5' }
2229
+ }
2230
+
2647
2231
  // Check if callee is supported
2648
2232
  const calleeSupport = checkSupport(expr.callee)
2649
2233
  if (!calleeSupport.supported) {
@@ -2778,12 +2362,13 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2778
2362
  }
2779
2363
 
2780
2364
  /**
2781
- * Check if expression contains any higher-order method calls.
2365
+ * Check if expression contains any higher-order callback method call
2366
+ * (`.filter`/`.sort`/`.reduce`/… with an arrow argument — see
2367
+ * {@link asCallbackMethodCall}) anywhere in the tree.
2782
2368
  */
2783
2369
  export function containsHigherOrder(expr: ParsedExpr): boolean {
2370
+ if (asCallbackMethodCall(expr) !== null) return true
2784
2371
  switch (expr.kind) {
2785
- case 'higher-order':
2786
- return true
2787
2372
  case 'call':
2788
2373
  return expr.args.some(containsHigherOrder) || containsHigherOrder(expr.callee)
2789
2374
  case 'member':
@@ -2798,7 +2383,7 @@ export function containsHigherOrder(expr: ParsedExpr): boolean {
2798
2383
  return containsHigherOrder(expr.left) || containsHigherOrder(expr.right)
2799
2384
  case 'conditional':
2800
2385
  return containsHigherOrder(expr.test) || containsHigherOrder(expr.consequent) || containsHigherOrder(expr.alternate)
2801
- case 'arrow-fn':
2386
+ case 'arrow':
2802
2387
  return containsHigherOrder(expr.body)
2803
2388
  case 'array-literal':
2804
2389
  return expr.elements.some(containsHigherOrder)
@@ -2847,6 +2432,29 @@ export function parseBlockBody(
2847
2432
  return statements
2848
2433
  }
2849
2434
 
2435
+ /**
2436
+ * Like {@link parseBlockBody} but tolerant: a statement `parseStatement` can't
2437
+ * represent is **skipped** rather than failing the whole block. Used to carry a
2438
+ * block-body memo's structure on the IR for adapters that only pattern-match a
2439
+ * recognised prefix of statements (e.g. a `const k = getter(); if (!k) return
2440
+ * CONST` guard) and ignore the rest — including a trailing client-directive
2441
+ * (`@client`) return that the strict parser would reject. Mirrors the tolerant
2442
+ * `continue`-on-unrecognised walks those adapters previously ran over a
2443
+ * re-parsed source string, so it never carries a *more* permissive result.
2444
+ */
2445
+ export function parseBlockBodyTolerant(
2446
+ block: ts.Block,
2447
+ sourceFile: ts.SourceFile,
2448
+ getJS: (node: ts.Node) => string
2449
+ ): ParsedStatement[] {
2450
+ const statements: ParsedStatement[] = []
2451
+ for (const stmt of block.statements) {
2452
+ const parsed = parseStatement(stmt, sourceFile, getJS)
2453
+ if (parsed !== null) statements.push(parsed)
2454
+ }
2455
+ return statements
2456
+ }
2457
+
2850
2458
  /**
2851
2459
  * Parse a single statement into ParsedStatement.
2852
2460
  */
@@ -2876,8 +2484,17 @@ function parseStatement(
2876
2484
  // return; (no value) -> return undefined, treat as return true
2877
2485
  return { kind: 'return', value: { kind: 'literal', value: true, literalType: 'boolean' } }
2878
2486
  }
2879
- const valueText = getJS(stmt.expression)
2880
- const value = parseExpression(valueText)
2487
+ // A bare object-literal return (`return { a: 1 }`) re-parses as a *block*
2488
+ // statement if its braces lead the source, yielding `unsupported`. Unwrap
2489
+ // any parens, and when the returned expression is an object literal, wrap
2490
+ // the text in parens to force expression context so it parses as an
2491
+ // `object-literal` ParsedExpr (consumed by the Go object-memo lowering).
2492
+ let retExpr: ts.Expression = stmt.expression
2493
+ while (ts.isParenthesizedExpression(retExpr)) retExpr = retExpr.expression
2494
+ const valueText = getJS(retExpr)
2495
+ const value = parseExpression(
2496
+ ts.isObjectLiteralExpression(retExpr) ? `(${valueText})` : valueText,
2497
+ )
2881
2498
  if (value.kind === 'unsupported') {
2882
2499
  return null
2883
2500
  }
@@ -2888,7 +2505,7 @@ function parseStatement(
2888
2505
  if (ts.isIfStatement(stmt)) {
2889
2506
  const conditionText = getJS(stmt.expression)
2890
2507
  const condition = parseExpression(conditionText)
2891
- if (condition.kind === 'unsupported') {
2508
+ if (condition.kind === 'unsupported' || condition.kind === 'object-literal') {
2892
2509
  return null
2893
2510
  }
2894
2511
 
@@ -2925,6 +2542,435 @@ function parseStatement(
2925
2542
  return null
2926
2543
  }
2927
2544
 
2545
+ // =============================================================================
2546
+ // Block → Expression Normalization (#2040)
2547
+ // =============================================================================
2548
+
2549
+ /**
2550
+ * The actionable refusal reason for a block body that is not purely-functionally
2551
+ * expressible. Carried on the `unsupported` ParsedExpr so adapters surface it as
2552
+ * the BF101 message. A loop that mutates a local to accumulate a value is a fold
2553
+ * (already expressible via `.reduce`); a loop that does anything else, a `break`,
2554
+ * a re-assignment, or a side-effecting/I-O call is genuinely imperative and has
2555
+ * no value-position lowering.
2556
+ */
2557
+ export const IMPERATIVE_BLOCK_REASON =
2558
+ 'Block body cannot be normalized to a value expression. Only pure ' +
2559
+ '`const` bindings, value-producing `if` / early `return`, and a final ' +
2560
+ '`return` are supported. Imperative shapes (raw `for` / `while` loops, ' +
2561
+ '`break`, local re-assignment, side-effecting or I/O calls) are not. ' +
2562
+ 'Rewrite an accumulation loop as `.reduce(...)`, or move the imperative ' +
2563
+ 'body to a `/* @client */` value so it runs natively on the client.'
2564
+
2565
+ /**
2566
+ * Whether a statement sequence always reaches a `return` on every control-flow
2567
+ * path (so anything textually after it is dead). A bare `return` terminates; an
2568
+ * `if` terminates only when it has an `else` and both branches terminate. Used
2569
+ * by {@link foldBlockToExpr} to decide whether the statements following an `if`
2570
+ * belong to the fall-through (else) path.
2571
+ */
2572
+ function statementsTerminate(stmts: ParsedStatement[]): boolean {
2573
+ for (const s of stmts) {
2574
+ if (s.kind === 'return') return true
2575
+ if (
2576
+ s.kind === 'if' &&
2577
+ s.alternate !== undefined &&
2578
+ statementsTerminate(s.consequent) &&
2579
+ statementsTerminate(s.alternate)
2580
+ ) {
2581
+ return true
2582
+ }
2583
+ }
2584
+ return false
2585
+ }
2586
+
2587
+ /**
2588
+ * Refusal reason when inlining a `const` binding would capture a free variable
2589
+ * of its initializer under a nested callback parameter of the same name. The
2590
+ * let-inline substitution is not a hygienic (alpha-renaming) substitution, so
2591
+ * rather than silently miscompile the callback we refuse and adapters surface
2592
+ * BF101.
2593
+ */
2594
+ export const CAPTURE_BLOCK_REASON =
2595
+ 'Block body cannot be normalized: inlining a `const` binding would capture ' +
2596
+ 'one of its free variables under a nested callback parameter of the same ' +
2597
+ 'name (e.g. `const x = a; … list.map(a => a + x)`). Rename the inner ' +
2598
+ 'parameter, or move the body to a `/* @client */` value so it runs natively ' +
2599
+ 'on the client.'
2600
+
2601
+ /**
2602
+ * Refusal reason when a `const` initializer may have side effects (it contains
2603
+ * a function/method call) and the binding is NOT used exactly once on a single
2604
+ * runtime path. Let-inline substitutes the initializer at each use site, which
2605
+ * would drop the effect (zero uses) or duplicate it (multiple uses / a use
2606
+ * inside a callback that runs per element) — exactly the side-effecting shape
2607
+ * the fold must refuse rather than miscompile.
2608
+ */
2609
+ export const IMPURE_INLINE_BLOCK_REASON =
2610
+ 'Block body cannot be normalized: a `const` whose initializer may have side ' +
2611
+ 'effects (a function or method call) is not used exactly once on every path, ' +
2612
+ 'so inlining it would drop the effect on some path or duplicate it on ' +
2613
+ 'another. Bind a pure value, use the binding exactly once unconditionally, ' +
2614
+ 'or move the body to a `/* @client */` value so it runs natively on the client.'
2615
+
2616
+ /**
2617
+ * Options for {@link foldBlockToExpr}.
2618
+ */
2619
+ export interface FoldBlockOptions {
2620
+ /**
2621
+ * Names of zero-argument calls that are idempotent reads with no observable
2622
+ * side effect — chiefly reactive getters (signal / memo accessors), which
2623
+ * return the same value each time within a render. Inlining such a read at
2624
+ * multiple sites is evaluation-count-neutral, so the fold may treat
2625
+ * `getter()` as pure. The caller (e.g. `jsx-to-ir`) supplies the set from its
2626
+ * analyzer-collected signal/memo names; callers without that context (the
2627
+ * plain `convertNode` callback path) omit it and every call stays "possibly
2628
+ * impure". A non-empty arg list or a member-call (`a.b()`) is never treated as
2629
+ * pure by this set.
2630
+ */
2631
+ pureCallNames?: ReadonlySet<string>
2632
+ }
2633
+
2634
+ /**
2635
+ * Whether an expression is provably free of side effects, so it is safe to
2636
+ * inline at any number of use sites. Conservative: a function / method call is
2637
+ * possibly impure, EXCEPT a zero-arg call to a name in `pureCallNames` (an
2638
+ * idempotent reactive getter read). Member access is treated as pure, matching
2639
+ * `substituteDestructuredFields` and the rest of the compiler's expression
2640
+ * handling.
2641
+ */
2642
+ function isPureInit(e: ParsedExpr, pureCallNames?: ReadonlySet<string>): boolean {
2643
+ const pure = (x: ParsedExpr) => isPureInit(x, pureCallNames)
2644
+ switch (e.kind) {
2645
+ case 'identifier':
2646
+ case 'literal':
2647
+ case 'regex':
2648
+ return true
2649
+ case 'member':
2650
+ return pure(e.object)
2651
+ case 'index-access':
2652
+ return pure(e.object) && pure(e.index)
2653
+ case 'binary':
2654
+ case 'logical':
2655
+ return pure(e.left) && pure(e.right)
2656
+ case 'unary':
2657
+ return pure(e.argument)
2658
+ case 'conditional':
2659
+ return pure(e.test) && pure(e.consequent) && pure(e.alternate)
2660
+ case 'template-literal':
2661
+ return e.parts.every(p => p.type !== 'expression' || pure(p.expr))
2662
+ case 'array-literal':
2663
+ return e.elements.every(pure)
2664
+ case 'object-literal':
2665
+ return e.properties.every(p => pure(p.value))
2666
+ case 'call':
2667
+ // A zero-arg reactive getter read (`filter()`, `count()`) is idempotent;
2668
+ // any other call may be effectful or non-deterministic.
2669
+ return (
2670
+ e.callee.kind === 'identifier' &&
2671
+ e.args.length === 0 &&
2672
+ pureCallNames !== undefined &&
2673
+ pureCallNames.has(e.callee.name)
2674
+ )
2675
+ // A method call may be effectful; an arrow value can capture impurity;
2676
+ // `unsupported` is opaque. Treat all as possibly impure.
2677
+ case 'array-method':
2678
+ case 'arrow':
2679
+ case 'unsupported':
2680
+ return false
2681
+ }
2682
+ }
2683
+
2684
+ /**
2685
+ * The `{ min, max }` number of times `name` is evaluated on a single runtime
2686
+ * path through `expr` — the minimum-cost path and the maximum-cost path. Used to
2687
+ * decide whether inlining a possibly-impure init is evaluation-count-preserving:
2688
+ * sound only when it is evaluated **exactly once on every path** (`min === 1 &&
2689
+ * max === 1`), so the substituted call neither drops nor duplicates its effect.
2690
+ *
2691
+ * Path semantics:
2692
+ * - `conditional` evaluates the test then exactly one arm → test + the min/max
2693
+ * of the two arms (a binding used in only one arm has `min` 0: it is skipped
2694
+ * on the other path).
2695
+ * - `logical` (`&&` / `||` / `??`) evaluates the left, then the right only on
2696
+ * some paths (short-circuit) → the right contributes to `max` but not `min`.
2697
+ * - a nested `arrow` body may run any number of times (a callback invoked per
2698
+ * element / comparison, or never) → a use inside has `min` 0 and `max`
2699
+ * `Infinity`, forcing an impure binding referenced from a callback to be
2700
+ * refused.
2701
+ */
2702
+ function usesPerPath(name: string, expr: ParsedExpr): { min: number; max: number } {
2703
+ const add = (a: { min: number; max: number }, b: { min: number; max: number }) => ({
2704
+ min: a.min + b.min,
2705
+ max: a.max + b.max,
2706
+ })
2707
+ const sum = (xs: ParsedExpr[]) => xs.reduce((acc, x) => add(acc, walk(x)), { min: 0, max: 0 })
2708
+ const walk = (e: ParsedExpr): { min: number; max: number } => {
2709
+ switch (e.kind) {
2710
+ case 'identifier':
2711
+ return e.name === name ? { min: 1, max: 1 } : { min: 0, max: 0 }
2712
+ case 'literal':
2713
+ case 'regex':
2714
+ case 'unsupported':
2715
+ return { min: 0, max: 0 }
2716
+ case 'member':
2717
+ return walk(e.object)
2718
+ case 'index-access':
2719
+ return add(walk(e.object), walk(e.index))
2720
+ case 'binary':
2721
+ return add(walk(e.left), walk(e.right))
2722
+ case 'logical': {
2723
+ // The right operand is only evaluated on some paths (short-circuit), so
2724
+ // it contributes to the max but never to the guaranteed min.
2725
+ const l = walk(e.left)
2726
+ const r = walk(e.right)
2727
+ return { min: l.min, max: l.max + r.max }
2728
+ }
2729
+ case 'unary':
2730
+ return walk(e.argument)
2731
+ case 'conditional': {
2732
+ const t = walk(e.test)
2733
+ const c = walk(e.consequent)
2734
+ const a = walk(e.alternate)
2735
+ return { min: t.min + Math.min(c.min, a.min), max: t.max + Math.max(c.max, a.max) }
2736
+ }
2737
+ case 'template-literal':
2738
+ return sum(e.parts.flatMap(p => (p.type === 'expression' ? [p.expr] : [])))
2739
+ case 'call':
2740
+ return add(walk(e.callee), sum(e.args))
2741
+ case 'array-literal':
2742
+ return sum(e.elements)
2743
+ case 'array-method':
2744
+ return add(walk(e.object), e.method === 'flat' ? { min: 0, max: 0 } : sum(e.args))
2745
+ case 'object-literal':
2746
+ return sum(e.properties.map(p => p.value))
2747
+ case 'arrow':
2748
+ // A callback body may run any number of times (per element, or never).
2749
+ return walk(e.body).max > 0 ? { min: 0, max: Number.POSITIVE_INFINITY } : { min: 0, max: 0 }
2750
+ }
2751
+ }
2752
+ return walk(expr)
2753
+ }
2754
+
2755
+ /**
2756
+ * Inline `name → value` everywhere it appears free in `expr` (the let-inline
2757
+ * step). Returns `null` if the substitution would capture a free variable of
2758
+ * `value` under a nested callback parameter of the same name — that shape is
2759
+ * unsound to inline non-hygienically, so the caller refuses with
2760
+ * {@link CAPTURE_BLOCK_REASON}. A nested arrow parameter that shadows `name`
2761
+ * leaves that inner reference untouched (it is the parameter, not the binding).
2762
+ * Mirrors the structural walk of `substituteDestructuredFields`; every
2763
+ * `ParsedExpr` kind is handled so a new kind surfaces as a compile error here.
2764
+ */
2765
+ function inlineBinding(
2766
+ expr: ParsedExpr,
2767
+ name: string,
2768
+ value: ParsedExpr,
2769
+ ): ParsedExpr | null {
2770
+ // Free variables of `value` that an enclosing callback parameter could capture.
2771
+ const valueFree = new Set<string>()
2772
+ collectIdentifiers(value, valueFree)
2773
+ let captured = false
2774
+
2775
+ const walk = (e: ParsedExpr, enclosing: ReadonlySet<string>): ParsedExpr => {
2776
+ switch (e.kind) {
2777
+ case 'identifier': {
2778
+ if (e.name !== name) return e
2779
+ // Shadowed by an enclosing callback param → this is the parameter, not
2780
+ // the binding; leave it.
2781
+ if (enclosing.has(e.name)) return e
2782
+ // Inlining here: does any enclosing param capture a free var of `value`?
2783
+ for (const p of enclosing) {
2784
+ if (valueFree.has(p)) {
2785
+ captured = true
2786
+ return e
2787
+ }
2788
+ }
2789
+ return value
2790
+ }
2791
+ case 'call':
2792
+ return { kind: 'call', callee: walk(e.callee, enclosing), args: e.args.map(a => walk(a, enclosing)) }
2793
+ case 'member':
2794
+ return { kind: 'member', object: walk(e.object, enclosing), property: e.property, computed: e.computed }
2795
+ case 'index-access':
2796
+ return { kind: 'index-access', object: walk(e.object, enclosing), index: walk(e.index, enclosing) }
2797
+ case 'binary':
2798
+ return { kind: 'binary', op: e.op, left: walk(e.left, enclosing), right: walk(e.right, enclosing) }
2799
+ case 'logical':
2800
+ return { kind: 'logical', op: e.op, left: walk(e.left, enclosing), right: walk(e.right, enclosing) }
2801
+ case 'unary':
2802
+ return { kind: 'unary', op: e.op, argument: walk(e.argument, enclosing) }
2803
+ case 'conditional':
2804
+ return { kind: 'conditional', test: walk(e.test, enclosing), consequent: walk(e.consequent, enclosing), alternate: walk(e.alternate, enclosing) }
2805
+ case 'template-literal':
2806
+ return {
2807
+ kind: 'template-literal',
2808
+ parts: e.parts.map(p =>
2809
+ p.type === 'expression' ? { type: 'expression', expr: walk(p.expr, enclosing) } : p,
2810
+ ),
2811
+ }
2812
+ case 'arrow': {
2813
+ const innerEnclosing = e.params.length === 0 ? enclosing : new Set([...enclosing, ...e.params])
2814
+ return { kind: 'arrow', params: e.params, body: walk(e.body, innerEnclosing) }
2815
+ }
2816
+ case 'array-literal':
2817
+ return { kind: 'array-literal', elements: e.elements.map(el => walk(el, enclosing)) }
2818
+ case 'array-method':
2819
+ if (e.method === 'flat') {
2820
+ return { kind: 'array-method', method: 'flat', object: walk(e.object, enclosing), args: [], flatDepth: e.flatDepth }
2821
+ }
2822
+ return { kind: 'array-method', method: e.method, object: walk(e.object, enclosing), args: e.args.map(a => walk(a, enclosing)) }
2823
+ case 'object-literal':
2824
+ return {
2825
+ kind: 'object-literal',
2826
+ properties: e.properties.map(p => ({ ...p, value: walk(p.value, enclosing) })),
2827
+ raw: e.raw,
2828
+ }
2829
+ case 'literal':
2830
+ case 'regex':
2831
+ case 'unsupported':
2832
+ return e
2833
+ }
2834
+ }
2835
+
2836
+ const result = walk(expr, new Set())
2837
+ return captured ? null : result
2838
+ }
2839
+
2840
+ /**
2841
+ * Fold a value-producing block body — a {@link ParsedStatement} sequence of
2842
+ * `const` bindings, value-producing `if` / early `return`, and a terminal
2843
+ * `return` — into a single {@link ParsedExpr}, so block-bodied memos / derived /
2844
+ * callbacks flow through the same expression surface as expression-bodied ones
2845
+ * (#2040, carved from #2018 stage 5). This generalizes the per-idiom block-memo
2846
+ * recognizers (#1897 / #1945 / #2015) the same way the evaluator replaced the
2847
+ * `bf_sort` / `bf_reduce` catalogue: one normalization, no growing pattern list.
2848
+ *
2849
+ * Transformations:
2850
+ * - `const x = <init>; …` → inline `x`'s init into the rest (let-inline).
2851
+ * - `if (c) <then> [else <else>] …` → `c ? fold(then-path) : fold(else-path)`,
2852
+ * where a branch that does not itself terminate continues into the
2853
+ * statements following the `if` (the early-return idiom).
2854
+ * - `return <v>` → `<v>`.
2855
+ *
2856
+ * The rest is folded first, leaving each binding as a free identifier, so its
2857
+ * use count can be measured before inlining. Inlining is refused (→ `ok: false`)
2858
+ * when it would be unsound:
2859
+ * - a possibly-impure init (one containing a call) used zero or more than once
2860
+ * on a path would drop or duplicate the side effect (a pure init is always
2861
+ * safe to inline any number of times);
2862
+ * - a substitution would capture a free variable of the init under a nested
2863
+ * callback parameter of the same name (substitution is not hygienic).
2864
+ *
2865
+ * Returns `{ ok: false }` for a sequence that cannot produce a value on some
2866
+ * path (falls through with no `return`) — the genuinely-imperative residue that
2867
+ * {@link IMPERATIVE_BLOCK_REASON} describes. The input is assumed to be the
2868
+ * STRICT parse ({@link parseBlockBody}, not the tolerant variant): every source
2869
+ * statement is represented, so a `false` here reflects the real shape rather
2870
+ * than a silently-dropped statement.
2871
+ */
2872
+ export function foldBlockToExpr(
2873
+ stmts: ParsedStatement[],
2874
+ opts?: FoldBlockOptions,
2875
+ ): { ok: true; expr: ParsedExpr } | { ok: false; reason: string } {
2876
+ if (stmts.length === 0) {
2877
+ return { ok: false, reason: IMPERATIVE_BLOCK_REASON }
2878
+ }
2879
+ const [head, ...rest] = stmts
2880
+ switch (head.kind) {
2881
+ case 'var-decl': {
2882
+ // Fold the remaining statements first, leaving `head.name` free so its use
2883
+ // count can drive the soundness check. Any earlier-binding references in
2884
+ // the rest are inlined by the enclosing `var-decl` frames; references to
2885
+ // `head.name` inside `head.init` cannot occur (a `const` can't read itself).
2886
+ const restFold = foldBlockToExpr(rest, opts)
2887
+ if (!restFold.ok) return restFold
2888
+ const uses = usesPerPath(head.name, restFold.expr)
2889
+ // A possibly-impure init is only safe to inline when it is evaluated
2890
+ // exactly once on EVERY path — same as the original block, which runs the
2891
+ // `const` initializer unconditionally once. `min !== 1` catches an effect
2892
+ // dropped on some path (unused, or used in only one ternary arm / a
2893
+ // short-circuited operand / a callback); `max !== 1` catches duplication.
2894
+ // A pure init is safe at any count (drop / duplicate is unobservable);
2895
+ // idempotent reactive getter reads in `pureCallNames` count as pure.
2896
+ if (!isPureInit(head.init, opts?.pureCallNames) && !(uses.min === 1 && uses.max === 1)) {
2897
+ return { ok: false, reason: IMPURE_INLINE_BLOCK_REASON }
2898
+ }
2899
+ const inlined = inlineBinding(restFold.expr, head.name, head.init)
2900
+ if (inlined === null) {
2901
+ return { ok: false, reason: CAPTURE_BLOCK_REASON }
2902
+ }
2903
+ return { ok: true, expr: inlined }
2904
+ }
2905
+ case 'return':
2906
+ return { ok: true, expr: head.value }
2907
+ case 'if': {
2908
+ // A branch that doesn't return falls through to the statements after the
2909
+ // `if` (early-return idiom). A branch that returns makes `rest` dead for
2910
+ // that path, so it is not appended. `rest` is intentionally duplicated
2911
+ // into both fall-through paths; because each path is a separate ternary
2912
+ // arm, a binding used once per arm is still evaluated at most once per
2913
+ // runtime path.
2914
+ const thenPath = statementsTerminate(head.consequent)
2915
+ ? head.consequent
2916
+ : [...head.consequent, ...rest]
2917
+ const elseBase = head.alternate ?? []
2918
+ const elsePath = statementsTerminate(elseBase)
2919
+ ? elseBase
2920
+ : [...elseBase, ...rest]
2921
+ const consequent = foldBlockToExpr(thenPath, opts)
2922
+ if (!consequent.ok) return consequent
2923
+ const alternate = foldBlockToExpr(elsePath, opts)
2924
+ if (!alternate.ok) return alternate
2925
+ return {
2926
+ ok: true,
2927
+ expr: {
2928
+ kind: 'conditional',
2929
+ test: head.condition,
2930
+ consequent: consequent.expr,
2931
+ alternate: alternate.expr,
2932
+ },
2933
+ }
2934
+ }
2935
+ }
2936
+ }
2937
+
2938
+ /**
2939
+ * Rewrite a ternary whose arms are used in BOOLEAN context — e.g. the result of
2940
+ * folding a block-bodied filter predicate (`if (c) return A; return B` →
2941
+ * `c ? A : B`) — into an equivalent `&&` / `||` expression, so it flows through
2942
+ * the ordinary boolean-expression lowering instead of needing a dedicated
2943
+ * block-condition renderer per adapter (#2040). Boolean-literal arms collapse:
2944
+ *
2945
+ * c ? true : false → c
2946
+ * c ? true : f → c || f
2947
+ * c ? t : false → c && t
2948
+ * c ? false : f → !c && f
2949
+ * c ? t : true → !c || t
2950
+ * c ? t : f → (c && t) || (!c && f)
2951
+ *
2952
+ * Arms are flattened recursively (an `else if` chain is a nested ternary); the
2953
+ * test is left as-is. Only valid where the consumer interprets the value as a
2954
+ * boolean (a filter predicate). Non-conditional input is returned unchanged.
2955
+ */
2956
+ export function predicateTernaryToLogical(expr: ParsedExpr): ParsedExpr {
2957
+ if (expr.kind !== 'conditional') return expr
2958
+ const cond = expr.test
2959
+ const t = predicateTernaryToLogical(expr.consequent)
2960
+ const f = predicateTernaryToLogical(expr.alternate)
2961
+ const isTrue = (x: ParsedExpr) => x.kind === 'literal' && x.literalType === 'boolean' && x.value === true
2962
+ const isFalse = (x: ParsedExpr) => x.kind === 'literal' && x.literalType === 'boolean' && x.value === false
2963
+ const not = (x: ParsedExpr): ParsedExpr => ({ kind: 'unary', op: '!', argument: x })
2964
+ const and = (a: ParsedExpr, b: ParsedExpr): ParsedExpr => ({ kind: 'logical', op: '&&', left: a, right: b })
2965
+ const or = (a: ParsedExpr, b: ParsedExpr): ParsedExpr => ({ kind: 'logical', op: '||', left: a, right: b })
2966
+ if (isTrue(t) && isFalse(f)) return cond
2967
+ if (isTrue(t)) return or(cond, f)
2968
+ if (isFalse(f)) return and(cond, t)
2969
+ if (isFalse(t)) return and(not(cond), f)
2970
+ if (isTrue(f)) return or(not(cond), t)
2971
+ return or(and(cond, t), and(not(cond), f))
2972
+ }
2973
+
2928
2974
  /**
2929
2975
  * Parse an if branch (then or else) into ParsedStatement array.
2930
2976
  */
@@ -2975,28 +3021,17 @@ export function exprToString(expr: ParsedExpr): string {
2975
3021
  return '`' + expr.parts.map(p =>
2976
3022
  p.type === 'string' ? p.value : `\${${exprToString(p.expr)}}`
2977
3023
  ).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)})`
3024
+ case 'arrow': {
3025
+ // Single-param arrows round-trip without parens (`x => …`); multi-param
3026
+ // need them (`(a, b) => …`).
3027
+ const params = expr.params.length === 1 ? expr.params[0] : `(${expr.params.join(', ')})`
3028
+ return `${params} => ${exprToString(expr.body)}`
3029
+ }
3030
+ case 'regex':
3031
+ return expr.raw
2982
3032
  case 'array-literal':
2983
3033
  return `[${expr.elements.map(exprToString).join(', ')}]`
2984
3034
  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
3035
  if (expr.method === 'flat') {
3001
3036
  // Preserve the normalised depth so diagnostics don't misleadingly
3002
3037
  // print `.flat()` for a `.flat(2)` / `.flat(Infinity)` source.
@@ -3004,12 +3039,11 @@ export function exprToString(expr: ParsedExpr): string {
3004
3039
  const depthSrc = d === 'infinity' ? 'Infinity' : String(d)
3005
3040
  return `${exprToString(expr.object)}.flat(${d === 1 ? '' : depthSrc})`
3006
3041
  }
3007
- if (expr.method === 'flatMap') {
3008
- const { param, raw } = expr.flatMapOp
3009
- return `${exprToString(expr.object)}.flatMap(${param} => ${raw})`
3010
- }
3011
3042
  return `${exprToString(expr.object)}.${expr.method}(${expr.args.map(exprToString).join(', ')})`
3012
3043
  case 'unsupported':
3044
+ // `raw` holds the original expression string (same value the old
3045
+ // `unsupported` carried), so the round-trip stays byte-identical.
3046
+ case 'object-literal':
3013
3047
  return `[UNSUPPORTED: ${expr.raw}]`
3014
3048
  }
3015
3049
  }
@@ -3058,30 +3092,19 @@ export function stringifyParsedExpr(expr: ParsedExpr): string {
3058
3092
  return '`' + expr.parts.map(p =>
3059
3093
  p.type === 'string' ? p.value : `\${${stringifyParsedExpr(p.expr)}}`
3060
3094
  ).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)})`
3095
+ case 'arrow': {
3096
+ // Round-trip to valid JS for the CSR / Hono path and downstream
3097
+ // re-parsers. A higher-order callback (`.sort`/`.filter`/…) reaches here
3098
+ // as the arrow argument of a generic `call`. Single-param arrows round-
3099
+ // trip without parens (`x => …`); multi-param need them (`(a, b) => …`).
3100
+ const params = expr.params.length === 1 ? expr.params[0] : `(${expr.params.join(', ')})`
3101
+ return `${params} => ${stringifyParsedExpr(expr.body)}`
3102
+ }
3103
+ case 'regex':
3104
+ return expr.raw
3065
3105
  case 'array-literal':
3066
3106
  return `[${expr.elements.map(stringifyParsedExpr).join(', ')}]`
3067
3107
  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
3108
  if (expr.method === 'flat') {
3086
3109
  // Round-trip the normalised depth back to JS for the CSR / Hono
3087
3110
  // path: `'infinity'` → `Infinity`, `1` is left implicit (`.flat()`).
@@ -3089,18 +3112,251 @@ export function stringifyParsedExpr(expr: ParsedExpr): string {
3089
3112
  const depthSrc = d === 'infinity' ? 'Infinity' : String(d)
3090
3113
  return `${stringifyParsedExpr(expr.object)}.flat(${d === 1 ? '' : depthSrc})`
3091
3114
  }
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
3115
  return `${stringifyParsedExpr(expr.object)}.${expr.method}(${expr.args.map(stringifyParsedExpr).join(', ')})`
3099
3116
  case 'unsupported':
3117
+ // `raw` is the original expression string, so re-stringification is
3118
+ // byte-identical to the pre-`object-literal` behaviour (Roadmap A-1).
3119
+ case 'object-literal':
3100
3120
  return expr.raw
3101
3121
  }
3102
3122
  }
3103
3123
 
3124
+ /**
3125
+ * Serialize a pure-expression `ParsedExpr` (a higher-order callback body) into
3126
+ * the minimal JSON the runtime evaluator consumes — the format pinned by the
3127
+ * `eval-vectors` golden cases and read by Go `eval.go` `EvalNode` / Perl
3128
+ * `Evaluator.pm` `evaluate`. Only the evaluator-recognized fields are emitted
3129
+ * per kind (a literal carries just `value`; `literalType` / `raw` are dropped —
3130
+ * the evaluator never reads them; `member.computed` is kept when set so a
3131
+ * computed member stays distinguishable), keeping the embedded body blob small
3132
+ * and stable.
3133
+ *
3134
+ * Returns `null` when the tree contains a shape outside the evaluator's surface
3135
+ * — a folded `higher-order` / `array-method`, an `arrow-fn`, an `unsupported`
3136
+ * node, an operator the evaluator doesn't implement, or a `call` whose callee
3137
+ * isn't an allowlisted builtin (`Math.*` / `String` / `Number` / `Boolean`) — so
3138
+ * the caller refuses the body (BF101 / `@client`) instead of emitting a blob the
3139
+ * evaluator would read as nil. The evaluator's support criterion is
3140
+ * purely-functional expressibility; this is its compile-time gate. (#2018)
3141
+ */
3142
+ export function serializeParsedExpr(expr: ParsedExpr): string | null {
3143
+ const node = toEvalNode(expr)
3144
+ return node === null ? null : JSON.stringify(node)
3145
+ }
3146
+
3147
+ /**
3148
+ * The free variables a higher-order callback body references — every bare
3149
+ * identifier in a value position, minus the callback's own `params`. The
3150
+ * adapter materializes each into the evaluator's `base_env` (mapping the JS name
3151
+ * to its SSR value). Walks exactly the value positions {@link serializeParsedExpr}
3152
+ * serializes (so it sees object-literal *values* and template-expression parts,
3153
+ * and skips member property names / object keys, which are not references).
3154
+ * Returns a sorted, de-duplicated list for stable emit. (#2018)
3155
+ */
3156
+ export function freeVarsInBody(body: ParsedExpr, params: ReadonlySet<string>): string[] {
3157
+ const found = new Set<string>()
3158
+ const visit = (e: ParsedExpr): void => {
3159
+ switch (e.kind) {
3160
+ case 'identifier':
3161
+ if (!params.has(e.name)) found.add(e.name)
3162
+ return
3163
+ case 'binary':
3164
+ case 'logical':
3165
+ visit(e.left)
3166
+ visit(e.right)
3167
+ return
3168
+ case 'unary':
3169
+ visit(e.argument)
3170
+ return
3171
+ case 'conditional':
3172
+ visit(e.test)
3173
+ visit(e.consequent)
3174
+ visit(e.alternate)
3175
+ return
3176
+ case 'member':
3177
+ visit(e.object)
3178
+ return
3179
+ case 'index-access':
3180
+ visit(e.object)
3181
+ visit(e.index)
3182
+ return
3183
+ case 'call':
3184
+ // A builtin callee (`String`/`Number`/`Boolean`, or `Math.<fn>`) is
3185
+ // resolved syntactically by the evaluator — its identifier is NOT a
3186
+ // captured free var. Visiting it would add `Math` / `String` to the
3187
+ // env, making the adapter emit an undefined `$Math` / `.Math` base_env
3188
+ // entry (Copilot review #2031). Skip the callee identifier in that
3189
+ // case; the arguments are still real references and are visited.
3190
+ if (evalBuiltinCalleeName(e.callee) === null) visit(e.callee)
3191
+ e.args.forEach(visit)
3192
+ return
3193
+ case 'template-literal':
3194
+ for (const p of e.parts) if (p.type === 'expression') visit(p.expr)
3195
+ return
3196
+ case 'array-literal':
3197
+ e.elements.forEach(visit)
3198
+ return
3199
+ case 'object-literal':
3200
+ // Object *values* are references; keys are not. (Shorthand `{ x }`
3201
+ // carries the ref on its `value` identifier, which is visited here.)
3202
+ for (const p of e.properties) visit(p.value)
3203
+ return
3204
+ // Non-serializable kinds don't occur in a serializable body
3205
+ // (serializeParsedExpr returns null for them); nothing to collect.
3206
+ case 'literal':
3207
+ case 'array-method':
3208
+ case 'arrow':
3209
+ case 'regex':
3210
+ case 'unsupported':
3211
+ return
3212
+ }
3213
+ }
3214
+ visit(body)
3215
+ return [...found].sort()
3216
+ }
3217
+
3218
+ // Operators the evaluator implements (Go `eval.go` evalBinary / evalUnary, Perl
3219
+ // `Evaluator.pm` _binary / _unary). An op outside these sets — loose `==`,
3220
+ // `instanceof`, `**`, bitwise/shift, or the parser's `'unknown'` sentinel — is
3221
+ // refused so the body falls back to BF101 rather than serializing an op the
3222
+ // evaluator would silently mis-handle.
3223
+ const EVAL_BINARY_OPS: ReadonlySet<string> = new Set([
3224
+ '+', '-', '*', '/', '%', '<', '<=', '>', '>=', '===', '!==',
3225
+ ])
3226
+ const EVAL_UNARY_OPS: ReadonlySet<string> = new Set(['!', '-', '+'])
3227
+
3228
+ // The only call shapes the evaluator executes (Go `eval.go` evalBuiltinName /
3229
+ // evalCallBuiltin, Perl `Evaluator.pm` _call_builtin): a bare `String` / `Number`
3230
+ // / `Boolean`, or a NON-computed `Math.<fn>` for a fixed `<fn>` set. Any other
3231
+ // callee — a bare function (`foo(x)`), a method (`x.bar(...)`), or a *computed*
3232
+ // builtin (`Math['max']`, which the evaluator rejects) — evaluates to nil at
3233
+ // runtime, so the gate refuses it at compile time instead (BF101 / `@client`).
3234
+ const EVAL_BUILTIN_IDENTS: ReadonlySet<string> = new Set(['String', 'Number', 'Boolean'])
3235
+ const EVAL_MATH_METHODS: ReadonlySet<string> = new Set([
3236
+ 'max', 'min', 'abs', 'floor', 'ceil', 'round',
3237
+ ])
3238
+
3239
+ /** The allowlisted builtin name a call callee resolves to (`Math.max` / `String`), or null. */
3240
+ function evalBuiltinCalleeName(callee: ParsedExpr): string | null {
3241
+ if (callee.kind === 'identifier') {
3242
+ return EVAL_BUILTIN_IDENTS.has(callee.name) ? callee.name : null
3243
+ }
3244
+ if (
3245
+ callee.kind === 'member' &&
3246
+ !callee.computed &&
3247
+ callee.object.kind === 'identifier' &&
3248
+ callee.object.name === 'Math' &&
3249
+ EVAL_MATH_METHODS.has(callee.property)
3250
+ ) {
3251
+ return `Math.${callee.property}`
3252
+ }
3253
+ return null
3254
+ }
3255
+
3256
+ /** Build the evaluator's minimal node object, or null for an out-of-surface kind. */
3257
+ function toEvalNode(e: ParsedExpr): Record<string, unknown> | null {
3258
+ switch (e.kind) {
3259
+ case 'literal':
3260
+ return { kind: 'literal', value: e.value }
3261
+ case 'identifier':
3262
+ return { kind: 'identifier', name: e.name }
3263
+ case 'binary': {
3264
+ if (!EVAL_BINARY_OPS.has(e.op)) return null
3265
+ const left = toEvalNode(e.left)
3266
+ const right = toEvalNode(e.right)
3267
+ return left && right ? { kind: 'binary', op: e.op, left, right } : null
3268
+ }
3269
+ case 'logical': {
3270
+ // `op` is the fixed `&&` | `||` | `??` union — all evaluator-supported.
3271
+ const left = toEvalNode(e.left)
3272
+ const right = toEvalNode(e.right)
3273
+ return left && right ? { kind: 'logical', op: e.op, left, right } : null
3274
+ }
3275
+ case 'unary': {
3276
+ if (!EVAL_UNARY_OPS.has(e.op)) return null
3277
+ const argument = toEvalNode(e.argument)
3278
+ return argument ? { kind: 'unary', op: e.op, argument } : null
3279
+ }
3280
+ case 'conditional': {
3281
+ const test = toEvalNode(e.test)
3282
+ const consequent = toEvalNode(e.consequent)
3283
+ const alternate = toEvalNode(e.alternate)
3284
+ return test && consequent && alternate
3285
+ ? { kind: 'conditional', test, consequent, alternate }
3286
+ : null
3287
+ }
3288
+ case 'member': {
3289
+ const object = toEvalNode(e.object)
3290
+ if (!object) return null
3291
+ // Carry `computed` only when set (absent reads as `false`): the evaluator
3292
+ // reads it to reject a computed builtin (`Math['max']`), so preserving it
3293
+ // keeps a computed member distinguishable from a plain `.prop` access. (A
3294
+ // computed builtin *call* is already refused by the callee gate above.)
3295
+ const node: Record<string, unknown> = { kind: 'member', object, property: e.property }
3296
+ if (e.computed) node.computed = true
3297
+ return node
3298
+ }
3299
+ case 'index-access': {
3300
+ const object = toEvalNode(e.object)
3301
+ const index = toEvalNode(e.index)
3302
+ return object && index ? { kind: 'index-access', object, index } : null
3303
+ }
3304
+ case 'call': {
3305
+ // The evaluator executes only the builtin allowlist; a non-builtin callee
3306
+ // would evaluate to nil at runtime, so refuse it here (the purity gate).
3307
+ if (evalBuiltinCalleeName(e.callee) === null) return null
3308
+ const callee = toEvalNode(e.callee)
3309
+ if (!callee) return null
3310
+ const args: Record<string, unknown>[] = []
3311
+ for (const a of e.args) {
3312
+ const c = toEvalNode(a)
3313
+ if (!c) return null
3314
+ args.push(c)
3315
+ }
3316
+ return { kind: 'call', callee, args }
3317
+ }
3318
+ case 'template-literal': {
3319
+ const parts: Record<string, unknown>[] = []
3320
+ for (const p of e.parts) {
3321
+ if (p.type === 'string') {
3322
+ parts.push({ type: 'string', value: p.value })
3323
+ } else {
3324
+ const expr = toEvalNode(p.expr)
3325
+ if (!expr) return null
3326
+ parts.push({ type: 'expression', expr })
3327
+ }
3328
+ }
3329
+ return { kind: 'template-literal', parts }
3330
+ }
3331
+ case 'array-literal': {
3332
+ const elements: Record<string, unknown>[] = []
3333
+ for (const el of e.elements) {
3334
+ const c = toEvalNode(el)
3335
+ if (!c) return null
3336
+ elements.push(c)
3337
+ }
3338
+ return { kind: 'array-literal', elements }
3339
+ }
3340
+ case 'object-literal': {
3341
+ const properties: Record<string, unknown>[] = []
3342
+ for (const p of e.properties) {
3343
+ const value = toEvalNode(p.value)
3344
+ if (!value) return null
3345
+ properties.push({ key: p.key, value })
3346
+ }
3347
+ return { kind: 'object-literal', properties }
3348
+ }
3349
+ // Outside the evaluator's pure-expression surface — refuse so the caller
3350
+ // falls back to BF101 / `@client`. A nested `arrow` (a callback inside the
3351
+ // body) is refused here, keeping the evaluator non-recursive.
3352
+ case 'array-method':
3353
+ case 'arrow':
3354
+ case 'regex':
3355
+ case 'unsupported':
3356
+ return null
3357
+ }
3358
+ }
3359
+
3104
3360
  /**
3105
3361
  * Extract the textual identifier path from a parsed expression's
3106
3362
  * callee — `{kind:'identifier', name:'String'}` → `"String"`,