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:
@@ -1332,8 +1332,9 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
|
||||
if isinstance(val, Macro):
|
||||
expanded = _expand_macro(val, expr[1:], env)
|
||||
return await _aser(expanded, env, ctx)
|
||||
if isinstance(val, Component) and _expand_components.get():
|
||||
return await _aser_component(val, expr[1:], env, ctx)
|
||||
if isinstance(val, Component):
|
||||
if _expand_components.get() or not val.is_pure:
|
||||
return await _aser_component(val, expr[1:], env, ctx)
|
||||
return await _aser_call(name, expr[1:], env, ctx)
|
||||
|
||||
# Serialize-mode special/HO forms (checked BEFORE HTML_TAGS
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -204,8 +204,9 @@ def register_components(sx_source: str) -> None:
|
||||
val.css_classes = set(all_classes)
|
||||
|
||||
# Recompute transitive deps for all components (cheap — just AST walking)
|
||||
from .deps import compute_all_deps
|
||||
from .deps import compute_all_deps, compute_all_io_refs, get_all_io_names
|
||||
compute_all_deps(_COMPONENT_ENV)
|
||||
compute_all_io_refs(_COMPONENT_ENV, get_all_io_names())
|
||||
|
||||
_compute_component_hash()
|
||||
|
||||
|
||||
@@ -502,9 +502,17 @@ class JSEmitter:
|
||||
"component-deps": "componentDeps",
|
||||
"component-set-deps!": "componentSetDeps",
|
||||
"component-css-classes": "componentCssClasses",
|
||||
"component-io-refs": "componentIoRefs",
|
||||
"component-set-io-refs!": "componentSetIoRefs",
|
||||
"env-components": "envComponents",
|
||||
"regex-find-all": "regexFindAll",
|
||||
"scan-css-classes": "scanCssClasses",
|
||||
# deps.sx IO detection
|
||||
"scan-io-refs": "scanIoRefs",
|
||||
"scan-io-refs-walk": "scanIoRefsWalk",
|
||||
"transitive-io-refs": "transitiveIoRefs",
|
||||
"compute-all-io-refs": "computeAllIoRefs",
|
||||
"component-pure?": "componentPure_p",
|
||||
}
|
||||
if name in RENAMES:
|
||||
return RENAMES[name]
|
||||
@@ -1904,6 +1912,14 @@ PLATFORM_DEPS_JS = '''
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function componentIoRefs(c) {
|
||||
return c.ioRefs ? c.ioRefs.slice() : [];
|
||||
}
|
||||
|
||||
function componentSetIoRefs(c, refs) {
|
||||
c.ioRefs = refs;
|
||||
}
|
||||
'''
|
||||
|
||||
PLATFORM_PARSER_JS = r"""
|
||||
@@ -3081,6 +3097,10 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has
|
||||
api_lines.append(' componentsNeeded: componentsNeeded,')
|
||||
api_lines.append(' pageComponentBundle: pageComponentBundle,')
|
||||
api_lines.append(' pageCssClasses: pageCssClasses,')
|
||||
api_lines.append(' scanIoRefs: scanIoRefs,')
|
||||
api_lines.append(' transitiveIoRefs: transitiveIoRefs,')
|
||||
api_lines.append(' computeAllIoRefs: computeAllIoRefs,')
|
||||
api_lines.append(' componentPure_p: componentPure_p,')
|
||||
|
||||
api_lines.append(f' _version: "{version}"')
|
||||
api_lines.append(' };')
|
||||
|
||||
@@ -247,9 +247,17 @@ class PyEmitter:
|
||||
"component-deps": "component_deps",
|
||||
"component-set-deps!": "component_set_deps",
|
||||
"component-css-classes": "component_css_classes",
|
||||
"component-io-refs": "component_io_refs",
|
||||
"component-set-io-refs!": "component_set_io_refs",
|
||||
"env-components": "env_components",
|
||||
"regex-find-all": "regex_find_all",
|
||||
"scan-css-classes": "scan_css_classes",
|
||||
# deps.sx IO detection
|
||||
"scan-io-refs": "scan_io_refs",
|
||||
"scan-io-refs-walk": "scan_io_refs_walk",
|
||||
"transitive-io-refs": "transitive_io_refs",
|
||||
"compute-all-io-refs": "compute_all_io_refs",
|
||||
"component-pure?": "component_pure_p",
|
||||
}
|
||||
if name in RENAMES:
|
||||
return RENAMES[name]
|
||||
@@ -1982,6 +1990,14 @@ PLATFORM_DEPS_PY = (
|
||||
' for m in _re.finditer(r\';;\\s*@css\\s+(.+)\', source):\n'
|
||||
' classes.update(m.group(1).split())\n'
|
||||
' return list(classes)\n'
|
||||
'\n'
|
||||
'def component_io_refs(c):\n'
|
||||
' """Return cached IO refs list for a component (may be empty)."""\n'
|
||||
' return list(c.io_refs) if hasattr(c, "io_refs") and c.io_refs else []\n'
|
||||
'\n'
|
||||
'def component_set_io_refs(c, refs):\n'
|
||||
' """Cache IO refs on a component."""\n'
|
||||
' c.io_refs = set(refs) if not isinstance(refs, set) else refs\n'
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -208,6 +208,112 @@
|
||||
classes)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 8. IO detection — scan component ASTs for IO primitive references
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Extends the dependency walker to detect references to IO primitives.
|
||||
;; IO names are provided by the host (from boundary.sx declarations).
|
||||
;; A component is "pure" if it (transitively) references no IO primitives.
|
||||
;;
|
||||
;; Platform interface additions:
|
||||
;; (component-io-refs c) → cached IO ref list (may be empty)
|
||||
;; (component-set-io-refs! c r) → cache IO refs on component
|
||||
|
||||
(define scan-io-refs-walk
|
||||
(fn (node io-names refs)
|
||||
(cond
|
||||
;; Symbol → check if name is in the IO set
|
||||
(= (type-of node) "symbol")
|
||||
(let ((name (symbol-name node)))
|
||||
(when (contains? io-names name)
|
||||
(when (not (contains? refs name))
|
||||
(append! refs name))))
|
||||
|
||||
;; List → recurse into all elements
|
||||
(= (type-of node) "list")
|
||||
(for-each (fn (item) (scan-io-refs-walk item io-names refs)) node)
|
||||
|
||||
;; Dict → recurse into values
|
||||
(= (type-of node) "dict")
|
||||
(for-each (fn (key) (scan-io-refs-walk (dict-get node key) io-names refs))
|
||||
(keys node))
|
||||
|
||||
;; Literals → no IO refs
|
||||
:else nil)))
|
||||
|
||||
|
||||
(define scan-io-refs
|
||||
(fn (node io-names)
|
||||
(let ((refs (list)))
|
||||
(scan-io-refs-walk node io-names refs)
|
||||
refs)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 9. Transitive IO refs — follow component deps and union IO refs
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define transitive-io-refs-walk
|
||||
(fn (n seen all-refs env io-names)
|
||||
(when (not (contains? seen n))
|
||||
(append! seen n)
|
||||
(let ((val (env-get env n)))
|
||||
(cond
|
||||
(= (type-of val) "component")
|
||||
(do
|
||||
;; Scan this component's body for IO refs
|
||||
(for-each
|
||||
(fn (ref)
|
||||
(when (not (contains? all-refs ref))
|
||||
(append! all-refs ref)))
|
||||
(scan-io-refs (component-body val) io-names))
|
||||
;; Recurse into component deps
|
||||
(for-each
|
||||
(fn (dep) (transitive-io-refs-walk dep seen all-refs env io-names))
|
||||
(scan-refs (component-body val))))
|
||||
|
||||
(= (type-of val) "macro")
|
||||
(do
|
||||
(for-each
|
||||
(fn (ref)
|
||||
(when (not (contains? all-refs ref))
|
||||
(append! all-refs ref)))
|
||||
(scan-io-refs (macro-body val) io-names))
|
||||
(for-each
|
||||
(fn (dep) (transitive-io-refs-walk dep seen all-refs env io-names))
|
||||
(scan-refs (macro-body val))))
|
||||
|
||||
:else nil)))))
|
||||
|
||||
|
||||
(define transitive-io-refs
|
||||
(fn (name env io-names)
|
||||
(let ((all-refs (list))
|
||||
(seen (list))
|
||||
(key (if (starts-with? name "~") name (str "~" name))))
|
||||
(transitive-io-refs-walk key seen all-refs env io-names)
|
||||
all-refs)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 10. Compute IO refs for all components in an environment
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define compute-all-io-refs
|
||||
(fn (env io-names)
|
||||
(for-each
|
||||
(fn (name)
|
||||
(let ((val (env-get env name)))
|
||||
(when (= (type-of val) "component")
|
||||
(component-set-io-refs! val (transitive-io-refs name env io-names)))))
|
||||
(env-components env))))
|
||||
|
||||
|
||||
(define component-pure?
|
||||
(fn (name env io-names)
|
||||
(empty? (transitive-io-refs name env io-names))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform interface summary
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -223,6 +329,8 @@
|
||||
;; (component-deps c) → cached deps list (may be empty)
|
||||
;; (component-set-deps! c d)→ cache deps on component
|
||||
;; (component-css-classes c)→ pre-scanned CSS class list
|
||||
;; (component-io-refs c) → cached IO ref list (may be empty)
|
||||
;; (component-set-io-refs! c r)→ cache IO refs on component
|
||||
;; (macro-body m) → AST body of macro
|
||||
;; (env-components env) → list of component names in env
|
||||
;; (regex-find-all pat src) → list of capture group matches
|
||||
|
||||
@@ -917,6 +917,14 @@ def scan_css_classes(source):
|
||||
classes.update(m.group(1).split())
|
||||
return list(classes)
|
||||
|
||||
def component_io_refs(c):
|
||||
"""Return cached IO refs list for a component (may be empty)."""
|
||||
return list(c.io_refs) if hasattr(c, "io_refs") and c.io_refs else []
|
||||
|
||||
def component_set_io_refs(c, refs):
|
||||
"""Cache IO refs on a component."""
|
||||
c.io_refs = set(refs) if not isinstance(refs, set) else refs
|
||||
|
||||
|
||||
# === Transpiled from eval ===
|
||||
|
||||
@@ -1210,6 +1218,24 @@ page_component_bundle = lambda page_source, env: components_needed(page_source,
|
||||
# page-css-classes
|
||||
page_css_classes = lambda page_source, env: (lambda needed: (lambda classes: _sx_begin(for_each(lambda name: (lambda val: (for_each(lambda cls: (_sx_append(classes, cls) if sx_truthy((not sx_truthy(contains_p(classes, cls)))) else NIL), component_css_classes(val)) if sx_truthy((type_of(val) == 'component')) else NIL))(env_get(env, name)), needed), for_each(lambda cls: (_sx_append(classes, cls) if sx_truthy((not sx_truthy(contains_p(classes, cls)))) else NIL), scan_css_classes(page_source)), classes))([]))(components_needed(page_source, env))
|
||||
|
||||
# scan-io-refs-walk
|
||||
scan_io_refs_walk = lambda node, io_names, refs: ((lambda name: ((_sx_append(refs, name) if sx_truthy((not sx_truthy(contains_p(refs, name)))) else NIL) if sx_truthy(contains_p(io_names, name)) else NIL))(symbol_name(node)) if sx_truthy((type_of(node) == 'symbol')) else (for_each(lambda item: scan_io_refs_walk(item, io_names, refs), node) if sx_truthy((type_of(node) == 'list')) else (for_each(lambda key: scan_io_refs_walk(dict_get(node, key), io_names, refs), keys(node)) if sx_truthy((type_of(node) == 'dict')) else NIL)))
|
||||
|
||||
# scan-io-refs
|
||||
scan_io_refs = lambda node, io_names: (lambda refs: _sx_begin(scan_io_refs_walk(node, io_names, refs), refs))([])
|
||||
|
||||
# transitive-io-refs-walk
|
||||
transitive_io_refs_walk = lambda n, seen, all_refs, env, io_names: (_sx_begin(_sx_append(seen, n), (lambda val: (_sx_begin(for_each(lambda ref: (_sx_append(all_refs, ref) if sx_truthy((not sx_truthy(contains_p(all_refs, ref)))) else NIL), scan_io_refs(component_body(val), io_names)), for_each(lambda dep: transitive_io_refs_walk(dep, seen, all_refs, env, io_names), scan_refs(component_body(val)))) if sx_truthy((type_of(val) == 'component')) else (_sx_begin(for_each(lambda ref: (_sx_append(all_refs, ref) if sx_truthy((not sx_truthy(contains_p(all_refs, ref)))) else NIL), scan_io_refs(macro_body(val), io_names)), for_each(lambda dep: transitive_io_refs_walk(dep, seen, all_refs, env, io_names), scan_refs(macro_body(val)))) if sx_truthy((type_of(val) == 'macro')) else NIL)))(env_get(env, n))) if sx_truthy((not sx_truthy(contains_p(seen, n)))) else NIL)
|
||||
|
||||
# transitive-io-refs
|
||||
transitive_io_refs = lambda name, env, io_names: (lambda all_refs: (lambda seen: (lambda key: _sx_begin(transitive_io_refs_walk(key, seen, all_refs, env, io_names), all_refs))((name if sx_truthy(starts_with_p(name, '~')) else sx_str('~', name))))([]))([])
|
||||
|
||||
# compute-all-io-refs
|
||||
compute_all_io_refs = lambda env, io_names: for_each(lambda name: (lambda val: (component_set_io_refs(val, transitive_io_refs(name, env, io_names)) if sx_truthy((type_of(val) == 'component')) else NIL))(env_get(env, name)), env_components(env))
|
||||
|
||||
# component-pure?
|
||||
component_pure_p = lambda name, env, io_names: empty_p(transitive_io_refs(name, env, io_names))
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Fixups -- wire up render adapter dispatch
|
||||
|
||||
@@ -168,6 +168,12 @@ class Component:
|
||||
closure: dict[str, Any] = field(default_factory=dict)
|
||||
css_classes: set[str] = field(default_factory=set) # pre-scanned :class values
|
||||
deps: set[str] = field(default_factory=set) # transitive component deps (~names)
|
||||
io_refs: set[str] = field(default_factory=set) # transitive IO primitive refs
|
||||
|
||||
@property
|
||||
def is_pure(self) -> bool:
|
||||
"""True if this component has no transitive IO dependencies."""
|
||||
return not self.io_refs
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Component ~{self.name}({', '.join(self.params)})>"
|
||||
|
||||
Reference in New Issue
Block a user