@pineforge/codegen-pyodide 0.10.4 → 1.0.0-rc.1

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.
Files changed (50) hide show
  1. package/README.md +16 -16
  2. package/glue.py +24 -16
  3. package/package.json +1 -1
  4. package/pineforge_codegen/__init__.py +125 -34
  5. package/pineforge_codegen/analyzer/__init__.py +2 -0
  6. package/pineforge_codegen/analyzer/base.py +754 -76
  7. package/pineforge_codegen/analyzer/call_handlers.py +260 -40
  8. package/pineforge_codegen/analyzer/contracts.py +37 -0
  9. package/pineforge_codegen/analyzer/diagnostics.py +30 -4
  10. package/pineforge_codegen/analyzer/tables.py +49 -8
  11. package/pineforge_codegen/analyzer/types.py +33 -1
  12. package/pineforge_codegen/ast_nodes.py +32 -1
  13. package/pineforge_codegen/block_locals.py +185 -0
  14. package/pineforge_codegen/builtin_keywords.py +42 -0
  15. package/pineforge_codegen/codegen/base.py +896 -156
  16. package/pineforge_codegen/codegen/constant_fold.py +131 -0
  17. package/pineforge_codegen/codegen/drawing.py +221 -79
  18. package/pineforge_codegen/codegen/emit_top.py +946 -213
  19. package/pineforge_codegen/codegen/helpers.py +435 -14
  20. package/pineforge_codegen/codegen/host_members.py +162 -0
  21. package/pineforge_codegen/codegen/input.py +252 -85
  22. package/pineforge_codegen/codegen/security.py +4372 -377
  23. package/pineforge_codegen/codegen/session_market.py +71 -0
  24. package/pineforge_codegen/codegen/ta.py +1188 -100
  25. package/pineforge_codegen/codegen/tables.py +193 -71
  26. package/pineforge_codegen/codegen/tv_number_format.py +270 -0
  27. package/pineforge_codegen/codegen/types.py +1882 -78
  28. package/pineforge_codegen/codegen/visit_call.py +920 -131
  29. package/pineforge_codegen/codegen/visit_expr.py +738 -57
  30. package/pineforge_codegen/codegen/visit_stmt.py +595 -49
  31. package/pineforge_codegen/external_requests.py +877 -0
  32. package/pineforge_codegen/lexer.py +104 -22
  33. package/pineforge_codegen/library_inline.py +1304 -0
  34. package/pineforge_codegen/library_modules.py +126 -0
  35. package/pineforge_codegen/library_v5.py +683 -0
  36. package/pineforge_codegen/limits.py +138 -0
  37. package/pineforge_codegen/method_binding.py +33 -0
  38. package/pineforge_codegen/parser.py +384 -68
  39. package/pineforge_codegen/pine_libraries.py +266 -0
  40. package/pineforge_codegen/pine_spelling.py +216 -0
  41. package/pineforge_codegen/pragmas.py +64 -10
  42. package/pineforge_codegen/security_contexts.py +1585 -0
  43. package/pineforge_codegen/session_reads.py +84 -0
  44. package/pineforge_codegen/signatures.py +48 -23
  45. package/pineforge_codegen/support_checker.py +1106 -85
  46. package/pineforge_codegen-1.0.0-rc.1.tar.gz +0 -0
  47. package/release.json +2 -2
  48. package/tables.json +23 -21
  49. package/transpile.worker.mjs +24 -16
  50. package/pineforge_codegen-0.10.4.tar.gz +0 -0
@@ -29,6 +29,9 @@ from ..symbols import (
29
29
  method_receiver_type_name,
30
30
  )
31
31
  from ..errors import SourceLocation, Diagnostic, CompileError, Level, Phase
