@pineforge/codegen-pyodide 0.9.0 → 0.10.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.
@@ -0,0 +1,163 @@
1
+ """Strict, source-order-preserving binding for typed Pine methods.
2
+
3
+ Typed extension methods are inventoried before semantic analysis so a call may
4
+ precede its declaration without falling through to a same-named builtin. This
5
+ module deliberately contains no analyzer or codegen state: both phases consume
6
+ the same signature and binding result.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from .ast_nodes import ASTNode, FuncCall, MethodDef, Program
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class MethodSignature:
18
+ key: str
19
+ param_names: tuple[str, ...]
20
+ param_defaults: tuple[ASTNode | None, ...]
21
+ param_type_hints: tuple[str | None, ...]
22
+ declaration: MethodDef | None = None
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class BoundMethodArgs:
27
+ """Non-receiver arguments in parameter and evaluation order."""
28
+
29
+ args_by_param: tuple[ASTNode, ...]
30
+ evaluation_order: tuple[ASTNode, ...]
31
+
32
+
33
+ class MethodBindError(ValueError):
34
+ pass
35
+
36
+
37
+ def signature_from_method_def(node: MethodDef) -> MethodSignature:
38
+ annotations = node.annotations or {}
39
+ defaults = list(annotations.get("param_defaults", ()))
40
+ hints = list(annotations.get("param_type_hints", ()))
41
+ while len(defaults) < len(node.params):
42
+ defaults.append(None)
43
+ while len(hints) < len(node.params):
44
+ hints.append(None)
45
+ return MethodSignature(
46
+ key=f"{node.type_name}.{node.name}",
47
+ param_names=tuple(node.params[1:]),
48
+ param_defaults=tuple(defaults[1:len(node.params)]),
49
+ param_type_hints=tuple(hints[1:len(node.params)]),
50
+ declaration=node,
51
+ )
52
+
53
+
54
+ def signature_from_callable(
55
+ key: str,
56
+ param_names: list[str] | tuple[str, ...],
57
+ param_defaults: list[ASTNode | None] | tuple[ASTNode | None, ...],
58
+ param_type_hints: list[str | None] | tuple[str | None, ...] = (),
59
+ ) -> MethodSignature:
60
+ """Build a non-receiver signature from an analyzed method FuncInfo."""
61
+
62
+ names = tuple(param_names)
63
+ defaults = list(param_defaults)
64
+ hints = list(param_type_hints)
65
+ while len(defaults) < len(names):
66
+ defaults.append(None)
67
+ while len(hints) < len(names):
68
+ hints.append(None)
69
+ return MethodSignature(
70
+ key=key,
71
+ param_names=names,
72
+ param_defaults=tuple(defaults[:len(names)]),
73
+ param_type_hints=tuple(hints[:len(names)]),
74
+ )
75
+
76
+
77
+ def inventory_method_signatures(program: Program) -> dict[str, MethodSignature]:
78
+ """Inventory direct method declarations without analyzing their bodies."""
79
+
80
+ result: dict[str, MethodSignature] = {}
81
+ for stmt in program.body:
82
+ if isinstance(stmt, MethodDef):
83
+ signature = signature_from_method_def(stmt)
84
+ # Preserve the analyzer's historical first-declaration lookup. This
85
+ # prepass does not introduce method overloading or duplicate policy.
86
+ result.setdefault(signature.key, signature)
87
+ return result
88
+
89
+
90
+ def _written_actuals(call: FuncCall) -> list[ASTNode]:
91
+ """Recover written order lost by the parser's args/kwargs split."""
92
+
93
+ fallback = [*call.args, *call.kwargs.values()]
94
+ recorded = (call.annotations or {}).get("call_arg_order", ())
95
+ if (
96
+ len(recorded) == len(fallback)
97
+ and {id(node) for node in recorded} == {id(node) for node in fallback}
98
+ ):
99
+ return list(recorded)
100
+ if not fallback or any(getattr(node, "loc", None) is None for node in fallback):
101
+ return fallback
102
+ indexed = list(enumerate(fallback))
103
+ indexed.sort(
104
+ key=lambda item: (
105
+ item[1].loc.line,
106
+ item[1].loc.col,
107
+ item[0],
108
+ )
109
+ )
110
+ return [node for _index, node in indexed]
111
+
112
+
113
+ def bind_method_call(
114
+ signature: MethodSignature,
115
+ call: FuncCall,
116
+ ) -> BoundMethodArgs:
117
+ """Bind one method call strictly, excluding the receiver argument."""
118
+
119
+ names = signature.param_names
120
+ if len(call.args) > len(names):
121
+ raise MethodBindError(
122
+ f"{signature.key}: too many positional arguments "
123
+ f"(expected {len(names)}, got {len(call.args)})"
124
+ )
125
+
126
+ for name in call.kwargs:
127
+ if name not in names:
128
+ raise MethodBindError(
129
+ f"{signature.key}: unknown keyword argument '{name}'"
130
+ )
131
+
132
+ bound: list[ASTNode | None] = [None] * len(names)
133
+ for index, value in enumerate(call.args):
134
+ bound[index] = value
135
+ for name, value in call.kwargs.items():
136
+ index = names.index(name)
137
+ if bound[index] is not None:
138
+ raise MethodBindError(
139
+ f"{signature.key}: argument '{name}' passed both "
140
+ "positionally and by keyword"
141
+ )
142
+ bound[index] = value
143
+
144
+ inserted_defaults: list[ASTNode] = []
145
+ for index, name in enumerate(names):
146
+ if bound[index] is not None:
147
+ continue
148
+ default = (
149
+ signature.param_defaults[index]
150
+ if index < len(signature.param_defaults)
151
+ else None
152
+ )
153
+ if default is None:
154
+ raise MethodBindError(
155
+ f"{signature.key}: missing required argument '{name}'"
156
+ )
157
+ bound[index] = default
158
+ inserted_defaults.append(default)
159
+
160
+ return BoundMethodArgs(
161
+ args_by_param=tuple(value for value in bound if value is not None),
162
+ evaluation_order=tuple([*_written_actuals(call), *inserted_defaults]),
163
+ )
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
  import re
