Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pineforge_codegen/codegen/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions pineforge_codegen/codegen/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 26 additions & 5 deletions pineforge_codegen/codegen/visit_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<double>()"]
Expand Down Expand Up @@ -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("")'
Expand All @@ -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("")'
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
50 changes: 50 additions & 0 deletions pineforge_codegen/intrinsic_binding.py
Original file line number Diff line number Diff line change
@@ -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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — intrinsic_keyword_diagnostics()

high coupling complexity (Ca·Ce = 12).

Grounded coupling-delta finding (deterministic), not an LLM guess.

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("<input>", 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)
3 changes: 3 additions & 0 deletions pineforge_codegen/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
34 changes: 29 additions & 5 deletions pineforge_codegen/signatures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)]))
Expand All @@ -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)]))
Expand Down Expand Up @@ -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).

Expand Down
4 changes: 4 additions & 0 deletions pineforge_codegen/support_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Loading