Enforce SX boundary contract via boundary.sx spec + runtime validation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m33s

Add boundary.sx declaring all 34 I/O primitives, 32 page helpers, and 9
allowed boundary types. Runtime validation in boundary.py checks every
registration against the spec — undeclared primitives/helpers crash at
startup with SX_BOUNDARY_STRICT=1 (now set in both dev and prod).

Key changes:
- Move 5 I/O-in-disguise primitives (app-url, asset-url, config,
  jinja-global, relations-from) from primitives.py to primitives_io.py
- Remove duplicate url-for/route-prefix from primitives.py (already in IO)
- Fix parse-datetime to return ISO string instead of raw datetime
- Add datetime→isoformat conversion in _convert_result at the edge
- Wrap page helper return values with boundary type validation
- Replace all SxExpr(f"...") patterns with sx_call() or _sx_fragment()
- Add assert declaration to primitives.sx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 23:50:02 +00:00
parent 54adc9c216
commit 04366990ec
21 changed files with 1342 additions and 415 deletions

View File

@@ -16,6 +16,16 @@ from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
from .parser import SxExpr
def _sx_fragment(*parts: str) -> SxExpr:
"""Wrap pre-rendered SX wire format strings in a fragment.
Infrastructure utility for composing already-serialized SX strings.
NOT for building SX from Python data — use sx_call() or _render_to_sx().
"""
joined = " ".join(p for p in parts if p)
return SxExpr(f"(<> {joined})") if joined else SxExpr("")
def call_url(ctx: dict, key: str, path: str = "/") -> str:
"""Call a URL helper from context (e.g., blog_url, account_url)."""
fn = ctx.get(key)
@@ -51,8 +61,7 @@ def _as_sx(val: Any) -> SxExpr | None:
if isinstance(val, SxExpr):
return val if val.source else None
html = str(val)
escaped = html.replace("\\", "\\\\").replace('"', '\\"')
return SxExpr(f'(~rich-text :html "{escaped}")')
return sx_call("rich-text", html=html)
async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
@@ -77,7 +86,7 @@ async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
def mobile_menu_sx(*sections: str) -> SxExpr:
"""Assemble mobile menu from pre-built sections (deepest first)."""
parts = [s for s in sections if s]
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
return _sx_fragment(*parts) if parts else SxExpr("")
async def mobile_root_nav_sx(ctx: dict) -> str:
@@ -130,7 +139,7 @@ async def _post_nav_items_sx(ctx: dict) -> SxExpr:
is_admin_page=is_admin_page or None)
if admin_nav:
parts.append(admin_nav)
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
return _sx_fragment(*parts) if parts else SxExpr("")
async def _post_admin_nav_items_sx(ctx: dict, slug: str,
@@ -158,7 +167,7 @@ async def _post_admin_nav_items_sx(ctx: dict, slug: str,
parts.append(await _render_to_sx("nav-link", href=href, label=label,
select_colours=select_colours,
is_selected=is_sel or None))
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
return _sx_fragment(*parts) if parts else SxExpr("")
# ---------------------------------------------------------------------------
@@ -265,7 +274,7 @@ async def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
async def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
"""Wrap inner sx in a header-child div."""
return await _render_to_sx("header-child-sx",
id=id, inner=SxExpr(f"(<> {inner_sx})"),
id=id, inner=_sx_fragment(inner_sx),
)
@@ -273,7 +282,7 @@ async def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
content: str = "", menu: str = "") -> str:
"""Build OOB response as sx wire format."""
return await _render_to_sx("oob-sx",
oobs=SxExpr(f"(<> {oobs})") if oobs else None,
oobs=_sx_fragment(oobs) if oobs else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
menu=SxExpr(menu) if menu else None,
@@ -294,7 +303,7 @@ async def full_page_sx(ctx: dict, *, header_rows: str,
if not menu:
menu = await mobile_root_nav_sx(ctx)
body_sx = await _render_to_sx("app-body",
header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None,
header_rows=_sx_fragment(header_rows) if header_rows else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
menu=SxExpr(menu) if menu else None,
@@ -303,7 +312,7 @@ async def full_page_sx(ctx: dict, *, header_rows: str,
if meta:
# Wrap body + meta in a fragment so sx.js renders both;
# auto-hoist moves meta/title/link elements to <head>.
body_sx = "(<> " + meta + " " + body_sx + ")"
body_sx = _sx_fragment(meta, body_sx)
return sx_page(ctx, body_sx, meta_html=meta_html)