@pineforge/codegen-pyodide 0.10.0 → 0.10.2

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.
@@ -21,8 +21,20 @@ from ..ast_nodes import (
21
21
  TupleLiteral,
22
22
  TypeDecl, EnumDecl, MethodDef, TypeField,
23
23
  )
24
- from ..symbols import PineType, Symbol, SymbolTable, TypeSpec
24
+ from ..symbols import (
25
+ PineType,
26
+ Symbol,
27
+ SymbolTable,
28
+ TypeSpec,
29
+ method_receiver_type_name,
30
+ )
25
31
  from ..errors import SourceLocation, Diagnostic, CompileError, Level, Phase
32
+ from ..method_binding import (
33
+ BoundMethodArgs,
34
+ MethodBindError,
35
+ bind_method_call,
36
+ inventory_method_signatures,
37
+ )
26
38
  from .. import signatures as sigs
27
39
  from .. import tv_input_choices as tv_in
28
40
  # Output dataclasses (contract with the codegen) live in contracts.py so
@@ -115,9 +127,16 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
115
127
  def __init__(self, ast: Program, filename: str = "<stdin>") -> None:
116
128
  self._ast = ast
117
129
  self._filename = filename
130
+ self._method_signatures = inventory_method_signatures(ast)
131
+ self._method_call_bindings: dict[
132
+ tuple[int, str], BoundMethodArgs
133
+ ] = {}
118
134
  self._symbols = SymbolTable()
119
135
  self._ta_call_sites: list[TACallSite] = []
120
136
  self._series_vars: set[str] = set()
137
+ self._series_var_members: set[str] = set()
138
+ self._series_decl_nodes: set[int] = set()
139
+ self._series_decl_bindings: set[tuple[int, str]] = set()
121
140
  self._series_bar_fields: set[str] = set()
122
141
  self._var_members: list[tuple[str, PineType, str]] = []
123
142
  self._func_infos: list[FuncInfo] = []
@@ -125,22 +144,75 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
125
144
  self._strategy_params: dict = {}
126
145
  self._diagnostics: list[Diagnostic] = []
127
146
  self._global_var_decls: list[tuple[str, PineType]] = []
147
+ # Top-level ordinary bindings are lexical global state even when a
148
+ # same-named callable history reference has already polluted the
149
+ # legacy raw ``series_vars`` set and suppresses ``global_var_decls``.
150
+ # Keep an AST-authoritative inventory for storage collision routing.
151
+ self._ordinary_global_binding_names: set[str] = {
152
+ stmt.name
153
+ for stmt in ast.body
154
+ if isinstance(stmt, VarDecl)
155
+ and not stmt.is_var
156
+ and not stmt.is_varip
157
+ }
158
+ self._ordinary_global_binding_names.update(
159
+ name
160
+ for stmt in ast.body
161
+ if isinstance(stmt, TupleAssign)
162
+ for name in stmt.names
163
+ if name != "_"
164
+ )
165
+ self._direct_program_binding_names: set[str] = {
166
+ stmt.name
167
+ for stmt in ast.body
168
+ if isinstance(stmt, VarDecl)
169
+ }
170
+ self._direct_program_binding_names.update(
171
+ name
172
+ for stmt in ast.body
173
+ if isinstance(stmt, TupleAssign)
174
+ for name in stmt.names
175
+ if name != "_"
176
+ )
177
+ # Exact declaration typing retained only for lexical-boundary checks.
178
+ # The legacy symbol/type registries are raw-name keyed and therefore
179
+ # cannot distinguish a direct script binding from a same-spelled local
180
+ # declared inside a top-level control-flow block.
181
+ self._var_decl_types_by_node: dict[
182
+ int, tuple[PineType, TypeSpec | None]
183
+ ] = {}
184
+ self._ordinary_global_binding_info: dict[
185
+ str, tuple[int, PineType, ASTNode | None]
186
+ ] = {}
187
+ self._ordinary_global_series_names: set[str] = set()
128
188
  self._global_expr_map: dict[str, Any] = {}
129
189
  self._var_member_init_exprs: dict[str, Any] = {}
190
+ # Exact declaration-node ownership for persistent vars. The member
191
+ # name can differ from ``node.name`` when sibling blocks declare the
192
+ # same raw identifier, so codegen must not reconstruct this mapping
193
+ # from names when it emits declaration-site initialization.
194
+ self._var_member_metadata_by_node: dict[int, tuple] = {}
195
+ self._var_member_type_specs_by_node: dict[int, TypeSpec | None] = {}
196
+ self._var_member_owners_by_node: dict[int, str | None] = {}
130
197
  # Block-scoped ``var``/``varip`` name-collision disambiguation.
131
198
  # Two same-named block-scoped vars in SIBLING non-global, non-function
132
199
  # scopes (e.g. ``var bool valid`` declared inside ``if A`` and again
133
200
  # inside ``if B``) would otherwise dedupe to ONE C++ member and
134
201
  # cross-contaminate. ``_block_node_stack`` tracks the enclosing
135
- # block AST nodes during analysis; ``_block_var_owner`` maps a raw
136
- # block-var name to the id() of the FIRST block that declared it;
137
- # ``_block_var_renames`` maps id(block_node) -> {raw_name: unique}
202
+ # branch/loop body owners during analysis; ``_block_var_owner`` maps a
203
+ # raw block-var name to the id() of the FIRST body that declared it;
204
+ # ``_block_var_renames`` maps id(body_owner) -> {raw_name: unique}
138
205
  # for every later colliding block so codegen can activate the
139
206
  # rename via ``_active_var_remap`` while emitting that block.
140
207
  self._block_node_stack: list[Any] = []
141
208
  self._block_var_owner: dict[str, int] = {}
142
209
  self._block_var_renames: dict[int, dict[str, str]] = {}
143
210
  self._block_var_seq = 0
211
+ # Identifier node identity -> the lexical Symbol.scope resolved while
212
+ # that node's source scope is live. Codegen uses this narrow
213
+ # provenance to distinguish true global aliases from same-named local
214
+ # shadows after the analyzer's scope stack has unwound.
215
+ self._identifier_binding_scopes: dict[int, str | None] = {}
144
216
  self._ta_counter = 0
145
217
  self._fixnan_counter = 0
146
218
  # All fixnan member names minted so far (base + clones), for O(1)
@@ -157,6 +229,8 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
157
229
  # Track user-defined function tuple returns
158
230
  self._func_returns_tuple: dict[str, bool] = {}
159
231
  self._func_tuple_element_count: dict[str, int] = {}
232
+ self._func_tuple_element_types: dict[str, tuple[PineType, ...]] = {}
233
+ self._tuple_element_types_by_node: dict[int, tuple[PineType, ...]] = {}
160
234
  # Track user-defined functions whose body returns a UDT instance —
161
235
  # maps func_name -> UDT type name. Detected from the body's final
162
236
  # expression (``=> Sample.new(...)`` or last stmt ``Sample.new(...)``).
@@ -164,18 +238,101 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
164
238
  self._func_udt_return_types: dict[str, str] = {}
165
239
  self._func_return_type_specs: dict[str, "TypeSpec"] = {}
166
240
  self._func_param_type_specs: dict[str, list] = {}
241
+ # Direct terminal gets on an unshadowed built-in temporary are
242
+ # registered while their lexical scope is live. A later-defined UDF
243
+ # used by ``array.from`` may make the first-pass scalar fallback stale;
244
+ # a bounded, side-effect-free refresh corrects only this proven shape.
245
+ self._direct_terminal_array_temporary_exprs: dict[str, ASTNode] = {}
246
+ # History receivers can mention an untyped UDF parameter before its
247
+ # concrete TypeSpec is learned from a call site. Keep the exact AST
248
+ # identifier identities that resolved to parameters during the
249
+ # definition pass; call handling re-checks those receivers once the
250
+ # argument specs are available. Node identity (rather than raw name)
251
+ # prevents a later local/loop binding with the same spelling from
252
+ # being mistaken for the parameter it shadows.
253
+ self._deferred_param_history_refs: dict[
254
+ str, list[tuple[Subscript, dict[int, str]]]
255
+ ] = {}
256
+ # Parameter flow between user callables, captured while the caller's
257
+ # lexical symbols are still in scope. This lets a later concrete map
258
+ # call revalidate history hidden behind wrappers without re-analyzing
259
+ # whole function bodies or conflating same-spelled locals.
260
+ self._deferred_param_call_edges: dict[
261
+ str,
262
+ list[
263
+ tuple[
264
+ int,
265
+ str,
266
+ list[str],
267
+ list[ASTNode | None],
268
+ list[dict[int, str]],
269
+ ]
270
+ ],
271
+ ] = {}
272
+ self._func_series_history_nodes: dict[
273
+ tuple[str, str], Subscript
274
+ ] = {}
167
275
  # Per-function var_members and series_vars (for call-site cloning)
168
276
  self._func_var_members: dict[str, list] = {} # func_name -> [(name, PineType, init_str)]
277
+ # Ordinary FuncDef raw persistent name -> exact emitted member. The
278
+ # map is identity for ordinary non-colliding functions and changes only
279
+ # when distinct persistent declarations would otherwise share a
280
+ # supported primitive/collection ``var`` owned by an ordinary FuncDef
281
+ # with the same raw spelling. Methods and top-level bindings remain on
282
+ # their established paths.
283
+ self._func_var_storage_names: dict[str, dict[str, str]] = {}
169
284
  self._func_series_vars: dict[str, set] = {} # func_name -> set[str]
285
+ # Declaration-bound non-persistent history locals are distinct from
286
+ # history parameters/global reads carried by ``func_series_vars``.
287
+ # Codegen needs this exact subset when a raw spelling also belongs to
288
+ # owner-qualified persistent state.
289
+ self._nonpersistent_series_decl_names: set[str] = set()
290
+ self._func_nonpersistent_series_vars: dict[str, set[str]] = {}
170
291
  # Per-call-site TA tracking for user functions
171
292
  self._func_ta_ranges: dict[str, tuple[int, int]] = {} # func_name -> (start, end) indices
293
+ # Exact counterpart to the legacy contiguous ranges. Borrowed nested
294
+ # sites can straddle unrelated TA allocations, so constructor argument
295
+ # substitution and codegen remaps must iterate these identities only.
296
+ self._func_ta_indices: dict[str, list[int]] = {}
297
+ # A shared TA site can be viewed through more than one callable's
298
+ # parameter names (innerLen -> outerLen). Preserve one constructor
299
+ # template per callable/site instead of mutating the site's single
300
+ # source template and relying on same-spelled forwarding parameters.
301
+ self._func_ta_ctor_args: dict[str, dict[int, list[str]]] = {}
302
+ # Exact TA-site mapping and source-template snapshot for each textual
303
+ # callable edge. Late whole-program propagation uses these to revisit
304
+ # an already-numbered edge when its callee gains another owned TA site
305
+ # (or refines an existing per-owner constructor template), without
306
+ # cloning the edge's previously-materialized state a second time.
307
+ self._func_ta_call_targets: dict[
308
+ tuple[int, int], dict[int, int]
309
+ ] = {}
310
+ self._func_ta_call_templates: dict[
311
+ int, dict[int, tuple[str, ...]]
312
+ ] = {}
172
313
  self._func_call_site_count: dict[str, int] = {} # func_name -> count
173
314
  self._func_call_cs_map: dict[int, tuple[str, int]] = {} # call_node_id -> (func_name, cs_idx)
315
+ # Primitive type facts retained per written call AST and reconciled to
316
+ # the emitted cs0/cs1/... identities after the stateful call graph is
317
+ # closed. Pine explicitly permits an untyped parameter to inherit a
318
+ # different type at each written call site.
319
+ self._callable_bound_param_types_by_node: dict[int, list[PineType]] = {}
320
+ self._func_callsite_param_types: dict[
321
+ tuple[str, int], list[PineType]
322
+ ] = {}
323
+ self._func_callsite_return_types: dict[
324
+ tuple[str, int], PineType
325
+ ] = {}
174
326
  # Textual nested calls whose identity is inherited from the active
175
327
  # parent clone rather than assigned a fixed source-level cs index.
176
328
  # Kept separately so a second propagation pass (security TF cloning)
177
329
  # does not accidentally backfill them as a new cs{N} call site.
178
330
  self._func_inherited_call_nodes: set[int] = set()
331
+ # Exact callee identity for those removed mappings. Natural parent
332
+ # clones still use active-index fallback, while a context-sensitive
333
+ # fresh parent has no active index and needs this edge identity to
334
+ # compose and dispatch its nested instance explicitly.
335
+ self._func_inherited_call_names: dict[int, str] = {}
179
336
  # Per-function fixnan site ownership: func_name -> list of fixnan site
180
337
  # indices in self._fixnan_sites owned by that function. Mirrors the
181
338
  # TA-range slicing but for fixnan state, so per-call-site cloning can
@@ -207,6 +364,23 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
207
364
  # var_name -> UDT type for variables holding UDT instances
208
365
  self._udt_var_types: dict[str, str] = {}
209
366
  self._collection_types: dict[str, TypeSpec] = {}
367
+ # Collection metadata for callable locals must retain lexical identity.
368
+ # Keys match FuncInfo.name: ``func`` for ordinary UDFs and
369
+ # ``Type.method`` for UDT methods. ``_collection_types`` remains the
370
+ # top-level/on_bar registry consumed outside callable emission.
371
+ self._func_collection_types: dict[str, dict[str, TypeSpec]] = {}
372
+ # id(immediate branch/loop owner) -> raw local name -> TypeSpec.
373
+ # A callable may legally reuse one raw name for unrelated collections
374
+ # in sibling lexical blocks; keeping those bindings in the flat
375
+ # callable inventory would make the last analyzed branch win.
376
+ self._block_collection_types: dict[int, dict[str, TypeSpec | None]] = {}
377
+ self._block_collection_owners: dict[int, str] = {}
378
+ # id(callable-local VarDecl) -> its exact collection TypeSpec, or None
379
+ # for a scalar/UDT tombstone. Codegen activates these in source order
380
+ # after emitting each declaration RHS.
381
+ self._callable_collection_bindings: dict[int, TypeSpec | None] = {}
382
+ self._callable_collection_binding_owners: dict[int, str] = {}
383
+ self._collection_scope_stack: list[str] = []
210
384
  self._udt_field_type_specs: dict[str, dict[str, TypeSpec]] = {}
211
385
  # Enum definitions: enum_name -> list of member names
212
386
  self._enum_defs: dict[str, list[str]] = {}
@@ -229,8 +403,11 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
229
403
  # pass can tell borrowed clones apart from a dead function's own
230
404
  # sites (see contracts.TACallSite.owner_func).
231
405
  self._enclosing_func_names: list[str] = []
232
- # Set of TA-site indices a nested user-func call rewrote in terms of the
233
- # current enclosing function's params (None when not inside a FuncDef body).
406
+ # Exact TA targets borrowed through nested callable edges while visiting
407
+ # the current callable body. Constructor templates are tracked
408
+ # separately in ``_func_ta_ctor_args`` because state ownership also
409
+ # matters for parameterless helpers such as ``ta.change``. ``None``
410
+ # means no callable body is active.
234
411
  self._nested_ta_touched: set | None = None
235
412
 
236
413
  # Pre-populate builtins
@@ -286,6 +463,58 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
286
463
  ]
287
464
  )
288
465
 
466
+ def _check_direct_terminal_array_element_callee_shadows(self) -> None:
467
+ """Reject terminal temporary reads whose element call is shadowed.
468
+
469
+ This is a whole-program syntactic preflight: it runs before the first
470
+ AST visit so definition order cannot leave partial function, call-site,
471
+ or TA state behind. Only bindings whose lexical lifetime reaches the
472
+ read are relevant: FuncDef parameters and declarations in the immediate
473
+ function body before the direct terminal or adjacent alias initializer.
474
+ The alias declaration is not active on its own RHS. Nested block locals
475
+ have expired and must not shadow the global UDF at either read point.
476
+ """
477
+ for func_def in self._ast.body:
478
+ if not isinstance(func_def, FuncDef):
479
+ continue
480
+ terminal = self._direct_terminal_return_expr(func_def)
481
+ element_call: FuncCall | None = None
482
+ read_index: int | None = None
483
+ if self._terminal_array_get_uses_direct_temporary(terminal):
484
+ element_call = (
485
+ self._direct_terminal_array_temporary_element_call(
486
+ terminal
487
+ )
488
+ )
489
+ read_index = len(func_def.body) - 1
490
+ else:
491
+ alias_candidate = (
492
+ self._direct_terminal_array_temporary_alias_candidate(
493
+ func_def, terminal
494
+ )
495
+ )
496
+ if alias_candidate is not None:
497
+ read_index, _, element_call = alias_candidate
498
+ if element_call is None or read_index is None:
499
+ continue
500
+
501
+ bindings = set(func_def.params)
502
+ for statement in func_def.body[:read_index]:
503
+ if isinstance(statement, VarDecl):
504
+ bindings.add(statement.name)
505
+ elif isinstance(statement, TupleAssign):
506
+ bindings.update(
507
+ name for name in statement.names if name != "_"
508
+ )
509
+ if element_call.callee.name not in bindings:
510
+ continue
511
+ self._error(
512
+ "Direct temporary-array element call "
513
+ f"'{element_call.callee.name}()' resolves to a local or "
514
+ "parameter, not a user-defined function.",
515
+ element_call.loc,
516
+ )
517
+
289
518
  # ------------------------------------------------------------------
290
519
  # Public entry point
291
520
  # ------------------------------------------------------------------
@@ -293,7 +522,17 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
293
522
  def analyze(self) -> AnalyzerContext:
294
523
  """Run semantic analysis and return the analyzer context."""
295
524
  self._ensure_pine_v6()
525
+ self._check_direct_terminal_array_element_callee_shadows()
296
526
  self._visit(self._ast)
527
+ self._check_direct_terminal_array_temporary_cycles()
528
+ self._register_resolved_direct_terminal_array_forward_calls()
529
+ self._refresh_direct_terminal_array_temporary_returns()
530
+ self._check_forward_tuple_helper_wrappers()
531
+ self._check_top_level_block_shadow_boundaries()
532
+ self._qualify_colliding_func_var_members()
533
+ self._check_cross_callable_series_collection_collisions()
534
+ self._check_declaration_exact_series_storage_boundaries()
535
+ self._check_persistent_drawing_member_collisions()
297
536
 
298
537
  # Propagate call-site counts to sub-functions called within
299
538
  # multi-call-site functions. If f() has N call sites and calls g()
@@ -327,6 +566,9 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
327
566
  symbols=self._symbols,
328
567
  ta_call_sites=self._ta_call_sites,
329
568
  series_vars=self._series_vars,
569
+ series_var_members=self._series_var_members,
570
+ series_decl_nodes=self._series_decl_nodes,
571
+ series_decl_bindings=self._series_decl_bindings,
330
572
  series_bar_fields=self._series_bar_fields,
331
573
  var_members=self._var_members,
332
574
  func_infos=self._func_infos,
@@ -338,10 +580,29 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
338
580
  filename=self._filename,
339
581
  global_var_decls=self._global_var_decls,
340
582
  global_expr_map=pure_global_expr_map,
583
+ identifier_binding_scopes=dict(self._identifier_binding_scopes),
341
584
  var_member_init_exprs=self._var_member_init_exprs,
585
+ var_member_metadata_by_node=self._var_member_metadata_by_node,
586
+ var_member_type_specs_by_node=self._var_member_type_specs_by_node,
587
+ var_member_owners_by_node=self._var_member_owners_by_node,
342
588
  func_ta_ranges=self._func_ta_ranges,
589
+ func_ta_indices=self._func_ta_indices,
343
590
  func_call_cs_map=self._func_call_cs_map,
591
+ func_inherited_call_names=dict(
592
+ self._func_inherited_call_names
593
+ ),
344
594
  func_call_site_counts=self._func_call_site_count,
595
+ func_callsite_param_types={
596
+ key: tuple(types)
597
+ for key, types in self._func_callsite_param_types.items()
598
+ },
599
+ func_callsite_return_types=dict(
600
+ self._func_callsite_return_types
601
+ ),
602
+ func_declared_param_type_specs={
603
+ name: tuple(specs)
604
+ for name, specs in self._func_param_type_specs.items()
605
+ },
345
606
  func_security_clone_only=self._func_security_clone_only,
346
607
  func_cs_ta_clone_names=self._func_cs_ta_clone_names,
347
608
  udt_defs=self._udt_fields,
@@ -349,15 +610,1527 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
349
610
  enum_member_strings=self._enum_member_strings,
350
611
  security_calls=getattr(self, "_security_calls", []),
351
612
  global_mutable_infos=mutable_global_infos,
613
+ ordinary_global_binding_names=set(
614
+ self._ordinary_global_binding_names
615
+ ),
616
+ ordinary_global_series_names=set(
617
+ self._ordinary_global_series_names
618
+ ),
352
619
  func_var_members=self._func_var_members,
620
+ func_var_storage_names={
621
+ owner: dict(names)
622
+ for owner, names in self._func_var_storage_names.items()
623
+ },
353
624
  func_series_vars=self._func_series_vars,
625
+ nonpersistent_series_decl_names=set(
626
+ self._nonpersistent_series_decl_names
627
+ ),
628
+ func_nonpersistent_series_vars={
629
+ owner: set(names)
630
+ for owner, names in (
631
+ self._func_nonpersistent_series_vars.items()
632
+ )
633
+ },
354
634
  func_return_type_specs=dict(self._func_return_type_specs),
355
635
  udt_var_types=dict(self._udt_var_types),
356
636
  collection_types=dict(self._collection_types),
637
+ func_collection_types={
638
+ name: dict(specs)
639
+ for name, specs in self._func_collection_types.items()
640
+ },
641
+ block_collection_types={
642
+ owner_id: dict(specs)
643
+ for owner_id, specs in self._block_collection_types.items()
644
+ },
645
+ block_collection_owners=dict(self._block_collection_owners),
646
+ callable_collection_bindings=dict(
647
+ self._callable_collection_bindings
648
+ ),
649
+ callable_collection_binding_owners=dict(
650
+ self._callable_collection_binding_owners
651
+ ),
357
652
  udt_field_type_specs=dict(self._udt_field_type_specs),
358
653
  block_var_renames=dict(self._block_var_renames),
359
654
  )
360
655
 
