pineforge-codegen 0.8.0__py3-none-any.whl → 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -127,8 +127,29 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
127
127
  self._global_var_decls: list[tuple[str, PineType]] = []
128
128
  self._global_expr_map: dict[str, Any] = {}
129
129
  self._var_member_init_exprs: dict[str, Any] = {}
130
+ # Block-scoped ``var``/``varip`` name-collision disambiguation.
131
+ # Two same-named block-scoped vars in SIBLING non-global, non-function
132
+ # scopes (e.g. ``var bool valid`` declared inside ``if A`` and again
133
+ # inside ``if B``) would otherwise dedupe to ONE C++ member and
134
+ # 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}
138
+ # for every later colliding block so codegen can activate the
139
+ # rename via ``_active_var_remap`` while emitting that block.
140
+ self._block_node_stack: list[Any] = []
141
+ self._block_var_owner: dict[str, int] = {}
142
+ self._block_var_renames: dict[int, dict[str, str]] = {}
143
+ self._block_var_seq = 0
130
144
  self._ta_counter = 0
131
145
  self._fixnan_counter = 0
146
+ # All fixnan member names minted so far (base + clones), for O(1)
147
+ # collision detection when minting a per-call-site fixnan clone.
148
+ self._fixnan_member_names: set[str] = set()
149
+ # Authoritative fixnan clone-name map for collisions: (func, cs_idx)
150
+ # -> {orig_member: cloned_member}. Consumed verbatim by the codegen
151
+ # when the default ``{base}_cs{cs_idx}`` formula would collide.
152
+ self._func_cs_fixnan_clone_names: dict[tuple[str, int], dict[str, str]] = {}
132
153
  # Track user-defined function nodes for deferred analysis
133
154
  self._func_defs: dict[str, FuncDef] = {}
134
155
  # Track user-defined function return types
@@ -141,6 +162,8 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
141
162
  # expression (``=> Sample.new(...)`` or last stmt ``Sample.new(...)``).
142
163
  # Probe: data/validation/udt-method-probe-20-udt-return-from-func.
143
164
  self._func_udt_return_types: dict[str, str] = {}
165
+ self._func_return_type_specs: dict[str, "TypeSpec"] = {}
166
+ self._func_param_type_specs: dict[str, list] = {}
144
167
  # Per-function var_members and series_vars (for call-site cloning)
145
168
  self._func_var_members: dict[str, list] = {} # func_name -> [(name, PineType, init_str)]
146
169
  self._func_series_vars: dict[str, set] = {} # func_name -> set[str]
@@ -148,6 +171,37 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
148
171
  self._func_ta_ranges: dict[str, tuple[int, int]] = {} # func_name -> (start, end) indices
149
172
  self._func_call_site_count: dict[str, int] = {} # func_name -> count
150
173
  self._func_call_cs_map: dict[int, tuple[str, int]] = {} # call_node_id -> (func_name, cs_idx)
174
+ # Textual nested calls whose identity is inherited from the active
175
+ # parent clone rather than assigned a fixed source-level cs index.
176
+ # Kept separately so a second propagation pass (security TF cloning)
177
+ # does not accidentally backfill them as a new cs{N} call site.
178
+ self._func_inherited_call_nodes: set[int] = set()
179
+ # Per-function fixnan site ownership: func_name -> list of fixnan site
180
+ # indices in self._fixnan_sites owned by that function. Mirrors the
181
+ # TA-range slicing but for fixnan state, so per-call-site cloning can
182
+ # mint fresh fixnan members per variant and the codegen dead-code pass
183
+ # can skip fixnan state owned by dead functions.
184
+ self._func_fixnan_indices: dict[str, list[int]] = {}
185
+ # Functions that need per-call-site BODY cloning despite owning no
186
+ # TA/series/var state. The set originated for security-tf
187
+ # monomorphization; it is also the existing emitter contract used by
188
+ # pure wrappers around stateful callees and fixnan-only functions.
189
+ # Security evaluator identity remains independently tracked by each
190
+ # SecurityCallInfo.callsite_idx.
191
+ self._func_security_clone_only: set[str] = set()
192
+ # Authoritative clone-name map: (func_name, cs_idx) -> {orig_member_name:
193
+ # cloned_member_name}. The codegen rebuilds its TA remap from the
194
+ # ``{orig}_cs{cs_idx}`` formula by default, but a TA site reached through
195
+ # MULTIPLE enclosing functions (e.g. a helper cloned both directly and via
196
+ # a range-widened outer function) can collide on that formula. When the
197
+ # analyzer must disambiguate a clone's member name, it records the actual
198
+ # chosen name here so the codegen consumes it verbatim instead of
199
+ # re-deriving a colliding name. Empty for the common (no-collision) case,
200
+ # keeping generated output byte-identical.
201
+ self._func_cs_ta_clone_names: dict[tuple[str, int], dict[str, str]] = {}
202
+ # All TA member names minted so far (base + clones), for O(1) collision
203
+ # detection when minting a new clone.
204
+ self._ta_member_names: set[str] = set()
151
205
  # UDT field definitions: type_name -> {field_name: PineType}
152
206
  self._udt_fields: dict[str, dict[str, PineType]] = {}
153
207
  # var_name -> UDT type for variables holding UDT instances
@@ -163,6 +217,21 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
163
217
  self._current_top_level_stmt: ASTNode | None = None
164
218
  self._global_scope = True
165
219
  self._static_vars: set[str] = set()
220
+ # Stack of enclosing user-function param-name sets, pushed while visiting
221
+ # a FuncDef body. Lets a nested user-func call detect when it substitutes
222
+ # a TA ctor length with one of the OUTER function's params, so the outer
223
+ # call site can re-substitute (e.g. f_bbwp(_bbwLen) -> f_basisMa(_len)).
224
+ self._enclosing_func_params: list[set[str]] = []
225
+ # Parallel stack of the function NAMES whose param-sets are in
226
+ # ``_enclosing_func_params``. The top of stack (or None at global
227
+ # scope) is the owner of any ORIGINAL ``ta.*`` site minted right now
228
+ # -- recorded on ``TACallSite.owner_func`` so the codegen dead-code
229
+ # pass can tell borrowed clones apart from a dead function's own
230
+ # sites (see contracts.TACallSite.owner_func).
231
+ 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).
234
+ self._nested_ta_touched: set | None = None
166
235
 
167
236
  # Pre-populate builtins
168
237
  self._populate_builtins()
@@ -232,6 +301,11 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
232
301
  # can call g_csK with isolated state.
