Phase 2: IO detection & selective expansion in deps.sx

Extend the spec with IO scanning functions (scan-io-refs, transitive-io-refs,
compute-all-io-refs, component-pure?) that detect IO primitive references in
component ASTs. Components are classified as pure (no IO deps, safe for client
rendering) or IO-dependent (must expand server-side).

The partial evaluator (_aser) now uses per-component IO metadata instead of
the global _expand_components toggle: IO-dependent components expand server-
side, pure components serialize for client. Layout slot context still expands
all components for backwards compat.

Spec: 5 new functions + 2 platform interface additions in deps.sx
Host: io_refs field + is_pure property on Component, compute_all_io_refs()
Bootstrap: both sx_ref.py and sx-ref.js updated with IO functions
Bundle analyzer: shows pure/IO classification per page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 13:19:17 +00:00
parent 652e7f81c8
commit 0ba7ebe349
13 changed files with 409 additions and 46 deletions

View File

@@ -68,6 +68,62 @@ def _compute_all_deps_fallback(env: dict[str, Any]) -> None:
val.deps = _transitive_deps_fallback(key, env)
def _scan_io_refs_fallback(node: Any, io_names: set[str]) -> set[str]:
"""Scan an AST node for references to IO primitive names."""
refs: set[str] = set()
_walk_io(node, io_names, refs)
return refs
def _walk_io(node: Any, io_names: set[str], refs: set[str]) -> None:
if isinstance(node, Symbol):
if node.name in io_names:
refs.add(node.name)
return
if isinstance(node, list):
for item in node:
_walk_io(item, io_names, refs)
return
if isinstance(node, dict):
for v in node.values():
_walk_io(v, io_names, refs)
return
def _transitive_io_refs_fallback(
name: str, env: dict[str, Any], io_names: set[str]
) -> set[str]:
"""Compute transitive IO primitive references for a component."""
all_refs: set[str] = set()
seen: set[str] = set()
def walk(n: str) -> None:
if n in seen:
return
seen.add(n)
val = env.get(n)
if isinstance(val, Component):
all_refs.update(_scan_io_refs_fallback(val.body, io_names))
for dep in _scan_ast(val.body):
walk(dep)
elif isinstance(val, Macro):
all_refs.update(_scan_io_refs_fallback(val.body, io_names))
for dep in _scan_ast(val.body):
walk(dep)
key = name if name.startswith("~") else f"~{name}"
walk(key)
return all_refs
def _compute_all_io_refs_fallback(
env: dict[str, Any], io_names: set[str]
) -> None:
for key, val in env.items():
if isinstance(val, Component):
val.io_refs = _transitive_io_refs_fallback(key, env, io_names)
def _scan_components_from_sx_fallback(source: str) -> set[str]:
import re
return {f"~{m}" for m in re.findall(r'\(~([a-zA-Z_][a-zA-Z0-9_\-]*)', source)}
@@ -131,3 +187,27 @@ def components_needed(page_sx: str, env: dict[str, Any]) -> set[str]:
from .ref.sx_ref import components_needed as _ref_cn
return set(_ref_cn(page_sx, env))
return _components_needed_fallback(page_sx, env)
def compute_all_io_refs(env: dict[str, Any], io_names: set[str]) -> None:
"""Compute and cache transitive IO refs for all Component entries in *env*."""
if _use_ref():
from .ref.sx_ref import compute_all_io_refs as _ref_cio
_ref_cio(env, list(io_names))
return
_compute_all_io_refs_fallback(env, io_names)
def get_all_io_names() -> set[str]:
"""Build the complete set of IO primitive names from all boundary tiers.
Includes: core IO (primitives_io.py handlers), plus all page helper names
from every service boundary.
"""
from .primitives_io import IO_PRIMITIVES
from .boundary import declared_helpers
names = set(IO_PRIMITIVES)
for _svc, helper_names in declared_helpers().items():
names.update(helper_names)
return names