32
+ from ..limits import TimeBudget, iter_ast_nodes
33
+ from ..security_contexts import PASS_WARNINGS_ANNOTATION, context_key, reads_bar_series
34
+ from ..session_reads import emitted_session_reads
32
35
  from ..method_binding import (
33
36
  BoundMethodArgs,
34
37
  MethodBindError,
@@ -124,9 +127,19 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
124
127
  the package layout.
125
128
  """
126
129
 
127
- def __init__(self, ast: Program, filename: str = "<stdin>") -> None:
130
+ def __init__(self, ast: Program, filename: str = "<stdin>",
131
+ budget: TimeBudget | None = None,
132
+ session_clones: frozenset[str] | None = None) -> None:
128
133
  self._ast = ast
129
134
  self._filename = filename
135
+ self._budget = budget
136
+ # The functions that may be emitted once per call site for their
137
+ # session.<flag>[k] reads; None: every one that reads a flag at an
138
+ # offset (pineforge_codegen._generate passes none first, then each
139
+ # time adds the ones whose reads the C++ holds).
140
+ self._session_clones = session_clones
141
+ self._session_uncloned: frozenset[str] = frozenset()
142
+ self._budget_visit_count = 0
130
143
  self._method_signatures = inventory_method_signatures(ast)
131
144
  self._method_call_bindings: dict[
132
145
  tuple[int, str], BoundMethodArgs
@@ -142,7 +155,12 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
142
155
  self._func_infos: list[FuncInfo] = []
143
156
  self._fixnan_sites: list[FixnanCallSite] = []
144
157
  self._strategy_params: dict = {}
145
- self._diagnostics: list[Diagnostic] = []
158
+ # The warnings of the passes between the support checker and here,
159
+ # held on the Program as (message, hint, location).
160
+ self._diagnostics: list[Diagnostic] = [
161
+ Diagnostic(level=Level.WARNING, phase=Phase.ANALYZER, location=loc,
162
+ message=message, hint=hint)
163
+ for message, hint, loc in (ast.annotations or {}).get(PASS_WARNINGS_ANNOTATION, ())]
146
164
  self._global_var_decls: list[tuple[str, PineType]] = []
147
165
  # Top-level ordinary bindings are lexical global state even when a
148
166
  # same-named callable history reference has already polluted the
@@ -231,6 +249,8 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
231
249
  self._func_tuple_element_count: dict[str, int] = {}
232
250
  self._func_tuple_element_types: dict[str, tuple[PineType, ...]] = {}
233
251
  self._tuple_element_types_by_node: dict[int, tuple[PineType, ...]] = {}
252
+ # request.security call id -> its tuple's size (``call_handlers``).
253
+ self._security_tuple_shapes: dict[int, int] = {}
234
254
  # Track user-defined functions whose body returns a UDT instance —
235
255
  # maps func_name -> UDT type name. Detected from the body's final
236
256
  # expression (``=> Sample.new(...)`` or last stmt ``Sample.new(...)``).
@@ -282,6 +302,14 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
282
302
  # their established paths.
283
303
  self._func_var_storage_names: dict[str, dict[str, str]] = {}
284
304
  self._func_series_vars: dict[str, set] = {} # func_name -> set[str]
305
+ self._session_history_unsafe: dict[str, str] = {}
306
+ # Plain UDF -> script variables (and ``bar_index``) its body reads
307
+ # through history, in source order; id(Subscript) -> (UDF, name, node)
308
+ # for each such read. See ``_note_function_global_history_read``.
309
+ self._func_global_history_reads: dict[str, list[str]] = {}
310
+ self._func_global_history_nodes: dict[int, tuple] = {}
311
+ self._global_history_only_stateful: set[str] = set()
312
+ self._global_history_typing_warned: set[tuple] = set()
285
313
  # Declaration-bound non-persistent history locals are distinct from
286
314
  # history parameters/global reads carried by ``func_series_vars``.
287
315
  # Codegen needs this exact subset when a raw spelling also belongs to
@@ -604,6 +632,8 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
604
632
  for name, specs in self._func_param_type_specs.items()
605
633
  },
606
634
  func_security_clone_only=self._func_security_clone_only,
635
+ session_history_unsafe=self._session_history_unsafe,
636
+ session_uncloned=self._session_uncloned,
607
637
  func_cs_ta_clone_names=self._func_cs_ta_clone_names,
608
638
  udt_defs=self._udt_fields,
609
639
  enum_defs=self._enum_defs,
@@ -622,6 +652,11 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
622
652
  for owner, names in self._func_var_storage_names.items()
623
653
  },
624
654
  func_series_vars=self._func_series_vars,
655
+ func_global_history_reads={
656
+ owner: list(names)
657
+ for owner, names in self._func_global_history_reads.items()
658
+ },
659
+ func_global_history_nodes=dict(self._func_global_history_nodes),
625
660
  nonpersistent_series_decl_names=set(
626
661
  self._nonpersistent_series_decl_names
627
662
  ),
@@ -2204,8 +2239,14 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
2204
2239
  out |= self._collect_security_mutable_globals(arg, resolving)
2205
2240
  for value in node.kwargs.values():
2206
2241
  out |= self._collect_security_mutable_globals(value, resolving)
2207
- for stmt in self._func_defs[func_name].body:
2208
- out |= self._collect_security_mutable_globals(stmt, resolving)
2242
+ # One walk of a body holds every global it reads, and the walk
2243
+ # returns their union: a helper reached again (``f(x) + f(x)``,
2244
+ # a diamond of such helpers) is not walked once per path.
2245
+ walked = f"walked:{func_name}"
2246
+ if walked not in resolving:
2247
+ resolving.add(walked)
2248
+ for stmt in self._func_defs[func_name].body:
2249
+ out |= self._collect_security_mutable_globals(stmt, resolving)
2209
2250
  resolving.remove(call_key)
2210
2251
  return out
2211
2252
 
@@ -2243,6 +2284,99 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
2243
2284
 
2244
2285
  return out
2245
2286
 
2287
+ def _session_history_unsafe_functions(
2288
+ self, func_defs: dict, find_calls, known_func_names: set[str],
2289
+ calls_by_parent: dict) -> dict[str, str]:
2290
+ """The functions and methods that cannot keep a ``session.*`` flag's
2291
+ history per call site, each with the reason (name -> why).
2292
+
2293
+ A read in a function keeps its call site's history: the function is
2294
+ emitted once per call site (``_session_call_*``, codegen). Four kinds
2295
+ of callable do not get that:
2296
+
2297
+ * a method: a call on a receiver the analyzer cannot type
2298
+ (``mk().m()``, a loop variable) is not told apart from the method's
2299
+ other calls;
2300
+ * a function a method calls, once in the method's one body;
2301
+ * a function a request.security expression evaluates: the evaluator
2302
+ inlines it or calls its chart variant, on the requested clock. The
2303
+ functions reached are those the expression calls, those they call,
2304
+ and those a global variable's definition or a mutable global's
2305
+ statements call when anything it reaches spells the global's name
2306
+ (the evaluator follows a mutable global by name, so a function's
2307
+ own binding of the name does not stop it);
2308
+ * a function a UDT field default calls, once for every ``new()``.
2309
+
2310
+ Codegen refuses a ``session.<flag>[k]`` it emits in one of them; a read
2311
+ it never emits (in ``plot()``, a dropped argument) refuses nothing.
2312
+ Every such emitted read failed the C++ compile before this lane.
2313
+ """
2314
+ unsafe: dict[str, str] = {}
2315
+
2316
+ def mark(names, why: str) -> None:
2317
+ for name in sorted(names):
2318
+ unsafe.setdefault(name, why)
2319
+
2320
+ def closure(roots) -> set[str]:
2321
+ found, pending = set(), list(roots)
2322
+ while pending:
2323
+ name = pending.pop()
2324
+ if name in found or name not in func_defs:
2325
+ continue
2326
+ found.add(name)
2327
+ pending.extend(callee for callee, _call in calls_by_parent.get(name, []))
2328
+ return found
2329
+
2330
+ methods = {name for name in func_defs if "." in name}
2331
+ mark(methods, "a method, whose calls PineForge cannot always tell apart")
2332
+ mark(closure(callee for m in methods for callee, _call in calls_by_parent.get(m, [])),
2333
+ "which a method calls")
2334
+
2335
+ # A request.security expression's reach.
2336
+ pending: list[tuple[Any, str | None]] = [
2337
+ (sec.expression, getattr(sec, "containing_func", "") or None)
2338
+ for sec in getattr(self, "_security_calls", []) or []
2339
+ if getattr(sec, "expression", None) is not None
2340
+ ]
2341
+ reached: set[str] = set()
2342
+ seen_nodes: set[int] = set()
2343
+ followed: set[str] = set()
2344
+ while pending:
2345
+ node, owner = pending.pop()
2346
+ if id(node) in seen_nodes:
2347
+ continue
2348
+ seen_nodes.add(id(node))
2349
+ if self._budget is not None:
2350
+ self._budget.check(getattr(node, "loc", None), Phase.ANALYZER)
2351
+ for callee, _call in find_calls(node, known_func_names, owner):
2352
+ if callee not in reached and callee in func_defs:
2353
+ reached.add(callee)
2354
+ pending.append((func_defs[callee], callee))
2355
+ for child, _depth in iter_ast_nodes(node):
2356
+ if not isinstance(child, Identifier) or child.name in followed:
2357
+ continue
2358
+ name = child.name
2359
+ info = self._global_binding_infos.get(name)
2360
+ if info is not None and (info.is_var or name in self._global_reassigned_names):
2361
+ followed.add(name)
2362
+ pending.extend((stmt, None) for stmt in info.source_stmts)
2363
+ elif name in self._global_expr_map:
2364
+ followed.add(name)
2365
+ pending.append((self._global_expr_map[name], None))
2366
+ mark(reached, "which a request.security expression evaluates on the requested clock")
2367
+
2368
+ # A UDT field default's call, for the types a script builds.
2369
+ built = {node.callee.object.name for node, _depth in iter_ast_nodes(self._ast)
2370
+ if isinstance(node, FuncCall) and isinstance(node.callee, MemberAccess)
2371
+ and node.callee.member == "new" and isinstance(node.callee.object, Identifier)}
2372
+ mark(closure(callee
2373
+ for stmt in self._ast.body
2374
+ if isinstance(stmt, TypeDecl) and stmt.name in built
2375
+ for field in stmt.fields if field.default is not None
2376
+ for callee, _call in find_calls(field.default, known_func_names, None)),
2377
+ "which a UDT field default calls once for every new()")
2378
+ return unsafe
2379
+
2246
2380
  def _propagate_call_site_counts(self) -> None:
2247
2381
  """Propagate stateful UDF identity through complete call paths.
2248
2382
 
@@ -2564,12 +2698,15 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
2564
2698
  owner_requirements.add(actual.name)
2565
2699
  series_changed = True
2566
2700
 
2567
- # Codegen synthesizes a Series buffer for two expression shapes that
2701
+ # Codegen synthesizes a Series buffer for three expression shapes that
2568
2702
  # do not appear in ``_func_series_vars`` themselves:
2569
2703
  #
2570
2704
  # * a call result read through history, e.g. ``f()[1]``;
2571
2705
  # * a scalar expression bridged into a UDF series parameter, e.g.
2572
- # ``history(close + open)`` where ``history(src) => src[1]``.
2706
+ # ``history(close + open)`` where ``history(src) => src[1]``;
2707
+ # * a session.* flag read at an offset, e.g. ``session.ismarket[1]``,
2708
+ # whose history in a function is the calls' of its call site
2709
+ # (TradingView's tapes, tests/test_e2e_session_history.py).
2573
2710
  #
2574
2711
  # A buffer is mutable per-call-site state just like TA/fixnan. Mark
2575
2712
  # its lexical owner stateful before the normal call-path closure so a
@@ -2609,6 +2746,15 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
2609
2746
  return True
2610
2747
  return False
2611
2748
 
2749
+ # A read written in a request.security expression keeps the requested
2750
+ # clock's history (codegen security.py), not its function's calls.
2751
+ requested_node_ids = {
2752
+ id(child)
2753
+ for sec in getattr(self, "_security_calls", []) or []
2754
+ if getattr(sec, "expression", None) is not None
2755
+ for child, _depth in iter_ast_nodes(sec.expression)
2756
+ }
2757
+
2612
2758
  def _has_synthetic_history_state(
2613
2759
  node, seen: set[int] | None = None) -> bool:
2614
2760
  if node is None:
@@ -2645,6 +2791,21 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
2645
2791
  name for name, func_def in func_defs.items()
2646
2792
  if _has_synthetic_history_state(func_def)
2647
2793
  }
