pulse-framework 0.1.72__py3-none-any.whl → 0.1.74__py3-none-any.whl

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.
@@ -10,8 +10,9 @@ from __future__ import annotations
10
10
  import ast
11
11
  import re
12
12
  from collections.abc import Callable, Mapping
13
+ from dataclasses import dataclass
13
14
  from pathlib import Path
14
- from typing import Any, cast
15
+ from typing import Any, cast, override
15
16
 
16
17
  from pulse.transpiler.builtins import BUILTINS, emit_method
17
18
  from pulse.transpiler.errors import TranspileError
@@ -30,6 +31,7 @@ from pulse.transpiler.nodes import (
30
31
  Function,
31
32
  Identifier,
32
33
  If,
34
+ LetDecl,
33
35
  Literal,
34
36
  Member,
35
37
  New,
@@ -78,6 +80,228 @@ ALLOWED_CMPOPS: dict[type[ast.cmpop], str] = {
78
80
  }
79
81
 
80
82
 
83
+ def _collect_param_names(args: ast.arguments) -> list[str]:
84
+ """Collect argument names (regular, vararg, kwonly, kwarg)."""
85
+ names: list[str] = [arg.arg for arg in args.args]
86
+ if args.vararg:
87
+ names.append(args.vararg.arg)
88
+ names.extend(arg.arg for arg in args.kwonlyargs)
89
+ if args.kwarg:
90
+ names.append(args.kwarg.arg)
91
+ return names
92
+
93
+
94
+ @dataclass(slots=True)
95
+ class Scope:
96
+ locals: set[str]
97
+ params: set[str]
98
+ parent: "Scope | None" = None
99
+
100
+
101
+ class ScopeAnalyzer(ast.NodeVisitor):
102
+ """Collect locals per scope (function/lambda/comprehension)."""
103
+
104
+ _scopes: dict[ast.AST, Scope]
105
+ _stack: list[Scope]
106
+ _global_scope: Scope
107
+
108
+ def __init__(self) -> None:
109
+ self._scopes = {}
110
+ self._stack = []
111
+ self._global_scope = Scope(locals=set(), params=set(), parent=None)
112
+
113
+ def analyze(
114
+ self, node: ast.FunctionDef | ast.AsyncFunctionDef
115
+ ) -> dict[ast.AST, Scope]:
116
+ self._stack.append(self._global_scope)
117
+ self._visit_defaults(node.args)
118
+ scope = self._new_scope(
119
+ node, _collect_param_names(node.args), parent=self._global_scope
120
+ )
121
+ self._stack.append(scope)
122
+ for stmt in node.body:
123
+ self.visit(stmt)
124
+ self._stack.pop()
125
+ self._stack.pop()
126
+ return self._scopes
127
+
128
+ def _new_scope(
129
+ self,
130
+ node: ast.AST,
131
+ params: list[str],
132
+ *,
133
+ parent: Scope | None,
134
+ ) -> Scope:
135
+ param_set = set(params)
136
+ scope = Scope(locals=set(param_set), params=param_set, parent=parent)
137
+ self._scopes[node] = scope
138
+ return scope
139
+
140
+ def _current_scope(self) -> Scope:
141
+ return self._stack[-1]
142
+
143
+ def _add_local(self, name: str) -> None:
144
+ self._current_scope().locals.add(name)
145
+
146
+ def _add_targets(self, target: ast.expr) -> None:
147
+ if isinstance(target, ast.Name):
148
+ self._add_local(target.id)
149
+ return
150
+ if isinstance(target, (ast.Tuple, ast.List)):
151
+ for elt in target.elts:
152
+ if isinstance(elt, ast.Name):
153
+ self._add_local(elt.id)
154
+
155
+ def _visit_target_expr(self, target: ast.expr) -> None:
156
+ if isinstance(target, (ast.Tuple, ast.List)):
157
+ for elt in target.elts:
158
+ self._visit_target_expr(elt)
159
+ return
160
+ self.visit(target)
161
+
162
+ def _analyze_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
163
+ self._visit_defaults(node.args)
164
+ scope = self._new_scope(
165
+ node,
166
+ _collect_param_names(node.args),
167
+ parent=self._current_scope(),
168
+ )
169
+ self._stack.append(scope)
170
+ for stmt in node.body:
171
+ self.visit(stmt)
172
+ self._stack.pop()
173
+
174
+ @property
175
+ def global_scope(self) -> Scope:
176
+ return self._global_scope
177
+
178
+ def _visit_defaults(self, args: ast.arguments) -> None:
179
+ for default in args.defaults:
180
+ self.visit(default)
181
+ for default in args.kw_defaults:
182
+ if default is not None:
183
+ self.visit(default)
184
+
185
+ @override
186
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
187
+ self._add_local(node.name)
188
+ self._analyze_function(node)
189
+
190
+ @override
191
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
192
+ self._add_local(node.name)
193
+ self._analyze_function(node)
194
+
195
+ @override
196
+ def visit_Lambda(self, node: ast.Lambda) -> None:
197
+ scope = self._new_scope(
198
+ node,
199
+ _collect_param_names(node.args),
200
+ parent=self._current_scope(),
201
+ )
202
+ self._stack.append(scope)
203
+ self.visit(node.body)
204
+ self._stack.pop()
205
+
206
+ def _visit_comprehension(
207
+ self,
208
+ node: ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp,
209
+ *,
210
+ elt: ast.expr,
211
+ key: ast.expr | None = None,
212
+ ) -> None:
213
+ scope = self._new_scope(node, [], parent=self._current_scope())
214
+ self._stack.append(scope)
215
+ if key is not None:
216
+ self.visit(key)
217
+ self.visit(elt)
218
+ for gen in node.generators:
219
+ self.visit(gen.iter)
220
+ for if_node in gen.ifs:
221
+ self.visit(if_node)
222
+ self._stack.pop()
223
+
224
+ @override
225
+ def visit_ListComp(self, node: ast.ListComp) -> None:
226
+ self._visit_comprehension(node, elt=node.elt)
227
+
228
+ @override
229
+ def visit_SetComp(self, node: ast.SetComp) -> None:
230
+ self._visit_comprehension(node, elt=node.elt)
231
+
232
+ @override
233
+ def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
234
+ self._visit_comprehension(node, elt=node.elt)
235
+
236
+ @override
237
+ def visit_DictComp(self, node: ast.DictComp) -> None:
238
+ self._visit_comprehension(node, elt=node.value, key=node.key)
239
+
240
+ @override
241
+ def visit_Assign(self, node: ast.Assign) -> None:
242
+ for target in node.targets:
243
+ if isinstance(target, (ast.Name, ast.Tuple, ast.List)):
244
+ self._add_targets(target)
245
+ self._visit_target_expr(target)
246
+ self.visit(node.value)
247
+
248
+ @override
249
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
250
+ if isinstance(node.target, (ast.Name, ast.Tuple, ast.List)):
251
+ self._add_targets(node.target)
252
+ self._visit_target_expr(node.target)
253
+ if node.value is not None:
254
+ self.visit(node.value)
255
+
256
+ @override
257
+ def visit_AugAssign(self, node: ast.AugAssign) -> None:
258
+ if isinstance(node.target, (ast.Name, ast.Tuple, ast.List)):
259
+ self._add_targets(node.target)
260
+ self._visit_target_expr(node.target)
261
+ self.visit(node.value)
262
+
263
+ @override
264
+ def visit_For(self, node: ast.For) -> None:
265
+ if isinstance(node.target, (ast.Name, ast.Tuple, ast.List)):
266
+ self._add_targets(node.target)
267
+ self._visit_target_expr(node.target)
268
+ self.visit(node.iter)
269
+ for stmt in node.body:
270
+ self.visit(stmt)
271
+ for stmt in node.orelse:
272
+ self.visit(stmt)
273
+
274
+ @override
275
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
276
+ if node.name:
277
+ self._add_local(node.name)
278
+ if node.type is not None:
279
+ self.visit(node.type)
280
+ for stmt in node.body:
281
+ self.visit(stmt)
282
+
283
+ @override
284
+ def visit_With(self, node: ast.With) -> None:
285
+ for item in node.items:
286
+ if item.optional_vars and isinstance(
287
+ item.optional_vars, (ast.Name, ast.Tuple, ast.List)
288
+ ):
289
+ self._add_targets(item.optional_vars)
290
+ if item.optional_vars is not None:
291
+ self._visit_target_expr(item.optional_vars)
292
+ self.visit(item.context_expr)
293
+ for stmt in node.body:
294
+ self.visit(stmt)
295
+
296
+ @override
297
+ def visit_Global(self, node: ast.Global) -> None:
298
+ raise TranspileError("global is not supported", node=node)
299
+
300
+ @override
301
+ def visit_Nonlocal(self, node: ast.Nonlocal) -> None:
302
+ raise TranspileError("nonlocal is not supported", node=node)
303
+
304
+
81
305
  class Transpiler:
82
306
  """Transpile Python AST to v2 Expr/Stmt AST nodes.
83
307
 
@@ -93,10 +317,12 @@ class Transpiler:
93
317
  fndef: ast.FunctionDef | ast.AsyncFunctionDef
94
318
  args: list[str]
95
319
  deps: Mapping[str, Expr]
96
- locals: set[str]
97
320
  jsx: bool
98
321
  source_file: Path | None
99
322
  _temp_counter: int
323
+ _scope_map: dict[ast.AST, Scope]
324
+ _scope_stack: list[Scope]
325
+ _global_scope: Scope
100
326
 
101
327
  def __init__(
102
328
  self,
@@ -109,22 +335,21 @@ class Transpiler:
109
335
  self.fndef = fndef
110
336
  self.source_file = source_file
111
337
  # Collect all argument names (regular, vararg, kwonly, kwarg)
112
- args: list[str] = [arg.arg for arg in fndef.args.args]
113
- if fndef.args.vararg:
114
- args.append(fndef.args.vararg.arg)
115
- args.extend(arg.arg for arg in fndef.args.kwonlyargs)
116
- if fndef.args.kwarg:
117
- args.append(fndef.args.kwarg.arg)
118
- self.args = args
338
+ self.args = _collect_param_names(fndef.args)
119
339
  self.deps = deps
120
340
  self.jsx = jsx
121
- self.locals = set(self.args)
122
341
  self._temp_counter = 0
342
+ analyzer = ScopeAnalyzer()
343
+ self._scope_map = analyzer.analyze(fndef)
344
+ self._global_scope = analyzer.global_scope
345
+ self._scope_stack = [self._scope_map[fndef]]
123
346
  self.init_temp_counter()
124
347
 
125
348
  def init_temp_counter(self) -> None:
126
349
  """Initialize temp counter to avoid collisions with args or globals."""
127
- all_names = set(self.args) | set(self.deps.keys())
350
+ all_names = set(self.deps.keys())
351
+ for scope in self._scope_map.values():
352
+ all_names.update(scope.locals)
128
353
  counter = 0
129
354
  while f"$tmp{counter}" in all_names:
130
355
  counter += 1
@@ -136,6 +361,38 @@ class Transpiler:
136
361
  self._temp_counter += 1
137
362
  return name
138
363
 
364
+ def _current_scope(self) -> Scope:
365
+ return self._scope_stack[-1]
366
+
367
+ def _push_scope(self, node: ast.AST) -> None:
368
+ self._scope_stack.append(self._scope_map[node])
369
+
370
+ def _pop_scope(self) -> None:
371
+ self._scope_stack.pop()
372
+
373
+ def _is_local_here(self, name: str) -> bool:
374
+ return name in self._current_scope().locals
375
+
376
+ def _is_local(self, name: str) -> bool:
377
+ scope: Scope | None = self._current_scope()
378
+ while scope is not None:
379
+ if name in scope.locals:
380
+ return True
381
+ scope = scope.parent
382
+ return False
383
+
384
+ def _require_local(
385
+ self, name: str, *, node: ast.expr | ast.stmt | ast.excepthandler
386
+ ) -> None:
387
+ if not self._is_local_here(name):
388
+ raise TranspileError(f"Assignment to unknown local: {name}", node=node)
389
+
390
+ def _function_prelude(self, scope: Scope) -> list[Stmt]:
391
+ names = sorted(scope.locals - scope.params)
392
+ if not names:
393
+ return []
394
+ return [LetDecl(names)]
395
+
139
396
  # --- Entrypoint ---------------------------------------------------------
140
397
 
141
398
  def transpile(self) -> Function | Arrow:
@@ -176,7 +433,8 @@ class Transpiler:
176
433
  return Arrow(self.args, expr)
177
434
 
178
435
  # General case: Function (for JSX or multi-statement)
179
- stmts = [self.emit_stmt(s) for s in body]
436
+ prelude = self._function_prelude(self._current_scope())
437
+ stmts = prelude + [self.emit_stmt(s) for s in body]
180
438
  is_async = isinstance(self.fndef, ast.AsyncFunctionDef)
181
439
  args = [self._jsx_args()] if self.jsx else self.args
182
440
  return Function(args, stmts, is_async=is_async)
@@ -202,7 +460,7 @@ class Transpiler:
202
460
  if default_idx >= 0:
203
461
  # Has a default value
204
462
  default_node = args.defaults[default_idx]
205
- default_expr = self.emit_expr(default_node)
463
+ default_expr = self._emit_default_expr(default_node)
206
464
  default_out.clear()
207
465
  default_expr.emit(default_out)
208
466
  destructure_parts.append(f"{param_name} = {''.join(default_out)}")
@@ -220,7 +478,7 @@ class Transpiler:
220
478
  default_node = args.kw_defaults[i]
221
479
  if default_node is not None:
222
480
  # Has a default value
223
- default_expr = self.emit_expr(default_node)
481
+ default_expr = self._emit_default_expr(default_node)
224
482
  default_out.clear()
225
483
  default_expr.emit(default_out)
226
484
  destructure_parts.append(f"{param_name} = {''.join(default_out)}")
@@ -234,6 +492,14 @@ class Transpiler:
234
492
 
235
493
  return "{" + ", ".join(destructure_parts) + "}"
236
494
 
495
+ def _emit_default_expr(self, node: ast.expr) -> Expr:
496
+ """Emit defaults in defining-scope context."""
497
+ self._scope_stack.append(self._global_scope)
498
+ try:
499
+ return self.emit_expr(node)
500
+ finally:
501
+ self._scope_stack.pop()
502
+
237
503
  # --- Statements ----------------------------------------------------------
238
504
 
239
505
  def emit_stmt(self, node: ast.stmt) -> Stmt:
@@ -253,11 +519,16 @@ class Transpiler:
253
519
  return Block([])
254
520
 
255
521
  if isinstance(node, ast.AugAssign):
522
+ if isinstance(node.target, ast.Subscript):
523
+ return self._emit_augmented_subscript_assign(node)
524
+ if isinstance(node.target, ast.Attribute):
525
+ return self._emit_augmented_attribute_assign(node)
256
526
  if not isinstance(node.target, ast.Name):
257
527
  raise TranspileError(
258
528
  "Only simple augmented assignments supported", node=node
259
529
  )
260
530
  target = node.target.id
531
+ self._require_local(target, node=node)
261
532
  op_type = type(node.op)
262
533
  if op_type not in ALLOWED_BINOPS:
263
534
  raise TranspileError(
@@ -278,6 +549,12 @@ class Transpiler:
278
549
  if isinstance(target_node, (ast.Tuple, ast.List)):
279
550
  return self._emit_unpacking_assign(target_node, node.value)
280
551
 
552
+ if isinstance(target_node, ast.Subscript):
553
+ return self._emit_subscript_assign(target_node, node.value)
554
+
555
+ if isinstance(target_node, ast.Attribute):
556
+ return self._emit_attribute_assign(target_node, node.value)
557
+
281
558
  if not isinstance(target_node, ast.Name):
282
559
  raise TranspileError(
283
560
  "Only simple assignments to local names supported", node=node
@@ -286,22 +563,16 @@ class Transpiler:
286
563
  target = target_node.id
287
564
  value_expr = self.emit_expr(node.value)
288
565
 
289
- if target in self.locals:
290
- return Assign(target, value_expr)
291
- else:
292
- self.locals.add(target)
293
- return Assign(target, value_expr, declare="let")
566
+ self._require_local(target, node=node)
567
+ return Assign(target, value_expr)
294
568
 
295
569
  if isinstance(node, ast.AnnAssign):
296
570
  if not isinstance(node.target, ast.Name):
297
571
  raise TranspileError("Only simple annotated assignments supported")
298
572
  target = node.target.id
299
573
  value = Literal(None) if node.value is None else self.emit_expr(node.value)
300
- if target in self.locals:
301
- return Assign(target, value)
302
- else:
303
- self.locals.add(target)
304
- return Assign(target, value, declare="let")
574
+ self._require_local(target, node=node)
575
+ return Assign(target, value)
305
576
 
306
577
  if isinstance(node, ast.If):
307
578
  cond = self.emit_expr(node.test)
@@ -348,14 +619,87 @@ class Transpiler:
348
619
  assert isinstance(e, ast.Name)
349
620
  name = e.id
350
621
  sub = Subscript(Identifier(tmp_name), Literal(idx))
351
- if name in self.locals:
352
- stmts.append(Assign(name, sub))
353
- else:
354
- self.locals.add(name)
355
- stmts.append(Assign(name, sub, declare="let"))
622
+ self._require_local(name, node=target)
623
+ stmts.append(Assign(name, sub))
356
624
 
357
625
  return StmtSequence(stmts)
358
626
 
627
+ def _emit_subscript_assign(self, target: ast.Subscript, value: ast.expr) -> Stmt:
628
+ """Emit subscript assignment: obj[key] = value"""
629
+ if isinstance(target.slice, ast.Tuple):
630
+ raise TranspileError(
631
+ "Multiple indices not supported in subscript", node=target.slice
632
+ )
633
+ if isinstance(target.slice, ast.Slice):
634
+ raise TranspileError("Slice assignment not supported", node=target.slice)
635
+ obj_expr = self.emit_expr(target.value)
636
+ target_expr = obj_expr.transpile_subscript(target.slice, self)
637
+ if not isinstance(target_expr, (Identifier, Member, Subscript)):
638
+ raise TranspileError(
639
+ "Only simple subscript assignments supported", node=target
640
+ )
641
+ value_expr = self.emit_expr(value)
642
+ return Assign(target_expr, value_expr)
643
+
644
+ def _emit_attribute_assign(self, target: ast.Attribute, value: ast.expr) -> Stmt:
645
+ """Emit attribute assignment: obj.attr = value"""
646
+ obj_expr = self.emit_expr(target.value)
647
+ value_expr = self.emit_expr(value)
648
+ target_expr = obj_expr.transpile_getattr(target.attr, self)
649
+ if not isinstance(target_expr, (Identifier, Member, Subscript)):
650
+ raise TranspileError(
651
+ "Only simple attribute assignments supported", node=target
652
+ )
653
+ return Assign(target_expr, value_expr)
654
+
655
+ def _emit_augmented_subscript_assign(self, node: ast.AugAssign) -> Stmt:
656
+ """Emit augmented subscript assignment: arr[i] += x"""
657
+ target = node.target
658
+ assert isinstance(target, ast.Subscript)
659
+
660
+ if isinstance(target.slice, ast.Tuple):
661
+ raise TranspileError(
662
+ "Multiple indices not supported in subscript", node=target.slice
663
+ )
664
+ if isinstance(target.slice, ast.Slice):
665
+ raise TranspileError("Slice assignment not supported", node=target.slice)
666
+
667
+ obj_expr = self.emit_expr(target.value)
668
+ op_type = type(node.op)
669
+ if op_type not in ALLOWED_BINOPS:
670
+ raise TranspileError(
671
+ f"Unsupported augmented assignment operator: {op_type.__name__}",
672
+ node=node,
673
+ )
674
+ target_expr = obj_expr.transpile_subscript(target.slice, self)
675
+ if not isinstance(target_expr, (Identifier, Member, Subscript)):
676
+ raise TranspileError(
677
+ "Only simple subscript assignments supported", node=target
678
+ )
679
+ value_expr = self.emit_expr(node.value)
680
+ return Assign(target_expr, value_expr, op=ALLOWED_BINOPS[op_type])
681
+
682
+ def _emit_augmented_attribute_assign(self, node: ast.AugAssign) -> Stmt:
683
+ """Emit augmented attribute assignment: obj.attr += x"""
684
+ target = node.target
685
+ assert isinstance(target, ast.Attribute)
686
+
687
+ obj_expr = self.emit_expr(target.value)
688
+ op_type = type(node.op)
689
+ if op_type not in ALLOWED_BINOPS:
690
+ raise TranspileError(
691
+ f"Unsupported augmented assignment operator: {op_type.__name__}",
692
+ node=node,
693
+ )
694
+
695
+ target_expr = obj_expr.transpile_getattr(target.attr, self)
696
+ if not isinstance(target_expr, (Identifier, Member, Subscript)):
697
+ raise TranspileError(
698
+ "Only simple attribute assignments supported", node=target
699
+ )
700
+ value_expr = self.emit_expr(node.value)
701
+ return Assign(target_expr, value_expr, op=ALLOWED_BINOPS[op_type])
702
+
359
703
  def _emit_for_loop(self, node: ast.For) -> Stmt:
360
704
  """Emit a for loop."""
361
705
  # Handle tuple unpacking in for target
@@ -367,7 +711,7 @@ class Transpiler:
367
711
  "Only simple name targets supported in for-loop unpacking"
368
712
  )
369
713
  names.append(e.id)
370
- self.locals.add(e.id)
714
+ self._require_local(e.id, node=node)
371
715
  iter_expr = self.emit_expr(node.iter)
372
716
  body = [self.emit_stmt(s) for s in node.body]
373
717
  # Use array pattern for destructuring
@@ -378,7 +722,7 @@ class Transpiler:
378
722
  raise TranspileError("Only simple name targets supported in for-loops")
379
723
 
380
724
  target = node.target.id
381
- self.locals.add(target)
725
+ self._require_local(target, node=node)
382
726
  iter_expr = self.emit_expr(node.iter)
383
727
  body = [self.emit_stmt(s) for s in node.body]
384
728
  return ForOf(target, iter_expr, body)
@@ -388,31 +732,29 @@ class Transpiler:
388
732
  ) -> Stmt:
389
733
  """Emit a nested function definition."""
390
734
  name = node.name
391
- params = [arg.arg for arg in node.args.args]
735
+ params = _collect_param_names(node.args)
736
+ self._require_local(name, node=node)
392
737
 
393
- # Save current locals and extend with params
394
- saved_locals = set(self.locals)
395
- self.locals.update(params)
396
-
397
- # Skip docstrings and emit body
398
- body_stmts = node.body
399
- if (
400
- body_stmts
401
- and isinstance(body_stmts[0], ast.Expr)
402
- and isinstance(body_stmts[0].value, ast.Constant)
403
- and isinstance(body_stmts[0].value.value, str)
404
- ):
405
- body_stmts = body_stmts[1:]
406
-
407
- stmts: list[Stmt] = [self.emit_stmt(s) for s in body_stmts]
738
+ self._push_scope(node)
739
+ try:
740
+ # Skip docstrings and emit body
741
+ body_stmts = node.body
742
+ if (
743
+ body_stmts
744
+ and isinstance(body_stmts[0], ast.Expr)
745
+ and isinstance(body_stmts[0].value, ast.Constant)
746
+ and isinstance(body_stmts[0].value.value, str)
747
+ ):
748
+ body_stmts = body_stmts[1:]
408
749
 
409
- # Restore outer locals and add function name
410
- self.locals = saved_locals
411
- self.locals.add(name)
750
+ prelude = self._function_prelude(self._current_scope())
751
+ stmts: list[Stmt] = prelude + [self.emit_stmt(s) for s in body_stmts]
752
+ finally:
753
+ self._pop_scope()
412
754
 
413
755
  is_async = isinstance(node, ast.AsyncFunctionDef)
414
756
  fn = Function(params, stmts, is_async=is_async)
415
- return Assign(name, fn, declare="const")
757
+ return Assign(name, fn)
416
758
 
417
759
  def _emit_try(self, node: ast.Try) -> Stmt:
418
760
  """Emit a try/except/finally statement."""
@@ -431,7 +773,6 @@ class Transpiler:
431
773
  handler = node.handlers[0]
432
774
  if handler.name:
433
775
  catch_param = handler.name
434
- self.locals.add(catch_param)
435
776
  catch_body = [self.emit_stmt(s) for s in handler.body]
436
777
 
437
778
  # Handle finally
@@ -508,23 +849,21 @@ class Transpiler:
508
849
 
509
850
  if isinstance(node, ast.ListComp):
510
851
  return self._emit_comprehension_chain(
511
- node.generators, lambda: self.emit_expr(node.elt)
852
+ node, lambda: self.emit_expr(node.elt)
512
853
  )
513
854
 
514
855
  if isinstance(node, ast.GeneratorExp):
515
856
  return self._emit_comprehension_chain(
516
- node.generators, lambda: self.emit_expr(node.elt)
857
+ node, lambda: self.emit_expr(node.elt)
517
858
  )
518
859
 
519
860
  if isinstance(node, ast.SetComp):
520
- arr = self._emit_comprehension_chain(
521
- node.generators, lambda: self.emit_expr(node.elt)
522
- )
861
+ arr = self._emit_comprehension_chain(node, lambda: self.emit_expr(node.elt))
523
862
  return New(Identifier("Set"), [arr])
524
863
 
525
864
  if isinstance(node, ast.DictComp):
526
865
  pairs = self._emit_comprehension_chain(
527
- node.generators,
866
+ node,
528
867
  lambda: Array([self.emit_expr(node.key), self.emit_expr(node.value)]),
529
868
  )
530
869
  return New(Identifier("Map"), [pairs])
@@ -562,14 +901,14 @@ class Transpiler:
562
901
  """Emit a name reference."""
563
902
  name = node.id
564
903
 
565
- # Check deps first
904
+ # Local variable (current or enclosing scope)
905
+ if self._is_local(name):
906
+ return Identifier(name)
907
+
908
+ # Check deps
566
909
  if name in self.deps:
567
910
  return self.deps[name]
568
911
 
569
- # Local variable
570
- if name in self.locals:
571
- return Identifier(name)
572
-
573
912
  # Check builtins
574
913
  if name in BUILTINS:
575
914
  return BUILTINS[name]
@@ -772,11 +1111,6 @@ class Transpiler:
772
1111
  if isinstance(node.slice, ast.Slice):
773
1112
  return self._emit_slice(value, node.slice)
774
1113
 
775
- # Negative index: use .at()
776
- if isinstance(node.slice, ast.UnaryOp) and isinstance(node.slice.op, ast.USub):
777
- idx_expr = self.emit_expr(node.slice.operand)
778
- return Call(Member(value, "at"), [Unary("-", idx_expr)])
779
-
780
1114
  # Delegate to Expr.transpile_subscript (default returns Subscript)
781
1115
  return value.transpile_subscript(node.slice, self)
782
1116
 
@@ -1012,29 +1346,26 @@ class Transpiler:
1012
1346
 
1013
1347
  def _emit_lambda(self, node: ast.Lambda) -> Expr:
1014
1348
  """Emit a lambda expression as an arrow function."""
1015
- params = [arg.arg for arg in node.args.args]
1349
+ params = _collect_param_names(node.args)
1016
1350
 
1017
- # Add params to locals temporarily
1018
- saved_locals = set(self.locals)
1019
- self.locals.update(params)
1020
-
1021
- body = self.emit_expr(node.body)
1022
-
1023
- self.locals = saved_locals
1351
+ self._push_scope(node)
1352
+ try:
1353
+ body = self.emit_expr(node.body)
1354
+ finally:
1355
+ self._pop_scope()
1024
1356
 
1025
1357
  return Arrow(params, body)
1026
1358
 
1027
1359
  def _emit_comprehension_chain(
1028
1360
  self,
1029
- generators: list[ast.comprehension],
1361
+ node: ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp,
1030
1362
  build_last: Callable[[], Expr],
1031
1363
  ) -> Expr:
1032
1364
  """Build a flatMap/map chain for comprehensions."""
1365
+ generators = node.generators
1033
1366
  if len(generators) == 0:
1034
1367
  raise TranspileError("Empty comprehension")
1035
1368
 
1036
- saved_locals = set(self.locals)
1037
-
1038
1369
  def build_chain(gen_index: int) -> Expr:
1039
1370
  gen = generators[gen_index]
1040
1371
  if gen.is_async:
@@ -1058,7 +1389,7 @@ class Transpiler:
1058
1389
  )
1059
1390
 
1060
1391
  for nm in names:
1061
- self.locals.add(nm)
1392
+ self._current_scope().locals.add(nm)
1062
1393
 
1063
1394
  base = iter_expr
1064
1395
 
@@ -1078,10 +1409,11 @@ class Transpiler:
1078
1409
  inner = build_chain(gen_index + 1)
1079
1410
  return Call(Member(base, "flatMap"), [Arrow(params, inner)])
1080
1411
 
1412
+ self._push_scope(node)
1081
1413
  try:
1082
1414
  return build_chain(0)
1083
1415
  finally:
1084
- self.locals = saved_locals
1416
+ self._pop_scope()
1085
1417
 
1086
1418
 
1087
1419
  def transpile(