Phase 7a: affinity annotations + fix parser escape sequences
Add :affinity :client/:server/:auto annotations to defcomp, with render-target function combining affinity + IO analysis. Includes spec (eval.sx, deps.sx), tests, Python evaluator, and demo page. Fix critical bug: Python SX parser _ESCAPE_MAP was missing \r and \0, causing bootstrapped JS parser to treat 'r' as whitespace — breaking all client-side SX parsing. Also add \0 to JS string emitter and fix serializer round-tripping for \r and \0. Reserved word escaping: bootstrappers now auto-append _ to identifiers colliding with JS/Python reserved words (e.g. default → default_, final → final_), so the spec never needs to avoid host language keywords. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
177
sx/sx/affinity-demo.sx
Normal file
177
sx/sx/affinity-demo.sx
Normal file
@@ -0,0 +1,177 @@
|
||||
;; Affinity demo — Phase 7a render boundary annotations.
|
||||
;;
|
||||
;; Demonstrates :affinity annotations on defcomp and how they influence
|
||||
;; the server/client render boundary decision. Components declare where
|
||||
;; they prefer to render; the runtime combines this with IO analysis.
|
||||
|
||||
;; --- Demo components with different affinities ---
|
||||
|
||||
(defcomp ~aff-demo-auto (&key label)
|
||||
(div :class "rounded border border-stone-200 bg-white p-4"
|
||||
(div :class "flex items-center gap-2 mb-2"
|
||||
(span :class "inline-block w-2 h-2 rounded-full bg-stone-400")
|
||||
(span :class "text-sm font-mono text-stone-500" ":affinity :auto"))
|
||||
(p :class "text-stone-800" (or label "Pure component — no IO calls. Auto-detected as client-renderable."))))
|
||||
|
||||
(defcomp ~aff-demo-client (&key label)
|
||||
:affinity :client
|
||||
(div :class "rounded border border-blue-200 bg-blue-50 p-4"
|
||||
(div :class "flex items-center gap-2 mb-2"
|
||||
(span :class "inline-block w-2 h-2 rounded-full bg-blue-400")
|
||||
(span :class "text-sm font-mono text-blue-600" ":affinity :client"))
|
||||
(p :class "text-blue-800" (or label "Explicitly client-rendered — even IO calls would be proxied."))))
|
||||
|
||||
(defcomp ~aff-demo-server (&key label)
|
||||
:affinity :server
|
||||
(div :class "rounded border border-amber-200 bg-amber-50 p-4"
|
||||
(div :class "flex items-center gap-2 mb-2"
|
||||
(span :class "inline-block w-2 h-2 rounded-full bg-amber-400")
|
||||
(span :class "text-sm font-mono text-amber-600" ":affinity :server"))
|
||||
(p :class "text-amber-800" (or label "Always server-rendered — auth-sensitive or secret-dependent."))))
|
||||
|
||||
(defcomp ~aff-demo-io-auto ()
|
||||
(div :class "rounded border border-red-200 bg-red-50 p-4"
|
||||
(div :class "flex items-center gap-2 mb-2"
|
||||
(span :class "inline-block w-2 h-2 rounded-full bg-red-400")
|
||||
(span :class "text-sm font-mono text-red-600" ":affinity :auto + IO"))
|
||||
(p :class "text-red-800 mb-3" "Auto affinity with IO dependency — auto-detected as server-rendered.")
|
||||
(~doc-code :code (highlight "(render-target name env io-names)" "lisp"))))
|
||||
|
||||
(defcomp ~aff-demo-io-client ()
|
||||
:affinity :client
|
||||
(div :class "rounded border border-violet-200 bg-violet-50 p-4"
|
||||
(div :class "flex items-center gap-2 mb-2"
|
||||
(span :class "inline-block w-2 h-2 rounded-full bg-violet-400")
|
||||
(span :class "text-sm font-mono text-violet-600" ":affinity :client + IO"))
|
||||
(p :class "text-violet-800 mb-3" "Client affinity overrides IO — calls proxied to server via /sx/io/.")
|
||||
(~doc-code :code (highlight "(component-affinity comp)" "lisp"))))
|
||||
|
||||
|
||||
;; --- Main page component ---
|
||||
|
||||
(defcomp ~affinity-demo-content (&key components)
|
||||
(div :class "space-y-8"
|
||||
(div :class "border-b border-stone-200 pb-6"
|
||||
(h1 :class "text-2xl font-bold text-stone-900" "Affinity Annotations")
|
||||
(p :class "mt-2 text-stone-600"
|
||||
"Phase 7a: components declare where they prefer to render. The "
|
||||
(code :class "bg-stone-100 px-1 rounded text-violet-700" "render-target")
|
||||
" function in deps.sx combines the annotation with IO analysis to produce a per-component boundary decision."))
|
||||
|
||||
;; Syntax
|
||||
(~doc-section :title "Syntax" :id "syntax"
|
||||
(p "Add " (code ":affinity") " between the params list and the body:")
|
||||
(~doc-code :code (highlight "(defcomp ~my-component (&key title)\n :affinity :client ;; or :server, or omit for :auto\n (div title))" "lisp"))
|
||||
(p "Three values:")
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li (code ":auto") " (default) — runtime decides from IO dependency analysis")
|
||||
(li (code ":client") " — always render client-side; IO calls proxied to server")
|
||||
(li (code ":server") " — always render server-side; never sent to client as SX")))
|
||||
|
||||
;; Live components
|
||||
(~doc-section :title "Live Components" :id "live"
|
||||
(p "These components are defined with different affinities. The server analyzed them at registration time:")
|
||||
|
||||
(div :class "space-y-4 mt-4"
|
||||
(~aff-demo-auto)
|
||||
(~aff-demo-client)
|
||||
(~aff-demo-server)
|
||||
(~aff-demo-io-auto)
|
||||
(~aff-demo-io-client)))
|
||||
|
||||
;; Analysis table from server
|
||||
(~doc-section :title "Server Analysis" :id "analysis"
|
||||
(p "The server computed these render targets at component registration time:")
|
||||
(div :class "overflow-x-auto rounded border border-stone-200"
|
||||
(table :class "w-full text-left text-sm"
|
||||
(thead (tr :class "border-b border-stone-200 bg-stone-100"
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "Component")
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "Affinity")
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "IO Deps")
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "Render Target")))
|
||||
(tbody
|
||||
(map (fn (c)
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" (get c "name"))
|
||||
(td :class "px-3 py-2"
|
||||
(span :class (str "inline-block px-2 py-0.5 rounded text-xs font-bold uppercase "
|
||||
(case (get c "affinity")
|
||||
"client" "bg-blue-100 text-blue-700"
|
||||
"server" "bg-amber-100 text-amber-700"
|
||||
"bg-stone-100 text-stone-600"))
|
||||
(get c "affinity")))
|
||||
(td :class "px-3 py-2 text-stone-600"
|
||||
(if (> (len (get c "io-refs")) 0)
|
||||
(span :class "text-red-600 font-medium"
|
||||
(join ", " (get c "io-refs")))
|
||||
(span :class "text-green-600" "none")))
|
||||
(td :class "px-3 py-2"
|
||||
(span :class (str "inline-block px-2 py-0.5 rounded text-xs font-bold uppercase "
|
||||
(if (= (get c "render-target") "client")
|
||||
"bg-green-100 text-green-700"
|
||||
"bg-orange-100 text-orange-700"))
|
||||
(get c "render-target")))))
|
||||
components)))))
|
||||
|
||||
;; Decision matrix
|
||||
(~doc-section :title "Decision Matrix" :id "matrix"
|
||||
(div :class "overflow-x-auto rounded border border-stone-200"
|
||||
(table :class "w-full text-left text-sm"
|
||||
(thead (tr :class "border-b border-stone-200 bg-stone-100"
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "Affinity")
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "Has IO?")
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "Render Target")
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "Why")))
|
||||
(tbody
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono" ":auto")
|
||||
(td :class "px-3 py-2" "No")
|
||||
(td :class "px-3 py-2 font-bold text-green-700" "client")
|
||||
(td :class "px-3 py-2 text-stone-600" "Pure — can render anywhere"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono" ":auto")
|
||||
(td :class "px-3 py-2" "Yes")
|
||||
(td :class "px-3 py-2 font-bold text-orange-700" "server")
|
||||
(td :class "px-3 py-2 text-stone-600" "IO must resolve server-side"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono" ":client")
|
||||
(td :class "px-3 py-2" "No")
|
||||
(td :class "px-3 py-2 font-bold text-green-700" "client")
|
||||
(td :class "px-3 py-2 text-stone-600" "Explicit + pure"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono" ":client")
|
||||
(td :class "px-3 py-2" "Yes")
|
||||
(td :class "px-3 py-2 font-bold text-green-700" "client")
|
||||
(td :class "px-3 py-2 text-stone-600" "Override — IO proxied via /sx/io/"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono" ":server")
|
||||
(td :class "px-3 py-2" "No")
|
||||
(td :class "px-3 py-2 font-bold text-orange-700" "server")
|
||||
(td :class "px-3 py-2 text-stone-600" "Override — auth-sensitive"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono" ":server")
|
||||
(td :class "px-3 py-2" "Yes")
|
||||
(td :class "px-3 py-2 font-bold text-orange-700" "server")
|
||||
(td :class "px-3 py-2 text-stone-600" "Both affinity and IO say server"))))))
|
||||
|
||||
;; How it integrates
|
||||
(~doc-section :title "How It Works" :id "how"
|
||||
(ol :class "list-decimal list-inside text-stone-700 space-y-2"
|
||||
(li (code "defcomp") " parses " (code ":affinity") " annotation between params and body")
|
||||
(li "Component object stores " (code "affinity") " field (\"auto\", \"client\", or \"server\")")
|
||||
(li (code "compute-all-io-refs") " scans transitive IO deps at registration time")
|
||||
(li (code "render-target") " in deps.sx combines affinity + IO analysis → \"server\" or \"client\"")
|
||||
(li "Server partial evaluator (" (code "_aser") ") checks " (code "render_target") ":")
|
||||
(ul :class "list-disc pl-8 text-stone-600"
|
||||
(li "\"server\" → expand component, embed rendered HTML")
|
||||
(li "\"client\" → serialize as SX, let client render"))
|
||||
(li "Client routing uses same info: IO deps in page registry → proxy registration")))
|
||||
|
||||
;; Verification
|
||||
(div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2"
|
||||
(p :class "font-semibold text-amber-800" "How to verify")
|
||||
(ol :class "list-decimal list-inside text-amber-700 space-y-1"
|
||||
(li "View page source — components with render-target \"server\" are expanded to HTML")
|
||||
(li "Components with render-target \"client\" appear as " (code "(~name ...)") " in the SX wire format")
|
||||
(li "Navigate away and back — client-routable pure components render instantly")
|
||||
(li "Check the analysis table above — it shows live data from the server's component registry")))))
|
||||
@@ -74,3 +74,8 @@
|
||||
:params ()
|
||||
:returns "async-generator<dict>"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "affinity-demo-data"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
@@ -125,7 +125,8 @@
|
||||
(dict :label "Routing Analyzer" :href "/isomorphism/routing-analyzer")
|
||||
(dict :label "Data Test" :href "/isomorphism/data-test")
|
||||
(dict :label "Async IO" :href "/isomorphism/async-io")
|
||||
(dict :label "Streaming" :href "/isomorphism/streaming")))
|
||||
(dict :label "Streaming" :href "/isomorphism/streaming")
|
||||
(dict :label "Affinity" :href "/isomorphism/affinity")))
|
||||
|
||||
(define plans-nav-items (list
|
||||
(dict :label "Status" :href "/plans/status"
|
||||
|
||||
@@ -1971,36 +1971,63 @@
|
||||
(~doc-section :title "Phase 7: Full Isomorphism" :id "phase-7"
|
||||
|
||||
(div :class "rounded border border-violet-200 bg-violet-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-amber-500 text-white uppercase" "In Progress"))
|
||||
(p :class "text-violet-900 font-medium" "What it enables")
|
||||
(p :class "text-violet-800" "Same SX code runs on either side. Runtime chooses optimal split. Offline-first with cached data + client eval."))
|
||||
|
||||
(~doc-subsection :title "Approach"
|
||||
(~doc-subsection :title "7a. Affinity Annotations & Render Target"
|
||||
|
||||
(div :class "space-y-4"
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "1. Runtime boundary optimizer")
|
||||
(p "Given component tree + IO dependency graph, decide per-component: server-expand, client-render, or stream. Planning step cached at registration, recomputed on component change."))
|
||||
(div :class "rounded border border-green-300 bg-green-50 p-3 mb-4"
|
||||
(div :class "flex items-center gap-2 mb-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete"))
|
||||
(p :class "text-green-800 text-sm" "Components declare where they prefer to render. The spec combines affinity with IO analysis to produce a per-component render target decision."))
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "2. Affinity annotations")
|
||||
(~doc-code :code (highlight "(defcomp ~product-grid (&key products)\n :affinity :client ;; interactive, prefer client\n ...)\n\n(defcomp ~auth-menu (&key user)\n :affinity :server ;; auth-sensitive, always server\n ...)" "lisp"))
|
||||
(p "Default: auto (runtime decides from IO analysis)."))
|
||||
(p "Affinity annotations let component authors express rendering preferences:")
|
||||
(~doc-code :code (highlight "(defcomp ~product-grid (&key products)\n :affinity :client ;; interactive, prefer client rendering\n (div ...))\n\n(defcomp ~auth-menu (&key user)\n :affinity :server ;; auth-sensitive, always server\n (div ...))\n\n(defcomp ~card (&key title)\n ;; no annotation = :affinity :auto (default)\n ;; runtime decides from IO analysis\n (div ...))" "lisp"))
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "3. Optimistic data updates")
|
||||
(p "Extend existing apply-optimistic/revert-optimistic in engine.sx from DOM-level to data-level. Client updates cached data optimistically, sends mutation to server, reverts on rejection."))
|
||||
(p "The " (code "render-target") " function in deps.sx combines affinity with IO analysis:")
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li (code ":affinity :server") " → always " (code "\"server\"") " (auth-sensitive, secrets, heavy IO)")
|
||||
(li (code ":affinity :client") " → always " (code "\"client\"") " (interactive, IO proxied)")
|
||||
(li (code ":affinity :auto") " (default) → " (code "\"server\"") " if IO-dependent, " (code "\"client\"") " if pure"))
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "4. Offline data layer")
|
||||
(p "Service Worker intercepts /internal/data/ requests, serves from IndexedDB when offline, syncs when back online."))
|
||||
(p "The server's partial evaluator (" (code "_aser") ") uses " (code "render_target") " instead of the previous " (code "is_pure") " check. Components with " (code ":affinity :client") " are serialized for client rendering even if they call IO primitives — the IO proxy (Phase 5) handles the calls.")
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "5. Isomorphic testing")
|
||||
(p "Evaluate same component on Python and JS, compare output. Extends existing test_sx_ref.py cross-evaluator comparison."))
|
||||
(~doc-subsection :title "Files"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
|
||||
(li "shared/sx/ref/eval.sx — defcomp annotation parsing, defcomp-kwarg helper")
|
||||
(li "shared/sx/ref/deps.sx — render-target function, platform interface")
|
||||
(li "shared/sx/types.py — Component.affinity field, render_target property")
|
||||
(li "shared/sx/evaluator.py — _sf_defcomp annotation extraction")
|
||||
(li "shared/sx/async_eval.py — _aser uses render_target")
|
||||
(li "shared/sx/ref/bootstrap_js.py — Component.affinity, componentAffinity()")
|
||||
(li "shared/sx/ref/bootstrap_py.py — component_affinity(), make_component()")
|
||||
(li "shared/sx/ref/test-eval.sx — 4 new defcomp affinity tests")
|
||||
(li "shared/sx/ref/test-deps.sx — 6 new render-target tests")))
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "6. Universal page descriptor")
|
||||
(p "defpage is portable: server executes via execute_page(), client executes via route match → fetch data → eval content → render DOM. Same descriptor, different execution environment."))))
|
||||
(~doc-subsection :title "Verification"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li "269 spec tests pass (10 new: 4 eval + 6 deps)")
|
||||
(li "79 Python unit tests pass")
|
||||
(li "Bootstrapped to both hosts (sx_ref.py + sx-browser.js)")
|
||||
(li "Backward compatible: existing defcomp without :affinity defaults to \"auto\""))))
|
||||
|
||||
(~doc-subsection :title "7b. Runtime Boundary Optimizer"
|
||||
(p "Given component tree + IO dependency graph + affinity annotations, decide per-component: server-expand, client-render, or stream. Planning step cached at registration, recomputed on component change.")
|
||||
(p :class "text-stone-500 text-sm italic" "Next: integrate render-target into the bundle analyzer, page registry, and orchestration.sx."))
|
||||
|
||||
(~doc-subsection :title "7c. Optimistic Data Updates"
|
||||
(p "Extend existing apply-optimistic/revert-optimistic in engine.sx from DOM-level to data-level. Client updates cached data optimistically, sends mutation to server, reverts on rejection."))
|
||||
|
||||
(~doc-subsection :title "7d. Offline Data Layer"
|
||||
(p "Service Worker intercepts /internal/data/ requests, serves from IndexedDB when offline, syncs when back online."))
|
||||
|
||||
(~doc-subsection :title "7e. Isomorphic Testing"
|
||||
(p "Evaluate same component on Python and JS, compare output. Extends existing test_sx_ref.py cross-evaluator comparison."))
|
||||
|
||||
(~doc-subsection :title "7f. Universal Page Descriptor"
|
||||
(p "defpage is portable: server executes via execute_page(), client executes via route match → fetch data → eval content → render DOM. Same descriptor, different execution environment."))
|
||||
|
||||
(div :class "rounded border border-amber-200 bg-amber-50 p-3 mt-2"
|
||||
(p :class "text-amber-800 text-sm" (strong "Depends on: ") "All previous phases.")))
|
||||
|
||||
@@ -479,6 +479,18 @@
|
||||
:stream-message stream-message
|
||||
:stream-time stream-time))
|
||||
|
||||
(defpage affinity-demo
|
||||
:path "/isomorphism/affinity"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Isomorphism"
|
||||
:sub-label "Isomorphism"
|
||||
:sub-href "/isomorphism/"
|
||||
:sub-nav (~section-nav :items isomorphism-nav-items :current "Affinity")
|
||||
:selected "Affinity")
|
||||
:data (affinity-demo-data)
|
||||
:content (~affinity-demo-content :components components))
|
||||
|
||||
;; Wildcard must come AFTER specific routes (first-match routing)
|
||||
(defpage isomorphism-page
|
||||
:path "/isomorphism/<slug>"
|
||||
|
||||
@@ -27,6 +27,7 @@ def _register_sx_helpers() -> None:
|
||||
"run-spec-tests": _run_spec_tests,
|
||||
"run-modular-tests": _run_modular_tests,
|
||||
"streaming-demo-data": _streaming_demo_data,
|
||||
"affinity-demo-data": _affinity_demo_data,
|
||||
})
|
||||
|
||||
|
||||
@@ -318,6 +319,8 @@ def _bundle_analyzer_data() -> dict:
|
||||
comp_details.append({
|
||||
"name": comp_name,
|
||||
"is-pure": is_pure,
|
||||
"affinity": val.affinity,
|
||||
"render-target": val.render_target,
|
||||
"io-refs": sorted(val.io_refs),
|
||||
"deps": sorted(val.deps),
|
||||
"source": source,
|
||||
@@ -875,3 +878,30 @@ async def _streaming_demo_data():
|
||||
"stream-message": "Model inference completed in ~5 seconds",
|
||||
"stream-time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
|
||||
|
||||
def _affinity_demo_data() -> dict:
|
||||
"""Return affinity analysis for the demo components."""
|
||||
from shared.sx.jinja_bridge import get_component_env
|
||||
from shared.sx.types import Component
|
||||
|
||||
env = get_component_env()
|
||||
demo_names = [
|
||||
"~aff-demo-auto",
|
||||
"~aff-demo-client",
|
||||
"~aff-demo-server",
|
||||
"~aff-demo-io-auto",
|
||||
"~aff-demo-io-client",
|
||||
]
|
||||
components = []
|
||||
for name in demo_names:
|
||||
val = env.get(name)
|
||||
if isinstance(val, Component):
|
||||
components.append({
|
||||
"name": name,
|
||||
"affinity": val.affinity,
|
||||
"render-target": val.render_target,
|
||||
"io-refs": sorted(val.io_refs),
|
||||
"is-pure": val.is_pure,
|
||||
})
|
||||
return {"components": components}
|
||||
|
||||
Reference in New Issue
Block a user