2794
+ # A plain function that reads a flag at an offset is emitted once per
2795
+ # call site, if this analysis may clone it. A callable that cannot
2796
+ # keep its call sites apart gets no clone for it (codegen refuses such
2797
+ # a read where it emits one).
2798
+ self._session_history_unsafe = self._session_history_unsafe_functions(
2799
+ func_defs, _find_calls, known_func_names, calls_by_parent)
2800
+ session_readers = frozenset(
2801
+ name for name, func_def in func_defs.items()
2802
+ if name not in self._session_history_unsafe
2803
+ and emitted_session_reads(func_def, requested_node_ids)
2804
+ )
2805
+ cloned = (session_readers if self._session_clones is None
2806
+ else session_readers & self._session_clones)
2807
+ self._session_uncloned = session_readers - cloned
2808
+ synthetic_history_stateful |= cloned
2648
2809
 
2649
2810
  # request.security owns a separate evaluator context and already
2650
2811
  # materializes/remaps its embedded TA state per SecurityCallInfo. Do
@@ -2665,8 +2826,10 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
2665
2826
  )
2666
2827
 
2667
2828
  # Canonical direct-state predicate. TA-only and fixnan-only helpers
2668
- # are just as stateful as functions carrying an explicit series/var.
2669
- stateful = (
2829
+ # are just as stateful as functions carrying an explicit series/var,
2830
+ # and so is a function reading a script variable through history: its
2831
+ # call sites each own that history.
2832
+ direct_state = (
2670
2833
  set(self._func_series_vars)
2671
2834
  | set(self._func_var_members)
2672
2835
  | set(self._func_ta_ranges)
@@ -2679,15 +2842,40 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
2679
2842
  # marker is intentionally separate from request.security evaluator
2680
2843
  # identity, so ordinary security calls remain shared unless the
2681
2844
  # dedicated timeframe-monomorphization pass clones them.
2682
- changed = True
2683
- while changed:
2684
- changed = False
2685
- for fname, calls in calls_by_parent.items():
2686
- if fname in stateful:
2687
- continue
2688
- if any(sub in stateful for sub, _ in calls):
2689
- stateful.add(fname)
2690
- changed = True
2845
+ def close_over_callers(seed: set[str]) -> set[str]:
2846
+ closed = set(seed)
2847
+ changed = True
2848
+ while changed:
2849
+ changed = False
2850
+ for fname, calls in calls_by_parent.items():
2851
+ if fname in closed:
2852
+ continue
2853
+ if any(sub in closed for sub, _ in calls):
2854
+ closed.add(fname)
2855
+ changed = True
2856
+ return closed
2857
+
2858
+ # A function whose untyped parameter receives two primitive families
2859
+ # at its written calls is TradingView's once per family: one variant
2860
+ # per call site, each typed from its own call.
2861
+ family_polymorphic = self._untyped_param_family_conflicts(
2862
+ func_defs, func_info_by_name, call_edges, _bound_user_call_args)
2863
+ family_polymorphic = self._bounded_family_polymorphism(
2864
+ family_polymorphic,
2865
+ direct_state | set(self._func_global_history_reads),
2866
+ close_over_callers, call_edges, func_info_by_name)
2867
+ stateful = close_over_callers(
2868
+ direct_state
2869
+ | set(self._func_global_history_reads)
2870
+ | family_polymorphic
2871
+ )
2872
+ # Callables stateful only through a script variable's history, or
2873
+ # their parameters' families: before those rules they shared one body,
2874
+ # so their call-site typing must not refuse a script that transpiled
2875
+ # then (``merge_profile`` below).
2876
+ self._global_history_only_stateful = (
2877
+ stateful - close_over_callers(direct_state)
2878
+ )
2691
2879
 
2692
2880
  # Direct fixnan-only functions and pure transitive wrappers own no
2693
2881
  # TA/series member that would trip the emitter's ordinary body-clone
@@ -2862,6 +3050,200 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
2862
3050
  _bound_user_call_args,
2863
3051
  )
2864
3052
 
3053
+ # The primitive families a written call can pass an untyped parameter.
3054
+ _ARGUMENT_FAMILIES = frozenset({
3055
+ PineType.INT, PineType.FLOAT, PineType.BOOL, PineType.STRING,
3056
+ PineType.COLOR,
3057
+ })
3058
+
3059
+ def _untyped_param_family_conflicts(
3060
+ self, func_defs, func_info_by_name, call_edges, bound_args,
3061
+ ) -> set[str]:
3062
+ """Plain functions TradingView compiles once per argument family.
3063
+
3064
+ An untyped parameter takes the type of each written call's argument:
3065
+ ``s(x) => str.tostring(x)`` spells ``s(5)`` "5" and ``s(2.5)`` "2.5"
3066
+ (lab tv pf-cgs2-untyped-params). One shared body is typed from the
3067
+ first call, so a function whose untyped scalar parameter receives
3068
+ another family -- anything but an int where the body holds a float,
3069
+ which holds it exactly -- narrowed it (a float into an ``int``).
3070
+ Only an argument whose type is known at its call counts: one reading
3071
+ an untyped parameter of the calling function is typed by that
3072
+ function's variant. The set holds each such function and, to a fixed
3073
+ point, every function one of them forwards an untyped parameter to
3074
+ (``wrap(y) => twice(y)``)."""
3075
+
3076
+ def untyped_params(name: str | None) -> dict[int, str]:
3077
+ node = func_defs.get(name) if name is not None else None
3078
+ if node is None:
3079
+ return {}
3080
+ specs = list(self._func_param_type_specs.get(name, ()))
3081
+ return {
3082
+ index: param
3083
+ for index, param in enumerate(node.params)
3084
+ if index >= len(specs) or specs[index] is None
3085
+ }
3086
+
3087
+ def scalar_untyped_params(name: str) -> dict[int, str]:
3088
+ info = func_info_by_name.get(name)
3089
+ if (info is None or getattr(info, "is_udt_method", False)
3090
+ or not isinstance(func_defs.get(name), FuncDef)):
3091
+ return {}
3092
+ series = self._func_series_vars.get(name, set())
3093
+ return {
3094
+ index: param
3095
+ for index, param in untyped_params(name).items()
3096
+ if param not in series
3097
+ }
3098
+
3099
+ def shared_family(name: str, index: int) -> PineType:
3100
+ info = func_info_by_name[name]
3101
+ specs = list(getattr(info, "param_type_specs", ()) or ())
3102
+ spec = specs[index] if index < len(specs) else None
3103
+ if spec is not None:
3104
+ return self._primitive_pine_type_from_spec(spec)
3105
+ types = list(getattr(info, "param_types", ()) or ())
3106
+ return types[index] if index < len(types) else PineType.UNKNOWN
3107
+
3108
+ def reads(value, names: set[str]) -> bool:
3109
+ return bool(names) and any(
3110
+ isinstance(sub, Identifier) and sub.name in names
3111
+ for sub, _depth in iter_ast_nodes(value)
3112
+ )
3113
+
3114
+ conflicted: set[str] = set()
3115
+ # The first conflicting call of each directly conflicted function:
3116
+ # (parameter index, shared family, call family, call).
3117
+ self._family_conflict_calls = {}
3118
+ for owner, callee, call in call_edges:
3119
+ params = scalar_untyped_params(callee)
3120
+ if not params or callee in conflicted:
3121
+ continue
3122
+ owner_params = set(untyped_params(owner).values())
3123
+ actuals = bound_args(callee, call)
3124
+ recorded = self._callable_bound_param_types_by_node.get(id(call), [])
3125
+ for index in params:
3126
+ if (index >= len(actuals) or index >= len(recorded)
3127
+ or actuals[index] is None
3128
+ or reads(actuals[index], owner_params)):
3129
+ continue
3130
+ family = recorded[index]
3131
+ shared = shared_family(callee, index)
3132
+ if (family in self._ARGUMENT_FAMILIES
3133
+ and shared in self._ARGUMENT_FAMILIES
3134
+ and family != shared
3135
+ and not (family == PineType.INT
3136
+ and shared == PineType.FLOAT)):
3137
+ conflicted.add(callee)
3138
+ self._family_conflict_calls[callee] = (
3139
+ index, shared, family, call)
3140
+ break
3141
+
3142
+ changed = True
3143
+ while changed:
3144
+ changed = False
3145
+ for owner, callee, call in call_edges:
3146
+ if owner not in conflicted or callee in conflicted:
3147
+ continue
3148
+ params = scalar_untyped_params(callee)
3149
+ owner_params = set(untyped_params(owner).values())
3150
+ actuals = bound_args(callee, call)
3151
+ if any(
3152
+ index < len(actuals) and actuals[index] is not None
3153
+ and reads(actuals[index], owner_params)
3154
+ for index in params
3155
+ ):
3156
+ conflicted.add(callee)
3157
+ changed = True
3158
+ return conflicted
3159
+
3160
+ # Copies of one function family polymorphism may add: one per call path
3161
+ # through the functions it makes stateful (``_build_func_instances``).
3162
+ _MAX_FAMILY_VARIANTS = 64
3163
+
3164
+ def _bounded_family_polymorphism(
3165
+ self, family, seed, close_over_callers, call_edges, func_info_by_name,
3166
+ ) -> set[str]:
3167
+ """``family`` without the functions whose variants would outgrow the
3168
+ script: more copies in all than ``_MAX_FAMILY_VARIANTS`` and four per
3169
+ written call, beyond those the functions' own state already makes.
3170
+
3171
+ Each function the family set makes stateful is emitted once per call
3172
+ path from the script through stateful functions, so a diamond of
3173
+ forwarding helpers (``fk(x) => f(k-1)(x) + f(k-1)(x)``) is 2**depth
3174
+ copies of its leaf (42 MB of C++ at depth 16) and a forwarding chain
3175
+ is its depth times its calls. Seventy flat calls are seventy copies,
3176
+ one per written call, and stay, as do those of a helper forwarding
3177
+ the parameter once. Past the bound, the function gaining the most
3178
+ copies and the family functions it is reached from keep the one body
3179
+ every earlier build emitted, typed from the first call, and a
3180
+ conflicting parameter warns; the rest keep their variants."""
3181
+ if not family:
3182
+ return family
3183
+ callers: dict[str, list[str | None]] = {}
3184
+ for owner, callee, _call in call_edges:
3185
+ callers.setdefault(callee, []).append(owner)
3186
+ cap = 1 << 20
3187
+
3188
+ def path_counts(stateful: set[str]) -> dict[str, int]:
3189
+ counts = {name: 0 for name in stateful}
3190
+ for _round in range(len(stateful) + 1):
3191
+ changed = False
3192
+ for name in stateful:
3193
+ total = min(cap, sum(
3194
+ counts.get(owner, 0) if owner in stateful else 1
3195
+ for owner in callers.get(name, ())))
3196
+ if total != counts[name]:
3197
+ counts[name] = total
3198
+ changed = True
3199
+ if not changed:
3200
+ break
3201
+ return counts
3202
+
3203
+ before = path_counts(close_over_callers(seed))
3204
+ bound = max(self._MAX_FAMILY_VARIANTS, 4 * len(call_edges))
3205
+ kept = set(family)
3206
+ dropped: set[str] = set()
3207
+ while kept:
3208
+ after = path_counts(close_over_callers(seed | kept))
3209
+ added = {name: count - before.get(name, 0)
3210
+ for name, count in after.items()
3211
+ if count > before.get(name, 0)}
3212
+ if sum(added.values()) <= bound:
3213
+ break
3214
+ worst = max(sorted(added), key=lambda name: added[name])
3215
+ reached_from: set[str] = set()
3216
+ pending = [worst]
3217
+ while pending:
3218
+ name = pending.pop()
3219
+ if name in reached_from:
3220
+ continue
3221
+ reached_from.add(name)
3222
+ pending.extend(owner for owner in callers.get(name, ())
3223
+ if owner is not None)
3224
+ implicated = kept & reached_from
3225
+ if not implicated:
3226
+ break
3227
+ kept -= implicated
3228
+ dropped |= implicated
3229
+ for callee, (index, shared, incoming, call) in sorted(
3230
+ getattr(self, "_family_conflict_calls", {}).items()):
3231
+ info = func_info_by_name.get(callee)
3232
+ if callee not in dropped or info is None or info.node is None:
3233
+ continue
3234
+ self._warn(
3235
+ "Untyped parameter '" + info.node.params[index]
3236
+ + "' of callable '" + callee + "' receives "
3237
+ + shared.value + " and " + incoming.value
3238
+ + " at its written calls; a copy per call path would exceed "
3239
+ + f"{self._MAX_FAMILY_VARIANTS} copies and four per written "
3240
+ + "call, so PineForge types it " + shared.value
3241
+ + ", as its first call, and converts the other argument. "
3242
+ + "Declare the parameter type to choose it.",
3243
+ call.loc,
3244
+ )
3245
+ return kept
3246
+
2865
3247
  @staticmethod
2866
3248
  def _primitive_pine_type_from_spec(spec) -> PineType:
2867
3249
  if spec is None or getattr(spec, "kind", None) != "primitive":
@@ -2932,6 +3314,31 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
2932
3314
  current[index] = candidate
2933
3315
  changed = True
2934
3316
  elif current[index] != candidate:
3317
+ if callee in self._global_history_only_stateful:
3318
+ # Its call sites shared one body before this callable
3319
+ # read a script variable's history; keep that body's
3320
+ # typing, the first type, rather than refuse a script
3321
+ # that transpiled then.
3322
+ warned = (callee, cs_idx, index)
3323
+ if warned not in self._global_history_typing_warned:
3324
+ self._global_history_typing_warned.add(warned)
3325
+ self._warn(
3326
+ "Untyped parameter '"
3327
+ + info.node.params[index]
3328
+ + "' of callable '"
3329
+ + callee
3330
+ + "' receives "
3331
+ + current[index].value
3332
+ + " and "
3333
+ + candidate.value
3334
+ + " through calls that share one written-call "
3335
+ + f"variant (cs{cs_idx}); PineForge types it "
3336
+ + current[index].value
3337
+ + ", so the other argument is converted. "
3338
+ + "Declare the parameter type to choose it.",
3339
+ call.loc,
3340
+ )
3341
+ continue
2935
3342
  self._error(
2936
3343
  "Cannot safely specialize untyped parameter '"
2937
3344
  + info.node.params[index]
@@ -3223,16 +3630,18 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
3223
3630
  )
3224
3631
 
3225
3632
  # ------------------------------------------------------------------
3226
- # Mixed-callsite UDF timeframe-param security rejection.
3633
+ # Per-call-site request.security contexts of a helper.
3227
3634
  #
3228
- # A ``request.security`` whose ``timeframe`` is a parameter of its
3229
- # containing UDF maps to ONE evaluator regardless of how many times the
3230
- # UDF is called. When the UDF is called from >= 2 sites with DISTINCT
3231
- # literal timeframes, a single evaluator cannot faithfully serve them
3232
- # all and the resolver would silently collapse onto the chart timeframe
3233
- # (``input_tf_``). Per-callsite evaluator specialization (cloning the
3234
- # evaluator + UDF) is the correct fix but is not wired in this iteration,
3235
- # so we reject deterministically instead of emitting wrong semantics.
3635
+ # A ``request.security`` whose ``timeframe`` or ``symbol`` is a parameter
3636
+ # of its containing UDF maps to ONE evaluator however often the UDF is
3637
+ # called. When its call sites pass different contexts -- timeframes, or a
3638
+ # Heikin-Ashi and a plain chart symbol -- one evaluator cannot serve them
3639
+ # all: the request is cloned per call site (``callsite_idx``), each clone
3640
+ # registered with its site's timeframe argument (a literal as a literal,
3641
+ # anything else -- an input, a global -- as that expression, read at
3642
+ # registration) and its site's symbol. A context reaching the helper
3643
+ # through another helper's parameter or a local is resolved before the
3644
+ # analyzer runs (``security_contexts``) and arrives here resolved.
3236
3645
  # ------------------------------------------------------------------
3237
3646
  def _check_mixed_callsite_security_tf(self) -> None:
3238
3647
  sec_calls = getattr(self, "_security_calls", None)
@@ -3248,16 +3657,19 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
3248
3657
  cloned_any = False
3249
3658
  for sec in sec_calls:
3250
3659
  containing = getattr(sec, "containing_func", "") or ""
3660
+ fdef = func_defs.get(containing) if containing else None
3251
3661
  tf_node = getattr(sec, "timeframe", None)
3252
- if not containing or not isinstance(tf_node, Identifier):
3662
+ symbol_node = getattr(sec, "symbol", None)
3663
+ if fdef is None or sec.context_resolved or sec.is_lower_tf_array:
3253
3664
  new_calls.append(sec)
3254
3665
  continue
3255
- param_name = tf_node.name
3256
- fdef = func_defs.get(containing)
3257
- if fdef is None or param_name not in fdef.params:
3666
+ tf_param = (tf_node.name if isinstance(tf_node, Identifier)
3667
+ and tf_node.name in fdef.params else None)
3668
+ symbol_param = (symbol_node.name if isinstance(symbol_node, Identifier)
3669
+ and symbol_node.name in fdef.params else None)
3670
+ if tf_param is None and symbol_param is None:
3258
3671
  new_calls.append(sec)
3259
3672
  continue
3260
- pidx = fdef.params.index(param_name)
3261
3673
  calls = list(self._iter_user_func_calls(containing))
3262
3674
  if not calls:
3263
3675
  new_calls.append(sec) # dead code — evaluator result never read
@@ -3280,7 +3692,8 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
3280
3692
  # naming (which keys purely off func_call_cs_map, not
3281
3693
  # has_ta/has_series) picks the right ``_cs{N}`` variant.
3282
3694
  already_tracked = self._func_call_site_count.get(containing, 0) > 0
3283
- per_cs: list[tuple[int, str | None]] = []
3695
+ # (cs_idx, context key, registered timeframe, heikinashi)
3696
+ per_cs: list[tuple[int, tuple, object, bool]] = []
3284
3697
  for i, call in enumerate(calls):
3285
3698
  if already_tracked:
3286
3699
  cs_info = self._func_call_cs_map.get(id(call))
@@ -3288,33 +3701,49 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
3288
3701
  continue # shouldn't happen: has_ta/has_series tracks ALL call sites
3289
3702
  cs_idx = cs_info[1]
3290
3703
  else:
3704
+ # Numbered in func_call_cs_map only if the request is
3705
+ # cloned below: a call mapped to ``f_cs0`` while one
3706
+ # context left ``f`` uncloned named a function nothing
3707
+ # emits (``f(string sym) => request.security(sym, "60",
3708
+ # close[1])`` did not compile).
3291
3709
  cs_idx = i
3292
- self._func_call_cs_map.setdefault(id(call), (containing, cs_idx))
3293
- arg = call.args[pidx] if pidx < len(call.args) else None
3294
- lit = self._callsite_tf_literal_value(arg)
3295
- per_cs.append((cs_idx, lit))
3710
+ key: list = []
3711
+ timeframe = tf_node
3712
+ if tf_param is not None:
3713
+ pidx = fdef.params.index(tf_param)
3714
+ arg = call.args[pidx] if pidx < len(call.args) else None
3715
+ lit = self._callsite_tf_literal_value(arg)
3716
+ if lit is not None:
3717
+ key.append(("lit", lit))
3718
+ timeframe = StringLiteral(value=lit, loc=tf_node.loc)
3719
+ else:
3720
+ key.append(("expr", self._security_context_key(arg)))
3721
+ timeframe = arg
3722
+ heikinashi = sec.heikinashi
3723
+ if symbol_param is not None:
3724
+ sidx = fdef.params.index(symbol_param)
3725
+ arg = call.args[sidx] if sidx < len(call.args) else None
3726
+ heikinashi = arg is not None and self._security_symbol_is_heikinashi(arg)
3727
+ key.append(heikinashi)
3728
+ per_cs.append((cs_idx, tuple(key), timeframe, heikinashi))
3296
3729
  if not per_cs:
3297
3730
  new_calls.append(sec) # dead code — evaluator result never read
3298
3731
  continue
3299
- distinct_literals = {lit for _, lit in per_cs if lit is not None}
3300
- if len(distinct_literals) < 2:
3301
- new_calls.append(sec) # single TF (or unresolved) — no cloning needed
3732
+ if len({key for _, key, _, _ in per_cs}) < 2:
3733
+ # One context on every call site: the helper's own request.
3734
+ sec.heikinashi = per_cs[0][3]
3735
+ new_calls.append(sec)
3302
3736
  continue
3303
- if any(lit is None for _, lit in per_cs):
3304
- # Some call site's tf isn't a compile-time literal — can't
3305
- # pin every clone to a concrete timeframe. Keep the original
3306
- # deterministic rejection rather than guess.
3737
+ # With a timeframe parameter, a key's first part is its timeframe.
3738
+ series = tf_param and next(
3739
+ (name for _, key, timeframe, _ in per_cs
3740
+ if key[0][0] == "expr" and (name := reads_bar_series(timeframe))), None)
3741
+ if series is not None:
3307
3742
  self._error(
3308
- "request.security timeframe parameter '"
3309
- + param_name
3310
- + "' of function '"
3311
- + containing
3312
- + "' is called with multiple distinct literal timeframes ("
3313
- + ", ".join(sorted(distinct_literals))
3314
- + "). A single request.security evaluator cannot serve "
3315
- "them all and would silently collapse onto the chart "
3316
- "timeframe. Pass a single timeframe, or inline a separate "
3317
- "request.security call at each call site.",
3743
+ "request.security timeframe parameter '" + str(tf_param)
3744
+ + "' of function '" + containing + "' is passed a timeframe "
3745
+ "that reads the chart bar ('" + series + "'): PineForge "
3746
+ "registers each call site's timeframe before the first bar.",
3318
3747
  tf_node.loc,
3319
3748
  )
3320
3749
  new_calls.append(sec)
@@ -3326,18 +3755,18 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
3326
3755
  # func_security_clone_only) actually clones its body, with
3327
3756
  # self._active_call_site_idx set to each of our cs_idx values
3328
3757
  # in turn while it does.
3758
+ for cs_idx, call in enumerate(calls):
3759
+ self._func_call_cs_map.setdefault(id(call), (containing, cs_idx))
3329
3760
  self._func_call_site_count[containing] = len(calls)
3330
3761
  self._func_security_clone_only.add(containing)
3331
- # Clone: one SecurityCallInfo per call site, each pinned to that
3332
- # site's literal timeframe via a synthetic StringLiteral (so the
3333
- # existing literal-timeframe resolution path needs no changes)
3334
- # and given a fresh, currently-unused sec_id.
3762
+ # Clone: one SecurityCallInfo per call site, each registered with
3763
+ # its site's context and given a fresh, currently-unused sec_id.
3335
3764
  next_sec_id = max((s.sec_id for s in sec_calls), default=-1) + 1
3336
3765
  next_sec_id = max(next_sec_id, len(sec_calls) + len(new_calls))
3337
- for cs_idx, lit in sorted(per_cs):
3766
+ for cs_idx, _key, timeframe, heikinashi in sorted(per_cs, key=lambda c: c[0]):
3338
3767
  clone = SecurityCallInfo(
3339
3768
  sec_id=next_sec_id,
3340
- timeframe=StringLiteral(value=lit, loc=tf_node.loc),
3769
+ timeframe=timeframe,
3341
3770
  expression=sec.expression,
3342
3771
  returns_tuple=sec.returns_tuple,
3343
3772
  tuple_size=sec.tuple_size,
@@ -3345,11 +3774,16 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
3345
3774
  gaps=sec.gaps,
3346
3775
  lookahead=sec.lookahead,
3347
3776
  ta_range=sec.ta_range,
3777
+ heikinashi=heikinashi,
3348
3778
  depends_on_mutable_globals=sec.depends_on_mutable_globals,
3349
3779
  mutable_globals=sec.mutable_globals,
3350
3780
  is_lower_tf_array=sec.is_lower_tf_array,
3351
3781
  containing_func=sec.containing_func,
3352
3782
  callsite_idx=cs_idx,
3783
+ string_result=sec.string_result,
3784
+ symbol=sec.symbol,
3785
+ foreign=sec.foreign,
3786
+ ignore_invalid=sec.ignore_invalid,
3353
3787
  )
3354
3788
  new_calls.append(clone)
3355
3789
  next_sec_id += 1
@@ -3391,20 +3825,29 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
3391
3825
  yield from _walk(self._ast)
3392
3826
 
3393
3827
  def _callsite_tf_literal_value(self, arg) -> str | None:
3394
- """Resolve a UDF call-site timeframe argument to a literal string
3395
- value when it is statically known: a string literal, or a known
3396
- constant / input-backed variable whose stored value is a string.
3397
- Returns None for anything that is not a compile-time string."""
3828
+ """A UDF call-site timeframe argument's value when it is a string
3829
+ literal, directly or through never-reassigned global aliases, else
3830
+ None. An input's default is not its value: an override changes it."""
3831
+ seen: set[str] = set()
3832
+ while (isinstance(arg, Identifier) and arg.name not in seen
3833
+ and arg.name not in self._global_reassigned_names):
3834
+ seen.add(arg.name)
3835
+ arg = self._global_expr_map.get(arg.name)
3398
3836
  if isinstance(arg, StringLiteral):
3399
3837
  return arg.value
3400
- if isinstance(arg, Identifier):
3401
- sym = self._symbols.resolve(arg.name)
3402
- if sym is not None and getattr(sym, "const_value", None) is not None:
3403
- val = sym.const_value
3404
- if isinstance(val, str):
3405
- return val
3406
3838
  return None
3407
3839
 
3840
+ def _security_context_key(self, arg):
3841
+ """A call-site context argument, compared by what it spells after
3842
+ following never-reassigned global aliases."""
3843
+ seen: set[str] = set()
3844
+ while (isinstance(arg, Identifier) and arg.name not in seen
3845
+ and arg.name not in self._global_reassigned_names
3846
+ and isinstance(self._global_expr_map.get(arg.name), Identifier)):
3847
+ seen.add(arg.name)
3848
+ arg = self._global_expr_map[arg.name]
3849
+ return context_key(arg)
3850
+
3408
3851
  def _is_static_expression(self, node: ASTNode | None) -> bool:
3409
3852
  if node is None:
3410
3853
  return True
@@ -3474,6 +3917,10 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
3474
3917
  """Dispatch to the appropriate visitor and return the inferred type."""
3475
3918
  if node is None:
3476
3919
  return PineType.VOID
3920
+ if self._budget is not None:
3921
+ self._budget_visit_count += 1
3922
+ if self._budget_visit_count % 128 == 0:
3923
+ self._budget.check(node.loc, Phase.ANALYZER)
3477
3924
 
3478
3925
  method_name = f"_visit_{type(node).__name__}"
3479
3926
  # Convert CamelCase to snake_case for method lookup
@@ -3508,12 +3955,16 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
3508
3955
  return PineType.VOID
3509
3956
 
3510
3957
  def _visit_ImportStmt(self, node: ImportStmt) -> PineType:
3958
+ # Imported here: the support checker imports the analyzer package.
3959
+ from ..support_checker import import_is_builtin_namespace_no_op, import_refusal
3960
+ if import_is_builtin_namespace_no_op(self._ast, node):
3961
+ return PineType.VOID
3511
3962
  loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
3512
3963
  diag = Diagnostic(
3513
3964
  level=Level.ERROR,
3514
3965
  phase=Phase.ANALYZER,
3515
3966
  location=loc,
3516
- message=f"Import is not supported: '{node.path}'",
3967
+ message=import_refusal(node),
3517
3968
  )
3518
3969
  raise CompileError([diag])
3519
3970
 
@@ -4047,20 +4498,112 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
4047
4498
  if hasattr(sym, "is_static_series"):
4048
4499
  delattr(sym, "is_static_series")
4049
4500
  else:
4050
- self._visit(node.target)
4501
+ target_type = self._visit(node.target)
4051
4502
  base_name = self._get_target_base_name(node.target)
4052
4503
  if base_name:
4053
4504
  self._static_vars.discard(base_name)
4054
4505
  sym = self._symbols.resolve(base_name)
4055
4506
  if sym and hasattr(sym, "is_static_series"):
4056
4507
  delattr(sym, "is_static_series")
4508
+ # The statement's value is the target's new value, typed as the
4509
+ # target: ``o.v += 1`` on a float field is a float.
4510
+ if (isinstance(node.target, MemberAccess)
4511
+ and target_type not in (None, PineType.UNKNOWN, PineType.VOID)):
4512
+ return target_type
4057
4513
 
4514
+ if isinstance(node.target, Identifier):
4515
+ target_sym = self._symbols.resolve(node.target.name)
4516
+ if (target_sym is not None and target_sym.pine_type
4517
+ not in (PineType.UNKNOWN, PineType.VOID)):
4518
+ return target_sym.pine_type
4058
4519
  return val_type
4059
4520
 
4521
+ def _selection_tuple_shape(
4522
+ self, node
4523
+ ) -> tuple[int, tuple[PineType, ...]] | None:
4524
+ """``(size, element types)`` of an if/switch whose every arm yields a
4525
+ tuple: a ``[a, b]`` literal, a call of a tuple-returning user function,
4526
+ or a nested selection of those. Pine types the selection as that
4527
+ tuple; arms must agree on its size. ``None`` for any other shape.
4528
+
4529
+ Element types are the arms' common type per position (int and float
4530
+ make float); a position the arms disagree on is left untyped.
4531
+ """
4532
+ if isinstance(node, IfStmt):
4533
+ arms = [node.body] + ([node.else_body] if node.else_body else [])
4534
+ elif isinstance(node, SwitchStmt):
4535
+ arms = [body for _case, body in node.cases]
4536
+ if node.default_body:
4537
+ arms.append(node.default_body)
4538
+ else:
4539
+ return None
4540
+ shapes: list[tuple[int, tuple[PineType, ...]]] = []
4541
+ for body in arms:
4542
+ if not body:
4543
+ return None
4544
+ terminal = body[-1]
4545
+ expr = terminal.expr if isinstance(terminal, ExprStmt) else terminal
4546
+ if isinstance(expr, TupleLiteral):
4547
+ shapes.append((
4548
+ len(expr.elements),
4549
+ self._tuple_element_types_by_node.get(id(expr), ()),
4550
+ ))
4551
+ elif (isinstance(expr, FuncCall)
4552
+ and isinstance(expr.callee, Identifier)
4553
+ and self._func_returns_tuple.get(expr.callee.name, False)):
4554
+ shapes.append((
4555
+ self._func_tuple_element_count.get(expr.callee.name, 0),
4556
+ self._func_tuple_element_types.get(expr.callee.name, ()),
4557
+ ))
4558
+ elif isinstance(expr, (IfStmt, SwitchStmt)):
4559
+ nested = self._selection_tuple_shape(expr)
4560
+ if nested is None:
4561
+ return None
4562
+ shapes.append(nested)
4563
+ else:
4564
+ return None
4565
+ sizes = {size for size, _types in shapes}
4566
+ if len(sizes) != 1:
4567
+ return None
4568
+ size = sizes.pop()
4569
+ if size < 2:
4570
+ return None
4571
+ if any(len(types) != size for _size, types in shapes):
4572
+ return size, ()
4573
+ element_types: list[PineType] = []
4574
+ for position in range(size):
4575
+ known = {
4576
+ types[position] for _size, types in shapes
4577
+ if types[position] not in (PineType.NA, PineType.UNKNOWN)
4578
+ }
4579
+ if len(known) == 1:
4580
+ element_types.append(known.pop())
4581
+ elif known and known <= {PineType.INT, PineType.FLOAT}:
4582
+ element_types.append(PineType.FLOAT)
4583
+ else:
4584
+ return size, ()
4585
+ return size, tuple(element_types)
4586
+
4060
4587
  def _visit_TupleAssign(self, node: TupleAssign) -> PineType:
4061
4588
  val_type = self._visit(node.value)
4062
4589
  loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1)
