diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index 52106d3..67565db 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -343,6 +343,18 @@ def _record_global_binding_stmt(self, name: str, pine_type: PineType, if top_stmt is not None and (not info.source_stmts or info.source_stmts[-1] is not top_stmt): info.source_stmts.append(top_stmt) + @staticmethod + def _is_input_func_call(node: FuncCall) -> bool: + """True for an ``input(...)`` or ``input.(...)`` call.""" + callee = node.callee + if isinstance(callee, Identifier) and callee.name == "input": + return True + return ( + isinstance(callee, MemberAccess) + and isinstance(callee.object, Identifier) + and callee.object.name == "input" + ) + def _collect_security_mutable_globals( self, node: ASTNode | None, resolving: set[str] | None = None ) -> set[str]: @@ -388,6 +400,20 @@ def _collect_security_mutable_globals( resolving.remove(call_key) return out + if isinstance(node, FuncCall) and self._is_input_func_call(node): + # An ``input.*()`` / ``input()`` initializer is a compile-time + # constant. Only its defval (first positional or ``defval=``) can + # carry a genuine data dependency; the cosmetic kwargs + # (group/tooltip/title/inline/display/confirm/minval/maxval/step) + # are presentation-only. Walking them would falsely pull a + # ``var string GROUP = "..."`` label into the security's + # mutable-globals set and trip the "TA ctor depends on rebound + # mutable globals" reject (parallax / higherTimeframeLength). + defval = node.args[0] if node.args else node.kwargs.get("defval") + if defval is not None: + out |= self._collect_security_mutable_globals(defval, resolving) + return out + def walk(value: Any) -> None: nonlocal out if value is None: @@ -694,6 +720,83 @@ def _udt_name_from_ctor(self, value: ASTNode) -> str | None: return None return owner + def _func_terminal_drawing_type(self, func_node: FuncDef) -> str | None: + """Resolve the drawing-handle / UDT type of a function's terminal + (return) expression for cases the direct ``_udt_name_from_ctor`` on the + last statement misses: + + - the last statement is an ``IfStmt`` whose terminal branch yields a + drawing/UDT constructor (``makeEventLabel`` => ``if cond\\n + label.new(...)``); and + - the last statement is a bare ``Identifier`` bound to a + drawing-handle local (``setTradeLine`` => ``line result = ...`` then + a trailing ``result``). + + Returns the drawing/UDT type name, or ``None``. Without this a function + that returns a ``line``/``label`` handle this way is mis-typed + ``double`` and clang rejects ``no viable conversion from Line to + double``. + """ + from .types import _DRAWING_TYPE_NAMES + + body = func_node.body + if not body: + return None + + # Map a drawing-handle local var name -> drawing type. Seeded from + # declared drawing type hints (``line result``) and the function's own + # drawing-typed parameters, plus any local first bound to a drawing + # ``.new(...)`` constructor. + local_drawing: dict[str, str] = {} + param_hints = (func_node.annotations or {}).get("param_type_hints", []) + for i, p in enumerate(func_node.params): + hint = param_hints[i] if i < len(param_hints) else None + if hint in _DRAWING_TYPE_NAMES: + local_drawing[p] = hint + + def _scan(stmts): + for st in stmts: + if isinstance(st, VarDecl): + if st.type_hint in _DRAWING_TYPE_NAMES: + local_drawing[st.name] = st.type_hint + else: + dt = self._udt_name_from_ctor(st.value) + if dt in _DRAWING_TYPE_NAMES: + local_drawing.setdefault(st.name, dt) + elif isinstance(st, Assignment) and isinstance(st.target, Identifier): + dt = self._udt_name_from_ctor(st.value) + if dt in _DRAWING_TYPE_NAMES: + local_drawing.setdefault(st.target.name, dt) + elif isinstance(st, IfStmt): + _scan(st.body) + _scan(st.else_body) + + _scan(body) + + def _resolve_terminal(stmt): + # An if used as the function's return expression: the value is the + # terminal of the executed branch — recurse into the body's (then + # else's) terminal statement. + if isinstance(stmt, IfStmt): + for branch in (stmt.body, stmt.else_body): + if branch: + t = _resolve_terminal(branch[-1]) + if t is not None: + return t + return None + expr = None + if isinstance(stmt, ExprStmt): + expr = stmt.expr + elif not isinstance(stmt, TupleLiteral) and hasattr(stmt, "loc"): + expr = stmt + if expr is None: + return None + if isinstance(expr, Identifier) and expr.name in local_drawing: + return local_drawing[expr.name] + return self._udt_name_from_ctor(expr) + + return _resolve_terminal(body[-1]) + def _visit_VarDecl(self, node: VarDecl) -> PineType: # Infer type from the value expression val_type = self._visit(node.value) @@ -1015,6 +1118,13 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: # last_stmt is itself an expression node (single-expr funcs) ret_expr = last_stmt if hasattr(last_stmt, "loc") else None udt_ret = self._udt_name_from_ctor(ret_expr) if ret_expr is not None else None + if udt_ret is None: + # Drawing-handle returns wrapped in an if-statement terminal + # branch (``makeEventLabel``) or returned as a bare drawing-handle + # local (``setTradeLine``) are not direct ctors on the last + # expression — resolve them so the function emits the C++ handle + # type (Line/Label/...) instead of the ``double`` default. + udt_ret = self._func_terminal_drawing_type(node) if udt_ret is not None: self._func_udt_return_types[node.name] = udt_ret # Array-return inference: a function whose body ends in diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 31a577c..f12c6f9 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -442,6 +442,29 @@ def __init__(self, ctx: AnalyzerContext) -> None: self._map_vars.add(_name) elif _spec.kind == "udt" and _spec.name: self._udt_var_types.setdefault(_name, _spec.name) + # Table / polyline variables and params have NO C++ representation + # (SKIP_VAR_TYPES). A *method* call on such a receiver + # (``panel.cell(...)``, ``dash.merge_cells(...)``) is a visual no-op + # that must be dropped — but unlike the namespace form + # (``table.cell(...)``) the receiver is a bare var/param the + # namespace-based skip cannot see. Collect those names so + # ``_is_skip_expr`` can drop their method calls. + _SKIP_DECL_TYPES = set(SKIP_VAR_TYPES) | {"polyline"} + self._visual_drop_vars: set[str] = set() + for _node in self._walk_ast(self.ctx.ast): + if isinstance(_node, VarDecl): + if _node.type_hint in _SKIP_DECL_TYPES: + self._visual_drop_vars.add(_node.name) + elif isinstance(_node.value, FuncCall): + _fn, _ns = self._resolve_callee(_node.value.callee) + if _fn == "new" and _ns in _SKIP_DECL_TYPES: + self._visual_drop_vars.add(_node.name) + elif isinstance(_node, (FuncDef, MethodDef)): + _hints = (getattr(_node, "annotations", None) or {}).get("param_type_hints") or [] + for _i, _p in enumerate(getattr(_node, "params", []) or []): + _h = _hints[_i] if _i < len(_hints) else None + if _h and str(_h).replace(" ", "") in _SKIP_DECL_TYPES: + self._visual_drop_vars.add(_p) # Collect request.security metadata per call self._security_eval_info: list[dict] = [] self._security_ta_variant_names: dict[tuple[int, int, tuple], str] = {} @@ -1735,6 +1758,11 @@ def _is_skip_expr(self, node) -> bool: return True if namespace in SKIP_VAR_TYPES: return True + # Method call on a table/polyline-typed receiver var/param + # (``panel.cell(...)``). These types have no C++ representation, so + # the call is a visual no-op — drop it (mirrors the namespace form). + if namespace in self._visual_drop_vars: + return True # strategy.risk.* — handled in _visit_stmt, not skipped if isinstance(node, MemberAccess): if isinstance(node.object, Identifier) and node.object.name in SKIP_NAMESPACES: diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index da96332..70a1393 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -908,11 +908,13 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No else: for i, s in enumerate(node.body): if i == len(node.body) - 1 and isinstance(s, ExprStmt): - # A void drawing setter / delete / visual-noop used as the - # last statement cannot be the return value (it lowers to a - # void C++ call). Emit it as a plain statement and let the - # default-return path below supply the function's result. - if self._call_is_void(s.expr): + # A void drawing setter / delete / visual-noop, or a dropped + # table/polyline method call (``panel.cell(...)``), used as + # the last statement cannot be the return value (it lowers to + # a void / no-op C++ call). Emit it as a plain statement + # (which ``_is_skip_expr`` drops) and let the default-return + # path below supply the function's result. + if self._call_is_void(s.expr) or self._is_skip_expr(s.expr): self._visit_stmt(s, lines, indent=2) else: lines.append(f" return {self._visit_expr(s.expr)};") @@ -920,10 +922,14 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No elif i == len(node.body) - 1 and isinstance(s, (SwitchStmt, IfStmt)): # Switch/if as last statement = return expression in PineScript # Emit as: double _ret = 0; if/switch assigns _ret; return _ret; - default_ret = ( - f"{ret_type}{{}}" if ret_type in self._udt_defs - else self._default_for_type(ret_type) - ) + # A drawing-handle / UDT return type must brace-init its + # default (``Label _func_ret = Label{};``) — falling through + # to ``_default_for_type`` would emit ``0.0`` and clang would + # reject ``Label _func_ret = 0.0;``. + if ret_type in self._udt_defs or ret_type in DRAWING_TYPE_TO_CPP.values(): + default_ret = f"{ret_type}{{}}" + else: + default_ret = self._default_for_type(ret_type) lines.append(f" {ret_type} _func_ret = {default_ret};") self._visit_if_switch_expr(s, "_func_ret", lines, indent=2) lines.append(f" return _func_ret;") @@ -938,10 +944,10 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No default_vals = ", ".join(["0.0"] * fi.tuple_element_count) lines.append(f" return std::make_tuple({default_vals});") else: - default_ret = ( - f"{ret_type}{{}}" if ret_type in self._udt_defs - else self._default_for_type(ret_type) - ) + if ret_type in self._udt_defs or ret_type in DRAWING_TYPE_TO_CPP.values(): + default_ret = f"{ret_type}{{}}" + else: + default_ret = self._default_for_type(ret_type) lines.append(f" return {default_ret};") lines.append(" }") diff --git a/pineforge_codegen/parser.py b/pineforge_codegen/parser.py index 3b2a2b4..74cbbd6 100644 --- a/pineforge_codegen/parser.py +++ b/pineforge_codegen/parser.py @@ -596,13 +596,17 @@ def _parse_param_type_annotation(self) -> str | None: self._advance() # Is there a type annotation before the parameter name? A builtin type # token always is; an IDENT is a type only if followed by another IDENT - # (``line ln``) or by ``[`` (``line[] arr``). + # (``line ln``), by ``[`` (``line[] arr``), or by ``<`` (the generic + # collection syntax ``array xs`` / ``matrix m`` / + # ``map mp``). Without the ``<`` case the generic type is + # mis-consumed as the parameter name and the whole function definition + # silently fails to parse (its body leaks to top-level scope). has_type = False if self._current().type in TYPE_TOKENS: has_type = True elif self._check(TokenType.IDENT): nxt = self._peek().type - if nxt == TokenType.IDENT or nxt == TokenType.LBRACKET: + if nxt in (TokenType.IDENT, TokenType.LBRACKET, TokenType.LT): has_type = True if not has_type: return None @@ -729,7 +733,8 @@ def _parse_method_def(self): if self._current().type in TYPE_KEYWORDS: param_type = self._parse_type_hint_string() elif (self._current().type == TokenType.IDENT - and self._peek().type == TokenType.IDENT): + and self._peek().type in (TokenType.IDENT, TokenType.LBRACKET, TokenType.LT)): + # ``line ln`` / ``float[] arr`` / ``array xs`` typed param. param_type = self._parse_type_hint_string() p = self._consume(TokenType.IDENT).value pdefault = None diff --git a/pineforge_codegen/support_checker.py b/pineforge_codegen/support_checker.py index 193f6bb..e55e62a 100644 --- a/pineforge_codegen/support_checker.py +++ b/pineforge_codegen/support_checker.py @@ -103,6 +103,13 @@ "label": SUPPORTED_LABEL, "linefill": SUPPORTED_LINEFILL, } _DRAWING_TYPE_NAMES: frozenset[str] = frozenset({"line", "box", "label", "linefill"}) +# Scalar visual-container types whose *method* calls (``panel.cell(...)``, +# ``ln.set_xy1(...)``) legitimately carry visual-constant args (``text.align_*``, +# ``size.*``). A method on a receiver of one of these types — including a +# table/box/line/label/linefill PARAMETER — is a visual sink, so its argument +# subtree is visited with constant-namespace reads allowed (mirrors the +# namespace-form ``table.cell``/``label.new`` handling). +_VISUAL_CONTAINER_TYPES: frozenset[str] = _DRAWING_TYPE_NAMES | frozenset({"table"}) SUPPORTED_COLOR_CONST: frozenset[str] = frozenset(COLOR_CONST_MAP) SUPPORTED_COLOR_FUNC: frozenset[str] = frozenset({"new", "rgb", "r", "g", "b", "t"}) # Cosmetic color builders with no backtest-logic effect. Warned (not rejected); @@ -171,17 +178,23 @@ # DIVERGENT_VARS_ERROR is a SUBSET that is escalated to ERROR (rejected): these # are silent MIS-ALIASES, not merely data-window divergences. They return a # plausible value that is the WRONG quantity (last_bar_index -> current bar -# index; time_close -> bar OPEN timestamp) and that value flows directly into -# trade logic, so the backtest would be silently wrong. A WARNING is not enough. +# index) and that value flows directly into trade logic, so the backtest would +# be silently wrong. A WARNING is not enough. +# +# NOTE: the bare ``time_close`` variable is NOT divergent — codegen lowers it to +# the engine's ``time_close()`` accessor, which returns the true bar-close +# timestamp (bar open + chart-timeframe duration via ``pine_time_close``), +# deterministic and TV-faithful. It was previously mis-listed here as a bar-open +# alias; that was stale. (The session-aware ``time_close(...)`` FUNCTION is a +# separate supported builtin handled in visit_call.) DIVERGENT_VARS: dict[str, str] = { "bar_index": "bar_index depends on the data window; PineForge and TradingView produce different values for the same script.", "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.", "timenow": "timenow is aliased to the current bar timestamp in PineForge; it is not real wall-clock time.", - "time_close": "time_close is aliased to the bar OPEN timestamp in PineForge; it does not represent the bar close time; backtest would be silently wrong — rejected.", } # Subset of DIVERGENT_VARS escalated from WARNING to ERROR (see comment above). -DIVERGENT_VARS_ERROR: frozenset[str] = frozenset({"last_bar_index", "time_close"}) +DIVERGENT_VARS_ERROR: frozenset[str] = frozenset({"last_bar_index"}) BARSTATE_APPROX_VARS: dict[str, str] = { "barstate.islast": "barstate.islast is always false in PineForge batch backtests.", @@ -445,6 +458,11 @@ def __init__(self, ast: Program, filename: str = "") -> None: # shapes before codegen. self._udt_drawing_fields: dict[str, set[str]] = {} self._var_udt_types: dict[str, str] = {} + # Names (vars and function params) declared as a scalar visual-container + # type (table/box/line/label/linefill). A method call on one of these + # (``panel.cell(...)``) is a visual sink whose args may carry visual + # constants, so it routes through ``_visit_children_const_ok``. + self._visual_container_vars: set[str] = set() self._drawing_tuple_vars: set[str] = set() self._func_tuple_drawing_returns: dict[str, list[bool]] = {} # Track whether we are inside an if/ternary condition expression. @@ -496,8 +514,21 @@ def _collect_user_definitions(self, ast: Program) -> None: tuple_returns = self._single_expr_tuple_drawing_mask(stmt) if tuple_returns: self._func_tuple_drawing_returns[stmt.name] = tuple_returns + self._collect_visual_container_params(stmt) elif isinstance(stmt, MethodDef): self._user_methods.add(stmt.name) + self._collect_visual_container_params(stmt) + + def _collect_visual_container_params(self, fn) -> None: + """Register a function's parameters that are declared with a scalar + visual-container type (``table panel``, ``line ln``) so method calls on + them inside the body (``panel.cell(...)``) are treated as visual sinks. + """ + hints = (getattr(fn, "annotations", None) or {}).get("param_type_hints") or [] + for i, pname in enumerate(getattr(fn, "params", []) or []): + hint = hints[i] if i < len(hints) else None + if hint and str(hint).replace(" ", "") in _VISUAL_CONTAINER_TYPES: + self._visual_container_vars.add(pname) @staticmethod def _type_name_contains_drawing(type_name: str | None) -> bool: @@ -739,6 +770,16 @@ def _visit_VarDecl(self, node: VarDecl) -> None: ns, name = _qualified_name(node.value.callee) if name == "new" and ns in self._user_types: self._var_udt_types[node.name] = ns + # Track scalar visual-container vars (``var table dash = table.new(...)``) + # so a direct ``dash.cell(..., text.align_left)`` is treated as a visual + # sink (mirrors the table/box/line/label/linefill PARAM tracking). + decl_hint = str(node.type_hint).replace(" ", "") if node.type_hint else None + if decl_hint in _VISUAL_CONTAINER_TYPES: + self._visual_container_vars.add(node.name) + elif isinstance(node.value, FuncCall): + vns, vname = _qualified_name(node.value.callee) + if vname == "new" and vns in _VISUAL_CONTAINER_TYPES: + self._visual_container_vars.add(node.name) if node.is_varip: self._err( node, @@ -1129,6 +1170,17 @@ def _visit_FuncCall(self, node: FuncCall) -> None: self._visit_children_const_ok(node) return + # Method call on a scalar visual-container receiver (``panel.cell(...)`` + # where ``panel`` is a ``table`` param, ``ln.set_xy1(...)`` on a ``line`` + # var). ``ns`` is the receiver variable/param name, not a namespace, so + # the namespace-form drawing/table branches above did not fire. These are + # visual sinks whose args legitimately carry visual constants + # (``text.align_*``, ``size.*``), so visit children with const reads + # allowed instead of tripping the free-expression const-namespace reject. + if ns is not None and ns in self._visual_container_vars: + self._visit_children_const_ok(node) + return + self._visit_children(node) def _visit_Identifier(self, node: Identifier) -> None: diff --git a/tests/gate-corpus/err/divergent_time_close.pine b/tests/gate-corpus/err/divergent_time_close.pine deleted file mode 100644 index a425840..0000000 --- a/tests/gate-corpus/err/divergent_time_close.pine +++ /dev/null @@ -1,9 +0,0 @@ -//@version=6 -strategy("T") -// The bare ``time_close`` variable is aliased to the bar OPEN timestamp in -// PineForge codegen, so a backtest comparing against it would be silently -// wrong -> hard reject (ERROR). (The session-aware time_close(...) FUNCTION is -// a separate, supported builtin and is not rejected.) -expired = time >= time_close -if expired - strategy.close_all() diff --git a/tests/test_codegen_new.py b/tests/test_codegen_new.py index 7c3b790..1c8f3b9 100644 --- a/tests/test_codegen_new.py +++ b/tests/test_codegen_new.py @@ -1,5 +1,6 @@ """Tests for the new CodeGen that reads from AnalyzerContext.""" +from pineforge_codegen import transpile from pineforge_codegen.lexer import Lexer from pineforge_codegen.parser import Parser from pineforge_codegen.analyzer import Analyzer @@ -1475,3 +1476,126 @@ def test_collection_lvalue_alias_readonly_stays_value_copy(): """) assert "std::vector& sel = " not in cpp assert "std::vector sel = " in cpp + + +# =========================================================================== +# Recovered-strategy regression tests (transpile-error -> supported). +# +# Each pins a distinct codegen fix that recovered a previously-rejected +# scraped strategy. They run the FULL pipeline (support_checker + analyzer + +# codegen) via ``transpile`` and assert the load-bearing emitted construct. +# =========================================================================== + + +def test_drawing_handle_return_via_bare_local_identifier(): + """lukeborgerding setTradeLine: a UDF that returns a bare ``line`` local + must emit a ``Line`` (handle) return type, not the ``double`` default — + otherwise clang rejects ``no viable conversion from Line to double``.""" + cpp = transpile('''//@version=6 +strategy("T") +setTradeLine(lineId, price) => + line result = lineId + if na(result) + result := line.new(time, price, time_close, price) + else + line.set_xy2(result, time_close, price) + result +var line ln = na +ln := setTradeLine(ln, close) +''') + assert "Line setTradeLine(" in cpp + assert "double setTradeLine(" not in cpp + + +def test_drawing_handle_return_via_if_terminal_branch(): + """parallax makeEventLabel: a UDF whose terminal statement is an ``if`` + whose branch yields ``label.new(...)`` must emit a ``Label`` return type + (resolved through the if-terminal), and the default-init must brace-init the + handle (``Label _func_ret = Label{};``), never ``0.0``.""" + cpp = transpile('''//@version=6 +strategy("T") +showLbl = input.bool(true) +makeEventLabel(bool trig, float lvl, string txt) => + if trig and showLbl + label.new(bar_index, lvl, txt, style = label.style_label_up) +makeEventLabel(close > open, low, "Up") +''') + assert "Label makeEventLabel(" in cpp + assert "Label _func_ret = 0.0" not in cpp + + +def test_time_close_variable_emits_faithful_accessor(): + """lukeborgerding: the bare ``time_close`` variable is no longer rejected as + a divergent mis-alias; it lowers to the engine ``time_close()`` accessor + (true bar-close timestamp).""" + cpp = transpile('''//@version=6 +strategy("T") +x = time_close +plot(x > time ? 1 : 0) +''') + assert "time_close()" in cpp + + +def test_security_ta_ctor_ignores_cosmetic_input_group_kwarg(): + """parallax: a constant ``var string`` used ONLY as an ``input.*`` cosmetic + ``group=`` kwarg must not be classified as a rebound mutable global, so the + ``ta.ema(close, len)[1]`` request.security TA-constructor reject does not + fire. The strategy must transpile and emit a request.security read.""" + cpp = transpile('''//@version=6 +strategy("T") +var string GRP = "Mapping" +htfLen = input.int(34, "HTF EMA Length", minval = 2, group = GRP) +htf = input.timeframe("240", "HTF", group = GRP) +useBias = input.bool(true, group = GRP) +htfEma = useBias ? request.security(syminfo.tickerid, htf, ta.ema(close, htfLen)[1], lookahead = barmerge.lookahead_on) : ta.ema(close, htfLen) +if close > htfEma + strategy.entry("L", strategy.long) +''') + assert "_req_sec" in cpp + + +def test_generic_collection_param_in_multiline_func_parses(): + """concordance percentileFromSorted: a multi-line UDF whose FIRST parameter + uses the generic ``array`` syntax must parse (and its body locals + resolve) — previously the generic ``<...>`` type was mis-consumed as the + param name and the whole function silently leaked to top-level scope, + surfacing as ``Undefined variable: 'result'``.""" + cpp = transpile('''//@version=6 +strategy("T") +percentileFromSorted(array sortedValues, float pct) => + float result = na + int n = array.size(sortedValues) + if n > 0 + if pct > 50.0 + result := array.get(sortedValues, 0) + else + result := array.get(sortedValues, 1) + result +var array xs = array.from(1.0, 2.0) +plot(percentileFromSorted(xs, 50.0)) +''') + # Parsed as a real function (not leaked to top-level): the param emits and + # the body's reassigned local is in scope (no Undefined-variable abort). + assert "percentileFromSorted(" in cpp + assert "Undefined" not in cpp + + +def test_table_param_visual_method_accepts_align_const_and_drops_call(): + """concordance dashCell/dashDivider: ``text.align_*`` passed to a method on + a ``table``-typed PARAMETER (``panel.cell(...)``) must be accepted (visual + constant), and codegen must DROP the table method call (table has no C++ + representation) instead of emitting a broken ``double``-receiver member + access.""" + cpp = transpile('''//@version=6 +strategy("T") +dashCell(table panel, int row, string txt) => + panel.cell(0, row, txt, text_halign = text.align_left) + panel.merge_cells(0, row, 1, row) +var table dash = table.new(position.top_right, 2, 2) +if barstate.islast + dashCell(dash, 0, "x") +''') + # text.align_left accepted (no reject) AND the table method calls dropped. + assert "dashCell(" in cpp + assert ".cell(" not in cpp + assert "merge_cells" not in cpp diff --git a/tests/test_support_checker.py b/tests/test_support_checker.py index 06fb5cc..ea6e573 100644 --- a/tests/test_support_checker.py +++ b/tests/test_support_checker.py @@ -81,7 +81,7 @@ def test_divergent_variables_warn(var_name: str): @pytest.mark.parametrize("var_name", sorted(DIVERGENT_VARS_ERROR)) def test_divergent_mis_alias_variables_error(var_name: str): - """last_bar_index / time_close are silent mis-aliases -> ERROR (rejected).""" + """last_bar_index is a silent mis-alias -> ERROR (rejected).""" src = PRELUDE + f"x = {var_name}\n" errs = _errors(src) assert errs, f"{var_name} is a silent mis-alias and must ERROR, not warn" @@ -96,8 +96,14 @@ def test_last_bar_index_errors(): _expect_error(PRELUDE + "x = last_bar_index\n", "last_bar_index") -def test_time_close_errors(): - _expect_error(PRELUDE + "x = time_close\n", "time_close") +def test_time_close_variable_accepted(): + """The bare ``time_close`` variable is faithfully supported: codegen lowers + it to the engine ``time_close()`` accessor (true bar-close = bar open + + chart-timeframe duration), so it is neither an error nor a divergence + warning.""" + src = PRELUDE + "x = time_close\n" + assert _errors(src) == [], "time_close is now faithfully emitted, not rejected" + assert not any("diverges" in d.message for d in _warnings(src)) def test_bar_index_still_warns(): @@ -116,7 +122,10 @@ def test_divergent_error_subset_is_subset(): assert DIVERGENT_VARS_ERROR <= set(DIVERGENT_VARS) assert "bar_index" not in DIVERGENT_VARS_ERROR assert "timenow" not in DIVERGENT_VARS_ERROR - assert {"last_bar_index", "time_close"} == set(DIVERGENT_VARS_ERROR) + # time_close is no longer rejected — codegen lowers it to the faithful + # engine time_close() accessor. + assert "time_close" not in DIVERGENT_VARS_ERROR + assert {"last_bar_index"} == set(DIVERGENT_VARS_ERROR) def test_time_close_function_call_not_flagged_as_divergent_var():