All signal operations (computed, effect, batch, etc.) now dispatch function calls through cek-call, which routes SX lambdas via cek-run and native callables via apply. This replaces the invoke shim. Key changes: - cek.sx: add cek-call (defined before reactive-shift-deref), replace invoke in subscriber disposal and ReactiveResetFrame handler - signals.sx: replace all 11 invoke calls with cek-call - js.sx: fix octal escape in js-quote-string (char-from-code 0) - platform_js.py: fix JS append to match Python (list concat semantics), add Continuation type guard in PLATFORM_CEK_JS, add scheduleIdle safety check, module ordering (cek before signals) - platform_py.py: fix ident-char regex (remove [ ] from valid chars), module ordering (cek before signals) - run_js_sx.py: emit PLATFORM_CEK_JS before transpiled spec files - page-functions.sx: add cek and provide page functions for SX URLs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
146 lines
4.8 KiB
Python
146 lines
4.8 KiB
Python
"""
|
|
SX Boundary Enforcement — runtime validation.
|
|
|
|
Reads declarations from boundary.sx + primitives.sx and validates
|
|
that all registered primitives, I/O handlers, and page helpers
|
|
are declared in the spec.
|
|
|
|
Controlled by SX_BOUNDARY_STRICT env var:
|
|
- "1": validation raises errors (fail fast)
|
|
- anything else: validation logs warnings
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger("sx.boundary")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lazy-loaded declaration sets (populated on first use)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_DECLARED_PURE: frozenset[str] | None = None
|
|
_DECLARED_IO: frozenset[str] | None = None
|
|
_DECLARED_HELPERS: dict[str, frozenset[str]] | None = None
|
|
|
|
|
|
def _load_declarations() -> None:
|
|
global _DECLARED_PURE, _DECLARED_IO, _DECLARED_HELPERS
|
|
if _DECLARED_PURE is not None:
|
|
return
|
|
try:
|
|
from .ref.boundary_parser import parse_primitives_sx, parse_boundary_sx
|
|
_DECLARED_PURE = parse_primitives_sx()
|
|
_DECLARED_IO, _DECLARED_HELPERS = parse_boundary_sx()
|
|
logger.debug(
|
|
"Boundary loaded: %d pure, %d io, %d services",
|
|
len(_DECLARED_PURE), len(_DECLARED_IO), len(_DECLARED_HELPERS),
|
|
)
|
|
except Exception as e:
|
|
# Don't cache failure — parser may not be ready yet (circular import
|
|
# during startup). Will retry on next call. Validation functions
|
|
# skip checks when declarations aren't loaded.
|
|
logger.debug("Boundary declarations not ready yet: %s", e)
|
|
|
|
|
|
def _is_strict() -> bool:
|
|
return os.environ.get("SX_BOUNDARY_STRICT") == "1"
|
|
|
|
|
|
def _report(message: str) -> None:
|
|
if _is_strict():
|
|
raise RuntimeError(f"SX boundary violation: {message}")
|
|
else:
|
|
logger.warning("SX boundary: %s", message)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation functions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def validate_primitive(name: str) -> None:
|
|
"""Validate that a pure primitive is declared in primitives.sx."""
|
|
_load_declarations()
|
|
if _DECLARED_PURE is None:
|
|
return # Not ready yet (circular import during startup), skip
|
|
if name not in _DECLARED_PURE:
|
|
_report(f"Undeclared pure primitive: {name!r}. Add to primitives.sx.")
|
|
|
|
|
|
def validate_io(name: str) -> None:
|
|
"""Validate that an I/O primitive is declared in boundary.sx or boundary-app.sx."""
|
|
_load_declarations()
|
|
if _DECLARED_IO is None:
|
|
return # Not ready yet, skip
|
|
if name not in _DECLARED_IO:
|
|
_report(
|
|
f"Undeclared I/O primitive: {name!r}. "
|
|
f"Add to boundary.sx (core) or boundary-app.sx (deployment)."
|
|
)
|
|
|
|
|
|
def validate_helper(service: str, name: str) -> None:
|
|
"""Validate that a page helper is declared in {service}/sx/boundary.sx."""
|
|
_load_declarations()
|
|
if _DECLARED_HELPERS is None:
|
|
return # Not ready yet, skip
|
|
svc_helpers = _DECLARED_HELPERS.get(service, frozenset())
|
|
if name not in svc_helpers:
|
|
_report(
|
|
f"Undeclared page helper: {name!r} for service {service!r}. "
|
|
f"Add to {service}/sx/boundary.sx."
|
|
)
|
|
|
|
|
|
def validate_boundary_value(value: Any, context: str = "") -> None:
|
|
"""Validate that a value is an allowed SX boundary type.
|
|
|
|
Allowed: int, float, str, bool, None/NIL, list, dict, SxExpr.
|
|
NOT allowed: datetime, ORM models, Quart objects, raw callables.
|
|
"""
|
|
from .types import NIL
|
|
from .parser import SxExpr
|
|
|
|
if value is None or value is NIL:
|
|
return
|
|
if isinstance(value, (int, float, str, bool)):
|
|
return
|
|
if isinstance(value, SxExpr):
|
|
return
|
|
if isinstance(value, list):
|
|
for item in value:
|
|
validate_boundary_value(item, context)
|
|
return
|
|
if isinstance(value, dict):
|
|
for k, v in value.items():
|
|
validate_boundary_value(v, context)
|
|
return
|
|
|
|
type_name = type(value).__name__
|
|
ctx_msg = f" (in {context})" if context else ""
|
|
_report(
|
|
f"Non-SX type crossing boundary{ctx_msg}: {type_name}. "
|
|
f"Convert to dict/string at the edge."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Declaration accessors (for introspection / bootstrapper use)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def declared_pure() -> frozenset[str]:
|
|
_load_declarations()
|
|
return _DECLARED_PURE or frozenset()
|
|
|
|
|
|
def declared_io() -> frozenset[str]:
|
|
_load_declarations()
|
|
return _DECLARED_IO or frozenset()
|
|
|
|
|
|
def declared_helpers() -> dict[str, frozenset[str]]:
|
|
_load_declarations()
|
|
return dict(_DECLARED_HELPERS) if _DECLARED_HELPERS else {}
|