656
+ def _check_forward_tuple_helper_wrappers(self) -> None:
657
+ """Fail before codegen on a tuple wrapper calling a later definition.
658
+
659
+ Direct terminal tuple metadata can be propagated after the callee is
660
+ analyzed, but the earlier wrapper body did not register that callee's
661
+ ordinary FuncInfo/call graph. Emitting it would therefore reach an
662
+ unknown-function error in codegen. Keep the boundary explicit until
663
+ forward callable registration itself is implemented.
664
+ """
665
+ positions = {
666
+ stmt.name: index
667
+ for index, stmt in enumerate(self._ast.body)
668
+ if isinstance(stmt, FuncDef)
669
+ }
670
+ for wrapper_name, wrapper_def in self._func_defs.items():
671
+ terminal = self._direct_terminal_return_expr(wrapper_def)
672
+ if not (
673
+ isinstance(terminal, FuncCall)
674
+ and isinstance(terminal.callee, Identifier)
675
+ ):
676
+ continue
677
+ callee_name = terminal.callee.name
678
+ if not self._func_returns_tuple.get(callee_name, False):
679
+ continue
680
+ if positions.get(callee_name, -1) <= positions.get(wrapper_name, -1):
681
+ continue
682
+ self._error(
683
+ "Tuple-return helper wrapper "
684
+ f"'{wrapper_name}' calls later-defined helper '{callee_name}'; "
685
+ "forward tuple-wrapper calls are not supported yet. "
686
+ f"Move '{callee_name}' above '{wrapper_name}' before passing "
687
+ "the wrapper to request.security().",
688
+ terminal.loc,
689
+ )
690
+
691
+ def _refresh_direct_typed_method_wrapper_returns(self) -> None:
692
+ """Reconcile direct sibling-method return types without revisiting AST.
693
+
694
+ Method signatures are inventoried before source-order analysis so a
695
+ call to a later declaration binds to the authored method, but the
696
+ callee's body-derived return type does not exist yet. A direct wrapper
697
+ such as ``outer(H self) => self.inner()`` therefore initially receives
698
+ the generic scalar fallback. Once declarations are registered, copy
699
+ the exact terminal callee contract through direct first-parameter
700
+ receiver edges to a fixed point. This pass is deliberately structural: it does
701
+ not call ``_visit`` and cannot duplicate TA, fixnan or written-callsite
702
+ state.
703
+ """
704
+ infos = {info.name: info for info in self._func_infos}
705
+ if not infos:
706
+ return
707
+
708
+ def direct_callee_key(info: FuncInfo) -> str | None:
709
+ node = info.node
710
+ if (
711
+ node is None
712
+ or not info.is_udt_method
713
+ or not node.params
714
+ ):
715
+ return None
716
+ terminal = self._direct_terminal_return_expr(node)
717
+ if not (
718
+ isinstance(terminal, FuncCall)
719
+ and isinstance(terminal.callee, MemberAccess)
720
+ and isinstance(terminal.callee.object, Identifier)
721
+ and terminal.callee.object.name == node.params[0]
722
+ ):
723
+ return None
724
+ if not info.param_type_specs:
725
+ return None
726
+ receiver_name = method_receiver_type_name(
727
+ info.param_type_specs[0]
728
+ )
729
+ if receiver_name is None:
730
+ return None
731
+ return f"{receiver_name}.{terminal.callee.member}"
732
+
733
+ # One newly resolved leaf can unlock one wrapper edge per pass. Cycles
734
+ # without an exact leaf retain their existing fail-closed fallback.
735
+ for _ in range(len(infos) + 1):
736
+ changed = False
737
+ for wrapper in infos.values():
738
+ callee_key = direct_callee_key(wrapper)
739
+ callee = infos.get(callee_key) if callee_key else None
740
+ if callee is None:
741
+ continue
742
+
743
+ if (
744
+ callee.return_type not in {PineType.UNKNOWN, PineType.VOID}
745
+ and wrapper.return_type != callee.return_type
746
+ ):
747
+ wrapper.return_type = callee.return_type
748
+ self._func_return_types[wrapper.name] = callee.return_type
749
+ changed = True
750
+
751
+ callee_spec = callee.return_type_spec
752
+ if (
753
+ callee_spec is not None
754
+ and wrapper.return_type_spec != callee_spec
755
+ ):
756
+ wrapper.return_type_spec = callee_spec
757
+ self._func_return_type_specs[wrapper.name] = callee_spec
758
+ changed = True
759
+
760
+ if (
761
+ callee.udt_return_type is not None
762
+ and wrapper.udt_return_type != callee.udt_return_type
763
+ ):
764
+ wrapper.udt_return_type = callee.udt_return_type
765
+ self._func_udt_return_types[wrapper.name] = (
766
+ callee.udt_return_type
767
+ )
768
+ changed = True
769
+ if not changed:
770
+ break
771
+
772
+ def _refresh_direct_terminal_array_temporary_returns(self) -> None:
773
+ """Reconcile exact primitive returns without revisiting call sites.
774
+
775
+ ``array.from(later_defined_udf()).get(0)`` can be visited before the
776
+ producer's return type exists. The ordinary fallback is then float,
777
+ while final codegen correctly learns the producer's primitive type.
778
+ Re-run only a preregistered, direct built-in temporary shape using the
779
+ cached structural resolver. It never calls ``_visit`` and therefore
780
+ cannot mint phantom TA/series/fixnan call-site state.
781
+ """
782
+ pending = self._direct_terminal_array_temporary_exprs
783
+ if not pending:
784
+ return
785
+
786
+ # One new exact result can unlock one earlier direct dependency per
787
+ # pass. Cycles retain their existing fail-closed fallback.
788
+ for _ in range(len(pending) + 1):
789
+ changed = False
790
+ for name, terminal in pending.items():
791
+ spec = self._cached_terminal_temporary_array_get_spec(terminal)
792
+ if spec is None or spec.kind != "primitive":
793
+ continue
794
+ pine_type = self._element_pine_type(spec)
795
+ if pine_type in (PineType.UNKNOWN, PineType.VOID):
796
+ continue
797
+ if (
798
+ self._func_return_types.get(name) == pine_type
799
+ and self._func_return_type_specs.get(name) == spec
800
+ ):
801
+ continue
802
+ self._func_return_types[name] = pine_type
803
+ self._func_return_type_specs[name] = spec
804
+ symbol = self._symbols.resolve(name)
805
+ if symbol is not None:
806
+ symbol.pine_type = pine_type
807
+ symbol.type_spec = spec
808
+ for func_info in self._func_infos:
809
+ if func_info.name == name:
810
+ func_info.return_type = pine_type
811
+ func_info.return_type_spec = spec
812
+ changed = True
813
+ if not changed:
814
+ break
815
+
816
+ def _direct_terminal_array_temporary_return_expr(
817
+ self,
818
+ func_def: FuncDef,
819
+ terminal: ASTNode | None,
820
+ ) -> ASTNode | None:
821
+ """Return the exact temporary read that determines a UDF return.
822
+
823
+ The established path accepts a direct terminal ``array.get``. A
824
+ single ordinary local may also carry that same value to a bare
825
+ terminal identity return::
826
+
827
+ reader() =>
828
+ value = array.from(later()).get(0)
829
+ value
830
+
831
+ Capture only that adjacent one-hop lexical shape while the function
832
+ scope is still live. Deferred reconciliation can then reuse the
833
+ initializer AST without re-visiting it after ``later`` is known.
834
+ Persistent/typed locals, alias chains, intervening statements, block
835
+ bindings, argument-bearing element calls, and every non-direct
836
+ producer stay outside this path.
837
+ """
838
+ if self._terminal_array_get_uses_direct_temporary(terminal):
839
+ return terminal
840
+ alias_candidate = self._direct_terminal_array_temporary_alias_candidate(
841
+ func_def, terminal
842
+ )
843
+ if alias_candidate is None:
844
+ return None
845
+ _, declaration, element_call = alias_candidate
846
+
847
+ # Raw-name lookup is safe only because the exact declaration identity
848
+ # was attached to its live lexical Symbol by _visit_VarDecl.
849
+ symbol = self._symbols.resolve(terminal.name)
850
+ if (
851
+ symbol is None
852
+ or getattr(symbol, "_pf_decl_node_id", None) != id(declaration)
853
+ ):
854
+ return None
855
+
856
+ known_definition = self._func_defs.get(element_call.callee.name)
857
+ if known_definition is not None and known_definition.params:
858
+ return None
859
+ return declaration.value
860
+
861
+ def _direct_terminal_array_temporary_alias_candidate(
862
+ self,
863
+ func_def: FuncDef,
864
+ terminal: ASTNode | None,
865
+ ) -> tuple[int, VarDecl, FuncCall] | None:
866
+ """Return one exact adjacent identity alias and its element call.
867
+
868
+ This helper is deliberately syntactic so the same program point can be
869
+ checked before any AST visit and captured later while declaration
870
+ identity is live. The declaration itself is therefore excluded from
871
+ the caller's active-binding scan: Pine evaluates its initializer before
872
+ introducing the new local name.
873
+ """
874
+ if not isinstance(terminal, Identifier):
875
+ return None
876
+
877
+ declarations = [
878
+ (index, stmt)
879
+ for index, stmt in enumerate(func_def.body[:-1])
880
+ if isinstance(stmt, VarDecl) and stmt.name == terminal.name
881
+ ]
882
+ if len(declarations) != 1:
883
+ return None
884
+ declaration_index, declaration = declarations[0]
885
+ if (
886
+ declaration.is_var
887
+ or declaration.is_varip
888
+ or declaration.type_hint is not None
889
+ or declaration_index != len(func_def.body) - 2
890
+ ):
891
+ return None
892
+
893
+ initializer = declaration.value
894
+ if not self._terminal_array_get_uses_direct_temporary(initializer):
895
+ return None
896
+ element_call = self._direct_terminal_array_temporary_element_call(
897
+ initializer
898
+ )
899
+ if (
900
+ element_call is None
901
+ or element_call.args
902
+ or element_call.kwargs
903
+ ):
904
+ return None
905
+ return declaration_index, declaration, element_call
906
+
907
+ @classmethod
908
+ def _direct_terminal_array_temporary_element_call(
909
+ cls,
910
+ terminal: ASTNode | None,
911
+ ) -> FuncCall | None:
912
+ """Return a direct UDF element call from the registered shape."""
913
+ if not isinstance(terminal, FuncCall) or not isinstance(
914
+ terminal.callee, MemberAccess
915
+ ):
916
+ return None
917
+ callee = terminal.callee
918
+ if callee.member != "get":
919
+ return None
920
+ if isinstance(callee.object, Identifier) and callee.object.name == "array":
921
+ receiver = (
922
+ terminal.args[0]
923
+ if terminal.args
924
+ else terminal.kwargs.get("id")
925
+ )
926
+ else:
927
+ receiver = callee.object
928
+
929
+ while True:
930
+ copy_source = cls._direct_namespace_array_copy_source(receiver)
931
+ if copy_source is not None:
932
+ receiver = copy_source
933
+ continue
934
+ if (
935
+ isinstance(receiver, FuncCall)
936
+ and isinstance(receiver.callee, MemberAccess)
937
+ and receiver.callee.member == "copy"
938
+ and not receiver.args
939
+ and not receiver.kwargs
940
+ ):
941
+ receiver = receiver.callee.object
942
+ continue
943
+ break
944
+ if not (
945
+ isinstance(receiver, FuncCall)
946
+ and isinstance(receiver.callee, MemberAccess)
947
+ and isinstance(receiver.callee.object, Identifier)
948
+ and receiver.callee.object.name == "array"
949
+ and receiver.callee.member == "from"
950
+ and receiver.args
951
+ ):
952
+ return None
953
+ element = receiver.args[0]
954
+ if (
955
+ isinstance(element, FuncCall)
956
+ and isinstance(element.callee, Identifier)
957
+ ):
958
+ return element
959
+ return None
960
+
961
+ @classmethod
962
+ def _direct_terminal_array_temporary_user_call(
963
+ cls,
964
+ terminal: ASTNode | None,
965
+ ) -> FuncCall | None:
966
+ """Return a forward-registerable namespace-functional element call."""
967
+ if not (
968
+ isinstance(terminal, FuncCall)
969
+ and isinstance(terminal.callee, MemberAccess)
970
+ and isinstance(terminal.callee.object, Identifier)
971
+ and terminal.callee.object.name == "array"
972
+ ):
973
+ return None
974
+ element = cls._direct_terminal_array_temporary_element_call(terminal)
975
+ if element is not None and not element.args and not element.kwargs:
976
+ return element
977
+ return None
978
+
979
+ def _register_resolved_direct_terminal_array_forward_calls(self) -> None:
980
+ """Register a formerly forward zero-argument element call once.
981
+
982
+ The initial lexical visit cannot dispatch a later-defined UDF. Once
983
+ its definition exists, ordinary call handling can safely register the
984
+ call because this deliberately bounded shape has no arguments whose
985
+ lexical bindings could have gone out of scope.
986
+ """
987
+ for terminal in self._direct_terminal_array_temporary_exprs.values():
988
+ call = self._direct_terminal_array_temporary_user_call(terminal)
989
+ if call is None:
990
+ continue
991
+ name = call.callee.name
992
+ if name not in self._func_defs:
993
+ continue
994
+ # Regular UDF default arguments are not emitted in C++ today.
995
+ # A syntactically empty call is forward-registerable only when the
996
+ # declaration itself is genuinely zero-parameter.
997
+ if self._func_defs[name].params:
998
+ continue
999
+ stateful = (
1000
+ name in self._func_ta_ranges
1001
+ or name in self._func_series_vars
1002
+ or name in self._func_var_members
1003
+ or name in self._func_fixnan_indices
1004
+ )
1005
+ if stateful and id(call) in self._func_call_cs_map:
1006
+ continue
1007
+ if not stateful and any(
1008
+ func_info.name == name for func_info in self._func_infos
1009
+ ):
1010
+ continue
1011
+ self._handle_user_func_call(name, call)
1012
+
1013
+ def _check_direct_terminal_array_temporary_cycles(self) -> None:
1014
+ """Reject recursion reached through a temporary-reader UDF edge.
1015
+
1016
+ Pine forbids recursive UDF execution. Forward registration makes a
1017
+ formerly unknown helper visible, so preserve the language boundary
1018
+ explicitly instead of generating a C++ recursion that only fails at
1019
+ runtime.
1020
+ """
1021
+ wrappers = self._direct_terminal_array_temporary_exprs
1022
+ known = set(self._func_defs)
1023
+
1024
+ def callees(node: Any, seen: set[int] | None = None) -> set[str]:
1025
+ if node is None:
1026
+ return set()
1027
+ if seen is None:
1028
+ seen = set()
1029
+ if isinstance(node, (list, tuple, dict)) or hasattr(node, "__dict__"):
1030
+ node_id = id(node)
1031
+ if node_id in seen:
1032
+ return set()
1033
+ seen.add(node_id)
1034
+ if isinstance(node, (list, tuple)):
1035
+ return set().union(*(callees(item, seen) for item in node))
1036
+ if isinstance(node, dict):
1037
+ return set().union(*(callees(item, seen) for item in node.values()))
1038
+ if not hasattr(node, "__dict__"):
1039
+ return set()
1040
+ found: set[str] = set()
1041
+ if (
1042
+ isinstance(node, FuncCall)
1043
+ and isinstance(node.callee, Identifier)
1044
+ and node.callee.name in known
1045
+ ):
1046
+ found.add(node.callee.name)
1047
+ for value in vars(node).values():
1048
+ found.update(callees(value, seen))
1049
+ return found
1050
+
1051
+ graph = {
1052
+ name: callees(func_def.body)
1053
+ for name, func_def in self._func_defs.items()
1054
+ }
1055
+
1056
+ def path_to(start: str, target: str) -> list[str] | None:
1057
+ stack: list[tuple[str, list[str]]] = [(start, [start])]
1058
+ visited: set[str] = set()
1059
+ while stack:
1060
+ name, path = stack.pop()
1061
+ if name == target:
1062
+ return path
1063
+ if name in visited:
1064
+ continue
1065
+ visited.add(name)
1066
+ for child in sorted(graph.get(name, ()), reverse=True):
1067
+ stack.append((child, [*path, child]))
1068
+ return None
1069
+
1070
+ for owner, terminal in wrappers.items():
1071
+ element = self._direct_terminal_array_temporary_element_call(
1072
+ terminal
1073
+ )
1074
+ if element is None or element.callee.name not in known:
1075
+ continue
1076
+ return_path = path_to(element.callee.name, owner)
1077
+ if return_path is None:
1078
+ continue
1079
+ cycle = [owner, *return_path]
1080
+ self._error(
1081
+ "Recursive direct temporary-array reader cycle is not "
1082
+ f"supported: {' -> '.join(cycle)}.",
1083
+ element.loc,
1084
+ )
1085
+
1086
+ def _qualify_colliding_func_var_members(self) -> None:
1087
+ """Give colliding ordinary-UDF primitive/collection vars distinct storage.
1088
+
1089
+ Pine function locals are lexical, but persistent locals are lowered to
1090
+ generated class members. Historically the direct FuncDef path used the
1091
+ raw Pine spelling as that member identity, so two functions declaring
1092
+ ``var float state`` shared both the member and the raw-name-keyed
1093
+ initializer cache. Separate init flags did not help: both flags still
1094
+ guarded writes into one member, and the later definition's initializer
1095
+ won for *both* functions.
1096
+
1097
+ Keep this first migration deliberately bounded:
1098
+
1099
+ * direct persistent declarations in ordinary ``FuncDef`` bodies only;
1100
+ * only groups whose exact types are primitive or a supported collection;
1101
+ * only when its member collides with another persistent/global binding;
1102
+ * no output change for a non-colliding source.
1103
+
1104
+ ``func_var_members`` deliberately retains raw lexical names. The new
1105
+ ``func_var_storage_names`` overlay lets codegen clone exact members and
1106
+ activate raw->storage remaps at each declaration site without making a
1107
+ future local shadow earlier global reads.
1108
+ """
1109
+ direct_node_ids_by_owner = {
1110
+ owner: {
1111
+ id(stmt)
1112
+ for stmt in func_def.body
1113
+ if isinstance(stmt, VarDecl)
1114
+ and (stmt.is_var or stmt.is_varip)
1115
+ }
1116
+ for owner, func_def in self._func_defs.items()
1117
+ }
1118
+
1119
+ bindings_by_member: dict[str, list[int]] = {}
1120
+ for node_id, meta in self._var_member_metadata_by_node.items():
1121
+ bindings_by_member.setdefault(meta[1], []).append(node_id)
1122
+ ordinary_global_members = set(self._ordinary_global_binding_names)
1123
+ nonpersistent_series_raw_names = {
1124
+ name
1125
+ for node_id, name in self._series_decl_bindings
1126
+ if node_id not in self._var_member_metadata_by_node
1127
+ }
1128
+
1129
+ qualifiable_bindings: list[tuple[int, str, VarDecl]] = []
1130
+ for node_id, meta in self._var_member_metadata_by_node.items():
1131
+ node, _member_name, _ptype, _init, is_callable_scoped = meta
1132
+ owner = self._var_member_owners_by_node.get(node_id)
1133
+ if (not is_callable_scoped
1134
+ or owner not in self._func_defs
1135
+ or node_id not in direct_node_ids_by_owner.get(owner, set())
1136
+ or not isinstance(node, VarDecl)):
1137
+ continue
1138
+ self._func_var_storage_names.setdefault(owner, {})[node.name] = (
1139
+ meta[1]
1140
+ )
1141
+ spec = self._var_member_type_specs_by_node.get(node_id)
1142
+ reserves_raw_series = (
1143
+ meta[1] in nonpersistent_series_raw_names
1144
+ # Preserve the established, more specific scalar-Series vs
1145
+ # persistent-map fail-closed diagnostic. Primitive/array/
1146
+ # matrix storage can still move out of the raw Series name.
1147
+ and (spec is None or spec.kind != "map")
1148
+ )
1149
+
1150
+ # Only a storage identity that is currently shared needs
1151
+ # migration. This includes global-vs-UDF and method-vs-UDF
1152
+ # collisions, plus a declaration-bound non-persistent Series that
1153
+ # needs to retain the raw member spelling, not just two ordinary
1154
+ # UDF owners.
1155
+ if (len(bindings_by_member.get(meta[1], [])) < 2
1156
+ and meta[1] not in ordinary_global_members
1157
+ and not reserves_raw_series):
1158
+ continue
1159
+
1160
+ # The current overlay is owner/raw keyed. If this owner has two
1161
+ # declaration nodes already sharing the member (for example a
1162
+ # direct local plus a first nested-block shadow), it cannot route
1163
+ # them independently. Leave both untouched so the exact-member
1164
+ # collision diagnostic below fails closed instead of pretending
1165
+ # the owner was repaired while silently targeting the outer var.
1166
+ same_owner_nodes = [
1167
+ other_id
1168
+ for other_id in bindings_by_member[meta[1]]
1169
+ if self._var_member_owners_by_node.get(other_id) == owner
1170
+ ]
1171
+ if len(same_owner_nodes) > 1:
1172
+ continue
1173
+
1174
+ if (spec is not None
1175
+ and spec.kind in {
1176
+ "primitive", "array", "map", "matrix",
1177
+ }):
1178
+ qualifiable_bindings.append((node_id, owner, node))
1179
+
1180
+ # Protect every existing user/class identity, including names declared
1181
+ # later in source. The deterministic allocator advances its leading
1182
+ # token when the user already occupies a base or derived clone spelling.
1183
+ used_names = {
1184
+ name for name, _ptype, _init in self._var_members
1185
+ }
1186
+ used_names.update(name for name, _ptype in self._global_var_decls)
1187
+ used_names.update(self._series_vars)
1188
+ used_names.update(self._func_defs)
1189
+
1190
+ # Generated members are referenced as bare identifiers inside emitted
1191
+ # methods. Protect against every authored identifier, not only class
1192
+ # members: a parameter/plain local/loop binder named like the helper
1193
+ # token (or one of its ``_csN`` / ``__niN`` clones) would otherwise
1194
+ # shadow the intended member in C++.
1195
+ authored_names: set[str] = set()
1196
+
1197
+ def collect_authored_names(value: Any) -> None:
1198
+ if isinstance(value, Identifier):
1199
+ authored_names.add(value.name)
1200
+ if isinstance(value, VarDecl):
1201
+ authored_names.add(value.name)
1202
+ elif isinstance(value, TupleAssign):
1203
+ authored_names.update(
1204
+ name for name in value.names if name != "_"
1205
+ )
1206
+ elif isinstance(value, ForStmt):
1207
+ if value.var:
1208
+ authored_names.add(value.var)
1209
+ elif isinstance(value, ForInStmt):
1210
+ if value.var:
1211
+ authored_names.add(value.var)
1212
+ authored_names.update(
1213
+ name for name in (value.vars or []) if name != "_"
1214
+ )
1215
+ elif isinstance(value, (FuncDef, MethodDef)):
1216
+ authored_names.add(value.name)
1217
+ authored_names.update(value.params)
1218
+ if isinstance(value, ASTNode):
1219
+ for child in vars(value).values():
1220
+ collect_authored_names(child)
1221
+ elif isinstance(value, (list, tuple)):
1222
+ for child in value:
1223
+ collect_authored_names(child)
1224
+ elif isinstance(value, dict):
1225
+ for child in value.values():
1226
+ collect_authored_names(child)
1227
+
1228
+ collect_authored_names(self._ast)
1229
+ used_names.update(authored_names)
1230
+
1231
+ allocated_storage_names: set[str] = set()
1232
+
1233
+ def generated_namespace_overlaps(candidate: str, other: str) -> bool:
1234
+ """Whether either base can be emitted as the other's clone.
1235
+
1236
+ Stateful call-site and nested-instance storage is derived by
1237
+ appending ``_csN`` / ``__niN`` to the persistent member base. A
1238
+ distinct owner can legitimately make one of those suffixes part
1239
+ of its authored function name (for example ``left`` and
1240
+ ``left_cs1``), so exact-name reservation alone is insufficient.
1241
+ Reserve the derived namespaces symmetrically, independent of
1242
+ source-definition order.
1243
+ """
1244
+ return any(
1245
+ candidate.startswith(f"{other}{suffix}")
1246
+ or other.startswith(f"{candidate}{suffix}")
1247
+ for suffix in ("_cs", "__ni")
1248
+ )
1249
+
1250
+ def helper_name_conflicts(candidate: str) -> bool:
1251
+ return (
1252
+ candidate in used_names
1253
+ or any(
1254
+ generated_namespace_overlaps(candidate, name)
1255
+ for name in used_names
1256
+ )
1257
+ or any(
1258
+ generated_namespace_overlaps(candidate, allocated)
1259
+ for allocated in allocated_storage_names
1260
+ )
1261
+ )
1262
+
1263
+ storage_sequence = 0
1264
+ for node_id, owner, node in qualifiable_bindings:
1265
+ # Start in a codegen-reserved namespace so an adversarial but
1266
+ # valid raw name such as ``_fvinit_left`` cannot equal a
1267
+ # generated function-init flag. Put a monotonically allocated
1268
+ # token before every authored component: appending ``_2`` to a
1269
+ # clone-shaped base (``left_cs1``) would remain inside the
1270
+ # earlier base's ``_cs*`` namespace forever. Advancing this
1271
+ # leading token instead always reaches a disjoint namespace.
1272
+ # Non-colliding sources never enter this path and therefore
1273
+ # retain byte-identical output.
1274
+ while True:
1275
+ storage_sequence += 1
1276
+ member_name = (
1277
+ f"_pfv_{storage_sequence}_{node.name}__{owner}"
1278
+ )
1279
+ if not helper_name_conflicts(member_name):
1280
+ break
1281
+ used_names.add(member_name)
1282
+ allocated_storage_names.add(member_name)
1283
+
1284
+ old = self._var_member_metadata_by_node[node_id]
1285
+ self._var_member_metadata_by_node[node_id] = (
1286
+ old[0], member_name, old[2], old[3], old[4],
1287
+ )
1288
+ self._func_var_storage_names[owner][node.name] = member_name
1289
+
1290
+ qualified_raw_names = {
1291
+ raw_name
1292
+ for storage_names in self._func_var_storage_names.values()
1293
+ for raw_name, storage_name in storage_names.items()
1294
+ if storage_name != raw_name
1295
+ }
1296
+ exact_series_bindings = set(self._series_decl_bindings)
1297
+ exact_series_node_ids = {
1298
+ node_id for node_id, _name in exact_series_bindings
1299
+ }
1300
+ for name, (node_id, ptype, expr) in (
1301
+ self._ordinary_global_binding_info.items()
1302
+ ):
1303
+ is_exact_series = (
1304
+ (node_id, name) in exact_series_bindings
1305
+ or (
1306
+ node_id in self._series_decl_nodes
1307
+ and node_id not in exact_series_node_ids
1308
+ )
1309
+ )
1310
+ if is_exact_series:
1311
+ self._ordinary_global_series_names.add(name)
1312
+ continue
1313
+ if name not in qualified_raw_names:
1314
+ continue
1315
+ # A same-named callable history binding can suppress this scalar
1316
+ # or collection from the legacy raw ``global_var_decls`` list.
1317
+ # Restore only collision-qualified globals from their exact
1318
+ # declaration record; all unrelated generated output stays stable.
1319
+ if not any(existing == name for existing, _ in self._global_var_decls):
1320
+ self._global_var_decls.append((name, ptype))
1321
+ if expr is not None:
1322
+ self._global_expr_map[name] = expr
1323
+
1324
+ # Rebuild the two flat, member-keyed inventories from the exact
1325
+ # declaration metadata. Dict insertion order is source-analysis order;
1326
+ # unresolved out-of-scope raw collisions retain their established
1327
+ # last-initializer-wins behavior rather than being silently broadened.
1328
+ self._var_members = []
1329
+ self._var_member_init_exprs = {}
1330
+ for _node_id, meta in self._var_member_metadata_by_node.items():
1331
+ node, member_name, ptype, init_str, _callable = meta
1332
+ self._var_members.append((member_name, ptype, init_str))
1333
+ if node.value is not None:
1334
+ self._var_member_init_exprs[member_name] = node.value
1335
+
1336
+ # History tracking is declaration-exact. Replace only the persistent
1337
+ # member portion of the set so renamed Series members (and later their
1338
+ # call-site clones) keep the right storage type.
1339
+ old_persistent_members = {
1340
+ meta[1] for meta in self._var_member_metadata_by_node.values()
1341
+ }
1342
+ # The set above contains the post-rename names; include pre-rename raw
1343
+ # spellings from every declaration to remove stale entries as well.
1344
+ old_persistent_members.update(
1345
+ meta[0].name for meta in self._var_member_metadata_by_node.values()
1346
+ )
1347
+ nonpersistent_series = (
1348
+ self._series_var_members - old_persistent_members
1349
+ )
1350
+ exact_persistent_series = {
1351
+ self._var_member_metadata_by_node[node_id][1]
1352
+ for node_id in self._series_decl_nodes
1353
+ if node_id in self._var_member_metadata_by_node
1354
+ }
1355
+ self._series_var_members = (
1356
+ nonpersistent_series | exact_persistent_series
1357
+ )
1358
+
1359
+ # Any exact member identity still owned by multiple persistent
1360
+ # declarations is outside the owner/raw overlay's safe scope (for
1361
+ # example two UDT methods, or a direct UDF var plus its first
1362
+ # same-named nested-block shadow). Do not retain the historical
1363
+ # last-initializer-wins behavior: fail closed until that declaration
1364
+ # shape has declaration-exact codegen remapping. Drawing collisions
1365
+ # keep their more specific diagnostic in the following check.
1366
+ from .types import _DRAWING_TYPE_NAMES
1367
+
1368
+ remaining_by_member: dict[str, list[int]] = {}
1369
+ for node_id, meta in self._var_member_metadata_by_node.items():
1370
+ remaining_by_member.setdefault(meta[1], []).append(node_id)
1371
+
1372
+ for member_name, node_ids in remaining_by_member.items():
1373
+ binding_count = len(node_ids) + int(
1374
+ member_name in ordinary_global_members
1375
+ )
1376
+ if binding_count < 2:
1377
+ continue
1378
+ specs = [
1379
+ self._var_member_type_specs_by_node.get(node_id)
1380
+ for node_id in node_ids
1381
+ ]
1382
+ if any(
1383
+ spec is not None
1384
+ and spec.kind == "udt"
1385
+ and spec.name in _DRAWING_TYPE_NAMES
1386
+ for spec in specs
1387
+ ):
1388
+ continue
1389
+ # An ordinary global contributes to ``binding_count`` without a
1390
+ # persistent declaration-node entry, so a single unsupported
1391
+ # persistent binding can still reach this diagnostic.
1392
+ node = self._var_member_metadata_by_node[node_ids[-1]][0]
1393
+ raw_name = getattr(node, "name", member_name)
1394
+ self._error(
1395
+ "Persistent bindings named "
1396
+ f"'{raw_name}' still share generated storage across distinct "
1397
+ "lexical declarations; this declaration shape is not "
1398
+ "supported yet.",
1399
+ node.loc,
1400
+ )
1401
+
1402
+ def _check_declaration_exact_series_storage_boundaries(self) -> None:
1403
+ """Reject raw Series identities that still cross lexical owners.
1404
+
1405
+ Non-persistent history locals are currently emitted as class-level
1406
+ ``Series`` members under their raw Pine spelling. That is safe for a
1407
+ single declaration, but the raw member is shared (or suppressed by a
1408
+ persistent member) when the spelling is reused by another lexical
1409
+ declaration. Parameters are intentionally absent from
1410
+ ``series_decl_bindings`` and remain legal: their history buffers are
1411
+ routed by the existing callable parameter path.
1412
+
1413
+ Keep this boundary declaration-exact and fail closed until ordinary
1414
+ local Series storage has the same owner-qualified overlay as persistent
1415
+ callable state. Direct script Series declarations remain supported,
1416
+ including a same-named persistent UDF member that was qualified above.
1417
+ """
1418
+ exact_series = set(self._series_decl_bindings)
1419
+ if not exact_series:
1420
+ return
1421
+
1422
+ # A helper reached exclusively through a request.security expression
1423
+ # does not use the ordinary raw class Series member: the security
1424
+ # emitter keys its local history by evaluator/function/source identity.
1425
+ # Preserve that proven isolation (including transitive helper calls)
1426
+ # while keeping any helper also reachable on the chart path subject to
1427
+ # the ordinary raw-storage collision checks below.
1428
+ known_func_names = set(self._func_defs)
1429
+ security_expression_ids = {
1430
+ id(sec.expression)
1431
+ for sec in getattr(self, "_security_calls", []) or []
1432
+ if getattr(sec, "expression", None) is not None
1433
+ }
1434
+ ordinary_roots: set[str] = set()
1435
+ security_roots: set[str] = set()
1436
+ call_edges: dict[str, set[str]] = {}
1437
+
1438
+ def scan_calls(
1439
+ value: Any,
1440
+ owner: str | None,
1441
+ in_security: bool = False,
1442
+ ) -> None:
1443
+ if value is None:
1444
+ return
1445
+ in_security = in_security or id(value) in security_expression_ids
1446
+ if isinstance(value, (FuncDef, MethodDef)):
1447
+ return
1448
+ if isinstance(value, FuncCall):
1449
+ callee = value.callee
1450
+ if (isinstance(callee, Identifier)
1451
+ and callee.name in known_func_names):
1452
+ if in_security:
1453
+ security_roots.add(callee.name)
1454
+ elif owner is None:
1455
+ ordinary_roots.add(callee.name)
1456
+ else:
1457
+ call_edges.setdefault(owner, set()).add(callee.name)
1458
+ if isinstance(value, (list, tuple)):
1459
+ for item in value:
1460
+ scan_calls(item, owner, in_security)
1461
+ return
1462
+ if isinstance(value, dict):
1463
+ for item in value.values():
1464
+ scan_calls(item, owner, in_security)
1465
+ return
1466
+ if isinstance(value, ASTNode):
1467
+ for child in vars(value).values():
1468
+ scan_calls(child, owner, in_security)
1469
+
1470
+ scan_calls(self._ast.body, None)
1471
+ for owner, func_def in self._func_defs.items():
1472
+ scan_calls(func_def.body, owner)
1473
+
1474
+ def reachable(roots: set[str]) -> set[str]:
1475
+ found = set(roots)
1476
+ pending = list(roots)
1477
+ while pending:
1478
+ owner = pending.pop()
1479
+ for callee in call_edges.get(owner, set()):
1480
+ if callee in found:
1481
+ continue
1482
+ found.add(callee)
1483
+ pending.append(callee)
1484
+ return found
1485
+
1486
+ security_evaluator_only = (
1487
+ reachable(security_roots) - reachable(ordinary_roots)
1488
+ )
1489
+
1490
+ self._nonpersistent_series_decl_names = {
1491
+ name
1492
+ for node_id, name in exact_series
1493
+ if node_id not in self._var_member_metadata_by_node
1494
+ }
1495
+ self._func_nonpersistent_series_vars = {}
1496
+
1497
+ persistent_raw_names = {
1498
+ meta[0].name
1499
+ for meta in self._var_member_metadata_by_node.values()
1500
+ if isinstance(meta[0], VarDecl)
1501
+ }
1502
+ unqualified_persistent_raw_names = {
1503
+ meta[0].name
1504
+ for meta in self._var_member_metadata_by_node.values()
1505
+ if (isinstance(meta[0], VarDecl)
1506
+ and meta[1] == meta[0].name)
1507
+ }
1508
+ callable_nonpersistent: dict[
1509
+ str, list[tuple[str, ASTNode]]
1510
+ ] = {}
1511
+ block_nonpersistent: dict[str, list[ASTNode]] = {}
1512
+
1513
+ def record_bindings(
1514
+ stmt: ASTNode,
1515
+ names: list[str],
1516
+ callable_owner: str | None,
1517
+ direct_program: bool,
1518
+ ) -> None:
1519
+ is_persistent = id(stmt) in self._var_member_metadata_by_node
1520
+ if is_persistent:
1521
+ return
1522
+ for name in names:
1523
+ if name == "_" or (id(stmt), name) not in exact_series:
1524
+ continue
1525
+ if callable_owner is not None:
1526
+ self._func_nonpersistent_series_vars.setdefault(
1527
+ callable_owner, set()
1528
+ ).add(name)
1529
+ if (callable_owner in security_evaluator_only
1530
+ and name not in self._ordinary_global_binding_names
1531
+ and name not in unqualified_persistent_raw_names):
1532
+ continue
1533
+ callable_nonpersistent.setdefault(name, []).append(
1534
+ (callable_owner, stmt)
1535
+ )
1536
+ elif not direct_program:
1537
+ block_nonpersistent.setdefault(name, []).append(stmt)
1538
+
1539
+ def walk_embedded(
1540
+ value: Any,
1541
+ callable_owner: str | None,
1542
+ ) -> None:
1543
+ """Visit expression-valued if/switch branches as lexical blocks."""
1544
+ if value is None or isinstance(value, (FuncDef, MethodDef)):
1545
+ return
1546
+ if isinstance(value, IfStmt):
1547
+ walk_embedded(value.condition, callable_owner)
1548
+ walk_statements(value.body, callable_owner, False)
1549
+ walk_statements(
1550
+ value.else_body or [], callable_owner, False
1551
+ )
1552
+ return
1553
+ if isinstance(value, SwitchStmt):
1554
+ walk_embedded(value.expr, callable_owner)
1555
+ for case_expr, body in value.cases:
1556
+ walk_embedded(case_expr, callable_owner)
1557
+ walk_statements(body, callable_owner, False)
1558
+ walk_statements(
1559
+ value.default_body or [], callable_owner, False
1560
+ )
1561
+ return
1562
+ if isinstance(value, (list, tuple)):
1563
+ for item in value:
1564
+ walk_embedded(item, callable_owner)
1565
+ return
1566
+ if isinstance(value, dict):
1567
+ for item in value.values():
1568
+ walk_embedded(item, callable_owner)
1569
+ return
1570
+ if isinstance(value, ASTNode):
1571
+ for child in vars(value).values():
1572
+ walk_embedded(child, callable_owner)
1573
+
1574
+ def walk_statements(
1575
+ stmts: list[ASTNode],
1576
+ callable_owner: str | None,
1577
+ direct_program: bool,
1578
+ ) -> None:
1579
+ for stmt in stmts:
1580
+ if isinstance(stmt, FuncDef):
1581
+ walk_statements(stmt.body, stmt.name, False)
1582
+ continue
1583
+ # Method state still uses a separate legacy ownership path.
1584
+ # Do not misclassify a method-local declaration as a script
1585
+ # block while this guard is deliberately scoped to FuncDef.
1586
+ if isinstance(stmt, MethodDef):
1587
+ continue
1588
+ if isinstance(stmt, VarDecl):
1589
+ walk_embedded(stmt.value, callable_owner)
1590
+ record_bindings(
1591
+ stmt, [stmt.name], callable_owner, direct_program
1592
+ )
1593
+ elif isinstance(stmt, TupleAssign):
1594
+ walk_embedded(stmt.value, callable_owner)
1595
+ record_bindings(
1596
+ stmt, stmt.names, callable_owner, direct_program
1597
+ )
1598
+ elif isinstance(stmt, Assignment):
1599
+ walk_embedded(stmt.target, callable_owner)
1600
+ walk_embedded(stmt.value, callable_owner)
1601
+ elif isinstance(stmt, ExprStmt):
1602
+ walk_embedded(stmt.expr, callable_owner)
1603
+
1604
+ if isinstance(stmt, IfStmt):
1605
+ walk_embedded(stmt.condition, callable_owner)
1606
+ walk_statements(stmt.body, callable_owner, False)
1607
+ walk_statements(
1608
+ stmt.else_body or [], callable_owner, False
1609
+ )
1610
+ elif isinstance(stmt, ForStmt):
1611
+ record_bindings(
1612
+ stmt, [stmt.var], callable_owner, False
1613
+ )
1614
+ walk_embedded(stmt.start, callable_owner)
1615
+ walk_embedded(stmt.end, callable_owner)
1616
+ walk_embedded(stmt.step, callable_owner)
1617
+ walk_statements(stmt.body, callable_owner, False)
1618
+ elif isinstance(stmt, ForInStmt):
1619
+ record_bindings(
1620
+ stmt,
1621
+ ([stmt.var] if stmt.var else (stmt.vars or [])),
1622
+ callable_owner,
1623
+ False,
1624
+ )
1625
+ walk_embedded(stmt.iterable, callable_owner)
1626
+ walk_statements(stmt.body, callable_owner, False)
1627
+ elif isinstance(stmt, WhileStmt):
1628
+ walk_embedded(stmt.condition, callable_owner)
1629
+ walk_statements(stmt.body, callable_owner, False)
1630
+ elif isinstance(stmt, SwitchStmt):
1631
+ walk_embedded(stmt.expr, callable_owner)
1632
+ for _case, body in stmt.cases:
1633
+ walk_embedded(_case, callable_owner)
1634
+ walk_statements(body, callable_owner, False)
1635
+ walk_statements(
1636
+ stmt.default_body or [], callable_owner, False
1637
+ )
1638
+
1639
+ walk_statements(self._ast.body, None, True)
1640
+
1641
+ # A block-local history declaration is normally emitted as the raw
1642
+ # class Series member. A persistent or callable-local claimant with
1643
+ # that spelling either suppresses the member or shares its buffer.
1644
+ for name, bindings in block_nonpersistent.items():
1645
+ if (name not in persistent_raw_names
1646
+ and name not in callable_nonpersistent
1647
+ and len(bindings) == 1):
1648
+ continue
1649
+ self._error(
1650
+ "A top-level block history binding named "
1651
+ f"'{name}' shares raw generated Series state with another "
1652
+ "lexical declaration; declaration-exact block storage is not "
1653
+ "supported yet.",
1654
+ getattr(bindings[-1], "loc", None),
1655
+ )
1656
+
1657
+ # A direct ordinary global reserves its class-member spelling even when
1658
+ # it is scalar. Persistent declarations reserve their raw spelling in
1659
+ # legacy var inventories even after exact owner qualification. Either
1660
+ # conflicts with a raw non-persistent callable Series member.
1661
+ for name, bindings in callable_nonpersistent.items():
1662
+ if (name not in self._ordinary_global_binding_names
1663
+ and name not in persistent_raw_names
1664
+ and len(bindings) == 1):
1665
+ continue
1666
+ self._error(
1667
+ "A callable history local named "
1668
+ f"'{name}' shares raw generated Series state with another "
1669
+ "lexical declaration; declaration-exact callable storage is "
1670
+ "not supported yet.",
1671
+ getattr(bindings[-1][1], "loc", None),
1672
+ )
1673
+
1674
+ def _check_cross_callable_series_collection_collisions(self) -> None:
1675
+ """Fail closed when legacy raw member names cannot preserve scoping.
1676
+
1677
+ Persistent callable locals are class members. A scalar Series local
1678
+ with the same spelling in another callable would otherwise bind to the
1679
+ collection member during C++ emission (``slot.push`` on PineMap). Keep
1680
+ the valid source out of malformed C++ until callable-owned Series
1681
+ members have fully namespaced storage.
1682
+ """
1683
+ persistent_collections: dict[str, set[str]] = {}
1684
+ for owner, specs in self._func_collection_types.items():
1685
+ persistent_names = {
1686
+ item[0] for item in self._func_var_members.get(owner, [])
1687
+ }
1688
+ persistent_collections[owner] = {
1689
+ self._func_var_storage_names.get(owner, {}).get(name, name)
1690
+ for name, spec in specs.items()
1691
+ if name in persistent_names
1692
+ and self._type_spec_contains_map(spec)
1693
+ }
1694
+
1695
+ emitted: set[tuple[str, str, str]] = set()
1696
+ for series_owner, names in self._func_series_vars.items():
1697
+ exact_series_names = {
1698
+ (
1699
+ self._func_var_storage_names.get(
1700
+ series_owner, {}
1701
+ ).get(name, name)
1702
+ if self._func_var_storage_names.get(
1703
+ series_owner, {}
1704
+ ).get(name, name) in self._series_var_members
1705
+ else name
1706
+ )
1707
+
1708
+ for name in names
1709
+ }
1710
+ for collection_owner, collection_names in persistent_collections.items():
1711
+ if series_owner == collection_owner:
1712
+ continue
1713
+ for exact_name in exact_series_names & collection_names:
1714
+ raw_name = next(
1715
+ (
1716
+ name for name in names
1717
+ if self._func_var_storage_names.get(
1718
+ series_owner, {}
1719
+ ).get(name, name) == exact_name
1720
+ ),
1721
+ exact_name,
1722
+ )
1723
+ key = (series_owner, collection_owner, exact_name)
1724
+ if key in emitted:
1725
+ continue
1726
+ emitted.add(key)
1727
+ node = self._func_series_history_nodes.get(
1728
+ (series_owner, raw_name)
1729
+ )
1730
+ self._error(
1731
+ "History references on a scalar callable local named "
1732
+ f"'{raw_name}' conflict with a persistent map local of the "
1733
+ "same name in another callable; scoped Series member "
1734
+ "storage is not implemented yet.",
1735
+ node.loc if node is not None else None,
1736
+ )
1737
+
1738
+ def _check_top_level_block_shadow_boundaries(self) -> None:
1739
+ """Fail closed for block shadows whose storage is not node-exact yet.
1740
+
1741
+ Same-typed primitive non-history declarations can use an ordinary C++
1742
+ lexical local once the analyzer preserves the outer symbol (see the
1743
+ scoped control-flow visitors below). Aggregate/UDT registries, local
1744
+ history Series storage, and cross-type codegen typing remain raw-name
1745
+ keyed, so allowing those shapes to shadow a direct script binding could
1746
+ silently retarget the class member or emit invalid C++.
1747
+ """
1748
+ def tuple_binding_type(
1749
+ stmt: TupleAssign, name: str
1750
+ ) -> tuple[PineType, TypeSpec]:
1751
+ try:
1752
+ index = stmt.names.index(name)
1753
+ except ValueError:
1754
+ index = -1
1755
+ element_types = self._tuple_element_types_by_node.get(
1756
+ id(stmt.value), ()
1757
+ )
1758
+ inferred = (
1759
+ element_types[index]
1760
+ if 0 <= index < len(element_types)
1761
+ else PineType.FLOAT
1762
+ )
1763
+ # Mirrors _visit_TupleAssign: bool is the only newly exact family;
1764
+ # all numeric elements retain the established float storage.
1765
+ pine_type = (
1766
+ PineType.BOOL if inferred == PineType.BOOL else PineType.FLOAT
1767
+ )
1768
+ return pine_type, TypeSpec.primitive(pine_type.value)
1769
+
1770
+ direct: dict[str, tuple[PineType, TypeSpec | None]] = {}
1771
+ for stmt in self._ast.body:
1772
+ if isinstance(stmt, VarDecl):
1773
+ direct[stmt.name] = self._var_decl_types_by_node.get(
1774
+ id(stmt), (PineType.UNKNOWN, None)
1775
+ )
1776
+ elif isinstance(stmt, TupleAssign):
1777
+ for name in stmt.names:
1778
+ if name != "_":
1779
+ direct[name] = tuple_binding_type(stmt, name)
1780
+
1781
+ def nested_bindings(
1782
+ stmts: list[ASTNode], primitive_mismatch_unsafe: bool
1783
+ ):
1784
+ for stmt in stmts:
1785
+ if isinstance(stmt, VarDecl):
1786
+ yield from embedded_bindings(stmt.value)
1787
+ yield stmt, stmt.name, primitive_mismatch_unsafe
1788
+ continue
1789
+ if isinstance(stmt, TupleAssign):
1790
+ yield from embedded_bindings(stmt.value)
1791
+ for name in stmt.names:
1792
+ if name != "_":
1793
+ yield stmt, name, primitive_mismatch_unsafe
1794
+ continue
1795
+ if isinstance(stmt, IfStmt):
1796
+ yield from embedded_bindings(stmt.condition)
1797
+ yield from nested_bindings(stmt.body, True)
1798
+ yield from nested_bindings(stmt.else_body or [], True)
1799
+ elif isinstance(stmt, (ForStmt, ForInStmt)):
1800
+ if isinstance(stmt, ForStmt):
1801
+ yield from embedded_bindings(stmt.start)
1802
+ yield from embedded_bindings(stmt.end)
1803
+ yield from embedded_bindings(stmt.step)
1804
+ else:
1805
+ yield from embedded_bindings(stmt.iterable)
1806
+ yield from nested_bindings(stmt.body, True)
1807
+ elif isinstance(stmt, WhileStmt):
1808
+ yield from embedded_bindings(stmt.condition)
1809
+ yield from nested_bindings(stmt.body, True)
1810
+ elif isinstance(stmt, SwitchStmt):
1811
+ yield from embedded_bindings(stmt.expr)
1812
+ for _case, body in stmt.cases:
1813
+ yield from embedded_bindings(_case)
1814
+ yield from nested_bindings(body, True)
1815
+ yield from nested_bindings(stmt.default_body or [], True)
1816
+ elif isinstance(stmt, Assignment):
1817
+ yield from embedded_bindings(stmt.target)
1818
+ yield from embedded_bindings(stmt.value)
1819
+ elif isinstance(stmt, ExprStmt):
1820
+ yield from embedded_bindings(stmt.expr)
1821
+
1822
+ def embedded_bindings(value: Any):
1823
+ """Yield declarations in expression-valued control-flow blocks."""
1824
+ if value is None or isinstance(value, (FuncDef, MethodDef)):
1825
+ return
1826
+ if isinstance(value, IfStmt):
1827
+ yield from embedded_bindings(value.condition)
1828
+ yield from nested_bindings(value.body, True)
1829
+ yield from nested_bindings(value.else_body or [], True)
1830
+ return
1831
+ if isinstance(value, SwitchStmt):
1832
+ yield from embedded_bindings(value.expr)
1833
+ for case_expr, body in value.cases:
1834
+ yield from embedded_bindings(case_expr)
1835
+ yield from nested_bindings(body, True)
1836
+ yield from nested_bindings(value.default_body or [], True)
1837
+ return
1838
+ if isinstance(value, (list, tuple)):
1839
+ for item in value:
1840
+ yield from embedded_bindings(item)
1841
+ return
1842
+ if isinstance(value, dict):
1843
+ for item in value.values():
1844
+ yield from embedded_bindings(item)
1845
+ return
1846
+ if isinstance(value, ASTNode):
1847
+ for child in vars(value).values():
1848
+ yield from embedded_bindings(child)
1849
+
1850
+ top_level_blocks: list[tuple[list[ASTNode], bool]] = []
1851
+ for stmt in self._ast.body:
1852
+ if isinstance(stmt, IfStmt):
1853
+ top_level_blocks.extend([
1854
+ (stmt.body, True), (stmt.else_body or [], True)
1855
+ ])
1856
+ elif isinstance(stmt, (ForStmt, ForInStmt)):
1857
+ top_level_blocks.append((stmt.body, True))
1858
+ elif isinstance(stmt, WhileStmt):
1859
+ top_level_blocks.append((stmt.body, True))
1860
+ elif isinstance(stmt, SwitchStmt):
1861
+ top_level_blocks.extend(
1862
+ (body, True) for _case, body in stmt.cases
1863
+ )
1864
+ top_level_blocks.append((stmt.default_body or [], True))
1865
+
1866
+ # Pine control flow is also expression-valued (for example
1867
+ # ``result = if ...``). Inspect those blocks without treating the
1868
+ # containing direct Program declaration itself as a nested binding.
1869
+ embedded_top_level_bindings: list[
1870
+ tuple[ASTNode, str, bool]
1871
+ ] = []
1872
+ for stmt in self._ast.body:
1873
+ if isinstance(stmt, VarDecl):
1874
+ embedded_top_level_bindings.extend(
1875
+ embedded_bindings(stmt.value)
1876
+ )
1877
+ elif isinstance(stmt, TupleAssign):
1878
+ embedded_top_level_bindings.extend(
1879
+ embedded_bindings(stmt.value)
1880
+ )
1881
+ elif isinstance(stmt, Assignment):
1882
+ embedded_top_level_bindings.extend(
1883
+ embedded_bindings(stmt.target)
1884
+ )
1885
+ embedded_top_level_bindings.extend(
1886
+ embedded_bindings(stmt.value)
1887
+ )
1888
+ elif isinstance(stmt, ExprStmt):
1889
+ embedded_top_level_bindings.extend(
1890
+ embedded_bindings(stmt.expr)
1891
+ )
1892
+
1893
+ exact_series = set(self._series_decl_bindings)
1894
+ exact_series_nodes = {node_id for node_id, _name in exact_series}
1895
+ aggregate_kinds = {"array", "map", "matrix", "udt"}
1896
+ candidate_bindings = list(embedded_top_level_bindings)
1897
+ for block, primitive_mismatch_unsafe in top_level_blocks:
1898
+ candidate_bindings.extend(
1899
+ nested_bindings(block, primitive_mismatch_unsafe)
1900
+ )
1901
+ for node, name, mismatch_unsafe in candidate_bindings:
1902
+ if name not in direct:
1903
+ continue
1904
+ if isinstance(node, ForStmt):
1905
+ nested_type, nested_spec = (
1906
+ PineType.INT, TypeSpec.primitive("int")
1907
+ )
1908
+ elif isinstance(node, TupleAssign):
1909
+ nested_type, nested_spec = tuple_binding_type(node, name)
1910
+ else:
1911
+ nested_type, nested_spec = self._var_decl_types_by_node.get(
1912
+ id(node), (PineType.FLOAT, None)
1913
+ )
1914
+ direct_type, direct_spec = direct[name]
1915
+ is_exact_series = (
1916
+ (id(node), name) in exact_series
1917
+ or (
1918
+ id(node) in self._series_decl_nodes
1919
+ and id(node) not in exact_series_nodes
1920
+ )
1921
+ )
1922
+ has_aggregate = any(
1923
+ spec is not None and spec.kind in aggregate_kinds
1924
+ for spec in (direct_spec, nested_spec)
1925
+ )
1926
+ has_incompatible_primitive = (
1927
+ mismatch_unsafe and nested_type != direct_type
1928
+ # The standard corpus uses a loop-local inferred int under
1929
+ # a direct float binding (Hexatrades ``base``). C++ widens
1930
+ # that RHS safely. The reverse direction and all other
1931
+ # cross-type shadows can truncate or fail compilation.
1932
+ and not (
1933
+ direct_type == PineType.FLOAT
1934
+ and nested_type == PineType.INT
1935
+ )
1936
+ )
1937
+ if (not is_exact_series
1938
+ and not has_aggregate
1939
+ and not has_incompatible_primitive):
1940
+ continue
1941
+ self._error(
1942
+ "A top-level block binding named "
1943
+ f"'{name}' shadows a direct script binding with history, "
1944
+ "aggregate, or incompatible typed state; declaration-exact "
1945
+ "block storage is not supported yet.",
1946
+ getattr(node, "loc", None),
1947
+ )
1948
+
1949
+ def _check_persistent_drawing_member_collisions(self) -> None:
1950
+ """Fail closed when two lexical drawing vars share one C++ member.
1951
+
1952
+ Persistent callable locals and the first block-scoped declaration still
1953
+ use their raw Pine spelling as the class-member identity. Reusing that
1954
+ spelling in another callable/global scope would silently share state
1955
+ (and can emit uncompilable C++ when the handle kinds differ). Sibling
1956
+ on-bar blocks that received ``__blkN`` identities are already safe.
1957
+ Until all callable storage is owner-qualified, reject only the drawing
1958
+ collisions made reachable by drawing-handle target typing.
1959
+ """
1960
+ from .types import _DRAWING_TYPE_NAMES
1961
+
1962
+ persistent_groups: dict[str, list[tuple[ASTNode, TypeSpec | None]]] = {}
1963
+ for node_id, meta in self._var_member_metadata_by_node.items():
1964
+ node, member_name, _ptype, _init, _callable = meta
1965
+ spec = self._var_member_type_specs_by_node.get(node_id)
1966
+ persistent_groups.setdefault(member_name, []).append((node, spec))
1967
+
1968
+ def is_drawing(spec: TypeSpec | None) -> bool:
1969
+ return bool(
1970
+ spec is not None
1971
+ and spec.kind == "udt"
1972
+ and spec.name in _DRAWING_TYPE_NAMES
1973
+ )
1974
+
1975
+ for member_name, bindings in persistent_groups.items():
1976
+ if len(bindings) < 2 or not any(is_drawing(spec) for _, spec in bindings):
1977
+ continue
1978
+ node = bindings[1][0]
1979
+ self._error(
1980
+ "Persistent drawing bindings named "
1981
+ f"'{getattr(node, 'name', member_name)}' in distinct lexical "
1982
+ "owners would share one generated state member; rename one "
1983
+ "binding until callable-owned persistent storage is "
1984
+ "owner-qualified.",
1985
+ node.loc,
1986
+ )
1987
+
1988
+ global_names = set(self._ordinary_global_binding_names)
1989
+ global_drawing_names: set[str] = set()
1990
+ for stmt in self._ast.body:
1991
+ if not isinstance(stmt, VarDecl) or stmt.is_var or stmt.is_varip:
1992
+ continue
1993
+ spec = (
1994
+ self._type_spec_from_hint(stmt.type_hint)
1995
+ if stmt.type_hint
1996
+ else self._type_spec_from_expr(stmt.value)
1997
+ )
1998
+ if is_drawing(spec):
1999
+ global_drawing_names.add(stmt.name)
2000
+
2001
+ for member_name, bindings in persistent_groups.items():
2002
+ if member_name not in global_names:
2003
+ continue
2004
+ if not (
2005
+ member_name in global_drawing_names
2006
+ or any(is_drawing(spec) for _, spec in bindings)
2007
+ ):
2008
+ continue
2009
+ node = bindings[0][0]
2010
+ self._error(
2011
+ "Persistent drawing state named "
2012
+ f"'{getattr(node, 'name', member_name)}' collides with a "
2013
+ "top-level class-member binding; rename one binding until "
2014
+ "persistent storage is owner-qualified.",
2015
+ node.loc,
2016
+ )
2017
+
2018
+ # Callable persistent drawing storage is currently cloned as a class
2019
+ # member and remapped by raw Pine name. If such a declaration shadows
2020
+ # an ancestor local or parameter, the preloaded clone remap and C++
2021
+ # lexical scope disagree about which binding is visible before/after
2022
+ # the declaration. Reject this narrow shape instead of silently
2023
+ # reading the parameter/local from the wrong storage. Independent
2024
+ # sibling branches receive separate lexical inventories here (the
2025
+ # existing distinct-owner collision gate above may still reject two
2026
+ # persistent drawings that would share the same member identity).
2027
+ callable_drawing_nodes = {
2028
+ node_id
2029
+ for node_id, meta in self._var_member_metadata_by_node.items()
2030
+ if meta[4] and is_drawing(
2031
+ self._var_member_type_specs_by_node.get(node_id)
2032
+ )
2033
+ }
2034
+
2035
+ def walk_callable_body(
2036
+ body: list[ASTNode],
2037
+ inherited_names: set[str],
2038
+ ) -> None:
2039
+ def walk_embedded_controls(value: Any, visible: set[str]) -> None:
2040
+ """Find block-valued if/switch expressions inside an RHS.
2041
+
2042
+ Pine permits ``x = if ...``. Those branch declarations have
2043
+ the same storage hazard as statement-level blocks, and the RHS
2044
+ must be inspected against the pre-declaration environment.
2045
+ """
2046
+ if value is None:
2047
+ return
2048
+ if isinstance(value, IfStmt):
2049
+ walk_embedded_controls(value.condition, visible)
2050
+ walk_callable_body(value.body, visible)
2051
+ walk_callable_body(value.else_body, visible)
2052
+ return
2053
+ if isinstance(value, SwitchStmt):
2054
+ walk_embedded_controls(value.expr, visible)
2055
+ for case_expr, case_body in value.cases:
2056
+ walk_embedded_controls(case_expr, visible)
2057
+ walk_callable_body(case_body, visible)
2058
+ walk_callable_body(value.default_body, visible)
2059
+ return
2060
+ if isinstance(value, (FuncDef, MethodDef)):
2061
+ return
2062
+ if isinstance(value, (list, tuple)):
2063
+ for item in value:
2064
+ walk_embedded_controls(item, visible)
2065
+ return
2066
+ if isinstance(value, dict):
2067
+ for item in value.values():
2068
+ walk_embedded_controls(item, visible)
2069
+ return
2070
+ if isinstance(value, ASTNode):
2071
+ for child in vars(value).values():
2072
+ walk_embedded_controls(child, visible)
2073
+
2074
+ visible = set(inherited_names)
2075
+ for stmt in body:
2076
+ if isinstance(stmt, VarDecl):
2077
+ walk_embedded_controls(stmt.value, visible)
2078
+ if (id(stmt) in callable_drawing_nodes
2079
+ and stmt.name in visible):
2080
+ self._error(
2081
+ "Persistent drawing binding "
2082
+ f"'{stmt.name}' shadows an ancestor callable "
2083
+ "parameter or local; rename one binding until "
2084
+ "callable-owned persistent storage has lexical "
2085
+ "owner qualification.",
2086
+ stmt.loc,
2087
+ )
2088
+ visible.add(stmt.name)
2089
+ continue
2090
+ if isinstance(stmt, TupleAssign):
2091
+ walk_embedded_controls(stmt.value, visible)
2092
+ visible.update(stmt.names)
2093
+ continue
2094
+ if isinstance(stmt, Assignment):
2095
+ walk_embedded_controls(stmt.target, visible)
2096
+ walk_embedded_controls(stmt.value, visible)
2097
+ continue
2098
+ if isinstance(stmt, ExprStmt):
2099
+ walk_embedded_controls(stmt.expr, visible)
2100
+ continue
2101
+ if isinstance(stmt, IfStmt):
2102
+ walk_embedded_controls(stmt.condition, visible)
2103
+ walk_callable_body(stmt.body, visible)
2104
+ walk_callable_body(stmt.else_body, visible)
2105
+ continue
2106
+ if isinstance(stmt, WhileStmt):
2107
+ walk_embedded_controls(stmt.condition, visible)
2108
+ walk_callable_body(stmt.body, visible)
2109
+ continue
2110
+ if isinstance(stmt, ForStmt):
2111
+ walk_embedded_controls(stmt.start, visible)
2112
+ walk_embedded_controls(stmt.end, visible)
2113
+ walk_embedded_controls(stmt.step, visible)
2114
+ walk_callable_body(stmt.body, visible | {stmt.var})
2115
+ continue
2116
+ if isinstance(stmt, ForInStmt):
2117
+ walk_embedded_controls(stmt.iterable, visible)
2118
+ loop_names = set(stmt.vars or [])
2119
+ if stmt.var:
2120
+ loop_names.add(stmt.var)
2121
+ walk_callable_body(stmt.body, visible | loop_names)
2122
+ continue
2123
+ if isinstance(stmt, SwitchStmt):
2124
+ walk_embedded_controls(stmt.expr, visible)
2125
+ for case_expr, case_body in stmt.cases:
2126
+ walk_embedded_controls(case_expr, visible)
2127
+ walk_callable_body(case_body, visible)
2128
+ walk_callable_body(stmt.default_body, visible)
2129
+
2130
+ for stmt in self._ast.body:
2131
+ if isinstance(stmt, (FuncDef, MethodDef)):
2132
+ walk_callable_body(stmt.body, set(stmt.params))
2133
+
361
2134
  def _record_global_binding_stmt(self, name: str, pine_type: PineType,
362
2135
  is_var: bool, decl_node: ASTNode | None = None) -> None:
363
2136
  info = self._global_binding_infos.get(name)
@@ -507,27 +2280,43 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
507
2280
 
508
2281
  recv = call.callee.object
509
2282
  method = call.callee.member
510
- udt_name: str | None = None
2283
+ receiver_type_name: str | None = None
511
2284
  if isinstance(recv, Identifier):
512
- udt_name = self._udt_var_types.get(recv.name)
513
2285
  owner_info = func_info_by_name.get(owner or "")
514
- if udt_name is None and owner_info is not None \
515
- and owner_info.node is not None:
2286
+ # Resolve the active callable's lexical parameters before the
2287
+ # flat variable registry. A parameter may legally shadow a
2288
+ # top-level UDT variable with a different type; consulting the
2289
+ # global binding first misidentifies the method call edge and
2290
+ # silently shares state between written wrapper call sites.
2291
+ if owner_info is not None and owner_info.node is not None:
516
2292
  if (getattr(owner_info, "is_udt_method", False)
517
2293
  and owner_info.node.params
518
2294
  and recv.name == owner_info.node.params[0]):
519
- udt_name = owner_info.udt_type_name
2295
+ owner_specs = list(
2296
+ getattr(owner_info, "param_type_specs", ()) or ()
2297
+ )
2298
+ receiver_type_name = method_receiver_type_name(
2299
+ owner_specs[0] if owner_specs else None
2300
+ ) or owner_info.udt_type_name
520
2301
  elif recv.name in owner_info.node.params:
521
2302
  param_idx = owner_info.node.params.index(recv.name)
522
2303
  specs = getattr(owner_info, "param_type_specs", []) or []
523
2304
  spec = specs[param_idx] if param_idx < len(specs) else None
524
- if spec is not None and spec.kind == "udt":
525
- udt_name = spec.name
526
- if udt_name is None:
2305
+ receiver_type_name = method_receiver_type_name(spec)
2306
+ # Resolve the surviving exact global/lexical symbol before the
2307
+ # flat raw-name registry. A later callable-local declaration can
2308
+ # overwrite that registry and otherwise attach the wrong stateful
2309
+ # method edge to wrappers and their written call sites.
2310
+ if receiver_type_name is None:
527
2311
  spec = self._type_spec_from_expr(recv)
528
- if spec is not None and spec.kind == "udt":
529
- udt_name = spec.name
530
- key = f"{udt_name}.{method}" if udt_name else ""
2312
+ receiver_type_name = method_receiver_type_name(spec)
2313
+ if receiver_type_name is None and isinstance(recv, Identifier):
2314
+ receiver_type_name = self._udt_var_types.get(recv.name)
2315
+ key = (
2316
+ f"{receiver_type_name}.{method}"
2317
+ if receiver_type_name
2318
+ else ""
2319
+ )
531
2320
  return key if key in func_defs else None
532
2321
 
