@kernlang/python 4.1.1-canary.228.1.41c150f2 → 4.1.1-canary.232.1.c7f7511c

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,7 +40,7 @@
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, assertNoDecimalOperator, classifyRegexLiteralIndexReadFailClose, classifyRegexLiteralMemberReadFailClose, decimalBareConstructionFailMessage, emitStringKeyArray, expandRegexIFold, instanceofRhsPythonType, instanceofRhsRejectReasonForName, isHostNamespaceRoot, isPostfixMutationOperator, isSupportedAssignOperator, isZeroWidthCapableRegex, KERN_DECIMAL_OPS_HELPER_PY, KERN_STDLIB_MODULES, KERN_TEXT_OPS_HELPER_PY, lookupStdlibCall, lookupStdlibProperty, lowerRegexAnchorsPython, lowerRegexNamedGroupsPython, needsArgParens, needsBinaryParens, normalizeRegexClasses, parseExpression, parseKeys, REGEX_EXEC_FAILCLOSE, REGEX_HOST_REGEXP_FAILCLOSE, REGEX_MATCHALL_NO_G_FAILCLOSE, REGEX_NONLITERAL_FAILCLOSE, REGEX_REPLACE_NONLITERAL_REPL_FAILCLOSE, REGEX_REPLACEALL_NO_G_FAILCLOSE, REGEX_SPLIT_LIMIT_FAILCLOSE, REGEX_SPLIT_ZEROWIDTH_FAILCLOSE, REGEX_TEST_G_FAILCLOSE, regexAstralFailMessage, regexCaptureMeta, regexIFoldFailMessage, regexLiteralReceiverIR, regexMethodRegexArgIdent, scanRegexAstral, suggestStdlibMethod, translateReplStringToPython, unmappedHostNamespaceMessage, unwrapTransparentReceiverIR, validateDecimalConstructionArg, validateDecimalDivModArgs, validateDecimalOperands, validateDecimalPowArgs, validateRegexNamedGroupsPortable, } from '@kernlang/core';
43
+ import { applyTemplate, assertNoDecimalOperator, classifyRegexLiteralIndexReadFailClose, classifyRegexLiteralMemberReadFailClose, decimalBareConstructionFailMessage, emitStringKeyArray, expandRegexIFold, instanceofRhsPythonType, instanceofRhsRejectReasonForName, isHostNamespaceRoot, isParenthesized, isPostfixMutationOperator, isSafeIntegerLiteralIndex, isSupportedAssignOperator, isZeroWidthCapableRegex, KERN_DECIMAL_OPS_HELPER_PY, KERN_STDLIB_MODULES, KERN_TEXT_OPS_HELPER_PY, lookupStdlibCall, lookupStdlibProperty, lowerRegexAnchorsPython, lowerRegexNamedGroupsPython, needsArgParens, needsBinaryParens, normalizeRegexClasses, parseExpression, parseKeys, REGEX_EXEC_FAILCLOSE, REGEX_HOST_REGEXP_FAILCLOSE, REGEX_MATCHALL_NO_G_FAILCLOSE, REGEX_NONLITERAL_FAILCLOSE, REGEX_REPLACE_NONLITERAL_REPL_FAILCLOSE, REGEX_REPLACEALL_NO_G_FAILCLOSE, REGEX_SPLIT_LIMIT_FAILCLOSE, REGEX_SPLIT_ZEROWIDTH_FAILCLOSE, REGEX_TEST_G_FAILCLOSE, regexAstralFailMessage, regexCaptureMeta, regexIFoldFailMessage, regexLiteralReceiverIR, regexMethodRegexArgIdent, scanRegexAstral, suggestStdlibMethod, translateReplStringToPython, unmappedHostNamespaceMessage, unwrapTransparentReceiverIR, validateDecimalConstructionArg, validateDecimalDivModArgs, validateDecimalOperands, validateDecimalPowArgs, validateRegexNamedGroupsPortable, } from '@kernlang/core';
44
44
  // Slice 0.9 — the TypeScript-AST closure helpers + classifier live on the Node
45
45
  // subpath (the barrel is browser-safe). Python codegen is Node-side and parses
46
46
  // block-bodied arrows, so it injects `typescriptClosureClassifier`.