15
15
 
16
16
  from .lexer import Token, TokenType
17
- from .errors import SourceLocation
17
+ from .errors import CompileError, Diagnostic, Level, Phase, SourceLocation
18
18
  from .ast_nodes import (
19
19
  ASTNode,
20
20
  Program, StrategyDecl, ImportStmt,
@@ -56,6 +56,7 @@ class Parser:
56
56
  self.pos = 0
57
57
  self._source = source
58
58
  self._filename = filename
59
+ self._recovery_count = 0
59
60
 
60
61
  # ------------------------------------------------------------------
61
62
  # Helpers
@@ -143,6 +144,9 @@ class Parser:
143
144
  self._recover()
144
145
  self._skip_newlines()
145
146
 
147
+ if self._recovery_count:
148
+ prog.annotations = dict(prog.annotations or {})
149
+ prog.annotations["parse_recovery_count"] = self._recovery_count
146
150
  return prog
147
151
 
148
152
  def _extract_version(self) -> int | None:
@@ -156,6 +160,7 @@ class Parser:
156
160
 
157
161
  def _recover(self) -> None:
158
162
  """Skip tokens until next NEWLINE or EOF for error recovery."""
163
+ self._recovery_count += 1
159
164
  while not self._at_end() and not self._check(TokenType.NEWLINE):
160
165
  self._advance()
161
166
  if self._check(TokenType.NEWLINE):
@@ -400,10 +405,13 @@ class Parser:
400
405
  start_tok = self._advance() # consume 'strategy' or 'indicator'
401
406
  # Parse arguments as a function call, then convert to StrategyDecl
402
407
  self._consume(TokenType.LPAREN)
403
- args, kwargs = self._parse_call_args()
408
+ args, kwargs, call_arg_order = self._parse_call_args()
404
409
  self._consume(TokenType.RPAREN)
405
410
  node = StrategyDecl(args=args, kwargs=kwargs)
406
- node.annotations = {"decl_kind": start_tok.value}
411
+ node.annotations = {
412
+ "decl_kind": start_tok.value,
413
+ "call_arg_order": call_arg_order,
414
+ }
407
415
  return self._set_loc(node, start_tok)
408
416
 
409
417
  def _parse_import_stmt(self) -> ImportStmt:
@@ -786,8 +794,16 @@ class Parser:
786
794
  start_tok = self._advance() # consume 'method'
787
795
  name = self._consume(TokenType.IDENT).value
788
796
  self._consume(TokenType.LPAREN)
789
- # First param is the type + self: TypeName self
790
- type_name = self._consume(TokenType.IDENT).value
797
+ # First param is the required receiver type + self name. Receiver
798
+ # types use the same complete Pine hint grammar as ordinary typed
799
+ # parameters: primitives lex as TYPE_* tokens, while collections may
800
+ # carry nested ``<...>`` arguments or postfix ``[]``. Consuming only
801
+ # IDENT here silently dropped valid declarations such as
802
+ # ``method id(int self)`` and ``method push(array<int> self, ...)``
803
+ # through the parser's top-level recovery path.
804
+ if self._current().type not in TYPE_KEYWORDS | {TokenType.IDENT}:
805
+ self._consume(TokenType.IDENT) # raises a located ParseError
806
+ type_name = self._parse_type_hint_string()
791
807
  params = [self._consume(TokenType.IDENT).value] # 'self' or user's name
792
808
  param_type_hints = [type_name]
793
809
  # Preserve per-param default expressions so codegen can substitute
@@ -1212,15 +1228,17 @@ class Parser:
1212
1228
  """Parse (args, kwargs) after callee expression."""
1213
1229
  start_tok = self._current()
1214
1230
  self._consume(TokenType.LPAREN)
1215
- args, kwargs = self._parse_call_args()
1231
+ args, kwargs, call_arg_order = self._parse_call_args()
1216
1232
  self._consume(TokenType.RPAREN)
1217
1233
  node = FuncCall(callee=callee, args=args, kwargs=kwargs)
1234
+ node.annotations = {"call_arg_order": call_arg_order}
1218
1235
  return self._set_loc(node, start_tok)
1219
1236
 
1220
- def _parse_call_args(self) -> tuple[list, dict]:
1237
+ def _parse_call_args(self) -> tuple[list, dict, list]:
1221
1238
  """Parse function call arguments and keyword arguments."""
1222
1239
  args: list = []
1223
1240
  kwargs: dict = {}
1241
+ call_arg_order: list = []
1224
1242
 
1225
1243
  while not self._check(TokenType.RPAREN) and not self._at_end():
1226
1244
  # Detect kwargs: IDENT = value (but not IDENT == value)
@@ -1228,15 +1246,36 @@ class Parser:
1228
1246
  and self._peek().type == TokenType.EQUALS
1229
1247
  and self._peek(2).type != TokenType.EQUALS):