4063
4590
  element_types = self._tuple_element_types_by_node.get(id(node.value), ())
4591
+ if not element_types and isinstance(node.value, (IfStmt, SwitchStmt)):
4592
+ shape = self._selection_tuple_shape(node.value)
4593
+ if shape is not None:
4594
+ element_types = shape[1]
4595
+ if (not element_types
4596
+ and isinstance(node.value, FuncCall)
4597
+ and isinstance(node.value.callee, Identifier)
4598
+ and self._func_returns_tuple.get(node.value.callee.name, False)):
4599
+ # A user function's string elements bind as strings. Its other
4600
+ # families keep the historical double storage of a call tuple.
4601
+ element_types = tuple(
4602
+ PineType.STRING if item == PineType.STRING else PineType.FLOAT
4603
+ for item in self._func_tuple_element_types.get(
4604
+ node.value.callee.name, ()
4605
+ )
4606
+ )
4064
4607
 
4065
4608
  is_val_static = self._is_static_expression(node.value)
4066
4609
 
@@ -4075,10 +4618,12 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
4075
4618
  )
4076
4619
  # Tuple bindings historically use double storage for every
4077
4620
  # numeric element, including integer literals. Preserve that
4078
- # contract while retaining the newly-authoritative bool family.
4621
+ # contract while retaining the bool and string families: a string
4622
+ # element read as a double compared by value (``a == b``) through
4623
+ # a static_cast of a std::string.
4079
4624
  element_type = (
4080
- PineType.BOOL
4081
- if inferred_element_type == PineType.BOOL
4625
+ inferred_element_type
4626
+ if inferred_element_type in (PineType.BOOL, PineType.STRING)
4082
4627
  else PineType.FLOAT
4083
4628
  )
