diff --git a/AGENTS.md b/AGENTS.md index 7a83db71..b1da6a30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -349,6 +349,14 @@ tests/ ## Support contracts +Registry builtins with verified keyword names +(`IntrinsicFunc.keyword_names_verified`) declare their complete keyword set +through their signature parameters. These names are validated before lowering +and again at the shared keyword merge; unknown names report PF-E2027 at the +keyword. Other records keep their existing binding policy. `math.pow` +declares `base, exponent`; both named forms emit the positional expression. +Argument ordering remains the separate PF-E2074 validation. + An aggregate `var` initializer that reads a statement-computed value (directly, through a user helper, or through another declaration-site `var`) runs at its declaration under the existing once flag. An earlier reassignment or collection diff --git a/CLAUDE.md b/CLAUDE.md index c75d5b60..4b4f1376 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -349,6 +349,14 @@ tests/ ## Support contracts +Registry builtins with verified keyword names +(`IntrinsicFunc.keyword_names_verified`) declare their complete keyword set +through their signature parameters. These names are validated before lowering +and again at the shared keyword merge; unknown names report PF-E2027 at the +keyword. Other records keep their existing binding policy. `math.pow` +declares `base, exponent`; both named forms emit the positional expression. +Argument ordering remains the separate PF-E2074 validation. + An aggregate `var` initializer that reads a statement-computed value (directly, through a user helper, or through another declaration-site `var`) runs at its declaration under the existing once flag. An earlier reassignment or collection diff --git a/pineforge_codegen/codegen/security.py b/pineforge_codegen/codegen/security.py index 91c3fe51..40f68a75 100644 --- a/pineforge_codegen/codegen/security.py +++ b/pineforge_codegen/codegen/security.py @@ -3236,6 +3236,7 @@ def _build_security_math_call( node.kwargs, sigs.get_param_names("math", func_name), visit, + intrinsic=sigs.get_intrinsic("math", func_name), ) if func_name == "round" and len(args) == 2: return _math_round_digits_expr(args) diff --git a/pineforge_codegen/codegen/tables.py b/pineforge_codegen/codegen/tables.py index 6dbf85f4..0c7df54a 100644 --- a/pineforge_codegen/codegen/tables.py +++ b/pineforge_codegen/codegen/tables.py @@ -18,6 +18,8 @@ import re +from ..intrinsic_binding import validate_intrinsic_keywords +from ..signatures import IntrinsicFunc from ..symbols import PineType from .helpers import ( evaluate_args_once, na_preserving_int_cast, pine_index_int_cast, @@ -1401,12 +1403,17 @@ def _math_round_digits_expr(args: list[str]) -> str: # kwarg merge helper (used by visitor / dispatch sites that accept kwargs) # --------------------------------------------------------------------------- -def _merge_kwargs(args: list, kwargs: dict, param_names: list | None, visit_expr) -> list: +def _merge_kwargs(args: list, kwargs: dict, param_names: list | None, visit_expr, + *, intrinsic: IntrinsicFunc | None = None) -> list: """Merge positional + kwargs into a unified positional list of C++ exprs. ``param_names`` is the declared signature order from ``signatures.py``. ``visit_expr`` is a callable applied to each kept - AST node; pass ``lambda x: x`` when the caller wants raw nodes back.""" + AST node; pass ``lambda x: x`` when the caller wants raw nodes back. + Registry callers supply their record so verified keyword names are checked + before any argument is evaluated or omitted. + """ + validate_intrinsic_keywords(intrinsic, kwargs) if not kwargs or not param_names: return [visit_expr(a) for a in args] merged = list(args) diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index e66d245b..775c828d 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -153,6 +153,7 @@ ) from ..collection_history import history_annotation, literal_offset from ..external_requests import RECORDED_KEY_ANNOTATION, request_stop_marker +from ..intrinsic_binding import validate_intrinsic_keywords from ..limits import iter_ast_nodes from ..security_contexts import ticker_symbol_arg from ..symbols import TypeSpec, method_receiver_type_name @@ -2405,6 +2406,7 @@ def _visit_func_call(self, node: FuncCall) -> str: args = _merge_kwargs( node.args, node.kwargs, sigs.get_param_names("request", func_name), self._visit_expr, + intrinsic=sigs.get_intrinsic("request", func_name), ) effects = [f"(void)({arg})" for arg in args] effects += [request_stop(marker), "na()"] @@ -2631,7 +2633,10 @@ def _visit_func_call(self, node: FuncCall) -> str: return lowered node = self._check_time_bars_back(func_name, node) if func_name == "time" and namespace is None and (node.args or node.kwargs): - args = _merge_kwargs(node.args, node.kwargs, sigs.get_param_names(None, "time"), self._visit_expr) + args = _merge_kwargs( + node.args, node.kwargs, sigs.get_param_names(None, "time"), + self._visit_expr, intrinsic=sigs.get_intrinsic(None, "time"), + ) tf_e = args[0] if len(args) > 0 else 'script_tf_' sess = args[1] if len(args) > 1 else 'std::string("")' tz_e = args[2] if len(args) > 2 else 'std::string("")' @@ -2642,7 +2647,10 @@ def _visit_func_call(self, node: FuncCall) -> str: # time_close(timeframe) or time_close(tf, session, tz) — same # session-tz default as time() above. if func_name == "time_close" and namespace is None and (node.args or node.kwargs): - args = _merge_kwargs(node.args, node.kwargs, sigs.get_param_names(None, "time_close"), self._visit_expr) + args = _merge_kwargs( + node.args, node.kwargs, sigs.get_param_names(None, "time_close"), + self._visit_expr, intrinsic=sigs.get_intrinsic(None, "time_close"), + ) tf_e = args[0] if len(args) > 0 else 'script_tf_' sess = args[1] if len(args) > 1 else 'std::string("")' tz_e = args[2] if len(args) > 2 else 'std::string("")' @@ -2722,6 +2730,7 @@ def _visit_func_call(self, node: FuncCall) -> str: node.args, node.kwargs, sigs.get_param_names(None, "timestamp"), lambda a: a, + intrinsic=sigs.get_intrinsic(None, "timestamp"), ) if len(merged) < 3 or any(a is None for a in merged[:3]): self._codegen_error( @@ -2824,6 +2833,7 @@ def _visit_func_call(self, node: FuncCall) -> str: node.kwargs, sigs.get_param_names("ta", "pivot_point_levels"), self._visit_expr, + intrinsic=sigs.get_intrinsic("ta", "pivot_point_levels"), ) else: args = [self._visit_expr(a) for a in node.args] @@ -3258,7 +3268,8 @@ def _visit_arg_for_series(arg_node, arg_idx): # Known intrinsic — use signature registry for kwargs resolution param_names = sigs.get_param_names(namespace, func_name) merged = _merge_kwargs( - node.args, node.kwargs, param_names, lambda a: a + node.args, node.kwargs, param_names, lambda a: a, + intrinsic=sigs.get_intrinsic(namespace, func_name), ) ordered_arg_nodes = list(merged) all_args = [self._visit_expr(a) for a in merged] @@ -3874,10 +3885,12 @@ def _str_format_expr(self, fmt_node, arg_nodes) -> str: def _visit_str_call(self, func_name: str, node) -> str: args = _merge_kwargs(node.args, node.kwargs, - sigs.get_param_names("str", func_name), self._visit_expr) + sigs.get_param_names("str", func_name), self._visit_expr, + intrinsic=sigs.get_intrinsic("str", func_name)) arg_nodes = _merge_kwargs( node.args, node.kwargs, sigs.get_param_names("str", func_name), lambda a: a, + intrinsic=sigs.get_intrinsic("str", func_name), ) if func_name == "repeat": @@ -4004,7 +4017,10 @@ def checked_substring(bound): return f'std::string("") /* unsupported: str.{comment_name} */' def _visit_math_call(self, func_name: str, node: FuncCall) -> str: - args = _merge_kwargs(node.args, node.kwargs, sigs.get_param_names("math", func_name), self._visit_expr) + args = _merge_kwargs( + node.args, node.kwargs, sigs.get_param_names("math", func_name), + self._visit_expr, intrinsic=sigs.get_intrinsic("math", func_name), + ) # Handle special cases first if func_name == "round" and len(args) == 2: return _math_round_digits_expr(args) @@ -4051,6 +4067,11 @@ def _resolve_func_args(self, node: FuncCall, sig_key: str) -> dict: """ # sig_key is like "strategy.entry" -> namespace="strategy", func_name="entry" parts = sig_key.split(".", 1) + validate_intrinsic_keywords( + sigs.get_intrinsic(parts[0], parts[1]) if len(parts) == 2 + else sigs.get_intrinsic(None, sig_key), + node.kwargs, + ) if len(parts) == 2: param_names = sigs.get_param_names(parts[0], parts[1]) or [] else: diff --git a/pineforge_codegen/intrinsic_binding.py b/pineforge_codegen/intrinsic_binding.py new file mode 100644 index 00000000..0ec5443c --- /dev/null +++ b/pineforge_codegen/intrinsic_binding.py @@ -0,0 +1,50 @@ +"""Keyword-name validation for intrinsic records with verified keyword names.""" + +from __future__ import annotations + +from .ast_nodes import ASTNode +from .diagnostic_codes import render_diagnostic +from .errors import CompileError, Diagnostic, Level, Phase, SourceLocation +from .pine_spelling import expr_start +from .signatures import IntrinsicFunc + + +def intrinsic_keyword_diagnostics( + intrinsic: IntrinsicFunc | None, + kwargs: dict[str, ASTNode], +) -> list[Diagnostic]: + """Check the complete keyword set of records with verified keyword names. + + Other records retain their existing binding, including ignored names. + This checks names, leaving argument order and other binding policies to + their existing validators. + """ + if intrinsic is None or not intrinsic.keyword_names_verified or not kwargs: + return [] + names = {param.name for signature in intrinsic.signatures + for param in signature.params} + diagnostics = [] + for name, value in kwargs.items(): + if name in names: + continue + location = ((value.annotations or {}).get("keyword_location") + or expr_start(value).loc or value.loc + or SourceLocation("", 1, 1, 2)) + message, hint = render_diagnostic( + "PF-E2027", {"key": intrinsic.name, "name": name}, + ) + diagnostics.append(Diagnostic( + level=Level.ERROR, phase=Phase.ANALYZER, location=location, + message=message, hint=hint, + )) + return diagnostics + + +def validate_intrinsic_keywords( + intrinsic: IntrinsicFunc | None, + kwargs: dict[str, ASTNode], +) -> None: + """Validate verified keyword names for direct codegen clients.""" + diagnostics = intrinsic_keyword_diagnostics(intrinsic, kwargs) + if diagnostics: + raise CompileError(diagnostics) diff --git a/pineforge_codegen/parser.py b/pineforge_codegen/parser.py index f3f5b335..4e2dce8b 100644 --- a/pineforge_codegen/parser.py +++ b/pineforge_codegen/parser.py @@ -1720,6 +1720,9 @@ def _parse_call_args(self) -> tuple[list, dict, ArgOrder]: ) self._advance() # consume = val = self._parse_expression() + val.annotations = { + **(val.annotations or {}), "keyword_location": self._loc(key_tok), + } kwargs[key_tok.value] = val call_arg_order.append(val) else: diff --git a/pineforge_codegen/signatures.py b/pineforge_codegen/signatures.py index 45fdc7c4..0ec84700 100644 --- a/pineforge_codegen/signatures.py +++ b/pineforge_codegen/signatures.py @@ -40,6 +40,9 @@ class IntrinsicFunc: """Complete intrinsic function definition with all overloads.""" name: str # e.g. "ta.sma" or "math.abs" signatures: list[FuncSig] = field(default_factory=list) + # Declared parameter names are the complete, verified keyword set. + # False preserves the record's existing keyword policy. + keyword_names_verified: bool = False @property def primary(self) -> FuncSig: @@ -73,8 +76,10 @@ def _sig(params: list[tuple], ret: PineType = PineType.FLOAT, returns_tuple=returns_tuple, tuple_count=tuple_count) -def _func(name: str, *sigs: FuncSig) -> IntrinsicFunc: - return IntrinsicFunc(name=name, signatures=list(sigs)) +def _func(name: str, *sigs: FuncSig, + keyword_names_verified: bool = False) -> IntrinsicFunc: + return IntrinsicFunc(name=name, signatures=list(sigs), + keyword_names_verified=keyword_names_verified) # Shortcuts for common param types @@ -234,8 +239,11 @@ def _ta(short_name: str, *sigs: FuncSig) -> None: MATH_FUNCTIONS: dict[str, IntrinsicFunc] = {} -def _math(short_name: str, *sigs: FuncSig) -> None: - MATH_FUNCTIONS[short_name] = _func(f"math.{short_name}", *sigs) +def _math(short_name: str, *sigs: FuncSig, + keyword_names_verified: bool = False) -> None: + MATH_FUNCTIONS[short_name] = _func( + f"math.{short_name}", *sigs, keyword_names_verified=keyword_names_verified, + ) # Arithmetic _math("abs", _sig([("x", F)])) @@ -261,7 +269,7 @@ def _math(short_name: str, *sigs: FuncSig) -> None: _sig([("x", F), ("y", F), ("z", F), ("w", F), ("u", F)]), _sig([("x", F), ("y", F), ("z", F), ("w", F), ("u", F), ("v", F)])) _math("sum", _sig([("source", F), ("length", I)])) # rolling sum → math::Sum in runtime -_math("pow", _sig([("base", F), ("exp", F)])) +_math("pow", _sig([("base", F), ("exponent", F)]), keyword_names_verified=True) _math("sqrt", _sig([("x", F)])) _math("exp", _sig([("x", F)])) _math("log", _sig([("x", F)])) @@ -767,6 +775,22 @@ def merge_kwargs_to_positional( return merged +def get_intrinsic(namespace: str | None, func_name: str) -> IntrinsicFunc | None: + """Look up a registry record and its keyword-name validation policy.""" + registry = { + "ta": TA_FUNCTIONS, + "math": MATH_FUNCTIONS, + "strategy": STRATEGY_FUNCTIONS, + "str": STR_FUNCTIONS, + "input": INPUT_FUNCTIONS, + "map": MAP_FUNCTIONS, + "syminfo": SYMINFO_FUNCTIONS, + "request": REQUEST_FUNCTIONS, + None: BUILTIN_FUNCTIONS, + }.get(namespace) + return registry.get(func_name) if registry is not None else None + + def get_param_names(namespace: str | None, func_name: str) -> list[str] | None: """Get parameter names for a function (for kwargs resolution). diff --git a/pineforge_codegen/support_checker.py b/pineforge_codegen/support_checker.py index 867e1595..ca3da8b4 100644 --- a/pineforge_codegen/support_checker.py +++ b/pineforge_codegen/support_checker.py @@ -50,6 +50,7 @@ ) from .errors import SourceLocation, Diagnostic, CompileError, Level, Phase from .builtin_keywords import POSITIONAL_BUILTINS +from .intrinsic_binding import intrinsic_keyword_diagnostics from .pine_spelling import expr_start from .external_requests import ( ABSENT_LOWERING, CHART_FALLBACK_ANNOTATION, FEED_LOWERING, FEED_WARNING_ANNOTATION, @@ -1635,6 +1636,9 @@ def _visit_FuncCall(self, node: FuncCall) -> None: return full = f"{ns}.{name}" if ns else name + self._diagnostics.extend(intrinsic_keyword_diagnostics( + sigs.get_intrinsic(ns, name), node.kwargs, + )) # The footprint another symbol's feed carries: the column of the # request.security whose whole expression this is. diff --git a/tests/test_intrinsic_keyword_binding.py b/tests/test_intrinsic_keyword_binding.py new file mode 100644 index 00000000..999eec70 --- /dev/null +++ b/tests/test_intrinsic_keyword_binding.py @@ -0,0 +1,146 @@ +"""Named intrinsic arguments retain the same expression as positional calls.""" + +from __future__ import annotations + +import csv +import io +import re + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError +from tests._e2e import ( + Build, chart_feed_head, execute_all, ok, skip_unless_e2e_env, +) + + +HEADER = '//@version=6\nstrategy("pf_probe")\n' +POSITIONAL = "math.pow(3.0, 2.0)" +NAMED = ( + "math.pow(base = 3.0, exponent = 2.0)", + "math.pow(3.0, exponent = 2.0)", + "math.pow(exponent = 2.0, base = 3.0)", +) + + +def _source(expression: str) -> str: + return HEADER + f"pf_value = {expression}\nplot(pf_value)\n" + + +def _pow_expressions(cpp: str) -> list[str]: + """Extract calls with their balanced argument lists, including nested calls.""" + calls = [] + for match in re.finditer(r"\bstd::pow\(", cpp): + depth = 1 + end = match.end() + while depth and end < len(cpp): + depth += (cpp[end] == "(") - (cpp[end] == ")") + end += 1 + assert depth == 0, "unbalanced generated pow call" + calls.append(cpp[match.start():end]) + assert calls, "no generated pow call" + return calls + + +@pytest.mark.parametrize("expression", NAMED) +@pytest.mark.parametrize("context", ( + "{expression}", + 'request.security(syminfo.tickerid, "60", {expression})', +)) +def test_named_arguments_emit_the_positional_expression(expression, context): + expected = transpile(_source(context.format(expression=POSITIONAL))) + actual = transpile(_source(context.format(expression=expression))) + assert _pow_expressions(actual) == _pow_expressions(expected) + + +@pytest.mark.parametrize("body", ( + "pf_value = {expression}\n", + "pf_unused() => {expression}\npf_value = close\n", + "if false\n pf_value = {expression}\n", + "pf_value = switch\n close > open => {expression}\n => 0.0\n", + 'pf_value = request.security(syminfo.tickerid, "60", {expression})\n', +)) +@pytest.mark.parametrize("expression", ( + "math.pow(3.0, exp = 2.0)", + "math.pow(3.0,\n exp = 1.0 + close)", +)) +def test_unknown_keyword_is_refused_at_its_name(body, expression): + source = HEADER + body.format(expression=expression) + with pytest.raises(CompileError) as caught: + transpile(source, filename="keywords.pine") + diagnostics = [d for d in caught.value.diagnostics if d.code == "PF-E2027"] + assert len(diagnostics) == 1 + diagnostic = diagnostics[0] + assert diagnostic.args == {"key": "math.pow", "name": "exp"} + lines = source.splitlines() + line = next(index for index, value in enumerate(lines, 1) if "exp =" in value) + col = lines[line - 1].index("exp =") + 1 + assert diagnostic.location.file == "keywords.pine" + assert (diagnostic.location.line, diagnostic.location.col, + diagnostic.location.end_col) == (line, col, col + len("exp")) + + +def test_positional_after_keyword_keeps_the_argument_order_diagnostic(): + source = _source("math.pow(exponent = 2.0, 3.0)") + with pytest.raises(CompileError) as caught: + transpile(source) + assert [d.code for d in caught.value.diagnostics] == ["PF-E2074"] + line = source.splitlines()[2] + assert caught.value.diagnostics[0].location.col == line.index("3.0") + 1 + + +@pytest.mark.parametrize("keyword, positional", ( + ("ta.sma(source = close, length = 5)", "ta.sma(close, 5)"), + ('input.int(defval = 3, title = "x")', 'input.int(3, "x")'), + ("math.sqrt(x = 4.0)", "math.sqrt(4.0)"), +)) +def test_existing_named_controls_keep_their_positional_emission(keyword, positional): + assert transpile(_source(keyword)) == transpile(_source(positional)) + + +def test_a_record_without_verified_names_keeps_its_existing_keyword_policy(): + assert transpile(_source("math.sqrt(4.0, pf_unused = 2.0)")) == \ + transpile(_source("math.sqrt(4.0)")) + + +@pytest.mark.parametrize("context", ( + "{expression}", + 'request.security(syminfo.tickerid, "60", {expression})', +)) +def test_codegen_also_validates_when_the_support_check_is_disabled(context): + expression = context.format(expression="math.pow(3.0, exp = 2.0)") + with pytest.raises(CompileError) as caught: + transpile(_source(expression), check_support=False) + assert [d.code for d in caught.value.diagnostics] == ["PF-E2027"] + + +def test_named_power_values_compile_and_run_like_the_positional_control(tmp_path): + engine = skip_unless_e2e_env() + feed = chart_feed_head(engine, tmp_path, 80) + tail = ( + "\n// @pf-trace pf_value=pf_value\n" + "if bar_index % 8 == 0\n" + ' strategy.entry("pf_long", strategy.long, qty = 1 + pf_value / 100)\n' + "if bar_index % 8 == 1\n" + ' strategy.close("pf_long")\n' + ) + expressions = (POSITIONAL, *NAMED) + runs = execute_all(engine, feed, tmp_path, { + str(index): Build(HEADER + f"pf_value = {expression}\n" + tail, trace=True) + for index, expression in enumerate(expressions) + }) + reference = ok(runs, "0") + assert reference.trades["default"] + assert reference.traces["default"] + for record in reference.traces["default"]: + assert record["name"] == "pf_value" + assert float(record["value"]) == pytest.approx(3.0 ** 2.0) + rows = list(csv.DictReader(io.StringIO(reference.trades["default"].decode()))) + assert rows + assert all(float(row["Qty"]) == pytest.approx(1 + (3.0 ** 2.0) / 100) + for row in rows) + for index in range(1, len(expressions)): + actual = ok(runs, str(index)) + assert actual.trades == reference.trades + assert actual.traces == reference.traces diff --git a/tests/test_intrinsic_keyword_generic.py b/tests/test_intrinsic_keyword_generic.py new file mode 100644 index 00000000..f10a141f --- /dev/null +++ b/tests/test_intrinsic_keyword_generic.py @@ -0,0 +1,273 @@ +"""Verified keyword names follow each callable's registry record.""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import replace +import re + +import pytest + +from pineforge_codegen import signatures, transpile +from pineforge_codegen.diagnostic_codes import render_diagnostic +from pineforge_codegen.errors import CompileError, Level +from pineforge_codegen.signatures import IntrinsicFunc + + +def _source(expression): + return ( + '//@version=6\nstrategy("pf_probe")\n' + f"pf_value = {expression}\nplot(pf_value)\n" + ) + + +def _arguments(intrinsic): + return [ + (param.name, f"{index + 2}.0") + for index, param in enumerate(intrinsic.primary.params) + ] + + +def _call(intrinsic, arguments): + return f"{intrinsic.name}({', '.join(arguments)})" + + +def _expressions(cpp, intrinsic): + short_name = intrinsic.name.rsplit(".", 1)[-1] + expressions = [] + for match in re.finditer(rf"\bstd::{re.escape(short_name)}\(", cpp): + depth = 1 + end = match.end() + while depth and end < len(cpp): + depth += (cpp[end] == "(") - (cpp[end] == ")") + end += 1 + assert depth == 0 + expressions.append(cpp[match.start():end]) + assert expressions, intrinsic.name + return expressions + + +@pytest.fixture(params=("sqrt", "atan2")) +def intrinsic_case(request, monkeypatch): + """Synthetic parameters use existing dispatch without a visitor change.""" + original = signatures.MATH_FUNCTIONS[request.param] + assert original.keyword_names_verified is False + existing_names = { + param.name + for registry in vars(signatures).values() + if isinstance(registry, dict) + for record in registry.values() + if isinstance(record, IntrinsicFunc) + for signature in record.signatures + for param in signature.params + } + intrinsic = replace(original, signatures=[ + replace(signature, params=[ + replace(param, name=f"pf_synthetic_argument_{index}") + for index, param in enumerate(signature.params) + ]) + for signature in original.signatures + ]) + assert not existing_names.intersection(intrinsic.param_names) + + @contextmanager + def with_verified_names(): + with monkeypatch.context() as patch: + patch.setattr(intrinsic, "keyword_names_verified", True) + yield + assert intrinsic.keyword_names_verified is False + + with monkeypatch.context() as patch: + patch.setitem(signatures.MATH_FUNCTIONS, request.param, intrinsic) + yield intrinsic, with_verified_names + assert signatures.MATH_FUNCTIONS[request.param] is original + assert original.keyword_names_verified is False + + +@pytest.mark.parametrize("context", ( + "{expression}", + 'request.security(syminfo.tickerid, "60", {expression})', +)) +@pytest.mark.parametrize("check_support", (True, False)) +def test_verified_names_accept_declared_keywords( + intrinsic_case, context, check_support, +): + intrinsic, with_verified_names = intrinsic_case + arguments = _arguments(intrinsic) + positional = _call(intrinsic, [value for _, value in arguments]) + expected = _expressions(transpile( + _source(context.format(expression=positional)), check_support=check_support, + ), intrinsic) + named_forms = ( + [f"{name} = {value}" for name, value in arguments], + [f"{name} = {value}" for name, value in reversed(arguments)], + [arguments[0][1]] + + [f"{name} = {value}" for name, value in arguments[1:]], + ) + with with_verified_names(): + for named in named_forms: + actual = transpile( + _source(context.format(expression=_call(intrinsic, named))), + check_support=check_support, + ) + assert _expressions(actual, intrinsic) == expected + + +@pytest.mark.parametrize("context", ( + "{expression}", + 'request.security(syminfo.tickerid, "60", {expression})', +)) +@pytest.mark.parametrize("check_support", (True, False)) +@pytest.mark.parametrize("keyword_layout", ( + "{name} = 2.0", + "\n {name} = 1.0 + close", +)) +def test_verified_names_refuse_an_unknown_keyword_at_its_computed_span( + intrinsic_case, context, check_support, keyword_layout, +): + intrinsic, with_verified_names = intrinsic_case + arguments = _arguments(intrinsic) + unknown = f"pf_unknown_{arguments[0][0]}" + assert unknown not in intrinsic.param_names + expression = _call(intrinsic, [ + *[value for _, value in arguments], + keyword_layout.format(name=unknown), + ]) + source = _source(context.format(expression=expression)) + start = source.index(f"{unknown} =") + line = source.count("\n", 0, start) + 1 + col = start - source.rfind("\n", 0, start) + expected_args = {"key": intrinsic.name, "name": unknown} + expected_text = render_diagnostic("PF-E2027", expected_args) + with with_verified_names(): + with pytest.raises(CompileError) as caught: + transpile( + source, filename="generic_keywords.pine", + check_support=check_support, + ) + diagnostics = [ + diagnostic for diagnostic in caught.value.diagnostics + if diagnostic.level == Level.ERROR + ] + assert len(diagnostics) == 1 + diagnostic = diagnostics[0] + assert diagnostic.code == "PF-E2027" + assert diagnostic.args == expected_args + assert (diagnostic.message, diagnostic.hint) == expected_text + assert diagnostic.location.file == "generic_keywords.pine" + assert ( + diagnostic.location.line, diagnostic.location.col, + diagnostic.location.end_col, + ) == (line, col, col + len(unknown)) + + +@pytest.mark.parametrize("context", ( + "{expression}", + 'request.security(syminfo.tickerid, "60", {expression})', +)) +@pytest.mark.parametrize("check_support", (True, False)) +def test_clearing_verified_names_restores_identical_bytes( + intrinsic_case, context, check_support, +): + intrinsic, with_verified_names = intrinsic_case + arguments = _arguments(intrinsic) + unknown = f"pf_unknown_{arguments[0][0]}" + positional = [value for _, value in arguments] + forms = ( + positional, + [f"{name} = {value}" for name, value in arguments], + [*positional, f"{unknown} = 2.0"], + ) + sources = [ + _source(context.format(expression=_call(intrinsic, form))) + for form in forms + ] + expected = [ + transpile(source, check_support=check_support).encode("utf-8") + for source in sources + ] + with with_verified_names(): + transpile(sources[0], check_support=check_support) + transpile(sources[1], check_support=check_support) + with pytest.raises(CompileError): + transpile(sources[2], check_support=check_support) + assert intrinsic.keyword_names_verified is False + assert [ + transpile(source, check_support=check_support).encode("utf-8") + for source in sources + ] == expected + + +@pytest.mark.parametrize("verified_short_name", ("sqrt", "exp")) +@pytest.mark.parametrize("context", ( + "{expression}", + 'request.security(syminfo.tickerid, "60", {expression})', +)) +@pytest.mark.parametrize("check_support", (True, False)) +def test_shared_parameter_names_resolve_each_callable_record( + monkeypatch, verified_short_name, context, check_support, +): + originals = { + short_name: signatures.MATH_FUNCTIONS[short_name] + for short_name in ("sqrt", "exp") + } + shared_name = "pf_shared_argument" + unknown = f"pf_unknown_{shared_name}" + records = { + short_name: replace( + original, + signatures=[replace( + original.primary, + params=[replace(original.primary.params[0], name=shared_name)], + )], + keyword_names_verified=short_name == verified_short_name, + ) + for short_name, original in originals.items() + } + with monkeypatch.context() as patch: + for short_name, record in records.items(): + patch.setitem(signatures.MATH_FUNCTIONS, short_name, record) + for short_name, record in records.items(): + positional = _source(context.format(expression=_call(record, ["2.0"]))) + expected = transpile(positional, check_support=check_support) + named = _source(context.format(expression=_call( + record, [f"{shared_name} = 2.0"], + ))) + assert _expressions(transpile( + named, check_support=check_support, + ), record) == _expressions(expected, record) + source = _source(context.format(expression=_call( + record, ["2.0", f"{unknown} = 3.0"], + ))) + if short_name == verified_short_name: + with pytest.raises(CompileError) as caught: + transpile(source, check_support=check_support) + diagnostic, = [ + diagnostic for diagnostic in caught.value.diagnostics + if diagnostic.level == Level.ERROR + ] + expected_args = {"key": record.name, "name": unknown} + assert diagnostic.code == "PF-E2027" + assert diagnostic.args == expected_args + assert (diagnostic.message, diagnostic.hint) == render_diagnostic( + "PF-E2027", expected_args, + ) + start = source.index(f"{unknown} =") + line = source.count("\n", 0, start) + 1 + col = start - source.rfind("\n", 0, start) + assert ( + diagnostic.location.line, diagnostic.location.col, + diagnostic.location.end_col, + ) == (line, col, col + len(unknown)) + else: + before = transpile(source, check_support=check_support).encode("utf-8") + with monkeypatch.context() as verify: + verify.setattr(record, "keyword_names_verified", True) + with pytest.raises(CompileError): + transpile(source, check_support=check_support) + assert record.keyword_names_verified is False + assert transpile( + source, check_support=check_support, + ).encode("utf-8") == before + for short_name, original in originals.items(): + assert signatures.MATH_FUNCTIONS[short_name] is original diff --git a/tests/test_intrinsic_registry.py b/tests/test_intrinsic_registry.py new file mode 100644 index 00000000..905b5d9b --- /dev/null +++ b/tests/test_intrinsic_registry.py @@ -0,0 +1,40 @@ +"""Every intrinsic registry participates in keyword-name lookup.""" + +from pineforge_codegen import signatures +from pineforge_codegen.signatures import IntrinsicFunc + + +def test_every_intrinsic_registry_record_is_reachable(): + registries = { + name: registry + for name, registry in vars(signatures).items() + if isinstance(registry, dict) and registry + and all(isinstance(record, IntrinsicFunc) for record in registry.values()) + } + assert registries, "no intrinsic registries discovered" + for registry_name, registry in registries.items(): + for key, record in registry.items(): + namespace = None + if record.name != key: + suffix = f".{key}" + assert record.name.endswith(suffix), (registry_name, key, record.name) + namespace = record.name[:-len(suffix)] + assert signatures.get_intrinsic(namespace, key) is record, ( + registry_name, key, record.name, + ) + + +def test_registry_records_carry_no_evidence_fields(): + import dataclasses + import re + + banned = ("ta" + "pe", "dig" + "est", "sha" + "256") + for field in dataclasses.fields(IntrinsicFunc): + assert not any(word in field.name.lower() for word in banned), field.name + for name, value in vars(signatures).items(): + assert not any(word in name.lower() for word in banned), name + if isinstance(value, dict): + for record in value.values(): + if isinstance(record, IntrinsicFunc): + for item in dataclasses.astuple(record): + assert not re.search(r"\b[0-9a-f]{64}\b", repr(item)), record.name diff --git a/tests/test_signatures.py b/tests/test_signatures.py index ebedb871..4ddb3fb2 100644 --- a/tests/test_signatures.py +++ b/tests/test_signatures.py @@ -649,7 +649,7 @@ def test_math_sqrt_kwargs(self): assert "std::sqrt" in cpp def test_math_pow_kwargs(self): - cpp = _generate(_pine('x = math.pow(base=2.0, exp=3.0)')) + cpp = _generate(_pine('x = math.pow(base=2.0, exponent=3.0)')) assert "std::pow" in cpp def test_math_log_kwargs(self):