1230
1248
  key_tok = self._advance()
1249
+ if key_tok.value in kwargs:
1250
+ raise CompileError(
1251
+ [
1252
+ Diagnostic(
1253
+ level=Level.ERROR,
1254
+ phase=Phase.PARSER,
1255
+ location=self._loc(key_tok),
1256
+ message=(
1257
+ "duplicate keyword argument "
1258
+ f"'{key_tok.value}'"
1259
+ ),
1260
+ hint=(
1261
+ f"Remove one '{key_tok.value}=' binding; "
1262
+ "a keyword argument may be specified only once."
1263
+ ),
1264
+ )
1265
+ ]
1266
+ )
1231
1267
  self._advance() # consume =
1232
1268
  val = self._parse_expression()
1233
1269
  kwargs[key_tok.value] = val
1270
+ call_arg_order.append(val)
1234
1271
  else:
1235
- args.append(self._parse_expression())
1272
+ val = self._parse_expression()
1273
+ args.append(val)
1274
+ call_arg_order.append(val)
1236
1275
 
1237
1276
  self._match(TokenType.COMMA)
1238
1277
 
1239
- return args, kwargs
1278
+ return args, kwargs, call_arg_order
1240
1279
 
1241
1280
  # -- Primary expressions --
1242
1281
 
@@ -229,7 +229,7 @@ _math("round",
229
229
  _sig([("x", F)], ret=I), # round(x) → int
230
230
  _sig([("x", F), ("precision", I)])) # round(x, n) → float
231
231
  _math("round_to_mintick", _sig([("x", F)]))
