@barefootjs/jsx 0.17.0 → 0.18.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 (88) hide show
  1. package/dist/adapters/env-signal.d.ts +42 -7
  2. package/dist/adapters/env-signal.d.ts.map +1 -1
  3. package/dist/adapters/interface.d.ts +20 -4
  4. package/dist/adapters/interface.d.ts.map +1 -1
  5. package/dist/adapters/parsed-expr-emitter.d.ts +3 -1
  6. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  7. package/dist/analyzer-context.d.ts.map +1 -1
  8. package/dist/analyzer.d.ts.map +1 -1
  9. package/dist/augment-inherited-props.d.ts +19 -0
  10. package/dist/augment-inherited-props.d.ts.map +1 -1
  11. package/dist/compiler.d.ts.map +1 -1
  12. package/dist/expression-parser.d.ts +48 -2
  13. package/dist/expression-parser.d.ts.map +1 -1
  14. package/dist/index.d.ts +24 -5
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1728 -969
  17. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  21. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  24. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/emit-registration.d.ts +4 -2
  26. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/html-template.d.ts +41 -0
  28. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/types.d.ts +22 -1
  30. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  31. package/dist/jsx-to-ir.d.ts.map +1 -1
  32. package/dist/loop-destructure.d.ts +55 -18
  33. package/dist/loop-destructure.d.ts.map +1 -1
  34. package/dist/lowering-registry.d.ts +13 -0
  35. package/dist/lowering-registry.d.ts.map +1 -1
  36. package/dist/relocate.d.ts +28 -0
  37. package/dist/relocate.d.ts.map +1 -1
  38. package/dist/ssr-defaults.d.ts.map +1 -1
  39. package/dist/ssr-seed-plan.d.ts +84 -0
  40. package/dist/ssr-seed-plan.d.ts.map +1 -0
  41. package/dist/types.d.ts +79 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/package.json +2 -2
  44. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +284 -12
  45. package/src/__tests__/augment-inherited-props.test.ts +96 -0
  46. package/src/__tests__/compiler-runtime-contract.test.ts +11 -1
  47. package/src/__tests__/compiler-stress-1244.test.ts +13 -4
  48. package/src/__tests__/csr-substitution-safety-divergence.test.ts +137 -0
  49. package/src/__tests__/destructured-map-params.test.ts +11 -1
  50. package/src/__tests__/expression-parser.test.ts +74 -3
  51. package/src/__tests__/free-identifiers.test.ts +55 -0
  52. package/src/__tests__/ir-sort-comparator.test.ts +261 -0
  53. package/src/__tests__/loop-destructure.test.ts +313 -0
  54. package/src/__tests__/loop-hoisted-template.test.ts +235 -0
  55. package/src/__tests__/materialize-getter-calls.test.ts +58 -0
  56. package/src/__tests__/props-destructuring.test.ts +110 -0
  57. package/src/__tests__/serialize-parsed-expr.test.ts +88 -2
  58. package/src/__tests__/ssr-defaults.test.ts +20 -0
  59. package/src/__tests__/ssr-seed-plan.test.ts +212 -0
  60. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +231 -1
  61. package/src/__tests__/tagged-template-interleave.test.ts +268 -0
  62. package/src/__tests__/unsupported-expression.test.ts +194 -7
  63. package/src/adapters/env-signal.ts +57 -9
  64. package/src/adapters/interface.ts +20 -4
  65. package/src/adapters/parsed-expr-emitter.ts +19 -2
  66. package/src/analyzer-context.ts +20 -0
  67. package/src/analyzer.ts +74 -1
  68. package/src/augment-inherited-props.ts +139 -9
  69. package/src/compiler.ts +10 -1
  70. package/src/expression-parser.ts +421 -50
  71. package/src/index.ts +30 -3
  72. package/src/ir-to-client-js/collect-elements.ts +15 -1
  73. package/src/ir-to-client-js/compute-inlinability.ts +6 -1
  74. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  75. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  76. package/src/ir-to-client-js/control-flow/stringify/loop.ts +30 -8
  77. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +30 -0
  78. package/src/ir-to-client-js/emit-registration.ts +4 -2
  79. package/src/ir-to-client-js/html-template.ts +198 -1
  80. package/src/ir-to-client-js/index.ts +1 -0
  81. package/src/ir-to-client-js/types.ts +22 -0
  82. package/src/jsx-to-ir.ts +409 -24
  83. package/src/loop-destructure.ts +89 -36
  84. package/src/lowering-registry.ts +16 -0
  85. package/src/relocate.ts +201 -14
  86. package/src/ssr-defaults.ts +34 -32
  87. package/src/ssr-seed-plan.ts +146 -0
  88. package/src/types.ts +76 -0
@@ -99,18 +99,40 @@ export type ParsedExpr =
99
99
  object: ParsedExpr
100
100
  args: ParsedExpr[]
101
101
  }
