@pineforge/codegen-pyodide 0.7.0

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 (37) hide show
  1. package/README.md +25 -0
  2. package/index.mjs +20 -0
  3. package/package.json +32 -0
  4. package/pineforge_codegen/__init__.py +53 -0
  5. package/pineforge_codegen/analyzer/__init__.py +60 -0
  6. package/pineforge_codegen/analyzer/base.py +1566 -0
  7. package/pineforge_codegen/analyzer/call_handlers.py +895 -0
  8. package/pineforge_codegen/analyzer/contracts.py +163 -0
  9. package/pineforge_codegen/analyzer/diagnostics.py +118 -0
  10. package/pineforge_codegen/analyzer/tables.py +204 -0
  11. package/pineforge_codegen/analyzer/types.py +261 -0
  12. package/pineforge_codegen/ast_nodes.py +293 -0
  13. package/pineforge_codegen/codegen/__init__.py +78 -0
  14. package/pineforge_codegen/codegen/base.py +1381 -0
  15. package/pineforge_codegen/codegen/emit_top.py +875 -0
  16. package/pineforge_codegen/codegen/helpers.py +163 -0
  17. package/pineforge_codegen/codegen/helpers_syminfo.py +132 -0
  18. package/pineforge_codegen/codegen/input.py +189 -0
  19. package/pineforge_codegen/codegen/security.py +1564 -0
  20. package/pineforge_codegen/codegen/ta.py +298 -0
  21. package/pineforge_codegen/codegen/tables.py +683 -0
  22. package/pineforge_codegen/codegen/types.py +592 -0
  23. package/pineforge_codegen/codegen/visit_call.py +1387 -0
  24. package/pineforge_codegen/codegen/visit_expr.py +729 -0
  25. package/pineforge_codegen/codegen/visit_stmt.py +766 -0
  26. package/pineforge_codegen/errors.py +98 -0
  27. package/pineforge_codegen/lexer.py +531 -0
  28. package/pineforge_codegen/parser.py +1198 -0
  29. package/pineforge_codegen/pragmas.py +117 -0
  30. package/pineforge_codegen/signatures.py +808 -0
  31. package/pineforge_codegen/support_checker.py +1223 -0
  32. package/pineforge_codegen/symbols.py +118 -0
  33. package/pineforge_codegen/tokens.py +406 -0
  34. package/pineforge_codegen/tv_input_choices.py +86 -0
  35. package/pineforge_codegen-0.7.0.tar.gz +0 -0
  36. package/release.json +7 -0
  37. package/tables.json +1653 -0
