@kernlang/python 3.5.9-canary.220.1.c398cd95 → 4.0.1-canary.222.1.f06f1a51

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.
@@ -40,9 +40,10 @@
40
40
  * threads a `indent` string. The propagation hoist embeds its own 4-space
41
41
  * relative indent on the `return __k_tN` line; the wrapper prepends the
42
42
  * surrounding indent so the post-emit result nests correctly. */
43
- import { applyTemplate, emitStringKeyArray, isPostfixMutationOperator, isSupportedAssignOperator, KERN_STDLIB_MODULES, lookupStdlib, needsArgParens, needsBinaryParens, parseExpression, parseKeys, suggestStdlibMethod, } from '@kernlang/core';
43
+ import { applyTemplate, collectFreeIdentifierNames, emitStringKeyArray, instanceofRhsPythonType, instanceofRhsRejectReasonForName, isPostfixMutationOperator, isSupportedAssignOperator, KERN_STDLIB_MODULES, lookupStdlib, lowerJsClosureBodyToPython, needsArgParens, needsBinaryParens, parseExpression, parseKeys, suggestStdlibMethod, } from '@kernlang/core';
44
44
  import { buildPythonParamList } from './codegen-helpers.js';
45
- import { KERN_FMT_HELPER_PY, KERN_I32_HELPER_PY, KERN_PAIR_HELPERS_PY, KERN_TMOD_HELPER_PY, } from './core/expr/index.js';
45
+ import { KERN_FMT_HELPER_PY, KERN_I32_HELPER_PY, KERN_JS_ARRAY_HELPERS_PY, KERN_JS_HELPER_PY, KERN_PAIR_HELPERS_PY, KERN_TMOD_HELPER_PY, } from './core/expr/index.js';
46
+ import { isSharedPortableArrayMethod, isSharedPortableArrayProperty, lowerPortableArrayMethodPy, lowerPortableArrayPropertyPy, sharedPortableMethodRequiresPureReceiver, } from './core/expr/list-ops.js';
46
47
  import { mapTsTypeToPython } from './type-map.js';
47
48
  const INDENT_STEP = ' ';