233
302
  self._propagate_call_site_counts()
234
303
 
304
+ # Reject (loudly) a request.security whose timeframe is a UDF parameter
305
+ # called with multiple distinct literal timeframes — a single evaluator
306
+ # cannot serve them and per-callsite specialization is not yet wired.
307
+ self._check_mixed_callsite_security_tf()
308
+
235
309
  # Keep only truly pure global expressions for request.security rebinding.
236
310
  # Globals later reassigned with := become series/stateful variables and
237
311
  # must not be rebound to their declaration-time initializer.
@@ -257,6 +331,8 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
257
331
  var_members=self._var_members,
258
332
  func_infos=self._func_infos,
259
333
  fixnan_sites=self._fixnan_sites,
334
+ func_fixnan_indices=self._func_fixnan_indices,
335
+ func_cs_fixnan_clone_names=self._func_cs_fixnan_clone_names,
260
336
  strategy_params=self._strategy_params,
261
337
  diagnostics=self._diagnostics,
262
338
  filename=self._filename,
@@ -266,6 +342,8 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
266
342
  func_ta_ranges=self._func_ta_ranges,
267
343
  func_call_cs_map=self._func_call_cs_map,
268
344
  func_call_site_counts=self._func_call_site_count,
345
+ func_security_clone_only=self._func_security_clone_only,
346
+ func_cs_ta_clone_names=self._func_cs_ta_clone_names,
269
347
  udt_defs=self._udt_fields,
270
348
  enum_defs=self._enum_defs,
271
349
  enum_member_strings=self._enum_member_strings,
@@ -273,9 +351,11 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
273
351
  global_mutable_infos=mutable_global_infos,
274
352
  func_var_members=self._func_var_members,
275
353
  func_series_vars=self._func_series_vars,
354
+ func_return_type_specs=dict(self._func_return_type_specs),
276
355
  udt_var_types=dict(self._udt_var_types),
277
356
  collection_types=dict(self._collection_types),
278
357
  udt_field_type_specs=dict(self._udt_field_type_specs),
358
+ block_var_renames=dict(self._block_var_renames),
279
359
  )
280
360
 
