diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index 9a8897c..319625b 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -403,6 +403,58 @@ def _ensure_pine_v6(self) -> None: ] ) + def _check_direct_terminal_array_element_callee_shadows(self) -> None: + """Reject terminal temporary reads whose element call is shadowed. + + This is a whole-program syntactic preflight: it runs before the first + AST visit so definition order cannot leave partial function, call-site, + or TA state behind. Only bindings whose lexical lifetime reaches the + read are relevant: FuncDef parameters and declarations in the immediate + function body before the direct terminal or adjacent alias initializer. + The alias declaration is not active on its own RHS. Nested block locals + have expired and must not shadow the global UDF at either read point. + """ + for func_def in self._ast.body: + if not isinstance(func_def, FuncDef): + continue + terminal = self._direct_terminal_return_expr(func_def) + element_call: FuncCall | None = None + read_index: int | None = None + if self._terminal_array_get_uses_direct_temporary(terminal): + element_call = ( + self._direct_terminal_array_temporary_element_call( + terminal + ) + ) + read_index = len(func_def.body) - 1 + else: + alias_candidate = ( + self._direct_terminal_array_temporary_alias_candidate( + func_def, terminal + ) + ) + if alias_candidate is not None: + read_index, _, element_call = alias_candidate + if element_call is None or read_index is None: + continue + + bindings = set(func_def.params) + for statement in func_def.body[:read_index]: + if isinstance(statement, VarDecl): + bindings.add(statement.name) + elif isinstance(statement, TupleAssign): + bindings.update( + name for name in statement.names if name != "_" + ) + if element_call.callee.name not in bindings: + continue + self._error( + "Direct temporary-array element call " + f"'{element_call.callee.name}()' resolves to a local or " + "parameter, not a user-defined function.", + element_call.loc, + ) + # ------------------------------------------------------------------ # Public entry point # ------------------------------------------------------------------ @@ -410,6 +462,7 @@ def _ensure_pine_v6(self) -> None: def analyze(self) -> AnalyzerContext: """Run semantic analysis and return the analyzer context.""" self._ensure_pine_v6() + self._check_direct_terminal_array_element_callee_shadows() self._visit(self._ast) self._check_direct_terminal_array_temporary_cycles() self._register_resolved_direct_terminal_array_forward_calls() @@ -627,6 +680,40 @@ def _direct_terminal_array_temporary_return_expr( """ if self._terminal_array_get_uses_direct_temporary(terminal): return terminal + alias_candidate = self._direct_terminal_array_temporary_alias_candidate( + func_def, terminal + ) + if alias_candidate is None: + return None + _, declaration, element_call = alias_candidate + + # Raw-name lookup is safe only because the exact declaration identity + # was attached to its live lexical Symbol by _visit_VarDecl. + symbol = self._symbols.resolve(terminal.name) + if ( + symbol is None + or getattr(symbol, "_pf_decl_node_id", None) != id(declaration) + ): + return None + + known_definition = self._func_defs.get(element_call.callee.name) + if known_definition is not None and known_definition.params: + return None + return declaration.value + + def _direct_terminal_array_temporary_alias_candidate( + self, + func_def: FuncDef, + terminal: ASTNode | None, + ) -> tuple[int, VarDecl, FuncCall] | None: + """Return one exact adjacent identity alias and its element call. + + This helper is deliberately syntactic so the same program point can be + checked before any AST visit and captured later while declaration + identity is live. The declaration itself is therefore excluded from + the caller's active-binding scan: Pine evaluates its initializer before + introducing the new local name. + """ if not isinstance(terminal, Identifier): return None @@ -646,15 +733,6 @@ def _direct_terminal_array_temporary_return_expr( ): return None - # Raw-name lookup is safe only because the exact declaration identity - # was attached to its live lexical Symbol by _visit_VarDecl. - symbol = self._symbols.resolve(terminal.name) - if ( - symbol is None - or getattr(symbol, "_pf_decl_node_id", None) != id(declaration) - ): - return None - initializer = declaration.value if not self._terminal_array_get_uses_direct_temporary(initializer): return None @@ -667,20 +745,7 @@ def _direct_terminal_array_temporary_return_expr( or element_call.kwargs ): return None - known_definition = self._func_defs.get(element_call.callee.name) - element_symbol = self._symbols.resolve(element_call.callee.name) - if element_symbol is not None and ( - known_definition is None or element_symbol.scope != "global" - ): - self._error( - "Direct temporary-array element call " - f"'{element_call.callee.name}()' resolves to a local or " - "parameter, not a user-defined function.", - element_call.loc, - ) - if known_definition is not None and known_definition.params: - return None - return initializer + return declaration_index, declaration, element_call @classmethod def _direct_terminal_array_temporary_element_call( diff --git a/tests/test_array_terminal_returns.py b/tests/test_array_terminal_returns.py index 067f124..79e1b13 100644 --- a/tests/test_array_terminal_returns.py +++ b/tests/test_array_terminal_returns.py @@ -7,7 +7,10 @@ import pytest from pineforge_codegen import transpile +from pineforge_codegen.analyzer import Analyzer from pineforge_codegen.errors import CompileError +from pineforge_codegen.lexer import Lexer +from pineforge_codegen.parser import Parser from tests._compile import compile_cpp @@ -966,6 +969,191 @@ def test_forward_local_identity_requires_one_unique_same_named_declaration( assert "std::string blocked(" not in cpp +def _direct_terminal_element_shadow_source( + *, + shadow_kind: str | None, + producer_later: bool, + namespace_spelling: bool, +) -> str: + """Build one direct-terminal lexical-shadow matrix cell.""" + producer = '''produce() => + unused = ta.sma(close, 2) + "x" +''' + terminal = ( + "array.get(array.copy(array.from(produce())), 0)" + if namespace_spelling + else "array.from(produce()).copy().get(0)" + ) + if shadow_kind == "local": + reader = f'''read_value() => + produce = 1 + {terminal} +''' + invocation = "read_value()" + elif shadow_kind == "parameter": + reader = f'''read_value(float produce) => + {terminal} +''' + invocation = "read_value(1.0)" + else: + reader = f'''read_value() => + {terminal} +''' + invocation = "read_value()" + definitions = reader + producer if producer_later else producer + reader + return ( + "//@version=6\n" + 'strategy("Direct terminal element shadow")\n' + f"{definitions}" + f"observed = {invocation}\n" + ) + + +def _direct_shadow_preflight_error( + source: str, + *, + filename: str, +): + ast = Parser( + Lexer(source, filename=filename).tokenize(), + source=source, + filename=filename, + ).parse() + analyzer = Analyzer(ast, filename=filename) + with pytest.raises(CompileError) as caught: + analyzer.analyze() + + # This rejection is a whole-program syntactic preflight. No function or + # call-site analysis may have begun, regardless of source order. + assert analyzer._func_defs == {} + assert analyzer._func_infos == [] + assert analyzer._func_call_cs_map == {} + assert analyzer._func_call_site_count == {} + assert analyzer._func_ta_ranges == {} + assert analyzer._ta_call_sites == [] + return caught.value.diagnostics + + +@pytest.mark.parametrize( + ("shadow_kind", "producer_later", "namespace_spelling"), + product(("local", "parameter"), (False, True), (False, True)), +) +def test_direct_terminal_element_callee_shadow_matrix_is_rejected( + shadow_kind: str, + producer_later: bool, + namespace_spelling: bool, +): + """All 2^3 lexical-shadow/order/spelling cells fail before codegen.""" + source = _direct_terminal_element_shadow_source( + shadow_kind=shadow_kind, + producer_later=producer_later, + namespace_spelling=namespace_spelling, + ) + filename = ( + "direct-shadow:" + f"{shadow_kind}-{int(producer_later)}-{int(namespace_spelling)}.pine" + ) + + diagnostics = _direct_shadow_preflight_error(source, filename=filename) + assert len(diagnostics) == 1 + diagnostic = diagnostics[0] + assert diagnostic.level.value == "error" + assert diagnostic.phase.value == "ANALYZER" + assert diagnostic.location.file == filename + expected_line = { + ("local", False): 8, + ("local", True): 5, + ("parameter", False): 7, + ("parameter", True): 4, + }[(shadow_kind, producer_later)] + expected_col = 44 if namespace_spelling else 23 + assert ( + diagnostic.location.line, + diagnostic.location.col, + diagnostic.location.end_col, + ) == (expected_line, expected_col, expected_col + 1) + assert diagnostic.message == ( + "Direct temporary-array element call 'produce()' resolves to a local " + "or parameter, not a user-defined function." + ) + + +@pytest.mark.parametrize( + ("producer_later", "namespace_spelling"), + product((False, True), repeat=2), +) +def test_direct_terminal_element_callee_no_shadow_controls( + producer_later: bool, + namespace_spelling: bool, +): + source = _direct_terminal_element_shadow_source( + shadow_kind=None, + producer_later=producer_later, + namespace_spelling=namespace_spelling, + ) + + cpp = transpile(source) + assert "std::string read_value_cs0(" in cpp + assert "double read_value_cs0(" not in cpp + assert "produce_cs0()" in cpp + assert "produce_cs1" not in cpp + assert cpp.count("ta::SMA _ta_sma_1;") == 1 + compile_cpp(cpp) + + +def test_direct_terminal_expired_block_local_does_not_shadow_element_callee(): + source = r'''//@version=6 +strategy("Expired block shadow") +produce() => + unused = ta.sma(close, 2) + "x" +reader() => + if close > open + produce = 1 + array.from(produce()).copy().get(0) +observed = reader() +''' + + cpp = transpile(source) + assert "std::string reader_cs0(" in cpp + assert "double reader_cs0(" not in cpp + assert "produce_cs0()" in cpp + assert "produce_cs1" not in cpp + assert cpp.count("ta::SMA _ta_sma_1;") == 1 + compile_cpp(cpp) + + +def test_direct_terminal_top_level_tuple_binding_shadows_element_callee(): + source = r'''//@version=6 +strategy("Direct terminal tuple shadow") +produce() => + unused = ta.sma(close, 2) + "x" +reader() => + [produce, other] = [1, 2] + array.from(produce()).copy().get(0) +observed = reader() +''' + diagnostics = _direct_shadow_preflight_error( + source, + filename="direct-shadow:tuple.pine", + ) + + assert len(diagnostics) == 1 + diagnostic = diagnostics[0] + assert diagnostic.phase.value == "ANALYZER" + assert ( + diagnostic.location.line, + diagnostic.location.col, + diagnostic.location.end_col, + ) == (8, 23, 24) + assert diagnostic.message == ( + "Direct temporary-array element call 'produce()' resolves to a local " + "or parameter, not a user-defined function." + ) + + @pytest.mark.parametrize( ("signature", "shadow_declaration", "call"), ( @@ -981,6 +1169,7 @@ def test_forward_local_identity_element_callee_shadow_remains_fail_closed( producer_first: bool, ): producer = '''produce() => + unused = ta.sma(close, 2) "x" ''' reader = f'''{signature} => @@ -995,11 +1184,177 @@ def test_forward_local_identity_element_callee_shadow_remains_fail_closed( f"observed = {call}\n" ) - with pytest.raises( - CompileError, - match="resolves to a local or parameter", - ): - transpile(source) + shadow_kind = "local" if shadow_declaration else "parameter" + filename = f"alias-shadow:{shadow_kind}-{int(producer_first)}.pine" + diagnostics = _direct_shadow_preflight_error( + source, + filename=filename, + ) + + assert len(diagnostics) == 1 + diagnostic = diagnostics[0] + assert diagnostic.level.value == "error" + assert diagnostic.phase.value == "ANALYZER" + assert diagnostic.location.file == filename + expected_line = { + ("local", False): 5, + ("local", True): 8, + ("parameter", False): 4, + ("parameter", True): 7, + }[(shadow_kind, producer_first)] + assert ( + diagnostic.location.line, + diagnostic.location.col, + diagnostic.location.end_col, + ) == (expected_line, 37, 38) + assert diagnostic.message == ( + "Direct temporary-array element call 'produce()' resolves to a local " + "or parameter, not a user-defined function." + ) + + +def test_forward_local_identity_expired_block_local_does_not_shadow_element_callee(): + source = r'''//@version=6 +strategy("Expired block alias shadow") +produce() => + unused = ta.sma(close, 2) + "x" +reader() => + if close > open + produce = 1 + local_value = array.from(produce()).copy().get(0) + local_value +observed = reader() +''' + + cpp = transpile(source) + assert "std::string reader_cs0(" in cpp + assert "double reader_cs0(" not in cpp + assert "std::string local_value =" in cpp + assert "produce_cs0()" in cpp + assert "produce_cs1" not in cpp + assert cpp.count("ta::SMA _ta_sma_1;") == 1 + compile_cpp(cpp) + + +@pytest.mark.parametrize("producer_first", (False, True)) +def test_forward_local_identity_self_named_alias_rhs_uses_global_udf( + producer_first: bool, +): + producer = '''produce() => + unused = ta.sma(close, 2) + "x" +''' + reader = '''reader() => + produce = array.from(produce()).copy().get(0) + produce +''' + definitions = producer + reader if producer_first else reader + producer + source = ( + "//@version=6\n" + 'strategy("Self-named alias RHS")\n' + f"{definitions}" + "observed = reader()\n" + ) + + cpp = transpile(source) + assert "std::string reader_cs0(" in cpp + assert "double reader_cs0(" not in cpp + assert "std::string produce =" in cpp + assert "produce_cs0()" in cpp + assert "produce_cs1" not in cpp + assert cpp.count("ta::SMA _ta_sma_1;") == 1 + compile_cpp(cpp) + + +@pytest.mark.parametrize( + ("producer_first", "namespace_spelling"), + product((False, True), repeat=2), +) +def test_forward_local_identity_top_level_tuple_binding_shadows_element_callee( + producer_first: bool, + namespace_spelling: bool, +): + producer = '''produce() => + unused = ta.sma(close, 2) + "x" +''' + initializer = ( + "array.get(array.copy(array.from(produce())), 0)" + if namespace_spelling + else "array.from(produce()).copy().get(0)" + ) + reader = f'''reader() => + [produce, other] = [1, 2] + local_value = {initializer} + local_value +''' + definitions = producer + reader if producer_first else reader + producer + source = ( + "//@version=6\n" + 'strategy("Adjacent alias tuple shadow")\n' + f"{definitions}" + "observed = reader()\n" + ) + filename = ( + "alias-shadow:tuple-" + f"{int(producer_first)}-{int(namespace_spelling)}.pine" + ) + diagnostics = _direct_shadow_preflight_error( + source, + filename=filename, + ) + + assert len(diagnostics) == 1 + diagnostic = diagnostics[0] + assert diagnostic.level.value == "error" + assert diagnostic.phase.value == "ANALYZER" + assert diagnostic.location.file == filename + expected_line = 8 if producer_first else 5 + expected_col = 58 if namespace_spelling else 37 + assert ( + diagnostic.location.line, + diagnostic.location.col, + diagnostic.location.end_col, + ) == (expected_line, expected_col, expected_col + 1) + assert diagnostic.message == ( + "Direct temporary-array element call 'produce()' resolves to a local " + "or parameter, not a user-defined function." + ) + + +@pytest.mark.parametrize("alias_carrier", (False, True)) +def test_expired_block_tuple_binding_does_not_shadow_element_callee( + alias_carrier: bool, +): + terminal = "array.from(produce()).copy().get(0)" + return_lines = ( + f" local_value = {terminal}\n local_value\n" + if alias_carrier + else f" {terminal}\n" + ) + source = ( + "//@version=6\n" + 'strategy("Expired block tuple shadow")\n' + "produce() =>\n" + " unused = ta.sma(close, 2)\n" + ' "x"\n' + "pair() =>\n" + " [1, 2]\n" + "reader() =>\n" + " if close > open\n" + " [produce, other] = pair()\n" + f"{return_lines}" + "observed = reader()\n" + ) + + cpp = transpile(source) + assert "std::string reader_cs0(" in cpp + assert "double reader_cs0(" not in cpp + assert "produce_cs0()" in cpp + assert "produce_cs1" not in cpp + assert cpp.count("ta::SMA _ta_sma_1;") == 1 + compile_cpp(cpp) @pytest.mark.parametrize(