533
2322
  def _find_calls(node, known_funcs: set[str],
@@ -569,6 +2358,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
569
2358
  calls_by_callee: dict[str, list[FuncCall]] = {
570
2359
  name: [] for name in known_func_names
571
2360
  }
2361
+ call_edges: list[tuple[str | None, str, FuncCall]] = []
572
2362
  # Preserve source order across definitions and top-level statements.
573
2363
  # Method bodies use their ``Type.method`` owner so ``self.sibling()``
574
2364
  # resolves without relying on a now-exited symbol-table scope.
@@ -581,6 +2371,198 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
581
2371
  owner = None
582
2372
  for callee, call in _find_calls(stmt, known_func_names, owner):
583
2373
  calls_by_callee.setdefault(callee, []).append(call)
2374
+ call_edges.append((owner, callee, call))
2375
+
2376
+ # A source-ordered method visit threads TA constructor arguments through
2377
+ # any already-known method callee. Forward definitions require the
2378
+ # complete call graph above, and an edge may need revisiting even after
2379
+ # it has a cs identity: its callee can acquire another exact TA owner (or
2380
+ # a refined per-owner ctor template) later in this same closure.
2381
+ #
2382
+ # Track the exact source-index/template snapshot materialized for each
2383
+ # textual edge. Only the delta is materialized on a later round, which
2384
+ # prevents cs>0 state from being cloned twice. A finite callable graph
2385
+ # converges in at most its path depth; retain a conservative explicit
2386
+ # bound and fail closed rather than looping on a recursive TA cycle.
2387
+ max_ta_rounds = max(2, len(func_defs) + len(call_edges) + 2)
2388
+ last_changed_call: FuncCall | None = None
2389
+ for _round in range(max_ta_rounds):
2390
+ late_method_ta_changed = False
2391
+ for owner, callee, call in call_edges:
2392
+ call_id = id(call)
2393
+ if call_id in self._func_inherited_call_nodes:
2394
+ continue
2395
+ callee_info = func_info_by_name.get(callee)
2396
+ if (
2397
+ callee_info is None
2398
+ or not getattr(callee_info, "is_udt_method", False)
2399
+ or callee not in self._func_ta_ranges
2400
+ ):
2401
+ continue
2402
+
2403
+ source_indices = list(self._func_ta_indices.get(callee, ()))
2404
+ if not source_indices:
2405
+ source_range = self._func_ta_ranges[callee]
2406
+ source_indices = list(range(*source_range))
2407
+ callee_templates = self._func_ta_ctor_args.get(callee, {})
2408
+ processed_templates = self._func_ta_call_templates.get(
2409
+ call_id, {}
2410
+ )
2411
+ existing_site = self._func_call_cs_map.get(call_id)
2412
+ candidate_cs_idx = (
2413
+ existing_site[1]
2414
+ if existing_site is not None and existing_site[0] == callee
2415
+ else self._func_call_site_count.get(callee, 0)
2416
+ )
2417
+ selected_targets = self._func_ta_call_targets.get(
2418
+ (call_id, candidate_cs_idx), {}
2419
+ )
2420
+ owner_indices = (
2421
+ set(self._func_ta_indices.get(owner, ()))
2422
+ if owner is not None
2423
+ else set()
2424
+ )
2425
+ owner_templates = (
2426
+ self._func_ta_ctor_args.get(owner, {})
2427
+ if owner is not None
2428
+ else {}
2429
+ )
2430
+ stale_indices: list[int] = []
2431
+ for index in source_indices:
2432
+ site = self._ta_call_sites[index]
2433
+ source_template = tuple(
2434
+ callee_templates.get(
2435
+ index,
2436
+ getattr(site, "_orig_ctor_args", site.ctor_args),
2437
+ )
2438
+ )
2439
+ target = selected_targets.get(index)
2440
+ if (
2441
+ processed_templates.get(index) != source_template
2442
+ or target is None
2443
+ or (
2444
+ owner is not None
2445
+ and (
2446
+ target not in owner_indices
2447
+ or target not in owner_templates
2448
+ )
2449
+ )
2450
+ ):
2451
+ stale_indices.append(index)
2452
+ if not stale_indices:
2453
+ continue
2454
+
2455
+ if existing_site is not None and existing_site[0] == callee:
2456
+ cs_idx = existing_site[1]
2457
+ materialize_fixnan = False
2458
+ else:
2459
+ cs_idx = candidate_cs_idx
2460
+ self._func_call_site_count[callee] = cs_idx + 1
2461
+ self._func_call_cs_map[call_id] = (callee, cs_idx)
2462
+ materialize_fixnan = True
2463
+
2464
+ selected = self._materialize_user_func_call_site_state(
2465
+ callee,
2466
+ cs_idx,
2467
+ call,
2468
+ ta_site_indices=stale_indices,
2469
+ materialize_fixnan=materialize_fixnan,
2470
+ )
2471
+ if selected and owner is not None:
2472
+ current_indices = self._func_ta_indices.setdefault(owner, [])
2473
+ current_indices[:] = sorted(
2474
+ set(current_indices) | set(selected.values())
2475
+ )
2476
+ self._func_ta_ranges[owner] = (
2477
+ min(current_indices), max(current_indices) + 1
2478
+ )
2479
+ owner_templates = self._func_ta_ctor_args.setdefault(
2480
+ owner, {}
2481
+ )
2482
+ for target in selected.values():
2483
+ owner_templates[target] = list(
2484
+ self._ta_call_sites[target].ctor_args
2485
+ )
2486
+ late_method_ta_changed = True
2487
+ last_changed_call = call
2488
+ if not late_method_ta_changed:
2489
+ break
2490
+ else:
2491
+ self._error(
2492
+ "Callable TA ownership propagation did not converge; "
2493
+ "recursive stateful call paths are unsupported.",
2494
+ last_changed_call.loc if last_changed_call is not None else None,
2495
+ )
2496
+
2497
+ # A history-reading callable receives ``Series<T>`` parameters. That
2498
+ # requirement must flow outward through every wrapper parameter that
2499
+ # is forwarded unchanged; otherwise a wrapper stays ``double`` and
2500
+ # generated C++ cannot bind it to the callee's ``const Series&``.
2501
+ # Resolve this after every UDF/method definition is known and iterate
2502
+ # to a fixed point so method <- inner <- outer chains are independent
2503
+ # of declaration/source order.
2504
+ def _bound_user_call_args(callee: str, call: FuncCall) -> list:
2505
+ info = func_info_by_name.get(callee)
2506
+ if info is None or info.node is None:
2507
+ return []
2508
+ params = list(info.node.params)
2509
+ if (
2510
+ getattr(info, "is_udt_method", False)
2511
+ and isinstance(call.callee, MemberAccess)
2512
+ ):
2513
+ if callee in self._method_signatures:
2514
+ binding = self._bind_typed_method_call(callee, call)
2515
+ return [call.callee.object, *binding.args_by_param]
2516
+ return [
2517
+ call.callee.object,
2518
+ *self._bind_callable_args(call, params[1:]),
2519
+ ]
2520
+ return self._bind_callable_args(call, params)
2521
+
2522
+ series_requirements: dict[str, set[str]] = {
2523
+ name: set(params)
2524
+ for name, params in self._func_series_vars.items()
2525
+ if params
2526
+ }
2527
+ series_changed = True
2528
+ while series_changed:
2529
+ series_changed = False
2530
+ for owner, callee, call in call_edges:
2531
+ callee_info = func_info_by_name.get(callee)
2532
+ if callee_info is None or callee_info.node is None:
2533
+ continue
2534
+ callee_series = series_requirements.get(callee, set())
2535
+ if not callee_series:
2536
+ continue
2537
+ actuals = _bound_user_call_args(callee, call)
2538
+ for index, param_name in enumerate(callee_info.node.params):
2539
+ if param_name not in callee_series or index >= len(actuals):
2540
+ continue
2541
+ actual = actuals[index]
2542
+ if not isinstance(actual, Identifier):
2543
+ continue
2544
+ if actual.name in BAR_FIELDS:
2545
+ self._series_bar_fields.add(actual.name)
2546
+ if owner is None:
2547
+ continue
2548
+ owner_info = func_info_by_name.get(owner)
2549
+ if (
2550
+ owner_info is None
2551
+ or owner_info.node is None
2552
+ or actual.name not in owner_info.node.params
2553
+ ):
2554
+ continue
2555
+ owner_series = self._func_series_vars.setdefault(
2556
+ owner, set()
2557
+ )
2558
+ if actual.name not in owner_series:
2559
+ owner_series.add(actual.name)
2560
+ owner_requirements = series_requirements.setdefault(
2561
+ owner, set()
2562
+ )
2563
+ if actual.name not in owner_requirements:
2564
+ owner_requirements.add(actual.name)
2565
+ series_changed = True
584
2566
 
585
2567
  # Codegen synthesizes a Series buffer for two expression shapes that
586
2568
  # do not appear in ``_func_series_vars`` themselves:
@@ -733,12 +2715,89 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
733
2715
  next_idx = max(next_idx, existing[1] + 1)
734
2716
  continue
735
2717
  self._func_call_cs_map[id(call)] = (fname, next_idx)
2718
+ # Most ordinary UDFs were already materialized by the lexical
2719
+ # call visitor. UDT method calls are resolved in this late
2720
+ # graph pass, so their TA/fixnan state still needs matching
2721
+ # cs0/cs1/... members before codegen emits the variants.
2722
+ self._materialize_user_func_call_site_state(
2723
+ fname, next_idx, call
2724
+ )
736
2725
  next_idx += 1
737
2726
  if next_idx > current:
738
2727
  self._func_call_site_count[fname] = next_idx
739
2728
 
740
2729
  # Inherit each multi-call-site parent's index space down the full path.
741
2730
  # Re-run to a fixed point for A -> B -> C chains.
2731
+ def _ta_variant_target_map(
2732
+ func_name: str, cs_idx: int
2733
+ ) -> dict[int, int]:
2734
+ source_indices = list(self._func_ta_indices.get(func_name, ()))
2735
+ if not source_indices and func_name in self._func_ta_ranges:
2736
+ source_indices = list(range(*self._func_ta_ranges[func_name]))
2737
+ if cs_idx == 0:
2738
+ return {index: index for index in source_indices}
2739
+ overrides = self._func_cs_ta_clone_names.get(
2740
+ (func_name, cs_idx), {}
2741
+ )
2742
+ by_member = {
2743
+ site.member_name: index
2744
+ for index, site in enumerate(self._ta_call_sites)
2745
+ }
2746
+ targets: dict[int, int] = {}
2747
+ for source_index in source_indices:
2748
+ source_name = self._ta_call_sites[source_index].member_name
2749
+ target_name = overrides.get(
2750
+ source_name, f"{source_name}_cs{cs_idx}"
2751
+ )
2752
+ target_index = by_member.get(target_name)
2753
+ if target_index is not None:
2754
+ targets[source_index] = target_index
2755
+ return targets
2756
+
2757
+ def _edge_ta_variant_target_map(
2758
+ parent_name: str,
2759
+ parent_cs_idx: int,
2760
+ callee_name: str,
2761
+ call_node: FuncCall,
2762
+ ) -> dict[int, int]:
2763
+ """Compose callee source identity through one parent call edge.
2764
+
2765
+ ``_ta_variant_target_map`` is keyed by the parent's base TA
2766
+ identities. Those identities need not equal the callee's source
2767
+ indices: an earlier textual call can make this edge select a
2768
+ shifted parent site. The edge's original materialization records
2769
+ the exact ``callee source -> parent base`` relation; compose that
2770
+ with the active parent variant instead of assuming equal keys.
2771
+ """
2772
+ parent_targets = _ta_variant_target_map(
2773
+ parent_name, parent_cs_idx
2774
+ )
2775
+ cs_info = self._func_call_cs_map.get(id(call_node))
2776
+ edge_cs_idx = (
2777
+ cs_info[1]
2778
+ if cs_info is not None and cs_info[0] == callee_name
2779
+ else 0
2780
+ )
2781
+ edge_targets = self._func_ta_call_targets.get(
2782
+ (id(call_node), edge_cs_idx), {}
2783
+ )
2784
+ source_indices = list(
2785
+ self._func_ta_indices.get(callee_name, ())
2786
+ )
2787
+ if not source_indices and callee_name in self._func_ta_ranges:
2788
+ source_indices = list(
2789
+ range(*self._func_ta_ranges[callee_name])
2790
+ )
2791
+ composed: dict[int, int] = {}
2792
+ for source_index in source_indices:
2793
+ parent_source_index = edge_targets.get(
2794
+ source_index, source_index
2795
+ )
2796
+ active_target = parent_targets.get(parent_source_index)
2797
+ if active_target is not None:
2798
+ composed[source_index] = active_target
2799
+ return composed
2800
+
742
2801
  changed = True
743
2802
  while changed:
744
2803
  changed = False
@@ -766,16 +2825,403 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
766
2825
  if cs_info == (sub, 0):
767
2826
  self._func_call_cs_map.pop(id(call_node), None)
768
2827
  self._func_inherited_call_nodes.add(id(call_node))
2828
+ self._func_inherited_call_names[
2829
+ id(call_node)
2830
+ ] = sub
2831
+ # Its definition-time type profile was likewise
2832
+ # provisional: forwarded untyped owner params
2833
+ # are still UNKNOWN during that visit. Each
2834
+ # inherited parent clone supplies the real
2835
+ # primitive profile below.
2836
+ self._func_callsite_param_types.pop(
2837
+ (sub, 0), None
2838
+ )
2839
+ self._func_callsite_return_types.pop(
2840
+ (sub, 0), None
2841
+ )
769
2842
  for cs_idx in range(current, count):
2843
+ parent_ta_targets = _edge_ta_variant_target_map(
2844
+ fname,
2845
+ cs_idx,
2846
+ sub,
2847
+ call_node,
2848
+ )
770
2849
  self._materialize_user_func_call_site_state(
771
2850
  sub,
772
2851
  cs_idx,
773
2852
  call_node,
774
2853
  reuse_existing_owner=fname,
2854
+ reuse_existing_targets=parent_ta_targets,
775
2855
  )
776
2856
  self._func_call_site_count[sub] = count
777
2857
  changed = True
778
2858
 
2859
+ self._resolve_callable_callsite_primitive_types(
2860
+ call_edges,
2861
+ func_info_by_name,
2862
+ _bound_user_call_args,
2863
+ )
2864
+
2865
+ @staticmethod
2866
+ def _primitive_pine_type_from_spec(spec) -> PineType:
2867
+ if spec is None or getattr(spec, "kind", None) != "primitive":
2868
+ return PineType.UNKNOWN
2869
+ return {
2870
+ "int": PineType.INT,
2871
+ "float": PineType.FLOAT,
2872
+ "bool": PineType.BOOL,
2873
+ "string": PineType.STRING,
2874
+ "color": PineType.COLOR,
2875
+ }.get(getattr(spec, "name", None), PineType.UNKNOWN)
2876
+
2877
+ def _resolve_callable_callsite_primitive_types(
2878
+ self,
2879
+ call_edges,
2880
+ func_info_by_name,
2881
+ bound_user_call_args,
2882
+ ) -> None:
2883
+ """Reconcile per-written-call primitive types after clone closure.
2884
+
2885
+ Direct UDF calls are typed during the lexical visit, but UDT methods
2886
+ and pure wrappers receive their cs identities only in the late stateful
2887
+ call-graph pass. Propagate an enclosing variant's parameter profile
2888
+ through exact forwarded argument ASTs until every emitted history
2889
+ callable variant has its own stable primitive profile.
2890
+ """
2891
+ edge_by_call_id = {
2892
+ id(call): (owner, callee, call)
2893
+ for owner, callee, call in call_edges
2894
+ }
2895
+
2896
+ def declared_types(name: str, size: int) -> list[PineType]:
2897
+ specs = list(self._func_param_type_specs.get(name, ()))
2898
+ return [
2899
+ self._primitive_pine_type_from_spec(
2900
+ specs[index] if index < len(specs) else None
2901
+ )
2902
+ for index in range(size)
2903
+ ]
2904
+
2905
+ def merge_profile(
2906
+ callee: str,
2907
+ cs_idx: int,
2908
+ incoming: list[PineType],
2909
+ call,
2910
+ ) -> bool:
2911
+ info = func_info_by_name.get(callee)
2912
+ if info is None or info.node is None:
2913
+ return False
2914
+ size = len(info.node.params)
2915
+ declared = declared_types(callee, size)
2916
+ key = (callee, cs_idx)
2917
+ current = list(
2918
+ self._func_callsite_param_types.get(
2919
+ key, [PineType.UNKNOWN] * size
2920
+ )
2921
+ )
2922
+ while len(current) < size:
2923
+ current.append(PineType.UNKNOWN)
2924
+ changed = False
2925
+ for index in range(size):
2926
+ candidate = declared[index]
2927
+ if candidate == PineType.UNKNOWN and index < len(incoming):
2928
+ candidate = incoming[index]
2929
+ if candidate == PineType.UNKNOWN:
2930
+ continue
2931
+ if current[index] == PineType.UNKNOWN:
2932
+ current[index] = candidate
2933
+ changed = True
2934
+ elif current[index] != candidate:
2935
+ self._error(
2936
+ "Cannot safely specialize untyped parameter '"
2937
+ + info.node.params[index]
2938
+ + "' of callable '"
2939
+ + callee
2940
+ + "': distinct primitive types collapse onto the same "
2941
+ + f"written-call variant cs{cs_idx}. Inline the calls "
2942
+ + "or declare an explicit parameter type.",
2943
+ call.loc,
2944
+ )
2945
+ if changed or key not in self._func_callsite_param_types:
2946
+ self._func_callsite_param_types[key] = current
2947
+ return changed
2948
+
2949
+ # Backfill every direct UDF/method identity assigned by the late graph
2950
+ # pass. Definition-time wrapper calls can still contain UNKNOWN params;
2951
+ # the fixed point below resolves those from their owner variant.
2952
+ for _owner, callee, call in call_edges:
2953
+ if _owner is not None:
2954
+ # Definition-time analyzer fallbacks for an expression over an
2955
+ # untyped owner parameter are not concrete facts (``y + 0``
2956
+ # historically reports FLOAT while ``y`` is still UNKNOWN).
2957
+ # The owner-variant fixed point below resolves nested calls.
2958
+ continue
2959
+ cs_info = self._func_call_cs_map.get(id(call))
2960
+ if cs_info is None or cs_info[0] != callee:
2961
+ continue
2962
+ incoming = self._callable_bound_param_types_by_node.get(
2963
+ id(call), []
2964
+ )
2965
+ merge_profile(callee, cs_info[1], incoming, call)
2966
+
2967
+ def owner_profile(owner: str, owner_cs: int | None) -> list[PineType]:
2968
+ info = func_info_by_name.get(owner)
2969
+ if info is None or info.node is None:
2970
+ return []
2971
+ size = len(info.node.params)
2972
+ if owner_cs is not None:
2973
+ profile = self._func_callsite_param_types.get(
2974
+ (owner, owner_cs)
2975
+ )
2976
+ if profile is not None:
2977
+ return list(profile)
2978
+ declared = declared_types(owner, size)
2979
+ legacy = list(getattr(info, "param_types", ()) or ())
2980
+ return [
2981
+ declared[index]
2982
+ if declared[index] != PineType.UNKNOWN
2983
+ else (
2984
+ legacy[index]
2985
+ if index < len(legacy)
2986
+ else PineType.UNKNOWN
2987
+ )
2988
+ for index in range(size)
2989
+ ]
2990
+
2991
+ def target_variant(
2992
+ owner: str | None,
2993
+ owner_cs: int | None,
2994
+ callee: str,
2995
+ call,
2996
+ ) -> int | None:
2997
+ cs_info = self._func_call_cs_map.get(id(call))
2998
+ callee_count = self._func_call_site_count.get(callee, 0)
2999
+ if owner is None:
3000
+ if cs_info is not None and cs_info[0] == callee:
3001
+ return cs_info[1]
3002
+ return None
3003
+ if cs_info is not None and cs_info[0] == callee:
3004
+ # A surviving lexical mapping is authoritative. Codegen's
3005
+ # context-sensitive instance dispatcher pins every clone of
3006
+ # this owner to that same written callee variant. If two owner
3007
+ # profiles disagree, merge_profile must reject the collapse;
3008
+ # pretending the owner index selects another callee clone
3009
+ # would type a function different from the one actually
3010
+ # emitted at the call edge.
3011
+ return cs_info[1]
3012
+ if owner_cs is not None and callee_count > 1:
3013
+ # A removed lexical map marks an inherited single-call path;
3014
+ # those variants deliberately follow the enclosing clone.
3015
+ return owner_cs if owner_cs < callee_count else None
3016
+ return None
3017
+
3018
+ def mentions_owner_parameter(value, names: set[str]) -> bool:
3019
+ if value is None:
3020
+ return False
3021
+ if isinstance(value, Identifier):
3022
+ return value.name in names
3023
+ if isinstance(value, (list, tuple)):
3024
+ return any(
3025
+ mentions_owner_parameter(item, names) for item in value
3026
+ )
3027
+ if isinstance(value, dict):
3028
+ return any(
3029
+ mentions_owner_parameter(item, names)
3030
+ for item in value.values()
3031
+ )
3032
+ if not hasattr(value, "__dict__"):
3033
+ return False
3034
+ return any(
3035
+ mentions_owner_parameter(child, names)
3036
+ for child in vars(value).values()
3037
+ )
3038
+
3039
+ # Calls visited inside an untyped callable are initially analyzed
3040
+ # before any concrete outer call-site profile exists. The ordinary
3041
+ # expression analyzer therefore records defaults for transformed
3042
+ # expressions (many numeric built-ins default to FLOAT). That value
3043
+ # is not evidence about the eventual written call: discard it for
3044
+ # every nested argument that depends on an untyped owner parameter so
3045
+ # the fixed point below either derives the type from the owner variant
3046
+ # or rejects the unresolved transformation deterministically.
3047
+ for owner, callee, call in call_edges:
3048
+ if owner is None:
3049
+ continue
3050
+ owner_info = func_info_by_name.get(owner)
3051
+ callee_info = func_info_by_name.get(callee)
3052
+ if (
3053
+ owner_info is None
3054
+ or owner_info.node is None
3055
+ or callee_info is None
3056
+ or callee_info.node is None
3057
+ ):
3058
+ continue
3059
+ owner_declared = declared_types(
3060
+ owner, len(owner_info.node.params)
3061
+ )
3062
+ untyped_owner_params = {
3063
+ param
3064
+ for index, param in enumerate(owner_info.node.params)
3065
+ if owner_declared[index] == PineType.UNKNOWN
3066
+ }
3067
+ if not untyped_owner_params:
3068
+ continue
3069
+ actuals = bound_user_call_args(callee, call)
3070
+ dependent_slots = {
3071
+ index
3072
+ for index, actual in enumerate(actuals)
3073
+ if mentions_owner_parameter(actual, untyped_owner_params)
3074
+ }
3075
+ if not dependent_slots:
3076
+ continue
3077
+ callee_declared = declared_types(
3078
+ callee, len(callee_info.node.params)
3079
+ )
3080
+ owner_count = self._func_call_site_count.get(owner, 0)
3081
+ owner_variants = range(owner_count) if owner_count > 0 else (None,)
3082
+ for owner_cs in owner_variants:
3083
+ callee_cs = target_variant(owner, owner_cs, callee, call)
3084
+ if callee_cs is None:
3085
+ continue
3086
+ key = (callee, callee_cs)
3087
+ current = list(
3088
+ self._func_callsite_param_types.get(
3089
+ key,
3090
+ [PineType.UNKNOWN] * len(callee_info.node.params),
3091
+ )
3092
+ )
3093
+ while len(current) < len(callee_info.node.params):
3094
+ current.append(PineType.UNKNOWN)
3095
+ invalidated = False
3096
+ for index in dependent_slots:
3097
+ if (
3098
+ index < len(current)
3099
+ and index < len(callee_declared)
3100
+ and callee_declared[index] == PineType.UNKNOWN
3101
+ and current[index] != PineType.UNKNOWN
3102
+ ):
3103
+ current[index] = PineType.UNKNOWN
3104
+ invalidated = True
3105
+ if invalidated:
3106
+ self._func_callsite_param_types[key] = current
3107
+ self._func_callsite_return_types.pop(key, None)
3108
+
3109
+ # Resolve parameter forwarding and direct-wrapper returns together.
3110
+ # The graph is finite and primitive types only move UNKNOWN -> known.
3111
+ for _ in range(64):
3112
+ changed = False
3113
+ for owner, callee, call in call_edges:
3114
+ if owner is None:
3115
+ continue
3116
+ owner_count = self._func_call_site_count.get(owner, 0)
3117
+ owner_variants = (
3118
+ range(owner_count) if owner_count > 0 else (None,)
3119
+ )
3120
+ owner_info = func_info_by_name.get(owner)
3121
+ if owner_info is None or owner_info.node is None:
3122
+ continue
3123
+ actuals = bound_user_call_args(callee, call)
3124
+ recorded = self._callable_bound_param_types_by_node.get(
3125
+ id(call), []
3126
+ )
3127
+ for owner_cs in owner_variants:
3128
+ callee_cs = target_variant(
3129
+ owner, owner_cs, callee, call
3130
+ )
3131
+ if callee_cs is None:
3132
+ continue
3133
+ profile = owner_profile(owner, owner_cs)
3134
+ param_map = {
3135
+ name: (
3136
+ profile[index]
3137
+ if index < len(profile)
3138
+ else PineType.UNKNOWN
3139
+ )
3140
+ for index, name in enumerate(owner_info.node.params)
3141
+ }
3142
+ incoming: list[PineType] = []
3143
+ owner_param_names = set(owner_info.node.params)
3144
+ for index, actual in enumerate(actuals):
3145
+ inferred = self._callsite_primitive_expr_type(
3146
+ actual, param_map
3147
+ )
3148
+ if (
3149
+ inferred == PineType.UNKNOWN
3150
+ and not mentions_owner_parameter(
3151
+ actual, owner_param_names
3152
+ )
3153
+ and index < len(recorded)
3154
+ ):
3155
+ inferred = recorded[index]
3156
+ incoming.append(inferred)
3157
+ if merge_profile(callee, callee_cs, incoming, call):
3158
+ changed = True
3159
+
3160
+ # Recompute each variant's primitive return. A direct terminal
3161
+ # wrapper call uses the exact callee variant return rather than the
3162
+ # shared definition-level cache.
3163
+ for key, profile in list(
3164
+ self._func_callsite_param_types.items()
3165
+ ):
3166
+ name, cs_idx = key
3167
+ info = func_info_by_name.get(name)
3168
+ if info is None or info.node is None:
3169
+ continue
3170
+ ret = self._callsite_callable_return_type(
3171
+ info.node, list(profile), info.return_type
3172
+ )
3173
+ terminal = self._direct_terminal_return_expr(info.node)
3174
+ if isinstance(terminal, FuncCall):
3175
+ edge = edge_by_call_id.get(id(terminal))
3176
+ if edge is not None:
3177
+ _, terminal_callee, terminal_call = edge
3178
+ terminal_cs = target_variant(
3179
+ name, cs_idx, terminal_callee, terminal_call
3180
+ )
3181
+ if terminal_cs is not None:
3182
+ nested_ret = self._func_callsite_return_types.get(
3183
+ (terminal_callee, terminal_cs),
3184
+ PineType.UNKNOWN,
3185
+ )
3186
+ if nested_ret != PineType.UNKNOWN:
3187
+ ret = nested_ret
3188
+ if self._func_callsite_return_types.get(key) != ret:
3189
+ self._func_callsite_return_types[key] = ret
3190
+ changed = True
3191
+ if not changed:
3192
+ break
3193
+
3194
+ # A live untyped history parameter without a variant type cannot be
3195
+ # emitted faithfully. Reject it rather than falling back to the first
3196
+ # call's global FuncInfo type and reintroducing source-order coercion.
3197
+ for name, series_names in self._func_series_vars.items():
3198
+ info = func_info_by_name.get(name)
3199
+ if info is None or info.node is None:
3200
+ continue
3201
+ count = self._func_call_site_count.get(name, 0)
3202
+ declared = declared_types(name, len(info.node.params))
3203
+ for cs_idx in range(count):
3204
+ profile = self._func_callsite_param_types.get(
3205
+ (name, cs_idx), []
3206
+ )
3207
+ for index, param in enumerate(info.node.params):
3208
+ if param not in series_names:
3209
+ continue
3210
+ if declared[index] != PineType.UNKNOWN:
3211
+ continue
3212
+ resolved = (
3213
+ profile[index]
3214
+ if index < len(profile)
3215
+ else PineType.UNKNOWN
3216
+ )
3217
+ if resolved == PineType.UNKNOWN:
3218
+ self._error(
3219
+ "Cannot infer the per-callsite primitive type of "
3220
+ f"history parameter '{param}' in callable '{name}' "
3221
+ f"variant cs{cs_idx}; declare its type explicitly.",
3222
+ info.node.loc,
3223
+ )
3224
+
779
3225
  # ------------------------------------------------------------------
780
3226
  # Mixed-callsite UDF timeframe-param security rejection.
781
3227
  #
@@ -895,6 +3341,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
895
3341
  expression=sec.expression,
896
3342
  returns_tuple=sec.returns_tuple,
897
3343
  tuple_size=sec.tuple_size,
3344
+ tuple_element_types=sec.tuple_element_types,
898
3345
  gaps=sec.gaps,
899
3346
  lookahead=sec.lookahead,
900
3347
  ta_range=sec.ta_range,
@@ -1098,6 +3545,63 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1098
3545
  return None
1099
3546
  return owner
1100
3547
 
3548
+ def _udt_name_from_nullable_ctor_selection(
3549
+ self, value: ASTNode | None
3550
+ ) -> str | None:
3551
+ """Exact user-UDT type for ctor-only nullable selections.
3552
+
3553
+ Keep this deliberately narrower than generic UDT expression
3554
+ inference. In particular, a terminal ``array.get(...UDT...)`` is a
3555
+ reference-identity surface with its own fail-closed rules; treating
3556
+ every UDT-valued expression selected against ``na`` as a by-value
3557
+ return would accidentally bypass those rules.
3558
+ """
3559
+ nullable = object()
3560
+
3561
+ def terminal(body: list[ASTNode]) -> ASTNode | None:
3562
+ if not body:
3563
+ return None
3564
+ node = body[-1]
3565
+ return node.expr if isinstance(node, ExprStmt) else node
3566
+
3567
+ def resolve(node: ASTNode | None) -> str | None | object:
3568
+ if node is None:
3569
+ return nullable
3570
+ if isinstance(node, ExprStmt):
3571
+ return resolve(node.expr)
3572
+ if isinstance(node, NaLiteral):
3573
+ return nullable
3574
+ direct = self._udt_name_from_ctor(node)
3575
+ if direct in self._udt_fields:
3576
+ return direct
3577
+ if isinstance(node, Ternary):
3578
+ return merge((resolve(node.true_val), resolve(node.false_val)))
3579
+ if isinstance(node, IfStmt):
3580
+ return merge((
3581
+ resolve(terminal(node.body)),
3582
+ resolve(terminal(node.else_body)),
3583
+ ))
3584
+ if isinstance(node, SwitchStmt):
3585
+ results = [
3586
+ resolve(terminal(branch))
3587
+ for _case, branch in node.cases
3588
+ ]
3589
+ results.append(resolve(terminal(node.default_body)))
3590
+ return merge(results)
3591
+ return None
3592
+
3593
+ def merge(results) -> str | None | object:
3594
+ resolved = list(results)
3595
+ if any(item is None for item in resolved):
3596
+ return None
3597
+ concrete = {item for item in resolved if item is not nullable}
3598
+ if not concrete:
3599
+ return nullable
3600
+ return next(iter(concrete)) if len(concrete) == 1 else None
3601
+
3602
+ result = resolve(value)
3603
+ return result if isinstance(result, str) else None
3604
+
1101
3605
  def _func_terminal_drawing_type(self, func_node: FuncDef) -> str | None:
1102
3606
  """Resolve the drawing-handle / UDT type of a function's terminal
1103
3607
  (return) expression for cases the direct ``_udt_name_from_ctor`` on the
@@ -1121,66 +3625,193 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1121
3625
  if not body:
1122
3626
  return None
1123
3627
 
1124
- # Map a drawing-handle local var name -> drawing type. Seeded from
1125
- # declared drawing type hints (``line result``) and the function's own
1126
- # drawing-typed parameters, plus any local first bound to a drawing
1127
- # ``<ns>.new(...)`` constructor.
1128
- local_drawing: dict[str, str] = {}
3628
+ # Build a source-ordered lexical binding map. ``None`` is an explicit
3629
+ # non-drawing tombstone: it prevents a same-named drawing declaration
3630
+ # from a nested or earlier block from poisoning a later scalar return.
3631
+ bindings: dict[str, str | None] = {}
1129
3632
  param_hints = (func_node.annotations or {}).get("param_type_hints", [])
1130
3633
  for i, p in enumerate(func_node.params):
1131
3634
  hint = param_hints[i] if i < len(param_hints) else None
1132
- if hint in _DRAWING_TYPE_NAMES:
1133
- local_drawing[p] = hint
1134
-
1135
- def _scan(stmts):
1136
- for st in stmts:
1137
- if isinstance(st, VarDecl):
1138
- if st.type_hint in _DRAWING_TYPE_NAMES:
1139
- local_drawing[st.name] = st.type_hint
1140
- else:
1141
- dt = self._udt_name_from_ctor(st.value)
1142
- if dt in _DRAWING_TYPE_NAMES:
1143
- local_drawing.setdefault(st.name, dt)
1144
- elif isinstance(st, Assignment) and isinstance(st.target, Identifier):
1145
- dt = self._udt_name_from_ctor(st.value)
1146
- if dt in _DRAWING_TYPE_NAMES:
1147
- local_drawing.setdefault(st.target.name, dt)
1148
- elif isinstance(st, IfStmt):
1149
- _scan(st.body)
1150
- _scan(st.else_body)
1151
-
1152
- _scan(body)
1153
-
1154
- def _resolve_terminal(stmt):
1155
- # An if used as the function's return expression: the value is the
1156
- # terminal of the executed branch — recurse into the body's (then
1157
- # else's) terminal statement.
3635
+ bindings[p] = hint if hint in _DRAWING_TYPE_NAMES else None
3636
+
3637
+ def _decl_drawing_type(stmt: VarDecl) -> str | None:
3638
+ if stmt.type_hint in _DRAWING_TYPE_NAMES:
3639
+ return stmt.type_hint
3640
+ direct = self._udt_name_from_ctor(stmt.value)
3641
+ if direct in _DRAWING_TYPE_NAMES:
3642
+ return direct
3643
+ exact = self._var_member_type_specs_by_node.get(id(stmt))
3644
+ if (exact is not None
3645
+ and exact.kind == "udt"
3646
+ and exact.name in _DRAWING_TYPE_NAMES):
3647
+ return exact.name
3648
+ spec = self._type_spec_from_expr(stmt.value)
3649
+ if (spec is not None
3650
+ and spec.kind == "udt"
3651
+ and spec.name in _DRAWING_TYPE_NAMES):
3652
+ return spec.name
3653
+ return None
3654
+
3655
+ def _scan_direct_prefix(
3656
+ stmts: list[ASTNode],
3657
+ env: dict[str, str | None],
3658
+ ) -> None:
3659
+ # Declarations inside nested control-flow bodies never leak into
3660
+ # this lexical environment. Their own branch environment is
3661
+ # created only when that control node is the terminal expression.
3662
+ for stmt in stmts:
3663
+ if isinstance(stmt, VarDecl):
3664
+ env[stmt.name] = _decl_drawing_type(stmt)
3665
+ elif isinstance(stmt, TupleAssign):
3666
+ for name in stmt.names:
3667
+ env[name] = None
3668
+
3669
+ terminal_na = object()
3670
+
3671
+ def _resolve_terminal(
3672
+ stmt: ASTNode,
3673
+ env: dict[str, str | None],
3674
+ ) -> str | None | object:
3675
+ if isinstance(stmt, ExprStmt):
3676
+ return _resolve_terminal(stmt.expr, env)
3677
+ if (isinstance(stmt, NaLiteral)
3678
+ or (isinstance(stmt, Identifier)
3679
+ and stmt.name == "na")):
3680
+ return terminal_na
1158
3681
  if isinstance(stmt, IfStmt):
3682
+ branch_results: list[str | None | object] = []
1159
3683
  for branch in (stmt.body, stmt.else_body):
1160
- if branch:
1161
- t = _resolve_terminal(branch[-1])
1162
- if t is not None:
1163
- return t
1164
- return None
1165
- expr = None
1166
- if isinstance(stmt, ExprStmt):
1167
- expr = stmt.expr
1168
- elif not isinstance(stmt, TupleLiteral) and hasattr(stmt, "loc"):
1169
- expr = stmt
1170
- if expr is None:
1171
- return None
1172
- if isinstance(expr, Identifier) and expr.name in local_drawing:
1173
- return local_drawing[expr.name]
1174
- return self._udt_name_from_ctor(expr)
3684
+ if not branch:
3685
+ continue # implicit na arm inherits the drawing type
3686
+ branch_env = dict(env)
3687
+ _scan_direct_prefix(branch[:-1], branch_env)
3688
+ branch_results.append(
3689
+ _resolve_terminal(branch[-1], branch_env)
3690
+ )
3691
+ concrete = [
3692
+ item for item in branch_results if item is not terminal_na
3693
+ ]
3694
+ if not concrete:
3695
+ return terminal_na
3696
+ first = concrete[0]
3697
+ return (
3698
+ first
3699
+ if all(item == first for item in concrete)
3700
+ else None
3701
+ )
3702
+ if isinstance(stmt, SwitchStmt):
3703
+ branch_results: list[str | None | object] = []
3704
+ for _case_expr, branch in stmt.cases:
3705
+ if not branch:
3706
+ continue
3707
+ branch_env = dict(env)
3708
+ _scan_direct_prefix(branch[:-1], branch_env)
3709
+ branch_results.append(
3710
+ _resolve_terminal(branch[-1], branch_env)
3711
+ )
3712
+ if stmt.default_body:
3713
+ branch_env = dict(env)
3714
+ _scan_direct_prefix(stmt.default_body[:-1], branch_env)
3715
+ branch_results.append(
3716
+ _resolve_terminal(stmt.default_body[-1], branch_env)
3717
+ )
3718
+ concrete = [
3719
+ item for item in branch_results if item is not terminal_na
3720
+ ]
3721
+ if not concrete:
3722
+ return terminal_na
3723
+ first = concrete[0]
3724
+ return (
3725
+ first
3726
+ if all(item == first for item in concrete)
3727
+ else None
3728
+ )
3729
+ if isinstance(stmt, VarDecl):
3730
+ return _decl_drawing_type(stmt)
3731
+ if isinstance(stmt, Identifier):
3732
+ return env.get(stmt.name)
3733
+ direct = self._udt_name_from_ctor(stmt)
3734
+ if direct in _DRAWING_TYPE_NAMES:
3735
+ return direct
3736
+ spec = self._type_spec_from_expr(stmt)
3737
+ if (spec is not None
3738
+ and spec.kind == "udt"
3739
+ and spec.name in _DRAWING_TYPE_NAMES):
3740
+ return spec.name
3741
+ return None
1175
3742
 
1176
- return _resolve_terminal(body[-1])
3743
+ _scan_direct_prefix(body[:-1], bindings)
3744
+ resolved = _resolve_terminal(body[-1], bindings)
3745
+ return resolved if isinstance(resolved, str) else None
3746
+
3747
+ def _record_collection_type(
3748
+ self,
3749
+ name: str,
3750
+ spec: TypeSpec,
3751
+ *,
3752
+ symbol_scope: str | None = None,
3753
+ ) -> None:
3754
+ """Record collection metadata without erasing another callable's local.
3755
+
3756
+ ``symbol_scope`` is supplied for reassignments so a UDF that mutates a
3757
+ top-level collection keeps updating the top-level binding. A VarDecl
3758
+ inside an active callable is necessarily lexical to that callable.
3759
+ UDT TypeSpecs retain their established registry path; this overlay is
3760
+ intentionally limited to array/map/matrix dispatch.
3761
+ """
3762
+ callable_key = (
3763
+ self._collection_scope_stack[-1]
3764
+ if self._collection_scope_stack
3765
+ else None
3766
+ )
3767
+ if (callable_key is not None
3768
+ and symbol_scope != "global"
3769
+ and self._block_node_stack):
3770
+ owner_id = id(self._block_node_stack[-1])
3771
+ self._block_collection_owners[owner_id] = callable_key
3772
+ self._block_collection_types.setdefault(owner_id, {})[name] = (
3773
+ spec if spec.kind in {"array", "map", "matrix"} else None
3774
+ )
3775
+ if spec.kind in {"array", "map", "matrix"}:
3776
+ return
3777
+ if spec.kind not in {"array", "map", "matrix"}:
3778
+ # Primitive locals have no collection metadata to export and must
3779
+ # not overwrite a same-named top-level collection. Preserve the
3780
+ # established UDT registry path until UDT identity gets its own
3781
+ # lexical migration.
3782
+ if (callable_key is not None
3783
+ and symbol_scope != "global"
3784
+ and spec.kind == "primitive"):
3785
+ return
3786
+ self._collection_types[name] = spec
3787
+ return
3788
+ if callable_key is not None and symbol_scope != "global":
3789
+ self._func_collection_types.setdefault(callable_key, {})[name] = spec
3790
+ return
3791
+ self._collection_types[name] = spec
1177
3792
 
1178
3793
  def _visit_VarDecl(self, node: VarDecl) -> PineType:
3794
+ outer_symbol = self._symbols.resolve(node.name)
3795
+ outer_spec = (
3796
+ getattr(outer_symbol, "type_spec", None)
3797
+ if outer_symbol is not None
3798
+ else self._collection_types.get(node.name)
3799
+ )
1179
3800
  # Infer type from the value expression
1180
3801
  val_type = self._visit(node.value)
1181
3802
  type_spec = self._type_spec_from_hint(node.type_hint) if node.type_hint else None
1182
3803
  if type_spec is None:
1183
3804
  type_spec = self._type_spec_from_expr(node.value)
3805
+ if self._collection_scope_stack:
3806
+ self._callable_collection_bindings[id(node)] = (
3807
+ type_spec
3808
+ if type_spec is not None
3809
+ and type_spec.kind in {"array", "map", "matrix"}
3810
+ else None
3811
+ )
3812
+ self._callable_collection_binding_owners[id(node)] = (
3813
+ self._collection_scope_stack[-1]
3814
+ )
1184
3815
 
1185
3816
  # Check for type hint override
1186
3817
  if node.type_hint:
@@ -1211,6 +3842,8 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1211
3842
  if udt_ctor is not None and type_spec is None:
1212
3843
  type_spec = TypeSpec.udt(udt_ctor)
1213
3844
 
3845
+ self._var_decl_types_by_node[id(node)] = (val_type, type_spec)
3846
+
1214
3847
  if self._global_scope:
1215
3848
  if self._is_static_expression(node.value):
1216
3849
  self._static_vars.add(node.name)
@@ -1233,34 +3866,68 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1233
3866
  udt_type_name=udt_ctor,
1234
3867
  type_spec=type_spec,
1235
3868
  )
3869
+ # A direct persistent primitive in an ordinary UDF can safely shadow
3870
+ # a top-level map once post-analysis owner qualification gives it exact
3871
+ # storage. Other scalar/map shadows still need the legacy fail-closed
3872
+ # marker because their scoped Series identity is unresolved.
3873
+ if (
3874
+ not self._global_scope
3875
+ and self._type_spec_contains_map(outer_spec)
3876
+ and not self._type_spec_contains_map(type_spec)
3877
+ and not (
3878
+ (node.is_var or node.is_varip)
3879
+ and not self._block_node_stack
3880
+ and bool(self._collection_scope_stack)
3881
+ and self._collection_scope_stack[-1] in self._func_defs
3882
+ and (
3883
+ node.name in self._ordinary_global_binding_names
3884
+ or any(
3885
+ meta[1] == node.name
3886
+ for meta in self._var_member_metadata_by_node.values()
3887
+ )
3888
+ )
3889
+ )
3890
+ ):
3891
+ setattr(sym, "_pf_shadows_map_state", True)
1236
3892
  if node.name in self._static_vars:
1237
3893
  setattr(sym, "is_static_series", True)
1238
3894
  self._symbols.define(sym)
3895
+ setattr(sym, "_pf_decl_node_id", id(node))
3896
+ setattr(sym, "_pf_decl_binding_name", node.name)
3897
+ if (type_spec is None
3898
+ and self._collection_scope_stack
3899
+ and self._block_node_stack
3900
+ and self._symbols.current_scope.name != "global"):
3901
+ owner_id = id(self._block_node_stack[-1])
3902
+ self._block_collection_owners[owner_id] = self._collection_scope_stack[-1]
3903
+ self._block_collection_types.setdefault(owner_id, {})[node.name] = None
1239
3904
  if udt_ctor is not None:
1240
3905
  self._udt_var_types[node.name] = udt_ctor
1241
3906
  if type_spec is not None:
1242
- self._collection_types[node.name] = type_spec
3907
+ self._record_collection_type(
3908
+ node.name,
3909
+ type_spec,
3910
+ symbol_scope=self._symbols.current_scope.name,
3911
+ )
1243
3912
  if type_spec.kind == "udt" and type_spec.name:
1244
3913
  self._udt_var_types[node.name] = type_spec.name
1245
3914
 
1246
3915
  # Track var members
1247
3916
  if node.is_var or node.is_varip:
1248
3917
  init_str = self._expr_to_str(node.value)
1249
- scope_name = self._symbols.current_scope.name
1250
3918
  # Block-scoped var name-collision disambiguation. A ``var``/``varip``
1251
- # declared inside a non-global, non-function block (an ``if`` / ``for``
1252
- # / ``while`` body at on_bar scope) is keyed by RAW name. Two sibling
1253
- # blocks declaring the same name would dedupe to ONE C++ member and
1254
- # cross-contaminate (proven: egoigor1976-1-trendline-strategy's
1255
- # ``var bool valid`` in the upper- and lower-trendline ``if`` blocks).
3919
+ # declared inside any non-global block (an ``if`` / ``for`` /
3920
+ # ``while`` body, including one nested in a callable) is keyed by
3921
+ # RAW name. Two sibling blocks declaring the same name would dedupe
3922
+ # to ONE C++ member and cross-contaminate (proven:
3923
+ # egoigor1976-1-trendline-strategy's ``var bool valid`` in the
3924
+ # upper- and lower-trendline ``if`` blocks).
1256
3925
  # When such a name already belongs to a DIFFERENT block, mint a
1257
3926
  # scope-unique member name and record the rename so codegen activates
1258
3927
  # it (via ``_active_var_remap``) while emitting that block.
1259
3928
  member_name = node.name
1260
3929
  is_block_scoped = (
1261
3930
  not self._global_scope
1262
- and not scope_name.startswith("func_")
1263
- and not scope_name.startswith("method_")
1264
3931
  and bool(self._block_node_stack)
1265
3932
  )
1266
3933
  if is_block_scoped:
@@ -1275,25 +3942,54 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1275
3942
  member_name = f"{node.name}__blk{self._block_var_seq}"
1276
3943
  self._block_var_renames.setdefault(block_id, {})[node.name] = member_name
1277
3944
  self._var_members.append((member_name, val_type, init_str))
3945
+ scope_cursor = self._symbols.current_scope
3946
+ is_callable_scoped = False
3947
+ callable_owner: str | None = None
3948
+ while scope_cursor is not None:
3949
+ if scope_cursor.name.startswith(("func_", "method_")):
3950
+ is_callable_scoped = True
3951
+ callable_owner = (
3952
+ self._collection_scope_stack[-1]
3953
+ if self._collection_scope_stack
3954
+ else scope_cursor.name.split("_", 1)[1]
3955
+ )
3956
+ break
3957
+ scope_cursor = scope_cursor.parent
3958
+ self._var_member_metadata_by_node[id(node)] = (
3959
+ node, member_name, val_type, init_str, is_callable_scoped,
3960
+ )
3961
+ self._var_member_type_specs_by_node[id(node)] = type_spec
3962
+ self._var_member_owners_by_node[id(node)] = callable_owner
3963
+ # Preserve the emitted storage identity on the lexical Symbol so a
3964
+ # later history read can mark the exact sibling member rather than
3965
+ # only the legacy raw spelling.
3966
+ setattr(sym, "_pf_var_member_name", member_name)
1278
3967
  # Capture the init AST too so codegen can inspect the RHS callee
1279
3968
  # (used to detect int64-returning builtins like ``time()`` and
1280
3969
  # promote the symbol storage type to ``int64_t``).
1281
3970
  if node.value is not None:
1282
3971
  self._var_member_init_exprs[member_name] = node.value
1283
- # Track function-scoped var members
1284
- if scope_name.startswith("func_"):
1285
- func_name = scope_name[5:] # strip "func_" prefix
1286
- if func_name not in self._func_var_members:
1287
- self._func_var_members[func_name] = []
1288
- self._func_var_members[func_name].append((node.name, val_type, init_str))
3972
+ # Track callable-scoped var members under the analyzer's canonical
3973
+ # owner identity. Ordinary UDFs use ``name`` and UDT methods use
3974
+ # ``Type.method``; both feed the same written-callsite clone graph.
3975
+ if is_callable_scoped and callable_owner is not None:
3976
+ if callable_owner not in self._func_var_members:
3977
+ self._func_var_members[callable_owner] = []
3978
+ self._func_var_members[callable_owner].append(
3979
+ (member_name, val_type, init_str)
3980
+ )
1289
3981
 
1290
3982
  # Track global-scope non-var declarations (needed as class members
1291
3983
  # so user functions can reference them)
1292
3984
  if (not node.is_var and not node.is_varip
1293
- and self._symbols.current_scope.name == "global"
1294
- and node.name not in self._series_vars):
1295
- self._global_var_decls.append((node.name, val_type))
1296
- self._global_expr_map[node.name] = node.value
3985
+ and self._symbols.current_scope.name == "global"):
3986
+ if self._global_scope and not self._block_node_stack:
3987
+ self._ordinary_global_binding_info[node.name] = (
3988
+ id(node), val_type, node.value,
3989
+ )
3990
+ if node.name not in self._series_vars:
3991
+ self._global_var_decls.append((node.name, val_type))
3992
+ self._global_expr_map[node.name] = node.value
1297
3993
 
1298
3994
  if self._symbols.current_scope.name == "global":
1299
3995
  self._record_global_binding_stmt(
@@ -1315,7 +4011,12 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1315
4011
  if isinstance(node.target, Identifier):
1316
4012
  spec = self._type_spec_from_expr(node.value)
1317
4013
  if spec is not None:
1318
- self._collection_types[node.target.name] = spec
4014
+ target_sym = self._symbols.resolve(node.target.name)
4015
+ self._record_collection_type(
4016
+ node.target.name,
4017
+ spec,
4018
+ symbol_scope=(target_sym.scope if target_sym is not None else None),
4019
+ )
1319
4020
 
1320
4021
  # Resolve the target
1321
4022
  if isinstance(node.target, Identifier):
@@ -1359,13 +4060,28 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1359
4060
  def _visit_TupleAssign(self, node: TupleAssign) -> PineType:
1360
4061
  val_type = self._visit(node.value)
1361
4062
  loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
4063
+ element_types = self._tuple_element_types_by_node.get(id(node.value), ())
1362
4064
 
1363
4065
  is_val_static = self._is_static_expression(node.value)
1364
4066
 
1365
- for name in node.names:
4067
+ for idx, name in enumerate(node.names):
1366
4068
  if name == "_":
1367
4069
  continue
1368
4070
 
4071
+ inferred_element_type = (
4072
+ element_types[idx]
4073
+ if idx < len(element_types)
4074
+ else PineType.FLOAT
4075
+ )
4076
+ # Tuple bindings historically use double storage for every
4077
+ # numeric element, including integer literals. Preserve that
4078
+ # contract while retaining the newly-authoritative bool family.
4079
+ element_type = (
4080
+ PineType.BOOL
4081
+ if inferred_element_type == PineType.BOOL
4082
+ else PineType.FLOAT
4083
+ )
4084
+
1369
4085
  if self._global_scope and is_val_static:
1370
4086
  self._static_vars.add(name)
1371
4087
  else:
@@ -1373,7 +4089,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1373
4089
 
1374
4090
  sym = Symbol(
1375
4091
  name=name,
1376
- pine_type=PineType.FLOAT, # tuple elements are typically float
4092
+ pine_type=element_type,
1377
4093
  is_series=False,
1378
4094
  is_var=False,
1379
4095
  is_const=False,
@@ -1384,6 +4100,15 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1384
4100
  if name in self._static_vars:
1385
4101
  setattr(sym, "is_static_series", True)
1386
4102
  self._symbols.define(sym)
4103
+ setattr(sym, "_pf_decl_node_id", id(node))
4104
+ setattr(sym, "_pf_decl_binding_name", name)
4105
+
4106
+ if (self._collection_scope_stack
4107
+ and self._block_node_stack
4108
+ and self._symbols.current_scope.name != "global"):
4109
+ owner_id = id(self._block_node_stack[-1])
4110
+ self._block_collection_owners[owner_id] = self._collection_scope_stack[-1]
4111
+ self._block_collection_types.setdefault(owner_id, {})[name] = None
1387
4112
 
1388
4113
  # Track global-scope tuple-assign targets (e.g.
1389
4114
  # ``[pdH, pdL] = request.security(...)``) as class members so user
@@ -1392,11 +4117,15 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1392
4117
  # "use of undeclared identifier".
1393
4118
  if (self._global_scope
1394
4119
  and self._symbols.current_scope.name == "global"
1395
- and name not in self._series_vars):
1396
- self._global_var_decls.append((name, PineType.FLOAT))
1397
- self._global_expr_map[name] = node.value
4120
+ ):
4121
+ self._ordinary_global_binding_info[name] = (
4122
+ id(node), element_type, node.value,
4123
+ )
4124
+ if name not in self._series_vars:
4125
+ self._global_var_decls.append((name, element_type))
4126
+ self._global_expr_map[name] = node.value
1398
4127
  self._record_global_binding_stmt(
1399
- name, PineType.FLOAT, False, decl_node=node,
4128
+ name, element_type, False, decl_node=node,
1400
4129
  )
1401
4130
 
1402
4131
  return val_type
@@ -1435,6 +4164,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1435
4164
  loc=loc,
1436
4165
  type_spec=pspec,
1437
4166
  )
4167
+ setattr(sym, "_pf_parameter_owner", node.name)
1438
4168
  self._symbols.define(sym)
1439
4169
 
1440
4170
  # Record TA counter before visiting body
@@ -1446,28 +4176,36 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1446
4176
  self._global_scope = False
1447
4177
  self._enclosing_func_params.append(set(node.params))
1448
4178
  self._enclosing_func_names.append(node.name)
4179
+ self._collection_scope_stack.append(node.name)
1449
4180
  self._nested_ta_touched = set()
1450
4181
  try:
1451
4182
  for stmt in node.body:
1452
4183
  body_type = self._visit(stmt)
1453
4184
  finally:
1454
4185
  self._global_scope = old_global
4186
+ self._collection_scope_stack.pop()
1455
4187
  self._enclosing_func_params.pop()
1456
4188
  self._enclosing_func_names.pop()
1457
4189
  nested_touched = self._nested_ta_touched
1458
4190
  self._nested_ta_touched = None
1459
4191
 
1460
- # Record TA range for this function. Widen to cover any nested-callee TA
1461
- # sites whose ctor args were rewritten in terms of THIS function's params
1462
- # (e.g. f_basisMa's sites parameterized by f_bbwp's _bbwLen), so resolving
1463
- # this function at its call site re-substitutes those nested sites too.
4192
+ # Record exact TA ownership for this function: direct allocations plus
4193
+ # targets borrowed from every nested stateful call. Per-owner ctor
4194
+ # templates retain forwarded-parameter expressions independently.
1464
4195
  ta_end = len(self._ta_call_sites)
1465
- lo, hi = ta_start, ta_end
1466
- if nested_touched:
1467
- lo = min(lo, min(nested_touched))
1468
- hi = max(hi, max(nested_touched) + 1)
1469
- if hi > lo:
1470
- self._func_ta_ranges[node.name] = (lo, hi)
4196
+ exact_indices = sorted(
4197
+ set(range(ta_start, ta_end)) | set(nested_touched or ())
4198
+ )
4199
+ if exact_indices:
4200
+ self._func_ta_indices[node.name] = exact_indices
4201
+ self._func_ta_ranges[node.name] = (
4202
+ min(exact_indices), max(exact_indices) + 1
4203
+ )
4204
+ templates = self._func_ta_ctor_args.setdefault(node.name, {})
4205
+ for index in exact_indices:
4206
+ templates.setdefault(
4207
+ index, list(self._ta_call_sites[index].ctor_args)
4208
+ )
1471
4209
 
1472
4210
  inferred_param_specs = self._param_type_specs_from_def(node)
1473
4211
  for i, param in enumerate(node.params):
@@ -1479,11 +4217,46 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1479
4217
  inferred_param_specs[i] = spec
1480
4218
  self._func_param_type_specs[node.name] = inferred_param_specs
1481
4219
 
4220
+ # Capture direct terminal map-call metadata before leaving the lexical
4221
+ # function scope. Local map variables and typed map parameters are no
4222
+ # longer resolvable from the symbol table after ``exit_scope()``. Keep
4223
+ # this narrow to map terminals so general/nonterminal inference and
4224
+ # generated output remain unchanged.
4225
+ terminal_ret_expr = self._direct_terminal_return_expr(node)
4226
+ temporary_return_expr = (
4227
+ self._direct_terminal_array_temporary_return_expr(
4228
+ node, terminal_ret_expr
4229
+ )
4230
+ )
4231
+ if temporary_return_expr is not None:
4232
+ self._direct_terminal_array_temporary_exprs[node.name] = (
4233
+ temporary_return_expr
4234
+ )
4235
+ terminal_direct_return_spec = self._type_spec_from_expr(
4236
+ terminal_ret_expr
4237
+ )
4238
+ terminal_map_return = self._terminal_map_call_return(
4239
+ terminal_ret_expr,
4240
+ {
4241
+ name: spec
4242
+ for name, spec in zip(node.params, inferred_param_specs)
4243
+ },
4244
+ )
4245
+ terminal_array_get_return = self._terminal_array_get_return(
4246
+ terminal_ret_expr,
4247
+ {
4248
+ name: spec
4249
+ for name, spec in zip(node.params, inferred_param_specs)
4250
+ },
4251
+ terminal_direct_return_spec,
4252
+ )
4253
+
1482
4254
  self._symbols.exit_scope()
1483
4255
 
1484
4256
  # Detect if function returns a tuple (last stmt is TupleLiteral)
1485
4257
  self._func_returns_tuple[node.name] = False
1486
4258
  self._func_tuple_element_count[node.name] = 0
4259
+ self._func_tuple_element_types[node.name] = ()
1487
4260
  if node.body:
1488
4261
  last_stmt = node.body[-1]
1489
4262
  tuple_node = None
@@ -1494,20 +4267,89 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1494
4267
  if tuple_node is not None:
1495
4268
  self._func_returns_tuple[node.name] = True
1496
4269
  self._func_tuple_element_count[node.name] = len(tuple_node.elements)
4270
+ self._func_tuple_element_types[node.name] = (
4271
+ self._tuple_element_types_by_node.get(id(tuple_node), ())
4272
+ )
4273
+ elif (
4274
+ isinstance(terminal_ret_expr, FuncCall)
4275
+ and isinstance(terminal_ret_expr.callee, Identifier)
4276
+ ):
4277
+ # A direct terminal helper wrapper preserves the callee's
4278
+ # tuple shape. Without this metadata request.security treats
4279
+ # ``outer(x) => inner(x)`` as scalar and emits std::get against
4280
+ # a double result. Keep propagation deliberately direct: more
4281
+ # complex conditional/collection return shapes remain outside
4282
+ # the supported helper-tuple contract.
4283
+ terminal_callee = terminal_ret_expr.callee.name
4284
+ if self._func_returns_tuple.get(terminal_callee, False):
4285
+ self._func_returns_tuple[node.name] = True
4286
+ self._func_tuple_element_count[node.name] = (
4287
+ self._func_tuple_element_count.get(terminal_callee, 0)
4288
+ )
4289
+ self._func_tuple_element_types[node.name] = (
4290
+ self._func_tuple_element_types.get(terminal_callee, ())
4291
+ )
4292
+
4293
+ # Re-run direct-wrapper propagation to a fixed point whenever a new
4294
+ # definition is analyzed. This makes ``outer()=>inner()`` source-order
4295
+ # safe when ``inner`` is defined later: once the callee's literal tuple
4296
+ # shape is known, every already-seen wrapper chain is updated before a
4297
+ # following request.security call is analyzed.
4298
+ changed = True
4299
+ while changed:
4300
+ changed = False
4301
+ for wrapper_name, wrapper_def in self._func_defs.items():
4302
+ wrapper_terminal = self._direct_terminal_return_expr(
4303
+ wrapper_def
4304
+ )
4305
+ if not (
4306
+ isinstance(wrapper_terminal, FuncCall)
4307
+ and isinstance(wrapper_terminal.callee, Identifier)
4308
+ ):
4309
+ continue
4310
+ callee_name = wrapper_terminal.callee.name
4311
+ if not self._func_returns_tuple.get(callee_name, False):
4312
+ continue
4313
+ tuple_count = self._func_tuple_element_count.get(
4314
+ callee_name, 0
4315
+ )
4316
+ tuple_types = self._func_tuple_element_types.get(
4317
+ callee_name, ()
4318
+ )
4319
+ if (
4320
+ self._func_returns_tuple.get(wrapper_name, False)
4321
+ and self._func_tuple_element_count.get(wrapper_name, 0)
4322
+ == tuple_count
4323
+ and self._func_tuple_element_types.get(wrapper_name, ())
4324
+ == tuple_types
4325
+ ):
4326
+ continue
4327
+ self._func_returns_tuple[wrapper_name] = True
4328
+ self._func_tuple_element_count[wrapper_name] = tuple_count
4329
+ self._func_tuple_element_types[wrapper_name] = tuple_types
4330
+ for info in self._func_infos:
4331
+ if info.name == wrapper_name:
4332
+ info.returns_tuple = True
4333
+ info.tuple_element_count = tuple_count
4334
+ changed = True
1497
4335
 
1498
4336
  # Detect if the function returns a UDT instance via ``T.new(...)`` —
1499
4337
  # used by codegen to emit the C++ return type as the struct name and
1500
4338
  # to propagate UDT typing onto the caller's local. Probe:
1501
4339
  # data/validation/udt-method-probe-20-udt-return-from-func.
1502
4340
  if node.body:
1503
- last_stmt = node.body[-1]
1504
- ret_expr = None
1505
- if isinstance(last_stmt, ExprStmt):
1506
- ret_expr = last_stmt.expr
1507
- elif not isinstance(last_stmt, (TupleLiteral,)):
1508
- # last_stmt is itself an expression node (single-expr funcs)
1509
- ret_expr = last_stmt if hasattr(last_stmt, "loc") else None
4341
+ ret_expr = terminal_ret_expr
1510
4342
  udt_ret = self._udt_name_from_ctor(ret_expr) if ret_expr is not None else None
4343
+ if udt_ret is None:
4344
+ udt_ret = self._udt_name_from_nullable_ctor_selection(ret_expr)
4345
+ if (udt_ret is None
4346
+ and terminal_direct_return_spec is not None
4347
+ and terminal_direct_return_spec.kind == "udt"):
4348
+ # A user UDT is a numeric object-ID handle just like a drawing
4349
+ # handle. Returning a parameter/local identity must retain
4350
+ # that exact handle type; limiting this route to drawings made
4351
+ # ``identity(Item value) => value`` emit ``double``.
4352
+ udt_ret = terminal_direct_return_spec.name
1511
4353
  if udt_ret is None:
1512
4354
  # Drawing-handle returns wrapped in an if-statement terminal
1513
4355
  # branch (``makeEventLabel``) or returned as a bare drawing-handle
@@ -1522,9 +4364,40 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1522
4364
  # returning an array returns a ``std::vector<...>``. The coarse
1523
4365
  # PineType return can't represent this, so carry the TypeSpec.
1524
4366
  if ret_expr is not None:
1525
- ret_spec = self._type_spec_from_expr(ret_expr)
4367
+ # This exact spec was captured while the function's lexical
4368
+ # symbols were still active. Re-resolving the terminal after
4369
+ # ``exit_scope()`` can bind a same-named top-level collection
4370
+ # and falsely turn a scalar UDF return into an array.
4371
+ ret_spec = terminal_direct_return_spec
1526
4372
  if ret_spec is not None and ret_spec.kind == "array":
1527
4373
  self._func_return_type_specs[node.name] = ret_spec
4374
+ if (terminal_direct_return_spec is not None
4375
+ and terminal_direct_return_spec.kind == "map"):
4376
+ self._func_return_type_specs[node.name] = terminal_direct_return_spec
4377
+ if (isinstance(terminal_ret_expr, Identifier)
4378
+ and terminal_direct_return_spec is not None
4379
+ and terminal_direct_return_spec.kind == "primitive"):
4380
+ # Preserve the lexical terminal identity before leaving the
4381
+ # function. Deferred map propagation must not reinterpret a
4382
+ # same-named scalar local through a top-level collection. Keep
4383
+ # this to direct identifier returns so unrelated call results
4384
+ # (for example ``map.size()``) retain legacy output typing.
4385
+ self._func_return_type_specs[node.name] = (
4386
+ terminal_direct_return_spec
4387
+ )
4388
+
4389
+ if terminal_map_return is not None:
4390
+ body_type, terminal_spec = terminal_map_return
4391
+ if terminal_spec is not None:
4392
+ self._func_return_type_specs[node.name] = terminal_spec
4393
+
4394
+ if terminal_array_get_return is not None:
4395
+ body_type, terminal_spec = terminal_array_get_return
4396
+ # Preserve the exact primitive TypeSpec as well as the coarse
4397
+ # PineType. Downstream collection construction consults this cache
4398
+ # directly; without it, asking for the UDF's element type can
4399
+ # re-visit the call and duplicate stateful call sites.
4400
+ self._func_return_type_specs[node.name] = terminal_spec
1528
4401
 
1529
4402
  # Store return type
1530
4403
  self._func_return_types[node.name] = body_type
@@ -1542,6 +4415,13 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1542
4415
  )
1543
4416
  self._symbols.define(sym)
1544
4417
 
4418
+ # A definition just completed may supply the primitive return needed
4419
+ # by an earlier direct ``array.from(udf()).get(...)`` reader. Refresh
4420
+ # before the next top-level statement is analyzed so its call site
4421
+ # observes the reconciled type.
4422
+ self._register_resolved_direct_terminal_array_forward_calls()
4423
+ self._refresh_direct_terminal_array_temporary_returns()
4424
+
1545
4425
  return PineType.VOID
1546
4426
 
1547
4427
  # ------------------------------------------------------------------
@@ -1590,7 +4470,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1590
4470
  return PineType.VOID
1591
4471
 
1592
4472
  def _visit_MethodDef(self, node) -> PineType:
1593
- """Register UDT instance method under a unique key ``TypeName.methodName``."""
4473
+ """Register a typed instance method under ``TypeName.methodName``."""
1594
4474
  method_key = f"{node.type_name}.{node.name}"
1595
4475
  self._symbols.enter_scope(f"method_{node.type_name}_{node.name}")
1596
4476
  loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
@@ -1598,28 +4478,90 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1598
4478
  param_types: list[PineType] = []
1599
4479
  param_specs: list = []
1600
4480
  for i, p in enumerate(node.params):
1601
- udt_self = node.type_name if i == 0 else None
1602
4481
  hint = param_hints[i] if i < len(param_hints) else None
1603
- ptype = self._type_hint_to_pine(hint) if hint else PineType.FLOAT
4482
+ # Only the receiver is required to be typed in Pine methods. Every
4483
+ # other omitted type is polymorphic per written call, exactly like
4484
+ # a regular UDF parameter; seeding it as FLOAT makes even a single
4485
+ # bool/int history call silently coerce through Series<double>.
4486
+ ptype = self._type_hint_to_pine(hint) if hint else PineType.UNKNOWN
1604
4487
  pspec = self._type_spec_from_hint(hint) if hint else None
4488
+ udt_self = (
4489
+ node.type_name
4490
+ if i == 0 and pspec is not None and pspec.kind == "udt"
4491
+ else None
4492
+ )
1605
4493
  param_types.append(ptype)
1606
4494
  param_specs.append(pspec)
1607
- self._symbols.define(Symbol(
4495
+ sym = Symbol(
1608
4496
  name=p, pine_type=ptype, is_series=False,
1609
4497
  is_var=False, is_const=False, const_value=None,
1610
4498
  scope=self._symbols.current_scope.name, loc=loc,
1611
4499
  udt_type_name=udt_self,
1612
4500
  type_spec=pspec,
1613
- ))
4501
+ )
4502
+ setattr(sym, "_pf_parameter_owner", method_key)
4503
+ self._symbols.define(sym)
1614
4504
  ret_type = PineType.VOID
4505
+ ta_start = len(self._ta_call_sites)
1615
4506
  old_global = self._global_scope
1616
4507
  self._global_scope = False
4508
+ self._enclosing_func_params.append(set(node.params))
4509
+ self._enclosing_func_names.append(method_key)
4510
+ self._collection_scope_stack.append(method_key)
4511
+ previous_nested_ta_touched = self._nested_ta_touched
4512
+ self._nested_ta_touched = set()
4513
+ terminal_ret_expr = self._direct_terminal_return_expr(node)
4514
+ return_type_spec = None
4515
+ method_udt_return = None
1617
4516
  try:
1618
4517
  for stmt in node.body:
1619
4518
  ret_type = self._visit(stmt)
4519
+ if terminal_ret_expr is not None:
4520
+ terminal_spec = self._type_spec_from_expr(terminal_ret_expr)
4521
+ if terminal_spec is not None and terminal_spec.kind == "map":
4522
+ return_type_spec = terminal_spec
4523
+ if terminal_spec is not None and terminal_spec.kind == "udt":
4524
+ # Methods may return ``self`` or another UDT-typed
4525
+ # parameter/local, not only a syntactic ``Type.new``.
4526
+ method_udt_return = terminal_spec.name
4527
+ return_type_spec = terminal_spec
4528
+ else:
4529
+ method_udt_return = (
4530
+ self._udt_name_from_ctor(terminal_ret_expr)
4531
+ or self._udt_name_from_nullable_ctor_selection(
4532
+ terminal_ret_expr
4533
+ )
4534
+ )
4535
+ if method_udt_return is not None:
4536
+ return_type_spec = TypeSpec.udt(method_udt_return)
1620
4537
  finally:
1621
4538
  self._global_scope = old_global
4539
+ self._collection_scope_stack.pop()
4540
+ self._enclosing_func_params.pop()
4541
+ self._enclosing_func_names.pop()
4542
+ nested_touched = self._nested_ta_touched
4543
+ self._nested_ta_touched = previous_nested_ta_touched
4544
+
4545
+ # UDT methods participate in the same stateful call graph as ordinary
4546
+ # UDFs. Preserve exact direct and borrowed TA ownership under the
4547
+ # canonical ``Type.method`` identity so written call sites can clone it.
4548
+ ta_end = len(self._ta_call_sites)
4549
+ exact_indices = sorted(
4550
+ set(range(ta_start, ta_end)) | set(nested_touched or ())
4551
+ )
4552
+ if exact_indices:
4553
+ self._func_ta_indices[method_key] = exact_indices
4554
+ self._func_ta_ranges[method_key] = (
4555
+ min(exact_indices), max(exact_indices) + 1
4556
+ )
4557
+ templates = self._func_ta_ctor_args.setdefault(method_key, {})
4558
+ for index in exact_indices:
4559
+ templates.setdefault(
4560
+ index, list(self._ta_call_sites[index].ctor_args)
4561
+ )
1622
4562
  self._symbols.exit_scope()
4563
+ if method_udt_return is not None:
4564
+ self._func_udt_return_types[method_key] = method_udt_return
1623
4565
 
1624
4566
  # Detect tuple return on UDT methods (mirrors the regular FuncDef logic
1625
4567
  # earlier in this file). Without this, codegen emits the method with a
@@ -1646,44 +4588,113 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1646
4588
  # them (e.g., synthetic MethodDef nodes from tests).
1647
4589
  while len(param_defaults) < len(node.params):
1648
4590
  param_defaults.append(None)
4591
+ self._func_param_type_specs[method_key] = list(param_specs)
1649
4592
  fi = FuncInfo(
1650
4593
  name=method_key,
1651
4594
  param_types=param_types,
1652
4595
  return_type=ret_type,
1653
- node=FuncDef(name=node.name, params=node.params,
1654
- body=node.body, is_single_expr=node.is_single_expr),
4596
+ node=FuncDef(
4597
+ name=node.name,
4598
+ params=node.params,
4599
+ body=node.body,
4600
+ is_single_expr=node.is_single_expr,
4601
+ annotations=dict(node.annotations or {}),
4602
+ ),
1655
4603
  is_udt_method=True,
1656
4604
  udt_type_name=node.type_name,
1657
4605
  returns_tuple=returns_tuple,
1658
4606
  tuple_element_count=tuple_element_count,
1659
4607
  param_defaults=param_defaults,
1660
4608
  param_type_specs=param_specs,
4609
+ return_type_spec=return_type_spec,
4610
+ udt_return_type=method_udt_return,
1661
4611
  )
1662
4612
  self._func_infos.append(fi)
4613
+ # A just-registered later sibling can resolve an earlier direct method
4614
+ # wrapper before the next source statement is analyzed.
4615
+ self._refresh_direct_typed_method_wrapper_returns()
1663
4616
  return PineType.VOID
1664
4617
 
1665
4618
  # ------------------------------------------------------------------
1666
4619
  # Control flow
1667
4620
  # ------------------------------------------------------------------
1668
4621
 
4622
+ def _top_level_branch_needs_lexical_scope(
4623
+ self, body: list[ASTNode]
4624
+ ) -> bool:
4625
+ """Whether a top-level branch declares over a direct script binding.
4626
+
4627
+ Functions already own a symbol-table scope and ``for``/``for in``
4628
+ create one explicitly. Top-level if/while/switch branches historically
4629
+ reused the global Scope, so a local declaration could overwrite the
4630
+ analyzer's outer Symbol even though codegen emits a C++ lexical local.
4631
+ Add a child scope only for the collision shape handled by this change;
4632
+ every unrelated program keeps the established analysis path.
4633
+ """
4634
+ if self._collection_scope_stack:
4635
+ return False
4636
+ for stmt in body:
4637
+ if (isinstance(stmt, VarDecl)
4638
+ and stmt.name in self._direct_program_binding_names):
4639
+ return True
4640
+ if (isinstance(stmt, TupleAssign)
4641
+ and any(
4642
+ name in self._direct_program_binding_names
4643
+ for name in stmt.names
4644
+ if name != "_"
4645
+ )):
4646
+ return True
4647
+ return False
4648
+
4649
+ def _enter_top_level_branch_scope(
4650
+ self, body: list[ASTNode], name: str
4651
+ ) -> bool:
4652
+ scoped = self._top_level_branch_needs_lexical_scope(body)
4653
+ if scoped:
4654
+ self._symbols.enter_scope(name)
4655
+ return scoped
4656
+
1669
4657
  def _visit_IfStmt(self, node: IfStmt) -> PineType:
1670
4658
  old_global = self._global_scope
1671
4659
  self._global_scope = False
1672
- self._block_node_stack.append(node)
1673
4660
  try:
1674
4661
  self._visit(node.condition)
1675
4662
  body_type = PineType.VOID
1676
- for stmt in node.body:
1677
- body_type = self._visit(stmt)
1678
- for stmt in node.else_body:
1679
- self._visit(stmt)
4663
+ body_scoped = self._enter_top_level_branch_scope(
4664
+ node.body, "top_if"
4665
+ )
4666
+ self._block_node_stack.append(node.body)
4667
+ try:
4668
+ for stmt in node.body:
4669
+ body_type = self._visit(stmt)
4670
+ finally:
4671
+ self._block_node_stack.pop()
4672
+ if body_scoped:
4673
+ self._symbols.exit_scope()
4674
+ if node.else_body:
4675
+ else_scoped = self._enter_top_level_branch_scope(
4676
+ node.else_body, "top_else"
4677
+ )
4678
+ self._block_node_stack.append(node.else_body)
4679
+ try:
4680
+ for stmt in node.else_body:
4681
+ self._visit(stmt)
4682
+ finally:
4683
+ self._block_node_stack.pop()
4684
+ if else_scoped:
4685
+ self._symbols.exit_scope()
1680
4686
  finally:
1681
- self._block_node_stack.pop()
1682
4687
  self._global_scope = old_global
1683
4688
  # If used as expression (x = if ...), return last expr type
1684
4689
  return body_type
1685
4690
 
1686
4691
  def _visit_ForStmt(self, node: ForStmt) -> PineType:
4692
+ outer_symbol = self._symbols.resolve(node.var)
4693
+ outer_spec = (
4694
+ getattr(outer_symbol, "type_spec", None)
4695
+ if outer_symbol is not None
4696
+ else self._collection_types.get(node.var)
4697
+ )
1687
4698
  self._symbols.enter_scope("for")
1688
4699
  loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
1689
4700
 
@@ -1697,8 +4708,13 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1697
4708
  const_value=None,
1698
4709
  scope="for",
1699
4710
  loc=loc,
4711
+ type_spec=TypeSpec.primitive("int"),
1700
4712
  )
4713
+ if self._type_spec_contains_map(outer_spec):
4714
+ setattr(sym, "_pf_shadows_map_state", True)
1701
4715
  self._symbols.define(sym)
4716
+ setattr(sym, "_pf_decl_node_id", id(node))
4717
+ setattr(sym, "_pf_decl_binding_name", node.var)
1702
4718
 
1703
4719
  old_global = self._global_scope
1704
4720
  self._global_scope = False
@@ -1723,22 +4739,66 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1723
4739
  self._block_node_stack.append(node)
1724
4740
  try:
1725
4741
  self._visit(node.iterable)
4742
+ iterable_spec = self._type_spec_from_expr(node.iterable)
4743
+ element_spec = (
4744
+ iterable_spec.element
4745
+ if iterable_spec is not None and iterable_spec.kind == "array"
4746
+ else None
4747
+ )
4748
+ tuple_specs: list[TypeSpec | None] = []
4749
+ if iterable_spec is not None and iterable_spec.kind == "map":
4750
+ tuple_specs = [iterable_spec.key, iterable_spec.value]
1726
4751
  self._symbols.enter_scope("for_in")
1727
4752
  if node.var:
1728
4753
  loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
1729
- self._symbols.define(Symbol(
1730
- name=node.var, pine_type=PineType.FLOAT, is_series=False,
4754
+ pine_type = self._element_pine_type(element_spec)
4755
+ if pine_type == PineType.VOID:
4756
+ pine_type = PineType.FLOAT
4757
+ outer_symbol = self._symbols.resolve(node.var)
4758
+ outer_spec = (
4759
+ getattr(outer_symbol, "type_spec", None)
4760
+ if outer_symbol is not None
4761
+ else self._collection_types.get(node.var)
4762
+ )
4763
+ sym = Symbol(
4764
+ name=node.var, pine_type=pine_type, is_series=False,
1731
4765
  is_var=False, is_const=False, const_value=None,
1732
4766
  scope=self._symbols.current_scope.name, loc=loc,
1733
- ))
4767
+ type_spec=element_spec,
4768
+ )
4769
+ if self._type_spec_contains_map(outer_spec):
4770
+ setattr(sym, "_pf_shadows_map_state", True)
4771
+ self._symbols.define(sym)
4772
+ setattr(sym, "_pf_decl_node_id", id(node))
4773
+ setattr(sym, "_pf_decl_binding_name", node.var)
1734
4774
  if node.vars:
1735
4775
  loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
1736
- for v in node.vars:
1737
- self._symbols.define(Symbol(
1738
- name=v, pine_type=PineType.FLOAT, is_series=False,
4776
+ for idx, v in enumerate(node.vars):
4777
+ binder_spec = (
4778
+ tuple_specs[idx]
4779
+ if idx < len(tuple_specs)
4780
+ else None
4781
+ )
4782
+ pine_type = self._element_pine_type(binder_spec)
4783
+ if pine_type == PineType.VOID:
4784
+ pine_type = PineType.FLOAT
4785
+ outer_symbol = self._symbols.resolve(v)
4786
+ outer_spec = (
4787
+ getattr(outer_symbol, "type_spec", None)
4788
+ if outer_symbol is not None
4789
+ else self._collection_types.get(v)
4790
+ )
4791
+ sym = Symbol(
4792
+ name=v, pine_type=pine_type, is_series=False,
1739
4793
  is_var=False, is_const=False, const_value=None,
1740
4794
  scope=self._symbols.current_scope.name, loc=loc,
1741
- ))
4795
+ type_spec=binder_spec,
4796
+ )
4797
+ if self._type_spec_contains_map(outer_spec):
4798
+ setattr(sym, "_pf_shadows_map_state", True)
4799
+ self._symbols.define(sym)
4800
+ setattr(sym, "_pf_decl_node_id", id(node))
4801
+ setattr(sym, "_pf_decl_binding_name", v)
1742
4802
  for stmt in node.body:
1743
4803
  self._visit(stmt)
1744
4804
  self._symbols.exit_scope()
@@ -1750,6 +4810,9 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1750
4810
  def _visit_WhileStmt(self, node: WhileStmt) -> PineType:
1751
4811
  old_global = self._global_scope
1752
4812
  self._global_scope = False
4813
+ body_scoped = self._enter_top_level_branch_scope(
4814
+ node.body, "top_while"
4815
+ )
1753
4816
  self._block_node_stack.append(node)
1754
4817
  try:
1755
4818
  self._visit(node.condition)
@@ -1757,6 +4820,8 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1757
4820
  self._visit(stmt)
1758
4821
  finally:
1759
4822
  self._block_node_stack.pop()
4823
+ if body_scoped:
4824
+ self._symbols.exit_scope()
1760
4825
  self._global_scope = old_global
1761
4826
  return PineType.VOID
1762
4827
 
@@ -1770,10 +4835,29 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1770
4835
  for case_expr, case_body in node.cases:
1771
4836
  if case_expr:
1772
4837
  self._visit(case_expr)
1773
- for stmt in case_body:
1774
- result_type = self._visit(stmt)
1775
- for stmt in node.default_body:
1776
- self._visit(stmt)
4838
+ case_scoped = self._enter_top_level_branch_scope(
4839
+ case_body, "top_switch_case"
4840
+ )
4841
+ self._block_node_stack.append(case_body)
4842
+ try:
4843
+ for stmt in case_body:
4844
+ result_type = self._visit(stmt)
4845
+ finally:
4846
+ self._block_node_stack.pop()
4847
+ if case_scoped:
4848
+ self._symbols.exit_scope()
4849
+ if node.default_body:
4850
+ default_scoped = self._enter_top_level_branch_scope(
4851
+ node.default_body, "top_switch_default"
4852
+ )
4853
+ self._block_node_stack.append(node.default_body)
4854
+ try:
4855
+ for stmt in node.default_body:
4856
+ self._visit(stmt)
4857
+ finally:
4858
+ self._block_node_stack.pop()
4859
+ if default_scoped:
4860
+ self._symbols.exit_scope()
1777
4861
  finally:
1778
4862
  self._global_scope = old_global
1779
4863
  # If used as expression (x = switch ...), return last expr type
@@ -1907,12 +4991,210 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1907
4991
  if isinstance(obj, Identifier) and obj.name == "request":
1908
4992
  return self._handle_request_call(member, node)
1909
4993
 
4994
+ # strategy.closedtrades.*(idx) / strategy.opentrades.*(idx).
4995
+ # The callee is a nested MemberAccess, so it does not enter the
4996
+ # direct ``strategy.*`` branch above. Its MemberAccess visitor
4997
+ # already owns the authoritative accessor return-type mapping;
4998
+ # preserve that type for the call instead of falling through to
4999
+ # VOID (which later becomes a C++ double declaration).
5000
+ if (isinstance(obj, MemberAccess)
5001
+ and isinstance(obj.object, Identifier)
5002
+ and obj.object.name == "strategy"
5003
+ and obj.member in ("closedtrades", "opentrades")):
5004
+ for arg in node.args:
5005
+ self._visit(arg)
5006
+ for val in node.kwargs.values():
5007
+ self._visit(val)
5008
+ return self._visit(callee)
5009
+
5010
+ # Compatibility functional form: ``Type.copy(object)``. The
5011
+ # canonical Pine spelling is ``object.copy()`` below, but retaining
5012
+ # this existing surface keeps older corpus sources well-defined.
5013
+ if (
5014
+ isinstance(obj, Identifier)
5015
+ and obj.name in self._udt_fields
5016
+ and member == "copy"
5017
+ ):
5018
+ for arg in node.args:
5019
+ self._visit(arg)
5020
+ for val in node.kwargs.values():
5021
+ self._visit(val)
5022
+ if len(node.args) != 1 or node.kwargs:
5023
+ self._error(
5024
+ f"{obj.name}.copy(...) expects exactly one object argument",
5025
+ node.loc,
5026
+ )
5027
+ return PineType.UNKNOWN
5028
+ return self._visit(node.args[0])
5029
+
1910
5030
  # General member call (e.g., array.push, etc.)
1911
- self._visit(obj)
5031
+ receiver_pine_type = self._visit(obj)
5032
+ visited_member_arg_types: dict[int, PineType] = {}
1912
5033
  for arg in node.args:
1913
- self._visit(arg)
5034
+ visited_member_arg_types[id(arg)] = self._visit(arg)
1914
5035
  for val in node.kwargs.values():
1915
- self._visit(val)
5036
+ visited_member_arg_types[id(val)] = self._visit(val)
5037
+ # UDT method call-site typing. Method definitions may contain an
5038
+ # untyped parameter history read; resolve receiver + args here and
5039
+ # apply the same deferred map-history gate as regular UDFs before
5040
+ # codegen can emit the parameter as a scalar double.
5041
+ receiver_spec = self._type_spec_from_expr(obj)
5042
+ receiver_type_name = method_receiver_type_name(receiver_spec)
5043
+ if receiver_type_name is not None:
5044
+ method_key = f"{receiver_type_name}.{member}"
5045
+ signature = self._method_signatures.get(method_key)
5046
+ strict_binding = (
5047
+ self._bind_typed_method_call(method_key, node)
5048
+ if signature is not None
5049
+ else None
5050
+ )
5051
+ if strict_binding is not None:
5052
+ # Defaults are real call-site expressions. Visit only the
5053
+ # inserted nodes here; written actuals were visited above.
5054
+ supplied_ids = {
5055
+ id(arg) for arg in [*node.args, *node.kwargs.values()]
5056
+ }
5057
+ for arg in strict_binding.evaluation_order:
5058
+ if id(arg) not in supplied_ids:
5059
+ visited_member_arg_types[id(arg)] = self._visit(arg)
5060
+ method_info = next(
5061
+ (
5062
+ info
5063
+ for info in self._func_infos
5064
+ if info.name == method_key
5065
+ and getattr(info, "is_udt_method", False)
5066
+ ),
5067
+ None,
5068
+ )
5069
+ if method_info is not None and method_info.node is not None:
5070
+ method_params = list(method_info.node.params)
5071
+ rest_params = method_params[1:]
5072
+ rest_bound = (
5073
+ list(strict_binding.args_by_param)
5074
+ if strict_binding is not None
5075
+ else self._bind_callable_args(node, rest_params)
5076
+ )
5077
+ full_bound: list[ASTNode | None] = [obj, *rest_bound]
5078
+ full_param_types = [
5079
+ receiver_pine_type,
5080
+ *[
5081
+ visited_member_arg_types.get(
5082
+ id(arg), PineType.UNKNOWN
5083
+ )
5084
+ if arg is not None
5085
+ else PineType.UNKNOWN
5086
+ for arg in rest_bound
5087
+ ],
5088
+ ]
5089
+ declared_specs = list(
5090
+ self._func_param_type_specs.get(method_key, ())
5091
+ )
5092
+ full_param_types = [
5093
+ (
5094
+ self._primitive_pine_type_from_spec(
5095
+ declared_specs[index]
5096
+ )
5097
+ if index < len(declared_specs)
5098
+ and self._primitive_pine_type_from_spec(
5099
+ declared_specs[index]
5100
+ ) != PineType.UNKNOWN
5101
+ else param_type
5102
+ )
5103
+ for index, param_type in enumerate(full_param_types)
5104
+ ]
5105
+ self._callable_bound_param_types_by_node[id(node)] = list(
5106
+ full_param_types
5107
+ )
5108
+ effective_specs = list(
5109
+ getattr(method_info, "param_type_specs", None) or []
5110
+ )
5111
+ while len(effective_specs) < len(method_params):
5112
+ effective_specs.append(None)
5113
+ for index, arg in enumerate(full_bound):
5114
+ if effective_specs[index] is None and arg is not None:
5115
+ effective_specs[index] = self._type_spec_from_expr(arg)
5116
+ self._record_deferred_param_call_edge(
5117
+ node,
5118
+ method_key,
5119
+ method_params,
5120
+ full_bound,
5121
+ )
5122
+ self._validate_deferred_param_history_refs(
5123
+ method_key,
5124
+ {
5125
+ name: spec
5126
+ for name, spec in zip(
5127
+ method_params, effective_specs
5128
+ )
5129
+ },
5130
+ )
5131
+ method_series = self._func_series_vars.get(
5132
+ method_key, set()
5133
+ )
5134
+ for index, param_name in enumerate(method_params):
5135
+ if (
5136
+ param_name not in method_series
5137
+ or index >= len(full_bound)
5138
+ ):
5139
+ continue
5140
+ arg = full_bound[index]
5141
+ if isinstance(arg, Identifier) and arg.name in BAR_FIELDS:
5142
+ self._series_bar_fields.add(arg.name)
5143
+ elif isinstance(arg, Identifier):
5144
+ arg_sym = self._symbols.resolve(arg.name)
5145
+ if arg_sym is not None:
5146
+ arg_sym.is_series = True
5147
+ # Materialize TA state while the lexical caller is still
5148
+ # active, just as ``_handle_user_func_call`` does for a
5149
+ # bare UDF call. Deferring every UDT-method call to the
5150
+ # whole-program call-graph pass loses the enclosing
5151
+ # callable's parameter stack: ``outer(self, len) =>
5152
+ # self.inner(len)`` then leaves ``inner``'s constructor
5153
+ # argument as the bare local name ``len`` instead of
5154
+ # threading the eventual top-level input through both
5155
+ # boundaries. Besides substituting the arguments, the
5156
+ # cs0 path records the borrowed TA site in
5157
+ # ``_nested_ta_touched`` so the caller's range is widened
5158
+ # and can be resolved again at its own call site.
5159
+ if method_key in self._func_ta_ranges:
5160
+ existing_site = self._func_call_cs_map.get(id(node))
5161
+ if (
5162
+ existing_site is None
5163
+ or existing_site[0] != method_key
5164
+ ):
5165
+ cs_idx = self._func_call_site_count.get(
5166
+ method_key, 0
5167
+ )
5168
+ self._func_call_site_count[method_key] = cs_idx + 1
5169
+ self._func_call_cs_map[id(node)] = (
5170
+ method_key, cs_idx
5171
+ )
5172
+ self._materialize_user_func_call_site_state(
5173
+ method_key, cs_idx, node
5174
+ )
5175
+ return self._callsite_callable_return_type(
5176
+ method_info.node,
5177
+ full_param_types,
5178
+ method_info.return_type,
5179
+ )
5180
+ if signature is not None:
5181
+ # The exact authored method exists later in source. Its
5182
+ # body-derived return/effect metadata is intentionally not
5183
+ # guessed here, and the call must not fall through to a
5184
+ # same-named builtin such as UDT ``copy()``.
5185
+ return PineType.UNKNOWN
5186
+ if (
5187
+ receiver_spec.kind == "udt"
5188
+ and receiver_spec.name in self._udt_fields
5189
+ and member == "copy"
5190
+ ):
5191
+ if node.args or node.kwargs:
5192
+ self._error(
5193
+ f"{receiver_spec.name}.copy() expects no arguments",
5194
+ node.loc,
5195
+ )
5196
+ return PineType.UNKNOWN
5197
+ return receiver_pine_type
1916
5198
  # Matrix method dispatch: ``m.get(0, 0)`` on ``matrix<int>`` must
1917
5199
  # type as INT, not VOID, so ``v = m.get(...)`` propagates the
1918
5200
  # element PineType. ``_type_spec_from_expr`` already carries the
@@ -1921,7 +5203,16 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1921
5203
  # arithmetic inference) honest. See call_handlers.py
1922
5204
  # ``_handle_matrix_method``.
1923
5205
  if isinstance(obj, Identifier):
1924
- recv_spec = self._collection_types.get(obj.name)
5206
+ recv_sym = self._symbols.resolve(obj.name)
5207
+ recv_spec = (
5208
+ getattr(recv_sym, "type_spec", None)
5209
+ if recv_sym is not None
5210
+ else None
5211
+ )
5212
+ # Only fall back to the top-level raw registry when lexical
5213
+ # resolution did not find a shadowing local/parameter.
5214
+ if recv_sym is None or recv_sym.scope == "global":
5215
+ recv_spec = recv_spec or self._collection_types.get(obj.name)
1925
5216
  if recv_spec is not None and recv_spec.kind == "matrix":
1926
5217
  return self._handle_matrix_method(member, recv_spec)
1927
5218
  return PineType.VOID
@@ -1990,10 +5281,530 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1990
5281
  self._visit(val)
1991
5282
  return PineType.VOID
1992
5283
 
5284
+ def _type_spec_contains_map(
5285
+ self,
5286
+ spec: TypeSpec | None,
5287
+ visiting_udts: frozenset[str] = frozenset(),
5288
+ ) -> bool:
5289
+ """Whether a lexical TypeSpec recursively owns a PineMap handle.
5290
+
5291
+ History buffers value-copy their elements. A map ID anywhere in that
5292
+ shape would therefore retain live storage across bars and silently
5293
+ turn ``[1]`` into a current-state alias. This check belongs in the
5294
+ analyzer, where symbol resolution is scope-aware: a scalar parameter
5295
+ or block local can safely shadow a same-named global map, while typed
5296
+ map parameters and inferred aliases are still rejected.
5297
+ """
5298
+ if spec is None:
5299
+ return False
5300
+ if spec.kind == "map":
5301
+ return True
5302
+ if spec.kind in {"array", "matrix"}:
5303
+ return self._type_spec_contains_map(spec.element, visiting_udts)
5304
+ if spec.kind == "udt" and spec.name:
5305
+ if spec.name in visiting_udts:
5306
+ return False
5307
+ nested_visiting = visiting_udts | {spec.name}
5308
+ return any(
5309
+ self._type_spec_contains_map(field_spec, nested_visiting)
5310
+ for field_spec in self._udt_field_type_specs.get(
5311
+ spec.name, {}
5312
+ ).values()
5313
+ )
5314
+ return False
5315
+
5316
+ def _history_receiver_type_spec(
5317
+ self,
5318
+ node: ASTNode,
5319
+ parameter_specs_by_node: dict[int, TypeSpec | None] | None = None,
5320
+ ) -> TypeSpec | None:
5321
+ """Resolve the value shape on the left of Pine's history operator.
5322
+
5323
+ General expression inference intentionally stays conservative. The
5324
+ history safety gate needs two extra, narrow facts: a ternary selecting
5325
+ two equal aggregate handles keeps that aggregate type, and an untyped
5326
+ parameter can be substituted with the TypeSpec learned at its call
5327
+ site. The substitution is keyed by AST identifier identity so a
5328
+ same-spelled local or loop binder is never confused with the parameter.
5329
+ """
5330
+ overrides = parameter_specs_by_node or {}
5331
+ if isinstance(node, Identifier):
5332
+ if id(node) in overrides:
5333
+ return overrides[id(node)]
5334
+ return self._type_spec_from_expr(node)
5335
+ if isinstance(node, Ternary):
5336
+ true_spec = self._history_receiver_type_spec(
5337
+ node.true_val, overrides
5338
+ )
5339
+ false_spec = self._history_receiver_type_spec(
5340
+ node.false_val, overrides
5341
+ )
5342
+ if true_spec is not None and true_spec == false_spec:
5343
+ return true_spec
5344
+ # ``na`` is context-typed by the opposite branch in Pine. Keep
5345
+ # this rule local to the history gate rather than broadening all
5346
+ # declaration inference.
5347
+ if isinstance(node.true_val, NaLiteral):
5348
+ return false_spec
5349
+ if isinstance(node.false_val, NaLiteral):
5350
+ return true_spec
5351
+ return None
5352
+ if isinstance(node, MemberAccess):
5353
+ owner = self._history_receiver_type_spec(node.object, overrides)
5354
+ if owner is not None and owner.kind == "udt" and owner.name:
5355
+ return (self._udt_field_type_specs.get(owner.name) or {}).get(
5356
+ node.member
5357
+ )
5358
+ return self._type_spec_from_expr(node)
5359
+ if isinstance(node, FuncCall) and isinstance(
5360
+ node.callee, MemberAccess
5361
+ ):
5362
+ callee = node.callee
5363
+ receiver = None
5364
+ if (
5365
+ isinstance(callee.object, Identifier)
5366
+ and callee.object.name == "map"
5367
+ and node.args
5368
+ ):
5369
+ receiver = node.args[0]
5370
+ elif not (
5371
+ isinstance(callee.object, Identifier)
5372
+ and callee.object.name in {
5373
+ "array", "matrix", "map", "request", "ta"
5374
+ }
5375
+ ):
5376
+ receiver = callee.object
5377
+ if receiver is not None:
5378
+ recv_spec = self._history_receiver_type_spec(
5379
+ receiver, overrides
5380
+ )
5381
+ if recv_spec is not None and recv_spec.kind == "map":
5382
+ if callee.member == "copy":
5383
+ return recv_spec
5384
+ if callee.member in {"put", "get", "remove"}:
5385
+ return recv_spec.value
5386
+ if callee.member == "keys":
5387
+ return TypeSpec.array(
5388
+ recv_spec.key or TypeSpec.primitive("string")
5389
+ )
5390
+ if callee.member == "values":
5391
+ return TypeSpec.array(
5392
+ recv_spec.value or TypeSpec.primitive("float")
5393
+ )
5394
+ return self._type_spec_from_expr(node)
5395
+
5396
+ def _parameter_identifiers_in_expr(
5397
+ self, node: ASTNode
5398
+ ) -> dict[str, dict[int, str]]:
5399
+ """Return parameter identifier nodes grouped by their callable owner."""
5400
+ grouped: dict[str, dict[int, str]] = {}
5401
+
5402
+ def visit(value) -> None:
5403
+ if value is None:
5404
+ return
5405
+ if isinstance(value, Identifier):
5406
+ sym = self._symbols.resolve(value.name)
5407
+ owner = getattr(sym, "_pf_parameter_owner", None)
5408
+ if owner:
5409
+ grouped.setdefault(owner, {})[id(value)] = value.name
5410
+ return
5411
+ if isinstance(value, (list, tuple)):
5412
+ for item in value:
5413
+ visit(item)
5414
+ return
5415
+ if isinstance(value, dict):
5416
+ for item in value.values():
5417
+ visit(item)
5418
+ return
5419
+ if isinstance(value, ASTNode):
5420
+ for child in vars(value).values():
5421
+ visit(child)
5422
+
5423
+ visit(node)
5424
+ return grouped
5425
+
5426
+ @staticmethod
5427
+ def _bind_callable_args(
5428
+ node: FuncCall,
5429
+ param_names: list[str],
5430
+ ) -> list[ASTNode | None]:
5431
+ """Bind positional/keyword AST arguments into declaration order."""
5432
+ bound: list[ASTNode | None] = [None] * len(param_names)
5433
+ for index, arg in enumerate(node.args):
5434
+ if index < len(bound):
5435
+ bound[index] = arg
5436
+ for name, value in node.kwargs.items():
5437
+ if name in param_names:
5438
+ bound[param_names.index(name)] = value
5439
+ return bound
5440
+
5441
+ def _bind_typed_method_call(
5442
+ self,
5443
+ method_key: str,
5444
+ node: FuncCall,
5445
+ ) -> BoundMethodArgs:
5446
+ """Strict shared binding for one exact typed method call."""
5447
+
5448
+ cache_key = (id(node), method_key)
5449
+ cached = self._method_call_bindings.get(cache_key)
5450
+ if cached is not None:
5451
+ return cached
5452
+ signature = self._method_signatures[method_key]
5453
+ try:
5454
+ bound = bind_method_call(signature, node)
5455
+ except MethodBindError as exc:
5456
+ self._error(str(exc), node.loc)
5457
+ raise AssertionError("unreachable") from exc
5458
+ self._method_call_bindings[cache_key] = bound
5459
+ return bound
5460
+
5461
+ def _record_deferred_param_call_edge(
5462
+ self,
5463
+ call_node: FuncCall,
5464
+ callee_owner: str,
5465
+ callee_param_names: list[str],
5466
+ bound_args: list[ASTNode | None],
5467
+ ) -> None:
5468
+ """Capture caller-param flow while lexical symbol identity is known."""
5469
+ if not self._collection_scope_stack:
5470
+ return
5471
+ caller_owner = self._collection_scope_stack[-1]
5472
+ parameter_nodes: list[dict[int, str]] = []
5473
+ has_flow = False
5474
+ for arg in bound_args:
5475
+ grouped = (
5476
+ self._parameter_identifiers_in_expr(arg)
5477
+ if arg is not None
5478
+ else {}
5479
+ )
5480
+ current = grouped.get(caller_owner, {})
5481
+ parameter_nodes.append(current)
5482
+ has_flow = has_flow or bool(current)
5483
+ if not has_flow:
5484
+ return
5485
+ edges = self._deferred_param_call_edges.setdefault(caller_owner, [])
5486
+ if any(edge[0] == id(call_node) for edge in edges):
5487
+ return
5488
+ edges.append(
5489
+ (
5490
+ id(call_node),
5491
+ callee_owner,
5492
+ list(callee_param_names),
5493
+ list(bound_args),
5494
+ parameter_nodes,
5495
+ )
5496
+ )
5497
+
5498
+ def _reject_unsupported_map_history(
5499
+ self, spec: TypeSpec, node: Subscript
5500
+ ) -> None:
5501
+ if spec.kind == "map":
5502
+ message = (
5503
+ "History references on map IDs are not supported in "
5504
+ "PineForge; map rollback uses identity snapshots rather "
5505
+ "than Series<PineMap>."
5506
+ )
5507
+ else:
5508
+ message = (
5509
+ "History references on map-bearing UDTs or collections "
5510
+ "are not supported in PineForge; their Series value copy "
5511
+ "would retain live map aliases."
5512
+ )
5513
+ self._error(message, node.loc)
5514
+
5515
+ def _validate_deferred_param_history_refs(
5516
+ self,
5517
+ owner: str,
5518
+ parameter_specs: dict[str, TypeSpec | None],
5519
+ visiting: frozenset[str] = frozenset(),
5520
+ ) -> None:
5521
+ """Re-run map-history safety after untyped callable args are known."""
5522
+ if owner in visiting:
5523
+ return
5524
+ next_visiting = visiting | {owner}
5525
+ for node, parameter_nodes in self._deferred_param_history_refs.get(
5526
+ owner, []
5527
+ ):
5528
+ overrides = {
5529
+ node_id: parameter_specs.get(name)
5530
+ for node_id, name in parameter_nodes.items()
5531
+ }
5532
+ object_spec = self._history_receiver_type_spec(
5533
+ node.object, overrides
5534
+ )
5535
+ if self._type_spec_contains_map(object_spec):
5536
+ assert object_spec is not None
5537
+ self._reject_unsupported_map_history(object_spec, node)
5538
+
5539
+ # Propagate concrete caller specs through wrapper calls. Each edge is
5540
+ # identity-keyed from the definition pass, so a local that shadows a
5541
+ # caller parameter cannot accidentally inherit its map TypeSpec.
5542
+ for (
5543
+ _call_id,
5544
+ callee_owner,
5545
+ callee_param_names,
5546
+ bound_args,
5547
+ parameter_nodes_by_arg,
5548
+ ) in self._deferred_param_call_edges.get(owner, []):
5549
+ callee_specs: dict[str, TypeSpec | None] = {}
5550
+ for index, param_name in enumerate(callee_param_names):
5551
+ arg = bound_args[index] if index < len(bound_args) else None
5552
+ if arg is None:
5553
+ callee_specs[param_name] = None
5554
+ continue
5555
+ parameter_nodes = (
5556
+ parameter_nodes_by_arg[index]
5557
+ if index < len(parameter_nodes_by_arg)
5558
+ else {}
5559
+ )
5560
+ overrides = {
5561
+ node_id: parameter_specs.get(name)
5562
+ for node_id, name in parameter_nodes.items()
5563
+ }
5564
+ callee_specs[param_name] = self._history_receiver_type_spec(
5565
+ arg, overrides
5566
+ )
5567
+ self._validate_deferred_param_history_refs(
5568
+ callee_owner,
5569
+ callee_specs,
5570
+ next_visiting,
5571
+ )
5572
+
5573
+ def _propagate_deferred_map_callable_specs(
5574
+ self,
5575
+ owner: str,
5576
+ parameter_specs: dict[str, TypeSpec | None],
5577
+ visiting: frozenset[str] = frozenset(),
5578
+ ) -> bool:
5579
+ """Monomorphize an all-untyped wrapper chain for one concrete map call.
5580
+
5581
+ Function definitions are analyzed before their concrete top-level call
5582
+ sites. Consequently ``wrapper(a) => identity(a)`` initially creates
5583
+ both ``FuncInfo`` records with scalar fallbacks. The definition pass
5584
+ already captured identity-keyed parameter-flow edges for deferred map
5585
+ history validation; reuse those exact edges to carry a later map
5586
+ ``TypeSpec`` inward, then infer map returns on the way back out.
5587
+
5588
+ This is deliberately bounded and map-triggered. Cycles stop at the
5589
+ active owner, incompatible previously-established parameter specs stop
5590
+ that edge, and scalar-only call graphs never mutate. PineForge emits
5591
+ one C++ body per ordinary UDF, so silently replacing one concrete map
5592
+ specialization with a different one would be a false polymorphic
5593
+ inference rather than a valid widening.
5594
+ """
5595
+ if owner in visiting:
5596
+ return False
5597
+ if not any(
5598
+ spec is not None and spec.kind == "map"
5599
+ for spec in parameter_specs.values()
5600
+ ):
5601
+ return False
5602
+
5603
+ func_info = next(
5604
+ (info for info in self._func_infos if info.name == owner),
5605
+ None,
5606
+ )
5607
+ func_def = self._func_defs.get(owner)
5608
+ if func_info is None or func_def is None:
5609
+ return False
5610
+
5611
+ changed = False
5612
+ while len(func_info.param_type_specs) < len(func_def.params):
5613
+ func_info.param_type_specs.append(None)
5614
+ declared_specs = list(
5615
+ self._func_param_type_specs.get(owner)
5616
+ or self._param_type_specs_from_def(func_def)
5617
+ )
5618
+ while len(declared_specs) < len(func_def.params):
5619
+ declared_specs.append(None)
5620
+
5621
+ # Refuse to overwrite a declared or previously learned concrete map
5622
+ # specialization. Equal specs and still-unresolved slots are safe.
5623
+ for index, param_name in enumerate(func_def.params):
5624
+ incoming = parameter_specs.get(param_name)
5625
+ if incoming is None:
5626
+ continue
5627
+ established = declared_specs[index] or func_info.param_type_specs[index]
5628
+ if (
5629
+ established is not None
5630
+ and established != incoming
5631
+ and (established.kind == "map" or incoming.kind == "map")
5632
+ ):
5633
+ self._error(
5634
+ f"User function '{owner}' is called with incompatible "
5635
+ "map parameter types; PineForge cannot emit multiple "
5636
+ "map specializations for one untyped function.",
5637
+ func_def.loc,
5638
+ )
5639
+ for index, param_name in enumerate(func_def.params):
5640
+ incoming = parameter_specs.get(param_name)
5641
+ if incoming is None or func_info.param_type_specs[index] is not None:
5642
+ continue
5643
+ func_info.param_type_specs[index] = incoming
5644
+ changed = True
5645
+
5646
+ next_visiting = visiting | {owner}
5647
+ edges = self._deferred_param_call_edges.get(owner, [])
5648
+ # Nested actual arguments can depend on a sibling edge's newly learned
5649
+ # return spec. A bounded local fixed point removes source-order
5650
+ # dependence without turning this into whole-program re-analysis.
5651
+ for _ in range(max(1, len(edges) + 1)):
5652
+ pass_changed = False
5653
+ for (
5654
+ _call_id,
5655
+ callee_owner,
5656
+ callee_param_names,
5657
+ bound_args,
5658
+ parameter_nodes_by_arg,
5659
+ ) in edges:
5660
+ callee_specs: dict[str, TypeSpec | None] = {}
5661
+ for index, param_name in enumerate(callee_param_names):
5662
+ arg = bound_args[index] if index < len(bound_args) else None
5663
+ if arg is None:
5664
+ callee_specs[param_name] = None
5665
+ continue
5666
+ parameter_nodes = (
5667
+ parameter_nodes_by_arg[index]
5668
+ if index < len(parameter_nodes_by_arg)
5669
+ else {}
5670
+ )
5671
+ overrides = {
5672
+ node_id: parameter_specs.get(name)
5673
+ for node_id, name in parameter_nodes.items()
5674
+ }
5675
+ callee_specs[param_name] = self._history_receiver_type_spec(
5676
+ arg, overrides
5677
+ )
5678
+ # A bare ``na`` actual argument acquires the one unambiguous
5679
+ # map type carried by its sibling arguments. This is needed
5680
+ # for wrappers such as ``select(c, m) => choose(c, m, na)``;
5681
+ # without it the inner untyped parameter remains scalar even
5682
+ # though Pine context-types the na handle. Multiple distinct
5683
+ # map specs stay unresolved rather than guessing.
5684
+ concrete_map_specs = {
5685
+ spec
5686
+ for spec in callee_specs.values()
5687
+ if spec is not None and spec.kind == "map"
5688
+ }
5689
+ if len(concrete_map_specs) == 1:
5690
+ contextual_map_spec = next(iter(concrete_map_specs))
5691
+ for index, param_name in enumerate(callee_param_names):
5692
+ arg = (
5693
+ bound_args[index]
5694
+ if index < len(bound_args)
5695
+ else None
5696
+ )
5697
+ if (
5698
+ callee_specs.get(param_name) is None
5699
+ and (
5700
+ isinstance(arg, NaLiteral)
5701
+ or (
5702
+ isinstance(arg, Identifier)
5703
+ and arg.name == "na"
5704
+ )
5705
+ )
5706
+ ):
5707
+ callee_specs[param_name] = contextual_map_spec
5708
+ if self._propagate_deferred_map_callable_specs(
5709
+ callee_owner,
5710
+ callee_specs,
5711
+ next_visiting,
5712
+ ):
5713
+ pass_changed = True
5714
+ changed = changed or pass_changed
5715
+ if not pass_changed:
5716
+ break
5717
+
5718
+ terminal = self._direct_terminal_return_expr(func_def)
5719
+ return_spec = None
5720
+ scalar_return_type = None
5721
+ if isinstance(terminal, Identifier) and terminal.name in parameter_specs:
5722
+ candidate = parameter_specs.get(terminal.name)
5723
+ if candidate is not None and candidate.kind == "map":
5724
+ return_spec = candidate
5725
+ if return_spec is None:
5726
+ return_spec = self._terminal_map_selection_return_spec(
5727
+ terminal, parameter_specs
5728
+ )
5729
+ if return_spec is None:
5730
+ terminal_map_call = self._terminal_map_call_return(
5731
+ terminal, parameter_specs
5732
+ )
5733
+ if terminal_map_call is not None:
5734
+ terminal_return_type, candidate = terminal_map_call
5735
+ if candidate is not None and candidate.kind == "map":
5736
+ return_spec = candidate
5737
+ elif terminal_return_type not in {
5738
+ PineType.UNKNOWN, PineType.VOID
5739
+ }:
5740
+ scalar_return_type = terminal_return_type
5741
+ if return_spec is None:
5742
+ candidate = self._type_spec_from_expr(terminal)
5743
+ if candidate is not None and candidate.kind == "map":
5744
+ return_spec = candidate
5745
+ if return_spec is not None:
5746
+ established_return = self._func_return_type_specs.get(owner)
5747
+ if established_return is None:
5748
+ self._func_return_type_specs[owner] = return_spec
5749
+ func_info.return_type_spec = return_spec
5750
+ changed = True
5751
+ elif established_return == return_spec:
5752
+ if func_info.return_type_spec is None:
5753
+ func_info.return_type_spec = return_spec
5754
+ changed = True
5755
+ # A different established return belongs to another concrete map
5756
+ # specialization. Keep it unchanged rather than falsely widening.
5757
+
5758
+ if scalar_return_type is None and isinstance(terminal, FuncCall):
5759
+ callee = terminal.callee
5760
+ callee_name = (
5761
+ callee.name if isinstance(callee, Identifier) else None
5762
+ )
5763
+ callee_info = next(
5764
+ (
5765
+ info
5766
+ for info in self._func_infos
5767
+ if info.name == callee_name
5768
+ ),
5769
+ None,
5770
+ )
5771
+ if (
5772
+ callee_info is not None
5773
+ and callee_info.return_type
5774
+ not in {PineType.UNKNOWN, PineType.VOID}
5775
+ ):
5776
+ scalar_return_type = callee_info.return_type
5777
+ if (
5778
+ return_spec is None
5779
+ and scalar_return_type is not None
5780
+ and func_info.return_type != scalar_return_type
5781
+ ):
5782
+ func_info.return_type = scalar_return_type
5783
+ self._func_return_types[owner] = scalar_return_type
5784
+ changed = True
5785
+
5786
+ return changed
5787
+
1993
5788
  def _visit_Subscript(self, node: Subscript) -> PineType:
1994
5789
  obj_type = self._visit(node.object)
1995
5790
  self._visit(node.index)
1996
5791
 
5792
+ object_spec = self._history_receiver_type_spec(node.object)
5793
+ if self._type_spec_contains_map(object_spec):
5794
+ assert object_spec is not None
5795
+ self._reject_unsupported_map_history(object_spec, node)
5796
+
5797
+ # An untyped parameter has no aggregate TypeSpec during the function
5798
+ # definition pass. Remember only the identifier nodes that resolved
5799
+ # to actual parameters; the call handler validates them before any
5800
+ # codegen state is committed.
5801
+ for owner, parameter_nodes in self._parameter_identifiers_in_expr(
5802
+ node.object
5803
+ ).items():
5804
+ self._deferred_param_history_refs.setdefault(owner, []).append(
5805
+ (node, parameter_nodes)
5806
+ )
5807
+
1997
5808
  # Detect series vars / bar fields
1998
5809
  if isinstance(node.object, Identifier):
1999
5810
  name = node.object.name
@@ -2002,15 +5813,59 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
2002
5813
  else:
2003
5814
  sym = self._symbols.resolve(name)
2004
5815
  if sym is not None:
5816
+ if getattr(sym, "_pf_shadows_map_state", False):
5817
+ self._error(
5818
+ "History references on scalar local or loop "
5819
+ "bindings that shadow a map ID are not supported "
5820
+ "until PineForge can allocate a lexically scoped "
5821
+ "Series buffer for that binding.",
5822
+ node.loc,
5823
+ )
2005
5824
  if getattr(sym, "type_spec", None) is None or sym.type_spec.kind not in ("array", "map"):
2006
- self._series_vars.add(name)
5825
+ exact_member = getattr(sym, "_pf_var_member_name", None)
5826
+ if exact_member is not None:
5827
+ self._series_var_members.add(exact_member)
5828
+ decl_node_id = getattr(sym, "_pf_decl_node_id", None)
5829
+ if decl_node_id is not None:
5830
+ self._series_decl_nodes.add(decl_node_id)
5831
+ binding_name = getattr(
5832
+ sym, "_pf_decl_binding_name", name
5833
+ )
5834
+ self._series_decl_bindings.add(
5835
+ (decl_node_id, binding_name)
5836
+ )
5837
+ global_sym = self._symbols.global_scope.symbols.get(name)
5838
+ shadows_map_state = (
5839
+ sym.scope != "global"
5840
+ and global_sym is not None
5841
+ and self._type_spec_contains_map(global_sym.type_spec)
5842
+ )
5843
+ # ``_series_vars`` is a legacy global-by-name set. Do
5844
+ # not let a lexical scalar series parameter poison a
5845
+ # same-named global PineMap member; the scoped series
5846
+ # registry below is authoritative for the function.
5847
+ if not shadows_map_state:
5848
+ self._series_vars.add(name)
2007
5849
  sym.is_series = True
2008
- # Track function-scoped series vars
5850
+ # Track callable-scoped series vars. Method symbol
5851
+ # scopes use ``method_Type_name`` while every later
5852
+ # clone/remap table is keyed by canonical
5853
+ # ``Type.method`` identity.
5854
+ func_name: str | None = None
2009
5855
  if sym.scope and sym.scope.startswith("func_"):
2010
5856
  func_name = sym.scope[5:]
5857
+ elif (
5858
+ sym.scope != "global"
5859
+ and self._collection_scope_stack
5860
+ ):
5861
+ func_name = self._collection_scope_stack[-1]
5862
+ if func_name is not None:
2011
5863
  if func_name not in self._func_series_vars:
2012
5864
  self._func_series_vars[func_name] = set()
2013
5865
  self._func_series_vars[func_name].add(name)
5866
+ self._func_series_history_nodes.setdefault(
5867
+ (func_name, name), node
5868
+ )
2014
5869
 
2015
5870
  return obj_type
2016
5871
 
@@ -2029,6 +5884,9 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
2029
5884
  return PineType.VOID
2030
5885
 
2031
5886
  sym = self._symbols.resolve(node.name)
5887
+ self._identifier_binding_scopes[id(node)] = (
5888
+ getattr(sym, "scope", None) if sym is not None else None
5889
+ )
2032
5890
  if sym is not None:
2033
5891
  return sym.pine_type
2034
5892
 
@@ -2313,6 +6171,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
2313
6171
  return PineType.COLOR
2314
6172
 
2315
6173
  def _visit_TupleLiteral(self, node: TupleLiteral) -> PineType:
2316
- for elem in node.elements:
2317
- self._visit(elem)
6174
+ self._tuple_element_types_by_node[id(node)] = tuple(
6175
+ self._visit(elem) for elem in node.elements
6176
+ )
2318
6177
  return PineType.FLOAT