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:
2026-03-07 23:53:33 +00:00
parent 81d8e55fb0
commit a70ff2b153
19 changed files with 540 additions and 224 deletions

View File

@@ -31,6 +31,19 @@ from shared.sx.types import Symbol, Keyword, NIL as SX_NIL
# SX -> Python transpiler
# ---------------------------------------------------------------------------
# Python reserved words — SX names that collide get _ suffix
# Excludes names we intentionally shadow (list, dict, range, filter, map)
_PY_RESERVED = frozenset({
"False", "None", "True", "and", "as", "assert", "async", "await",
"break", "class", "continue", "def", "del", "elif", "else", "except",
"finally", "for", "from", "global", "if", "import", "in", "is",
"lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try",
"while", "with", "yield",
# builtins we don't want to shadow
"default", "type", "id", "input", "open", "print", "set", "super",
})
class PyEmitter:
"""Transpile an SX AST node to Python source code."""
@@ -124,6 +137,7 @@ class PyEmitter:
"component-closure": "component_closure",
"component-has-children?": "component_has_children",
"component-name": "component_name",
"component-affinity": "component_affinity",
"macro-params": "macro_params",
"macro-rest-param": "macro_rest_param",
"macro-body": "macro_body",
@@ -182,6 +196,7 @@ class PyEmitter:
"sf-lambda": "sf_lambda",
"sf-define": "sf_define",
"sf-defcomp": "sf_defcomp",
"defcomp-kwarg": "defcomp_kwarg",
"sf-defmacro": "sf_defmacro",
"sf-begin": "sf_begin",
"sf-quote": "sf_quote",
@@ -262,6 +277,7 @@ class PyEmitter:
"transitive-io-refs": "transitive_io_refs",
"compute-all-io-refs": "compute_all_io_refs",
"component-pure?": "component_pure_p",
"render-target": "render_target",
# router.sx
"split-path-segments": "split_path_segments",
"make-route-segment": "make_route_segment",
@@ -281,9 +297,9 @@ class PyEmitter:
result = result[:-1] + "_b"
# Kebab to snake_case
result = result.replace("-", "_")
# Avoid Python keyword conflicts
if result in ("list", "dict", "range", "filter"):
result = result # keep as-is, these are our SX aliases
# Escape Python reserved words
if result in _PY_RESERVED:
result = result + "_"
return result
# --- List emission ---
@@ -1220,9 +1236,9 @@ def make_lambda(params, body, env):
return Lambda(params=list(params), body=body, closure=dict(env))
def make_component(name, params, has_children, body, env):
def make_component(name, params, has_children, body, env, affinity="auto"):
return Component(name=name, params=list(params), has_children=has_children,
body=body, closure=dict(env))
body=body, closure=dict(env), affinity=str(affinity) if affinity else "auto")
def make_macro(params, rest_param, body, env, name=None):
@@ -1311,6 +1327,10 @@ def component_name(c):
return c.name
def component_affinity(c):
return getattr(c, 'affinity', 'auto')
def macro_params(m):
return m.params