4084
4629
 
@@ -4134,6 +4679,32 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
4134
4679
  # Function definition
4135
4680
  # ------------------------------------------------------------------
4136
4681
 
4682
+ def _returns_tuple_call(self, value) -> bool:
4683
+ """A call to a tuple-returning user function or ``ta.*`` function."""
4684
+ if not isinstance(value, FuncCall):
4685
+ return False
4686
+ callee = value.callee
4687
+ if isinstance(callee, Identifier):
4688
+ return self._func_returns_tuple.get(callee.name, False)
4689
+ return (isinstance(callee, MemberAccess)
4690
+ and isinstance(callee.object, Identifier)
4691
+ and callee.object.name == "ta"
4692
+ and callee.member in TA_TUPLE_RETURNS)
4693
+
4694
+ def _statement_value_type(self, stmt, fallback: PineType) -> PineType:
4695
+ """Type of the value a function's last statement leaves.
4696
+
4697
+ A trailing declaration ``[T] x = e`` returns ``x``, typed as the
4698
+ variable (``float y = 4`` is a float); a trailing assignment types
4699
+ itself as its target in ``_visit_Assignment``.
4700
+ """
4701
+ if not isinstance(stmt, VarDecl) or not stmt.name:
4702
+ return fallback
4703
+ sym = self._symbols.resolve(stmt.name)
4704
+ if sym is None or sym.pine_type in (PineType.UNKNOWN, PineType.VOID):
4705
+ return fallback
4706
+ return sym.pine_type
4707
+
4137
4708
  def _visit_FuncDef(self, node: FuncDef) -> PineType:
4138
4709
  # Store the function def for later analysis
4139
4710
  self._func_defs[node.name] = node
@@ -4181,6 +4752,8 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
4181
4752
  try:
4182
4753
  for stmt in node.body:
4183
4754
  body_type = self._visit(stmt)
4755
+ if node.body:
4756
+ body_type = self._statement_value_type(node.body[-1], body_type)
4184
4757
  finally:
4185
4758
  self._global_scope = old_global
4186
4759
  self._collection_scope_stack.pop()
@@ -4251,6 +4824,18 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
4251
4824
  terminal_direct_return_spec,
4252
4825
  )
4253
4826
 
4827
+ # ``[p, q] = pair(v)`` last returns that tuple: its element types are
4828
+ # the declared names', resolvable only inside the function scope.
4829
+ tuple_decl_types = ()
4830
+ if (node.body and isinstance(node.body[-1], TupleAssign)
4831
+ and "_" not in node.body[-1].names
4832
+ and self._returns_tuple_call(node.body[-1].value)):
4833
+ tuple_decl_types = tuple(
4834
+ sym.pine_type if sym is not None else PineType.FLOAT
4835
+ for sym in (self._symbols.resolve(name)
4836
+ for name in node.body[-1].names)
4837
+ )
4838
+
4254
4839
  self._symbols.exit_scope()