48
49
  function freshCtx(options) {
@@ -51,6 +52,8 @@ function freshCtx(options) {
51
52
  imports: new Set(),
52
53
  helpers: new Set(),
53
54
  symbolMap: options?.symbolMap ?? {},
55
+ inClassBody: options?.inClassBody ?? false,
56
+ inConstructor: options?.inConstructor ?? false,
54
57
  shadowedSymbols: new Set(),
55
58
  localScopes: [],
56
59
  regexScopes: [],
@@ -60,7 +63,12 @@ function freshCtx(options) {
60
63
  tryDepth: 0,
61
64
  finallyDepth: 0,
62
65
  standaloneExpression: false,
66
+ coerceJsValues: options?.coerceJsValues ?? true,
63
67
  traceHooks: options?.traceHooks,
68
+ pendingHoists: [],
69
+ closureSeq: 0,
70
+ loopScopeIndexes: [],
71
+ loopLaterAssignFrames: [],
64
72
  };
65
73
  }
66
74
  /** PR-4 — Python helpers that normalize `each` pair-mode iteration sources.
@@ -151,6 +159,13 @@ export function emitNativeKernBodyPythonWithImports(handlerNode, options) {
151
159
  }
152
160
  try {
153
161
  const code = emitChildrenPy(handlerNode.children ?? [], ctx, '').join('\n');
162
+ // Slices 0+1 — a hoisted closure def left un-flushed means some statement
163
+ // emitter produced a block arrow without routing through emitChildrenPy's
164
+ // flush point. That would silently drop the def → NameError at runtime.
165
+ // Fail loud instead.
166
+ if (ctx.pendingHoists.length > 0) {
167
+ throw new Error('Internal codegen error: block-arrow closure def(s) were not flushed (a statement emitter bypassed the emitChildrenPy hoist point).');
168
+ }
154
169
  return { code, imports: ctx.imports, usedPropagation: ctx.usedPropagation, helpers: ctx.helpers };
155
170
  }
156
171
  finally {
@@ -192,15 +207,83 @@ function trailingCommentToPy(raw) {
192
207
  return `# ${raw.slice(2, -2).trim()}`.trimEnd();
193
208
  return `# ${raw}`.trimEnd();
194
209
  }
195
- function emitChildrenPy(children, ctx, indent, initialBindings = []) {
210
+ /** Slice-2 fix map each bare-identifier write target inside a loop body to the
211
+ * LAST top-level child index whose subtree writes it. Recurses into nested
212
+ * statements (if/else branches, nested loops, try bodies) but attributes every
213
+ * write to the TOP-LEVEL child containing it (the granularity
214
+ * `loopLaterAssignFrames.current` tracks). Member/index targets (`this.x`,
215
+ * `a[i]`) are excluded — mutating a captured OBJECT is by-reference in both
216
+ * languages and never pinned.
217
+ *
218
+ * Covered write node types (the body-statement emitters that can rebind a bare
219
+ * name): `assign` (its `target=` prop, INCLUDING the compound `op="+="`/`-=`/…
220
+ * and postfix `op="++"`/`--` forms — all the same node type, distinguished only
221
+ * by `op=`, all rebinding `target=`) and `set` (its `name=` prop, a bare-name
222
+ * cell write). `let`/`cell` are DECLARATIONS, not reassignments, so they are
223
+ * not scanned (the binding they create is the thing being pinned). */
224
+ function collectLoopAssignLastIndexes(children) {
225
+ const last = new Map();
226
+ const scan = (node, topIdx) => {
227
+ if (node.type === 'assign') {
228
+ // `target=` is the bare name (or member/index) being rebound, regardless of
229
+ // `op=` (plain `=`, compound `+=`, or postfix `++`/`--`).
230
+ const target = String(node.props?.target ?? '');
231
+ if (target && !target.includes('.') && !target.includes('['))
232
+ last.set(target, topIdx);
233
+ }
234
+ else if (node.type === 'set') {
235
+ // `set name=… to=…` rebinds a bare-name cell; the target is `name=`.
236
+ const target = String(node.props?.name ?? '');
237
+ if (target && !target.includes('.') && !target.includes('['))
238
+ last.set(target, topIdx);
239
+ }
240
+ for (const child of node.children ?? [])
241
+ scan(child, topIdx);
242
+ };
243
+ for (let i = 0; i < children.length; i++)
244
+ scan(children[i], i);
245
+ return last;
246
+ }
247
+ function emitChildrenPy(children, ctx, indent, initialBindings = [], isLoopBody = false) {
196
248
  const lines = [];
197
249
  ctx.localScopes.push(new Map(initialBindings));
198
250
  ctx.regexScopes.push(new Map(initialBindings.map(([name]) => [name, null])));
199
251
  ctx.renameStack.push(new Map());
252
+ // Slice-2 loop-variable pinning. When this recursion is a loop BODY, record
253
+ // the just-pushed scope's index so `emitBlockClosurePy` can decide whether a
254
+ // captured name resolves at-or-inside the enclosing loop body (→ pin). Only
255
+ // loop bodies mark a scope here — if/else/try/branch/with bodies do not, so a
256
+ // closure inside an `if` that is itself inside a loop still pins via the
257
+ // outer loop's recorded index (the `if` body's own scope index is >= it).
258
+ if (isLoopBody)
259
+ ctx.loopScopeIndexes.push(ctx.localScopes.length - 1);
260
+ // Slice-2 fix — pre-scan this loop body for bare-name assign targets so the
261
+ // closure emitter can reject a pin whose binding is reassigned AFTER the
262
+ // closure-creating statement (see loopLaterAssignFrames doc).
263
+ const loopFrame = isLoopBody ? { assignLast: collectLoopAssignLastIndexes(children), current: -1 } : null;
264
+ if (loopFrame)
265
+ ctx.loopLaterAssignFrames.push(loopFrame);
266
+ // Slices 0+1 fix (agon review, claude 0.7) — isolate the hoist buffer per
267
+ // recursion level. A statement emitter that lowers a HEADER expression (an
268
+ // `if`/`while` condition, an `each`/`for` iterable, a `branch` scrutinee)
269
+ // pushes that expression's closure defs into the buffer BEFORE recursing
270
+ // into its body via this function. Without isolation, the body-level
271
+ // per-child flush below would steal those defs and splice them INSIDE the
272
+ // body — after the header line already referenced the def name (runtime
273
+ // NameError: `if __kern_closure_0(2):` with the def indented under it).
274
+ // Saving/clearing here means a header def survives untouched until the
275
+ // PARENT level's per-child flush, which splices it before the entire
276
+ // statement — defs bind once, so before-the-header placement is correct for
277
+ // every header position including `elif` chains (the def simply precedes
278
+ // the whole if/elif chain).
279
+ const outerHoists = ctx.pendingHoists;
280
+ ctx.pendingHoists = [];
200
281
  try {
201
282
  for (let i = 0; i < children.length; i++) {
202
283
  const child = children[i];
203
- const trailStart = lines.length;
284
+ if (loopFrame)
285
+ loopFrame.current = i;
286
+ let trailStart = lines.length;
204
287
  if (child.type === 'comment') {
205
288
  for (const line of emitCommentPy(child))
206
289
  lines.push(`${indent}${line}`);
@@ -327,7 +410,11 @@ function emitChildrenPy(children, ctx, indent, initialBindings = []) {
327
410
  throw new Error("Propagation '?' is not allowed in `while cond=` — bind the call to a `let` first, then test the bound name.");
328
411
  }
329
412
  lines.push(`${indent}while ${emitPyExprCtx(condIR, ctx)}:`);
330
- const inner = emitChildrenPy(child.children ?? [], ctx, indent + INDENT_STEP);
413
+ // Slice-2: a `while` body is a loop body — per-iteration locals declared
414
+ // INSIDE it (JS re-binds block-scoped lets each iteration) must pin.
415
+ // The condition var, declared OUTSIDE, resolves below the loop scope and
416
+ // stays late-bound (by-reference, JS-parity-correct).
417
+ const inner = emitChildrenPy(child.children ?? [], ctx, indent + INDENT_STEP, [], true);
331
418
  if (inner.length === 0)
332
419
  lines.push(`${indent}${INDENT_STEP}pass`);
333
420
  for (const sl of inner)
@@ -463,7 +550,7 @@ function emitChildrenPy(children, ctx, indent, initialBindings = []) {
463
550
  const inner = emitChildrenPy(child.children ?? [], ctx, indent + INDENT_STEP, [
464
551
  [k, 'const'],
465
552
  [v, 'const'],
466
- ]);
553
+ ], true);
467
554
  if (inner.length === 0 && !ctx.traceHooks?.eachIterNext)
468
555
  lines.push(`${indent}${INDENT_STEP}pass`);
469
556
  for (const sl of inner)
@@ -491,7 +578,7 @@ function emitChildrenPy(children, ctx, indent, initialBindings = []) {
491
578
  if (ctx.traceHooks?.eachIterNext) {
492
579
  lines.push(`${indent}${INDENT_STEP}_kern_trace({"op": "iter-next", "binding": ${JSON.stringify(k)}, "value": ${k}})`);
493
580
  }
494
- const inner = emitChildrenPy(child.children ?? [], ctx, indent + INDENT_STEP, [[k, 'const']]);
581
+ const inner = emitChildrenPy(child.children ?? [], ctx, indent + INDENT_STEP, [[k, 'const']], true);
495
582
  if (inner.length === 0 && !ctx.traceHooks?.eachIterNext)
496
583
  lines.push(`${indent}${INDENT_STEP}pass`);
497
584
  for (const sl of inner)
@@ -504,7 +591,7 @@ function emitChildrenPy(children, ctx, indent, initialBindings = []) {
504
591
  if (ctx.traceHooks?.eachIterNext) {
505
592
  lines.push(`${indent}${INDENT_STEP}_kern_trace({"op": "iter-next", "binding": ${JSON.stringify(v)}, "value": ${v}})`);
506
593
  }
507
- const inner = emitChildrenPy(child.children ?? [], ctx, indent + INDENT_STEP, [[v, 'const']]);
594
+ const inner = emitChildrenPy(child.children ?? [], ctx, indent + INDENT_STEP, [[v, 'const']], true);
508
595
  if (inner.length === 0 && !ctx.traceHooks?.eachIterNext)
509
596
  lines.push(`${indent}${INDENT_STEP}pass`);
510
597
  for (const sl of inner)
@@ -552,7 +639,7 @@ function emitChildrenPy(children, ctx, indent, initialBindings = []) {
552
639
  if (ctx.traceHooks?.eachIterNext) {
553
640
  lines.push(`${indent}${INDENT_STEP}_kern_trace({"op": "iter-next", "binding": ${JSON.stringify(primaryBindingPy)}, "value": ${primaryBindingPy}})`);
554
641
  }
555
- const inner = emitChildrenPy(child.children ?? [], ctx, indent + INDENT_STEP, initialBindings);
642
+ const inner = emitChildrenPy(child.children ?? [], ctx, indent + INDENT_STEP, initialBindings, true);
556
643
  // `pass` is needed only when the for-loop body would otherwise be empty:
557
644
  // - index-mode path emits NO assignment (direct destructuring), so an
558
645
  // empty children list leaves the loop bodyless → IndentationError.
@@ -575,6 +662,24 @@ function emitChildrenPy(children, ctx, indent, initialBindings = []) {
575
662
  for (const line of emitBranchPy(child, ctx, indent))
576
663
  lines.push(line);
577
664
  }
665
+ // Slices 0+1 — flush hoisted block-arrow closure defs. `emitLambdaPy`
666
+ // pushed each `def __kern_closure_N(...):` block into `ctx.pendingHoists`
667
+ // when it lowered a block arrow used by THIS child. Splice them in at the
668
+ // current indent IMMEDIATELY BEFORE the child's own lines so the def
669
+ // precedes its use — works at any nesting level because every nested
670
+ // emission funnels through emitChildrenPy. Bump `trailStart` past the
671
+ // spliced defs so the trailing-comment check below still measures only
672
+ // the child's own line count.
673
+ if (ctx.pendingHoists.length > 0) {
674
+ const hoistLines = [];
675
+ for (const def of ctx.pendingHoists) {
676
+ for (const dl of def)
677
+ hoistLines.push(`${indent}${dl}`);
678
+ }
679
+ lines.splice(trailStart, 0, ...hoistLines);
680
+ trailStart += hoistLines.length;
681
+ ctx.pendingHoists = [];
682
+ }
578
683
  // W1 — re-attach an inline same-line trailing comment (captured by the
579
684
  // migrator as `trailingComment=`) to the simple statement's last line,
580
685
  // converted to a Python `#` comment.
@@ -588,9 +693,18 @@ function emitChildrenPy(children, ctx, indent, initialBindings = []) {
588
693
  }
589
694
  }
590
695
  finally {
696
+ if (isLoopBody)
697
+ ctx.loopScopeIndexes.pop();
698
+ if (loopFrame)
699
+ ctx.loopLaterAssignFrames.pop();
591
700
  ctx.localScopes.pop();
592
701
  ctx.regexScopes.pop();
593
702
  ctx.renameStack.pop();
703
+ // Restore the parent level's hoist buffer (see the isolation comment at
704
+ // entry). Any defs THIS level's last child left behind are appended so the
705
+ // parent's flush (or the defensive end-of-body throw) still sees them —
706
+ // hoists are never silently dropped.
707
+ ctx.pendingHoists = outerHoists.concat(ctx.pendingHoists);
594
708
  }
595
709
  return lines;
596
710
  }
@@ -667,7 +781,7 @@ function emitRangeForPy(node, ctx, indent) {
667
781
  if (ctx.traceHooks?.forIterNext) {
668
782
  out.push(`${bodyIndent}_kern_trace({"op": "iter-next", "binding": ${JSON.stringify(name)}, "value": ${name}})`);
669
783
  }
670
- const inner = emitChildrenPy(node.children ?? [], ctx, bodyIndent, [[name, 'const']]);
784
+ const inner = emitChildrenPy(node.children ?? [], ctx, bodyIndent, [[name, 'const']], true);
671
785
  if (inner.length === 0 && !ctx.traceHooks?.forIterNext)
672
786
  out.push(`${bodyIndent}pass`);
673
787
  for (const sl of inner)
@@ -1248,6 +1362,20 @@ function lookupLocalBinding(ctx, name) {
1248
1362
  }
1249
1363
  return undefined;
1250
1364
  }
1365
+ /** Slice-2 — the index into `ctx.localScopes` of the innermost scope that
1366
+ * binds `name`, or `null` if no scope binds it (an unresolved/host name).
1367
+ * Used by `emitBlockClosurePy` to decide loop-variable pinning: a captured
1368
+ * name pins IFF its binding index is at-or-inside the outermost enclosing
1369
+ * loop body (`>= ctx.loopScopeIndexes[0]`). Walks innermost→outermost so a
1370
+ * shadowing inner re-declaration wins over an outer binding of the same name,
1371
+ * matching the rename resolution the body emission uses. */
1372
+ function findBindingScopeIndex(ctx, name) {
1373
+ for (let i = ctx.localScopes.length - 1; i >= 0; i--) {
1374
+ if (ctx.localScopes[i].has(name))
1375
+ return i;
1376
+ }
1377
+ return null;
1378
+ }
1251
1379
  function isAssignableTarget(node) {
1252
1380
  if (node.kind === 'ident')
1253
1381
  return true;
@@ -1556,9 +1684,13 @@ const NON_EXCEPTION_LITERAL_KINDS = new Set([
1556
1684
  * `emitPyExprCtx` which threads the live ctx (and therefore the live
1557
1685
  * imports set) end-to-end. */
1558
1686
  export function emitPyExpression(node, options) {
1687
+ return emitPyExpressionWithImports(node, options).code;
1688
+ }
1689
+ export function emitPyExpressionWithImports(node, options) {
1559
1690
  const ctx = freshCtx(options);
1560
1691
  ctx.standaloneExpression = true;
1561
- return emitPyExprCtx(node, ctx);
1692
+ const code = emitPyExprCtx(node, ctx);
1693
+ return { code, imports: ctx.imports, helpers: ctx.helpers };
1562
1694
  }
1563
1695
  function emitPyExprCtx(node, ctx) {
1564
1696
  switch (node.kind) {
@@ -1578,7 +1710,13 @@ function emitPyExprCtx(node, ctx) {
1578
1710
  case 'nullLit':
1579
1711
  return 'None';
1580
1712
  case 'undefLit':
1581
- return 'None';
1713
+ // Ground/React layer (no helper channel) keeps the pre-slice collapse to
1714
+ // None; native bodies materialize the sentinel so `${undefined}` renders
1715
+ // "undefined" (vs null's "null") and `?? `/`typeof` can distinguish it.
1716
+ if (!ctx.coerceJsValues)
1717
+ return 'None';
1718
+ ctx.helpers.add(KERN_FMT_HELPER_PY);
1719
+ return '_KERN_UNDEFINED';
1582
1720
  case 'regexLit':
1583
1721
  ctx.imports.add('re');
1584
1722
  return `__k_re.compile(${pyRegexPattern(node)}, ${pyRegexFlags(node.flags, { allowGlobal: true })})`;
@@ -1597,6 +1735,8 @@ function emitPyExprCtx(node, ctx) {
1597
1735
  // module names) pass through unchanged.
1598
1736
  if (ctx.shadowedSymbols.has(node.name))
1599
1737
  return node.name;
1738
+ if (ctx.inClassBody && node.name === 'super')
1739
+ return 'super()';
1600
1740
  return ctx.symbolMap[node.name] ?? node.name;
1601
1741
  }
1602
1742
  case 'member':
@@ -1621,14 +1761,44 @@ function emitPyExprCtx(node, ctx) {
1621
1761
  }
1622
1762
  case 'await':
1623
1763
  return `await ${emitPyExprCtx(node.argument, ctx)}`;
1624
- case 'new':
1625
- return emitPyExprCtx(node.argument, ctx);
1764
+ case 'new': {
1765
+ // Host Error mapping (spec §1): `new Error(args)` → `Exception(args)` on
1766
+ // Python, since `raise Error(...)` / `isinstance(x, Error)` would
1767
+ // NameError (Python has no global `Error`). The mapping also covers
1768
+ // `let e = new Error(x)` and `throw new Error(x)` (both flow through the
1769
+ // `new` expression). `new TypeError(...)` is intentionally NOT mapped —
1770
+ // Python has a native `TypeError`, so it emits as-is.
1771
+ // TODO(kern): RangeError/SyntaxError/ReferenceError/EvalError/URIError
1772
+ // have NO same-named Python builtins and emit as-is → runtime NameError;
1773
+ // map or reject in the v2 builtin-errors slice. A user KERN class
1774
+ // literally named `Error` would be shadowed by this mapping —
1775
+ // registry-precedence hardening is also v2.
1776
+ const arg = node.argument;
1777
+ if (arg.kind === 'call' && arg.callee.kind === 'ident' && arg.callee.name === 'Error') {
1778
+ const remapped = { ...arg, callee: { ...arg.callee, name: 'Exception' } };
1779
+ return emitPyExprCtx(remapped, ctx);
1780
+ }
1781
+ // `new Error` WITHOUT parens is valid JS (≡ `new Error()`) and parses as
1782
+ // a bare ident argument — remap it too, else it emits a bare `Error`
1783
+ // NameError (agon review, claude/zai convergence).
1784
+ if (arg.kind === 'ident' && arg.name === 'Error') {
1785
+ return 'Exception()';
1786
+ }
1787
+ return emitPyExprCtx(arg, ctx);
1788
+ }
1626
1789
  case 'typeAssert':
1627
1790
  return emitPyExprCtx(node.expression, ctx);
1628
1791
  case 'nonNull':
1629
1792
  return emitPyExprCtx(node.expression, ctx);
1630
1793
  case 'tmplLit': {
1631
- // Lower TS template literals to Python f-strings.
1794
+ // Lower TS template literals to Python f-strings. In native bodies, wrap
1795
+ // each interpolation in _kern_fmt so JS value→string coercion semantics
1796
+ // (true→"true", null→"null", undefined→"undefined", 1.0→"1", arrays→
1797
+ // comma-joined, objects→"[object Object]") are preserved. The helper-less
1798
+ // Ground/React layer keeps the pre-slice raw f-string interpolation.
1799
+ const coerce = ctx.coerceJsValues;
1800
+ if (coerce)
1801
+ ctx.helpers.add(KERN_FMT_HELPER_PY);
1632
1802
  let out = 'f"';
1633
1803
  for (let i = 0; i < node.quasis.length; i++) {
1634
1804
  out += node.quasis[i]
@@ -1637,8 +1807,10 @@ function emitPyExprCtx(node, ctx) {
1637
1807
  .replace(/\n/g, '\\n')
1638
1808
  .replace(/\{/g, '{{')
1639
1809
  .replace(/\}/g, '}}');
1640
- if (i < node.expressions.length)
1641
- out += `{${emitPyExprCtx(node.expressions[i], ctx)}}`;
1810
+ if (i < node.expressions.length) {
1811
+ const inner = emitPyExprCtx(node.expressions[i], ctx);
1812
+ out += coerce ? `{_kern_fmt(${inner})}` : `{${inner}}`;
1813
+ }
1642
1814
  }
1643
1815
  out += '"';
1644
1816
  return out;
@@ -1669,11 +1841,58 @@ function emitPyExprCtx(node, ctx) {
1669
1841
  if (node.op === 'instanceof') {
1670
1842
  // JS `a instanceof B` → Python `isinstance(a, B)`. Emitting `instanceof`
1671
1843
  // verbatim would be a Python *syntax* error, so this lowering is
1672
- // mandatory (unlike raw host methods, which emit verbatim). The RHS
1673
- // class name emits as-is — e.g. `Error` stays `Error`, consistent with
1674
- // how host globals like `Date` already emit; KERN's portable surface is
1675
- // the stdlib namespace, not raw host constructors.
1676
- return `isinstance(${left}, ${right})`;
1844
+ // mandatory (unlike raw host methods, which emit verbatim).
1845
+ //
1846
+ // RHS handling (spec §2, shared table in core/instanceof-rhs.ts):
1847
+ // - accepted host global mapped Python type: `Array`→`list`,
1848
+ // `Error`→`Exception` (so `e instanceof Error` ≡
1849
+ // `isinstance(e, Exception)`, mirroring `new Error(...)` →
1850
+ // `Exception(...)`; Python `except Exception as e` + this check ≡
1851
+ // JS catch + `e instanceof Error` for KERN-thrown errors).
1852
+ // - rejected RHS (wrapper-parity trap / unsupported builtin /
1853
+ // non-type-name) → THROW fail-closed. This is defense in depth:
1854
+ // the eligibility gate already rejects these bodies, so this throw
1855
+ // can only fire on directly-built IR, never on gate-passed source —
1856
+ // gate and lowerer share core/instanceof-rhs.ts and cannot drift.
1857
+ // - any other ident/member RHS → emit as-is (user classes work; an
1858
+ // unknown name fails loud at Python runtime with NameError —
1859
+ // registry-precedence hardening is v2).
1860
+ const rhs = node.right;
1861
+ if (rhs.kind === 'ident') {
1862
+ const rejectReason = instanceofRhsRejectReasonForName(rhs.name);
1863
+ if (rejectReason !== null) {
1864
+ throw new Error(`instanceof RHS '${rhs.name}' has no Python lowering (${rejectReason}). ` +
1865
+ 'JS primitive-wrapper / unmapped-builtin instanceof has no isinstance parity; ' +
1866
+ 'this body is ineligible for native KERN.');
1867
+ }
1868
+ const mapped = instanceofRhsPythonType(rhs.name);
1869
+ if (mapped !== null)
1870
+ return `isinstance(${left}, ${mapped})`;
1871
+ return `isinstance(${left}, ${right})`;
1872
+ }
1873
+ if (rhs.kind === 'member') {
1874
+ return `isinstance(${left}, ${right})`;
1875
+ }
1876
+ throw new Error('instanceof RHS is not a type name (instanceof-rhs-not-a-type-name); ' +
1877
+ 'only a class identifier or qualified member name can lower to isinstance().');
1878
+ }
1879
+ if (node.op === '+' && ctx.coerceJsValues) {
1880
+ // JS `+` is overloaded: string concat if either operand is string-ish,
1881
+ // numeric addition otherwise. Python has no implicit coercion, so we
1882
+ // lower based on syntactic hints:
1883
+ // - If either operand is syntactically string-producing (strLit/tmplLit),
1884
+ // emit _kern_fmt(left) + _kern_fmt(right) for JS string concat.
1885
+ // - Otherwise (idents/calls/members/numbers — type unknown at emit time),
1886
+ // emit __kern_add(left, right) so numeric + stays additive and dynamic
1887
+ // string concat is coerced at runtime.
1888
+ // The helper-less Ground/React layer skips this and falls through to the
1889
+ // generic raw `+` path below (pre-slice behavior, zero regression).
1890
+ ctx.helpers.add(KERN_FMT_HELPER_PY);
1891
+ const isStr = (n) => n.kind === 'strLit' || n.kind === 'tmplLit';
1892
+ if (isStr(node.left) || isStr(node.right)) {
1893
+ return `_kern_fmt(${left}) + _kern_fmt(${right})`;
1894
+ }
1895
+ return `__kern_add(${left}, ${right})`;
1677
1896
  }
1678
1897
  if (node.op === '??') {
1679
1898
  // Slice 4c — nullish coalesce lowering. Two shapes:
@@ -1697,11 +1916,22 @@ function emitPyExprCtx(node, ctx) {
1697
1916
  // Slice 4c (post-buddy-review) was the easy-win expansion after the
1698
1917
  // 22.7% empirical-gate scan; this lifts the slice-2 `??` throw and
1699
1918
  // adds an estimated +7% to native eligibility on Agon-AI bodies.
1919
+ // Ground/React layer keeps the pre-slice None-only nullish test (no
1920
+ // sentinel, no helper). Native bodies also exclude the undefined
1921
+ // sentinel so `undefined ?? x` coalesces.
1922
+ if (!ctx.coerceJsValues) {
1923
+ if (isReceiverChainPure(node.left)) {
1924
+ return `(${left} if ${left} is not None else ${right})`;
1925
+ }
1926
+ const tmp = `__k_nc${++ctx.gensymCounter}`;
1927
+ return `(${tmp} if (${tmp} := ${left}) is not None else ${right})`;
1928
+ }
1929
+ ctx.helpers.add(KERN_FMT_HELPER_PY);
1700
1930
  if (isReceiverChainPure(node.left)) {
1701
- return `(${left} if ${left} is not None else ${right})`;
1931
+ return `(${left} if (${left} is not None and ${left} is not _KERN_UNDEFINED) else ${right})`;
1702
1932
  }
1703
1933
  const tmp = `__k_nc${++ctx.gensymCounter}`;
1704
- return `(${tmp} if (${tmp} := ${left}) is not None else ${right})`;
1934
+ return `(${tmp} if ((${tmp} := ${left}) is not None and ${tmp} is not _KERN_UNDEFINED) else ${right})`;
1705
1935
  }
1706
1936
  const forceLeft = needsComparisonChainParens(node.left, node.op);
1707
1937
  const forceRight = needsComparisonChainParens(node.right, node.op);
@@ -1805,6 +2035,21 @@ function emitPyTypeof(argument, ctx) {
1805
2035
  const value = emitPyExprCtx(argument, ctx);
1806
2036
  const wrapped = needsArgParens(argument) ? `(${value})` : value;
1807
2037
  const tmp = `__k_typeof${++ctx.gensymCounter}`;
2038
+ // Native bodies: a runtime value holding the undefined sentinel reports
2039
+ // "undefined" (JS `typeof undefined`), not "object". The walrus binds in the
2040
+ // first test so the sentinel branch is checked before the None branch. The
2041
+ // helper-less Ground layer never materializes the sentinel, so it keeps the
2042
+ // pre-slice None-first form.
2043
+ if (ctx.coerceJsValues) {
2044
+ ctx.helpers.add(KERN_FMT_HELPER_PY);
2045
+ return (`("undefined" if (${tmp} := ${wrapped}) is _KERN_UNDEFINED ` +
2046
+ `else "object" if ${tmp} is None ` +
2047
+ `else "boolean" if isinstance(${tmp}, bool) ` +
2048
+ `else "number" if isinstance(${tmp}, (int, float)) ` +
2049
+ `else "string" if isinstance(${tmp}, str) ` +
2050
+ `else "function" if callable(${tmp}) ` +
2051
+ `else "object")`);
2052
+ }
1808
2053
  return (`("object" if (${tmp} := ${wrapped}) is None ` +
1809
2054
  `else "boolean" if isinstance(${tmp}, bool) ` +
1810
2055
  `else "number" if isinstance(${tmp}, (int, float)) ` +
@@ -1814,6 +2059,9 @@ function emitPyTypeof(argument, ctx) {
1814
2059
  }
1815
2060
  function emitLambdaPy(node, ctx) {
1816
2061
  const names = node.params.map((p) => p.name);
2062
+ if (node.bodyBlock) {
2063
+ return emitBlockClosurePy(node, names, ctx);
2064
+ }
1817
2065
  const previous = new Set(ctx.shadowedSymbols);
1818
2066
  for (const name of names)
1819
2067
  ctx.shadowedSymbols.add(name);
@@ -1825,6 +2073,180 @@ function emitLambdaPy(node, ctx) {
1825
2073
  ctx.shadowedSymbols = previous;
1826
2074
  }
1827
2075
  }
2076
+ /** Slices 0+1 — lower a block-bodied arrow (`x => { ... }`) to a hoisted local
2077
+ * Python `def`. Pushes `def __kern_closure_N(params): <body>` into
2078
+ * `ctx.pendingHoists` (flushed by emitChildrenPy immediately before the
2079
+ * enclosing statement) and RETURNS the def name as the expression string, so
2080
+ * `let scale = (x) => {...}` lowers to `scale = __kern_closure_0` with the def
2081
+ * hoisted above it.
2082
+ *
2083
+ * The closure body is lowered through `lowerJsClosureBodyToPython`, reusing
2084
+ * the class-path expression/condition callbacks:
2085
+ * - `lowerExpression(raw)` = `emitPyExprCtx(parseExpression(raw), ctx)` —
2086
+ * identical to every other native-body expression emit, so a captured
2087
+ * RENAMED outer variable resolves through `ctx` (the rename stack /
2088
+ * symbolMap) exactly as it does outside the closure.
2089
+ * - `lowerCondition(raw)` mirrors the class/native if-emitter, which lowers a
2090
+ * condition as the bare `emitPyExprCtx(parseExpression(cond), ctx)` (NO
2091
+ * js_truthy wrapper). Matching it EXACTLY means a condition inside a
2092
+ * closure lowers identically to the same condition outside one.
2093
+ *
2094
+ * Closure PARAMS shadow outer renames while the body is lowered (same
2095
+ * `shadowedSymbols` save/restore as the expression-lambda branch) — params
2096
+ * must NOT be renamed, while captures of renamed outer vars still resolve
2097
+ * through ctx. The v1 gate (commit A) guarantees the lowering succeeds;
2098
+ * gate/lowerer drift (`ok:false`) is a loud bug. */
2099
+ function emitBlockClosurePy(node, names, ctx) {
2100
+ const closureName = `__kern_closure_${ctx.closureSeq++}`;
2101
+ const previous = new Set(ctx.shadowedSymbols);
2102
+ for (const name of names)
2103
+ ctx.shadowedSymbols.add(name);
2104
+ try {
2105
+ const lowered = lowerJsClosureBodyToPython(node.bodyBlock.raw, {
2106
+ lowerExpression: (raw) => emitPyExprCtx(parseExpression(raw), ctx),
2107
+ // Mirror the native/class if-emitter EXACTLY (bare expression, no
2108
+ // js_truthy) so a condition inside the closure matches the same
2109
+ // condition outside it.
2110
+ lowerCondition: (raw) => emitPyExprCtx(parseExpression(raw), ctx),
2111
+ // The closure's own params are def-locals, never `nonlocal`: a write to a
2112
+ // param (`(x) => { x = x + 1 }`) must not be reported as a written FREE
2113
+ // name. The lowerer excludes both params and block-locals.
2114
+ paramNames: names,
2115
+ // Bare write TARGETS resolve through the SAME rename machinery reads use
2116
+ // — a write to a shadow-renamed capture must target the renamed binding
2117
+ // (`__k_shadow_x_N = …`), not the outer one (probe-verified silent
2118
+ // wrong-values without this). Params/block-locals are never renamed, so
2119
+ // the resolver is identity for them.
2120
+ lowerAssignTarget: (name) => resolveLocalRename(ctx, name),
2121
+ });
2122
+ if (!lowered.ok) {
2123
+ // The commit-A gate already accepted this block, so a lowering failure
2124
+ // here is gate/lowerer drift — surface it loudly.
2125
+ throw new Error(`Internal codegen error: block-arrow closure passed the v1 gate but failed to lower (${lowered.reason ?? 'unknown'}).`);
2126
+ }
2127
+ // Slice-2 loop-variable pinning. JS closures capture variables BY
2128
+ // REFERENCE; a binding created PER-ITERATION (an each/for loop var, or any
2129
+ // let/const declared inside a loop body) is re-bound each iteration, so
2130
+ // each closure sees its own iteration's value. A naive Python hoisted def
2131
+ // late-binds → every closure sees the LAST value (the classic 0,1,2 vs
2132
+ // 2,2,2 bug). FIX: pin such captures via a default arg
2133
+ // (`def __kern_closure_N(p, x=x):`) — Python evaluates defaults at def
2134
+ // time = the hoist point before the enclosing statement = exactly the
2135
+ // per-iteration snapshot JS produces.
2136
+ //
2137
+ // RULE: pin a captured name IFF its binding resolves at-or-inside the
2138
+ // OUTERMOST loop body enclosing the closure (scope index >=
2139
+ // loopScopeIndexes[0]). A binding declared OUTSIDE every loop (a function
2140
+ // param, an accumulator, a `while` condition var) resolves below that
2141
+ // index and stays late-bound — JS sees its CURRENT value at call time, and
2142
+ // Python late binding is already parity-correct for it. Over-pinning those
2143
+ // would WRONGLY freeze a value JS does not freeze.
2144
+ const pinParams = [];
2145
+ // The user-facing names that got PINNED (per-iteration loop captures). A
2146
+ // WRITTEN free name that is also pinned is unlowerable in v1 (the def-time
2147
+ // pin freezes the value; a `nonlocal` write would target the wrong binding)
2148
+ // — it throws `closure-pinned-write` below. Tracked here so the throw and
2149
+ // the disjointness assertion can consult it.
2150
+ const pinnedNames = new Set();
2151
+ if (ctx.loopScopeIndexes.length > 0) {
2152
+ const free = collectFreeIdentifierNames(node.bodyBlock.raw, names);
2153
+ const outermostLoopScope = ctx.loopScopeIndexes[0];
2154
+ // Alphabetical by user-facing name for deterministic emission order.
2155
+ for (const name of [...free].sort()) {
2156
+ const scopeIndex = findBindingScopeIndex(ctx, name);
2157
+ if (scopeIndex === null || scopeIndex < outermostLoopScope)
2158
+ continue;
2159
+ // Slice-2 fix (agon review, claude 0.7) — a pin FREEZES the value at
2160
+ // def time, but JS captures by reference: if the pinned binding is
2161
+ // REASSIGNED in a later sibling statement of any enclosing loop body,
2162
+ // the JS closure sees the mutation and the frozen default does not
2163
+ // (`let t = 0; fns.push(() => t); t = t + x` → JS [1,2], pinned
2164
+ // Python [0,0]). Fail closed instead of emitting silent divergence.
2165
+ // Assignments in a STRICTLY-LOWER top-level child (`< current`) run
2166
+ // before the closure and are captured by the pin — those are fine. The
2167
+ // `>=` (not `>`) rejects an assignment in the SAME top-level child as the
2168
+ // closure too: within-child statement order is not tracked (the whole
2169
+ // child shares one index), so a same-child closure+reassignment cannot be
2170
+ // proven safe — fail closed beats the silent divergence (kimi 0.85).
2171
+ for (const frame of ctx.loopLaterAssignFrames) {
2172
+ const lastAssign = frame.assignLast.get(name);
2173
+ if (lastAssign !== undefined && lastAssign >= frame.current) {
2174
+ throw new Error(`Closure captures loop-local '${name}' which is reassigned after the closure is created — ` +
2175
+ `the per-iteration pin would freeze a value JS does not freeze. ` +
2176
+ `Bind the final value to a fresh const before creating the closure (v1 limitation).`);
2177
+ }
2178
+ }
2179
+ // Resolve to the SAME Python name the body emission uses. A captured
2180
+ // loop-body binding goes through the block-scope rename stack
2181
+ // (`resolveLocalRename`) — identical to the `ident` emit path — so a
2182
+ // shadow-renamed inner `x` pins as `__k_shadow_x_N=__k_shadow_x_N`.
2183
+ // (symbolMap is param-only and never names a loop-body-scoped binding,
2184
+ // so it is intentionally not consulted here.)
2185
+ const renamed = resolveLocalRename(ctx, name);
2186
+ // Defensive (agon review, claude 0.3 nit): a renamed pin equal to a
2187
+ // closure param would emit `def f(p, p=p)` — a Python SyntaxError.
2188
+ // Renames are __k_shadow_*/gensym forms, so this is theoretical; fail
2189
+ // loud rather than emit invalid Python if it ever happens.
2190
+ if (names.includes(renamed)) {
2191
+ throw new Error(`Closure parameter '${renamed}' collides with the rename of captured '${name}' — rename the parameter.`);
2192
+ }
2193
+ pinParams.push(`${renamed}=${renamed}`);
2194
+ pinnedNames.add(name);
2195
+ }
2196
+ }
2197
+ // Mutation v1 — free-variable WRITES (`nonlocal`). A bare write to a free
2198
+ // capture (one that is neither a closure param nor a block-local — the
2199
+ // lowerer already excluded those) needs a `nonlocal` declaration so Python
2200
+ // rebinds the OUTER binding instead of creating a def-local shadow (without
2201
+ // it: `UnboundLocalError` for read+write, or a silent dead local for
2202
+ // write-only). Member/index writes never appear in `writtenFreeNames` —
2203
+ // they mutate a captured object by reference and need no `nonlocal`.
2204
+ //
2205
+ // The eligibility≢lowerability gap (documented on the gate): a write to a
2206
+ // PINNED per-iteration capture cannot be lowered. The def-time default-arg
2207
+ // pin freezes the value, and a `nonlocal` on that name would rebind the
2208
+ // enclosing loop-body binding — neither matches JS's per-iteration
2209
+ // by-reference capture. The gate cannot see the enclosing loop from a
2210
+ // single statement, so we fail closed LOUDLY here.
2211
+ const nonlocalNames = [];
2212
+ for (const name of [...lowered.writtenFreeNames].sort()) {
2213
+ if (pinnedNames.has(name)) {
2214
+ throw new Error(`Closure writes to per-iteration loop capture '${name}', which v1 cannot lower: ` +
2215
+ `mutation of a per-iteration loop capture needs cell-boxing (v2). ` +
2216
+ `Bind to an outer variable or restructure.`);
2217
+ }
2218
+ nonlocalNames.push(name);
2219
+ }
2220
+ // Council's riskiest-thing defensive assertion: a name cannot be both
2221
+ // pinned (a per-iteration loop capture → default-arg) AND nonlocal (an
2222
+ // outer free write). By construction they are disjoint — pinning requires
2223
+ // the binding to resolve AT-OR-INSIDE the outermost loop, while a
2224
+ // nonlocal-written name is precisely one that did NOT (the pinned case
2225
+ // throws above). If this ever fires, the pin condition and the written-free
2226
+ // classification have drifted — a DESIGN error, not a user error.
2227
+ for (const name of nonlocalNames) {
2228
+ if (pinnedNames.has(name)) {
2229
+ throw new Error(`Internal codegen invariant violated: '${name}' is both pinned and nonlocal in closure ${closureName}.`);
2230
+ }
2231
+ }
2232
+ const params = [...names, ...pinParams].join(', ');
2233
+ // `lowered.lines` are body lines at 4-space indent (the lowerer's own
2234
+ // convention); they nest directly under the `def` header. A `nonlocal` line
2235
+ // (if any) is the def's FIRST body statement (Python requires it before any
2236
+ // use of the name). The declared names are RENAME-RESOLVED — the body's
2237
+ // write targets went through `lowerAssignTarget` (same resolver), so the
2238
+ // nonlocal declaration, the writes, and the enclosing binding all agree on
2239
+ // the renamed Python name for shadow-renamed captures.
2240
+ const nonlocalLine = nonlocalNames.length > 0
2241
+ ? [` nonlocal ${nonlocalNames.map((n) => resolveLocalRename(ctx, n)).join(', ')}`]
2242
+ : [];
2243
+ ctx.pendingHoists.push([`def ${closureName}(${params}):`, ...nonlocalLine, ...lowered.lines]);
2244
+ return closureName;
2245
+ }
2246
+ finally {
2247
+ ctx.shadowedSymbols = previous;
2248
+ }
2249
+ }
1828
2250
  function valueReferencesIdent(node, name) {
1829
2251
  switch (node.kind) {
1830
2252
  case 'ident':
@@ -1864,16 +2286,26 @@ function valueReferencesIdent(node, name) {
1864
2286
  case 'lambda':
1865
2287
  if (node.params.some((p) => p.name === name))
1866
2288
  return false;
2289
+ if (node.bodyBlock)
2290
+ return rawBlockReferencesIdent(node.bodyBlock.raw, name);
1867
2291
  return valueReferencesIdent(node.body, name);
1868
2292
  default:
1869
2293
  return false;
1870
2294
  }
1871
2295
  }
2296
+ /** Conservative word-boundary check for an identifier inside a raw closure
2297
+ * block. Used when a block-bodied arrow (slices 0+1) has no expression `body`
2298
+ * to recurse. Over-matching is safe for the capture analyses that consume it. */
2299
+ function rawBlockReferencesIdent(raw, name) {
2300
+ return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(raw);
2301
+ }
1872
2302
  function containsLambdaCapturingIdent(node, name) {
1873
2303
  switch (node.kind) {
1874
2304
  case 'lambda':
1875
2305
  if (node.params.some((p) => p.name === name))
1876
2306
  return false;
2307
+ if (node.bodyBlock)
2308
+ return rawBlockReferencesIdent(node.bodyBlock.raw, name);
1877
2309
  return valueReferencesIdent(node.body, name);
1878
2310
  case 'member':
1879
2311
  return containsLambdaCapturingIdent(node.object, name);
@@ -1917,6 +2349,16 @@ function lowerChain(node, ctx) {
1917
2349
  const inner = obj.kind === 'member' || obj.kind === 'call' || obj.kind === 'index'
1918
2350
  ? lowerChain(obj, ctx)
1919
2351
  : { guard: null, expr: emitPyExprCtx(obj, ctx) };
2352
+ // Portable Array *property* read (non-call `.length`) lowers through the
2353
+ // SAME shared list-ops hook the route emitter uses, so `this.items.length`
2354
+ // emits `len(self.items)` (not invalid `self.items.length`) — identical to
2355
+ // a route handler's `arr.length` by construction. Only the trailing `.prop`
2356
+ // link is rewritten; the accumulated optional-chain guard is left UNTOUCHED
2357
+ // and still flows through `wrapGuardIfAny`, so `items?.length` stays
2358
+ // `(len(items) if items is not None else None)`-shaped.
2359
+ const linkExpr = isSharedPortableArrayProperty(node.property)
2360
+ ? (lowerPortableArrayPropertyPy(inner.expr, node.property) ?? `${inner.expr}.${node.property}`)
2361
+ : `${inner.expr}.${node.property}`;
1920
2362
  if (node.optional) {
1921
2363
  // The receiver expression names what we need to test. The expr names
1922
2364
  // the receiver twice (once in test, once in branch); reject when that
@@ -1926,9 +2368,9 @@ function lowerChain(node, ctx) {
1926
2368
  'Bind the call/await result to a `let` first, then use `let.field?.next` on the bound name.');
1927
2369
  }
1928
2370
  const newGuard = inner.guard === null ? `${inner.expr} is not None` : `${inner.guard} and ${inner.expr} is not None`;
1929
- return { guard: newGuard, expr: `${inner.expr}.${node.property}` };
2371
+ return { guard: newGuard, expr: linkExpr };
1930
2372
  }
1931
- return { guard: inner.guard, expr: `${inner.expr}.${node.property}` };
2373
+ return { guard: inner.guard, expr: linkExpr };
1932
2374
  }
1933
2375
  if (node.kind === 'index') {
1934
2376
  const obj = node.object;
@@ -1964,6 +2406,23 @@ function lowerChain(node, ctx) {
1964
2406
  const stdlib = applyStdlibLoweringPython(node, ctx);
1965
2407
  if (stdlib !== null)
1966
2408
  return { guard: null, expr: stdlib };
2409
+ // Lambda-bearing array methods (`map`/`filter`/`some`/`every`) lower to a
2410
+ // call-by-name comprehension. Peeked BEFORE the portable-array shim because
2411
+ // none of these four are in that shim's set; gating here keeps the two paths
2412
+ // independent and lets a non-matching shape fall through unchanged.
2413
+ const lambdaArray = lowerLambdaArrayCallPython(node, ctx);
2414
+ if (lambdaArray !== null)
2415
+ return { guard: null, expr: lambdaArray };
2416
+ // Portable array methods (e.g. `arr.push(x)`) lower through the SAME shared
2417
+ // helper the route emitter uses, so a class method's `this.items.push(x)`
2418
+ // matches a route handler's `arr.push(x)` by construction (no per-path drift).
2419
+ const portableArray = lowerPortableArrayCallPython(node, ctx);
2420
+ if (portableArray !== null)
2421
+ return { guard: null, expr: portableArray };
2422
+ if (ctx.inConstructor && node.callee.kind === 'ident' && node.callee.name === 'super') {
2423
+ const superArgs = node.args.map((arg) => emitPyExprCtx(arg, ctx)).join(', ');
2424
+ return { guard: null, expr: `super().__init__(${superArgs})` };
2425
+ }
1967
2426
  if (node.callee.kind === 'ident' && node.callee.name === 'String') {
1968
2427
  if (node.args.length !== 1) {
1969
2428
  throw new Error('String() portable coercion expects exactly one argument on Python target.');
@@ -1974,6 +2433,14 @@ function lowerChain(node, ctx) {
1974
2433
  ctx.helpers.add(KERN_FMT_HELPER_PY);
1975
2434
  return { guard: null, expr: `_kern_fmt(${arg})` };
1976
2435
  }
2436
+ // Host Error mapping, call-without-new form: JS `Error("x")` (no `new`)
2437
+ // constructs an error too — remap like `new Error(...)`, else it emits a
2438
+ // bare `Error(...)` NameError on Python (agon review, kimi 0.7). The same
2439
+ // documented user-class-named-Error shadowing edge applies (v2 hardening).
2440
+ if (node.callee.kind === 'ident' && node.callee.name === 'Error') {
2441
+ const errArgs = node.args.map((arg) => emitPyExprCtx(arg, ctx)).join(', ');
2442
+ return { guard: null, expr: `Exception(${errArgs})` };
2443
+ }
1977
2444
  const callee = node.callee;
1978
2445
  const inner = callee.kind === 'member' || callee.kind === 'call' || callee.kind === 'index'
1979
2446
  ? lowerChain(callee, ctx)
@@ -1981,6 +2448,393 @@ function lowerChain(node, ctx) {
1981
2448
  const args = node.args.map((a) => emitPyExprCtx(a, ctx)).join(', ');
1982
2449
  return { guard: inner.guard, expr: `${inner.expr}(${args})` };
1983
2450
  }
2451
+ /**
2452
+ * Lower a portable Array *method call* (e.g. `arr.push(x)`) through the shared
2453
+ * `list-ops` module, so a class-method body and a route handler lower the same
2454
+ * portable subset to identical Python. Returns `null` — and the caller falls
2455
+ * through to the generic call emission — for anything that is not a bare,
2456
+ * non-optional member call of a shared portable method on a guard-free
2457
+ * receiver. Mirrors the peek-then-emit shape of `lowerRegexCallPython`.
2458
+ */
2459
+ function lowerPortableArrayCallPython(call, ctx) {
2460
+ const callee = call.callee;
2461
+ if (callee.kind !== 'member' || callee.optional)
2462
+ return null;
2463
+ // Gate on method name BEFORE emitting receiver/args, so a non-shared call
2464
+ // falls through without any duplicated emission. Arity is NOT gated here —
2465
+ // the shared `lowerPortableArrayMethodPy` validates the arg count per method
2466
+ // (push/concat are single-arg, slice takes 0/1/2) and returns null for shapes
2467
+ // it can't lower, so a malformed call falls through unchanged. A blanket
2468
+ // `args.length !== 1` guard here would have wrongly blocked `slice()` /
2469
+ // `slice(1, 3)` (a push-shaped assumption — see spec 3a).
2470
+ if (!isSharedPortableArrayMethod(callee.property))
2471
+ return null;
2472
+ const recvNode = callee.object;
2473
+ // Per-method purity contract (scalar-method sweep). Multi-eval / mutating
2474
+ // methods (push/reverse/at/lastIndexOf) name the receiver more than once
2475
+ // (`(recv.append(x) or len(recv))`, `(recv.reverse() or recv)`), so a
2476
+ // side-effectful receiver — `makeBag().items.push(x)`, `bags[idx()].reverse()`
2477
+ // — would run those effects twice on Python and break JS parity; lower only a
2478
+ // provably-pure receiver for those. Single-eval methods
2479
+ // (slice/includes/indexOf/join/flat/concat/fill) name the receiver once, so they
2480
+ // accept an impure receiver — the old blanket `isReceiverChainPure` guard
2481
+ // wrongly skipped `makeBox().items.slice(1)` (the prior agon-review 0.97
2482
+ // finding). The optional-chain guard below still applies to ALL methods.
2483
+ if (sharedPortableMethodRequiresPureReceiver(callee.property) && !isReceiverChainPure(recvNode))
2484
+ return null;
2485
+ const recv = recvNode.kind === 'member' || recvNode.kind === 'call' || recvNode.kind === 'index'
2486
+ ? lowerChain(recvNode, ctx)
2487
+ : { guard: null, expr: emitPyExprCtx(recvNode, ctx) };
2488
+ // A pure receiver can still be an optional chain (`a?.b`), which carries a
2489
+ // None-guard the flat shim can't honor — fall through for those too.
2490
+ if (recv.guard !== null)
2491
+ return null;
2492
+ const args = call.args.map((a) => (callee.property === 'fill' ? emitPyArrayFillArg(a, ctx) : emitPyExprCtx(a, ctx)));
2493
+ const lowered = lowerPortableArrayMethodPy(recv.expr, callee.property, args);
2494
+ if (lowered !== null && callee.property === 'fill') {
2495
+ ctx.helpers.add(KERN_JS_ARRAY_HELPERS_PY);
2496
+ }
2497
+ return lowered;
2498
+ }
2499
+ function emitPyArrayFillArg(node, ctx) {
2500
+ if (node.kind === 'undefLit')
2501
+ return '_KERN_UNDEFINED';
2502
+ return emitPyExprCtx(node, ctx);
2503
+ }
2504
+ /** Methods this peek lowers to a call-by-name comprehension. `reduce`/
2505
+ * `reduceRight` share the call-by-name callback resolution but lower to
2506
+ * `functools.reduce` (a different arg-count/arity contract), so they live in
2507
+ * `REDUCE_ARRAY_METHODS` and route through `lowerReduceArrayCallPython`.
2508
+ * `sort`-with-comparator remains DEFERRED (see slice report). */
2509
+ const LAMBDA_ARRAY_METHODS = new Set([
2510
+ 'map',
2511
+ 'filter',
2512
+ 'some',
2513
+ 'every',
2514
+ // find-family + flatMap: same call-by-name comprehension architecture as
2515
+ // map/filter (callback 1 or 2 params; the 2-param form binds the index via
2516
+ // enumerate). The find-family wraps the predicate in `js_truthy`.
2517
+ 'find',
2518
+ 'findIndex',
2519
+ 'findLast',
2520
+ 'findLastIndex',
2521
+ 'flatMap',
2522
+ ]);
2523
+ /** reduce/reduceRight take a callback of EXACTLY 2 params (acc, cur) and the
2524
+ * CALL itself carries 1 arg (cb) or 2 (cb, seed) — a different arg-count and
2525
+ * callback-arity contract than the enumerate-comprehension methods above, so
2526
+ * they are dispatched on their own path inside `lowerLambdaArrayCallPython`. */
2527
+ const REDUCE_ARRAY_METHODS = new Set(['reduce', 'reduceRight']);
2528
+ /**
2529
+ * Lower a lambda-bearing Array method call (`recv.map(cb)` / `.filter` / `.some`
2530
+ * / `.every`) on the class/native-body Python path to a call-by-name
2531
+ * comprehension. Returns `null` — and the caller falls through to the generic
2532
+ * call emission — for any shape this peek does not own. Peeked at the same
2533
+ * dispatch point as `lowerPortableArrayCallPython` (these four methods are NOT
2534
+ * in that shim's set).
2535
+ *
2536
+ * GATE (all required): a non-optional `member` callee, method ∈
2537
+ * {map,filter,some,every}, exactly one call arg, and that arg is a `lambda`
2538
+ * (expression- or block-bodied) OR a bare `ident`.
2539
+ *
2540
+ * Callback shapes resolved to a Python NAME `cb`:
2541
+ * - block lambda → `emitPyExprCtx` returns the hoisted `def __kern_closure_N`
2542
+ * name (closures v1), and the def is pushed into
2543
+ * `ctx.pendingHoists` (flushed by emitChildrenPy before the
2544
+ * enclosing statement).
2545
+ * - expr lambda → emit `lambda x: <expr>`, hoist ONE assignment
2546
+ * `__kern_cb_N = <lambda>` into the SAME `ctx.pendingHoists`
2547
+ * buffer; `cb` = `__kern_cb_N`.
2548
+ * - bare ident → the rename-resolved identifier as-is. Arity is unknown for
2549
+ * a named callback, so it is always called single-arg. JS
2550
+ * would pass `(el, idx, arr)`; a named callback that reads a
2551
+ * 2nd/3rd param diverges — an ACCEPTED edge for this slice.
2552
+ *
2553
+ * MEMBER-EXPRESSION callbacks (`this.items.map(this.fmt)`) FALL THROUGH verbatim
2554
+ * (the arg is a `member`, not `lambda`/`ident`, so the gate rejects it). This is
2555
+ * deliberate: JS `.map(this.fmt)` passes the method UNBOUND (`this` is undefined
2556
+ * inside it), so the TS target is already broken for such code. Lowering it on
2557
+ * Python would create works-on-Python / breaks-on-TS anti-parity; until KERN
2558
+ * defines a bound-method story there is nothing to be parity-correct WITH.
2559
+ *
2560
+ * TRUTHINESS: filter/some/every wrap the predicate result in `js_truthy(...)`.
2561
+ * This is JS-CORRECT — JS keeps `[]`/`{}` truthy while bare Python drops them.
2562
+ * Two VERIFIED pre-existing divergences are intentionally NOT fixed here:
2563
+ * (1) the ROUTE path's filter/find-family predicates are BARE (`if ${body}`,
2564
+ * core/expr/index.ts ~:294) — a pre-existing route truthiness bug, a
2565
+ * follow-up for the deferral-sweep slice;
2566
+ * (2) the class path's `if cond=` lowering is bare (no js_truthy wrapper) — a
2567
+ * separate follow-up.
2568
+ * `js_truthy` here matches JS semantics for the lambda-array predicates only.
2569
+ */
2570
+ function lowerLambdaArrayCallPython(call, ctx) {
2571
+ // SCOPE GATE (do not remove). This lowering is for the class/native-body
2572
+ // statement path (`emitNativeKernBodyPythonWithImports`, standaloneExpression
2573
+ // = false). The standalone-expression entry point (`emitPyExpression`) is the
2574
+ // Ground/React declarative + expression-unit surface; it emits array methods
2575
+ // VERBATIM (e.g. `values.filter(lambda value: ...)`) by design, so do NOT
2576
+ // intercept there. The FastAPI ROUTE path lowers `.map`/`.filter` through a
2577
+ // SEPARATE string-rewrite (`core/expr` `rewriteExpr`/`lowerJsArrayMethods`),
2578
+ // never this IR `lowerChain` peek, so routes are untouched.
2579
+ if (ctx.standaloneExpression)
2580
+ return null;
2581
+ const callee = call.callee;
2582
+ if (callee.kind !== 'member' || callee.optional)
2583
+ return null;
2584
+ const method = callee.property;
2585
+ const isReduce = REDUCE_ARRAY_METHODS.has(method);
2586
+ if (!LAMBDA_ARRAY_METHODS.has(method) && !isReduce)
2587
+ return null;
2588
+ // reduce/reduceRight have a DISTINCT contract (callback EXACTLY 2 params; the
2589
+ // call carries 1 arg [cb] or 2 [cb, seed]) and lower to `functools.reduce`,
2590
+ // not a comprehension. Handle them on their own path before the shared
2591
+ // enumerate-comprehension machinery below. `callee.object` is the receiver.
2592
+ if (isReduce)
2593
+ return lowerReduceArrayCallPython(call, callee.object, method, ctx);
2594
+ if (call.args.length !== 1)
2595
+ return null;
2596
+ const arg = call.args[0];
2597
+ if (arg.kind !== 'lambda' && arg.kind !== 'ident')
2598
+ return null;
2599
+ // Resolve the callback to a NAME. For a lambda the arg arity decides the
2600
+ // comprehension form (enumerate when 2 params); a bare ident is arity-unknown
2601
+ // and always called single-arg (documented divergence above).
2602
+ let cb;
2603
+ let twoArity = false;
2604
+ if (arg.kind === 'lambda') {
2605
+ // The enumerate comprehension only supplies (el, i); a callback declaring a
2606
+ // 3rd param (`(el, i, arr) => …`) would be DEFINED with 3 params but CALLED
2607
+ // with 2 → runtime TypeError. Fall through verbatim (the pre-slice status quo
2608
+ // for that shape) rather than emit a broken lowering.
2609
+ if (arg.params.length > 2)
2610
+ return null;
2611
+ twoArity = arg.params.length >= 2;
2612
+ const emitted = emitLambdaPy(arg, ctx);
2613
+ if (arg.bodyBlock) {
2614
+ // Block lambda → `emitLambdaPy` already pushed the hoisted def and
2615
+ // returned its bare name; use it directly as the callback name.
2616
+ cb = emitted;
2617
+ }
2618
+ else {
2619
+ // Expression lambda → `emitted` is a `lambda x: <expr>`. Hoist ONE
2620
+ // assignment into the SAME buffer as closure defs (flushed before the
2621
+ // enclosing statement) and call it by name, so the comprehension names
2622
+ // the callback exactly once.
2623
+ cb = `__kern_cb_${ctx.closureSeq++}`;
2624
+ ctx.pendingHoists.push([`${cb} = ${emitted}`]);
2625
+ }
2626
+ }
2627
+ else {
2628
+ // Bare LOCAL ident callback (`let f = …; recv.map(f)`). Emit through the
2629
+ // ident path so a renamed binding resolves to its Python name.
2630
+ cb = emitPyExprCtx(arg, ctx);
2631
+ }
2632
+ // Receiver: lowered ONCE. Every template below names `recv` EXACTLY ONCE, so
2633
+ // there is NO purity gate here (deliberate contrast with the multi-eval
2634
+ // list-ops methods, which DO gate — see `lowerPortableArrayCallPython`).
2635
+ // FUTURE READER: do not add a redundant `isReceiverChainPure` gate; the
2636
+ // single-eval property is what makes M6 (`this.bump().map(...)` runs bump()
2637
+ // exactly once) correct.
2638
+ const recvNode = callee.object;
2639
+ const recv = recvNode.kind === 'member' || recvNode.kind === 'call' || recvNode.kind === 'index'
2640
+ ? lowerChain(recvNode, ctx)
2641
+ : { guard: null, expr: emitPyExprCtx(recvNode, ctx) };
2642
+ // An optional-chain receiver (`a?.b`) carries a None-guard the comprehension
2643
+ // can't honor — fall through unchanged for those.
2644
+ if (recv.guard !== null)
2645
+ return null;
2646
+ // A compound receiver (a ternary `xs if c else ys`, a binary expression)
2647
+ // dropped bare into a generator head parses WRONG: Python reads the `if`
2648
+ // as the comprehension filter (`for el in xs if c else ys` → SyntaxError) —
2649
+ // agon review, agy 1.0. Call-arg positions (`enumerate(recv)`) are immune;
2650
+ // the bare `for … in recv` heads and `recv[::-1]` are not. Space-heuristic
2651
+ // parens: atoms (`self.items`, `makeBox().items`, `[1, 2]`?) — note a
2652
+ // bracketed literal contains ', ' but leading `[`/`(` already self-delimits;
2653
+ // anything else with top-level spaces gets wrapped. Parens are never wrong,
2654
+ // the heuristic only preserves byte-identical output for the common shapes.
2655
+ const recvExpr = parenthesizeIterable(recv.expr);
2656
+ // Hygienic loop vars, fresh per call.
2657
+ const seq = ctx.gensymCounter++;
2658
+ const el = `__kern_el_${seq}`;
2659
+ const ix = `__kern_ix_${seq}`;
2660
+ const callCb = twoArity ? `${cb}(${el}, ${ix})` : `${cb}(${el})`;
2661
+ const head = twoArity ? `for ${ix}, ${el} in enumerate(${recvExpr})` : `for ${el} in ${recvExpr}`;
2662
+ if (method === 'map') {
2663
+ return `[${callCb} ${head}]`;
2664
+ }
2665
+ if (method === 'flatMap') {
2666
+ // map, then flatten ONE level — JS flatMap only flattens arrays, so a
2667
+ // scalar/string callback result is appended as a single element. The
2668
+ // callback is bound to a fresh `__kern_r_N` via a one-element `for`, so it
2669
+ // is called EXACTLY ONCE per element. This is deliberately BETTER than the
2670
+ // FastAPI route's body-substitution lowering (core/expr/index.ts ~:336),
2671
+ // which textually re-emits the callback body twice (`__x` substituted in
2672
+ // both the isinstance test and the fallback) and double-evaluates a
2673
+ // side-effecting callback — a tracked route follow-up. flatMap has no
2674
+ // predicate, so NO js_truthy here.
2675
+ const r = `__kern_r_${seq}`;
2676
+ const y = `__kern_y_${seq}`;
2677
+ return `[${y} ${head} for ${r} in [${callCb}] for ${y} in (${r} if isinstance(${r}, list) else [${r}])]`;
2678
+ }
2679
+ // filter + find-family predicates wrap in `js_truthy` (JS-correct; map/flatMap
2680
+ // do not need it). The helper lands once via the helper Set.
2681
+ ctx.helpers.add(KERN_JS_HELPER_PY);
2682
+ if (method === 'filter') {
2683
+ return `[${el} ${head} if js_truthy(${callCb})]`;
2684
+ }
2685
+ if (method === 'some') {
2686
+ return `any(js_truthy(${callCb}) ${head})`;
2687
+ }
2688
+ if (method === 'every') {
2689
+ return `all(js_truthy(${callCb}) ${head})`;
2690
+ }
2691
+ // find-family — `next((<gen>), <miss>)` never raises. find/findLast yield the
2692
+ // ELEMENT (miss → None); findIndex/findLastIndex yield the INDEX (miss → -1).
2693
+ // The *Last variants scan a reversed view; with a 2-param callback the index
2694
+ // must come from enumerate, so they reverse `list(enumerate(recv))` (mirrors
2695
+ // the route's findLast/findLastIndex shape).
2696
+ if (method === 'find') {
2697
+ return `next((${el} ${head} if js_truthy(${callCb})), None)`;
2698
+ }
2699
+ if (method === 'findIndex') {
2700
+ // The index is always bound here, so iterate enumerate(recv) even for a
2701
+ // 1-param callback (which is called single-arg on the element).
2702
+ const enumHead = `for ${ix}, ${el} in enumerate(${recvExpr})`;
2703
+ return `next((${ix} ${enumHead} if js_truthy(${callCb})), -1)`;
2704
+ }
2705
+ if (method === 'findLast') {
2706
+ const revHead = twoArity
2707
+ ? `for ${ix}, ${el} in reversed(list(enumerate(${recvExpr})))`
2708
+ : `for ${el} in reversed(${recvExpr})`;
2709
+ return `next((${el} ${revHead} if js_truthy(${callCb})), None)`;
2710
+ }
2711
+ // method === 'findLastIndex'
2712
+ const revIndexHead = `for ${ix}, ${el} in reversed(list(enumerate(${recvExpr})))`;
2713
+ return `next((${ix} ${revIndexHead} if js_truthy(${callCb})), -1)`;
2714
+ }
2715
+ /** Lower `recv.reduce(cb)` / `.reduce(cb, seed)` / `.reduceRight(...)` on the
2716
+ * class/native-body Python path to `functools.reduce`. Returns `null` (caller
2717
+ * falls through to verbatim emission) for any shape this peek does not own.
2718
+ *
2719
+ * CONTRACT (all required): a non-optional `member` callee already checked by
2720
+ * the caller; the call carries 1 arg (cb) or 2 (cb, seed); the callback is a
2721
+ * `lambda` with EXACTLY 2 params (acc, cur) OR a bare `ident` (arity unknown —
2722
+ * accepted, called with two args by functools.reduce). A 1- or 3+-param lambda
2723
+ * callback (e.g. an idx-reading `(a, c, i) =>`) FALLS THROUGH verbatim (status
2724
+ * quo) rather than emit a callback functools.reduce would call with the wrong
2725
+ * arity.
2726
+ *
2727
+ * JS `reduce((acc, cur) => …)` arg order == `functools.reduce(fn(acc, cur), …)`,
2728
+ * so NO adaptation is needed (asserted by the order-sensitive FR7 fixture).
2729
+ * reduceRight is the same callback over the reversed sequence (`recv[::-1]`).
2730
+ *
2731
+ * PARITY NOTE: JS `[].reduce(cb)` (no seed, empty array) throws TypeError;
2732
+ * Python `functools.reduce(cb, [])` raises TypeError too — same failure mode,
2733
+ * documented parity (not a divergence).
2734
+ *
2735
+ * IMPORT: `functools` is registered via `ctx.imports.add('functools')`, which
2736
+ * the fn/method generators render as `import functools as __k_functools` (the
2737
+ * same alias convention as `re` → `__k_re`); the template names
2738
+ * `__k_functools.reduce`. The Set dedupes, so the import lands exactly once per
2739
+ * body. (The FastAPI route path uses a SEPARATE imports channel that emits a
2740
+ * bare `import functools`; this class path is untouched by that.) */
2741
+ function lowerReduceArrayCallPython(call, recvNode, method, ctx) {
2742
+ if (call.args.length !== 1 && call.args.length !== 2)
2743
+ return null;
2744
+ const cbArg = call.args[0];
2745
+ if (cbArg.kind !== 'lambda' && cbArg.kind !== 'ident')
2746
+ return null;
2747
+ // Member-expression callbacks fall through verbatim (same unbound-this policy
2748
+ // as the comprehension methods) — the gate above already rejects them.
2749
+ let cb;
2750
+ if (cbArg.kind === 'lambda') {
2751
+ // EXACTLY 2 params (acc, cur). A 1-param or 3+-param (idx-reading) callback
2752
+ // would be mis-called by functools.reduce — fall through verbatim.
2753
+ if (cbArg.params.length !== 2)
2754
+ return null;
2755
+ const emitted = emitLambdaPy(cbArg, ctx);
2756
+ if (cbArg.bodyBlock) {
2757
+ // Block lambda → hoisted def name from emitLambdaPy; use it directly.
2758
+ cb = emitted;
2759
+ }
2760
+ else {
2761
+ // Expression lambda → hoist `__kern_cb_N = lambda a, c: <expr>` and pass
2762
+ // the name, so functools.reduce names the callback exactly once.
2763
+ cb = `__kern_cb_${ctx.closureSeq++}`;
2764
+ ctx.pendingHoists.push([`${cb} = ${emitted}`]);
2765
+ }
2766
+ }
2767
+ else {
2768
+ // Bare LOCAL ident callback — resolve through the ident path.
2769
+ cb = emitPyExprCtx(cbArg, ctx);
2770
+ }
2771
+ // Receiver: lowered ONCE (same single-eval property as the comprehension
2772
+ // methods — no purity gate). `recvNode` is the caller's already-narrowed
2773
+ // `callee.object`.
2774
+ const recv = recvNode.kind === 'member' || recvNode.kind === 'call' || recvNode.kind === 'index'
2775
+ ? lowerChain(recvNode, ctx)
2776
+ : { guard: null, expr: emitPyExprCtx(recvNode, ctx) };
2777
+ if (recv.guard !== null)
2778
+ return null;
2779
+ // reduceRight reverses the sequence; reduce uses it as-is. The slice binds
2780
+ // TIGHTER than ternary/binary receivers (`xs if c else ys[::-1]` slices only
2781
+ // `ys` — agon review, agy 1.0), so the receiver is parenthesized when
2782
+ // compound. The plain-reduce position is a call argument (self-delimiting),
2783
+ // but wrap uniformly so both methods agree on the receiver text.
2784
+ const recvExpr = parenthesizeIterable(recv.expr);
2785
+ const seq = method === 'reduceRight' ? `${recvExpr}[::-1]` : recvExpr;
2786
+ ctx.imports.add('functools');
2787
+ if (call.args.length === 2) {
2788
+ const seed = emitPyExprCtx(call.args[1], ctx);
2789
+ return `__k_functools.reduce(${cb}, ${seq}, ${seed})`;
2790
+ }
2791
+ return `__k_functools.reduce(${cb}, ${seq})`;
2792
+ }
2793
+ /** Parenthesize a lowered receiver for generator-head (`for el in <recv>`) and
2794
+ * slice (`<recv>[::-1]`) positions, where a compound expression parses wrong
2795
+ * (a bare ternary's `if` reads as the comprehension filter — SyntaxError; a
2796
+ * slice binds only the rightmost operand). Space heuristic: an expression
2797
+ * with no top-level spaces is an atom/chain (`self.items`, `makeBox().items`,
2798
+ * `xs[1:]`) and stays bare (byte-identical to pre-fix output); one that
2799
+ * starts with a self-delimiting bracket also stays bare; anything else wraps.
2800
+ * Parens are never semantically wrong — the heuristic only preserves
2801
+ * idiomatic output for the common shapes. */
2802
+ function parenthesizeIterable(expr) {
2803
+ if (!expr.includes(' '))
2804
+ return expr;
2805
+ // A leading bracket is only self-delimiting if it CLOSES at the very end
2806
+ // (`[1, 2, 3]` yes; `[1] if c else [2]` no — same opener, not enclosing).
2807
+ const open = expr[0];
2808
+ const close = open === '[' ? ']' : open === '(' ? ')' : open === '{' ? '}' : null;
2809
+ if (close !== null && expr[expr.length - 1] === close) {
2810
+ let depth = 0;
2811
+ let quote = null;
2812
+ for (let i = 0; i < expr.length; i++) {
2813
+ const ch = expr[i];
2814
+ if (quote) {
2815
+ if (ch === '\\')
2816
+ i++;
2817
+ else if (ch === quote)
2818
+ quote = null;
2819
+ continue;
2820
+ }
2821
+ if (ch === '"' || ch === "'")
2822
+ quote = ch;
2823
+ else if (ch === '[' || ch === '(' || ch === '{')
2824
+ depth++;
2825
+ else if (ch === ']' || ch === ')' || ch === '}') {
2826
+ depth--;
2827
+ // Depth returns to 0 before the end → the leading bracket does NOT
2828
+ // enclose the whole expression.
2829
+ if (depth === 0 && i < expr.length - 1)
2830
+ return `(${expr})`;
2831
+ }
2832
+ }
2833
+ if (depth === 0)
2834
+ return expr;
2835
+ }
2836
+ return `(${expr})`;
2837
+ }
1984
2838
  function lowerRegexCallPython(call, ctx) {
1985
2839
  const callee = call.callee;
1986
2840
  if (callee.kind !== 'member')
@@ -2180,6 +3034,12 @@ function lowerListLambdaPython(moduleName, methodName, call, ctx) {
2180
3034
  if (callback.params.length !== 1) {
2181
3035
  throw new Error(`List.${methodName} expects a one-parameter lambda on the Python target.`);
2182
3036
  }
3037
+ // Block-bodied arrow callback: the comprehension lowering only handles an
3038
+ // expression `body`. Fall through (null) so the lambda routes through the
3039
+ // default call path → `emitLambdaPy` (which fails closed in commit A and
3040
+ // hoists a local def in commit B).
3041
+ if (!callback.body)
3042
+ return null;
2183
3043
  const name = callback.params[0].name;
2184
3044
  const previous = new Set(ctx.shadowedSymbols);
2185
3045
  ctx.shadowedSymbols.add(name);
@@ -2260,7 +3120,10 @@ export function lowerBitwiseAndModuloAST(node) {
2260
3120
  args: node.args.map(lowerBitwiseAndModuloAST),
2261
3121
  };
2262
3122
  case 'lambda':
2263
- return { ...node, body: lowerBitwiseAndModuloAST(node.body) };
3123
+ // Block-bodied arrows carry raw text, not an expression `body`. The raw
3124
+ // is re-parsed and lowered during closure emission (commit B), so leave
3125
+ // it untouched here.
3126
+ return node.bodyBlock ? node : { ...node, body: lowerBitwiseAndModuloAST(node.body) };
2264
3127
  case 'spread':
2265
3128
  return { ...node, argument: lowerBitwiseAndModuloAST(node.argument) };
2266
3129
  case 'await':
@@ -2337,7 +3200,10 @@ export function registerHelpers(node, ctx) {
2337
3200
  registerHelpers(node.index, ctx);
2338
3201
  break;
2339
3202
  case 'lambda':
2340
- registerHelpers(node.body, ctx);
3203
+ // Block-bodied arrows have no expression `body`; helpers referenced by a
3204
+ // block body are registered during closure emission (commit B).
3205
+ if (node.body)
3206
+ registerHelpers(node.body, ctx);
2341
3207
  break;
2342
3208
  case 'spread':
2343
3209
  registerHelpers(node.argument, ctx);