@@ -0,0 +1,163 @@
1
+ """Pure-utility codegen helpers.
2
+
3
+ Mixin holding stateless / near-stateless name-mangling and AST-walk
4
+ helpers used everywhere in the codegen. Lives here so the heavier
5
+ visitor / emitter mixins can depend on it without owning its
6
+ implementation. Keep this module free of imports from any other
7
+ ``codegen/*`` submodule so it stays at the bottom of the dependency
8
+ graph.
9
+
10
+ Mixin contract: ``NamingHelper`` reads at most one piece of host state,
11
+ ``self._all_member_names`` (used by ``_func_safe_name``). The class
12
+ mixing this in (``CodeGen``) sets that attribute in its constructor.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from ..ast_nodes import (
18
+ Identifier, MemberAccess,
19
+ VarDecl, Assignment, TupleAssign, ForStmt, ForInStmt,
20
+ )
21
+
22
+
23
+ # C++ reserved names that conflict with PineScript identifiers when used
24
+ # verbatim as variable names. Carried forward from the historic codegen.py
25
+ # table; intentionally narrower than the full C++ keyword set because Pine
26
+ # already reserves keywords like ``if``/``else``/``for``/``return`` so they
27
+ # can never reach codegen as identifier strings. Lives here (not base.py)
28
+ # so the mixin can read it without forcing an import cycle on ``base``.
29
+ CPP_RESERVED = {
30
+ "exp", "log", "abs", "max", "min", "and", "or", "not",
31
+ "int", "float", "bool", "string", "short", "long", "new", "delete",
32
+ "class", "struct", "return", "void", "auto", "const", "static",
33
+ }
34
+
35
+
36
+ class NamingHelper:
37
+ """Identifier escaping, callee resolution, and a generic AST walker.
38
+
39
+ Mixed into ``CodeGen``; not meant to be instantiated standalone.
40
+ Methods that need shared state (``_all_member_names``) document the
41
+ contract explicitly so substitution is safe."""
42
+
43
+ # Set by CodeGen.__init__; declared here only as documentation of the
44
+ # mixin contract. Stays a plain set; the host class owns the value.
45
+ _all_member_names: set[str]
46
+
47
+ @staticmethod
48
+ def _cpp_string_escape(s: str) -> str:
49
+ """Escape a Python string for embedding inside a C++ string literal."""
50
+ return (
51
+ s.replace("\\", "\\\\")
52
+ .replace('"', '\\"')
53
+ .replace("\n", "\\n")
54
+ .replace("\r", "\\r")
55
+ )
56
+
57
+ def _safe_name(self, name: str) -> str:
58
+ """Rename identifiers that collide with C++ reserved words."""
59
+ if name in CPP_RESERVED:
60
+ return f"_{name}_"
61
+ return name
62
+
63
+ def _func_safe_name(self, name: str) -> str:
64
+ """Prefix function names that collide with class members (series vars or var members)."""
65
+ safe = self._safe_name(name)
66
+ if safe in self._all_member_names:
67
+ return f"_fn_{safe}"
68
+ return safe
69
+
70
+ def _resolve_callee(self, callee) -> tuple[str | None, str | None]:
71
+ """Extract ``(func_name, namespace)`` from a callee expression.
72
+
73
+ - ``foo()`` -> ``("foo", None)``
74
+ - ``ns.foo()`` -> ``("foo", "ns")``
75
+ - ``strategy.risk.max_orders(...)`` -> ``("max_orders", "strategy")``
76
+ (only the outermost root namespace is reported; nested chains
77
+ collapse to the leftmost identifier — historical behavior).
78
+ - anything else -> ``(None, None)``"""
79
+ if isinstance(callee, Identifier):
80
+ return callee.name, None
81
+ if isinstance(callee, MemberAccess) and isinstance(callee.object, Identifier):
82
+ return callee.member, callee.object.name
83
+ if isinstance(callee, MemberAccess) and isinstance(callee.object, MemberAccess):
84
+ if isinstance(callee.object.object, Identifier):
85
+ return callee.member, callee.object.object.name
86
+ return None, None
87
+
88
+ def _get_target_name(self, target) -> str | None:
89
+ """Return the bare name of an assignment target, or ``None`` for non-trivial LHS."""
90
+ if isinstance(target, Identifier):
91
+ return target.name
92
+ return None
93
+
94
+ def _walk_ast(self, node):
95
+ """Yield every node in the subtree rooted at ``node`` (including ``node``).
96
+
97
+ Walks every attribute that historically holds an AST child or list
98
+ of children: ``body``/``else_body``/``cases``/``default_body``,
99
+ the standard binary/unary/ternary slots, ``args``, ``kwargs``,
100
+ and ``TypeDecl.fields[*].default``. Order is depth-first; the
101
+ visit order itself is not part of the public contract — only the
102
+ set of yielded nodes."""
103
+ if node is None:
104
+ return
105
+ yield node
106
+ for attr in ("body", "else_body"):
107
+ children = getattr(node, attr, None)
108
+ if isinstance(children, list):
109
+ for child in children:
110
+ yield from self._walk_ast(child)
111
+ if hasattr(node, "cases") and isinstance(node.cases, list):
112
+ for expr, stmts in node.cases:
113
+ if expr is not None:
114
+ yield from self._walk_ast(expr)
115
+ for child in stmts:
116
+ yield from self._walk_ast(child)
117
+ if hasattr(node, "default_body") and isinstance(node.default_body, list):
118
+ for child in node.default_body:
119
+ yield from self._walk_ast(child)
120
+ for attr in ("value", "target", "condition", "true_val", "false_val",
121
+ "left", "right", "object", "operand", "callee", "index",
122
+ "expr"):
123
+ child = getattr(node, attr, None)
124
+ if child is not None:
125
+ yield from self._walk_ast(child)
126
+ args = getattr(node, "args", None)
127
+ if isinstance(args, list):
128
+ for a in args:
129
+ yield from self._walk_ast(a)
130
+ kwargs = getattr(node, "kwargs", None)
131
+ if isinstance(kwargs, dict):
132
+ for v in kwargs.values():
133
+ yield from self._walk_ast(v)
134
+ fields = getattr(node, "fields", None)
135
+ if isinstance(fields, list):
136
+ for f in fields:
137
+ if hasattr(f, "default") and f.default is not None:
138
+ yield from self._walk_ast(f.default)
139
+
140
+ def _collect_binding_names(self, stmts) -> set[str]:
141
+ """Return every name bound by a statement in ``stmts`` (recursively):
142
+ ``var``/plain declarations, assignment targets, tuple-assign names, and
143
+ ``for`` loop variables. Used to teach the unknown-identifier guard
144
+ about ordinary function-local scalars, which are emitted inline and are
145
+ otherwise tracked nowhere (``func_var_members`` only carries vars that
146
+ become persistent struct members)."""
147
+ names: set[str] = set()
148
+ for stmt in stmts or []:
149
+ for n in self._walk_ast(stmt):
150
+ if isinstance(n, VarDecl) and n.name:
151
+ names.add(n.name)
152
+ elif isinstance(n, TupleAssign):
153
+ names.update(x for x in n.names if x)
154
+ elif isinstance(n, Assignment) and isinstance(n.target, Identifier):
155
+ names.add(n.target.name)
156
+ elif isinstance(n, ForStmt) and n.var:
157
+ names.add(n.var)
158
+ elif isinstance(n, ForInStmt):
159
+ if n.var:
160
+ names.add(n.var)
161
+ if n.vars:
162
+ names.update(x for x in n.vars if x)
163
+ return names
@@ -0,0 +1,132 @@
1
+ """Syminfo derivation helpers for PineForge codegen.
2
+
3
+ Emits C++ inline helper functions for syminfo fields that can be derived
4
+ at codegen/runtime from the existing SymInfo struct without requiring
5
+ engine changes:
6
+
7
+ - ``_pf_derive_main_tickerid(tickerid)`` — strip futures suffix from tickerid
8
+ e.g., ``"CME_MINI:ES1!"`` → ``"CME_MINI:ES"``, ``"NASDAQ:AAPL"`` → ``"NASDAQ:AAPL"``
9
+ - ``_pf_derive_country(tickerid)`` — lookup country by exchange prefix
10
+ e.g., ``"NASDAQ:AAPL"`` → ``"US"``, ``"LSE:BP"`` → ``"GB"`` (ISO 3166-1)
11
+
12
+ These are emitted as ``static inline`` free functions before the
13
+ ``GeneratedStrategy`` class definition. They depend only on ``<string>``
14
+ and ``<regex>`` (both already pulled in by the standard includes block).
15
+ """
16
+
17
+ # Prefix → country lookup table used for ``syminfo.country`` derivation.
18
+ # Mirrors Pine v6 semantics best-effort; not an exhaustive list. All values
19
+ # MUST be ISO 3166-1 alpha-2 codes (Pine returns ISO codes: LSE → "GB", not
20
+ # "UK"). Prefixes with no single ISO country (pan-European EURONEXT, global
21
+ # crypto venues BINANCE/KRAKEN/BYBIT/OKX/BITMEX/DERIBIT) are intentionally
22
+ # absent — the helper returns na<std::string>() for them, matching TV's na
23
+ # for symbols without a listing country.
24
+ PREFIX_TO_COUNTRY: dict[str, str] = {
25
+ "NASDAQ": "US",
26
+ "NYSE": "US",
27
+ "NYMEX": "US",
28
+ "AMEX": "US",
29
+ "ARCA": "US",
30
+ "CBOE": "US",
31
+ "CME": "US",
32
+ "CME_MINI": "US",
33
+ "CBOT": "US",
34
+ "COMEX": "US",
35
+ "OTC": "US",
36
+ "LSE": "GB",
37
+ "AQUIS": "GB",
38
+ "TSE": "JP",
39
+ "OSE": "JP",
40
+ "HKEX": "HK",
41
+ "SGX": "SG",
42
+ "ASX": "AU",
43
+ "XETRA": "DE",
44
+ "BSE": "IN",
45
+ "NSE": "IN",
46
+ "COINBASE": "US",
47
+ "UPBIT": "KR",
48
+ "KRX": "KR",
49
+ "KOSPI": "KR",
50
+ "SSE": "CN",
51
+ "SZSE": "CN",
52
+ "JSE": "ZA",
53
+ "BMF": "BR",
54
+ "BMFBOVESPA": "BR",
55
+ "B3": "BR",
56
+ "MOEX": "RU",
57
+ "TSX": "CA",
58
+ "VENTURE": "CA",
59
+ "SIX": "CH",
60
+ }
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # C++ code generation
64
+ # ---------------------------------------------------------------------------
65
+
66
+ # The futures-suffix regex: strips one or more digits at the end, optionally
67
+ # followed by a ``!``. Examples:
68
+ # CME_MINI:ES1! → CME_MINI:ES
69
+ # NYMEX:CL2! → NYMEX:CL
70
+ # NASDAQ:AAPL → NASDAQ:AAPL (no suffix, unchanged)
71
+ _MAIN_TICKERID_CPP = r"""
72
+ static inline std::string _pf_derive_prefix(const std::string& tickerid) {
73
+ std::size_t colon = tickerid.find(':');
74
+ return (colon == std::string::npos) ? tickerid : tickerid.substr(0, colon);
75
+ }
76
+
77
+ static inline std::string _pf_derive_main_tickerid(const std::string& tickerid) {
78
+ // Strip trailing digits (optionally followed by '!') from the symbol part.
79
+ // e.g. "CME_MINI:ES1!" -> "CME_MINI:ES", "NYMEX:CL2!" -> "NYMEX:CL"
80
+ std::string result = tickerid;
81
+ std::size_t colon = result.find(':');
82
+ std::size_t start = (colon == std::string::npos) ? 0 : colon + 1;
83
+ // Find end of base symbol (strip trailing digits + optional '!')
84
+ std::size_t end = result.size();
85
+ if (end > start && result[end - 1] == '!') {
86
+ --end;
87
+ }
88
+ while (end > start && std::isdigit((unsigned char)result[end - 1])) {
89
+ --end;
90
+ }
91
+ return result.substr(0, end);
92
+ }
93
+ """.strip()
94
+
95
+
96
+ def _build_country_lookup_cpp() -> str:
97
+ """Build the C++ prefix-to-country lookup table from PREFIX_TO_COUNTRY."""
98
+ entries = []
99
+ for prefix, country in sorted(PREFIX_TO_COUNTRY.items()):
100
+ entries.append(f' {{"{prefix}", "{country}"}}')
101
+ table = ",\n".join(entries)
102
+ return (
103
+ "static inline std::string _pf_derive_country(const std::string& tickerid) {\n"
104
+ " // Lookup country by exchange prefix (text before ':').\n"
105
+ " std::size_t colon = tickerid.find(':');\n"
106
+ " std::string prefix = (colon == std::string::npos)\n"
107
+ " ? tickerid : tickerid.substr(0, colon);\n"
108
+ " static const std::unordered_map<std::string, std::string> _tbl = {\n"
109
+ f"{table}\n"
110
+ " };\n"
111
+ " auto it = _tbl.find(prefix);\n"
112
+ ' return (it != _tbl.end()) ? it->second : na<std::string>();\n'
113
+ "}\n"
114
+ )
115
+
116
+
117
+ def emit_syminfo_helpers() -> list[str]:
118
+ """Return list of C++ lines for the syminfo derivation helper functions.
119
+
120
+ Call this from ``_emit_includes`` (after the ``using namespace pineforge;``
121
+ line) to inject the helpers before the ``GeneratedStrategy`` class.
122
+ """
123
+ lines: list[str] = []
124
+ lines.append("// --- syminfo derivation helpers (PineForge G2) ---")
125
+ for cpp_line in _MAIN_TICKERID_CPP.splitlines():
126
+ lines.append(cpp_line)
127
+ lines.append("")
128
+ for cpp_line in _build_country_lookup_cpp().splitlines():
129
+ lines.append(cpp_line)
130
+ lines.append("// --- end syminfo derivation helpers ---")
131
+ lines.append("")
132
+ return lines
@@ -0,0 +1,189 @@
1
+ """Pine ``input.*`` call helpers for the codegen.
2
+
3
+ Mixin holding the family of ``_is_input_*`` / ``_get_input_*`` /
4
+ ``_input_type_to_getter`` / ``_enforce_enum_declared_before_input_enum``
5
+ methods. They share a small piece of state (``self._enum_defs``) and a
6
+ common dependency on the analyzer's signature registry.
7
+
8
+ Mixin contract — host class must provide:
9
+
10
+ - ``self._enum_defs`` (``dict[str, list[str]]``).
11
+ - ``self._resolve_callee`` (``NamingHelper``).
12
+ - ``self._visit_expr`` (visitor mixin, currently on ``base.py``;
13
+ used to render the title-arg fallback expression).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from ..ast_nodes import FuncCall, Identifier, MemberAccess, StringLiteral
19
+ from .. import signatures as sigs
20
+
21
+
22
+ class InputHelper:
23
+ """``input.*`` call analysis helpers — defaults, titles, getter dispatch, enum guard."""
24
+
25
+ def _is_input_call(self, node: FuncCall) -> bool:
26
+ """True if ``node`` is an ``input(...)`` or ``input.<type>(...)`` call."""
27
+ func_name, namespace = self._resolve_callee(node.callee)
28
+ return self._is_input_call_by_name(func_name, namespace)
29
+
30
+ @staticmethod
31
+ def _is_input_call_by_name(func_name: str | None, namespace: str | None) -> bool:
32
+ """Stateless variant: classify a ``(func_name, namespace)`` pair as input call.
33
+
34
+ Used during prescan when only the resolved callee tuple is
35
+ available (no FuncCall node)."""
36
+ if func_name == "input" and namespace is None:
37
+ return True
38
+ if namespace == "input":
39
+ return True
40
+ return False
41
+
42
+ def _get_input_default(self, node: FuncCall):
43
+ """Pull the default-value AST node out of an ``input(...)`` / ``input.<t>(...)`` call.
44
+
45
+ Honors keyword arguments by merging them in declaration order via
46
+ the signatures registry; falls back to the first positional
47
+ argument or the ``defval=`` kwarg when the registry has no entry."""
48
+ func_name, namespace = self._resolve_callee(node.callee)
49
+ param_names = None
50
+ if namespace == "input" and func_name in sigs.INPUT_FUNCTIONS:
51
+ param_names = sigs.get_param_names("input", func_name)
52
+ elif func_name == "input" and namespace is None:
53
+ param_names = sigs.get_param_names(None, "input")
54
+ if param_names:
55
+ merged = list(node.args)
56
+ for i, pname in enumerate(param_names):
57
+ if pname in node.kwargs:
58
+ while len(merged) <= i:
59
+ merged.append(None)
60
+ if i >= len(merged) or merged[i] is None:
61
+ merged[i] = node.kwargs[pname]
62
+ while merged and merged[-1] is None:
63
+ merged.pop()
64
+ if merged and merged[0] is not None:
65
+ return merged[0]
66
+ return None
67
+ if node.args:
68
+ return node.args[0]
69
+ if "defval" in node.kwargs:
70
+ return node.kwargs["defval"]
71
+ return None
72
+
73
+ def _get_input_title(self, node: FuncCall, var_name: str | None = None) -> str:
74
+ """Pull the title string from an ``input(...)`` call.
75
+
76
+ Reads positional arg #2 or the ``title=`` kwarg via the
77
+ signatures registry. When the title isn't a ``StringLiteral``
78
+ (e.g. computed at runtime), the rendered C++ expression is
79
+ returned. Falls back to ``var_name`` when no title is provided
80
+ so generated UI labels still have a stable identity."""
81
+ func_name, namespace = self._resolve_callee(node.callee)
82
+ param_names = None
83
+ if namespace == "input" and func_name in sigs.INPUT_FUNCTIONS:
84
+ param_names = sigs.get_param_names("input", func_name)
85
+ elif func_name == "input" and namespace is None:
86
+ param_names = sigs.get_param_names(None, "input")
87
+ if param_names:
88
+ merged = list(node.args)
89
+ for i, pname in enumerate(param_names):
90
+ if pname in node.kwargs:
91
+ while len(merged) <= i:
92
+ merged.append(None)
93
+ if i >= len(merged) or merged[i] is None:
94
+ merged[i] = node.kwargs[pname]
95
+ if len(merged) > 1 and merged[1] is not None:
96
+ title_node = merged[1]
97
+ if isinstance(title_node, StringLiteral):
98
+ return title_node.value
99
+ return self._visit_expr(title_node)
100
+ return var_name if var_name else ""
101
+
102
+ # Native price series accepted as an input.source defval. The analyzer
103
+ # hard-rejects anything else (see support_checker), so a defval reaching
104
+ # codegen is one of these or a user-series identifier.
105
+ _NATIVE_SOURCE_SERIES = frozenset(
106
+ {"open", "high", "low", "close", "volume",
107
+ "hl2", "hlc3", "ohlc4", "hlcc4"}
108
+ )
109
+
110
+ @staticmethod
111
+ def _input_type_to_getter(func_name: str | None, namespace: str | None) -> str:
112
+ """Map an ``input.<type>`` call to its C++ runtime getter name.
113
+
114
+ ``input.float`` / ``input.price`` collapse to ``get_input_double``;
115
+ ``input.int`` / ``input.enum`` map to ``get_input_int`` (C++ int32);
116
+ ``input.color`` maps to ``get_input_int64`` (a packed ARGB color
117
+ ``0xAARRGGBB`` overflows signed int32); ``input.time`` maps to
118
+ ``get_input_int64`` because Pine v6 ``input.time`` returns a series
119
+ int Unix timestamp in MILLISECONDS, which overflows int32 for any
120
+ modern date. ``input.source`` is rendered separately by
121
+ ``_render_input_value`` (it returns a Series<double>&, not a scalar)
122
+ and never routes through this table. Bare ``input(...)`` and any
123
+ unrecognised variant default to double."""
124
+ if func_name in ("int",):
125
+ return "get_input_int"
126
+ if func_name in ("float", "source", "price"):
127
+ return "get_input_double"
128
+ if func_name in ("bool",):
129
+ return "get_input_bool"
130
+ if func_name in ("string", "timeframe", "session", "symbol", "text_area"):
131
+ return "get_input_string"
132
+ if func_name == "color":
133
+ return "get_input_int64"
134
+ if func_name == "time":
135
+ return "get_input_int64"
136
+ if func_name == "enum":
137
+ return "get_input_int"
138
+ return "get_input_double"
139
+
140
+ def _source_defval_to_base_series(self, default) -> str:
141
+ """Map an input.source defval (close/high/hl2/…) to its engine base
142
+ source series member (``_src_close_`` …). Falls back to
143
+ ``_src_close_`` for a non-native defval — unreachable once the
144
+ analyzer guard is in place, but keeps codegen total."""
145
+ name = default.name if isinstance(default, Identifier) else None
146
+ if name in self._NATIVE_SOURCE_SERIES:
147
+ return f"_src_{name}_"
148
+ return "_src_close_"
149
+
150
+ def _render_input_value(self, node: FuncCall, func_name: str | None,
151
+ namespace: str | None, title: str) -> str:
152
+ """Render the C++ value expression for an ``input.*`` call site.
153
+
154
+ ``input.source`` resolves to ``get_input_source("title", _src_<field>_)[0]``
155
+ — the engine returns the (optionally operator-overridden) native
156
+ series and we read its current value; a subscripted source var is
157
+ already tracked as a series var by the analyzer so ``src[1]`` lowers
158
+ to a Series subscript. Every other input type routes through the
159
+ scalar getter table."""
160
+ if namespace == "input" and func_name == "source":
161
+ default = self._get_input_default(node)
162
+ base = self._source_defval_to_base_series(default)
163
+ return f'get_input_source("{title}", {base})[0]'
164
+ default = self._get_input_default(node)
165
+ default_cpp = self._visit_expr(default) if default is not None else "0"
166
+ getter = self._input_type_to_getter(func_name, namespace)
167
+ return f'{getter}("{title}", {default_cpp})'
168
+
169
+ def _enforce_enum_declared_before_input_enum(self, node: FuncCall) -> None:
170
+ """Mirror the analyzer: ``input.enum(Enum.member)`` requires Enum to be defined above the call.
171
+
172
+ Codegen runs after the analyzer so the constraint should already
173
+ be enforced; we duplicate the check defensively because
174
+ violations would otherwise lead to a silent ``KeyError`` during
175
+ member resolution."""
176
+ dv = self._get_input_default(node)
177
+ if not isinstance(dv, MemberAccess) or not isinstance(dv.object, Identifier):
178
+ return
179
+ ename = dv.object.name
180
+ if ename not in self._enum_defs:
181
+ raise ValueError(
182
+ f"enum '{ename}' must be declared above input.enum() "
183
+ "(internal: Analyzer should reject this first)"
184
+ )
185
+ if dv.member not in self._enum_defs[ename]:
186
+ raise ValueError(
187
+ f"{ename}.{dv.member} is not a member of enum {ename} "
188
+ "(internal: Analyzer should reject this first)"
189
+ )