diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index 8ca8260..4db4434 100644 --- a/shared/static/scripts/sx-ref.js +++ b/shared/static/scripts/sx-ref.js @@ -609,6 +609,14 @@ return result; } + function componentIoRefs(c) { + return c.ioRefs ? c.ioRefs.slice() : []; + } + + function componentSetIoRefs(c, refs) { + c.ioRefs = refs; + } + // ========================================================================= // Platform interface — Parser @@ -2456,6 +2464,43 @@ callExpr.push(dictGet(kwargs, k)); } } return classes; })(); }; + // scan-io-refs-walk + var scanIoRefsWalk = function(node, ioNames, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() { + var name = symbolName(node); + return (isSxTruthy(contains(ioNames, name)) ? (isSxTruthy(!contains(refs, name)) ? append_b(refs, name) : NIL) : NIL); +})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanIoRefsWalk(item, ioNames, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanIoRefsWalk(dictGet(node, key), ioNames, refs); }, keys(node)) : NIL))); }; + + // scan-io-refs + var scanIoRefs = function(node, ioNames) { return (function() { + var refs = []; + scanIoRefsWalk(node, ioNames, refs); + return refs; +})(); }; + + // transitive-io-refs-walk + var transitiveIoRefsWalk = function(n, seen, allRefs, env, ioNames) { return (isSxTruthy(!contains(seen, n)) ? (append_b(seen, n), (function() { + var val = envGet(env, n); + return (isSxTruthy((typeOf(val) == "component")) ? (forEach(function(ref) { return (isSxTruthy(!contains(allRefs, ref)) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(componentBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(componentBody(val)))) : (isSxTruthy((typeOf(val) == "macro")) ? (forEach(function(ref) { return (isSxTruthy(!contains(allRefs, ref)) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(macroBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(macroBody(val)))) : NIL)); +})()) : NIL); }; + + // transitive-io-refs + var transitiveIoRefs = function(name, env, ioNames) { return (function() { + var allRefs = []; + var seen = []; + var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name))); + transitiveIoRefsWalk(key, seen, allRefs, env, ioNames); + return allRefs; +})(); }; + + // compute-all-io-refs + var computeAllIoRefs = function(env, ioNames) { return forEach(function(name) { return (function() { + var val = envGet(env, name); + return (isSxTruthy((typeOf(val) == "component")) ? componentSetIoRefs(val, transitiveIoRefs(name, env, ioNames)) : NIL); +})(); }, envComponents(env)); }; + + // component-pure? + var componentPure_p = function(name, env, ioNames) { return isEmpty(transitiveIoRefs(name, env, ioNames)); }; + // ========================================================================= // Platform interface — DOM adapter (browser-only) @@ -3626,6 +3671,10 @@ callExpr.push(dictGet(kwargs, k)); } } componentsNeeded: componentsNeeded, pageComponentBundle: pageComponentBundle, pageCssClasses: pageCssClasses, + scanIoRefs: scanIoRefs, + transitiveIoRefs: transitiveIoRefs, + computeAllIoRefs: computeAllIoRefs, + componentPure_p: componentPure_p, _version: "ref-2.0 (boot+cssx+dom+engine+html+orchestration+parser+sx, bootstrap-compiled)" }; @@ -3649,4 +3698,4 @@ callExpr.push(dictGet(kwargs, k)); } } if (typeof module !== "undefined" && module.exports) module.exports = Sx; else global.Sx = Sx; -})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this); \ No newline at end of file +})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this); diff --git a/shared/sx/async_eval.py b/shared/sx/async_eval.py index c9827d2..de0950d 100644 --- a/shared/sx/async_eval.py +++ b/shared/sx/async_eval.py @@ -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 diff --git a/shared/sx/deps.py b/shared/sx/deps.py index 488fb4c..ceeee4a 100644 --- a/shared/sx/deps.py +++ b/shared/sx/deps.py @@ -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 diff --git a/shared/sx/jinja_bridge.py b/shared/sx/jinja_bridge.py index 71533d1..9f44cd4 100644 --- a/shared/sx/jinja_bridge.py +++ b/shared/sx/jinja_bridge.py @@ -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() diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index 3efe0f0..160bbc2 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -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(' };') diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index 01630a2..b98f2de 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -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' ) diff --git a/shared/sx/ref/deps.sx b/shared/sx/ref/deps.sx index c0ff265..10bc059 100644 --- a/shared/sx/ref/deps.sx +++ b/shared/sx/ref/deps.sx @@ -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 diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index 64a250c..1cf606d 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -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 diff --git a/shared/sx/types.py b/shared/sx/types.py index 4207421..380f2ce 100644 --- a/shared/sx/types.py +++ b/shared/sx/types.py @@ -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"" diff --git a/sx/sx/analyzer.sx b/sx/sx/analyzer.sx index 2220d76..43853f8 100644 --- a/sx/sx/analyzer.sx +++ b/sx/sx/analyzer.sx @@ -1,25 +1,30 @@ -;; Bundle analyzer — live demonstration of Phase 1 component dependency analysis. +;; Bundle analyzer — live demonstration of dependency analysis + IO detection. ;; Shows per-page component bundles vs total, visualizing payload savings. -;; @css bg-green-100 text-green-800 bg-violet-600 bg-stone-200 text-violet-600 text-stone-600 text-green-600 rounded-full h-2.5 grid-cols-3 +;; Phase 2 adds IO classification: which components are pure vs IO-dependent. +;; @css bg-green-100 text-green-800 bg-violet-600 bg-stone-200 text-violet-600 text-stone-600 text-green-600 rounded-full h-2.5 grid-cols-3 bg-blue-100 text-blue-800 bg-amber-100 text-amber-800 grid-cols-4 -(defcomp ~bundle-analyzer-content (&key pages total-components total-macros) +(defcomp ~bundle-analyzer-content (&key pages total-components total-macros + pure-count io-count) (~doc-page :title "Page Bundle Analyzer" (p :class "text-stone-600 mb-6" - "Live analysis of component dependency graphs across all pages in this app. " + "Live analysis of component dependency graphs and IO classification across all pages. " "Each bar shows how many of the " (strong (str total-components)) " total components a page actually needs, computed by the " (a :href "/specs/deps" :class "text-violet-700 underline" "deps.sx") - " transitive closure algorithm.") + " transitive closure algorithm. " + "Phase 2 IO detection classifies each component as pure or IO-dependent.") - (div :class "mb-8 grid grid-cols-3 gap-4" + (div :class "mb-8 grid grid-cols-4 gap-4" (~analyzer-stat :label "Total Components" :value (str total-components) :cls "text-violet-600") (~analyzer-stat :label "Total Macros" :value (str total-macros) :cls "text-stone-600") - (~analyzer-stat :label "Pages Analyzed" :value (str (len pages)) - :cls "text-green-600")) + (~analyzer-stat :label "Pure Components" :value (str pure-count) + :cls "text-blue-600") + (~analyzer-stat :label "IO-Dependent" :value (str io-count) + :cls "text-amber-600")) (~doc-section :title "Per-Page Bundles" :id "bundles" (div :class "space-y-3" @@ -31,7 +36,10 @@ :direct (get page "direct") :total total-components :pct (get page "pct") - :savings (get page "savings"))) + :savings (get page "savings") + :io-refs (get page "io-refs") + :pure-in-page (get page "pure-in-page") + :io-in-page (get page "io-in-page"))) pages))) (~doc-section :title "How It Works" :id "how" @@ -39,7 +47,8 @@ (li (strong "Scan: ") "Regex finds all " (code "(~name") " patterns in the page's content expression.") (li (strong "Resolve: ") "Each referenced component's body AST is walked to find transitive " (code "~") " references.") (li (strong "Closure: ") "The full set is the union of direct + transitive deps, following chains through the component graph.") - (li (strong "Bundle: ") "Only these component definitions are serialized into the page payload. Everything else is omitted.")) + (li (strong "Bundle: ") "Only these component definitions are serialized into the page payload. Everything else is omitted.") + (li (strong "IO detect: ") "Each component body is scanned for references to IO primitives (frag, query, service, etc.). Components with zero transitive IO refs are pure — safe for client rendering.")) (p :class "mt-4 text-stone-600" "The analysis handles circular references (via seen-set), " "walks all branches of control flow (if/when/cond/case), " @@ -50,18 +59,24 @@ (div :class (str "text-3xl font-bold " cls) value) (div :class "text-sm text-stone-500 mt-1" label))) -(defcomp ~analyzer-row (&key name path needed direct total pct savings) +(defcomp ~analyzer-row (&key name path needed direct total pct savings + io-refs pure-in-page io-in-page) (div :class "rounded border border-stone-200 p-4" (div :class "flex items-center justify-between mb-2" (div (span :class "font-mono font-semibold text-stone-800" name) (span :class "text-stone-400 text-sm ml-2" path)) - (div :class "text-right" - (span :class "font-mono text-sm" - (span :class "text-violet-700 font-bold" (str needed)) - (span :class "text-stone-400" (str " / " total))) - (span :class "ml-2 inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800" - (str savings "% saved")))) + (div :class "flex items-center gap-2" + (span :class "inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800" + (str pure-in-page " pure")) + (span :class "inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800" + (str io-in-page " IO")) + (div :class "text-right" + (span :class "font-mono text-sm" + (span :class "text-violet-700 font-bold" (str needed)) + (span :class "text-stone-400" (str " / " total))) + (span :class "ml-2 inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800" + (str savings "% saved"))))) (div :class "w-full bg-stone-200 rounded-full h-2.5" (div :class "bg-violet-600 h-2.5 rounded-full transition-all" :style (str "width: " pct "%"))))) diff --git a/sx/sx/plans.sx b/sx/sx/plans.sx index 5212469..c96617c 100644 --- a/sx/sx/plans.sx +++ b/sx/sx/plans.sx @@ -683,43 +683,61 @@ ;; Phase 2 ;; ----------------------------------------------------------------------- - (~doc-section :title "Phase 2: Smart Server/Client Boundary" :id "phase-2" + (~doc-section :title "Phase 2: Smart Server/Client Boundary — IO Detection" :id "phase-2" - (div :class "rounded border border-violet-200 bg-violet-50 p-4 mb-4" - (p :class "text-violet-900 font-medium" "What it enables") - (p :class "text-violet-800" "Formalized partial evaluation model. Server evaluates IO, serializes pure subtrees. The system automatically knows \"this component needs server data\" vs \"this component is pure and can render anywhere.\"")) + (div :class "rounded border border-green-300 bg-green-50 p-4 mb-4" + (div :class "flex items-center gap-2 mb-2" + (span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete") + (a :href "/specs/deps" :class "text-green-700 underline text-sm font-medium" "View canonical spec: deps.sx") + (a :href "/isomorphism/bundle-analyzer" :class "text-green-700 underline text-sm font-medium" "Live bundle analyzer with IO")) + (p :class "text-green-900 font-medium" "What it enables") + (p :class "text-green-800" "Automatic IO detection and selective expansion. Server expands IO-dependent components, serializes pure ones for client. Per-component intelligence replaces global toggle.")) - (~doc-subsection :title "Current Mechanism" - (p "_aser in async_eval.py already does partial evaluation — IO primitives are awaited and substituted, HTML tags and component calls serialize as SX. The _expand_components context var controls expansion. But this is a global toggle, not per-component.")) - - (~doc-subsection :title "Approach" + (~doc-subsection :title "IO Detection in the Spec" + (p "Five new functions in " + (a :href "/specs/deps" :class "text-violet-700 underline" "deps.sx") + " extend the Phase 1 walker to detect IO primitive references:") (div :class "space-y-4" (div - (h4 :class "font-semibold text-stone-700" "1. Automatic IO detection") - (p "Extend Phase 1 AST walker to check for references to IO_PRIMITIVES names (frag, query, service, current-user, etc.).") - (~doc-code :code (highlight "def has_io_deps(name: str, env: dict) -> bool:\n \"\"\"True if component transitively references any IO primitive.\"\"\"\n ..." "python"))) + (h4 :class "font-semibold text-stone-700" "1. IO scanning") + (p (code "scan-io-refs") " walks an AST node, collecting symbol names that match an IO name set. The IO set is provided by the host from boundary declarations (all three tiers: core IO, deployment IO, page helpers).") + (~doc-code :code (highlight "(define scan-io-refs\n (fn (node io-names)\n (let ((refs (list)))\n (scan-io-refs-walk node io-names refs)\n refs)))" "lisp"))) (div - (h4 :class "font-semibold text-stone-700" "2. Component metadata") - (~doc-code :code (highlight "ComponentMeta:\n deps: set[str] # transitive component deps (Phase 1)\n io_refs: set[str] # IO primitive names referenced\n is_pure: bool # True if io_refs empty (transitively)" "python"))) + (h4 :class "font-semibold text-stone-700" "2. Transitive IO closure") + (p (code "transitive-io-refs") " follows component deps recursively, unioning IO refs from all reachable components and macros. Cycle-safe via seen-set.") + (~doc-code :code (highlight "(define transitive-io-refs\n (fn (name env io-names)\n ;; Walk deps, scan each body for IO refs,\n ;; union all refs transitively.\n ...))" "lisp"))) (div - (h4 :class "font-semibold text-stone-700" "3. Selective expansion") - (p "Refine _aser: instead of checking a global _expand_components flag, check the component's is_pure metadata:") - (ul :class "list-disc pl-5 text-stone-700 space-y-1" - (li "IO-dependent → expand server-side (IO must resolve)") - (li "Pure → serialize for client (let client render)") - (li "Explicit override: :server true on defcomp forces server expansion"))) + (h4 :class "font-semibold text-stone-700" "3. Batch computation") + (p (code "compute-all-io-refs") " iterates the env, computes transitive IO refs for each component, and caches the result via " (code "component-set-io-refs!") ". Called after " (code "compute-all-deps") " at component registration time.")) (div - (h4 :class "font-semibold text-stone-700" "4. Data manifest for pages") - (p "PageDef produces a declaration of what IO the page needs, enabling Phase 3 (client can prefetch data) and Phase 5 (streaming).")))) + (h4 :class "font-semibold text-stone-700" "4. Component metadata") + (p "Each component now carries " (code "io_refs") " (transitive IO primitive names) alongside " (code "deps") " and " (code "css_classes") ". The derived " (code "is_pure") " property is true when " (code "io_refs") " is empty — the component can render anywhere without server data.")))) + + (~doc-subsection :title "Selective Expansion" + (p "The partial evaluator " (code "_aser") " now uses per-component IO metadata instead of a global toggle:") + (ul :class "list-disc pl-5 text-stone-700 space-y-1" + (li (strong "IO-dependent") " → expand server-side (IO must resolve)") + (li (strong "Pure") " → serialize for client (let client render)") + (li (strong "Layout slot context") " → all components still expand (backwards compat via " (code "_expand_components") " context var)")) + (p "A component calling " (code "(highlight ...)") " or " (code "(query ...)") " is IO-dependent. A component with only HTML tags and string ops is pure.")) + + (~doc-subsection :title "Platform interface additions" + (p "Two new platform functions each host implements:") + (ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm" + (li "(component-io-refs c) → cached IO ref list") + (li "(component-set-io-refs! c refs) → cache IO refs on component"))) (~doc-subsection :title "Verification" (ul :class "list-disc pl-5 text-stone-700 space-y-1" - (li "Components calling (query ...) classified IO-dependent; pure components classified pure") - (li "Existing pages produce identical output (regression)")))) + (li "Components calling (query ...) or (highlight ...) classified IO-dependent") + (li "Pure components (HTML-only) classified pure with empty io_refs") + (li "Transitive IO detection: component calling ~other where ~other calls (current-user) → IO-dependent") + (li "Bootstrapped to both hosts (sx_ref.py + sx-ref.js)") + (li (a :href "/isomorphism/bundle-analyzer" :class "text-violet-700 underline" "Live bundle analyzer") " shows per-page IO classification")))) ;; ----------------------------------------------------------------------- ;; Phase 3 diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index 944ea4b..21a71bd 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -414,7 +414,8 @@ :selected (or (find-current isomorphism-nav-items slug) "")) :content (case slug "bundle-analyzer" (~bundle-analyzer-content - :pages pages :total-components total-components :total-macros total-macros) + :pages pages :total-components total-components :total-macros total-macros + :pure-count pure-count :io-count io-count) :else (~plan-isomorphic-content))) (defpage bundle-analyzer @@ -428,7 +429,8 @@ :selected "Bundle Analyzer") :data (bundle-analyzer-data) :content (~bundle-analyzer-content - :pages pages :total-components total-components :total-macros total-macros)) + :pages pages :total-components total-components :total-macros total-macros + :pure-count pure-count :io-count io-count)) ;; --------------------------------------------------------------------------- ;; Plans section diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index 709bb2a..03ff445 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -277,6 +277,8 @@ def _bundle_analyzer_data() -> dict: env = get_component_env() total_components = sum(1 for v in env.values() if isinstance(v, Component)) total_macros = sum(1 for v in env.values() if isinstance(v, Macro)) + pure_count = sum(1 for v in env.values() if isinstance(v, Component) and v.is_pure) + io_count = total_components - pure_count pages_data = [] for name, page_def in sorted(get_all_pages("sx").items()): @@ -286,6 +288,20 @@ def _bundle_analyzer_data() -> dict: n = len(needed) pct = round(n / total_components * 100) if total_components else 0 savings = 100 - pct + + # IO classification for components in this page + pure_in_page = 0 + io_in_page = 0 + page_io_refs: set[str] = set() + for comp_name in needed: + val = env.get(comp_name) + if isinstance(val, Component): + if val.is_pure: + pure_in_page += 1 + else: + io_in_page += 1 + page_io_refs.update(val.io_refs) + pages_data.append({ "name": name, "path": page_def.path, @@ -293,6 +309,9 @@ def _bundle_analyzer_data() -> dict: "needed": n, "pct": pct, "savings": savings, + "io-refs": len(page_io_refs), + "pure-in-page": pure_in_page, + "io-in-page": io_in_page, }) pages_data.sort(key=lambda p: p["needed"], reverse=True) @@ -301,6 +320,8 @@ def _bundle_analyzer_data() -> dict: "pages": pages_data, "total-components": total_components, "total-macros": total_macros, + "pure-count": pure_count, + "io-count": io_count, }