281
361
  def _record_global_binding_stmt(self, name: str, pine_type: PineType,
@@ -299,6 +379,18 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
299
379
  if top_stmt is not None and (not info.source_stmts or info.source_stmts[-1] is not top_stmt):
300
380
  info.source_stmts.append(top_stmt)
301
381
 
382
+ @staticmethod
383
+ def _is_input_func_call(node: FuncCall) -> bool:
384
+ """True for an ``input(...)`` or ``input.<member>(...)`` call."""
385
+ callee = node.callee
386
+ if isinstance(callee, Identifier) and callee.name == "input":
387
+ return True
388
+ return (
389
+ isinstance(callee, MemberAccess)
390
+ and isinstance(callee.object, Identifier)
391
+ and callee.object.name == "input"
392
+ )
393
+
302
394
  def _collect_security_mutable_globals(
303
395
  self, node: ASTNode | None, resolving: set[str] | None = None
304
396
  ) -> set[str]:
@@ -344,6 +436,20 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
344
436
  resolving.remove(call_key)
345
437
  return out
346
438
 
439
+ if isinstance(node, FuncCall) and self._is_input_func_call(node):
440
+ # An ``input.*()`` / ``input()`` initializer is a compile-time
441
+ # constant. Only its defval (first positional or ``defval=``) can
442
+ # carry a genuine data dependency; the cosmetic kwargs
443
+ # (group/tooltip/title/inline/display/confirm/minval/maxval/step)
444
+ # are presentation-only. Walking them would falsely pull a
445
+ # ``var string GROUP = "..."`` label into the security's
446
+ # mutable-globals set and trip the "TA ctor depends on rebound
447
+ # mutable globals" reject (parallax / higherTimeframeLength).
448
+ defval = node.args[0] if node.args else node.kwargs.get("defval")
449
+ if defval is not None:
450
+ out |= self._collect_security_mutable_globals(defval, resolving)
451
+ return out
452
+
347
453
  def walk(value: Any) -> None:
348
454
  nonlocal out
349
455
  if value is None:
@@ -365,40 +471,274 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
365
471
  return out
366
472
 
367
473
  def _propagate_call_site_counts(self) -> None:
368
- """Propagate call-site counts from parent functions to sub-functions.
369
-
370
- If function F has N call sites and calls sub-function G internally,
371
- G also needs N variants so that F_csK can call G_csK with isolated state.
372
- This ensures every stateful sub-function gets per-call-site isolation
373
- inherited from its parent.
474
+ """Propagate stateful UDF identity through complete call paths.
475
+
476
+ A UDF is stateful when it owns series/``var`` state, TA state, or
477
+ ``fixnan`` state, and every pure wrapper that reaches such a UDF is
478
+ stateful transitively. Give each wrapper's textual calls stable cs
479
+ identities, then inherit a multi-call-site parent's count down to its
480
+ stateful callees. Any inherited TA/fixnan variants are materialized
481
+ immediately; exporting a count without the corresponding members would
482
+ make codegen reference undeclared or shared state.
374
483
  """
375
484
  from pineforge_codegen.ast_nodes import FuncCall, FuncDef, Identifier
376
485
 
377
- # Collect all user function definitions from AST
378
486
  func_defs: dict[str, FuncDef] = {}
379
487
  for stmt in self._ast.body:
380
488
  if isinstance(stmt, FuncDef):
381
489
  func_defs[stmt.name] = stmt
382
490
 
383
- # Find which functions call which sub-functions (direct calls only)
384
- def _find_calls(node, known_funcs: set[str]) -> set[str]:
385
- calls: set[str] = set()
386
- if isinstance(node, FuncCall) and isinstance(node.callee, Identifier):
387
- if node.callee.name in known_funcs:
388
- calls.add(node.callee.name)
491
+ # UDT methods participate in the same stateful call graph as plain
492
+ # UDFs. Their analyzer identity is ``Type.method`` while FuncInfo.node
493
+ # carries the synthetic FuncDef used by codegen.
494
+ func_info_by_name = {fi.name: fi for fi in self._func_infos}
495
+ for fi in self._func_infos:
496
+ if getattr(fi, "is_udt_method", False) and fi.node is not None:
497
+ func_defs.setdefault(fi.name, fi.node)
498
+
499
+ # Preserve call-node identity as well as the callee name: late clone
500
+ # materialization needs the textual call's argument mapping to resolve
501
+ # parameterized TA constructor lengths.
502
+ def _resolved_user_call_name(call: FuncCall, owner: str | None) -> str | None:
503
+ if isinstance(call.callee, Identifier):
504
+ return call.callee.name if call.callee.name in func_defs else None
505
+ if not isinstance(call.callee, MemberAccess):
506
+ return None
507
+
508
+ recv = call.callee.object
509
+ method = call.callee.member
510
+ udt_name: str | None = None
511
+ if isinstance(recv, Identifier):
512
+ udt_name = self._udt_var_types.get(recv.name)
513
+ 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:
516
+ if (getattr(owner_info, "is_udt_method", False)
517
+ and owner_info.node.params
518
+ and recv.name == owner_info.node.params[0]):
519
+ udt_name = owner_info.udt_type_name
520
+ elif recv.name in owner_info.node.params:
521
+ param_idx = owner_info.node.params.index(recv.name)
522
+ specs = getattr(owner_info, "param_type_specs", []) or []
523
+ 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:
527
+ 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 ""
531
+ return key if key in func_defs else None
532
+
533
+ def _find_calls(node, known_funcs: set[str],
534
+ owner: str | None = None,
535
+ seen: set[int] | None = None) -> list[tuple[str, FuncCall]]:
536
+ calls: list[tuple[str, FuncCall]] = []
537
+ if node is None:
538
+ return calls
539
+ if seen is None:
540
+ seen = set()
541
+ if isinstance(node, (list, tuple, dict)) or hasattr(node, "__dict__"):
542
+ node_id = id(node)
543
+ if node_id in seen:
544
+ return calls
545
+ seen.add(node_id)
546
+ if isinstance(node, (list, tuple)):
547
+ for item in node:
548
+ calls.extend(_find_calls(item, known_funcs, owner, seen))
549
+ return calls
550
+ if isinstance(node, dict):
551
+ for item in node.values():
552
+ calls.extend(_find_calls(item, known_funcs, owner, seen))
553
+ return calls
554
+ if not hasattr(node, "__dict__"):
555
+ return calls
556
+ if isinstance(node, FuncCall):
557
+ resolved = _resolved_user_call_name(node, owner)
558
+ if resolved in known_funcs:
559
+ calls.append((resolved, node))
389
560
  for attr_val in vars(node).values():
390
- if isinstance(attr_val, list):
391
- for item in attr_val:
392
- if hasattr(item, '__dict__'):
393
- calls |= _find_calls(item, known_funcs)
394
- elif hasattr(attr_val, '__dict__'):
395
- calls |= _find_calls(attr_val, known_funcs)
561
+ calls.extend(_find_calls(attr_val, known_funcs, owner, seen))
396
562
  return calls
397
563
 
398
564
  known_func_names = set(func_defs.keys())
565
+ calls_by_parent = {
566
+ name: _find_calls(func_def, known_func_names, name)
567
+ for name, func_def in func_defs.items()
568
+ }
569
+ calls_by_callee: dict[str, list[FuncCall]] = {
570
+ name: [] for name in known_func_names
571
+ }
572
+ # Preserve source order across definitions and top-level statements.
573
+ # Method bodies use their ``Type.method`` owner so ``self.sibling()``
574
+ # resolves without relying on a now-exited symbol-table scope.
575
+ for stmt in self._ast.body:
576
+ if isinstance(stmt, FuncDef):
577
+ owner = stmt.name
578
+ elif isinstance(stmt, MethodDef):
579
+ owner = f"{stmt.type_name}.{stmt.name}"
580
+ else:
581
+ owner = None
582
+ for callee, call in _find_calls(stmt, known_func_names, owner):
583
+ calls_by_callee.setdefault(callee, []).append(call)
584
+
585
+ # Codegen synthesizes a Series buffer for two expression shapes that
586
+ # do not appear in ``_func_series_vars`` themselves:
587
+ #
588
+ # * a call result read through history, e.g. ``f()[1]``;
589
+ # * a scalar expression bridged into a UDF series parameter, e.g.
590
+ # ``history(close + open)`` where ``history(src) => src[1]``.
591
+ #
592
+ # A buffer is mutable per-call-site state just like TA/fixnan. Mark
593
+ # its lexical owner stateful before the normal call-path closure so a
594
+ # function invoked from two source sites receives two emitted bodies
595
+ # (and therefore two independent generated buffer members). Without
596
+ # this, moving the old function-local static into a class member would
597
+ # accidentally merge both Pine call sites into one history stream.
598
+ def _actual_arg(call: FuncCall, param_name: str, param_idx: int):
599
+ if param_idx < len(call.args):
600
+ return call.args[param_idx]
601
+ return call.kwargs.get(param_name)
602
+
603
+ def _needs_scalar_series_bridge(call: FuncCall) -> bool:
604
+ if not isinstance(call.callee, Identifier):
605
+ return False
606
+ callee = call.callee.name
607
+ fi = func_info_by_name.get(callee)
608
+ if fi is None or fi.node is None:
609
+ return False
610
+ series_params = self._func_series_vars.get(callee, set())
611
+ if not series_params:
612
+ return False
613
+ direct_bar_series = {
614
+ "open", "high", "low", "close", "volume",
615
+ "hl2", "hlc3", "ohlc4",
616
+ }
617
+ for idx, param_name in enumerate(fi.node.params):
618
+ if param_name not in series_params:
619
+ continue
620
+ arg = _actual_arg(call, param_name, idx)
621
+ if arg is None:
622
+ continue
623
+ if isinstance(arg, Identifier) and (
624
+ arg.name in direct_bar_series or arg.name in self._series_vars
625
+ ):
626
+ continue
627
+ return True
628
+ return False
399
629
 
400
- # For each multi-call-site function, propagate count to sub-functions
401
- # that have stateful locals (series vars or var members)
630
+ def _has_synthetic_history_state(
631
+ node, seen: set[int] | None = None) -> bool:
632
+ if node is None:
633
+ return False
634
+ if seen is None:
635
+ seen = set()
636
+ if isinstance(node, (list, tuple, dict)) or hasattr(node, "__dict__"):
637
+ node_id = id(node)
638
+ if node_id in seen:
639
+ return False
640
+ seen.add(node_id)
641
+ if isinstance(node, (list, tuple)):
642
+ return any(
643
+ _has_synthetic_history_state(item, seen) for item in node
644
+ )
645
+ if isinstance(node, dict):
646
+ return any(
647
+ _has_synthetic_history_state(item, seen)
648
+ for item in node.values()
649
+ )
650
+ if not hasattr(node, "__dict__"):
651
+ return False
652
+ if (isinstance(node, Subscript)
653
+ and isinstance(node.object, FuncCall)):
654
+ return True
655
+ if isinstance(node, FuncCall) and _needs_scalar_series_bridge(node):
656
+ return True
657
+ return any(
658
+ _has_synthetic_history_state(value, seen)
659
+ for value in vars(node).values()
660
+ )
661
+
662
+ synthetic_history_stateful = {
663
+ name for name, func_def in func_defs.items()
664
+ if _has_synthetic_history_state(func_def)
665
+ }
666
+
667
+ # request.security owns a separate evaluator context and already
668
+ # materializes/remaps its embedded TA state per SecurityCallInfo. Do
669
+ # not thread ordinary UDF clone indices through a function used as a
670
+ # security expression (or through the containing wrapper); doing so
671
+ # would double-clone evaluator state and disturb expression identity.
672
+ security_boundary_funcs: set[str] = set()
673
+ for sec in getattr(self, "_security_calls", []) or []:
674
+ containing = getattr(sec, "containing_func", "") or ""
675
+ if containing:
676
+ security_boundary_funcs.add(containing)
677
+ expression = getattr(sec, "expression", None)
678
+ if expression is not None:
679
+ security_boundary_funcs.update(
680
+ sub for sub, _ in _find_calls(
681
+ expression, known_func_names, containing or None
682
+ )
683
+ )
684
+
685
+ # Canonical direct-state predicate. TA-only and fixnan-only helpers
686
+ # are just as stateful as functions carrying an explicit series/var.
687
+ stateful = (
688
+ set(self._func_series_vars)
689
+ | set(self._func_var_members)
690
+ | set(self._func_ta_ranges)
691
+ | set(self._func_fixnan_indices)
692
+ | synthetic_history_stateful
693
+ )
694
+
695
+ # Close upward over the call graph so a pure A -> B -> stateful C chain
696
+ # receives variants at every level. The existing clone-only emitter
697
+ # marker is intentionally separate from request.security evaluator
698
+ # identity, so ordinary security calls remain shared unless the
699
+ # dedicated timeframe-monomorphization pass clones them.
700
+ changed = True
701
+ while changed:
702
+ changed = False
703
+ for fname, calls in calls_by_parent.items():
704
+ if fname in stateful:
705
+ continue
706
+ if any(sub in stateful for sub, _ in calls):
707
+ stateful.add(fname)
708
+ changed = True
709
+
710
+ # Direct fixnan-only functions and pure transitive wrappers own no
711
+ # TA/series member that would trip the emitter's ordinary body-clone
712
+ # gate. Reuse its established body-only clone marker; this does not
713
+ # create or renumber any SecurityCallInfo.
714
+ for fname in sorted(stateful):
715
+ if (fname not in self._func_ta_ranges
716
+ and fname not in self._func_series_vars
717
+ and fname not in self._func_var_members):
718
+ self._func_security_clone_only.add(fname)
719
+
720
+ # The initial visitor only numbers directly-stateful callees. Backfill
721
+ # stable identities for newly discovered pure wrappers without
722
+ # disturbing any indices already assigned by the visitor or security
723
+ # monomorphization.
724
+ for fname in sorted(stateful):
725
+ calls = calls_by_callee.get(fname, [])
726
+ current = self._func_call_site_count.get(fname, 0)
727
+ next_idx = current
728
+ for call in calls:
729
+ if id(call) in self._func_inherited_call_nodes:
730
+ continue
731
+ existing = self._func_call_cs_map.get(id(call))
732
+ if existing is not None and existing[0] == fname:
733
+ next_idx = max(next_idx, existing[1] + 1)
734
+ continue
735
+ self._func_call_cs_map[id(call)] = (fname, next_idx)
736
+ next_idx += 1
737
+ if next_idx > current:
738
+ self._func_call_site_count[fname] = next_idx
739
+
740
+ # Inherit each multi-call-site parent's index space down the full path.
741
+ # Re-run to a fixed point for A -> B -> C chains.
402
742
  changed = True
403
743
  while changed:
404
744
  changed = False
@@ -407,17 +747,217 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
407
747
  continue
408
748
  if fname not in func_defs:
409
749
  continue
410
- sub_calls = _find_calls(func_defs[fname], known_func_names)
411
- for sub in sub_calls:
412
- has_state = (sub in self._func_series_vars or
413
- sub in self._func_var_members)
414
- if not has_state:
750
+ if fname in security_boundary_funcs:
751
+ continue
752
+ for sub, call_node in calls_by_parent.get(fname, []):
753
+ if sub not in stateful:
415
754
  continue
416
755
  current = self._func_call_site_count.get(sub, 0)
417
756
  if current < count:
757
+ # One textual nested call is not an independent cs0
758
+ # path: it inherits the active parent clone index.
759
+ # Remove the visitor's provisional cs0 map so codegen's
760
+ # established active-index fallback dispatches
761
+ # F_csK -> G_csK. Keeping the map would make the
762
+ # context-sensitive instance pre-pass pin every parent
763
+ # clone to G_cs0 before that fallback can run.
764
+ if current == 1:
765
+ cs_info = self._func_call_cs_map.get(id(call_node))
766
+ if cs_info == (sub, 0):
767
+ self._func_call_cs_map.pop(id(call_node), None)
768
+ self._func_inherited_call_nodes.add(id(call_node))
769
+ for cs_idx in range(current, count):
770
+ self._materialize_user_func_call_site_state(
771
+ sub,
772
+ cs_idx,
773
+ call_node,
774
+ reuse_existing_owner=fname,
775
+ )
418
776
  self._func_call_site_count[sub] = count
419
777
  changed = True
420
778
 
779
+ # ------------------------------------------------------------------
780
+ # Mixed-callsite UDF timeframe-param security rejection.
781
+ #
782
+ # A ``request.security`` whose ``timeframe`` is a parameter of its
783
+ # containing UDF maps to ONE evaluator regardless of how many times the
784
+ # UDF is called. When the UDF is called from >= 2 sites with DISTINCT
785
+ # literal timeframes, a single evaluator cannot faithfully serve them
786
+ # all and the resolver would silently collapse onto the chart timeframe
787
+ # (``input_tf_``). Per-callsite evaluator specialization (cloning the
788
+ # evaluator + UDF) is the correct fix but is not wired in this iteration,
789
+ # so we reject deterministically instead of emitting wrong semantics.
790
+ # ------------------------------------------------------------------
791
+ def _check_mixed_callsite_security_tf(self) -> None:
792
+ sec_calls = getattr(self, "_security_calls", None)
793
+ if not sec_calls:
794
+ return
795
+ # Build user-function definitions lookup once.
796
+ func_defs: dict[str, FuncDef] = {}
797
+ for stmt in self._ast.body:
798
+ if isinstance(stmt, FuncDef):
799
+ func_defs[stmt.name] = stmt
800
+
801
+ new_calls: list[SecurityCallInfo] = []
802
+ cloned_any = False
803
+ for sec in sec_calls:
804
+ containing = getattr(sec, "containing_func", "") or ""
805
+ tf_node = getattr(sec, "timeframe", None)
806
+ if not containing or not isinstance(tf_node, Identifier):
807
+ new_calls.append(sec)
808
+ continue
809
+ param_name = tf_node.name
810
+ fdef = func_defs.get(containing)
811
+ if fdef is None or param_name not in fdef.params:
812
+ new_calls.append(sec)
813
+ continue
814
+ pidx = fdef.params.index(param_name)
815
+ calls = list(self._iter_user_func_calls(containing))
816
+ if not calls:
817
+ new_calls.append(sec) # dead code — evaluator result never read
818
+ continue
819
+ # Number call sites for THIS function. If ``containing`` already
820
+ # has TA call sites or series/var members, the per-call-site
821
+ # UDF-body-cloning mechanism already numbered its call sites in
822
+ # _func_call_cs_map (a request.security nested ta.* registers as
823
+ # part of the function-body walk, same as a top-level ta.* call)
824
+ # — reuse that authoritative numbering so this clone's
825
+ # callsite_idx stays aligned with self._active_call_site_idx.
826
+ # Otherwise (e.g. ``f(tf) => request.security(sym, tf, close)``
827
+ # with no nested ta.* call) that mechanism never fired for this
828
+ # function at all, since it gates on has_ta/has_series; assign
829
+ # fresh cs_idx ourselves in call-site order and BACKFILL
830
+ # func_call_cs_map / func_call_site_count / func_security_clone_only
831
+ # so the codegen actually clones this function's body too (the
832
+ # has_ta/has_series emission gate ORs in func_security_clone_only
833
+ # — see codegen/base.py) and the existing top-level call-site
834
+ # naming (which keys purely off func_call_cs_map, not
835
+ # has_ta/has_series) picks the right ``_cs{N}`` variant.
836
+ already_tracked = self._func_call_site_count.get(containing, 0) > 0
837
+ per_cs: list[tuple[int, str | None]] = []
838
+ for i, call in enumerate(calls):
839
+ if already_tracked:
840
+ cs_info = self._func_call_cs_map.get(id(call))
841
+ if cs_info is None or cs_info[0] != containing:
842
+ continue # shouldn't happen: has_ta/has_series tracks ALL call sites
843
+ cs_idx = cs_info[1]
844
+ else:
845
+ cs_idx = i
846
+ self._func_call_cs_map.setdefault(id(call), (containing, cs_idx))
847
+ arg = call.args[pidx] if pidx < len(call.args) else None
848
+ lit = self._callsite_tf_literal_value(arg)
849
+ per_cs.append((cs_idx, lit))
850
+ if not per_cs:
851
+ new_calls.append(sec) # dead code — evaluator result never read
852
+ continue
853
+ distinct_literals = {lit for _, lit in per_cs if lit is not None}
854
+ if len(distinct_literals) < 2:
855
+ new_calls.append(sec) # single TF (or unresolved) — no cloning needed
856
+ continue
857
+ if any(lit is None for _, lit in per_cs):
858
+ # Some call site's tf isn't a compile-time literal — can't
859
+ # pin every clone to a concrete timeframe. Keep the original
860
+ # deterministic rejection rather than guess.
861
+ self._error(
862
+ "request.security timeframe parameter '"
863
+ + param_name
864
+ + "' of function '"
865
+ + containing
866
+ + "' is called with multiple distinct literal timeframes ("
867
+ + ", ".join(sorted(distinct_literals))
868
+ + "). A single request.security evaluator cannot serve "
869
+ "them all and would silently collapse onto the chart "
870
+ "timeframe. Pass a single timeframe, or inline a separate "
871
+ "request.security call at each call site.",
872
+ tf_node.loc,
873
+ )
874
+ new_calls.append(sec)
875
+ continue
876
+ if not already_tracked:
877
+ # First thing establishing per-call-site identity for this
878
+ # function — make it authoritative so the codegen's
879
+ # function-emission gate (has_ta or has_series or
880
+ # func_security_clone_only) actually clones its body, with
881
+ # self._active_call_site_idx set to each of our cs_idx values
882
+ # in turn while it does.
883
+ self._func_call_site_count[containing] = len(calls)
884
+ self._func_security_clone_only.add(containing)
885
+ # Clone: one SecurityCallInfo per call site, each pinned to that
886
+ # site's literal timeframe via a synthetic StringLiteral (so the
887
+ # existing literal-timeframe resolution path needs no changes)
888
+ # and given a fresh, currently-unused sec_id.
889
+ next_sec_id = max((s.sec_id for s in sec_calls), default=-1) + 1
890
+ next_sec_id = max(next_sec_id, len(sec_calls) + len(new_calls))
891
+ for cs_idx, lit in sorted(per_cs):
892
+ clone = SecurityCallInfo(
893
+ sec_id=next_sec_id,
894
+ timeframe=StringLiteral(value=lit, loc=tf_node.loc),
895
+ expression=sec.expression,
896
+ returns_tuple=sec.returns_tuple,
897
+ tuple_size=sec.tuple_size,
898
+ gaps=sec.gaps,
899
+ lookahead=sec.lookahead,
900
+ ta_range=sec.ta_range,
901
+ depends_on_mutable_globals=sec.depends_on_mutable_globals,
902
+ mutable_globals=sec.mutable_globals,
903
+ is_lower_tf_array=sec.is_lower_tf_array,
904
+ containing_func=sec.containing_func,
905
+ callsite_idx=cs_idx,
906
+ )
907
+ new_calls.append(clone)
908
+ next_sec_id += 1
909
+ cloned_any = True
910
+
911
+ if cloned_any:
912
+ # A freshly-backfilled func_call_site_count[containing] may need
913
+ # to cascade to a sub-function containing's body calls (the same
914
+ # propagation _propagate_call_site_counts() already did once
915
+ # before this method ran) — re-run it now that backfill exists.
916
+ self._propagate_call_site_counts()
917
+ # Renumber sec_id contiguously 0..N-1 in final list order — the
918
+ # codegen indexes _security_eval_info / register_security_eval /
919
+ # _eval_security_N by sec_id, all assumed dense from registration
920
+ # order. The provisional IDs assigned above only needed to be
921
+ # distinct from each other while building new_calls; this final
922
+ # pass is what callers (e.g. the codegen's expr_node identity
923
+ # lookup, which now also keys off callsite_idx) actually see.
924
+ for i, sec in enumerate(new_calls):
925
+ sec.sec_id = i
926
+ self._security_calls = new_calls
927
+
928
+ def _iter_user_func_calls(self, func_name: str):
929
+ """Yield every ``func_name(...)`` call anywhere in the AST (top-level
930
+ and nested inside function bodies)."""
931
+ def _walk(node):
932
+ if node is None:
933
+ return
934
+ if (isinstance(node, FuncCall) and isinstance(node.callee, Identifier)
935
+ and node.callee.name == func_name):
936
+ yield node
937
+ for attr_val in vars(node).values():
938
+ if isinstance(attr_val, list):
939
+ for item in attr_val:
940
+ if hasattr(item, "__dict__"):
941
+ yield from _walk(item)
942
+ elif attr_val is not None and hasattr(attr_val, "__dict__"):
943
+ yield from _walk(attr_val)
944
+ yield from _walk(self._ast)
945
+
946
+ def _callsite_tf_literal_value(self, arg) -> str | None:
947
+ """Resolve a UDF call-site timeframe argument to a literal string
948
+ value when it is statically known: a string literal, or a known
949
+ constant / input-backed variable whose stored value is a string.
950
+ Returns None for anything that is not a compile-time string."""
951
+ if isinstance(arg, StringLiteral):
952
+ return arg.value
953
+ if isinstance(arg, Identifier):
954
+ sym = self._symbols.resolve(arg.name)
955
+ if sym is not None and getattr(sym, "const_value", None) is not None:
956
+ val = sym.const_value
957
+ if isinstance(val, str):
958
+ return val
959
+ return None
960
+
421
961
  def _is_static_expression(self, node: ASTNode | None) -> bool:
422
962
  if node is None:
423
963
  return True
@@ -535,19 +1075,105 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
535
1075
  # ------------------------------------------------------------------
536
1076
 
537
1077
  def _udt_name_from_ctor(self, value: ASTNode) -> str | None:
538
- """If value is ``TypeName.new(...)`` for a user-defined type, return TypeName."""
1078
+ """If value is ``TypeName.new(...)`` for a user-defined type OR a
1079
+ drawing handle (``label.new``/``line.new``/``box.new``/``linefill.new``),
1080
+ return the type name."""
539
1081
  if not isinstance(value, FuncCall):
540
1082
  return None
541
1083
  cal = value.callee
542
1084
  if not isinstance(cal, MemberAccess) or not isinstance(cal.object, Identifier):
543
1085
  return None
544
1086
  owner = cal.object.name
545
- if owner not in self._udt_fields:
546
- return None
547
1087
  m = cal.member
548
- if m == "new" or (isinstance(m, str) and m.startswith("new")):
1088
+ if not (m == "new" or (isinstance(m, str) and m.startswith("new"))):
1089
+ return None
1090
+ # Drawing-objects-as-data: label.new(...)/line.new(...)/... return a
1091
+ # handle of the self-type. These are not in _udt_fields (they are not
1092
+ # user UDTs) but must still be recognised so a function whose body ends
1093
+ # in label.new(...) emits a ``Label`` (not ``double``) return type.
1094
+ from .types import _DRAWING_TYPE_NAMES
1095
+ if owner in _DRAWING_TYPE_NAMES:
549
1096
  return owner
550
- return None
1097
+ if owner not in self._udt_fields:
1098
+ return None
1099
+ return owner
1100
+
1101
+ def _func_terminal_drawing_type(self, func_node: FuncDef) -> str | None:
1102
+ """Resolve the drawing-handle / UDT type of a function's terminal
1103
+ (return) expression for cases the direct ``_udt_name_from_ctor`` on the
1104
+ last statement misses:
1105
+
1106
+ - the last statement is an ``IfStmt`` whose terminal branch yields a
1107
+ drawing/UDT constructor (``makeEventLabel`` => ``if cond\\n
1108
+ label.new(...)``); and
1109
+ - the last statement is a bare ``Identifier`` bound to a
1110
+ drawing-handle local (``setTradeLine`` => ``line result = ...`` then
1111
+ a trailing ``result``).
1112
+
1113
+ Returns the drawing/UDT type name, or ``None``. Without this a function
1114
+ that returns a ``line``/``label`` handle this way is mis-typed
1115
+ ``double`` and clang rejects ``no viable conversion from Line to
1116
+ double``.
1117
+ """
1118
+ from .types import _DRAWING_TYPE_NAMES
1119
+
1120
+ body = func_node.body
1121
+ if not body:
1122
+ return None
1123
+
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] = {}
1129
+ param_hints = (func_node.annotations or {}).get("param_type_hints", [])
1130
+ for i, p in enumerate(func_node.params):
1131
+ 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.
1158
+ if isinstance(stmt, IfStmt):
1159
+ 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)
1175
+
1176
+ return _resolve_terminal(body[-1])
551
1177
 
552
1178
  def _visit_VarDecl(self, node: VarDecl) -> PineType:
553
1179
  # Infer type from the value expression
@@ -620,14 +1246,41 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
620
1246
  # Track var members
621
1247
  if node.is_var or node.is_varip:
622
1248
  init_str = self._expr_to_str(node.value)
623
- self._var_members.append((node.name, val_type, init_str))
1249
+ scope_name = self._symbols.current_scope.name
1250
+ # 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).
1256
+ # When such a name already belongs to a DIFFERENT block, mint a
1257
+ # scope-unique member name and record the rename so codegen activates
1258
+ # it (via ``_active_var_remap``) while emitting that block.
1259
+ member_name = node.name
1260
+ is_block_scoped = (
1261
+ not self._global_scope
1262
+ and not scope_name.startswith("func_")
1263
+ and not scope_name.startswith("method_")
1264
+ and bool(self._block_node_stack)
1265
+ )
1266
+ if is_block_scoped:
1267
+ block_id = id(self._block_node_stack[-1])
1268
+ owner = self._block_var_owner.get(node.name)
1269
+ if owner is None:
1270
+ # First block to claim this name keeps the raw member name.
1271
+ self._block_var_owner[node.name] = block_id
1272
+ elif owner != block_id:
1273
+ # Sibling-scope collision: disambiguate this declaration.
1274
+ self._block_var_seq += 1
1275
+ member_name = f"{node.name}__blk{self._block_var_seq}"
1276
+ self._block_var_renames.setdefault(block_id, {})[node.name] = member_name
1277
+ self._var_members.append((member_name, val_type, init_str))
624
1278
  # Capture the init AST too so codegen can inspect the RHS callee
625
1279
  # (used to detect int64-returning builtins like ``time()`` and
626
1280
  # promote the symbol storage type to ``int64_t``).
627
1281
  if node.value is not None:
628
- self._var_member_init_exprs[node.name] = node.value
1282
+ self._var_member_init_exprs[member_name] = node.value
629
1283
  # Track function-scoped var members
630
- scope_name = self._symbols.current_scope.name
631
1284
  if scope_name.startswith("func_"):
632
1285
  func_name = scope_name[5:] # strip "func_" prefix
633
1286
  if func_name not in self._func_var_members:
@@ -732,6 +1385,20 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
732
1385
  setattr(sym, "is_static_series", True)
733
1386
  self._symbols.define(sym)
734
1387
 
1388
+ # Track global-scope tuple-assign targets (e.g.
1389
+ # ``[pdH, pdL] = request.security(...)``) as class members so user
1390
+ # functions / later references resolve — mirroring _visit_VarDecl.
1391
+ # Without this the names are never declared and the C++ errors with
1392
+ # "use of undeclared identifier".
1393
+ if (self._global_scope
1394
+ 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
1398
+ self._record_global_binding_stmt(
1399
+ name, PineType.FLOAT, False, decl_node=node,
1400
+ )
1401
+
735
1402
  return val_type
736
1403
 
737
1404
  # ------------------------------------------------------------------
@@ -745,18 +1412,28 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
745
1412
  # Enter function scope
746
1413
  self._symbols.enter_scope(f"func_{node.name}")
747
1414
 
748
- # Define parameters (type unknown until called)
1415
+ # Define parameters. The type is UNKNOWN until inferred from a call
1416
+ # site, BUT a declared type hint (``string tf``, ``pivot hi``, ``line[] arr``)
1417
+ # is authoritative — record it as the symbol's ``type_spec`` / ``pine_type``
1418
+ # so (a) the param emits with the right C++ type and (b) callers passing
1419
+ # this param into another function can infer that function's param type
1420
+ # (e.g. ``getLineStyle(styleStr)`` where ``styleStr`` is a ``string`` param).
749
1421
  loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
750
- for param in node.params:
1422
+ param_hints = (node.annotations or {}).get("param_type_hints", [])
1423
+ for i, param in enumerate(node.params):
1424
+ hint = param_hints[i] if i < len(param_hints) else None
1425
+ pspec = self._type_spec_from_hint(hint) if hint else None
1426
+ ptype = self._type_hint_to_pine(hint) if hint else PineType.UNKNOWN
751
1427
  sym = Symbol(
752
1428
  name=param,
753
- pine_type=PineType.UNKNOWN,
1429
+ pine_type=ptype,
754
1430
  is_series=False,
755
1431
  is_var=False,
756
1432
  is_const=False,
757
1433
  const_value=None,
758
1434
  scope=f"func_{node.name}",
759
1435
  loc=loc,
1436
+ type_spec=pspec,
760
1437
  )
761
1438
  self._symbols.define(sym)
762
1439
 
@@ -767,16 +1444,40 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
767
1444
  body_type = PineType.VOID
768
1445
  old_global = self._global_scope
769
1446
  self._global_scope = False
1447
+ self._enclosing_func_params.append(set(node.params))
1448
+ self._enclosing_func_names.append(node.name)
1449
+ self._nested_ta_touched = set()
770
1450
  try:
771
1451
  for stmt in node.body:
772
1452
  body_type = self._visit(stmt)
773
1453
  finally:
774
1454
  self._global_scope = old_global
775
-
776
- # Record TA range for this function
1455
+ self._enclosing_func_params.pop()
1456
+ self._enclosing_func_names.pop()
1457
+ nested_touched = self._nested_ta_touched
1458
+ self._nested_ta_touched = None
1459
+
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.
777
1464
  ta_end = len(self._ta_call_sites)
778
- if ta_end > ta_start:
779
- self._func_ta_ranges[node.name] = (ta_start, ta_end)
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)
1471
+
1472
+ inferred_param_specs = self._param_type_specs_from_def(node)
1473
+ for i, param in enumerate(node.params):
1474
+ if i < len(inferred_param_specs) and inferred_param_specs[i] is not None:
1475
+ continue
1476
+ sym = self._symbols.resolve(param)
1477
+ spec = getattr(sym, "type_spec", None) if sym is not None else None
1478
+ if spec is not None and i < len(inferred_param_specs):
1479
+ inferred_param_specs[i] = spec
1480
+ self._func_param_type_specs[node.name] = inferred_param_specs
780
1481
 
781
1482
  self._symbols.exit_scope()
782
1483
 
@@ -807,8 +1508,23 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
807
1508
  # last_stmt is itself an expression node (single-expr funcs)
808
1509
  ret_expr = last_stmt if hasattr(last_stmt, "loc") else None
809
1510
  udt_ret = self._udt_name_from_ctor(ret_expr) if ret_expr is not None else None
1511
+ if udt_ret is None:
1512
+ # Drawing-handle returns wrapped in an if-statement terminal
1513
+ # branch (``makeEventLabel``) or returned as a bare drawing-handle
1514
+ # local (``setTradeLine``) are not direct ctors on the last
1515
+ # expression — resolve them so the function emits the C++ handle
1516
+ # type (Line/Label/...) instead of the ``double`` default.
1517
+ udt_ret = self._func_terminal_drawing_type(node)
810
1518
  if udt_ret is not None:
811
1519
  self._func_udt_return_types[node.name] = udt_ret
1520
+ # Array-return inference: a function whose body ends in
1521
+ # ``array.from(...)`` / ``array.new<T>(...)`` / a UDT method
1522
+ # returning an array returns a ``std::vector<...>``. The coarse
1523
+ # PineType return can't represent this, so carry the TypeSpec.
1524
+ if ret_expr is not None:
1525
+ ret_spec = self._type_spec_from_expr(ret_expr)
1526
+ if ret_spec is not None and ret_spec.kind == "array":
1527
+ self._func_return_type_specs[node.name] = ret_spec
812
1528
 
813
1529
  # Store return type
814
1530
  self._func_return_types[node.name] = body_type
@@ -880,12 +1596,14 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
880
1596
  loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
881
1597
  param_hints = (node.annotations or {}).get("param_type_hints", [])
882
1598
  param_types: list[PineType] = []
1599
+ param_specs: list = []
883
1600
  for i, p in enumerate(node.params):
884
1601
  udt_self = node.type_name if i == 0 else None
885
1602
  hint = param_hints[i] if i < len(param_hints) else None
886
1603
  ptype = self._type_hint_to_pine(hint) if hint else PineType.FLOAT
887
1604
  pspec = self._type_spec_from_hint(hint) if hint else None
888
1605
  param_types.append(ptype)
1606
+ param_specs.append(pspec)
889
1607
  self._symbols.define(Symbol(
890
1608
  name=p, pine_type=ptype, is_series=False,
891
1609
  is_var=False, is_const=False, const_value=None,
@@ -939,6 +1657,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
939
1657
  returns_tuple=returns_tuple,
940
1658
  tuple_element_count=tuple_element_count,
941
1659
  param_defaults=param_defaults,
1660
+ param_type_specs=param_specs,
942
1661
  )
943
1662
  self._func_infos.append(fi)
944
1663
  return PineType.VOID
@@ -950,6 +1669,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
950
1669
  def _visit_IfStmt(self, node: IfStmt) -> PineType:
951
1670
  old_global = self._global_scope
952
1671
  self._global_scope = False
1672
+ self._block_node_stack.append(node)
953
1673
  try:
954
1674
  self._visit(node.condition)
955
1675
  body_type = PineType.VOID
@@ -958,6 +1678,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
958
1678
  for stmt in node.else_body:
959
1679
  self._visit(stmt)
960
1680
  finally:
1681
+ self._block_node_stack.pop()
961
1682
  self._global_scope = old_global
962
1683
  # If used as expression (x = if ...), return last expr type
963
1684
  return body_type
@@ -981,6 +1702,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
981
1702
 
982
1703
  old_global = self._global_scope
983
1704
  self._global_scope = False
1705
+ self._block_node_stack.append(node)
984
1706
  try:
985
1707
  self._visit(node.start)
986
1708
  self._visit(node.end)
@@ -989,6 +1711,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
989
1711
  for stmt in node.body:
990
1712
  self._visit(stmt)
991
1713
  finally:
1714
+ self._block_node_stack.pop()
992
1715
  self._global_scope = old_global
993
1716
 
994
1717
  self._symbols.exit_scope()
@@ -997,6 +1720,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
997
1720
  def _visit_ForInStmt(self, node) -> PineType:
998
1721
  old_global = self._global_scope
999
1722
  self._global_scope = False
1723
+ self._block_node_stack.append(node)
1000
1724
  try:
1001
1725
  self._visit(node.iterable)
1002
1726
  self._symbols.enter_scope("for_in")
@@ -1019,17 +1743,20 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1019
1743
  self._visit(stmt)
1020
1744
  self._symbols.exit_scope()
1021
1745
  finally:
1746
+ self._block_node_stack.pop()
1022
1747
  self._global_scope = old_global
1023
1748
  return PineType.VOID
1024
1749
 
1025
1750
  def _visit_WhileStmt(self, node: WhileStmt) -> PineType:
1026
1751
  old_global = self._global_scope
1027
1752
  self._global_scope = False
1753
+ self._block_node_stack.append(node)
1028
1754
  try:
1029
1755
  self._visit(node.condition)
1030
1756
  for stmt in node.body:
1031
1757
  self._visit(stmt)
1032
1758
  finally:
1759
+ self._block_node_stack.pop()
1033
1760
  self._global_scope = old_global
1034
1761
  return PineType.VOID
1035
1762
 
@@ -1079,6 +1806,20 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1079
1806
 
1080
1807
  # String concatenation: if either side is STRING, result is STRING
1081
1808
  if left_type == PineType.STRING or right_type == PineType.STRING:
1809
+ def _mark_string_param(expr) -> None:
1810
+ if not isinstance(expr, Identifier):
1811
+ return
1812
+ sym = self._symbols.resolve(expr.name)
1813
+ if sym is None or not (sym.scope or "").startswith("func_"):
1814
+ return
1815
+ if sym.pine_type == PineType.UNKNOWN:
1816
+ sym.pine_type = PineType.STRING
1817
+ sym.type_spec = TypeSpec.primitive("string")
1818
+
1819
+ if left_type == PineType.STRING:
1820
+ _mark_string_param(node.right)
1821
+ if right_type == PineType.STRING:
1822
+ _mark_string_param(node.left)
1082
1823
  return PineType.STRING
1083
1824
 
1084
1825
  # Arithmetic: promote to FLOAT if either side is FLOAT
@@ -1151,6 +1892,15 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1151
1892
  if isinstance(obj, Identifier) and obj.name == "str":
1152
1893
  for arg in node.args:
1153
1894
  self._visit(arg)
1895
+ # Most str.* return a string, but predicates and index helpers
1896
+ # are scalar. Keep this aligned with signatures.py and the C++
1897
+ # emitter's _infer_type path.
1898
+ if member in ("contains", "startswith", "endswith"):
1899
+ return PineType.BOOL
1900
+ if member == "tonumber":
1901
+ return PineType.FLOAT
1902
+ if member in ("length", "pos"):
1903
+ return PineType.INT
1154
1904
  return PineType.STRING
1155
1905
 
1156
1906
  # request.* calls
@@ -1374,9 +2124,11 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1374
2124
 
1375
2125
  # syminfo.*
1376
2126
  if ns == "syminfo":
1377
- if node.member == "mintick":
1378
- return PineType.FLOAT
1379
- return PineType.STRING
2127
+ from .. import signatures as _pf_sigs
2128
+ return _pf_sigs.SYMINFO_VARIABLES.get(
2129
+ f"syminfo.{node.member}",
2130
+ PineType.STRING,
2131
+ )
1380
2132
 
1381
2133
  # color.* constants
1382
2134
  if ns == "color":
@@ -1435,7 +2187,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
1435
2187
 
1436
2188
  # text.* constants (align_left, align_right, etc.)
1437
2189
  if ns == "text":
1438
- return PineType.INT
2190
+ return PineType.STRING
1439
2191
 
1440
2192
  # extend.* constants (left, right, both, none)
1441
2193
  if ns == "extend":