102
- // `.flat(depth?)` (#1448 Tier C). The flatten depth is validated and
103
- // normalised into a structured `FlatDepth` at parse time — the literal
104
- // never reaches `args`, so adapters fold via a runtime helper instead of
105
- // re-inspecting the depth argument. A non-literal depth refuses with
106
- // BF101 (the depth must be known at template time). See the `.flat` arm
107
- // in `convertNode`.
102
+ // `.flat(depth?)` (#1448 Tier C, dynamic depth #2094). The flatten depth
103
+ // is validated at parse time — a literal integer / `Infinity` /
104
+ // unary-minus-literal normalises into a structured `FlatDepth` and the
105
+ // literal never reaches `args`, so adapters fold via a runtime helper
106
+ // instead of re-inspecting the depth argument. A non-literal depth that
107
+ // itself parses to a SUPPORTED `ParsedExpr` (a numeric prop, signal read,
108
+ // arithmetic, …) becomes a DYNAMIC depth: `depthExpr` carries that
109
+ // expression and `flatDepth` is a meaningless placeholder (`1`) — every
110
+ // consumer must check `depthExpr` first. A depth expression that itself
111
+ // doesn't resolve (an `unsupported` shape) still refuses with BF101 (the
112
+ // depth must be *expressible*, even if not known until render time). See
113
+ // the `.flat` arm in `convertNode`.
114
+ //
115
+ // Runtime coercion contract for a dynamic depth (JS `ToIntegerOrInfinity`,
116
+ // pinned by the `flat_dynamic` golden-vector cases): truncate toward
117
+ // zero; a NaN-producing value coerces to `0`; negative coerces to `0`
118
+ // (JS: negative depth never recurses, same as `0` — a shallow copy);
119
+ // `Infinity` / a huge finite value flattens fully. This is intentionally
120
+ // NOT the same runtime helper as the literal-depth path: the literal path
121
+ // pre-normalises `Infinity` to a `-1` *sentinel* meaning "flatten fully"
122
+ // (see `FlatDepth`'s doc), and a genuinely dynamic value of `-1` means
123
+ // the OPPOSITE (JS: no flatten) — reusing one helper for both would
124
+ // silently invert that case. Each adapter therefore routes a dynamic
125
+ // depth through a SEPARATE runtime helper (Go: `bf_flat_dynamic` /
126
+ // `FlatDynamicDepth`, distinct from the literal path's `bf_flat` / `Flat`)
127
+ // that performs the coercion above from scratch, never reusing the
128
+ // literal path's `-1`-means-infinite convention.
108
129
  | {
109
130
  kind: 'array-method'
110
131
  method: 'flat'
111
132
  object: ParsedExpr
112
133
  args: []
113
134
  flatDepth: FlatDepth
135
+ depthExpr?: ParsedExpr
114
136
  }
115
137
  | { kind: 'unsupported'; raw: string; reason: string }
116
138
 