232
- _math("sign", _sig([("x", F)], ret=I))
232
+ _math("sign", _sig([("x", F)], ret=F))
233
233
  _math("max",
234
234
  _sig([("x", F), ("y", F)]), # 2-arg
235
235
  _sig([("x", F), ("y", F), ("z", F)]), # 3-arg
@@ -307,13 +307,13 @@ _strat("exit", _sig([
307
307
  _strat("close", _sig([
308
308
  ("id", S), ("comment", S, None),
309
309
  ("qty", F, None), ("qty_percent", F, None),
310
- ("alert_message", S, None), ("disable_alert", B, None),
311
- ("immediately", B, False),
310
+ ("alert_message", S, None), ("immediately", B, False),
311
+ ("disable_alert", B, None),
312
312
  ], ret=VOID))
313
313
 
314
314
  _strat("close_all", _sig([
315
315
  ("comment", S, None), ("alert_message", S, None),
316
- ("disable_alert", B, None), ("immediately", B, False),
316
+ ("immediately", B, False), ("disable_alert", B, None),
317
317
  ], ret=VOID))
318
318
 
319
319
  _strat("cancel", _sig([("id", S)], ret=VOID))
@@ -10,14 +10,11 @@ Buckets:
10
10
  * HARD_REJECT_FUNC / HARD_REJECT_NAMESPACE - calls that have no PineForge
11
11
  semantics at all (e.g. ``request.financial``, ``ticker.*``).
12
12
  * DIVERGENT_VARS - built-in variables whose PineForge value diverges from
13
- TradingView. Most are reported as WARNING (e.g. ``bar_index`` depends on the
14
- data window, ``timenow`` is not wall-clock) — these often appear in visual or
15
- logging code that does not affect trade outcomes. A subset
16
- (DIVERGENT_VARS_ERROR: ``last_bar_index`` aliased to the *current* bar index,
17
- ``time_close`` aliased to the bar *open* timestamp) are silent MIS-ALIASES:
18
- they produce a plausible-looking but wrong value that flows straight into
19
- trade logic, so a backtest would be silently wrong. Those are escalated to
20
- ERROR (rejected) rather than merely warned.
13
+ TradingView. They are reported as WARNING (e.g. ``bar_index`` and
14
+ ``last_bar_index`` depend on the fed data window, ``timenow`` is not
15
+ wall-clock) — these often appear in visual or logging code that does not
16
+ affect trade outcomes. DIVERGENT_VARS_ERROR is reserved for silent
17
+ mis-aliases severe enough to reject; it is currently empty.
21
18
  * NOT_YET - calls the runtime could support but the transpiler does not yet
22
19
  emit (e.g. ``max_bars_back``, bare ``barssince``).
23
20
  * request.security - only ``symbol`` / ``timeframe`` / ``expression`` allowed,
@@ -81,6 +78,9 @@ SUPPORTED_INPUT: frozenset[str] = frozenset(sigs.INPUT_FUNCTIONS)
81
78
  SUPPORTED_ARRAY: frozenset[str] = frozenset(set(ARRAY_METHODS) | {"new", "new_float", "new_int", "new_bool", "new_string", "from"} | set(ARRAY_DRAWING_NEW_CTORS))
82
79
  SUPPORTED_MAP: frozenset[str] = frozenset(set(MAP_METHODS) | {"new"})
83
80
  SUPPORTED_MATRIX: frozenset[str] = frozenset(set(MATRIX_METHODS) | {"new"})
81
+ _SUPPORTED_MAP_VALUE_TYPES: frozenset[str] = frozenset(
82
+ {"float", "int", "bool", "string"}
83
+ )
84
84
  SUPPORTED_SYMINFO: frozenset[str] = frozenset(SYMINFO_MEMBER_MAP)
85
85
  # Drawing-objects-as-data (spec §4.5). Geometry methods are REAL (route to the
86
86
  # per-type arena); *_NOOP visual setters are accepted no-ops (Level.WARNING).
@@ -174,11 +174,11 @@ HARD_REJECT_NAMESPACE: dict[str, str] = {
174
174
  # logging or visual logic that does not affect trade outcomes. The checker still
175
175
  # flags divergence so users see the risk.
176
176
  #
177
- # DIVERGENT_VARS_ERROR is a SUBSET that is escalated to ERROR (rejected): these
178
- # are silent MIS-ALIASES, not merely data-window divergences. They return a
179
- # plausible value that is the WRONG quantity (last_bar_index -> current bar
180
- # index) and that value flows directly into trade logic, so the backtest would
181
- # be silently wrong. A WARNING is not enough.
177
+ # DIVERGENT_VARS_ERROR is a SUBSET reserved for silent MIS-ALIASES that must be
178
+ # escalated to ERROR (rejected), rather than merely warned. It is currently
179
+ # empty: last_bar_index lowers to the true final index of the fed data window,
180
+ # but that index can still differ from TradingView when the window does not
181
+ # cover the same chart history.
182
182
  #
183
183
  # NOTE: the bare ``time_close`` variable is NOT divergent — codegen lowers it to
184
184
  # the engine's ``time_close()`` accessor, which returns the true bar-close
@@ -188,20 +188,32 @@ HARD_REJECT_NAMESPACE: dict[str, str] = {
188
188
  # separate supported builtin handled in visit_call.)
189
189
  DIVERGENT_VARS: dict[str, str] = {
190
190
  "bar_index": "bar_index depends on the data window; PineForge and TradingView produce different values for the same script.",
191
- "last_bar_index": "last_bar_index is aliased to the CURRENT bar index in PineForge codegen (not the index of the last bar); backtest would be silently wrong — rejected.",
191
+ "last_bar_index": "last_bar_index is the final index of PineForge's fed data window; it can diverge from TradingView when the window does not cover the same chart history.",
192
192
  "timenow": "timenow is aliased to the current bar timestamp in PineForge; it is not real wall-clock time.",
193
193
  }
194
194
 
195
195
  # Subset of DIVERGENT_VARS escalated from WARNING to ERROR (see comment above).
196
- DIVERGENT_VARS_ERROR: frozenset[str] = frozenset({"last_bar_index"})
196
+ DIVERGENT_VARS_ERROR: frozenset[str] = frozenset()
197
197
 
198
198
  BARSTATE_APPROX_VARS: dict[str, str] = {
199
- "barstate.islast": "barstate.islast is always false in PineForge batch backtests.",
199
+ "barstate.islast": (
200
+ "In direct batch chart-scope evaluation, barstate.islast is true only "
201
+ "on the final bar of PineForge's fed chart-data window. Inside a "
202
+ "request.security() history index, PineForge currently selects the "
203
+ "false branch; requested-context last-bar state is not modeled."
204
+ ),
200
205
  "barstate.ishistory": "barstate.ishistory is always true in PineForge batch backtests.",
201
206
  "barstate.isrealtime": "barstate.isrealtime is always false in PineForge batch backtests.",
202
207
  "barstate.isnew": "barstate.isnew follows PineForge first-tick execution state.",
203
208
  "barstate.isconfirmed": "barstate.isconfirmed follows PineForge last-tick execution state.",
204
- "barstate.islastconfirmedhistory": "barstate.islastconfirmedhistory is always false in PineForge batch backtests.",
209
+ "barstate.islastconfirmedhistory": (
210
+ "In direct historical batch chart-scope evaluation, PineForge "
211
+ "approximates barstate.islastconfirmedhistory as true only on the "
212
+ "final bar of the fed chart-data window. Inside a request.security() "
213
+ "history index, PineForge currently selects the false branch; "
214
+ "TradingView's requested-context and realtime-boundary semantics are "
215
+ "not modeled."
216
+ ),
205
217
  }
206
218
 
207
219
  STRATEGY_UNSUPPORTED_PARAMS: dict[str, set[str]] = {
@@ -209,9 +221,13 @@ STRATEGY_UNSUPPORTED_PARAMS: dict[str, set[str]] = {
209
221
  "order": {"comment", "alert_message", "disable_alert", "qty_type"},
210
222
  "exit": {"comment_profit", "comment_loss", "comment_trailing", "alert_message", "alert_profit", "alert_loss", "alert_trailing", "disable_alert"},
211
223
  "close": {"alert_message", "disable_alert"},
212
- "close_all": {"comment", "alert_message", "disable_alert", "immediately"},
224
+ "close_all": {"alert_message", "disable_alert"},
213
225
  }
214
226
 
227
+ # Alert delivery remains outside the backtest runtime contract. Keep these
228
+ # parameters warning-only so strategies that use webhook metadata still run;
229
+ # a follow-up must plumb alert events before codegen may claim support.
230
+
215
231
  # strategy.closedtrades / strategy.opentrades accessor surfaces are NOT
216
232
  # symmetric in Pine v6. opentrades has no exit_* fields (a trade has not
217
233
  # closed yet). Both lack ``direction`` (Pine has ``size`` whose sign carries
@@ -461,6 +477,12 @@ class SupportChecker:
461
477
  # shapes before codegen.
462
478
  self._udt_drawing_fields: dict[str, set[str]] = {}
463
479
  self._var_udt_types: dict[str, str] = {}
480
+ # PineMap IDs require runtime snapshots during COOF rollback. History
481
+ # buffers and generic matrices still value-copy their elements, so
482
+ # map-bearing shapes that would silently retain live aliases are gated
483
+ # until those containers have recursive checkpoint adapters.
484
+ self._udt_field_type_names: dict[str, dict[str, str]] = {}
485
+ self._map_bearing_udts: set[str] = set()
464
486
  # Names (vars and function params) declared as a scalar visual-container
465
487
  # type (table/box/line/label/linefill). A method call on one of these
466
488
  # (``panel.cell(...)``) is a visual sink whose args may carry visual
@@ -515,6 +537,10 @@ class SupportChecker:
515
537
  for stmt in ast.body:
516
538
  if isinstance(stmt, TypeDecl):
517
539
  self._user_types.add(stmt.name)
540
+ self._udt_field_type_names[stmt.name] = {
541
+ field.name: str(field.type_name or "").replace(" ", "")
542
+ for field in stmt.fields
543
+ }
518
544
  drawing_fields = {
519
545
  field.name
520
546
  for field in stmt.fields
@@ -536,6 +562,140 @@ class SupportChecker:
536
562
  self._user_methods.add(stmt.name)
537
563
  self._collect_visual_container_params(stmt)
538
564
 
565
+ # Resolve direct and transitively nested map-bearing UDTs. Pine type
566
+ # declarations are normally dependency ordered, but the fixed point
567
+ # also keeps this guard deterministic for synthetic test ASTs.
568
+ changed = True
569
+ while changed:
570
+ changed = False
571
+ for udt_name, fields in self._udt_field_type_names.items():
572
+ bearing_fields = {
573
+ field_name
574
+ for field_name, type_name in fields.items()
575
+ if "map<" in type_name
576
+ or any(
577
+ type_name == nested
578
+ or f"<{nested}>" in type_name
579
+ or f"<{nested}," in type_name
580
+ or f",{nested}>" in type_name
581
+ for nested in self._map_bearing_udts
582
+ )
583
+ }
584
+ if bearing_fields:
585
+ if udt_name not in self._map_bearing_udts:
586
+ self._map_bearing_udts.add(udt_name)
587
+ changed = True
588
+
589
+ # Constructor checks alone are insufficient: a typed ``na``
590
+ # declaration, UDT field, or unused callable parameter can introduce
591
+ # the same unsupported runtime shape without ever calling map.new() or
592
+ # matrix.new(). Validate every declared type boundary after the UDT
593
+ # fixed point is known so unsupported shapes fail with a Pine source
594
+ # diagnostic instead of a generated-C++ static_assert.
595
+ for stmt in ast.body:
596
+ if isinstance(stmt, TypeDecl):
597
+ for field in stmt.fields:
598
+ self._validate_declared_type_hint(
599
+ stmt,
600
+ field.type_name,
601
+ context=f"field '{stmt.name}.{field.name}'",
602
+ )
603
+ elif isinstance(stmt, (FuncDef, MethodDef)):
604
+ hints = (
605
+ (getattr(stmt, "annotations", None) or {})
606
+ .get("param_type_hints")
607
+ or []
608
+ )
609
+ for index, param_name in enumerate(stmt.params):
610
+ hint = hints[index] if index < len(hints) else None
611
+ if hint:
612
+ self._validate_declared_type_hint(
613
+ stmt,
614
+ hint,
615
+ context=f"parameter '{param_name}'",
616
+ )
617
+
618
+ @staticmethod
619
+ def _split_declared_type_args(text: str) -> list[str]:
620
+ """Split ``K,V`` while preserving nested generic arguments."""
621
+ args: list[str] = []
622
+ current: list[str] = []
623
+ depth = 0
624
+ for char in text:
625
+ if char == "<":
626
+ depth += 1
627
+ elif char == ">":
628
+ depth -= 1
629
+ if char == "," and depth == 0:
630
+ args.append("".join(current))
631
+ current = []
632
+ else:
633
+ current.append(char)
634
+ if current:
635
+ args.append("".join(current))
636
+ return args
637
+
638
+ def _validate_declared_type_hint(
639
+ self,
640
+ node: ASTNode,
641
+ type_name: str | None,
642
+ *,
643
+ context: str,
644
+ ) -> None:
645
+ """Fail closed for runtime collection limits at every type boundary."""
646
+ if not type_name:
647
+ return
648
+ compact = str(type_name).replace(" ", "")
649
+ if compact.endswith("[]"):
650
+ compact = f"array<{compact[:-2]}>"
651
+
652
+ if compact.startswith("map<") and compact.endswith(">"):
653
+ parts = self._split_declared_type_args(compact[4:-1])
654
+ if len(parts) != 2:
655
+ return
656
+ key_type, value_type = parts
657
+ if key_type != "string":
658
+ self._err(
659
+ node,
660
+ f"{context}: map keys must be string in PineForge's "
661
+ "supported map subset.",
662
+ )
663
+ if value_type not in _SUPPORTED_MAP_VALUE_TYPES:
664
+ self._err(
665
+ node,
666
+ f"{context}: map values must be primitive in PineForge's "
667
+ "supported map subset.",
668
+ )
669
+ return
670
+
671
+ if compact.startswith("matrix<") and compact.endswith(">"):
672
+ element_type = compact[len("matrix<"):-1]
673
+ if element_type in self._map_bearing_udts:
674
+ self._err(
675
+ node,
676
+ f"matrix<{element_type}> is not supported when the UDT "
677
+ "contains a map field; rollback would retain live map "
678
+ "aliases.",
679
+ hint="Use array<UDT> for recursively checkpointed state.",
680
+ )
681
+ return
682
+ if "<" in element_type:
683
+ self._err(
684
+ node,
685
+ f"matrix<{element_type}>: nested collection element types "
686
+ "not supported in v1.",
687
+ )
688
+ return
689
+
690
+ # Arrays are recursively checkpointed and may contain valid maps, but
691
+ # the nested map still has the same key/value boundary.
692
+ if compact.startswith("array<") and compact.endswith(">"):
693
+ self._validate_declared_type_hint(
694
+ node,
695
+ compact[len("array<"):-1],
696
+ context=context,
697
+ )
698
+
539
699
  def _collect_visual_container_params(self, fn) -> None:
540
700
  """Register a function's parameters that are declared with a scalar
541
701
  visual-container type (``table panel``, ``line ln``) so method calls on
@@ -814,6 +974,12 @@ class SupportChecker:
814
974
  # so a direct ``dash.cell(..., text.align_left)`` is treated as a visual
815
975
  # sink (mirrors the table/box/line/label/linefill PARAM tracking).
816
976
  decl_hint = str(node.type_hint).replace(" ", "") if node.type_hint else None
977
+ if decl_hint:
978
+ self._validate_declared_type_hint(
979
+ node,
980
+ decl_hint,
981
+ context=f"variable '{node.name}'",
982
+ )
817
983
  if decl_hint in _VISUAL_CONTAINER_TYPES:
818
984
  self._visual_container_vars.add(node.name)
819
985
  elif isinstance(node.value, FuncCall):
@@ -1160,7 +1326,7 @@ class SupportChecker:
1160
1326
  targs = [str(t).replace(" ", "") for t in targs]
1161
1327
  if targs and targs[0] != "string":
1162
1328
  self._err(node, "map keys must be string in PineForge's supported map subset.")
1163
- if len(targs) > 1 and targs[1] not in {"float", "int", "bool", "string"}:
1329
+ if len(targs) > 1 and targs[1] not in _SUPPORTED_MAP_VALUE_TYPES:
1164
1330
  self._err(node, "map values must be primitive in PineForge's supported map subset.")
1165
1331
  if ns == "matrix" and name == "new":
1166
1332
  targs = (getattr(node.callee, "annotations", None) or {}).get("template_args") or []
@@ -1172,6 +1338,15 @@ class SupportChecker:
1172
1338
  self._visit_children(node)
1173
1339
  return
1174
1340
  allowed_prim = {"float", "int", "bool", "string", "color"}
1341
+ if t in self._map_bearing_udts:
1342
+ self._err(
1343
+ node,
1344
+ f"matrix<{t}> is not supported when the UDT contains "
1345
+ "a map field; rollback would retain live map aliases.",
1346
+ hint="Use array<UDT> for recursively checkpointed state.",
1347
+ )
1348
+ self._visit_children(node)
1349
+ return
1175
1350
  if t not in allowed_prim and t not in self._user_types:
1176
1351
  self._err(node, f"matrix<{t}> element type not supported. Allowed: float, int, bool, string, color, or a declared UDT.")
1177
1352
  self._visit_children(node)