@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
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect } from 'bun:test'
2
2
  import ts from 'typescript'
3
- import { parseExpression, isSupported, exprToString, stringifyParsedExpr, parseBlockBody, extractArrowBodyExpression, parseStyleObjectEntries, parseProviderObjectLiteral } from '../expression-parser'
3
+ import { parseExpression, isSupported, exprToString, stringifyParsedExpr, parseBlockBody, foldBlockToExpr, extractArrowBodyExpression, parseStyleObjectEntries, parseProviderObjectLiteral, asCallbackMethodCall, containsHigherOrder } from '../expression-parser'
4
4
  import { collectAllTypeRanges, reconstructWithoutTypes } from '../strip-types'
5
5
 
6
6
  describe('expression-parser', () => {
@@ -175,58 +175,53 @@ describe('expression-parser', () => {
175
175
 
176
176
  test('parses arrow function with single param and expression body', () => {
177
177
  const result = parseExpression('x => x + 1')
178
- expect(result.kind).toBe('arrow-fn')
179
- if (result.kind === 'arrow-fn') {
180
- expect(result.param).toBe('x')
178
+ expect(result.kind).toBe('arrow')
179
+ if (result.kind === 'arrow') {
180
+ expect(result.params).toEqual(['x'])
181
181
  expect(result.body.kind).toBe('binary')
182
182
  }
183
183
  })
184
184
 
185
185
  test('parses filter() call into higher-order kind', () => {
186
186
  const result = parseExpression('todos().filter(t => !t.done)')
187
- expect(result.kind).toBe('higher-order')
188
- if (result.kind === 'higher-order') {
189
- expect(result.method).toBe('filter')
190
- expect(result.param).toBe('t')
191
- expect(result.predicate.kind).toBe('unary')
192
- }
187
+ const cb = asCallbackMethodCall(result)
188
+ expect(cb).not.toBeNull()
189
+ expect(cb!.method).toBe('filter')
190
+ expect(cb!.arrow.params[0]).toBe('t')
191
+ expect(cb!.arrow.body.kind).toBe('unary')
193
192
  })
194
193
 
195
194
  test('parses every() call into higher-order kind', () => {
196
195
  const result = parseExpression('todos().every(t => t.done)')
197
- expect(result.kind).toBe('higher-order')
198
- if (result.kind === 'higher-order') {
199
- expect(result.method).toBe('every')
200
- expect(result.param).toBe('t')
201
- }
196
+ const cb = asCallbackMethodCall(result)
197
+ expect(cb).not.toBeNull()
198
+ expect(cb!.method).toBe('every')
199
+ expect(cb!.arrow.params[0]).toBe('t')
202
200
  })
203
201
 
204
202
  test('parses some() call into higher-order kind', () => {
205
203
  const result = parseExpression('todos().some(t => t.important)')
206
- expect(result.kind).toBe('higher-order')
207
- if (result.kind === 'higher-order') {
208
- expect(result.method).toBe('some')
209
- expect(result.param).toBe('t')
210
- }
204
+ const cb = asCallbackMethodCall(result)
205
+ expect(cb).not.toBeNull()
206
+ expect(cb!.method).toBe('some')
207
+ expect(cb!.arrow.params[0]).toBe('t')
211
208
  })
212
209
 
213
210
  test('parses find() call into higher-order kind', () => {
214
211
  const result = parseExpression('users().find(u => u.id === selectedId())')
215
- expect(result.kind).toBe('higher-order')
216
- if (result.kind === 'higher-order') {
217
- expect(result.method).toBe('find')
218
- expect(result.param).toBe('u')
219
- expect(result.predicate.kind).toBe('binary')
220
- }
212
+ const cb = asCallbackMethodCall(result)
213
+ expect(cb).not.toBeNull()
214
+ expect(cb!.method).toBe('find')
215
+ expect(cb!.arrow.params[0]).toBe('u')
216
+ expect(cb!.arrow.body.kind).toBe('binary')
221
217
  })
222
218
 
223
219
  test('parses findIndex() call into higher-order kind', () => {
224
220
  const result = parseExpression('items().findIndex(t => t.done)')
225
- expect(result.kind).toBe('higher-order')
226
- if (result.kind === 'higher-order') {
227
- expect(result.method).toBe('findIndex')
228
- expect(result.param).toBe('t')
229
- }
221
+ const cb = asCallbackMethodCall(result)
222
+ expect(cb).not.toBeNull()
223
+ expect(cb!.method).toBe('findIndex')
224
+ expect(cb!.arrow.params[0]).toBe('t')
230
225
  })
231
226
 
232
227
  test('parses find().property into member kind with higher-order object', () => {
@@ -234,10 +229,9 @@ describe('expression-parser', () => {
234
229
  expect(result.kind).toBe('member')
235
230
  if (result.kind === 'member') {
236
231
  expect(result.property).toBe('name')
237
- expect(result.object.kind).toBe('higher-order')
238
- if (result.object.kind === 'higher-order') {
239
- expect(result.object.method).toBe('find')
240
- }
232
+ const cb = asCallbackMethodCall(result.object)
233
+ expect(cb).not.toBeNull()
234
+ expect(cb!.method).toBe('find')
241
235
  }
242
236
  })
243
237
 
@@ -246,11 +240,10 @@ describe('expression-parser', () => {
246
240
  expect(result.kind).toBe('member')
247
241
  if (result.kind === 'member') {
248
242
  expect(result.property).toBe('length')
249
- expect(result.object.kind).toBe('higher-order')
250
- if (result.object.kind === 'higher-order') {
251
- expect(result.object.method).toBe('filter')
252
- expect(result.object.param).toBe('t')
253
- }
243
+ const cb = asCallbackMethodCall(result.object)
244
+ expect(cb).not.toBeNull()
245
+ expect(cb!.method).toBe('filter')
246
+ expect(cb!.arrow.params[0]).toBe('t')
254
247
  }
255
248
  })
256
249
 
@@ -261,28 +254,28 @@ describe('expression-parser', () => {
261
254
  // existing higher-order paths.
262
255
  test('parses .filter(Boolean) into higher-order kind with synthetic identity predicate (#1443)', () => {
263
256
  const result = parseExpression('arr.filter(Boolean)')
264
- expect(result.kind).toBe('higher-order')
265
- if (result.kind === 'higher-order') {
266
- expect(result.method).toBe('filter')
267
- // The synthetic param is just an identifier-matching marker;
268
- // adapters substitute it into their loop variable. Whichever
269
- // name we pick must equal `predicate.name` so the substitution
270
- // round-trips into a truthy check.
271
- expect(result.predicate.kind).toBe('identifier')
272
- if (result.predicate.kind === 'identifier') {
273
- expect(result.predicate.name).toBe(result.param)
274
- }
257
+ const cb = asCallbackMethodCall(result)
258
+ expect(cb).not.toBeNull()
259
+ expect(cb!.method).toBe('filter')
260
+ // The synthetic param is just an identifier-matching marker;
261
+ // adapters substitute it into their loop variable. Whichever
262
+ // name we pick must equal the body identifier so the substitution
263
+ // round-trips into a truthy check.
264
+ expect(cb!.arrow.body.kind).toBe('identifier')
265
+ if (cb!.arrow.body.kind === 'identifier') {
266
+ expect(cb!.arrow.body.name).toBe(cb!.arrow.params[0])
275
267
  }
276
268
  })
277
269
 
278
270
  // The Boolean-callable shortcut is filter-specific because the
279
271
  // truthy-identity rewrite only matches `filter`'s semantics. For
280
272
  // `.every(Boolean)` / `.some(Boolean)` etc. the rewrite would
281
- // produce different JS semantics — leave them on the unsupported
282
- // path until each gets its own deliberate lowering.
273
+ // produce different JS semantics — leave them un-synthesised
274
+ // (the bare `Boolean` callable does not become a callback arrow)
275
+ // until each gets its own deliberate lowering.
283
276
  test('does NOT lower .every(Boolean) or .some(Boolean) — filter-specific shortcut (#1443)', () => {
284
- expect(parseExpression('arr.every(Boolean)').kind).not.toBe('higher-order')
285
- expect(parseExpression('arr.some(Boolean)').kind).not.toBe('higher-order')
277
+ expect(asCallbackMethodCall(parseExpression('arr.every(Boolean)'))).toBeNull()
278
+ expect(asCallbackMethodCall(parseExpression('arr.some(Boolean)'))).toBeNull()
286
279
  })
287
280
 
288
281
  // Array literals show up in the registry Slot's
@@ -300,6 +293,86 @@ describe('expression-parser', () => {
300
293
  }
301
294
  })
302
295
 
296
+ // Object literal → `object-literal` kind (Roadmap A-1). Carried so
297
+ // adapters can lower an object value structurally instead of
298
+ // re-parsing the source with `ts.createSourceFile`. Object literals
299
+ // reach the parser parenthesised — `() => ({ … })`, a prop value —
300
+ // because a leading `{` at statement position is a block, not an
301
+ // expression (covered below).
302
+ test('parses object literal into object-literal kind', () => {
303
+ const result = parseExpression('({ a: 1, b: x, "c-d": foo() })')
304
+ expect(result.kind).toBe('object-literal')
305
+ if (result.kind === 'object-literal') {
306
+ expect(result.properties.map(p => p.key)).toEqual(['a', 'b', 'c-d'])
307
+ expect(result.properties.map(p => p.value.kind)).toEqual([
308
+ 'literal', 'identifier', 'call',
309
+ ])
310
+ expect(result.properties.every(p => !p.shorthand)).toBe(true)
311
+ // `raw` preserves the original string for byte-identical fallback.
312
+ expect(result.raw).toBe('({ a: 1, b: x, "c-d": foo() })')
313
+ }
314
+ })
315
+
316
+ test('records the key kind so numeric and string keys are distinguishable', () => {
317
+ const result = parseExpression('({ a: 1, "1": 2, 3: 4 })')
318
+ expect(result.kind).toBe('object-literal')
319
+ if (result.kind === 'object-literal') {
320
+ // `key` normalises all three to a string ('a' / '1' / '3'), but
321
+ // `keyKind` keeps `{ '1': … }` (string) distinct from `{ 3: … }`
322
+ // (numeric) — a consumer that rejects numeric keys needs this.
323
+ expect(result.properties.map(p => p.key)).toEqual(['a', '1', '3'])
324
+ expect(result.properties.map(p => p.keyKind)).toEqual(['identifier', 'string', 'numeric'])
325
+ }
326
+ })
327
+
328
+ test('parses shorthand object literal, expanding `{ a }` to `a: a`', () => {
329
+ const result = parseExpression('({ a, b: 2 })')
330
+ expect(result.kind).toBe('object-literal')
331
+ if (result.kind === 'object-literal') {
332
+ expect(result.properties[0]).toMatchObject({
333
+ key: 'a', shorthand: true, value: { kind: 'identifier', name: 'a' },
334
+ })
335
+ expect(result.properties[1]).toMatchObject({ key: 'b', shorthand: false })
336
+ }
337
+ })
338
+
339
+ test('carries the raw numeric token (= ts NumericLiteral.text) (Roadmap A-3)', () => {
340
+ // `raw` is the TS `NumericLiteral.text` — the exact token the adapter's
341
+ // `tsLiteralToGo` already emits — so a structured lowering matches it
342
+ // byte-for-byte. TS normalises separators / radix / exponent in `.text`
343
+ // (`1_000`/`0x10`/`1e3` → decimal), which is precisely why carrying the
344
+ // token beats the lossy `parseFloat` `value` (e.g. `parseFloat('1_000')`
345
+ // is 1, not 1000).
346
+ for (const [src, value, raw] of [
347
+ ['1', 1, '1'],
348
+ ['3.14', 3.14, '3.14'],
349
+ ['0x10', 16, '16'],
350
+ ['1e3', 1000, '1000'],
351
+ ['1_000', 1000, '1000'],
352
+ ] as const) {
353
+ const r = parseExpression(src)
354
+ expect(r).toMatchObject({ kind: 'literal', literalType: 'number', value, raw })
355
+ }
356
+ // Non-numeric literals don't carry `raw` (their `value` is canonical).
357
+ expect((parseExpression("'x'") as { raw?: string }).raw).toBeUndefined()
358
+ expect((parseExpression('true') as { raw?: string }).raw).toBeUndefined()
359
+ })
360
+
361
+ test('falls through to unsupported for spread / computed-key object literals', () => {
362
+ // A spread member is not a plain map property — preserves the
363
+ // pre-A-1 `unsupported` behaviour (byte-identical).
364
+ expect(parseExpression('({ ...rest, a: 1 })').kind).toBe('unsupported')
365
+ // Computed key `[k]: v` can't resolve to a static name.
366
+ expect(parseExpression('({ [k]: 1 })').kind).toBe('unsupported')
367
+ })
368
+
369
+ test('a bare `{ … }` at statement position is a block, not an object literal', () => {
370
+ // TS parses a leading `{` as a block statement, so a non-parenthesised
371
+ // object literal string is `unsupported` (`Not an expression
372
+ // statement`). Real call sites always supply the parenthesised form.
373
+ expect(parseExpression('{ a: 1 }').kind).toBe('unsupported')
374
+ })
375
+
303
376
  // Destructured filter param (#1443). The parser rewrites the
304
377
  // shorthand binding `({done})` into the equivalent dotted-access
305
378
  // form on a synthetic param (`_t.done`), so adapters can reuse
@@ -307,20 +380,21 @@ describe('expression-parser', () => {
307
380
  // residual-object-accessor pipeline (#1384 territory).
308
381
  test('lowers .filter(({done}) => done) to higher-order with synthetic param (#1443)', () => {
309
382
  const result = parseExpression('todos().filter(({done}) => done)')
310
- expect(result.kind).toBe('higher-order')
311
- if (result.kind === 'higher-order') {
312
- expect(result.method).toBe('filter')
313
- // Synthetic param won't collide with `done` (the destructured
314
- // local) or any name in the body.
315
- expect(result.param).not.toBe('done')
316
- // Predicate is rewritten to `<synthetic>.done`
317
- expect(result.predicate.kind).toBe('member')
318
- if (result.predicate.kind === 'member') {
319
- expect(result.predicate.property).toBe('done')
320
- expect(result.predicate.object.kind).toBe('identifier')
321
- if (result.predicate.object.kind === 'identifier') {
322
- expect(result.predicate.object.name).toBe(result.param)
323
- }
383
+ const cb = asCallbackMethodCall(result)
384
+ expect(cb).not.toBeNull()
385
+ expect(cb!.method).toBe('filter')
386
+ const param = cb!.arrow.params[0]
387
+ const predicate = cb!.arrow.body
388
+ // Synthetic param won't collide with `done` (the destructured
389
+ // local) or any name in the body.
390
+ expect(param).not.toBe('done')
391
+ // Predicate is rewritten to `<synthetic>.done`
392
+ expect(predicate.kind).toBe('member')
393
+ if (predicate.kind === 'member') {
394
+ expect(predicate.property).toBe('done')
395
+ expect(predicate.object.kind).toBe('identifier')
396
+ if (predicate.object.kind === 'identifier') {
397
+ expect(predicate.object.name).toBe(param)
324
398
  }
325
399
  }
326
400
  })
@@ -331,12 +405,12 @@ describe('expression-parser', () => {
331
405
  // original field name).
332
406
  test('lowers .filter(({done: isDone}) => isDone) to higher-order with renamed destructure (#1443)', () => {
333
407
  const result = parseExpression('todos().filter(({done: isDone}) => isDone)')
334
- expect(result.kind).toBe('higher-order')
335
- if (result.kind === 'higher-order') {
336
- expect(result.predicate.kind).toBe('member')
337
- if (result.predicate.kind === 'member') {
338
- expect(result.predicate.property).toBe('done') // original field, not the local rename
339
- }
408
+ const cb = asCallbackMethodCall(result)
409
+ expect(cb).not.toBeNull()
410
+ const predicate = cb!.arrow.body
411
+ expect(predicate.kind).toBe('member')
412
+ if (predicate.kind === 'member') {
413
+ expect(predicate.property).toBe('done') // original field, not the local rename
340
414
  }
341
415
  })
342
416
 
@@ -348,47 +422,48 @@ describe('expression-parser', () => {
348
422
  // (covered separately).
349
423
  test('lowers .filter(({done = false}) => done) to higher-order with `??` default (#1531)', () => {
350
424
  const result = parseExpression('todos().filter(({done = false}) => done)')
351
- expect(result.kind).toBe('higher-order')
352
- if (result.kind === 'higher-order') {
353
- expect(result.method).toBe('filter')
354
- expect(result.param).not.toBe('done')
355
- // Predicate is `<synthetic>.done ?? false`.
356
- expect(result.predicate.kind).toBe('logical')
357
- if (result.predicate.kind === 'logical') {
358
- expect(result.predicate.op).toBe('??')
359
- expect(result.predicate.left.kind).toBe('member')
360
- if (result.predicate.left.kind === 'member') {
361
- expect(result.predicate.left.property).toBe('done')
362
- expect(result.predicate.left.object.kind).toBe('identifier')
363
- if (result.predicate.left.object.kind === 'identifier') {
364
- expect(result.predicate.left.object.name).toBe(result.param)
365
- }
366
- }
367
- expect(result.predicate.right.kind).toBe('literal')
368
- if (result.predicate.right.kind === 'literal') {
369
- expect(result.predicate.right.value).toBe(false)
370
- expect(result.predicate.right.literalType).toBe('boolean')
425
+ const cb = asCallbackMethodCall(result)
426
+ expect(cb).not.toBeNull()
427
+ const param = cb!.arrow.params[0]
428
+ const predicate = cb!.arrow.body
429
+ expect(cb!.method).toBe('filter')
430
+ expect(param).not.toBe('done')
431
+ // Predicate is `<synthetic>.done ?? false`.
432
+ expect(predicate.kind).toBe('logical')
433
+ if (predicate.kind === 'logical') {
434
+ expect(predicate.op).toBe('??')
435
+ expect(predicate.left.kind).toBe('member')
436
+ if (predicate.left.kind === 'member') {
437
+ expect(predicate.left.property).toBe('done')
438
+ expect(predicate.left.object.kind).toBe('identifier')
439
+ if (predicate.left.object.kind === 'identifier') {
440
+ expect(predicate.left.object.name).toBe(param)
371
441
  }
372
442
  }
443
+ expect(predicate.right.kind).toBe('literal')
444
+ if (predicate.right.kind === 'literal') {
445
+ expect(predicate.right.value).toBe(false)
446
+ expect(predicate.right.literalType).toBe('boolean')
447
+ }
373
448
  }
374
449
  })
375
450
 
376
451
  test('lowers .filter(({count = 0}) => count > 5) — numeric default (#1531)', () => {
377
452
  const result = parseExpression('items().filter(({count = 0}) => count > 5)')
378
- expect(result.kind).toBe('higher-order')
379
- if (result.kind === 'higher-order') {
380
- // Predicate: `(_t.count ?? 0) > 5`.
381
- expect(result.predicate.kind).toBe('binary')
382
- if (result.predicate.kind === 'binary') {
383
- expect(result.predicate.op).toBe('>')
384
- expect(result.predicate.left.kind).toBe('logical')
385
- if (result.predicate.left.kind === 'logical') {
386
- expect(result.predicate.left.op).toBe('??')
387
- expect(result.predicate.left.left.kind).toBe('member')
388
- expect(result.predicate.left.right.kind).toBe('literal')
389
- if (result.predicate.left.right.kind === 'literal') {
390
- expect(result.predicate.left.right.value).toBe(0)
391
- }
453
+ const cb = asCallbackMethodCall(result)
454
+ expect(cb).not.toBeNull()
455
+ const predicate = cb!.arrow.body
456
+ // Predicate: `(_t.count ?? 0) > 5`.
457
+ expect(predicate.kind).toBe('binary')
458
+ if (predicate.kind === 'binary') {
459
+ expect(predicate.op).toBe('>')
460
+ expect(predicate.left.kind).toBe('logical')
461
+ if (predicate.left.kind === 'logical') {
462
+ expect(predicate.left.op).toBe('??')
463
+ expect(predicate.left.left.kind).toBe('member')
464
+ expect(predicate.left.right.kind).toBe('literal')
465
+ if (predicate.left.right.kind === 'literal') {
466
+ expect(predicate.left.right.value).toBe(0)
392
467
  }
393
468
  }
394
469
  }
@@ -396,21 +471,21 @@ describe('expression-parser', () => {
396
471
 
397
472
  test("lowers .filter(({name = 'anon'}) => name.startsWith('a')) — string default (#1531)", () => {
398
473
  const result = parseExpression("items().filter(({name = 'anon'}) => name.startsWith('a'))")
399
- expect(result.kind).toBe('higher-order')
400
- if (result.kind === 'higher-order') {
401
- // Predicate: `(_t.name ?? 'anon').startsWith('a')`. Since #1448
402
- // Tier B, `.startsWith` lowers to an `array-method` node (it was
403
- // a generic `call` before), so the receiver is on `.object`.
404
- expect(result.predicate.kind).toBe('array-method')
405
- if (result.predicate.kind === 'array-method') {
406
- expect(result.predicate.method).toBe('startsWith')
407
- expect(result.predicate.object.kind).toBe('logical')
408
- if (result.predicate.object.kind === 'logical') {
409
- expect(result.predicate.object.op).toBe('??')
410
- expect(result.predicate.object.right.kind).toBe('literal')
411
- if (result.predicate.object.right.kind === 'literal') {
412
- expect(result.predicate.object.right.value).toBe('anon')
413
- }
474
+ const cb = asCallbackMethodCall(result)
475
+ expect(cb).not.toBeNull()
476
+ const predicate = cb!.arrow.body
477
+ // Predicate: `(_t.name ?? 'anon').startsWith('a')`. Since #1448
478
+ // Tier B, `.startsWith` lowers to an `array-method` node (it was
479
+ // a generic `call` before), so the receiver is on `.object`.
480
+ expect(predicate.kind).toBe('array-method')
481
+ if (predicate.kind === 'array-method') {
482
+ expect(predicate.method).toBe('startsWith')
483
+ expect(predicate.object.kind).toBe('logical')
484
+ if (predicate.object.kind === 'logical') {
485
+ expect(predicate.object.op).toBe('??')
486
+ expect(predicate.object.right.kind).toBe('literal')
487
+ if (predicate.object.right.kind === 'literal') {
488
+ expect(predicate.object.right.value).toBe('anon')
414
489
  }
415
490
  }
416
491
  }
@@ -467,11 +542,21 @@ describe('expression-parser', () => {
467
542
  }
468
543
  })
469
544
 
470
- test('.replace(/re/, repl) refuses the regex form with the deferred reason (#1448 Tier B)', () => {
545
+ test('.replace(/re/, repl) carries the regex form structurally; isSupported refuses it with the deferred reason (#1448 Tier B, #2039)', () => {
546
+ // The regex form is deferred, but its shape is carried as an
547
+ // `array-method` whose first arg is a `regex` node — so the Go ctor
548
+ // lowering can recover the trailing-slash pattern without re-parsing
549
+ // (#2039). Template use is still refused, via `isSupported`.
471
550
  const result = parseExpression(`name().replace(/a/g, "b")`)
472
- expect(result.kind).toBe('unsupported')
473
- if (result.kind === 'unsupported') {
474
- expect(result.reason).toContain('regex form is deferred')
551
+ expect(result.kind).toBe('array-method')
552
+ if (result.kind === 'array-method') {
553
+ expect(result.method).toBe('replace')
554
+ expect(result.args[0].kind).toBe('regex')
555
+ }
556
+ const support = isSupported(result)
557
+ expect(support.supported).toBe(false)
558
+ if (!support.supported) {
559
+ expect(support.reason).toContain('regex form is deferred')
475
560
  }
476
561
  })
477
562
 
@@ -498,33 +583,31 @@ describe('expression-parser', () => {
498
583
 
499
584
  test('lowers .filter(({label = `untitled-${suffix}`}) => label) — template-literal default (#1531)', () => {
500
585
  const result = parseExpression('items().filter(({label = `untitled-${suffix}`}) => label)')
501
- expect(result.kind).toBe('higher-order')
502
- if (result.kind === 'higher-order') {
503
- // Predicate: `_t.label ?? \`untitled-${suffix}\``.
504
- expect(result.predicate.kind).toBe('logical')
505
- if (result.predicate.kind === 'logical') {
506
- expect(result.predicate.op).toBe('??')
507
- expect(result.predicate.right.kind).toBe('template-literal')
508
- }
586
+ const cb = asCallbackMethodCall(result)
587
+ expect(cb).not.toBeNull()
588
+ const predicate = cb!.arrow.body
589
+ // Predicate: `_t.label ?? \`untitled-${suffix}\``.
590
+ expect(predicate.kind).toBe('logical')
591
+ if (predicate.kind === 'logical') {
592
+ expect(predicate.op).toBe('??')
593
+ expect(predicate.right.kind).toBe('template-literal')
509
594
  }
510
595
  })
511
596
 
512
597
  test('lowers .every(({done = true}) => done) — defaults work on .every (#1531)', () => {
513
598
  const result = parseExpression('items().every(({done = true}) => done)')
514
- expect(result.kind).toBe('higher-order')
515
- if (result.kind === 'higher-order') {
516
- expect(result.method).toBe('every')
517
- expect(result.predicate.kind).toBe('logical')
518
- }
599
+ const cb = asCallbackMethodCall(result)
600
+ expect(cb).not.toBeNull()
601
+ expect(cb!.method).toBe('every')
602
+ expect(cb!.arrow.body.kind).toBe('logical')
519
603
  })
520
604
 
521
605
  test('lowers .find(({active = false}) => active) — defaults work on .find (#1531)', () => {
522
606
  const result = parseExpression('items().find(({active = false}) => active)')
523
- expect(result.kind).toBe('higher-order')
524
- if (result.kind === 'higher-order') {
525
- expect(result.method).toBe('find')
526
- expect(result.predicate.kind).toBe('logical')
527
- }
607
+ const cb = asCallbackMethodCall(result)
608
+ expect(cb).not.toBeNull()
609
+ expect(cb!.method).toBe('find')
610
+ expect(cb!.arrow.body.kind).toBe('logical')
528
611
  })
529
612
 
530
613
  // Semantic note (#1531): JS destructure defaults trigger only on
@@ -537,16 +620,16 @@ describe('expression-parser', () => {
537
620
  // than a silent semantic shift.
538
621
  test('uses `??` (not sentinel undefined check) — pins the null-triggers-default gap (#1531)', () => {
539
622
  const result = parseExpression('items().filter(({done = false}) => done)')
540
- expect(result.kind).toBe('higher-order')
541
- if (result.kind === 'higher-order') {
542
- // `??` => `null` ALSO triggers the default; this is the
543
- // documented gap from JS destructure-default semantics, which
544
- // only trigger on `undefined`. If this assertion fails, the
545
- // rewrite shape changed re-document the semantic difference.
546
- expect(result.predicate.kind).toBe('logical')
547
- if (result.predicate.kind === 'logical') {
548
- expect(result.predicate.op).toBe('??')
549
- }
623
+ const cb = asCallbackMethodCall(result)
624
+ expect(cb).not.toBeNull()
625
+ const predicate = cb!.arrow.body
626
+ // `??` => `null` ALSO triggers the default; this is the
627
+ // documented gap from JS destructure-default semantics, which
628
+ // only trigger on `undefined`. If this assertion fails, the
629
+ // rewrite shape changed — re-document the semantic difference.
630
+ expect(predicate.kind).toBe('logical')
631
+ if (predicate.kind === 'logical') {
632
+ expect(predicate.op).toBe('??')
550
633
  }
551
634
  })
552
635
 
@@ -556,15 +639,15 @@ describe('expression-parser', () => {
556
639
  // `(_t.done ?? false)`.
557
640
  test('lowers .filter(({done: isDone = false}) => isDone) — renamed + default (#1531)', () => {
558
641
  const result = parseExpression('todos().filter(({done: isDone = false}) => isDone)')
559
- expect(result.kind).toBe('higher-order')
560
- if (result.kind === 'higher-order') {
561
- expect(result.predicate.kind).toBe('logical')
562
- if (result.predicate.kind === 'logical') {
563
- expect(result.predicate.op).toBe('??')
564
- expect(result.predicate.left.kind).toBe('member')
565
- if (result.predicate.left.kind === 'member') {
566
- expect(result.predicate.left.property).toBe('done') // field, not the rename
567
- }
642
+ const cb = asCallbackMethodCall(result)
643
+ expect(cb).not.toBeNull()
644
+ const predicate = cb!.arrow.body
645
+ expect(predicate.kind).toBe('logical')
646
+ if (predicate.kind === 'logical') {
647
+ expect(predicate.op).toBe('??')
648
+ expect(predicate.left.kind).toBe('member')
649
+ if (predicate.left.kind === 'member') {
650
+ expect(predicate.left.property).toBe('done') // field, not the rename
568
651
  }
569
652
  }
570
653
  })
@@ -575,12 +658,12 @@ describe('expression-parser', () => {
575
658
  // to validate).
576
659
  test('lowers .filter(({a, ...rest}) => a) — unused rest is harmless (#1532)', () => {
577
660
  const result = parseExpression('items().filter(({a, ...rest}) => a)')
578
- expect(result.kind).toBe('higher-order')
579
- if (result.kind === 'higher-order') {
580
- expect(result.predicate.kind).toBe('member')
581
- if (result.predicate.kind === 'member') {
582
- expect(result.predicate.property).toBe('a')
583
- }
661
+ const cb = asCallbackMethodCall(result)
662
+ expect(cb).not.toBeNull()
663
+ const predicate = cb!.arrow.body
664
+ expect(predicate.kind).toBe('member')
665
+ if (predicate.kind === 'member') {
666
+ expect(predicate.property).toBe('a')
584
667
  }
585
668
  })
586
669
 
@@ -590,31 +673,32 @@ describe('expression-parser', () => {
590
673
  // would whenever `priority !== 'done'`.
591
674
  test('lowers .filter(({done, ...rest}) => done && rest.priority > 0) — rest member access (#1532)', () => {
592
675
  const result = parseExpression('items().filter(({done, ...rest}) => done && rest.priority > 0)')
593
- expect(result.kind).toBe('higher-order')
594
- if (result.kind === 'higher-order') {
595
- expect(result.method).toBe('filter')
596
- // Predicate: `_t.done && _t.priority > 0`.
597
- expect(result.predicate.kind).toBe('logical')
598
- if (result.predicate.kind === 'logical') {
599
- expect(result.predicate.op).toBe('&&')
600
- const left = result.predicate.left
601
- expect(left.kind).toBe('member')
602
- if (left.kind === 'member') {
603
- expect(left.property).toBe('done')
604
- if (left.object.kind === 'identifier') {
605
- expect(left.object.name).toBe(result.param)
606
- }
676
+ const cb = asCallbackMethodCall(result)
677
+ expect(cb).not.toBeNull()
678
+ const param = cb!.arrow.params[0]
679
+ const predicate = cb!.arrow.body
680
+ expect(cb!.method).toBe('filter')
681
+ // Predicate: `_t.done && _t.priority > 0`.
682
+ expect(predicate.kind).toBe('logical')
683
+ if (predicate.kind === 'logical') {
684
+ expect(predicate.op).toBe('&&')
685
+ const left = predicate.left
686
+ expect(left.kind).toBe('member')
687
+ if (left.kind === 'member') {
688
+ expect(left.property).toBe('done')
689
+ if (left.object.kind === 'identifier') {
690
+ expect(left.object.name).toBe(param)
607
691
  }
608
- const right = result.predicate.right
609
- expect(right.kind).toBe('binary')
610
- if (right.kind === 'binary') {
611
- const lhs = right.left
612
- expect(lhs.kind).toBe('member')
613
- if (lhs.kind === 'member') {
614
- expect(lhs.property).toBe('priority')
615
- if (lhs.object.kind === 'identifier') {
616
- expect(lhs.object.name).toBe(result.param)
617
- }
692
+ }
693
+ const right = predicate.right
694
+ expect(right.kind).toBe('binary')
695
+ if (right.kind === 'binary') {
696
+ const lhs = right.left
697
+ expect(lhs.kind).toBe('member')
698
+ if (lhs.kind === 'member') {
699
+ expect(lhs.property).toBe('priority')
700
+ if (lhs.object.kind === 'identifier') {
701
+ expect(lhs.object.name).toBe(param)
618
702
  }
619
703
  }
620
704
  }
@@ -623,26 +707,27 @@ describe('expression-parser', () => {
623
707
 
624
708
  test('lowers .filter(({a, ...r}) => r.x && r.y) — multiple rest accesses share the synthetic param (#1532)', () => {
625
709
  const result = parseExpression('items().filter(({a, ...r}) => r.x && r.y)')
626
- expect(result.kind).toBe('higher-order')
627
- if (result.kind === 'higher-order') {
628
- // Predicate: `_t.x && _t.y`.
629
- expect(result.predicate.kind).toBe('logical')
630
- if (result.predicate.kind === 'logical') {
631
- const left = result.predicate.left
632
- expect(left.kind).toBe('member')
633
- if (left.kind === 'member') {
634
- expect(left.property).toBe('x')
635
- if (left.object.kind === 'identifier') {
636
- expect(left.object.name).toBe(result.param)
637
- }
710
+ const cb = asCallbackMethodCall(result)
711
+ expect(cb).not.toBeNull()
712
+ const param = cb!.arrow.params[0]
713
+ const predicate = cb!.arrow.body
714
+ // Predicate: `_t.x && _t.y`.
715
+ expect(predicate.kind).toBe('logical')
716
+ if (predicate.kind === 'logical') {
717
+ const left = predicate.left
718
+ expect(left.kind).toBe('member')
719
+ if (left.kind === 'member') {
720
+ expect(left.property).toBe('x')
721
+ if (left.object.kind === 'identifier') {
722
+ expect(left.object.name).toBe(param)
638
723
  }
639
- const right = result.predicate.right
640
- expect(right.kind).toBe('member')
641
- if (right.kind === 'member') {
642
- expect(right.property).toBe('y')
643
- if (right.object.kind === 'identifier') {
644
- expect(right.object.name).toBe(result.param)
645
- }
724
+ }
725
+ const right = predicate.right
726
+ expect(right.kind).toBe('member')
727
+ if (right.kind === 'member') {
728
+ expect(right.property).toBe('y')
729
+ if (right.object.kind === 'identifier') {
730
+ expect(right.object.name).toBe(param)
646
731
  }
647
732
  }
648
733
  }
@@ -650,13 +735,13 @@ describe('expression-parser', () => {
650
735
 
651
736
  test('lowers .every(({a, ...rest}) => rest.x) — rest rewrite applies across higher-order methods (#1532)', () => {
652
737
  const result = parseExpression('items().every(({a, ...rest}) => rest.x)')
653
- expect(result.kind).toBe('higher-order')
654
- if (result.kind === 'higher-order') {
655
- expect(result.method).toBe('every')
656
- expect(result.predicate.kind).toBe('member')
657
- if (result.predicate.kind === 'member') {
658
- expect(result.predicate.property).toBe('x')
659
- }
738
+ const cb = asCallbackMethodCall(result)
739
+ expect(cb).not.toBeNull()
740
+ const predicate = cb!.arrow.body
741
+ expect(cb!.method).toBe('every')
742
+ expect(predicate.kind).toBe('member')
743
+ if (predicate.kind === 'member') {
744
+ expect(predicate.property).toBe('x')
660
745
  }
661
746
  })
662
747
 
@@ -667,8 +752,11 @@ describe('expression-parser', () => {
667
752
  // `_t.done` (which would return the value, masking the mistake).
668
753
  test('rejects .filter(({done, ...rest}) => rest.done) — rest key collides with declared field (#1532)', () => {
669
754
  const result = parseExpression('items().filter(({done, ...rest}) => rest.done)')
670
- // Falls back to plain `call` — adapter surfaces BF021.
755
+ // Falls back to plain `call` — adapter surfaces BF021. The
756
+ // destructure rewrite was refused, so it is NOT a lowerable
757
+ // callback call.
671
758
  expect(result.kind).toBe('call')
759
+ expect(asCallbackMethodCall(result)).toBeNull()
672
760
  })
673
761
 
674
762
  // Mode A typed error via RENAMED key (#1532 review). The rest
@@ -679,6 +767,7 @@ describe('expression-parser', () => {
679
767
  test('rejects .filter(({done: d, ...rest}) => rest.done) — renamed key collision (#1532 review)', () => {
680
768
  const result = parseExpression('items().filter(({done: d, ...rest}) => rest.done)')
681
769
  expect(result.kind).toBe('call')
770
+ expect(asCallbackMethodCall(result)).toBeNull()
682
771
  })
683
772
 
684
773
  // Mode A typed error via NESTED-PATTERN slot (#1532 review).
@@ -690,6 +779,7 @@ describe('expression-parser', () => {
690
779
  test('rejects .filter(({user: {name}, ...rest}) => rest.user) — nested-pattern outer key collision (#1532 review)', () => {
691
780
  const result = parseExpression('items().filter(({user: {name}, ...rest}) => rest.user)')
692
781
  expect(result.kind).toBe('call')
782
+ expect(asCallbackMethodCall(result)).toBeNull()
693
783
  })
694
784
 
695
785
  // Inner-arrow parameter shadowing (#1532 review). The outer
@@ -703,13 +793,12 @@ describe('expression-parser', () => {
703
793
  // reference is `rest.x` (Mode A member access).
704
794
  test('lowers .filter(({a, ...rest}) => rest.x.map((rest) => rest.y).length > 0) — inner-arrow param shadows outer rest (#1532 review)', () => {
705
795
  const result = parseExpression('items().filter(({a, ...rest}) => rest.x.map((rest) => rest.y).length > 0)')
706
- expect(result.kind).toBe('higher-order')
707
- if (result.kind === 'higher-order') {
708
- expect(result.method).toBe('filter')
709
- // Predicate is the outer body rewritten — confirms validation
710
- // didn't refuse on the inner-scope `rest` reference.
711
- expect(result.predicate.kind).toBe('binary')
712
- }
796
+ const cb = asCallbackMethodCall(result)
797
+ expect(cb).not.toBeNull()
798
+ expect(cb!.method).toBe('filter')
799
+ // Predicate is the outer body rewritten — confirms validation
800
+ // didn't refuse on the inner-scope `rest` reference.
801
+ expect(cb!.arrow.body.kind).toBe('binary')
713
802
  })
714
803
 
715
804
  // Mode B (#1532): rest used as a call argument can't be lowered
@@ -718,11 +807,13 @@ describe('expression-parser', () => {
718
807
  test('rejects .filter(({a, ...rest}) => Object.keys(rest).length > 0) — rest passed to call (#1532)', () => {
719
808
  const result = parseExpression('items().filter(({a, ...rest}) => Object.keys(rest).length > 0)')
720
809
  expect(result.kind).toBe('call')
810
+ expect(asCallbackMethodCall(result)).toBeNull()
721
811
  })
722
812
 
723
813
  test('rejects .filter(({a, ...rest}) => fn(rest)) — rest passed as bare arg (#1532)', () => {
724
814
  const result = parseExpression('items().filter(({a, ...rest}) => fn(rest))')
725
815
  expect(result.kind).toBe('call')
816
+ expect(asCallbackMethodCall(result)).toBeNull()
726
817
  })
727
818
 
728
819
  // Mode B (#1532): bare `rest` as the arrow's return value. No
@@ -731,6 +822,7 @@ describe('expression-parser', () => {
731
822
  test('rejects .filter(({a, ...rest}) => rest) — rest as bare return value (#1532)', () => {
732
823
  const result = parseExpression('items().filter(({a, ...rest}) => rest)')
733
824
  expect(result.kind).toBe('call')
825
+ expect(asCallbackMethodCall(result)).toBeNull()
734
826
  })
735
827
 
736
828
  // Function-expression form with a destructured param (#1532).
@@ -742,6 +834,7 @@ describe('expression-parser', () => {
742
834
  test('rejects .filter(function ({a, ...rest}) { return rest }) — function-expression form refused upstream (#1532)', () => {
743
835
  const result = parseExpression('items().filter(function ({a, ...rest}) { return rest })')
744
836
  expect(result.kind).toBe('call')
837
+ expect(asCallbackMethodCall(result)).toBeNull()
745
838
  })
746
839
 
747
840
  // Method call on rest (#1532 review). `rest.foo()` would lower
@@ -753,6 +846,7 @@ describe('expression-parser', () => {
753
846
  test('rejects .filter(({a, ...rest}) => rest.foo()) — method call on rest (#1532 review)', () => {
754
847
  const result = parseExpression('items().filter(({a, ...rest}) => rest.foo())')
755
848
  expect(result.kind).toBe('call')
849
+ expect(asCallbackMethodCall(result)).toBeNull()
756
850
  })
757
851
 
758
852
  // Same for method-call-with-args — confirms the dedicated branch
@@ -760,6 +854,7 @@ describe('expression-parser', () => {
760
854
  test('rejects .filter(({a, ...rest}) => rest.hasOwnProperty("k")) — method call on rest with args (#1532 review)', () => {
761
855
  const result = parseExpression('items().filter(({a, ...rest}) => rest.hasOwnProperty("k"))')
762
856
  expect(result.kind).toBe('call')
857
+ expect(asCallbackMethodCall(result)).toBeNull()
763
858
  })
764
859
 
765
860
  // Computed rest access with a literal key still refuses (#1532):
@@ -769,6 +864,7 @@ describe('expression-parser', () => {
769
864
  test('rejects .filter(({a, ...rest}) => rest[0]) — computed rest access with literal key (#1532)', () => {
770
865
  const result = parseExpression('items().filter(({a, ...rest}) => rest[0])')
771
866
  expect(result.kind).toBe('call')
867
+ expect(asCallbackMethodCall(result)).toBeNull()
772
868
  })
773
869
 
774
870
  // Rest at a nested destructure level (#1532 out-of-scope) — we
@@ -777,6 +873,7 @@ describe('expression-parser', () => {
777
873
  test('rejects .filter(({user: {name, ...rest}}) => rest.email) — nested rest (#1532)', () => {
778
874
  const result = parseExpression('items().filter(({user: {name, ...rest}}) => rest.email)')
779
875
  expect(result.kind).toBe('call')
876
+ expect(asCallbackMethodCall(result)).toBeNull()
780
877
  })
781
878
 
782
879
  // Default value + rest composition (#1532 review). #1531's leaf
@@ -785,27 +882,27 @@ describe('expression-parser', () => {
785
882
  // refactor of either feature can't silently break the cross.
786
883
  test('lowers .filter(({done = false, ...rest}) => done && rest.priority > 0) — default + rest compose (#1532 review)', () => {
787
884
  const result = parseExpression('items().filter(({done = false, ...rest}) => done && rest.priority > 0)')
788
- expect(result.kind).toBe('higher-order')
789
- if (result.kind === 'higher-order') {
790
- // Predicate: `(_t.done ?? false) && _t.priority > 0`.
791
- expect(result.predicate.kind).toBe('logical')
792
- if (result.predicate.kind === 'logical') {
793
- // Left: `_t.done ?? false`.
794
- expect(result.predicate.left.kind).toBe('logical')
795
- if (result.predicate.left.kind === 'logical') {
796
- expect(result.predicate.left.op).toBe('??')
797
- expect(result.predicate.left.left.kind).toBe('member')
798
- if (result.predicate.left.left.kind === 'member') {
799
- expect(result.predicate.left.left.property).toBe('done')
800
- }
885
+ const cb = asCallbackMethodCall(result)
886
+ expect(cb).not.toBeNull()
887
+ const predicate = cb!.arrow.body
888
+ // Predicate: `(_t.done ?? false) && _t.priority > 0`.
889
+ expect(predicate.kind).toBe('logical')
890
+ if (predicate.kind === 'logical') {
891
+ // Left: `_t.done ?? false`.
892
+ expect(predicate.left.kind).toBe('logical')
893
+ if (predicate.left.kind === 'logical') {
894
+ expect(predicate.left.op).toBe('??')
895
+ expect(predicate.left.left.kind).toBe('member')
896
+ if (predicate.left.left.kind === 'member') {
897
+ expect(predicate.left.left.property).toBe('done')
801
898
  }
802
- // Right: `_t.priority > 0` — confirms the rest rewrite ran.
803
- expect(result.predicate.right.kind).toBe('binary')
804
- if (result.predicate.right.kind === 'binary') {
805
- expect(result.predicate.right.left.kind).toBe('member')
806
- if (result.predicate.right.left.kind === 'member') {
807
- expect(result.predicate.right.left.property).toBe('priority')
808
- }
899
+ }
900
+ // Right: `_t.priority > 0` — confirms the rest rewrite ran.
901
+ expect(predicate.right.kind).toBe('binary')
902
+ if (predicate.right.kind === 'binary') {
903
+ expect(predicate.right.left.kind).toBe('member')
904
+ if (predicate.right.left.kind === 'member') {
905
+ expect(predicate.right.left.property).toBe('priority')
809
906
  }
810
907
  }
811
908
  }
@@ -818,22 +915,23 @@ describe('expression-parser', () => {
818
915
  // source key (`priority`). Lowers cleanly.
819
916
  test('lowers .filter(({done: d, ...rest}) => d && rest.priority > 0) — renamed leaf + Mode A (#1532 review)', () => {
820
917
  const result = parseExpression('items().filter(({done: d, ...rest}) => d && rest.priority > 0)')
821
- expect(result.kind).toBe('higher-order')
822
- if (result.kind === 'higher-order') {
823
- expect(result.predicate.kind).toBe('logical')
824
- if (result.predicate.kind === 'logical') {
825
- // Left: `_t.done` (rename rewrites to the SOURCE key).
826
- const left = result.predicate.left
827
- expect(left.kind).toBe('member')
828
- if (left.kind === 'member') {
829
- expect(left.property).toBe('done')
830
- if (left.object.kind === 'identifier') {
831
- expect(left.object.name).toBe(result.param)
832
- }
918
+ const cb = asCallbackMethodCall(result)
919
+ expect(cb).not.toBeNull()
920
+ const param = cb!.arrow.params[0]
921
+ const predicate = cb!.arrow.body
922
+ expect(predicate.kind).toBe('logical')
923
+ if (predicate.kind === 'logical') {
924
+ // Left: `_t.done` (rename rewrites to the SOURCE key).
925
+ const left = predicate.left
926
+ expect(left.kind).toBe('member')
927
+ if (left.kind === 'member') {
928
+ expect(left.property).toBe('done')
929
+ if (left.object.kind === 'identifier') {
930
+ expect(left.object.name).toBe(param)
833
931
  }
834
- // Right: `_t.priority > 0` — rest member access.
835
- expect(result.predicate.right.kind).toBe('binary')
836
932
  }
933
+ // Right: `_t.priority > 0` — rest member access.
934
+ expect(predicate.right.kind).toBe('binary')
837
935
  }
838
936
  })
839
937
 
@@ -846,6 +944,7 @@ describe('expression-parser', () => {
846
944
  test('rejects .filter(({a, ...rest}) => rest.x === fn(rest)) — Mode A + Mode B mixed (#1532 review)', () => {
847
945
  const result = parseExpression('items().filter(({a, ...rest}) => rest.x === fn(rest))')
848
946
  expect(result.kind).toBe('call')
947
+ expect(asCallbackMethodCall(result)).toBeNull()
849
948
  })
850
949
 
851
950
  // Synthetic param collision with closure-captured `_t` (#1532
@@ -855,11 +954,10 @@ describe('expression-parser', () => {
855
954
  // silently shadow the closure capture.
856
955
  test('picks a non-colliding synthetic param when rest body references `_t` (#1532 review)', () => {
857
956
  const result = parseExpression('items().filter(({a, ...rest}) => rest.x === _t)')
858
- expect(result.kind).toBe('higher-order')
859
- if (result.kind === 'higher-order') {
860
- expect(result.param).not.toBe('_t')
861
- expect(result.param).toMatch(/^_t_+$/)
862
- }
957
+ const cb = asCallbackMethodCall(result)
958
+ expect(cb).not.toBeNull()
959
+ expect(cb!.arrow.params[0]).not.toBe('_t')
960
+ expect(cb!.arrow.params[0]).toMatch(/^_t_+$/)
863
961
  })
864
962
 
865
963
  // Cross-method coverage for the rest rewrite (#1532). The arrow
@@ -869,20 +967,23 @@ describe('expression-parser', () => {
869
967
  // remaining three.
870
968
  test('lowers .some(({a, ...rest}) => rest.x) — cross-method (#1532 review)', () => {
871
969
  const result = parseExpression('items().some(({a, ...rest}) => rest.x)')
872
- expect(result.kind).toBe('higher-order')
873
- if (result.kind === 'higher-order') expect(result.method).toBe('some')
970
+ const cb = asCallbackMethodCall(result)
971
+ expect(cb).not.toBeNull()
972
+ expect(cb!.method).toBe('some')
874
973
  })
875
974
 
876
975
  test('lowers .find(({a, ...rest}) => rest.x) — cross-method (#1532 review)', () => {
877
976
  const result = parseExpression('items().find(({a, ...rest}) => rest.x)')
878
- expect(result.kind).toBe('higher-order')
879
- if (result.kind === 'higher-order') expect(result.method).toBe('find')
977
+ const cb = asCallbackMethodCall(result)
978
+ expect(cb).not.toBeNull()
979
+ expect(cb!.method).toBe('find')
880
980
  })
881
981
 
882
982
  test('lowers .findIndex(({a, ...rest}) => rest.x) — cross-method (#1532 review)', () => {
883
983
  const result = parseExpression('items().findIndex(({a, ...rest}) => rest.x)')
884
- expect(result.kind).toBe('higher-order')
885
- if (result.kind === 'higher-order') expect(result.method).toBe('findIndex')
984
+ const cb = asCallbackMethodCall(result)
985
+ expect(cb).not.toBeNull()
986
+ expect(cb!.method).toBe('findIndex')
886
987
  })
887
988
 
888
989
  // Nested destructure with a default on an INNER LEAF composes
@@ -894,26 +995,26 @@ describe('expression-parser', () => {
894
995
  // mechanics produce a correct lowering for free.
895
996
  test('lowers .filter(({user: {name = "anon"}}) => name) — inner-leaf default composes (#1531)', () => {
896
997
  const result = parseExpression('items().filter(({user: {name = "anon"}}) => name)')
897
- expect(result.kind).toBe('higher-order')
898
- if (result.kind === 'higher-order') {
899
- // Predicate: `_t.user.name ?? "anon"`.
900
- expect(result.predicate.kind).toBe('logical')
901
- if (result.predicate.kind === 'logical') {
902
- expect(result.predicate.op).toBe('??')
903
- // Walk: `_t.user.name`.
904
- const lhs = result.predicate.left
905
- expect(lhs.kind).toBe('member')
906
- if (lhs.kind === 'member') {
907
- expect(lhs.property).toBe('name')
908
- expect(lhs.object.kind).toBe('member')
909
- if (lhs.object.kind === 'member') {
910
- expect(lhs.object.property).toBe('user')
911
- }
912
- }
913
- if (result.predicate.right.kind === 'literal') {
914
- expect(result.predicate.right.value).toBe('anon')
998
+ const cb = asCallbackMethodCall(result)
999
+ expect(cb).not.toBeNull()
1000
+ const predicate = cb!.arrow.body
1001
+ // Predicate: `_t.user.name ?? "anon"`.
1002
+ expect(predicate.kind).toBe('logical')
1003
+ if (predicate.kind === 'logical') {
1004
+ expect(predicate.op).toBe('??')
1005
+ // Walk: `_t.user.name`.
1006
+ const lhs = predicate.left
1007
+ expect(lhs.kind).toBe('member')
1008
+ if (lhs.kind === 'member') {
1009
+ expect(lhs.property).toBe('name')
1010
+ expect(lhs.object.kind).toBe('member')
1011
+ if (lhs.object.kind === 'member') {
1012
+ expect(lhs.object.property).toBe('user')
915
1013
  }
916
1014
  }
1015
+ if (predicate.right.kind === 'literal') {
1016
+ expect(predicate.right.value).toBe('anon')
1017
+ }
917
1018
  }
918
1019
  })
919
1020
 
@@ -925,6 +1026,7 @@ describe('expression-parser', () => {
925
1026
  test('rejects .filter(({user: {name} = {}}) => name) — default on nested-pattern slot (#1531)', () => {
926
1027
  const result = parseExpression('items().filter(({user: {name} = {}}) => name)')
927
1028
  expect(result.kind).toBe('call')
1029
+ expect(asCallbackMethodCall(result)).toBeNull()
928
1030
  })
929
1031
 
930
1032
  // Cross-binding default reference (`{ a, b = a }`) — JS resolves
@@ -935,6 +1037,7 @@ describe('expression-parser', () => {
935
1037
  test('rejects .filter(({a, b = a}) => b) — default refs another destructured binding (#1536)', () => {
936
1038
  const result = parseExpression('items().filter(({a, b = a}) => b)')
937
1039
  expect(result.kind).toBe('call')
1040
+ expect(asCallbackMethodCall(result)).toBeNull()
938
1041
  })
939
1042
 
940
1043
  // Side-effecting default (`{ x = getX() }`) — the rewrite duplicates
@@ -945,11 +1048,13 @@ describe('expression-parser', () => {
945
1048
  test('rejects .filter(({x = getX()}) => x + x) — default contains a call (#1536)', () => {
946
1049
  const result = parseExpression('items().filter(({x = getX()}) => x + x)')
947
1050
  expect(result.kind).toBe('call')
1051
+ expect(asCallbackMethodCall(result)).toBeNull()
948
1052
  })
949
1053
 
950
1054
  test('rejects .filter(({xs = arr.slice()}) => xs) — default contains array-method (#1536)', () => {
951
1055
  const result = parseExpression('items().filter(({xs = arr.slice()}) => xs)')
952
1056
  expect(result.kind).toBe('call')
1057
+ expect(asCallbackMethodCall(result)).toBeNull()
953
1058
  })
954
1059
 
955
1060
  // Pure shapes that DO compose with the inline rewrite — these
@@ -958,13 +1063,13 @@ describe('expression-parser', () => {
958
1063
  // `fallback` resolves to outer scope; that's fine — duplicating
959
1064
  // a bare identifier read has no semantic cost.
960
1065
  const result = parseExpression('items().filter(({x = fallback}) => x)')
961
- expect(result.kind).toBe('higher-order')
962
- if (result.kind === 'higher-order') {
963
- expect(result.predicate.kind).toBe('logical')
964
- if (result.predicate.kind === 'logical') {
965
- expect(result.predicate.op).toBe('??')
966
- expect(result.predicate.right.kind).toBe('identifier')
967
- }
1066
+ const cb = asCallbackMethodCall(result)
1067
+ expect(cb).not.toBeNull()
1068
+ const predicate = cb!.arrow.body
1069
+ expect(predicate.kind).toBe('logical')
1070
+ if (predicate.kind === 'logical') {
1071
+ expect(predicate.op).toBe('??')
1072
+ expect(predicate.right.kind).toBe('identifier')
968
1073
  }
969
1074
  })
970
1075
 
@@ -973,12 +1078,12 @@ describe('expression-parser', () => {
973
1078
  // side effects); duplicating across reference sites is harmless
974
1079
  // for the common case.
975
1080
  const result = parseExpression('items().filter(({x = config.fallback}) => x)')
976
- expect(result.kind).toBe('higher-order')
977
- if (result.kind === 'higher-order') {
978
- expect(result.predicate.kind).toBe('logical')
979
- if (result.predicate.kind === 'logical') {
980
- expect(result.predicate.right.kind).toBe('member')
981
- }
1081
+ const cb = asCallbackMethodCall(result)
1082
+ expect(cb).not.toBeNull()
1083
+ const predicate = cb!.arrow.body
1084
+ expect(predicate.kind).toBe('logical')
1085
+ if (predicate.kind === 'logical') {
1086
+ expect(predicate.right.kind).toBe('member')
982
1087
  }
983
1088
  })
984
1089
 
@@ -990,26 +1095,27 @@ describe('expression-parser', () => {
990
1095
  // what the user could have written by hand.
991
1096
  test('lowers .filter(({user: {name}}) => …) with nested destructure (#1530)', () => {
992
1097
  const result = parseExpression("items().filter(({user: {name}}) => name === 'alice')")
993
- expect(result.kind).toBe('higher-order')
994
- if (result.kind === 'higher-order') {
995
- expect(result.method).toBe('filter')
996
- expect(result.param).not.toBe('name')
997
- // Predicate: <_t>.user.name === 'alice'
998
- expect(result.predicate.kind).toBe('binary')
999
- if (result.predicate.kind === 'binary') {
1000
- expect(result.predicate.op).toBe('===')
1001
- // Walk the left-leaning chain: `_t.user.name`.
1002
- const lhs = result.predicate.left
1003
- expect(lhs.kind).toBe('member')
1004
- if (lhs.kind === 'member') {
1005
- expect(lhs.property).toBe('name')
1006
- expect(lhs.object.kind).toBe('member')
1007
- if (lhs.object.kind === 'member') {
1008
- expect(lhs.object.property).toBe('user')
1009
- expect(lhs.object.object.kind).toBe('identifier')
1010
- if (lhs.object.object.kind === 'identifier') {
1011
- expect(lhs.object.object.name).toBe(result.param)
1012
- }
1098
+ const cb = asCallbackMethodCall(result)
1099
+ expect(cb).not.toBeNull()
1100
+ const param = cb!.arrow.params[0]
1101
+ const predicate = cb!.arrow.body
1102
+ expect(cb!.method).toBe('filter')
1103
+ expect(param).not.toBe('name')
1104
+ // Predicate: <_t>.user.name === 'alice'
1105
+ expect(predicate.kind).toBe('binary')
1106
+ if (predicate.kind === 'binary') {
1107
+ expect(predicate.op).toBe('===')
1108
+ // Walk the left-leaning chain: `_t.user.name`.
1109
+ const lhs = predicate.left
1110
+ expect(lhs.kind).toBe('member')
1111
+ if (lhs.kind === 'member') {
1112
+ expect(lhs.property).toBe('name')
1113
+ expect(lhs.object.kind).toBe('member')
1114
+ if (lhs.object.kind === 'member') {
1115
+ expect(lhs.object.property).toBe('user')
1116
+ expect(lhs.object.object.kind).toBe('identifier')
1117
+ if (lhs.object.object.kind === 'identifier') {
1118
+ expect(lhs.object.object.name).toBe(param)
1013
1119
  }
1014
1120
  }
1015
1121
  }
@@ -1018,21 +1124,20 @@ describe('expression-parser', () => {
1018
1124
 
1019
1125
  test('lowers .filter(({a: {b: {c}}}) => c) with doubly-nested destructure (#1530)', () => {
1020
1126
  const result = parseExpression('items().filter(({a: {b: {c}}}) => c)')
1021
- expect(result.kind).toBe('higher-order')
1022
- if (result.kind === 'higher-order') {
1023
- // Predicate: `_t.a.b.c`.
1024
- let node = result.predicate
1025
- const expected = ['c', 'b', 'a']
1026
- for (const property of expected) {
1027
- expect(node.kind).toBe('member')
1028
- if (node.kind !== 'member') return
1029
- expect(node.property).toBe(property)
1030
- node = node.object
1031
- }
1032
- expect(node.kind).toBe('identifier')
1033
- if (node.kind === 'identifier') {
1034
- expect(node.name).toBe(result.param)
1035
- }
1127
+ const cb = asCallbackMethodCall(result)
1128
+ expect(cb).not.toBeNull()
1129
+ // Predicate: `_t.a.b.c`.
1130
+ let node = cb!.arrow.body
1131
+ const expected = ['c', 'b', 'a']
1132
+ for (const property of expected) {
1133
+ expect(node.kind).toBe('member')
1134
+ if (node.kind !== 'member') return
1135
+ expect(node.property).toBe(property)
1136
+ node = node.object
1137
+ }
1138
+ expect(node.kind).toBe('identifier')
1139
+ if (node.kind === 'identifier') {
1140
+ expect(node.name).toBe(cb!.arrow.params[0])
1036
1141
  }
1037
1142
  })
1038
1143
 
@@ -1041,16 +1146,16 @@ describe('expression-parser', () => {
1041
1146
  // substitutes `n` (body reference) with `_t.user.name` (original
1042
1147
  // field path).
1043
1148
  const result = parseExpression('items().filter(({user: {name: n}}) => n)')
1044
- expect(result.kind).toBe('higher-order')
1045
- if (result.kind === 'higher-order') {
1046
- expect(result.param).not.toBe('n')
1047
- expect(result.predicate.kind).toBe('member')
1048
- if (result.predicate.kind === 'member') {
1049
- expect(result.predicate.property).toBe('name') // field, not the rename
1050
- expect(result.predicate.object.kind).toBe('member')
1051
- if (result.predicate.object.kind === 'member') {
1052
- expect(result.predicate.object.property).toBe('user')
1053
- }
1149
+ const cb = asCallbackMethodCall(result)
1150
+ expect(cb).not.toBeNull()
1151
+ const predicate = cb!.arrow.body
1152
+ expect(cb!.arrow.params[0]).not.toBe('n')
1153
+ expect(predicate.kind).toBe('member')
1154
+ if (predicate.kind === 'member') {
1155
+ expect(predicate.property).toBe('name') // field, not the rename
1156
+ expect(predicate.object.kind).toBe('member')
1157
+ if (predicate.object.kind === 'member') {
1158
+ expect(predicate.object.property).toBe('user')
1054
1159
  }
1055
1160
  }
1056
1161
  })
@@ -1061,8 +1166,10 @@ describe('expression-parser', () => {
1061
1166
  // lowering it via the object-destructure path (#1530).
1062
1167
  test('rejects .filter(({a: [x]}) => x) — array binding inside object destructure (#1530)', () => {
1063
1168
  const result = parseExpression('items().filter(({a: [x]}) => x)')
1064
- // Falls through to plain `call` — adapter surfaces BF101.
1169
+ // Falls through to plain `call` — adapter surfaces BF101. The
1170
+ // rewrite was refused, so it is NOT a lowerable callback call.
1065
1171
  expect(result.kind).toBe('call')
1172
+ expect(asCallbackMethodCall(result)).toBeNull()
1066
1173
  })
1067
1174
 
1068
1175
  // Non-identifier property names (`{ 'x': y }`, `{ 0: y }`) used to
@@ -1073,11 +1180,13 @@ describe('expression-parser', () => {
1073
1180
  test('rejects .filter(({ "x": y }) => y) — string-literal key in destructure (#1530)', () => {
1074
1181
  const result = parseExpression("items().filter(({ 'x': y }) => y)")
1075
1182
  expect(result.kind).toBe('call')
1183
+ expect(asCallbackMethodCall(result)).toBeNull()
1076
1184
  })
1077
1185
 
1078
1186
  test('rejects .filter(({ 0: y }) => y) — numeric-literal key in destructure (#1530)', () => {
1079
1187
  const result = parseExpression('items().filter(({ 0: y }) => y)')
1080
1188
  expect(result.kind).toBe('call')
1189
+ expect(asCallbackMethodCall(result)).toBeNull()
1081
1190
  })
1082
1191
 
1083
1192
  // Synthetic param collision — body references a free `_t` AND the
@@ -1088,11 +1197,10 @@ describe('expression-parser', () => {
1088
1197
  // the synthetic param must NOT be `_t`, otherwise the rewrite
1089
1198
  // would silently shadow it.
1090
1199
  const result = parseExpression('items().filter(({user: {name}}) => name === _t)')
1091
- expect(result.kind).toBe('higher-order')
1092
- if (result.kind === 'higher-order') {
1093
- expect(result.param).not.toBe('_t')
1094
- expect(result.param).toMatch(/^_t_+$/)
1095
- }
1200
+ const cb = asCallbackMethodCall(result)
1201
+ expect(cb).not.toBeNull()
1202
+ expect(cb!.arrow.params[0]).not.toBe('_t')
1203
+ expect(cb!.arrow.params[0]).toMatch(/^_t_+$/)
1096
1204
  })
1097
1205
 
1098
1206
  // Function-keyword filter callback (#1443): `function (x) { return
@@ -1101,16 +1209,36 @@ describe('expression-parser', () => {
1101
1209
  // `(x) => x.done`.
1102
1210
  test('lowers .filter(function(x) { return x.done }) to higher-order (#1443)', () => {
1103
1211
  const result = parseExpression('todos().filter(function (x) { return x.done })')
1104
- expect(result.kind).toBe('higher-order')
1105
- if (result.kind === 'higher-order') {
1106
- expect(result.param).toBe('x')
1107
- expect(result.predicate.kind).toBe('member')
1108
- }
1212
+ const cb = asCallbackMethodCall(result)
1213
+ expect(cb).not.toBeNull()
1214
+ expect(cb!.arrow.params[0]).toBe('x')
1215
+ expect(cb!.arrow.body.kind).toBe('member')
1109
1216
  })
1110
1217
 
1111
- test('rejects function-keyword filter with multiple statements (#1443)', () => {
1218
+ // #2040: a value-producing multi-statement block body (pure `const`
1219
+ // bindings + a terminal `return`) is normalised to a single expression via
1220
+ // let-inline, so the higher-order detector recognises it. Previously the
1221
+ // "only single-`return`" restriction refused it.
1222
+ test('folds function-keyword filter with let-inline block body (#2040)', () => {
1112
1223
  const result = parseExpression('todos().filter(function (x) { const y = x; return y.done })')
1224
+ const cb = asCallbackMethodCall(result)
1225
+ expect(cb).not.toBeNull()
1226
+ expect(cb!.arrow.params[0]).toBe('x')
1227
+ // `const y = x; return y.done` inlines to `x.done`.
1228
+ expect(cb!.arrow.body).toEqual({
1229
+ kind: 'member',
1230
+ object: { kind: 'identifier', name: 'x' },
1231
+ property: 'done',
1232
+ computed: false,
1233
+ })
1234
+ })
1235
+
1236
+ // #2040: an imperative block body (local re-assignment / mutation) has no
1237
+ // value-position lowering and stays `unsupported`.
1238
+ test('rejects function-keyword filter with imperative block body (#2040)', () => {
1239
+ const result = parseExpression('todos().filter(function (x) { let y = 0; y = x.n; return y })')
1113
1240
  expect(result.kind).toBe('call')
1241
+ expect(asCallbackMethodCall(result)).toBeNull()
1114
1242
  })
1115
1243
  })
1116
1244
 
@@ -1270,13 +1398,19 @@ describe('expression-parser', () => {
1270
1398
  expect(result.reason).toContain('Standalone arrow functions')
1271
1399
  })
1272
1400
 
1273
- test('nested higher-order methods are NOT supported', () => {
1274
- // This would be: items().filter(x => x.items.filter(y => y.done).length > 0)
1275
- // For now, test a simpler case that triggers nested detection
1401
+ test('nested higher-order methods parse generically (lowerability deferred to the adapter)', () => {
1402
+ // items().filter(x => x.items().filter(y => y.done).length > 0)
1403
+ // The parser no longer gates nested higher-order callbacks (#2018
1404
+ // P5): the outer call is recognised as a generic callback method
1405
+ // call, and the inner callback survives inside the predicate body.
1406
+ // The "is it lowerable" decision now lives in the adapter, not the
1407
+ // parser/`isSupported` layer.
1276
1408
  const expr = parseExpression('items().filter(x => x.items().filter(y => y.done).length > 0)')
1277
- const result = isSupported(expr)
1278
- expect(result.supported).toBe(false)
1279
- expect(result.level).toBe('L5_UNSUPPORTED')
1409
+ const cb = asCallbackMethodCall(expr)
1410
+ expect(cb).not.toBeNull()
1411
+ expect(cb!.method).toBe('filter')
1412
+ // The nested higher-order call is still present in the predicate body.
1413
+ expect(containsHigherOrder(cb!.arrow.body)).toBe(true)
1280
1414
  })
1281
1415
  })
1282
1416
 
@@ -1578,9 +1712,29 @@ describe('expression-parser — .flat(depth?) lowering (#1448 Tier C)', () => {
1578
1712
  })
1579
1713
 
1580
1714
  describe('expression-parser — .flatMap(fn) projection (#1448 Tier C)', () => {
1581
- // Accepted catalogue: self, single field, and array-literal tuples of
1582
- // self / field leaves.
1583
- type Proj = { kind: 'self' } | { kind: 'field'; field: string } | { kind: 'tuple'; elements: unknown[] }
1715
+ // The structured `flatMapOp` projection catalogue is gone (#2018 P5):
1716
+ // `.flatMap` now parses to a generic callback `call`, and the projection
1717
+ // shape lives in the callback arrow body. These helpers assert the
1718
+ // SAME projection intent against the generic arrow body so the catalogue
1719
+ // coverage is preserved.
1720
+ type Proj = { kind: 'self' } | { kind: 'field'; field: string } | { kind: 'tuple'; elements: Proj[] }
1721
+ function leafMatches(body: ReturnType<typeof parseExpression>, param: string, leaf: Proj): boolean {
1722
+ if (leaf.kind === 'self') return body.kind === 'identifier' && body.name === param
1723
+ if (leaf.kind === 'field') {
1724
+ return (
1725
+ body.kind === 'member' &&
1726
+ !body.computed &&
1727
+ body.property === leaf.field &&
1728
+ body.object.kind === 'identifier' &&
1729
+ body.object.name === param
1730
+ )
1731
+ }
1732
+ // tuple
1733
+ if (body.kind !== 'array-literal') return false
1734
+ if (body.elements.length !== leaf.elements.length) return false
1735
+ return leaf.elements.every((el, i) => leafMatches(body.elements[i], param, el))
1736
+ }
1737
+
1584
1738
  const accepted: Array<[string, string, Proj]> = [
1585
1739
  ['self (i => i)', 'arr.flatMap(i => i)', { kind: 'self' }],
1586
1740
  ['field (i => i.tags)', 'arr.flatMap(i => i.tags)', { kind: 'field', field: 'tags' }],
@@ -1589,40 +1743,35 @@ describe('expression-parser — .flatMap(fn) projection (#1448 Tier C)', () => {
1589
1743
  ['tuple self + field', 'arr.flatMap(i => [i, i.tags])', { kind: 'tuple', elements: [{ kind: 'self' }, { kind: 'field', field: 'tags' }] }],
1590
1744
  ]
1591
1745
  for (const [label, expr, projection] of accepted) {
1592
- test(`${label} — lowers to a flatMap array-method`, () => {
1746
+ test(`${label} — lowers to a generic flatMap callback call`, () => {
1593
1747
  const result = parseExpression(expr)
1594
- expect(result.kind).toBe('array-method')
1595
- if (result.kind === 'array-method' && result.method === 'flatMap') {
1596
- expect(result.flatMapOp.projection).toEqual(projection)
1597
- } else {
1598
- throw new Error(`expected a flatMap array-method, got ${result.kind}`)
1599
- }
1748
+ const cb = asCallbackMethodCall(result)
1749
+ expect(cb).not.toBeNull()
1750
+ expect(cb!.method).toBe('flatMap')
1751
+ expect(leafMatches(cb!.arrow.body, cb!.arrow.params[0], projection)).toBe(true)
1600
1752
  })
1601
1753
  }
1602
1754
 
1603
- // Out of catalogue refused (BF101 + @client hint).
1604
- const refused = [
1755
+ // Out of the old structured catalogue: previously refused as
1756
+ // `unsupported`. The "is it lowerable" decision moved to the adapter
1757
+ // (#2018 P5), so the PARSER now accepts these generically — they parse
1758
+ // to a `flatMap` callback `call`. (The 2-arg form keeps its extra arg.)
1759
+ const nowGeneric: Array<[string, string]> = [
1605
1760
  ['deep field access', 'arr.flatMap(i => i.a.b)'],
1606
1761
  ['index/array callback params', 'arr.flatMap((i, idx) => i.tags)'],
1607
- // Tuple with a non-leaf element (arithmetic / literal / deep access).
1608
1762
  ['tuple with arithmetic element', 'arr.flatMap(i => [i.a, i.b + 1])'],
1609
1763
  ['tuple with literal element', 'arr.flatMap(i => [i.a, "x"])'],
1610
1764
  ['tuple with deep access', 'arr.flatMap(i => [i.a, i.b.c])'],
1611
1765
  ['tuple with spread', 'arr.flatMap(i => [...i.a])'],
1612
- // Empty tuple is a degenerate no-op — refused so emitters never
1613
- // produce a zero-arg `flat_map_tuple` call.
1614
1766
  ['empty tuple', 'arr.flatMap(i => [])'],
1615
- // Wrong-arity forms are intercepted by the same arm (not the generic
1616
- // "flatMap has no template lowering" gate) so the reason stays tailored.
1617
1767
  ['2-arg flatMap(fn, thisArg)', 'arr.flatMap(i => i.tags, ctx)'],
1618
1768
  ]
1619
- for (const [label, expr] of refused) {
1620
- test(`${label} — refuses`, () => {
1769
+ for (const [label, expr] of nowGeneric) {
1770
+ test(`${label} — parses as a generic flatMap callback call`, () => {
1621
1771
  const result = parseExpression(expr)
1622
- expect(result.kind).toBe('unsupported')
1623
- if (result.kind === 'unsupported') {
1624
- expect(result.reason).toContain('flatMap shape not supported')
1625
- }
1772
+ const cb = asCallbackMethodCall(result)
1773
+ expect(cb).not.toBeNull()
1774
+ expect(cb!.method).toBe('flatMap')
1626
1775
  })
1627
1776
  }
1628
1777
 
@@ -1783,3 +1932,166 @@ describe('parseStyleObjectEntries', () => {
1783
1932
  expect(parseStyleObjectEntries('color')).toBeNull()
1784
1933
  })
1785
1934
  })
1935
+
1936
+ // =============================================================================
1937
+ // Block → Expression Normalization (#2040)
1938
+ // =============================================================================
1939
+
1940
+ describe('foldBlockToExpr', () => {
1941
+ // Parse `{ … }` block source into ParsedStatement[] for the fold under test.
1942
+ function parseBlock(blockSrc: string) {
1943
+ const sf = ts.createSourceFile('b.ts', `(() => ${blockSrc})`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
1944
+ const stmt = sf.statements[0]
1945
+ if (!ts.isExpressionStatement(stmt)) throw new Error('expected expression statement')
1946
+ let expr = stmt.expression
1947
+ while (ts.isParenthesizedExpression(expr)) expr = expr.expression
1948
+ if (!ts.isArrowFunction(expr) || !ts.isBlock(expr.body)) throw new Error('expected block-body arrow')
1949
+ return parseBlockBody(expr.body, sf, n => n.getText(sf))
1950
+ }
1951
+
1952
+ test('let-inline: a const binding inlines into the returned expression', () => {
1953
+ const stmts = parseBlock('{ const x = a + 1; return x * 2 }')!
1954
+ const folded = foldBlockToExpr(stmts)
1955
+ expect(folded.ok).toBe(true)
1956
+ // `x` is replaced by `a + 1` in `x * 2`, giving the `*`-over-`+` tree.
1957
+ expect(folded.ok && folded.expr).toEqual({
1958
+ kind: 'binary',
1959
+ op: '*',
1960
+ left: { kind: 'binary', op: '+', left: { kind: 'identifier', name: 'a' }, right: { kind: 'literal', value: 1, literalType: 'number', raw: '1' } },
1961
+ right: { kind: 'literal', value: 2, literalType: 'number', raw: '2' },
1962
+ })
1963
+ })
1964
+
1965
+ test('chained let-inline: later bindings see earlier ones', () => {
1966
+ const stmts = parseBlock('{ const a = x + 1; const b = a * 2; return b }')!
1967
+ const folded = foldBlockToExpr(stmts)
1968
+ expect(folded.ok).toBe(true)
1969
+ expect(folded.ok && folded.expr).toEqual({
1970
+ kind: 'binary',
1971
+ op: '*',
1972
+ left: { kind: 'binary', op: '+', left: { kind: 'identifier', name: 'x' }, right: { kind: 'literal', value: 1, literalType: 'number', raw: '1' } },
1973
+ right: { kind: 'literal', value: 2, literalType: 'number', raw: '2' },
1974
+ })
1975
+ })
1976
+
1977
+ test('early return: `if (c) return A; return B` → ternary', () => {
1978
+ const stmts = parseBlock("{ if (f() === 'active') return !t.done; return true }")!
1979
+ const folded = foldBlockToExpr(stmts)
1980
+ expect(folded.ok).toBe(true)
1981
+ expect(folded.ok && folded.expr.kind).toBe('conditional')
1982
+ // `stringifyParsedExpr` normalises string literals to double quotes.
1983
+ expect(folded.ok && stringifyParsedExpr(folded.expr)).toBe('f() === "active" ? !t.done : true')
1984
+ })
1985
+
1986
+ test('if/else: both branches return → ternary', () => {
1987
+ const stmts = parseBlock('{ if (c) { return 1 } else { return 2 } }')!
1988
+ const folded = foldBlockToExpr(stmts)
1989
+ expect(folded.ok).toBe(true)
1990
+ expect(folded.ok && stringifyParsedExpr(folded.expr)).toBe('c ? 1 : 2')
1991
+ })
1992
+
1993
+ test('else-if chain → right-nested ternary', () => {
1994
+ const stmts = parseBlock('{ if (a()) return 1; else if (b()) return 2; return 3 }')!
1995
+ const folded = foldBlockToExpr(stmts)
1996
+ expect(folded.ok).toBe(true)
1997
+ expect(folded.ok && stringifyParsedExpr(folded.expr)).toBe('a() ? 1 : b() ? 2 : 3')
1998
+ })
1999
+
2000
+ test('let-inline before an early return is visible in both ternary arms', () => {
2001
+ const stmts = parseBlock("{ const f = filter(); if (f === 'active') return !t.done; return true }")!
2002
+ const folded = foldBlockToExpr(stmts)
2003
+ expect(folded.ok).toBe(true)
2004
+ expect(folded.ok && stringifyParsedExpr(folded.expr)).toBe('filter() === "active" ? !t.done : true')
2005
+ })
2006
+
2007
+ test('refuses a block that falls through without returning a value', () => {
2008
+ const stmts = parseBlock('{ const x = 1 }')!
2009
+ const folded = foldBlockToExpr(stmts)
2010
+ expect(folded.ok).toBe(false)
2011
+ })
2012
+
2013
+ test('refuses an `if` whose branch does not produce a value (side-effect only)', () => {
2014
+ const stmts = parseBlock('{ if (c) { const z = 1 } return 0 }')
2015
+ // `{ const z = 1 }` is a value-less then-branch that falls through to the
2016
+ // trailing `return 0`; the fold still produces `c ? 0 : 0` since the
2017
+ // then-branch continues into the rest. A genuinely imperative shape is the
2018
+ // reassignment case below, which `parseBlockBody` cannot even represent.
2019
+ expect(stmts).not.toBeNull()
2020
+ })
2021
+
2022
+ test('parseBlockBody returns null for an imperative statement (for loop)', () => {
2023
+ const stmts = parseBlock('{ let s = 0; for (const x of arr) s += x; return s }')
2024
+ expect(stmts).toBeNull()
2025
+ })
2026
+
2027
+ test('parseBlockBody returns null for a local re-assignment', () => {
2028
+ const stmts = parseBlock('{ let y = 1; y = y + 1; return y }')
2029
+ expect(stmts).toBeNull()
2030
+ })
2031
+
2032
+ // Soundness: let-inline must not be hygienic-blind or duplicate/drop effects
2033
+ // (PR #2051 review).
2034
+ test('refuses substitution that would capture a var under a nested callback param', () => {
2035
+ // `x` is bound to the outer `a`; inlining into `list.map(a => a + x)` would
2036
+ // capture the outer `a` under the inner `map` param `a`. Refuse rather than
2037
+ // silently miscompile to `a + a`.
2038
+ const stmts = parseBlock('{ const x = a; return list.map(a => a + x) }')!
2039
+ expect(foldBlockToExpr(stmts).ok).toBe(false)
2040
+ })
2041
+
2042
+ test('refuses a possibly-impure init used more than once (double-eval)', () => {
2043
+ const stmts = parseBlock('{ const d = next(); return d + d }')!
2044
+ expect(foldBlockToExpr(stmts).ok).toBe(false)
2045
+ })
2046
+
2047
+ test('refuses a possibly-impure init that is never used (dropped effect)', () => {
2048
+ const stmts = parseBlock('{ const _ = log(); return a - b }')!
2049
+ expect(foldBlockToExpr(stmts).ok).toBe(false)
2050
+ })
2051
+
2052
+ test('refuses a possibly-impure init referenced inside a callback (per-element eval)', () => {
2053
+ const stmts = parseBlock('{ const d = next(); return arr.map(x => x + d) }')!
2054
+ expect(foldBlockToExpr(stmts).ok).toBe(false)
2055
+ })
2056
+
2057
+ test('allows a possibly-impure init used exactly once (eval-count preserved)', () => {
2058
+ const stmts = parseBlock('{ const d = next(); return d }')!
2059
+ const folded = foldBlockToExpr(stmts)
2060
+ expect(folded.ok).toBe(true)
2061
+ expect(folded.ok && folded.expr).toEqual({ kind: 'call', callee: { kind: 'identifier', name: 'next' }, args: [] })
2062
+ })
2063
+
2064
+ test('allows a pure init used many times (signal-getter / member access)', () => {
2065
+ // A pure init is safe to inline at any number of sites — `filter()` read
2066
+ // once in the condition is the canonical case; a member access twice is fine.
2067
+ const stmts = parseBlock('{ const n = item.n; return n > 0 ? n : 0 }')!
2068
+ const folded = foldBlockToExpr(stmts)
2069
+ expect(folded.ok).toBe(true)
2070
+ })
2071
+
2072
+ // Soundness: an impure init must be evaluated once on EVERY path, not just
2073
+ // "at most once on some path" (PR #2051 re-review). A binding used in only one
2074
+ // ternary arm is dropped on the other arm even though `max` uses is 1.
2075
+ test('refuses a possibly-impure init used on only one branch (then)', () => {
2076
+ const stmts = parseBlock('{ const d = next(); if (c) return d; return 0 }')!
2077
+ expect(foldBlockToExpr(stmts).ok).toBe(false)
2078
+ })
2079
+
2080
+ test('refuses a possibly-impure init used on only one branch (else)', () => {
2081
+ const stmts = parseBlock('{ const d = next(); if (c) return 0; return d }')!
2082
+ expect(foldBlockToExpr(stmts).ok).toBe(false)
2083
+ })
2084
+
2085
+ test('refuses a possibly-impure init behind a short-circuiting operand', () => {
2086
+ // `c && next()` skips the call when `c` is falsy; the original always calls
2087
+ // it once.
2088
+ const stmts = parseBlock('{ const d = next(); return c && d }')!
2089
+ expect(foldBlockToExpr(stmts).ok).toBe(false)
2090
+ })
2091
+
2092
+ test('allows a possibly-impure init evaluated unconditionally before a short-circuit', () => {
2093
+ // `next() && c` always evaluates the call exactly once (left operand).
2094
+ const stmts = parseBlock('{ const d = next(); return d && c }')!
2095
+ expect(foldBlockToExpr(stmts).ok).toBe(true)
2096
+ })
2097
+ })