4255
4840
 
4256
4841
  # Detect if function returns a tuple (last stmt is TupleLiteral)
@@ -4264,12 +4849,27 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
4264
4849
  tuple_node = last_stmt.expr
4265
4850
  elif isinstance(last_stmt, TupleLiteral):
4266
4851
  tuple_node = last_stmt
4852
+ selection_shape = (
4853
+ self._selection_tuple_shape(last_stmt)
4854
+ if isinstance(last_stmt, (IfStmt, SwitchStmt))
4855
+ else None
4856
+ )
4267
4857
  if tuple_node is not None:
4268
4858
  self._func_returns_tuple[node.name] = True
4269
4859
  self._func_tuple_element_count[node.name] = len(tuple_node.elements)
4270
4860
  self._func_tuple_element_types[node.name] = (
4271
4861
  self._tuple_element_types_by_node.get(id(tuple_node), ())
4272
4862
  )
4863
+ elif tuple_decl_types:
4864
+ self._func_returns_tuple[node.name] = True
4865
+ self._func_tuple_element_count[node.name] = len(tuple_decl_types)
4866
+ self._func_tuple_element_types[node.name] = tuple_decl_types
4867
+ elif selection_shape is not None:
4868
+ # ``f() => if c ... g() else [a, b]``: every arm yields a
4869
+ # tuple of one size, so the function returns that tuple.
4870
+ self._func_returns_tuple[node.name] = True
4871
+ self._func_tuple_element_count[node.name] = selection_shape[0]
4872
+ self._func_tuple_element_types[node.name] = selection_shape[1]
4273
4873
  elif (
4274
4874
  isinstance(terminal_ret_expr, FuncCall)
4275
4875
  and isinstance(terminal_ret_expr.callee, Identifier)
@@ -4289,6 +4889,20 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
4289
4889
  self._func_tuple_element_types[node.name] = (
4290
4890
  self._func_tuple_element_types.get(terminal_callee, ())
4291
4891
  )
4892
+ elif (
4893
+ isinstance(terminal_ret_expr, FuncCall)
4894
+ and id(terminal_ret_expr) in self._security_tuple_shapes
4895
+ ):
4896
+ # ``htf(sym, tf) => request.security(sym, tf, pack())``
4897
+ # returns the requested tuple: it was typed a double, which
4898
+ # did not compile, and its callers destructure it.
4899
+ self._func_returns_tuple[node.name] = True
4900
+ self._func_tuple_element_count[node.name] = (
4901
+ self._security_tuple_shapes[id(terminal_ret_expr)]
4902
+ )
4903
+ self._func_tuple_element_types[node.name] = (
4904
+ self._tuple_element_types_by_node.get(id(terminal_ret_expr), ())
4905
+ )
4292
4906
 
4293
4907
  # Re-run direct-wrapper propagation to a fixed point whenever a new
4294
4908
  # definition is analyzed. This makes ``outer()=>inner()`` source-order
@@ -4516,6 +5130,8 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
4516
5130
  try:
4517
5131
  for stmt in node.body:
4518
5132
  ret_type = self._visit(stmt)
5133
+ if node.body:
5134
+ ret_type = self._statement_value_type(node.body[-1], ret_type)
4519
5135
  if terminal_ret_expr is not None:
4520
5136
  terminal_spec = self._type_spec_from_expr(terminal_ret_expr)
4521
5137
  if terminal_spec is not None and terminal_spec.kind == "map":
@@ -5255,6 +5871,17 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
5255
5871
  self._visit(arg)
5256
5872
  return PineType.COLOR
5257
5873
 
5874
+ # string(x): Pine's cast to string, usually of na
5875
+ # (``[string(na), string(na)]`` arms of a string tuple). It fell
5876
+ # to the unknown-builtin default FLOAT, which untyped a selection
5877
+ # tuple whose other arm carries strings.
5878
+ if func_name == "string":
5879
+ for arg in node.args:
5880
+ self._visit(arg)
5881
+ for val in node.kwargs.values():
5882
+ self._visit(val)
5883
+ return PineType.STRING
5884
+
5258
5885
  # User-defined function call
5259
5886
  if func_name in self._func_defs:
5260
5887
  return self._handle_user_func_call(func_name, node)
@@ -5521,6 +6148,18 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
5521
6148
  """Re-run map-history safety after untyped callable args are known."""
5522
6149
  if owner in visiting:
5523
6150
  return
6151
+ # The same owner, specs and path check the same nodes again, so a
6152
+ # helper that a diamond of callers reaches (``f1(x) => f0(x) +
6153
+ # f0(x)``, ...) is validated once, not once per path. A failed check
6154
+ # raises, so only a passed validation is remembered.
6155
+ key = (
6156
+ owner, visiting, tuple(sorted(parameter_specs.items())),
6157
+ len(self._deferred_param_history_refs.get(owner, [])),
6158
+ len(self._deferred_param_call_edges.get(owner, [])),
6159
+ )
6160
+ validated = self.__dict__.setdefault("_deferred_param_history_validated", set())
6161
+ if key in validated:
6162
+ return
5524
6163
  next_visiting = visiting | {owner}
5525
6164
  for node, parameter_nodes in self._deferred_param_history_refs.get(
5526
6165
  owner, []
@@ -5569,6 +6208,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
5569
6208
  callee_specs,
5570
6209
  next_visiting,
5571
6210
  )
6211
+ validated.add(key)
5572
6212
 
5573
6213
  def _propagate_deferred_map_callable_specs(
5574
6214
  self,
@@ -5866,9 +6506,40 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
5866
6506
  self._func_series_history_nodes.setdefault(
5867
6507
  (func_name, name), node
5868
6508
  )
6509
+ self._note_function_global_history_read(node, name, sym)
5869
6510
 
5870
6511
  return obj_type
5871
6512
 
6513
+ def _note_function_global_history_read(
6514
+ self, node: Subscript, name: str, sym) -> None:
6515
+ """Record ``x[k]`` in a plain UDF body on a script variable.
6516
+
6517
+ TradingView builds the history of a series used inside a function
6518
+ through each successive call of it: in ``f() => gv[1]``, ``gv[1]`` is
6519
+ ``gv`` as that call site saw it on its latest call at or before the
6520
+ previous bar, whether ``gv`` is ``var`` or not, and ``bar_index`` reads
6521
+ the same way; the chart built-ins keep the chart's history
6522
+ (tests/fixtures/function_global_history). Codegen gives every emitted
6523
+ body of the function its own buffer, so the function needs one body
6524
+ per call site, like any other stateful function.
6525
+ """
6526
+ if not self._enclosing_func_names or sym.scope != "global":
6527
+ return
6528
+ owner = self._enclosing_func_names[-1]
6529
+ if not isinstance(self._func_defs.get(owner), FuncDef):
6530
+ return
6531
+ # A built-in other than bar_index is chart history.
6532
+ if name != "bar_index" and getattr(sym, "_pf_decl_node_id", None) is None:
6533
+ return
6534
+ spec = getattr(sym, "type_spec", None)
6535
+ if (sym.pine_type not in (PineType.INT, PineType.FLOAT, PineType.BOOL)
6536
+ or (spec is not None and spec.kind != "primitive")):
6537
+ return
6538
+ reads = self._func_global_history_reads.setdefault(owner, [])
6539
+ if name not in reads:
6540
+ reads.append(name)
6541
+ self._func_global_history_nodes[id(node)] = (owner, name, node)
6542
+
5872
6543
  def _visit_Identifier(self, node: Identifier) -> PineType:
5873
6544
  # Some identifiers are namespace prefixes handled elsewhere
5874
6545
  if node.name in ("strategy", "ta", "input", "math", "str", "color",
@@ -6020,9 +6691,11 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
6020
6691
  if ns == "barstate":
6021
6692
  return PineType.BOOL
6022
6693
 
6023
- # alert.* constants (freq_once_per_bar, freq_once_per_bar_close, etc.)
6694
+ # alert.freq_* are const strings ("all", "once_per_bar",
6695
+ # "once_per_bar_close"); any other alert.* member is refused by
6696
+ # the support checker.
6024
6697
  if ns == "alert":
6025
- return PineType.INT
6698
+ return PineType.STRING
6026
6699
 
6027
6700
  # position.* constants for tables (middle_right, top_left, etc.)
6028
6701
  if ns == "position":
@@ -6112,6 +6785,11 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
6112
6785
  return PineType.FLOAT
6113
6786
 
6114
6787
  sym = self._symbols.resolve(ns)
6788
+ # A field read's receiver is an identifier read like any other:
6789
+ # codegen needs its binding scope (``_call_site_var_name``).
6790
+ self._identifier_binding_scopes[id(node.object)] = (
6791
+ getattr(sym, "scope", None) if sym is not None else None
6792
+ )
6115
6793
  udt_name = None
6116
6794
  if sym is not None:
6117
6795
  udt_name = sym.udt_type_name