@@ -48,6 +48,7 @@ import { collectFreeIdentifierNames, lowerJsClosureBodyToPython, typescriptClosu
48
48
  import { buildPythonParamList } from './codegen-helpers.js';
49
49
  import { KERN_FMT_HELPER_PY, KERN_JS_ARRAY_FROM_HELPER_PY, KERN_JS_ARRAY_HELPERS_PY, KERN_JS_HELPER_PY, KERN_JS_MATH_HELPERS_PY, KERN_JS_NUMBER_HELPERS_PY, KERN_JS_OBJECT_HELPERS_PY, KERN_JSON_STRINGIFY_SHIM_PY, KERN_NULLISH_HELPER_PY, KERN_PAIR_HELPERS_PY, KERN_REGEX_MATCH_HELPER_PY, KERN_REGEX_MATCHALL_HELPER_PY, KERN_TMOD_HELPER_PY, KERN_TO_NUMBER_HELPER_PY, } from './core/expr/index.js';
50
50
  import { isSharedPortableArrayMethod, isSharedPortableArrayProperty, lowerPortableArrayMethodPy, lowerPortableArrayPropertyPy, sharedPortableMethodRequiresPureReceiver, } from './core/expr/list-ops.js';
51
+ import { DOT_DICT_SHIM_PY } from './targets/python.js';
51
52
  import { mapTsTypeToPython } from './type-map.js';
52
53
  /** Parse options for Python codegen — always inject the TypeScript-backed
53
54
  * closure classifier so block-bodied arrows parse (slice 0.9). */
@@ -58,6 +59,42 @@ const TS_PARSE_OPTS = { closureClassifier: typescriptClosureClassifier };
58
59
  function parseExpr(input) {
59
60
  return parseExpression(input, TS_PARSE_OPTS);
60
61
  }
62
+ /** Property-specific fail-close for a non-length read/call on a nested record
63
+ * array field — message LOCKSTEP with the TS twin (`nestedArrayMemberThrowTS`
64
+ * in codegen-expression.ts): 'has no portable property "<name>"'. */
65
+ const KERN_NESTED_NO_PROPERTY_HELPER_PY = `def _kern_nested_no_property(path, prop):
66
+ raise Exception('portable: nested array field "' + path + '" has no portable property "' + prop + '"')
67
+ `;
68
+ const KERN_NESTED_ARRAY_HELPER_PY = `def _kern_nested_array_value(record, field, index=None):
69
+ if not isinstance(record, dict) or field not in record:
70
+ raise Exception("portable: nested array receiver must be a record field")
71
+ value = record[field]
72
+ if not isinstance(value, list):
73
+ raise Exception("portable: nested record field must be an array")
74
+ if index is None:
75
+ return len(value)
76
+ if not isinstance(index, int) or isinstance(index, bool) or index < 0 or index >= len(value):
77
+ raise Exception("portable: nested array index must be an in-bounds non-negative safe integer")
78
+ out = value[index]
79
+ if not (out is None or isinstance(out, str) or isinstance(out, bool) or (isinstance(out, (int, float)) and not isinstance(out, bool) and out == out and out != float("inf") and out != float("-inf"))):
80
+ raise Exception("portable: nested array element must be a portable scalar")
81
+ return out
82
+ `;
83
+ const KERN_NESTED_ARRAY_REF_HELPER_PY = `def _kern_nested_array_ref(record, field):
84
+ if not isinstance(record, dict) or field not in record:
85
+ raise Exception("portable: nested array receiver must be a record field")
86
+ value = record[field]
87
+ if not isinstance(value, list):
88
+ raise Exception("portable: nested record field must be an array")
89
+ return value
90
+ `;
91
+ const KERN_NESTED_ARRAY_ITER_HELPER_PY = `def _kern_nested_array_iter(record, field):
92
+ value = _kern_nested_array_ref(record, field)
93
+ for out in value:
94
+ if not (out is None or isinstance(out, str) or isinstance(out, bool) or (isinstance(out, (int, float)) and not isinstance(out, bool) and out == out and out != float("inf") and out != float("-inf"))):
95
+ raise Exception("portable: nested array element must be a portable scalar")
96
+ return value
97
+ `;
61
98
  const INDENT_STEP = ' ';
62
99
  function freshCtx(options) {
63
100
  return {
@@ -70,6 +107,12 @@ function freshCtx(options) {
70
107
  shadowedSymbols: new Set(),
71
108
  localScopes: [],
72
109
  regexScopes: [],
110
+ recordScopes: [],
111
+ recordArrayFieldScopes: [],
112
+ recordScalarArrayFieldScopes: [],
113
+ maybeRecordArrayFieldScopes: [],
114
+ arrayBindingScopes: [],
115
+ scalarArrayBindingScopes: [],
73
116
  renameStack: [],
74
117
  propagateStyle: options?.propagateStyle ?? 'value',
75
118
  usedPropagation: false,
@@ -169,6 +212,12 @@ export function emitNativeKernBodyPythonWithImports(handlerNode, options) {
169
212
  // no regex literal was assigned to it). Mirroring it here keeps regex
170
213
  // and local-binding scope stacks index-aligned.
171
214
  ctx.regexScopes.push(new Map(outerBindings.map((n) => [n, null])));
215
+ ctx.recordScopes.push(new Map(outerBindings.map((n) => [n, false])));
216
+ ctx.recordArrayFieldScopes.push(new Map(outerBindings.map((n) => [n, null])));
217
+ ctx.recordScalarArrayFieldScopes.push(new Map(outerBindings.map((n) => [n, null])));
218
+ ctx.maybeRecordArrayFieldScopes.push(new Map(outerBindings.map((n) => [n, null])));
219
+ ctx.arrayBindingScopes.push(new Map());
220
+ ctx.scalarArrayBindingScopes.push(new Map());
172
221
  ctx.renameStack.push(new Map());
173
222
  }
174
223
  try {
@@ -186,6 +235,12 @@ export function emitNativeKernBodyPythonWithImports(handlerNode, options) {
186
235
  if (outerBindings.length > 0) {
187
236
  ctx.localScopes.pop();
188
237
  ctx.regexScopes.pop();
238
+ ctx.recordScopes.pop();
239
+ ctx.recordArrayFieldScopes.pop();
240
+ ctx.recordScalarArrayFieldScopes.pop();
241
+ ctx.maybeRecordArrayFieldScopes.pop();
242
+ ctx.arrayBindingScopes.pop();
243
+ ctx.scalarArrayBindingScopes.pop();
189
244
  ctx.renameStack.pop();
190
245
  }
191
246
  }
@@ -259,10 +314,24 @@ function collectLoopAssignLastIndexes(children) {
259
314
  scan(children[i], i);
260
315
  return last;
261
316
  }
317
+ function childrenCanFallThrough(children) {
318
+ for (const child of children) {
319
+ if (child.type === 'return' || child.type === 'throw' || child.type === 'break' || child.type === 'continue') {
320
+ return false;
321
+ }
322
+ }
323
+ return true;
324
+ }
262
325
  function emitChildrenPy(children, ctx, indent, initialBindings = [], isLoopBody = false) {
263
326
  const lines = [];
264
327
  ctx.localScopes.push(new Map(initialBindings));
265
328
  ctx.regexScopes.push(new Map(initialBindings.map(([name]) => [name, null])));
329
+ ctx.recordScopes.push(new Map(initialBindings.map(([name]) => [name, false])));
330
+ ctx.recordArrayFieldScopes.push(new Map(initialBindings.map(([name]) => [name, null])));
331
+ ctx.recordScalarArrayFieldScopes.push(new Map(initialBindings.map(([name]) => [name, null])));
332
+ ctx.maybeRecordArrayFieldScopes.push(new Map(initialBindings.map(([name]) => [name, null])));
333
+ ctx.arrayBindingScopes.push(new Map());
334
+ ctx.scalarArrayBindingScopes.push(new Map());
266
335
  ctx.renameStack.push(new Map());
267
336
  // Slice-2 loop-variable pinning. When this recursion is a loop BODY, record
268
337
  // the just-pushed scope's index so `emitBlockClosurePy` can decide whether a
@@ -330,6 +399,9 @@ function emitChildrenPy(children, ctx, indent, initialBindings = [], isLoopBody
330
399
  for (const line of emitFnPy(child, ctx, indent))
331
400
  lines.push(line);
332
401
  }
402
+ else if (child.type === 'capability') {
403
+ throw new Error('capability nodes are not supported in emitted TypeScript/Python until an emitted capability ABI exists');
404
+ }
333
405
  else if (child.type === 'assign') {
334
406
  // Same shadowing guard for a bare-identifier assignment target: after
335
407
  // `assign target="e" …`, `e` is no longer the caught error.
@@ -391,11 +463,18 @@ function emitChildrenPy(children, ctx, indent, initialBindings = [], isLoopBody
391
463
  // Wrap UNCONDITIONALLY (no "looks boolean" skip-analysis in v1).
392
464
  ctx.helpers.add(KERN_JS_HELPER_PY);
393
465
  lines.push(`${indent}if _kern_truthy(${emitPyExprCtx(condIR, ctx)}):`);
466
+ const branchBase = cloneBranchBindingScopes(ctx);
467
+ const branchOutcomes = [];
468
+ let remainingBranchReachable = !isStaticBooleanLiteral(condIR, true);
469
+ restoreBranchBindingScopes(ctx, branchBase);
394
470
  const inner = emitChildrenPy(child.children ?? [], ctx, indent + INDENT_STEP);
395
471
  if (inner.length === 0)
396
472
  lines.push(`${indent}${INDENT_STEP}pass`);
397
473
  for (const sl of inner)
398
474
  lines.push(sl);
475
+ if (!isStaticBooleanLiteral(condIR, false) && childrenCanFallThrough(child.children ?? [])) {
476
+ branchOutcomes.push(cloneBranchBindingScopes(ctx));
477
+ }
399
478
  // Walk the `else` chain. Recognised shapes for `else`:
400
479
  // 1. else > [if, else_inner] → emit `elif`, recurse on else_inner
401
480
  // 2. else > [if] → terminal `elif` with no else
@@ -405,10 +484,12 @@ function emitChildrenPy(children, ctx, indent, initialBindings = [], isLoopBody
405
484
  let elseCandidate = children[i + 1];
406
485
  if (elseCandidate?.type === 'else')
407
486
  i++;
487
+ let hasTerminalElse = false;
408
488
  while (elseCandidate && elseCandidate.type === 'else') {
409
489
  const ec = elseCandidate.children ?? [];
410
490
  const isChainable = ec.length >= 1 && ec[0].type === 'if' && (ec.length === 1 || (ec.length === 2 && ec[1].type === 'else'));
411
491
  if (isChainable) {
492
+ restoreBranchBindingScopes(ctx, branchBase);
412
493
  const ifNode = ec[0];
413
494
  const nestedCondRaw = String(ifNode.props?.cond ?? '');
414
495
  const nestedCondIR = parseExpr(nestedCondRaw);
@@ -423,18 +504,33 @@ function emitChildrenPy(children, ctx, indent, initialBindings = [], isLoopBody
423
504
  lines.push(`${indent}${INDENT_STEP}pass`);
424
505
  for (const sl of ifInner)
425
506
  lines.push(sl);
507
+ if (remainingBranchReachable &&
508
+ !isStaticBooleanLiteral(nestedCondIR, false) &&
509
+ childrenCanFallThrough(ifNode.children ?? [])) {
510
+ branchOutcomes.push(cloneBranchBindingScopes(ctx));
511
+ }
512
+ if (isStaticBooleanLiteral(nestedCondIR, true))
513
+ remainingBranchReachable = false;
426
514
  elseCandidate = ec.length === 2 ? ec[1] : undefined;
427
515
  }
428
516
  else {
517
+ restoreBranchBindingScopes(ctx, branchBase);
429
518
  lines.push(`${indent}else:`);
430
519
  const elseInner = emitChildrenPy(ec, ctx, indent + INDENT_STEP);
431
520
  if (elseInner.length === 0)
432
521
  lines.push(`${indent}${INDENT_STEP}pass`);
433
522
  for (const el of elseInner)
434
523
  lines.push(el);
524
+ if (remainingBranchReachable && childrenCanFallThrough(ec))
525
+ branchOutcomes.push(cloneBranchBindingScopes(ctx));
526
+ hasTerminalElse = true;
527
+ remainingBranchReachable = false;
435
528
  break;
436
529
  }
437
530
  }
531
+ if (!hasTerminalElse && remainingBranchReachable)
532
+ branchOutcomes.push(branchBase);
533
+ mergeBranchBindingSnapshots(ctx, branchBase, branchOutcomes);
438
534
  }
439
535
  else if (child.type === 'else') {
440
536
  // Slice-2 review fix: orphan `else` is a structural error (matches TS side).
@@ -586,7 +682,8 @@ function emitChildrenPy(children, ctx, indent, initialBindings = [], isLoopBody
586
682
  }
587
683
  const k = String(pairKey);
588
684
  const v = String(pairValue);
589
- const sourceExpr = emitPyExprCtx(listIR, ctx);
685
+ assertNoKeyedNestedRecordReceiverPy(listIR, ctx);
686
+ const sourceExpr = emitEachIterablePy(listIR, ctx);
590
687
  ctx.helpers.add(KERN_PAIR_HELPERS_PY);
591
688
  const iterableExpr = isAwait ? `_kern_async_pairs(${sourceExpr})` : `_kern_pairs(${sourceExpr})`;
592
689
  lines.push(`${indent}${isAwait ? 'async ' : ''}for ${k}, ${v} in ${iterableExpr}:`);
@@ -616,7 +713,8 @@ function emitChildrenPy(children, ctx, indent, initialBindings = [], isLoopBody
616
713
  if (entryKey && entryValue) {
617
714
  throw new Error('body-statement `each` cannot combine `entryKey=` and `entryValue=`.');
618
715
  }
619
- const sourceExpr = emitPyExprCtx(listIR, ctx);
716
+ assertNoKeyedNestedRecordReceiverPy(listIR, ctx);
717
+ const sourceExpr = emitEachIterablePy(listIR, ctx);
620
718
  if (entryKey) {
621
719
  const k = String(entryKey);
622
720
  const iterableExpr = `${sourceExpr}.keys()`;
@@ -662,7 +760,7 @@ function emitChildrenPy(children, ctx, indent, initialBindings = [], isLoopBody
662
760
  // the IR-semantics differential audit (PR-3b).
663
761
  const asName = String(child.props?.name ?? child.props?.as ?? 'item');
664
762
  const idxName = child.props?.index !== undefined ? String(child.props.index) : null;
665
- const iterableExpr = emitPyExprCtx(listIR, ctx);
763
+ const iterableExpr = emitEachIterablePy(listIR, ctx);
666
764
  let primaryBindingPy;
667
765
  let initialBindings;
668
766
  if (idxName !== null) {
@@ -745,6 +843,12 @@ function emitChildrenPy(children, ctx, indent, initialBindings = [], isLoopBody
745
843
  ctx.loopLaterAssignFrames.pop();
746
844
  ctx.localScopes.pop();
747
845
  ctx.regexScopes.pop();
846
+ ctx.recordScopes.pop();
847
+ ctx.recordArrayFieldScopes.pop();
848
+ ctx.recordScalarArrayFieldScopes.pop();
849
+ ctx.maybeRecordArrayFieldScopes.pop();
850
+ ctx.arrayBindingScopes.pop();
851
+ ctx.scalarArrayBindingScopes.pop();
748
852
  ctx.renameStack.pop();
749
853
  // Restore the parent level's hoist buffer (see the isolation comment at
750
854
  // entry). Any defs THIS level's last child left behind are appended so the
@@ -768,6 +872,17 @@ function resolveLocalRename(ctx, name) {
768
872
  }
769
873
  return name;
770
874
  }
875
+ function emitScopedIdentPy(ctx, name) {
876
+ // Only custom nested-record rewrites need this helper; ordinary expression
877
+ // emission descends through the ident case, which already resolves block
878
+ // renames before applying symbol-map names.
879
+ const blockRename = resolveLocalRename(ctx, name);
880
+ if (blockRename !== name)
881
+ return blockRename;
882
+ if (ctx.shadowedSymbols.has(name))
883
+ return name;
884
+ return ctx.symbolMap[name] ?? name;
885
+ }
771
886
  /** Returns the renamed name if `let name=` here would shadow a binding in
772
887
  * any OUTER scope; otherwise returns `name` unchanged. Used by `emitLetPy`
773
888
  * to give an inner-block shadow a unique Python name + record the rename
@@ -1067,6 +1182,8 @@ function emitLetPy(node, ctx) {
1067
1182
  }
1068
1183
  const valueIR = parseExpr(String(rawValue));
1069
1184
  setRegexBinding(ctx, userName, valueIR.kind === 'regexLit' ? valueIR : null);
1185
+ setRecordBinding(ctx, userName, valueIR.kind === 'objectLit', recordArrayFieldsForValue(valueIR, ctx), recordScalarArrayFieldsForValue(valueIR, ctx));
1186
+ bindArrayStatusFromLet(ctx, userName, valueIR);
1070
1187
  if (valueIR.kind === 'propagate' && valueIR.op === '?') {
1071
1188
  rejectPropagationInsideTry(ctx);
1072
1189
  const tmp = `__k_t${++ctx.gensymCounter}`;
@@ -1082,11 +1199,22 @@ function emitLetPy(node, ctx) {
1082
1199
  lines.push(letAssignTracePy(name));
1083
1200
  return lines;
1084
1201
  }
1085
- const lines = [`${name} = ${emitPyExprCtx(valueIR, ctx)}`];
1202
+ const lines = [`${name} = ${emitLetInitializerPy(valueIR, ctx)}`];
1086
1203
  if (ctx.traceHooks?.letAssign)
1087
1204
  lines.push(letAssignTracePy(name));
1088
1205
  return lines;
1089
1206
  }
1207
+ /** RECORD-binding initializer wrap (nested-values slice-1): a `let` bound to a
1208
+ * DIRECT object literal becomes a `__DotDict` so certified field reads
1209
+ * (`r.a`, `r.length`) resolve on the Python leg. ONLY this position wraps —
1210
+ * inline/argument/return object literals keep base plain-dict emission. */
1211
+ function emitLetInitializerPy(valueIR, ctx) {
1212
+ const rhs = emitPyExprCtx(valueIR, ctx);
1213
+ if (valueIR.kind !== 'objectLit')
1214
+ return rhs;
1215
+ ctx.helpers.add(DOT_DICT_SHIM_PY);
1216
+ return `__DotDict(${rhs})`;
1217
+ }
1090
1218
  function unwrapBodyExpr(value) {
1091
1219
  if (value === undefined || value === null)
1092
1220
  return undefined;
@@ -1371,6 +1499,7 @@ function emitAssignPy(node, ctx) {
1371
1499
  throw new Error('body-statement `assign target=` must be an identifier, member access, or index access.');
1372
1500
  }
1373
1501
  assertAssignableLocalTarget(targetIR, ctx);
1502
+ applyArrayMutationAssignPy(targetIR, ctx);
1374
1503
  // Python lacks `++` / `--`; lower postfix mutation to the canonical compound
1375
1504
  // assignment (`X += 1` / `X -= 1`). The TS round-trip stays byte-equivalent
1376
1505
  // because TS emits `X++;` from the same IR — only the Python target diverges
@@ -1386,12 +1515,20 @@ function emitAssignPy(node, ctx) {
1386
1515
  // Emit FIRST (its `emitPyExprCtx` lowering fail-closes a regex method on a
1387
1516
  // bound regex ident) so the RHS is checked against the PRE-reassignment table
1388
1517
  // (`re = s.match(re)` must still see `re` as a regex). Mirrors the TS leg.
1389
- const stmt = `${emitPyExprCtx(targetIR, ctx)} ${rawOp} ${emitPyExprCtx(valueIR, ctx)}`;
1518
+ // A plain-`=` ident reassignment to a DIRECT object literal takes the SAME
1519
+ // __DotDict wrap as a record `let` initializer — `rebindRecordOnReassign`
1520
+ // below re-marks the binding as a record, so the VALUE must be record-
1521
+ // shaped too or later emitted field reads (attribute access) would hit a
1522
+ // plain dict. Lockstep with `emitLetInitializerPy`.
1523
+ const rhs = rawOp === '=' && targetIR.kind === 'ident' ? emitLetInitializerPy(valueIR, ctx) : emitPyExprCtx(valueIR, ctx);
1524
+ const stmt = `${emitPyExprCtx(targetIR, ctx)} ${rawOp} ${rhs}`;
1390
1525
  // Reassign-invalidation (Slice-3c): keep the regex-binding table honest. A
1391
1526
  // plain `=` to a direct regex literal stays a regex binding (still
1392
1527
  // fail-closed); any compound op (`+=`, …) or non-regex RHS UNMARKS it.
1393
1528
  if (targetIR.kind === 'ident') {
1394
1529
  rebindRegexOnReassign(ctx, targetIR.name, rawOp === '=' ? valueIR : { kind: 'undefLit' });
1530
+ rebindRecordOnReassign(ctx, targetIR.name, rawOp === '=' ? valueIR : { kind: 'undefLit' });
1531
+ rebindArrayOnReassign(ctx, targetIR.name, rawOp === '=' ? valueIR : { kind: 'undefLit' });
1395
1532
  }
1396
1533
  // Differential-harness opt-in (see BodyEmitOptions.traceHooks.letAssign): the
1397
1534
  // `assign` contract observes a reassignment via the same `{op:"assign"}` event
@@ -1411,6 +1548,8 @@ function declareLocalBinding(ctx, name, kind) {
1411
1548
  }
1412
1549
  scope.set(name, kind);
1413
1550
  setRegexBinding(ctx, name, null);
1551
+ setRecordBinding(ctx, name, false);
1552
+ setArrayBindingStatus(ctx, name, null);
1414
1553
  }
1415
1554
  function setRegexBinding(ctx, name, regex) {
1416
1555
  ctx.regexScopes.at(-1)?.set(name, regex);
@@ -1439,6 +1578,360 @@ function rebindRegexOnReassign(ctx, name, valueIR) {
1439
1578
  }
1440
1579
  }
1441
1580
  }
1581
+ /** Record whether `name` is bound to a DIRECT record literal in the current scope. */
1582
+ function setRecordBinding(ctx, name, isRecord, arrayFields = null, scalarArrayFields = null) {
1583
+ ctx.recordScopes.at(-1)?.set(name, isRecord);
1584
+ ctx.recordArrayFieldScopes.at(-1)?.set(name, isRecord ? arrayFields : null);
1585
+ ctx.recordScalarArrayFieldScopes.at(-1)?.set(name, isRecord ? scalarArrayFields : null);
1586
+ ctx.maybeRecordArrayFieldScopes.at(-1)?.set(name, isRecord ? arrayFields : null);
1587
+ }
1588
+ function recordArrayFieldsForValue(valueIR, ctx) {
1589
+ if (valueIR.kind !== 'objectLit')
1590
+ return null;
1591
+ const fields = new Set();
1592
+ for (const entry of valueIR.entries) {
1593
+ if ('kind' in entry)
1594
+ continue;
1595
+ if (entry.value.kind === 'arrayLit') {
1596
+ assertPortableArrayLiteralElementsPy(entry.value);
1597
+ fields.add(entry.key);
1598
+ }
1599
+ else if (entry.value.kind === 'ident') {
1600
+ const status = lookupArrayBindingStatus(ctx, entry.value.name);
1601
+ if (status === 'fresh' || status === 'fresh-push' || status === 'captured')
1602
+ fields.add(entry.key);
1603
+ }
1604
+ }
1605
+ return fields;
1606
+ }
1607
+ function recordScalarArrayFieldsForValue(valueIR, ctx) {
1608
+ if (valueIR.kind !== 'objectLit')
1609
+ return null;
1610
+ const fields = new Set();
1611
+ for (const entry of valueIR.entries) {
1612
+ if ('kind' in entry)
1613
+ continue;
1614
+ if (entry.value.kind === 'arrayLit' && arrayLiteralHasOnlyScalarElements(entry.value))
1615
+ fields.add(entry.key);
1616
+ else if (entry.value.kind === 'ident') {
1617
+ const status = lookupArrayBindingStatus(ctx, entry.value.name);
1618
+ if ((status === 'fresh' || status === 'fresh-push' || status === 'captured') &&
1619
+ lookupScalarArrayBinding(ctx, entry.value.name)) {
1620
+ fields.add(entry.key);
1621
+ }
1622
+ }
1623
+ }
1624
+ return fields;
1625
+ }
1626
+ function assertPortableArrayLiteralElementsPy(valueIR) {
1627
+ for (const item of valueIR.items) {
1628
+ if (item.kind === 'arrayLit') {
1629
+ assertPortableArrayLiteralElementsPy(item);
1630
+ continue;
1631
+ }
1632
+ if (item.kind === 'strLit' || item.kind === 'boolLit' || item.kind === 'nullLit')
1633
+ continue;
1634
+ if (item.kind === 'numLit' && item.bigint !== true && Number.isFinite(item.value)) {
1635
+ if (isIntegerValuedFloatLiteralPy(item))
1636
+ throw new Error('portable: float literal has an integer value (float/int divergence)');
1637
+ continue;
1638
+ }
1639
+ throw new Error('portable-array: array literal fields must contain only portable scalar or array elements');
1640
+ }
1641
+ }
1642
+ function arrayLiteralHasOnlyScalarElements(valueIR) {
1643
+ return valueIR.items.every((item) => (item.kind === 'numLit' &&
1644
+ item.bigint !== true &&
1645
+ Number.isFinite(item.value) &&
1646
+ !isIntegerValuedFloatLiteralPy(item)) ||
1647
+ item.kind === 'strLit' ||
1648
+ item.kind === 'boolLit' ||
1649
+ item.kind === 'nullLit');
1650
+ }
1651
+ function setArrayBindingStatus(ctx, name, status) {
1652
+ const scope = ctx.arrayBindingScopes.at(-1);
1653
+ if (!scope)
1654
+ return;
1655
+ if (status === null)
1656
+ scope.delete(name);
1657
+ else
1658
+ scope.set(name, status);
1659
+ }
1660
+ function setScalarArrayBinding(ctx, name, scalar) {
1661
+ const scope = ctx.scalarArrayBindingScopes.at(-1);
1662
+ if (!scope)
1663
+ return;
1664
+ if (scalar)
1665
+ scope.set(name, true);
1666
+ else
1667
+ scope.delete(name);
1668
+ }
1669
+ function lookupArrayBindingStatus(ctx, name) {
1670
+ for (let i = ctx.localScopes.length - 1; i >= 0; i--) {
1671
+ if (!ctx.localScopes[i].has(name))
1672
+ continue;
1673
+ return ctx.arrayBindingScopes[i]?.get(name) ?? null;
1674
+ }
1675
+ return null;
1676
+ }
1677
+ function lookupScalarArrayBinding(ctx, name) {
1678
+ for (let i = ctx.localScopes.length - 1; i >= 0; i--) {
1679
+ if (!ctx.localScopes[i].has(name))
1680
+ continue;
1681
+ return ctx.scalarArrayBindingScopes[i]?.get(name) === true;
1682
+ }
1683
+ return false;
1684
+ }
1685
+ function setDeclaringArrayBindingStatus(ctx, name, status) {
1686
+ for (let i = ctx.localScopes.length - 1; i >= 0; i--) {
1687
+ if (!ctx.localScopes[i].has(name))
1688
+ continue;
1689
+ const scope = ctx.arrayBindingScopes[i];
1690
+ if (status === null)
1691
+ scope?.delete(name);
1692
+ else
1693
+ scope?.set(name, status);
1694
+ return;
1695
+ }
1696
+ }
1697
+ function setDeclaringScalarArrayBinding(ctx, name, scalar) {
1698
+ for (let i = ctx.localScopes.length - 1; i >= 0; i--) {
1699
+ if (!ctx.localScopes[i].has(name))
1700
+ continue;
1701
+ const scope = ctx.scalarArrayBindingScopes[i];
1702
+ if (scalar)
1703
+ scope?.set(name, true);
1704
+ else
1705
+ scope?.delete(name);
1706
+ return;
1707
+ }
1708
+ }
1709
+ function assertFreshArrayCaptureNotInRepeatableLoopPy(ctx, name) {
1710
+ const loopScopeIndex = ctx.loopScopeIndexes.at(-1);
1711
+ if (loopScopeIndex === undefined)
1712
+ return;
1713
+ const scopeIndex = findBindingScopeIndex(ctx, name);
1714
+ if (scopeIndex !== null && scopeIndex < loopScopeIndex) {
1715
+ throw new Error(`fresh array binding "${name}" cannot be captured inside a repeatable loop body`);
1716
+ }
1717
+ }
1718
+ function isStaticBooleanLiteral(node, value) {
1719
+ return node.kind === 'boolLit' && node.value === value;
1720
+ }
1721
+ function cloneFieldScopes(scopes) {
1722
+ return scopes.map((scope) => new Map([...scope.entries()].map(([name, fields]) => [name, fields === null ? null : new Set(fields)])));
1723
+ }
1724
+ function cloneBranchBindingScopes(ctx) {
1725
+ return {
1726
+ array: ctx.arrayBindingScopes.map((scope) => new Map(scope)),
1727
+ scalarArray: ctx.scalarArrayBindingScopes.map((scope) => new Map(scope)),
1728
+ record: ctx.recordScopes.map((scope) => new Map(scope)),
1729
+ recordArrayField: cloneFieldScopes(ctx.recordArrayFieldScopes),
1730
+ recordScalarArrayField: cloneFieldScopes(ctx.recordScalarArrayFieldScopes),
1731
+ maybeRecordArrayField: cloneFieldScopes(ctx.maybeRecordArrayFieldScopes),
1732
+ };
1733
+ }
1734
+ function restoreBranchBindingScopes(ctx, snapshot) {
1735
+ ctx.arrayBindingScopes = snapshot.array.map((scope) => new Map(scope));
1736
+ ctx.scalarArrayBindingScopes = snapshot.scalarArray.map((scope) => new Map(scope));
1737
+ ctx.recordScopes = snapshot.record.map((scope) => new Map(scope));
1738
+ ctx.recordArrayFieldScopes = cloneFieldScopes(snapshot.recordArrayField);
1739
+ ctx.recordScalarArrayFieldScopes = cloneFieldScopes(snapshot.recordScalarArrayField);
1740
+ ctx.maybeRecordArrayFieldScopes = cloneFieldScopes(snapshot.maybeRecordArrayField);
1741
+ }
1742
+ function strongestArrayBindingStatus(statuses) {
1743
+ if (statuses.includes('captured'))
1744
+ return 'captured';
1745
+ if (statuses.includes('stale'))
1746
+ return 'stale';
1747
+ if (statuses.includes('fresh'))
1748
+ return 'fresh';
1749
+ if (statuses.includes('fresh-push'))
1750
+ return 'fresh-push';
1751
+ return null;
1752
+ }
1753
+ function mergeArrayScopes(base, outcomes) {
1754
+ const merged = base.map((scope) => new Map(scope));
1755
+ for (let scopeIndex = 0; scopeIndex < merged.length; scopeIndex++) {
1756
+ const names = new Set(merged[scopeIndex].keys());
1757
+ for (const outcome of outcomes)
1758
+ for (const name of outcome[scopeIndex]?.keys() ?? [])
1759
+ names.add(name);
1760
+ for (const name of names) {
1761
+ const status = strongestArrayBindingStatus(outcomes.map((outcome) => outcome[scopeIndex]?.get(name) ?? null));
1762
+ if (status === null)
1763
+ merged[scopeIndex].delete(name);
1764
+ else
1765
+ merged[scopeIndex].set(name, status);
1766
+ }
1767
+ }
1768
+ return merged;
1769
+ }
1770
+ function mergeScalarArrayScopes(base, outcomes) {
1771
+ const merged = base.map((scope) => new Map(scope));
1772
+ for (let scopeIndex = 0; scopeIndex < merged.length; scopeIndex++) {
1773
+ const names = new Set(merged[scopeIndex].keys());
1774
+ for (const outcome of outcomes)
1775
+ for (const name of outcome[scopeIndex]?.keys() ?? [])
1776
+ names.add(name);
1777
+ for (const name of names) {
1778
+ const scalar = outcomes.length > 0 && outcomes.every((outcome) => outcome[scopeIndex]?.get(name) === true);
1779
+ if (scalar)
1780
+ merged[scopeIndex].set(name, true);
1781
+ else
1782
+ merged[scopeIndex].delete(name);
1783
+ }
1784
+ }
1785
+ return merged;
1786
+ }
1787
+ function mergeRecordScopes(base, outcomes) {
1788
+ const merged = base.map((scope) => new Map(scope));
1789
+ for (let scopeIndex = 0; scopeIndex < merged.length; scopeIndex++) {
1790
+ const names = new Set(merged[scopeIndex].keys());
1791
+ for (const outcome of outcomes)
1792
+ for (const name of outcome[scopeIndex]?.keys() ?? [])
1793
+ names.add(name);
1794
+ for (const name of names) {
1795
+ merged[scopeIndex].set(name, outcomes.length > 0 && outcomes.every((outcome) => outcome[scopeIndex]?.get(name) === true));
1796
+ }
1797
+ }
1798
+ return merged;
1799
+ }
1800
+ function mergeFieldScopes(base, outcomes, mode) {
1801
+ const merged = base.map((scope) => new Map(scope));
1802
+ for (let scopeIndex = 0; scopeIndex < merged.length; scopeIndex++) {
1803
+ const names = new Set(merged[scopeIndex].keys());
1804
+ for (const outcome of outcomes)
1805
+ for (const name of outcome[scopeIndex]?.keys() ?? [])
1806
+ names.add(name);
1807
+ for (const name of names) {
1808
+ const outcomeFields = outcomes.map((outcome) => outcome[scopeIndex]?.get(name) ?? null);
1809
+ const fields = new Set();
1810
+ if (mode === 'union') {
1811
+ for (const set of outcomeFields)
1812
+ if (set)
1813
+ for (const field of set)
1814
+ fields.add(field);
1815
+ }
1816
+ else if (outcomeFields.length > 0 && outcomeFields.every((set) => set !== null)) {
1817
+ for (const field of outcomeFields[0] ?? []) {
1818
+ if (outcomeFields.every((set) => set?.has(field) === true))
1819
+ fields.add(field);
1820
+ }
1821
+ }
1822
+ merged[scopeIndex].set(name, fields.size > 0 ? fields : null);
1823
+ }
1824
+ }
1825
+ return merged;
1826
+ }
1827
+ function mergeBranchBindingSnapshots(ctx, base, outcomes) {
1828
+ ctx.arrayBindingScopes = mergeArrayScopes(base.array, outcomes.map((outcome) => outcome.array));
1829
+ ctx.scalarArrayBindingScopes = mergeScalarArrayScopes(base.scalarArray, outcomes.map((outcome) => outcome.scalarArray));
1830
+ ctx.recordScopes = mergeRecordScopes(base.record, outcomes.map((outcome) => outcome.record));
1831
+ ctx.recordArrayFieldScopes = mergeFieldScopes(base.recordArrayField, outcomes.map((outcome) => outcome.recordArrayField), 'intersection');
1832
+ ctx.recordScalarArrayFieldScopes = mergeFieldScopes(base.recordScalarArrayField, outcomes.map((outcome) => outcome.recordScalarArrayField), 'intersection');
1833
+ ctx.maybeRecordArrayFieldScopes = mergeFieldScopes(base.maybeRecordArrayField, outcomes.map((outcome) => outcome.maybeRecordArrayField), 'union');
1834
+ }
1835
+ function lookupRecordBinding(ctx, name) {
1836
+ for (let i = ctx.recordScopes.length - 1; i >= 0; i--) {
1837
+ const scope = ctx.recordScopes[i];
1838
+ if (scope.has(name))
1839
+ return scope.get(name) === true;
1840
+ }
1841
+ return false;
1842
+ }
1843
+ function lookupRecordArrayField(ctx, name, field) {
1844
+ for (let i = ctx.recordArrayFieldScopes.length - 1; i >= 0; i--) {
1845
+ const scope = ctx.recordArrayFieldScopes[i];
1846
+ if (!scope.has(name))
1847
+ continue;
1848
+ return scope.get(name)?.has(field) === true;
1849
+ }
1850
+ return false;
1851
+ }
1852
+ function lookupRecordScalarArrayField(ctx, name, field) {
1853
+ for (let i = ctx.recordScalarArrayFieldScopes.length - 1; i >= 0; i--) {
1854
+ const scope = ctx.recordScalarArrayFieldScopes[i];
1855
+ if (!scope.has(name))
1856
+ continue;
1857
+ return scope.get(name)?.has(field) === true;
1858
+ }
1859
+ return false;
1860
+ }
1861
+ function lookupMaybeRecordArrayField(ctx, name, field) {
1862
+ for (let i = ctx.maybeRecordArrayFieldScopes.length - 1; i >= 0; i--) {
1863
+ const scope = ctx.maybeRecordArrayFieldScopes[i];
1864
+ if (!scope.has(name))
1865
+ continue;
1866
+ return scope.get(name)?.has(field) === true;
1867
+ }
1868
+ return false;
1869
+ }
1870
+ function bindArrayStatusFromLet(ctx, name, valueIR) {
1871
+ if (valueIR.kind === 'arrayLit') {
1872
+ setArrayBindingStatus(ctx, name, valueIR.items.length === 0 ? 'fresh-push' : 'fresh');
1873
+ setScalarArrayBinding(ctx, name, arrayLiteralHasOnlyScalarElements(valueIR));
1874
+ return;
1875
+ }
1876
+ if (valueIR.kind === 'ident') {
1877
+ const sourceStatus = lookupArrayBindingStatus(ctx, valueIR.name);
1878
+ const sourceScalar = lookupScalarArrayBinding(ctx, valueIR.name);
1879
+ if (sourceStatus === 'fresh' || sourceStatus === 'fresh-push') {
1880
+ setDeclaringArrayBindingStatus(ctx, valueIR.name, 'stale');
1881
+ setDeclaringScalarArrayBinding(ctx, valueIR.name, false);
1882
+ setArrayBindingStatus(ctx, name, 'stale');
1883
+ setScalarArrayBinding(ctx, name, false);
1884
+ return;
1885
+ }
1886
+ if (sourceStatus === 'captured') {
1887
+ setArrayBindingStatus(ctx, name, 'captured');
1888
+ setScalarArrayBinding(ctx, name, sourceScalar);
1889
+ return;
1890
+ }
1891
+ if (sourceStatus === 'stale') {
1892
+ setArrayBindingStatus(ctx, name, 'stale');
1893
+ setScalarArrayBinding(ctx, name, false);
1894
+ return;
1895
+ }
1896
+ }
1897
+ if (valueIR.kind === 'member' &&
1898
+ valueIR.object.kind === 'ident' &&
1899
+ lookupRecordArrayField(ctx, valueIR.object.name, valueIR.property)) {
1900
+ setArrayBindingStatus(ctx, name, 'captured');
1901
+ setScalarArrayBinding(ctx, name, lookupRecordScalarArrayField(ctx, valueIR.object.name, valueIR.property));
1902
+ return;
1903
+ }
1904
+ setArrayBindingStatus(ctx, name, null);
1905
+ setScalarArrayBinding(ctx, name, false);
1906
+ }
1907
+ function rebindArrayOnReassign(ctx, name, valueIR) {
1908
+ if (valueIR.kind === 'arrayLit') {
1909
+ setDeclaringArrayBindingStatus(ctx, name, 'stale');
1910
+ setDeclaringScalarArrayBinding(ctx, name, false);
1911
+ }
1912
+ else {
1913
+ setDeclaringArrayBindingStatus(ctx, name, null);
1914
+ setDeclaringScalarArrayBinding(ctx, name, false);
1915
+ }
1916
+ }
1917
+ /** Reassign-invalidation for the record table — mirrors `rebindRegexOnReassign`
1918
+ * (and the TS emitter's `rebindRecordOnReassign`): owning-scope update, no
1919
+ * stale record classification after `assign target=r value=<non-record>`. */
1920
+ function rebindRecordOnReassign(ctx, name, valueIR) {
1921
+ const next = valueIR.kind === 'objectLit';
1922
+ const arrayFields = recordArrayFieldsForValue(valueIR, ctx);
1923
+ const scalarArrayFields = recordScalarArrayFieldsForValue(valueIR, ctx);
1924
+ for (let i = ctx.recordScopes.length - 1; i >= 0; i--) {
1925
+ const scope = ctx.recordScopes[i];
1926
+ if (scope.has(name)) {
1927
+ scope.set(name, next);
1928
+ ctx.recordArrayFieldScopes[i]?.set(name, next ? arrayFields : null);
1929
+ ctx.recordScalarArrayFieldScopes[i]?.set(name, next ? scalarArrayFields : null);
1930
+ ctx.maybeRecordArrayFieldScopes[i]?.set(name, next ? arrayFields : null);
1931
+ return;
1932
+ }
1933
+ }
1934
+ }
1442
1935
  function assertAssignableLocalTarget(target, ctx) {
1443
1936
  if (target.kind !== 'ident')
1444
1937
  return;
@@ -1777,7 +2270,7 @@ function emitDoPy(node, ctx) {
1777
2270
  ctx.usedPropagation = true;
1778
2271
  return [`${tmp} = ${inner}`, `if ${tmp}.kind == 'err':`, errPropagationLine(tmp, ctx)];
1779
2272
  }
1780
- return [`${emitPyExprCtx(valueIR, ctx)}`];
2273
+ return [`${emitPyExprCtx(valueIR, ctx, { preserveFreshPush: true })}`];
1781
2274
  }
1782
2275
  /** ValueIR `kind`s that lower to Python literals/values and would trigger
1783
2276
  * `TypeError: exceptions must derive from BaseException` if `raise`d
@@ -1822,12 +2315,213 @@ export function emitPyExpressionWithImports(node, options) {
1822
2315
  if (outerBindings.length > 0) {
1823
2316
  ctx.localScopes.push(new Map(outerBindings.map((n) => [n, 'const'])));
1824
2317
  ctx.regexScopes.push(new Map(outerBindings.map((n) => [n, null])));
2318
+ ctx.recordScopes.push(new Map(outerBindings.map((n) => [n, false])));
2319
+ ctx.recordArrayFieldScopes.push(new Map(outerBindings.map((n) => [n, null])));
2320
+ ctx.recordScalarArrayFieldScopes.push(new Map(outerBindings.map((n) => [n, null])));
2321
+ ctx.maybeRecordArrayFieldScopes.push(new Map(outerBindings.map((n) => [n, null])));
2322
+ ctx.arrayBindingScopes.push(new Map());
2323
+ ctx.scalarArrayBindingScopes.push(new Map());
1825
2324
  ctx.renameStack.push(new Map());
1826
2325
  }
1827
2326
  const code = emitPyExprCtx(node, ctx);
1828
2327
  return { code, imports: ctx.imports, helpers: ctx.helpers };
1829
2328
  }
1830
- function emitPyExprCtx(node, ctx) {
2329
+ const MUTATING_ARRAY_METHODS = new Set([
2330
+ 'copyWithin',
2331
+ 'fill',
2332
+ 'pop',
2333
+ 'push',
2334
+ 'reverse',
2335
+ 'shift',
2336
+ 'sort',
2337
+ 'splice',
2338
+ 'unshift',
2339
+ ]);
2340
+ function captureFreshArrayRecordSourcesPy(node, ctx) {
2341
+ const freshSources = new Set();
2342
+ for (const entry of node.entries) {
2343
+ if ('kind' in entry)
2344
+ continue;
2345
+ if (entry.value.kind === 'member' &&
2346
+ entry.value.object.kind === 'ident' &&
2347
+ lookupMaybeRecordArrayField(ctx, entry.value.object.name, entry.value.property)) {
2348
+ throw new Error(`record field "${entry.value.object.name}.${entry.value.property}" cannot be captured by another record field`);
2349
+ }
2350
+ if (entry.value.kind !== 'ident')
2351
+ continue;
2352
+ const status = lookupArrayBindingStatus(ctx, entry.value.name);
2353
+ if (status === 'fresh' || status === 'fresh-push') {
2354
+ if (freshSources.has(entry.value.name)) {
2355
+ throw new Error(`fresh array binding "${entry.value.name}" can be captured only once`);
2356
+ }
2357
+ assertFreshArrayCaptureNotInRepeatableLoopPy(ctx, entry.value.name);
2358
+ freshSources.add(entry.value.name);
2359
+ }
2360
+ else if (status === 'captured') {
2361
+ throw new Error(`fresh array binding "${entry.value.name}" was already captured by a record field`);
2362
+ }
2363
+ else if (status === 'stale') {
2364
+ throw new Error(`stale array binding "${entry.value.name}" cannot be captured by a record field`);
2365
+ }
2366
+ }
2367
+ for (const name of freshSources)
2368
+ setDeclaringArrayBindingStatus(ctx, name, 'captured');
2369
+ }
2370
+ function rootIdentName(node) {
2371
+ if (node.kind === 'ident')
2372
+ return node.name;
2373
+ if (node.kind === 'member' || node.kind === 'index')
2374
+ return rootIdentName(node.object);
2375
+ return null;
2376
+ }
2377
+ function recordArrayFieldMutationTarget(node, ctx) {
2378
+ if (node.kind === 'member' &&
2379
+ node.object.kind === 'ident' &&
2380
+ lookupMaybeRecordArrayField(ctx, node.object.name, node.property)) {
2381
+ return { recordName: node.object.name, fieldName: node.property };
2382
+ }
2383
+ if (node.kind === 'member' || node.kind === 'index')
2384
+ return recordArrayFieldMutationTarget(node.object, ctx);
2385
+ return null;
2386
+ }
2387
+ function isIntegerValuedFloatLiteralPy(node) {
2388
+ return (node.raw.includes('.') || /[eE]/.test(node.raw)) && Number.isInteger(node.value);
2389
+ }
2390
+ function isFreshnessPreservingPushElementPy(node) {
2391
+ if (node.kind === 'strLit' || node.kind === 'boolLit' || node.kind === 'nullLit')
2392
+ return true;
2393
+ if (node.kind !== 'numLit')
2394
+ return false;
2395
+ if (node.bigint || !Number.isFinite(node.value))
2396
+ return false;
2397
+ if (isIntegerValuedFloatLiteralPy(node))
2398
+ throw new Error('portable: float literal has an integer value (float/int divergence)');
2399
+ return true;
2400
+ }
2401
+ function pushMutationTargetPy(node) {
2402
+ if (node.kind !== 'call' || node.optional || node.args.length !== 1)
2403
+ return null;
2404
+ const callee = node.callee;
2405
+ if (callee.kind !== 'member' || callee.optional || callee.property !== 'push')
2406
+ return null;
2407
+ return { target: callee.object, element: node.args[0] };
2408
+ }
2409
+ function applyArrayMutationTargetPy(target, ctx, preserveFreshPush = false) {
2410
+ const recordField = recordArrayFieldMutationTarget(target, ctx);
2411
+ if (recordField !== null) {
2412
+ throw new Error(`record array field "${recordField.recordName}.${recordField.fieldName}" cannot be mutated after capture`);
2413
+ }
2414
+ const targetName = rootIdentName(target);
2415
+ if (targetName === null)
2416
+ return;
2417
+ const status = lookupArrayBindingStatus(ctx, targetName);
2418
+ if (status === 'captured') {
2419
+ throw new Error(`fresh array binding "${targetName}" was already captured by a record field`);
2420
+ }
2421
+ if (status === 'fresh-push' && preserveFreshPush)
2422
+ return;
2423
+ if (status === 'fresh' || status === 'fresh-push')
2424
+ setDeclaringArrayBindingStatus(ctx, targetName, 'stale');
2425
+ }
2426
+ function applyArrayMutationDoPy(node, ctx, preserveFreshPush = false) {
2427
+ const pushTarget = pushMutationTargetPy(node);
2428
+ if (pushTarget !== null) {
2429
+ const keepFresh = preserveFreshPush && isFreshnessPreservingPushElementPy(pushTarget.element);
2430
+ applyArrayMutationTargetPy(pushTarget.target, ctx, keepFresh);
2431
+ return;
2432
+ }
2433
+ if (node.kind !== 'call')
2434
+ return;
2435
+ const callee = node.callee;
2436
+ if (callee.kind === 'member' && MUTATING_ARRAY_METHODS.has(callee.property))
2437
+ applyArrayMutationTargetPy(callee.object, ctx);
2438
+ }
2439
+ function applyArrayMutationAssignPy(target, ctx) {
2440
+ if (target.kind === 'ident') {
2441
+ const status = lookupArrayBindingStatus(ctx, target.name);
2442
+ if (status === 'captured') {
2443
+ throw new Error(`fresh array binding "${target.name}" was already captured by a record field`);
2444
+ }
2445
+ if (status === 'fresh' || status === 'fresh-push' || status === 'stale') {
2446
+ throw new Error(`array binding "${target.name}" cannot be reassigned by portable assign`);
2447
+ }
2448
+ return;
2449
+ }
2450
+ applyArrayMutationTargetPy(target, ctx);
2451
+ }
2452
+ function forEachValueIRChildPy(node, visit) {
2453
+ if (node.kind === 'member')
2454
+ visit(node.object);
2455
+ else if (node.kind === 'index') {
2456
+ visit(node.object);
2457
+ visit(node.index);
2458
+ }
2459
+ else if (node.kind === 'call') {
2460
+ visit(node.callee);
2461
+ for (const arg of node.args)
2462
+ visit(arg);
2463
+ }
2464
+ else if (node.kind === 'binary') {
2465
+ visit(node.left);
2466
+ visit(node.right);
2467
+ }
2468
+ else if (node.kind === 'unary' ||
2469
+ node.kind === 'spread' ||
2470
+ node.kind === 'await' ||
2471
+ node.kind === 'new' ||
2472
+ node.kind === 'propagate') {
2473
+ visit(node.argument);
2474
+ }
2475
+ else if (node.kind === 'typeAssert' || node.kind === 'nonNull')
2476
+ visit(node.expression);
2477
+ else if (node.kind === 'conditional') {
2478
+ visit(node.test);
2479
+ visit(node.consequent);
2480
+ visit(node.alternate);
2481
+ }
2482
+ else if (node.kind === 'tmplLit')
2483
+ for (const expr of node.expressions)
2484
+ visit(expr);
2485
+ else if (node.kind === 'objectLit') {
2486
+ for (const entry of node.entries) {
2487
+ if ('kind' in entry && entry.kind === 'spread')
2488
+ visit(entry.argument);
2489
+ else
2490
+ visit(entry.value);
2491
+ }
2492
+ }
2493
+ else if (node.kind === 'arrayLit')
2494
+ for (const item of node.items)
2495
+ visit(item);
2496
+ else if (node.kind === 'lambda' && node.body)
2497
+ visit(node.body);
2498
+ }
2499
+ function applyArrayMutationsInExpressionPy(node, ctx, preserveFreshPush = false) {
2500
+ applyArrayMutationDoPy(node, ctx, preserveFreshPush);
2501
+ // Only a direct `do xs.push(<scalar>)` statement certifies the push-built freshness chain.
2502
+ // Nested pushes inside a larger expression are still mutation expressions and must stale.
2503
+ forEachValueIRChildPy(node, (child) => applyArrayMutationsInExpressionPy(child, ctx));
2504
+ }
2505
+ function assertRecordArrayFieldReadsProvenPy(node, ctx) {
2506
+ if (node.kind === 'member' &&
2507
+ node.object.kind === 'ident' &&
2508
+ lookupMaybeRecordArrayField(ctx, node.object.name, node.property) &&
2509
+ (node.optional || isParenthesized(node.object))) {
2510
+ throw new Error(`record array field "${node.object.name}.${node.property}" must use a bare non-optional receiver`);
2511
+ }
2512
+ if (node.kind === 'member' &&
2513
+ node.object.kind === 'ident' &&
2514
+ lookupMaybeRecordArrayField(ctx, node.object.name, node.property) &&
2515
+ !lookupRecordArrayField(ctx, node.object.name, node.property)) {
2516
+ throw new Error(`record array field "${node.object.name}.${node.property}" is not proven on every branch`);
2517
+ }
2518
+ forEachValueIRChildPy(node, (child) => assertRecordArrayFieldReadsProvenPy(child, ctx));
2519
+ }
2520
+ function emitPyExprCtx(node, ctx, options = {}) {
2521
+ applyArrayMutationsInExpressionPy(node, ctx, options.preserveFreshPush === true);
2522
+ assertRecordArrayFieldReadsProvenPy(node, ctx);
2523
+ if (node.kind === 'objectLit')
2524
+ captureFreshArrayRecordSourcesPy(node, ctx);
1831
2525
  switch (node.kind) {
1832
2526
  case 'numLit':
1833
2527
  return node.raw;
@@ -2255,7 +2949,10 @@ function emitPyExprCtx(node, ctx) {
2255
2949
  }
2256
2950
  case 'objectLit': {
2257
2951
  // Slice 2d — Python dict literal. Keys are ALWAYS double-quoted (no
2258
- // shorthand-key syntax in Python).
2952
+ // shorthand-key syntax in Python). Emits the BASE plain-dict form —
2953
+ // record-binding `let` initializers (the ONLY position where field
2954
+ // reads are certified) wrap in __DotDict at the let site, so every
2955
+ // other object-literal position keeps its pre-slice bytes.
2259
2956
  const entries = node.entries.map((e) => {
2260
2957
  if ('kind' in e && e.kind === 'spread') {
2261
2958
  return `**${emitPyExprCtx(e.argument, ctx)}`;
@@ -2777,9 +3474,118 @@ function lowerOptionalLink(inner, objectNode, ctx) {
2777
3474
  const presence = optionalPresenceTest(`${tmp} := ${inner.expr}`, ctx);
2778
3475
  return { guard: presence, branchRef: tmp };
2779
3476
  }
3477
+ /** Nested-record-field receiver, LOCKSTEP with the TS twin
3478
+ * (`nestedRecordFieldReceiver` in codegen-expression.ts): fires ONLY when the
3479
+ * binding table proves the SOURCE-named ident is a record literal; any other
3480
+ * two-level chain (`this.data.filter(...)`, object params, unproven idents,
3481
+ * parenthesized receivers) keeps its base lowering. INVARIANT: the lookup is
3482
+ * by SOURCE name, but the EMITTED name goes through `ctx.symbolMap` — Python's
3483
+ * flat function scope renames shadowed bindings, while the TS leg never
3484
+ * renames (real block scoping), which is why the TS twin has no remap. */
3485
+ function provenRecordFieldReceiverPy(node, ctx) {
3486
+ if (node.kind !== 'member' || node.optional)
3487
+ return null;
3488
+ if (node.object.kind !== 'ident')
3489
+ return null;
3490
+ if (!lookupRecordBinding(ctx, node.object.name))
3491
+ return null;
3492
+ if (isParenthesized(node.object))
3493
+ return null;
3494
+ return { record: emitScopedIdentPy(ctx, node.object.name), field: node.property };
3495
+ }
3496
+ function nestedRecordFieldReceiverPy(node, ctx) {
3497
+ if (node.kind !== 'member' || node.object.kind !== 'ident')
3498
+ return null;
3499
+ const proven = provenRecordFieldReceiverPy(node, ctx);
3500
+ if (proven === null)
3501
+ return null;
3502
+ if (!lookupRecordArrayField(ctx, node.object.name, node.property))
3503
+ return null;
3504
+ return proven;
3505
+ }
3506
+ function nestedRecordFieldNonArrayReceiverPy(node, ctx) {
3507
+ if (node.kind !== 'member' || node.object.kind !== 'ident')
3508
+ return null;
3509
+ const proven = provenRecordFieldReceiverPy(node, ctx);
3510
+ if (proven === null)
3511
+ return null;
3512
+ if (lookupRecordArrayField(ctx, node.object.name, node.property))
3513
+ return null;
3514
+ return proven;
3515
+ }
3516
+ function emitEachIterablePy(node, ctx) {
3517
+ if (node.kind === 'member' && node.object.kind === 'ident') {
3518
+ if (!lookupRecordBinding(ctx, node.object.name))
3519
+ return emitPyExprCtx(node, ctx);
3520
+ if (node.optional || isParenthesized(node.object)) {
3521
+ throw new Error(`each nested record-array receiver "${node.object.name}.${node.property}" is not proven`);
3522
+ }
3523
+ if (lookupMaybeRecordArrayField(ctx, node.object.name, node.property) &&
3524
+ !lookupRecordArrayField(ctx, node.object.name, node.property)) {
3525
+ throw new Error(`record array field "${node.object.name}.${node.property}" is not proven on every branch`);
3526
+ }
3527
+ if (lookupRecordArrayField(ctx, node.object.name, node.property) &&
3528
+ !lookupRecordScalarArrayField(ctx, node.object.name, node.property)) {
3529
+ throw new Error(`record array field "${node.object.name}.${node.property}" elements are not proven portable scalars`);
3530
+ }
3531
+ ctx.helpers.add(KERN_NESTED_ARRAY_REF_HELPER_PY);
3532
+ ctx.helpers.add(KERN_NESTED_ARRAY_ITER_HELPER_PY);
3533
+ const record = emitScopedIdentPy(ctx, node.object.name);
3534
+ return `_kern_nested_array_iter(${record}, ${JSON.stringify(node.property)})`;
3535
+ }
3536
+ return emitPyExprCtx(node, ctx);
3537
+ }
3538
+ function assertNoKeyedNestedRecordReceiverPy(node, ctx) {
3539
+ if (node.kind !== 'member' || node.object.kind !== 'ident')
3540
+ return;
3541
+ if (!lookupRecordBinding(ctx, node.object.name))
3542
+ return;
3543
+ if (node.optional || isParenthesized(node.object))
3544
+ return;
3545
+ throw new Error(`keyed iteration over nested record field "${node.object.name}.${node.property}" is outside the portable domain`);
3546
+ }
2780
3547
  function lowerChain(node, ctx) {
2781
3548
  if (node.kind === 'member') {
2782
3549
  const obj = node.object;
3550
+ const directArrayField = nestedRecordFieldReceiverPy(node, ctx);
3551
+ if (directArrayField !== null) {
3552
+ ctx.helpers.add(KERN_NESTED_ARRAY_REF_HELPER_PY);
3553
+ return {
3554
+ guard: null,
3555
+ expr: `_kern_nested_array_ref(${directArrayField.record}, ${JSON.stringify(directArrayField.field)})`,
3556
+ };
3557
+ }
3558
+ const nested = nestedRecordFieldReceiverPy(obj, ctx);
3559
+ if (nested !== null) {
3560
+ if (node.optional)
3561
+ throw new Error('portable: optional nested member access is outside the portable domain');
3562
+ if (node.property !== 'length') {
3563
+ ctx.helpers.add(KERN_NESTED_NO_PROPERTY_HELPER_PY);
3564
+ return {
3565
+ guard: null,
3566
+ expr: `_kern_nested_no_property(${JSON.stringify(`${nested.record}.${nested.field}`)}, ${JSON.stringify(node.property)})`,
3567
+ };
3568
+ }
3569
+ ctx.helpers.add(KERN_NESTED_ARRAY_HELPER_PY);
3570
+ return { guard: null, expr: `_kern_nested_array_value(${nested.record}, ${JSON.stringify(nested.field)})` };
3571
+ }
3572
+ const nonArrayNested = nestedRecordFieldNonArrayReceiverPy(obj, ctx);
3573
+ if (nonArrayNested !== null) {
3574
+ if (node.optional)
3575
+ throw new Error('portable: optional nested member access is outside the portable domain');
3576
+ if (node.property !== 'length') {
3577
+ ctx.helpers.add(KERN_NESTED_NO_PROPERTY_HELPER_PY);
3578
+ return {
3579
+ guard: null,
3580
+ expr: `_kern_nested_no_property(${JSON.stringify(`${nonArrayNested.record}.${nonArrayNested.field}`)}, ${JSON.stringify(node.property)})`,
3581
+ };
3582
+ }
3583
+ ctx.helpers.add(KERN_NESTED_ARRAY_HELPER_PY);
3584
+ return {
3585
+ guard: null,
3586
+ expr: `_kern_nested_array_value(${nonArrayNested.record}, ${JSON.stringify(nonArrayNested.field)})`,
3587
+ };
3588
+ }
2783
3589
  // Error-substrate Slice 1 — a `<caughtBinding>.message` read lowers to
2784
3590
  // `str(<caughtBinding>)`. Python exceptions have NO `.message` attribute
2785
3591
  // (a bare `e.message` raises AttributeError), but `str(Exception("x"))`
@@ -2829,6 +3635,11 @@ function lowerChain(node, ctx) {
2829
3635
  const inner = obj.kind === 'member' || obj.kind === 'call' || obj.kind === 'index'
2830
3636
  ? lowerChain(obj, ctx)
2831
3637
  : { guard: null, expr: wrapCompoundRootExpr(obj, emitPyExprCtx(obj, ctx)) };
3638
+ // Nested-values slice-1 — a PROVEN record binding's single-level member
3639
+ // read must stay an ATTRIBUTE read even for shared array property names
3640
+ // (`r.length` is the scalar FIELD "length" on a __DotDict, never len(r));
3641
+ // every other receiver keeps the base shared list-ops lowering below.
3642
+ const recordReceiver = obj.kind === 'ident' && lookupRecordBinding(ctx, obj.name);
2832
3643
  // Portable Array *property* read (non-call `.length`) lowers through the
2833
3644
  // SAME shared list-ops hook the route emitter uses, so `this.items.length`
2834
3645
  // emits `len(self.items)` (not invalid `self.items.length`) — identical to
@@ -2846,18 +3657,52 @@ function lowerChain(node, ctx) {
2846
3657
  // receiver is not itself an optional chain ⇒ `inner.guard === null`); a
2847
3658
  // non-pure receiver UNDER an existing optional chain still throws below.
2848
3659
  const opt = lowerOptionalLink(inner, node.object, ctx);
2849
- const linkExpr = isSharedPortableArrayProperty(node.property)
3660
+ const linkExpr = !recordReceiver && isSharedPortableArrayProperty(node.property)
2850
3661
  ? (lowerPortableArrayPropertyPy(opt.branchRef, node.property) ?? `${opt.branchRef}.${node.property}`)
2851
3662
  : `${opt.branchRef}.${node.property}`;
2852
3663
  return { guard: opt.guard, expr: linkExpr, lambdaBind: opt.lambdaBind };
2853
3664
  }
2854
- const linkExpr = isSharedPortableArrayProperty(node.property)
3665
+ const linkExpr = !recordReceiver && isSharedPortableArrayProperty(node.property)
2855
3666
  ? (lowerPortableArrayPropertyPy(inner.expr, node.property) ?? `${inner.expr}.${node.property}`)
2856
3667
  : `${inner.expr}.${node.property}`;
2857
3668
  return { guard: inner.guard, expr: linkExpr, lambdaBind: inner.lambdaBind };
2858
3669
  }
2859
3670
  if (node.kind === 'index') {
2860
3671
  const obj = node.object;
3672
+ const nested = nestedRecordFieldReceiverPy(obj, ctx);
3673
+ if (nested !== null) {
3674
+ if (node.optional)
3675
+ throw new Error('portable: optional nested index access is outside the portable domain');
3676
+ ctx.helpers.add(KERN_NESTED_ARRAY_HELPER_PY);
3677
+ if (!isSafeIntegerLiteralIndex(node.index)) {
3678
+ return {
3679
+ guard: null,
3680
+ expr: `_kern_nested_array_value(${nested.record}, ${JSON.stringify(nested.field)}, "__kern_invalid_index__")`,
3681
+ };
3682
+ }
3683
+ const literalIndex = node.index;
3684
+ return {
3685
+ guard: null,
3686
+ expr: `_kern_nested_array_value(${nested.record}, ${JSON.stringify(nested.field)}, ${literalIndex.raw})`,
3687
+ };
3688
+ }
3689
+ const nonArrayNested = nestedRecordFieldNonArrayReceiverPy(obj, ctx);
3690
+ if (nonArrayNested !== null) {
3691
+ if (node.optional)
3692
+ throw new Error('portable: optional nested index access is outside the portable domain');
3693
+ ctx.helpers.add(KERN_NESTED_ARRAY_HELPER_PY);
3694
+ if (!isSafeIntegerLiteralIndex(node.index)) {
3695
+ return {
3696
+ guard: null,
3697
+ expr: `_kern_nested_array_value(${nonArrayNested.record}, ${JSON.stringify(nonArrayNested.field)}, "__kern_invalid_index__")`,
3698
+ };
3699
+ }
3700
+ const literalIndex = node.index;
3701
+ return {
3702
+ guard: null,
3703
+ expr: `_kern_nested_array_value(${nonArrayNested.record}, ${JSON.stringify(nonArrayNested.field)}, ${literalIndex.raw})`,
3704
+ };
3705
+ }
2861
3706
  // Slice 2 review fix — the bracket (`index`) form of a regex-literal
2862
3707
  // property access (`/x/["source"]`, `/x/["flags"]`, `/x/["test"](s)`)
2863
3708
  // launders the pattern/flags back to a string exactly like the dotted
@@ -2906,6 +3751,28 @@ function lowerChain(node, ctx) {
2906
3751
  throw new Error("Optional call '?.()' is not yet supported on Python target. " +
2907
3752
  'Bind the function reference to a `let` first, then test for `none` before calling.');
2908
3753
  }
3754
+ // Nested-values slice-1 CHOKE POINT (lockstep with the TS call case's
3755
+ // identical guard): ANY method call on a PROVEN record binding's array
3756
+ // field fails closed BEFORE any per-method lowering (regex / lambda-array /
3757
+ // portable-array) can admit it — the runner abstains on every such program.
3758
+ if (node.callee.kind === 'member' && !node.callee.optional) {
3759
+ const nestedRecv = nestedRecordFieldReceiverPy(node.callee.object, ctx);
3760
+ if (nestedRecv !== null) {
3761
+ ctx.helpers.add(KERN_NESTED_NO_PROPERTY_HELPER_PY);
3762
+ return {
3763
+ guard: null,
3764
+ expr: `_kern_nested_no_property(${JSON.stringify(`${nestedRecv.record}.${nestedRecv.field}`)}, ${JSON.stringify(node.callee.property)})`,
3765
+ };
3766
+ }
3767
+ const nonArrayNested = nestedRecordFieldNonArrayReceiverPy(node.callee.object, ctx);
3768
+ if (nonArrayNested !== null) {
3769
+ ctx.helpers.add(KERN_NESTED_NO_PROPERTY_HELPER_PY);
3770
+ return {
3771
+ guard: null,
3772
+ expr: `_kern_nested_no_property(${JSON.stringify(`${nonArrayNested.record}.${nonArrayNested.field}`)}, ${JSON.stringify(node.callee.property)})`,
3773
+ };
3774
+ }
3775
+ }
2909
3776
  // Slice 2a — KERN-stdlib dispatch must run on a top-level Module.method
2910
3777
  // call BEFORE we descend into the callee chain, so `Number.floor(x)`
2911
3778
  // doesn't degrade into a non-stdlib `Number.floor(x)` Python emit.
@@ -4407,7 +5274,9 @@ function emitExpressionV1Py(node, ctx) {
4407
5274
  declareLocalBinding(ctx, userName, 'const');
4408
5275
  const name = maybeRenameOnShadow(ctx, userName);
4409
5276
  setRegexBinding(ctx, userName, exprIR.kind === 'regexLit' ? exprIR : null);
4410
- const lines = [`${name} = ${emitPyExprCtx(exprIR, ctx)}`];
5277
+ setRecordBinding(ctx, userName, exprIR.kind === 'objectLit', recordArrayFieldsForValue(exprIR, ctx), recordScalarArrayFieldsForValue(exprIR, ctx));
5278
+ bindArrayStatusFromLet(ctx, userName, exprIR);
5279
+ const lines = [`${name} = ${emitLetInitializerPy(exprIR, ctx)}`];
4411
5280
  if (ctx.traceHooks?.letAssign)
4412
5281
  lines.push(letAssignTracePy(name));
4413
5282
  return lines;