@@ -247,8 +269,12 @@ export interface SupportResult {
247
269
  const UNSUPPORTED_METHODS = new Set([
248
270
  // Higher-order array methods. Seven of these (`filter`, `every`,
249
271
  // `some`, `find`, `findIndex`, `findLast`, `findLastIndex`) are
250
- // intercepted as `higher-order` IR before reaching this gate;
251
- // `map` is intercepted as an IRLoop. `reduce` / `reduceRight` stay
272
+ // intercepted as `higher-order` IR before reaching this gate.
273
+ // `map` is intercepted as an IRLoop when its callback returns JSX,
274
+ // and as a `CALLBACK_METHODS` evaluator lowering (`map_eval`, #2073)
275
+ // when it returns a value — it stays listed here so the fall-throughs
276
+ // (a bare `arr.map` reference, a function-reference callback) still
277
+ // refuse loudly. `reduce` / `reduceRight` stay
252
278
  // listed here so the shapes the Tier C catalogue can't lower still
253
279
  // refuse loudly: the `convertNode` call branch intercepts a matching
254
280
  // `.reduce(fn, init)` / `.reduceRight(fn, init)` into the structured
@@ -603,11 +629,12 @@ export function tsNodeToParsedExpr(node: ts.Node): ParsedExpr {
603
629
  * Higher-order array methods whose callback body the runtime evaluator drives
604
630
  * (#2018). Recognised generically as a `call` whose callee is `<recv>.<method>`
605
631
  * 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.
632
+ * to the evaluator. A JSX-returning `.map` / `.flatMap` is an IRLoop upstream
633
+ * and never reaches this recognition; the value-returning `.map(cb)` form
634
+ * (e.g. `tags.map(t => \`#${t}\`).join(' ')`) lowers via `map_eval` (#2073).
608
635
  */
609
636
  export const CALLBACK_METHODS: ReadonlySet<string> = new Set([
610
- 'filter', 'every', 'some', 'find', 'findIndex', 'findLast', 'findLastIndex',
637
+ 'filter', 'map', 'every', 'some', 'find', 'findIndex', 'findLast', 'findLastIndex',
611
638
  'sort', 'toSorted', 'reduce', 'reduceRight', 'flatMap',
612
639
  ])
613
640
 
@@ -790,6 +817,7 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
790
817
  if (callee.property === 'flat') {
791
818
  const depthNode = node.arguments[0]
792
819
  let flatDepth: FlatDepth
820
+ let depthExpr: ParsedExpr | undefined
793
821
  if (depthNode === undefined) {
794
822
  flatDepth = 1
795
823
  } else if (ts.isIdentifier(depthNode) && depthNode.text === 'Infinity') {
@@ -806,16 +834,34 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
806
834
  n = -Number(depthNode.operand.text)
807
835
  }
808
836
  if (n === undefined || Number.isNaN(n)) {
809
- return {
810
- kind: 'unsupported',
811
- raw,
812
- reason: `\`.flat(depth)\` needs a literal integer or \`Infinity\` depth — a computed depth can't be resolved at template time. Use a literal depth, or pre-compute the value before the template.`,
837
+ // Not a literal — try a DYNAMIC depth: an expression that
838
+ // itself resolves to a supported `ParsedExpr` (a numeric prop,
839
+ // signal read, arithmetic, …) can be lowered through a runtime
840
+ // helper that coerces it at render time (JS
841
+ // `ToIntegerOrInfinity`; see the `depthExpr` doc on the
842
+ // `array-method`/`flat` type). Still refuse with BF101 when
843
+ // the depth expression itself is outside the supported
844
+ // surface — the depth must be *expressible*, even if not
845
+ // known until render time.
846
+ const parsedDepth = convertNode(depthNode, raw)
847
+ if (checkSupport(parsedDepth).supported) {
848
+ depthExpr = parsedDepth
849
+ flatDepth = 1 // unused placeholder — consumers must check `depthExpr` first
850
+ } else {
851
+ return {
852
+ kind: 'unsupported',
853
+ raw,
854
+ reason: `\`.flat(depth)\` needs a literal integer, \`Infinity\`, or a supported dynamic depth expression — this depth can't be resolved. Use a literal depth, a supported expression (prop/signal/arithmetic), or pre-compute the value before the template.`,
855
+ }
813
856
  }
857
+ } else {
858
+ const truncated = Math.trunc(n)
859
+ flatDepth = truncated < 0 ? 0 : truncated
814
860
  }
815
- const truncated = Math.trunc(n)
816
- flatDepth = truncated < 0 ? 0 : truncated
817
861
  }
818
- return { kind: 'array-method', method: 'flat', object: callee.object, args: [], flatDepth }
862
+ return depthExpr !== undefined
863
+ ? { kind: 'array-method', method: 'flat', object: callee.object, args: [], flatDepth, depthExpr }
864
+ : { kind: 'array-method', method: 'flat', object: callee.object, args: [], flatDepth }
819
865
  }
820
866
  // `.toLowerCase()` — string-only (the IR carries a value-builtin
821
867
  // tag, not a receiver-type discriminator, so the `array-method`
@@ -1864,6 +1910,7 @@ function validateRestUsage(
1864
1910
  case 'array-method':
1865
1911
  walk(e.object)
1866
1912
  for (const a of e.args) walk(a)
1913
+ if (e.method === 'flat' && e.depthExpr) walk(e.depthExpr)
1867
1914
  return
1868
1915
  case 'literal':
1869
1916
  case 'unsupported':
@@ -1976,6 +2023,7 @@ function collectIdentifiers(expr: ParsedExpr, out: Set<string>): void {
1976
2023
  case 'array-method':
1977
2024
  collectIdentifiers(expr.object, out)
1978
2025
  expr.args.forEach(e => collectIdentifiers(e, out))
2026
+ if (expr.method === 'flat' && expr.depthExpr) collectIdentifiers(expr.depthExpr, out)
1979
2027
  return
1980
2028
  case 'literal':
1981
2029
  case 'regex':
@@ -2073,8 +2121,16 @@ function substituteDestructuredFields(
2073
2121
  case 'array-method':
2074
2122
  if (e.method === 'flat') {
2075
2123
  // `flatDepth` is a normalised literal — no destructure refs to
2076
- // substitute. Preserve verbatim.
2077
- return { kind: 'array-method', method: 'flat', object: walk(e.object), args: [], flatDepth: e.flatDepth }
2124
+ // substitute. `depthExpr` (a dynamic depth) DOES need the same
2125
+ // substitution as any other value position.
2126
+ return {
2127
+ kind: 'array-method',
2128
+ method: 'flat',
2129
+ object: walk(e.object),
2130
+ args: [],
2131
+ flatDepth: e.flatDepth,
2132
+ ...(e.depthExpr ? { depthExpr: walk(e.depthExpr) } : {}),
2133
+ }
2078
2134
  }
2079
2135
  return { kind: 'array-method', method: e.method, object: walk(e.object), args: e.args.map(walk) }
2080
2136
  case 'literal':
@@ -2197,6 +2253,16 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2197
2253
  const argSupport = checkSupport(arg)
2198
2254
  if (!argSupport.supported) return argSupport
2199
2255
  }
2256
+ // A dynamic `.flat(depth)` carries its depth expression outside
2257
+ // `args` (see the `depthExpr` doc) — check it too. The parser
2258
+ // already gated this at parse time (only a SUPPORTED depth
2259
+ // expression becomes `depthExpr`), so this re-check is defensive:
2260
+ // it keeps `isSupported` total for any `array-method`/`flat` node
2261
+ // regardless of how it was constructed (e.g. by a rewrite walker).
2262
+ if (expr.method === 'flat' && expr.depthExpr) {
2263
+ const depthSupport = checkSupport(expr.depthExpr)
2264
+ if (!depthSupport.supported) return depthSupport
2265
+ }
2200
2266
  return { supported: true, level: 'L2' }
2201
2267
  }
2202
2268
 
@@ -2207,8 +2273,12 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2207
2273
  // the receiver and the callback BODY are supported. Recognised before the
2208
2274
  // `UNSUPPORTED_METHODS` gate so the eval-lowered shapes aren't refused
2209
2275
  // (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.
2276
+ // falls through to the gate). A nested callback inside the body is NOT
2277
+ // refused here: the evaluator refuses it (`serializeParsedExpr` null)
2278
+ // and each adapter then either lowers it faithfully (Mojo's inline
2279
+ // `grep`, Go's `len (bf_filter_eval …)`) or surfaces BF101 at its
2280
+ // predicate fallback's exact degrade points (#2038) — a blanket refusal
2281
+ // here would break the faithful shapes (#1443 PR4).
2212
2282
  const cb = asCallbackMethodCall(expr)
2213
2283
  if (cb) {
2214
2284
  const objSupport = checkSupport(cb.object)
@@ -2329,6 +2399,31 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2329
2399
  case 'logical': {
2330
2400
  const leftSupport = checkSupport(expr.left)
2331
2401
  if (!leftSupport.supported) return leftSupport
2402
+
2403
+ // `x ?? {}` — admit an EMPTY object-literal fallback as the right
2404
+ // operand of `??` only. A standalone object literal stays refused
2405
+ // (the `object-literal` case above), and a non-empty one here still
2406
+ // falls through to the general `checkSupport(expr.right)` refusal
2407
+ // below. This is deliberately narrow, scoped to the one shape that
2408
+ // needs it (chart's `<Ctx.Provider value={{ config: props.config ??
2409
+ // {} }}>`):
2410
+ // - `??` only, not `&&` / `||` — a `{}` operand there plays a
2411
+ // different (always-truthy) role that each adapter's
2412
+ // boolean/filter dispatcher already handles by treating an
2413
+ // object-literal leaf as an unconditional-truthy sentinel; that
2414
+ // path doesn't need a real emitted value the way `??`'s value
2415
+ // position does.
2416
+ // - EMPTY object literal only (`properties.length === 0`) — the
2417
+ // one caller of this shape never needs populated fallback keys,
2418
+ // and restricting to empty means every adapter's `objectLiteral`
2419
+ // value-emitter only has to special-case the zero-property case
2420
+ // (emit its language's empty dict/hashref literal) rather than
2421
+ // also lowering arbitrary member values through the general
2422
+ // expression pipeline for SSR/CSR parity.
2423
+ if (expr.op === '??' && expr.right.kind === 'object-literal' && expr.right.properties.length === 0) {
2424
+ return { supported: true, level: 'L4' }
2425
+ }
2426
+
2332
2427
  const rightSupport = checkSupport(expr.right)
2333
2428
  if (!rightSupport.supported) return rightSupport
2334
2429
 
@@ -2388,7 +2483,11 @@ export function containsHigherOrder(expr: ParsedExpr): boolean {
2388
2483
  case 'array-literal':
2389
2484
  return expr.elements.some(containsHigherOrder)
2390
2485
  case 'array-method':
2391
- return containsHigherOrder(expr.object) || expr.args.some(containsHigherOrder)
2486
+ return (
2487
+ containsHigherOrder(expr.object) ||
2488
+ expr.args.some(containsHigherOrder) ||
2489
+ (expr.method === 'flat' && expr.depthExpr !== undefined && containsHigherOrder(expr.depthExpr))
2490
+ )
2392
2491
  default:
2393
2492
  return false
2394
2493
  }
@@ -2741,7 +2840,10 @@ function usesPerPath(name: string, expr: ParsedExpr): { min: number; max: number
2741
2840
  case 'array-literal':
2742
2841
  return sum(e.elements)
2743
2842
  case 'array-method':
2744
- return add(walk(e.object), e.method === 'flat' ? { min: 0, max: 0 } : sum(e.args))
2843
+ if (e.method === 'flat') {
2844
+ return add(walk(e.object), e.depthExpr ? walk(e.depthExpr) : { min: 0, max: 0 })
2845
+ }
2846
+ return add(walk(e.object), sum(e.args))
2745
2847
  case 'object-literal':
2746
2848
  return sum(e.properties.map(p => p.value))
2747
2849
  case 'arrow':
@@ -2817,7 +2919,14 @@ function inlineBinding(
2817
2919
  return { kind: 'array-literal', elements: e.elements.map(el => walk(el, enclosing)) }
2818
2920
  case 'array-method':
2819
2921
  if (e.method === 'flat') {
2820
- return { kind: 'array-method', method: 'flat', object: walk(e.object, enclosing), args: [], flatDepth: e.flatDepth }
2922
+ return {
2923
+ kind: 'array-method',
2924
+ method: 'flat',
2925
+ object: walk(e.object, enclosing),
2926
+ args: [],
2927
+ flatDepth: e.flatDepth,
2928
+ ...(e.depthExpr ? { depthExpr: walk(e.depthExpr, enclosing) } : {}),
2929
+ }
2821
2930
  }
2822
2931
  return { kind: 'array-method', method: e.method, object: walk(e.object, enclosing), args: e.args.map(a => walk(a, enclosing)) }
2823
2932
  case 'object-literal':
@@ -3033,8 +3142,12 @@ export function exprToString(expr: ParsedExpr): string {
3033
3142
  return `[${expr.elements.map(exprToString).join(', ')}]`
3034
3143
  case 'array-method':
3035
3144
  if (expr.method === 'flat') {
3036
- // Preserve the normalised depth so diagnostics don't misleadingly
3037
- // print `.flat()` for a `.flat(2)` / `.flat(Infinity)` source.
3145
+ // Preserve the normalised / dynamic depth so diagnostics don't
3146
+ // misleadingly print `.flat()` for a `.flat(2)` / `.flat(Infinity)`
3147
+ // / `.flat(depthProp)` source.
3148
+ if (expr.depthExpr) {
3149
+ return `${exprToString(expr.object)}.flat(${exprToString(expr.depthExpr)})`
3150
+ }
3038
3151
  const d = expr.flatDepth
3039
3152
  const depthSrc = d === 'infinity' ? 'Infinity' : String(d)
3040
3153
  return `${exprToString(expr.object)}.flat(${d === 1 ? '' : depthSrc})`
@@ -3106,8 +3219,12 @@ export function stringifyParsedExpr(expr: ParsedExpr): string {
3106
3219
  return `[${expr.elements.map(stringifyParsedExpr).join(', ')}]`
3107
3220
  case 'array-method':
3108
3221
  if (expr.method === 'flat') {
3109
- // Round-trip the normalised depth back to JS for the CSR / Hono
3110
- // path: `'infinity'` → `Infinity`, `1` is left implicit (`.flat()`).
3222
+ // Round-trip the normalised / dynamic depth back to JS for the
3223
+ // CSR / Hono path: `'infinity'` → `Infinity`, `1` is left implicit
3224
+ // (`.flat()`), a dynamic depth round-trips its own expression.
3225
+ if (expr.depthExpr) {
3226
+ return `${stringifyParsedExpr(expr.object)}.flat(${stringifyParsedExpr(expr.depthExpr)})`
3227
+ }
3111
3228
  const d = expr.flatDepth
3112
3229
  const depthSrc = d === 'infinity' ? 'Infinity' : String(d)
3113
3230
  return `${stringifyParsedExpr(expr.object)}.flat(${d === 1 ? '' : depthSrc})`
@@ -3121,6 +3238,85 @@ export function stringifyParsedExpr(expr: ParsedExpr): string {
3121
3238
  }
3122
3239
  }
3123
3240
 
3241
+ /**
3242
+ * Rewrite every zero-arg `call` node whose callee is a bare identifier in
3243
+ * `names` into that identifier — `tag()` → `tag` — leaving everything else
3244
+ * untouched. Returns a new tree; the input is never mutated.
3245
+ *
3246
+ * Rationale: in an SSR seed/constructor context a signal/memo getter call
3247
+ * reads the already-computed SEEDED value, so `tag()` reduces to "the value
3248
+ * bound to `tag`". Materialising the call lets the runtime evaluator (which
3249
+ * refuses any non-builtin call, {@link toEvalNode}'s `evalBuiltinCalleeName`
3250
+ * gate) evaluate a predicate that reads sibling memos — e.g. a `.filter`
3251
+ * predicate `(p) => !tag() || p.tags.includes(tag())` — with the getter's
3252
+ * value supplied through the evaluator's `base_env` instead of an
3253
+ * unsupported call node. `names` is caller-supplied (typically the sibling
3254
+ * signals/memos seeded alongside the derived memo being lowered), so a call
3255
+ * to an unrelated function is left as a `call` node and still refused by the
3256
+ * evaluator's builtin gate if it reaches `serializeParsedExpr`.
3257
+ */
3258
+ export function materializeGetterCalls(expr: ParsedExpr, names: ReadonlySet<string>): ParsedExpr {
3259
+ const rw = (e: ParsedExpr): ParsedExpr => materializeGetterCalls(e, names)
3260
+ switch (expr.kind) {
3261
+ case 'call':
3262
+ if (
3263
+ expr.args.length === 0 &&
3264
+ expr.callee.kind === 'identifier' &&
3265
+ names.has(expr.callee.name)
3266
+ ) {
3267
+ return { kind: 'identifier', name: expr.callee.name }
3268
+ }
3269
+ return { kind: 'call', callee: rw(expr.callee), args: expr.args.map(rw) }
3270
+ case 'binary':
3271
+ return { kind: 'binary', op: expr.op, left: rw(expr.left), right: rw(expr.right) }
3272
+ case 'logical':
3273
+ return { kind: 'logical', op: expr.op, left: rw(expr.left), right: rw(expr.right) }
3274
+ case 'unary':
3275
+ return { kind: 'unary', op: expr.op, argument: rw(expr.argument) }
3276
+ case 'conditional':
3277
+ return {
3278
+ kind: 'conditional',
3279
+ test: rw(expr.test),
3280
+ consequent: rw(expr.consequent),
3281
+ alternate: rw(expr.alternate),
3282
+ }
3283
+ case 'member':
3284
+ return { kind: 'member', object: rw(expr.object), property: expr.property, computed: expr.computed }
3285
+ case 'index-access':
3286
+ return { kind: 'index-access', object: rw(expr.object), index: rw(expr.index) }
3287
+ case 'template-literal':
3288
+ return {
3289
+ kind: 'template-literal',
3290
+ parts: expr.parts.map(p => (p.type === 'string' ? p : { type: 'expression', expr: rw(p.expr) })),
3291
+ }
3292
+ case 'array-literal':
3293
+ return { kind: 'array-literal', elements: expr.elements.map(rw) }
3294
+ case 'array-method':
3295
+ // `flat`'s `args` is always `[]` (the depth is carried structurally in
3296
+ // `flatDepth` / `depthExpr`, not `args`) — still rewrite `object` (and
3297
+ // `depthExpr`, when dynamic), just skip the `args.map` that every
3298
+ // other method needs.
3299
+ if (expr.method === 'flat') {
3300
+ return { ...expr, object: rw(expr.object), ...(expr.depthExpr ? { depthExpr: rw(expr.depthExpr) } : {}) }
3301
+ }
3302
+ return { ...expr, object: rw(expr.object), args: expr.args.map(rw) }
3303
+ case 'object-literal':
3304
+ return {
3305
+ kind: 'object-literal',
3306
+ raw: expr.raw,
3307
+ properties: expr.properties.map(p => ({ ...p, value: rw(p.value) })),
3308
+ }
3309
+ case 'arrow':
3310
+ return { kind: 'arrow', params: expr.params, body: rw(expr.body) }
3311
+ // Leaves / opaque shapes — nothing to rewrite.
3312
+ case 'identifier':
3313
+ case 'literal':
3314
+ case 'regex':
3315
+ case 'unsupported':
3316
+ return expr
3317
+ }
3318
+ }
3319
+
3124
3320
  /**
3125
3321
  * Serialize a pure-expression `ParsedExpr` (a higher-order callback body) into
3126
3322
  * the minimal JSON the runtime evaluator consumes — the format pinned by the
@@ -3155,30 +3351,36 @@ export function serializeParsedExpr(expr: ParsedExpr): string | null {
3155
3351
  */
3156
3352
  export function freeVarsInBody(body: ParsedExpr, params: ReadonlySet<string>): string[] {
3157
3353
  const found = new Set<string>()
3158
- const visit = (e: ParsedExpr): void => {
3354
+ // `bound` starts as the outer callback's own `params` and grows when the
3355
+ // walk descends into a nested `map`/`filter` callback arrow (#2094) — a
3356
+ // NESTED arrow's own params shadow the outer ones for its body only,
3357
+ // mirroring {@link freeIdentifiers}'s lexical scoping. Before #2094 a
3358
+ // serializable body never contained a nested arrow, so this parameter
3359
+ // didn't need to change per call; it does now.
3360
+ const visit = (e: ParsedExpr, bound: ReadonlySet<string>): void => {
3159
3361
  switch (e.kind) {
3160
3362
  case 'identifier':
3161
- if (!params.has(e.name)) found.add(e.name)
3363
+ if (!bound.has(e.name)) found.add(e.name)
3162
3364
  return
3163
3365
  case 'binary':
3164
3366
  case 'logical':
3165
- visit(e.left)
3166
- visit(e.right)
3367
+ visit(e.left, bound)
3368
+ visit(e.right, bound)
3167
3369
  return
3168
3370
  case 'unary':
3169
- visit(e.argument)
3371
+ visit(e.argument, bound)
3170
3372
  return
3171
3373
  case 'conditional':
3172
- visit(e.test)
3173
- visit(e.consequent)
3174
- visit(e.alternate)
3374
+ visit(e.test, bound)
3375
+ visit(e.consequent, bound)
3376
+ visit(e.alternate, bound)
3175
3377
  return
3176
3378
  case 'member':
3177
- visit(e.object)
3379
+ visit(e.object, bound)
3178
3380
  return
3179
3381
  case 'index-access':
3180
- visit(e.object)
3181
- visit(e.index)
3382
+ visit(e.object, bound)
3383
+ visit(e.index, bound)
3182
3384
  return
3183
3385
  case 'call':
3184
3386
  // A builtin callee (`String`/`Number`/`Boolean`, or `Math.<fn>`) is
@@ -3186,35 +3388,137 @@ export function freeVarsInBody(body: ParsedExpr, params: ReadonlySet<string>): s
3186
3388
  // captured free var. Visiting it would add `Math` / `String` to the
3187
3389
  // env, making the adapter emit an undefined `$Math` / `.Math` base_env
3188
3390
  // 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)
3391
+ // case; the arguments are still real references and are visited
3392
+ // (a nested `.map(cb)` / `.filter(cb)` callback call's `args[0]` is
3393
+ // the callback `arrow`, visited below with its own params bound).
3394
+ if (evalBuiltinCalleeName(e.callee) === null) visit(e.callee, bound)
3395
+ e.args.forEach((a) => visit(a, bound))
3192
3396
  return
3193
3397
  case 'template-literal':
3194
- for (const p of e.parts) if (p.type === 'expression') visit(p.expr)
3398
+ for (const p of e.parts) if (p.type === 'expression') visit(p.expr, bound)
3195
3399
  return
3196
3400
  case 'array-literal':
3197
- e.elements.forEach(visit)
3401
+ e.elements.forEach((el) => visit(el, bound))
3198
3402
  return
3199
3403
  case 'object-literal':
3200
3404
  // Object *values* are references; keys are not. (Shorthand `{ x }`
3201
3405
  // carries the ref on its `value` identifier, which is visited here.)
3202
- for (const p of e.properties) visit(p.value)
3406
+ for (const p of e.properties) visit(p.value, bound)
3407
+ return
3408
+ case 'array-method':
3409
+ // `.includes(x)` / `.join(sep?)` are serializable ({@link
3410
+ // toEvalNode}); their `object` (the receiver) and `args` (needle /
3411
+ // separator) are the value positions serialized, so visit both when
3412
+ // the tree reaches here with either method. A nested `.map`/`.filter`
3413
+ // reaches this walk as a `call` (above), not an `array-method` — see
3414
+ // {@link asCallbackMethodCall}. Every other `array-method` is
3415
+ // non-serializable and doesn't occur in a serializable body.
3416
+ if (e.method === 'includes' || e.method === 'join') {
3417
+ visit(e.object, bound)
3418
+ e.args.forEach((a) => visit(a, bound))
3419
+ }
3203
3420
  return
3421
+ // A nested callback arrow (the `.map`/`.filter` callback argument,
3422
+ // #2094): its own params shadow the outer bound set for its body only.
3423
+ case 'arrow': {
3424
+ const inner = e.params.length === 0 ? bound : new Set([...bound, ...e.params])
3425
+ visit(e.body, inner)
3426
+ return
3427
+ }
3204
3428
  // Non-serializable kinds don't occur in a serializable body
3205
3429
  // (serializeParsedExpr returns null for them); nothing to collect.
3206
3430
  case 'literal':
3207
- case 'array-method':
3208
- case 'arrow':
3209
3431
  case 'regex':
3210
3432
  case 'unsupported':
3211
3433
  return
3212
3434
  }
3213
3435
  }
3214
- visit(body)
3436
+ visit(body, params)
3215
3437
  return [...found].sort()
3216
3438
  }
3217
3439
 
3440
+ /**
3441
+ * Every value-position identifier in `expr` NOT bound by an enclosing arrow's
3442
+ * own parameters — with proper lexical scoping: an arrow's params bind only
3443
+ * within that arrow's body, and nested arrows accumulate onto the enclosing
3444
+ * bound set. Unlike {@link freeVarsInBody} (which assumes a single flat param
3445
+ * set and never recurses into nested `arrow` nodes, since a serializable
3446
+ * evaluator body never contains one), this walks the full source-level tree —
3447
+ * including arrows — so a caller can ask "is this name free ANYWHERE in the
3448
+ * expression, honoring each arrow's own scope" rather than only within one
3449
+ * callback body.
3450
+ *
3451
+ * Walks the same value positions as {@link serializeParsedExpr} /
3452
+ * {@link freeVarsInBody}: call callee (skipped when it resolves to an
3453
+ * evaluator builtin — see {@link evalBuiltinCalleeName} — so `Math.floor(x)`
3454
+ * doesn't report `Math` as free) + args, binary/logical/unary operands,
3455
+ * conditional branches, a member's OBJECT only (the property name is not a
3456
+ * reference), an index-access's object + index, template-literal expression
3457
+ * parts, array-literal elements, array-method object + args, and an
3458
+ * object-literal's property VALUES (not keys). An `arrow` recurses into its
3459
+ * body with its own params added to the bound set.
3460
+ *
3461
+ * Returns `null` when the tree contains an `unsupported` node (or any other
3462
+ * shape this walk can't analyze) — the caller must fail safe rather than
3463
+ * assume nothing is free.
3464
+ */
3465
+ export function freeIdentifiers(expr: ParsedExpr): Set<string> | null {
3466
+ const free = new Set<string>()
3467
+
3468
+ function visit(e: ParsedExpr, bound: ReadonlySet<string>): boolean {
3469
+ switch (e.kind) {
3470
+ case 'literal':
3471
+ case 'regex':
3472
+ return true
3473
+ case 'identifier':
3474
+ if (!bound.has(e.name)) free.add(e.name)
3475
+ return true
3476
+ case 'call': {
3477
+ const isBuiltinCallee = evalBuiltinCalleeName(e.callee) !== null
3478
+ if (!isBuiltinCallee && !visit(e.callee, bound)) return false
3479
+ for (const a of e.args) if (!visit(a, bound)) return false
3480
+ return true
3481
+ }
3482
+ case 'member':
3483
+ return visit(e.object, bound)
3484
+ case 'index-access':
3485
+ return visit(e.object, bound) && visit(e.index, bound)
3486
+ case 'binary':
3487
+ case 'logical':
3488
+ return visit(e.left, bound) && visit(e.right, bound)
3489
+ case 'unary':
3490
+ return visit(e.argument, bound)
3491
+ case 'conditional':
3492
+ return visit(e.test, bound) && visit(e.consequent, bound) && visit(e.alternate, bound)
3493
+ case 'template-literal':
3494
+ for (const p of e.parts) {
3495
+ if (p.type === 'expression' && !visit(p.expr, bound)) return false
3496
+ }
3497
+ return true
3498
+ case 'array-literal':
3499
+ for (const el of e.elements) if (!visit(el, bound)) return false
3500
+ return true
3501
+ case 'array-method':
3502
+ if (!visit(e.object, bound)) return false
3503
+ for (const a of e.args) if (!visit(a, bound)) return false
3504
+ if (e.method === 'flat' && e.depthExpr && !visit(e.depthExpr, bound)) return false
3505
+ return true
3506
+ case 'object-literal':
3507
+ for (const p of e.properties) if (!visit(p.value, bound)) return false
3508
+ return true
3509
+ case 'arrow': {
3510
+ const inner = new Set(bound)
3511
+ for (const p of e.params) inner.add(p)
3512
+ return visit(e.body, inner)
3513
+ }
3514
+ case 'unsupported':
3515
+ return false
3516
+ }
3517
+ }
3518
+
3519
+ return visit(expr, new Set()) ? free : null
3520
+ }
3521
+
3218
3522
  // Operators the evaluator implements (Go `eval.go` evalBinary / evalUnary, Perl
3219
3523
  // `Evaluator.pm` _binary / _unary). An op outside these sets — loose `==`,
3220
3524
  // `instanceof`, `**`, bitwise/shift, or the parser's `'unknown'` sentinel — is
@@ -3302,6 +3606,44 @@ function toEvalNode(e: ParsedExpr): Record<string, unknown> | null {
3302
3606
  return object && index ? { kind: 'index-access', object, index } : null
3303
3607
  }
3304
3608
  case 'call': {
3609
+ // A nested `.map(cb)` / `.filter(cb)` callback call (#2094) — the
3610
+ // SAME recognition `callbackMethod()` dispatch uses. Only these two
3611
+ // widen into the evaluator's surface: they are order-preserving,
3612
+ // per-element, and produce a bounded result, so the pure gate can
3613
+ // still reason about them structurally. `sort` / `reduce` /
3614
+ // `reduceRight` / `flat` / `flatMap` / `every` / `some` / `find*`
3615
+ // nested INSIDE an eval body stay refused — `asCallbackMethodCall`
3616
+ // recognizes them too, but they fall through to the generic
3617
+ // non-builtin-callee refusal below because `cb.method` doesn't match
3618
+ // either case here.
3619
+ const cb = asCallbackMethodCall(e)
3620
+ if (cb && (cb.method === 'map' || cb.method === 'filter')) {
3621
+ const object = toEvalNode(cb.object)
3622
+ if (!object) return null
3623
+ const body = toEvalNode(cb.arrow.body)
3624
+ if (!body) return null
3625
+ // `cb.arrow.params` is always plain identifier names (the `ParsedExpr`
3626
+ // `arrow` variant only carries destructure-rewritten / plain params —
3627
+ // an array-binding-pattern param stays `unsupported` upstream and
3628
+ // never reaches here). 1- and 2-param arrows are both supported for
3629
+ // `map`/`filter` (element, and index when declared); the Go/eval-cases
3630
+ // reference pins the 2-param contract.
3631
+ //
3632
+ // Re-serialized in the SAME `call` / `member` / `arrow` shape
3633
+ // `asCallbackMethodCall` recognizes — NOT a bespoke wrapper — because
3634
+ // the `eval-vectors.json` golden corpus carries the genuine
3635
+ // `ParsedExpr` `parseExpression` produces (unfiltered by
3636
+ // `toEvalNode`; see `eval-generate.ts`), which is exactly this
3637
+ // shape. Using the same shape here means the compiled-template
3638
+ // embedded body and the golden-vector corpus reach the evaluator in
3639
+ // one encoding, so a single `EvalNode`/`evaluate` recognition path
3640
+ // (Go `eval.go`, `eval-reference.ts`) covers both.
3641
+ return {
3642
+ kind: 'call',
3643
+ callee: { kind: 'member', object, property: cb.method, computed: false },
3644
+ args: [{ kind: 'arrow', params: cb.arrow.params, body }],
3645
+ }
3646
+ }
3305
3647
  // The evaluator executes only the builtin allowlist; a non-builtin callee
3306
3648
  // would evaluate to nil at runtime, so refuse it here (the purity gate).
3307
3649
  if (evalBuiltinCalleeName(e.callee) === null) return null
@@ -3346,10 +3688,39 @@ function toEvalNode(e: ParsedExpr): Record<string, unknown> | null {
3346
3688
  }
3347
3689
  return { kind: 'object-literal', properties }
3348
3690
  }
3691
+ case 'array-method': {
3692
+ // `.includes(x)` is one `array-method` the evaluator executes
3693
+ // (Go `eval.go` / Perl `Evaluator.pm`, includes support): the
3694
+ // receiver-type dispatch (array SameValueZero membership vs string
3695
+ // substring) happens at evaluator runtime, same as the SSR template
3696
+ // lowering's `bf_includes` / `$bf->includes`.
3697
+ if (e.method === 'includes' && e.args.length === 1) {
3698
+ const object = toEvalNode(e.object)
3699
+ const arg = toEvalNode(e.args[0])
3700
+ return object && arg ? { kind: 'array-method', method: 'includes', object, args: [arg] } : null
3701
+ }
3702
+ // `.join(sep?)` (#2094) — 0 or 1 serializable separator arg. Lets a
3703
+ // nested `.map`/`.filter` chain feed straight into `.join(' ')` inside
3704
+ // an eval body (the #1938 blog-showcase shape:
3705
+ // `p.tags.map(t => '#' + t)` is joined by the OUTER `.join`, which is
3706
+ // already the caller-visible template expression, not inside the eval
3707
+ // body — this arm is for a `.join` that itself appears NESTED inside
3708
+ // an eval body, e.g. a `.map(p => p.tags.join(','))` projection).
3709
+ if (e.method === 'join' && e.args.length <= 1) {
3710
+ const object = toEvalNode(e.object)
3711
+ if (!object) return null
3712
+ if (e.args.length === 0) return { kind: 'array-method', method: 'join', object, args: [] }
3713
+ const sep = toEvalNode(e.args[0])
3714
+ return sep ? { kind: 'array-method', method: 'join', object, args: [sep] } : null
3715
+ }
3716
+ // Every other `array-method` (`slice`, `flat`, `flatMap` [handled as
3717
+ // its own `call` shape when it has an arrow, so not reached here],
3718
+ // …) is outside the evaluator's surface and refuses.
3719
+ return null
3720
+ }
3349
3721
  // Outside the evaluator's pure-expression surface — refuse so the caller
3350
3722
  // falls back to BF101 / `@client`. A nested `arrow` (a callback inside the
3351
3723
  // body) is refused here, keeping the evaluator non-recursive.
3352
- case 'array-method':
3353
3724
  case 'arrow':
3354
3725
  case 'regex':
3355
3726
  case 'unsupported':