Enforce SX boundary contract via boundary.sx spec + runtime validation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m33s

Add boundary.sx declaring all 34 I/O primitives, 32 page helpers, and 9
allowed boundary types. Runtime validation in boundary.py checks every
registration against the spec — undeclared primitives/helpers crash at
startup with SX_BOUNDARY_STRICT=1 (now set in both dev and prod).

Key changes:
- Move 5 I/O-in-disguise primitives (app-url, asset-url, config,
  jinja-global, relations-from) from primitives.py to primitives_io.py
- Remove duplicate url-for/route-prefix from primitives.py (already in IO)
- Fix parse-datetime to return ISO string instead of raw datetime
- Add datetime→isoformat conversion in _convert_result at the edge
- Wrap page helper return values with boundary type validation
- Replace all SxExpr(f"...") patterns with sx_call() or _sx_fragment()
- Add assert declaration to primitives.sx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 23:50:02 +00:00
parent 54adc9c216
commit 04366990ec
21 changed files with 1342 additions and 415 deletions

View File

@@ -4,10 +4,16 @@
;; Defines how SX source text is tokenized and parsed into AST.
;; The parser is intentionally simple — s-expressions need minimal parsing.
;;
;; Single-pass recursive descent: reads source text directly into AST,
;; no separate tokenization phase. All mutable cursor state lives inside
;; the parse closure.
;;
;; Grammar:
;; program → expr*
;; expr → atom | list | quote-sugar
;; expr → atom | list | vector | map | quote-sugar
;; list → '(' expr* ')'
;; vector → '[' expr* ']' (sugar for list)
;; map → '{' (key expr)* '}'
;; atom → string | number | keyword | symbol | boolean | nil
;; string → '"' (char | escape)* '"'
;; number → '-'? digit+ ('.' digit+)? ([eE] [+-]? digit+)?
@@ -15,316 +21,256 @@
;; symbol → ident
;; boolean → 'true' | 'false'
;; nil → 'nil'
;; ident → [a-zA-Z_~*+\-><=/!?&] [a-zA-Z0-9_~*+\-><=/!?.:&]*
;; ident → ident-start ident-char*
;; comment → ';' to end of line (discarded)
;;
;; Dict literal:
;; {key val ...} → dict object (keys are keywords or expressions)
;;
;; Quote sugar:
;; `(expr) → (quasiquote expr)
;; ,(expr) → (unquote expr)
;; ,@(expr) → (splice-unquote expr)
;; `expr → (quasiquote expr)
;; ,expr → (unquote expr)
;; ,@expr → (splice-unquote expr)
;;
;; Platform interface (each target implements natively):
;; (ident-start? ch) → boolean
;; (ident-char? ch) → boolean
;; (make-symbol name) → Symbol value
;; (make-keyword name) → Keyword value
;; (escape-string s) → string with " and \ escaped for serialization
;; ==========================================================================
;; --------------------------------------------------------------------------
;; Tokenizer
;; Parser — single-pass recursive descent
;; --------------------------------------------------------------------------
;; Produces a flat stream of tokens from source text.
;; Each token is a (type value line col) tuple.
;; Returns a list of top-level AST expressions.
(define tokenize
(define sx-parse
(fn (source)
(let ((pos 0)
(line 1)
(col 1)
(tokens (list))
(len-src (len source)))
;; Main loop — bootstrap compilers convert to while
(define scan-next
;; -- Cursor helpers (closure over pos, source, len-src) --
(define skip-comment
(fn ()
(when (and (< pos len-src) (not (= (nth source pos) "\n")))
(set! pos (inc pos))
(skip-comment))))
(define skip-ws
(fn ()
(when (< pos len-src)
(let ((ch (nth source pos)))
(cond
;; Whitespace — skip
(whitespace? ch)
(do (advance-pos!) (scan-next))
;; Whitespace
(or (= ch " ") (= ch "\t") (= ch "\n") (= ch "\r"))
(do (set! pos (inc pos)) (skip-ws))
;; Comment — skip to end of line
(= ch ";")
(do (skip-to-eol!) (scan-next))
(do (set! pos (inc pos))
(skip-comment)
(skip-ws))
;; Not whitespace or comment — stop
:else nil)))))
;; -- Atom readers --
(define read-string
(fn ()
(set! pos (inc pos)) ;; skip opening "
(let ((buf ""))
(define read-str-loop
(fn ()
(if (>= pos len-src)
(error "Unterminated string")
(let ((ch (nth source pos)))
(cond
(= ch "\"")
(do (set! pos (inc pos)) nil) ;; done
(= ch "\\")
(do (set! pos (inc pos))
(let ((esc (nth source pos)))
(set! buf (str buf
(cond
(= esc "n") "\n"
(= esc "t") "\t"
(= esc "r") "\r"
:else esc)))
(set! pos (inc pos))
(read-str-loop)))
:else
(do (set! buf (str buf ch))
(set! pos (inc pos))
(read-str-loop)))))))
(read-str-loop)
buf)))
(define read-ident
(fn ()
(let ((start pos))
(define read-ident-loop
(fn ()
(when (and (< pos len-src)
(ident-char? (nth source pos)))
(set! pos (inc pos))
(read-ident-loop))))
(read-ident-loop)
(slice source start pos))))
(define read-keyword
(fn ()
(set! pos (inc pos)) ;; skip :
(make-keyword (read-ident))))
(define read-number
(fn ()
(let ((start pos))
;; Optional leading minus
(when (and (< pos len-src) (= (nth source pos) "-"))
(set! pos (inc pos)))
;; Integer digits
(define read-digits
(fn ()
(when (and (< pos len-src)
(let ((c (nth source pos)))
(and (>= c "0") (<= c "9"))))
(set! pos (inc pos))
(read-digits))))
(read-digits)
;; Decimal part
(when (and (< pos len-src) (= (nth source pos) "."))
(set! pos (inc pos))
(read-digits))
;; Exponent
(when (and (< pos len-src)
(or (= (nth source pos) "e")
(= (nth source pos) "E")))
(set! pos (inc pos))
(when (and (< pos len-src)
(or (= (nth source pos) "+")
(= (nth source pos) "-")))
(set! pos (inc pos)))
(read-digits))
(parse-number (slice source start pos)))))
(define read-symbol
(fn ()
(let ((name (read-ident)))
(cond
(= name "true") true
(= name "false") false
(= name "nil") nil
:else (make-symbol name)))))
;; -- Composite readers --
(define read-list
(fn (close-ch)
(let ((items (list)))
(define read-list-loop
(fn ()
(skip-ws)
(if (>= pos len-src)
(error "Unterminated list")
(if (= (nth source pos) close-ch)
(do (set! pos (inc pos)) nil) ;; done
(do (append! items (read-expr))
(read-list-loop))))))
(read-list-loop)
items)))
(define read-map
(fn ()
(let ((result (dict)))
(define read-map-loop
(fn ()
(skip-ws)
(if (>= pos len-src)
(error "Unterminated map")
(if (= (nth source pos) "}")
(do (set! pos (inc pos)) nil) ;; done
(let ((key-expr (read-expr))
(key-str (if (= (type-of key-expr) "keyword")
(keyword-name key-expr)
(str key-expr)))
(val-expr (read-expr)))
(dict-set! result key-str val-expr)
(read-map-loop))))))
(read-map-loop)
result)))
;; -- Main expression reader --
(define read-expr
(fn ()
(skip-ws)
(if (>= pos len-src)
(error "Unexpected end of input")
(let ((ch (nth source pos)))
(cond
;; Lists
(= ch "(")
(do (set! pos (inc pos)) (read-list ")"))
(= ch "[")
(do (set! pos (inc pos)) (read-list "]"))
;; Map
(= ch "{")
(do (set! pos (inc pos)) (read-map))
;; String
(= ch "\"")
(do (append! tokens (scan-string)) (scan-next))
;; Open paren
(= ch "(")
(do (append! tokens (list "lparen" "(" line col))
(advance-pos!)
(scan-next))
;; Close paren
(= ch ")")
(do (append! tokens (list "rparen" ")" line col))
(advance-pos!)
(scan-next))
;; Open bracket (list sugar)
(= ch "[")
(do (append! tokens (list "lbracket" "[" line col))
(advance-pos!)
(scan-next))
;; Close bracket
(= ch "]")
(do (append! tokens (list "rbracket" "]" line col))
(advance-pos!)
(scan-next))
;; Open brace (dict literal)
(= ch "{")
(do (append! tokens (list "lbrace" "{" line col))
(advance-pos!)
(scan-next))
;; Close brace
(= ch "}")
(do (append! tokens (list "rbrace" "}" line col))
(advance-pos!)
(scan-next))
;; Quasiquote sugar
(= ch "`")
(do (advance-pos!)
(let ((inner (scan-next-expr)))
(append! tokens (list "quasiquote" inner line col))
(scan-next)))
;; Unquote / splice-unquote
(= ch ",")
(do (advance-pos!)
(if (and (< pos len-src) (= (nth source pos) "@"))
(do (advance-pos!)
(let ((inner (scan-next-expr)))
(append! tokens (list "splice-unquote" inner line col))
(scan-next)))
(let ((inner (scan-next-expr)))
(append! tokens (list "unquote" inner line col))
(scan-next))))
(read-string)
;; Keyword
(= ch ":")
(do (append! tokens (scan-keyword)) (scan-next))
(read-keyword)
;; Quasiquote sugar
(= ch "`")
(do (set! pos (inc pos))
(list (make-symbol "quasiquote") (read-expr)))
;; Unquote / splice-unquote
(= ch ",")
(do (set! pos (inc pos))
(if (and (< pos len-src) (= (nth source pos) "@"))
(do (set! pos (inc pos))
(list (make-symbol "splice-unquote") (read-expr)))
(list (make-symbol "unquote") (read-expr))))
;; Number (or negative number)
(or (digit? ch)
(and (= ch "-") (< (inc pos) len-src)
(digit? (nth source (inc pos)))))
(do (append! tokens (scan-number)) (scan-next))
(or (and (>= ch "0") (<= ch "9"))
(and (= ch "-")
(< (inc pos) len-src)
(let ((next-ch (nth source (inc pos))))
(and (>= next-ch "0") (<= next-ch "9")))))
(read-number)
;; Symbol
;; Symbol (must be ident-start char)
(ident-start? ch)
(do (append! tokens (scan-symbol)) (scan-next))
(read-symbol)
;; Unknown — skip
;; Unexpected
:else
(do (advance-pos!) (scan-next)))))))
(scan-next)
tokens)))
(error (str "Unexpected character: " ch)))))))
;; --------------------------------------------------------------------------
;; Token scanners (pseudo-code — each target implements natively)
;; --------------------------------------------------------------------------
(define scan-string
(fn ()
;; Scan from opening " to closing ", handling escape sequences.
;; Returns ("string" value line col).
;; Escape sequences: \" \\ \n \t \r
(let ((start-line line)
(start-col col)
(result ""))
(advance-pos!) ;; skip opening "
(define scan-str-loop
(fn ()
(if (>= pos (len source))
(error "Unterminated string")
(let ((ch (nth source pos)))
(cond
(= ch "\"")
(do (advance-pos!) nil) ;; done
(= ch "\\")
(do (advance-pos!)
(let ((esc (nth source pos)))
(set! result (str result
(case esc
"n" "\n"
"t" "\t"
"r" "\r"
:else esc)))
(advance-pos!)
(scan-str-loop)))
:else
(do (set! result (str result ch))
(advance-pos!)
(scan-str-loop)))))))
(scan-str-loop)
(list "string" result start-line start-col))))
(define scan-keyword
(fn ()
;; Scan :identifier
(let ((start-line line) (start-col col))
(advance-pos!) ;; skip :
(let ((name (scan-ident-chars)))
(list "keyword" name start-line start-col)))))
(define scan-number
(fn ()
;; Scan integer or float literal
(let ((start-line line) (start-col col) (buf ""))
(when (= (nth source pos) "-")
(set! buf "-")
(advance-pos!))
;; Integer part
(define scan-digits
(fn ()
(when (and (< pos (len source)) (digit? (nth source pos)))
(set! buf (str buf (nth source pos)))
(advance-pos!)
(scan-digits))))
(scan-digits)
;; Decimal part
(when (and (< pos (len source)) (= (nth source pos) "."))
(set! buf (str buf "."))
(advance-pos!)
(scan-digits))
;; Exponent
(when (and (< pos (len source))
(or (= (nth source pos) "e") (= (nth source pos) "E")))
(set! buf (str buf (nth source pos)))
(advance-pos!)
(when (and (< pos (len source))
(or (= (nth source pos) "+") (= (nth source pos) "-")))
(set! buf (str buf (nth source pos)))
(advance-pos!))
(scan-digits))
(list "number" (parse-number buf) start-line start-col))))
(define scan-symbol
(fn ()
;; Scan identifier, check for true/false/nil
(let ((start-line line)
(start-col col)
(name (scan-ident-chars)))
(cond
(= name "true") (list "boolean" true start-line start-col)
(= name "false") (list "boolean" false start-line start-col)
(= name "nil") (list "nil" nil start-line start-col)
:else (list "symbol" name start-line start-col)))))
;; --------------------------------------------------------------------------
;; Parser — tokens → AST
;; --------------------------------------------------------------------------
(define parse
(fn (tokens)
;; Parse all top-level expressions from token stream.
(let ((pos 0)
(exprs (list)))
(define parse-loop
(fn ()
(when (< pos (len tokens))
(let ((result (parse-expr tokens)))
(append! exprs result)
(parse-loop)))))
(parse-loop)
exprs)))
(define parse-expr
(fn (tokens)
;; Parse a single expression.
(let ((tok (nth tokens pos)))
(case (first tok) ;; token type
"lparen"
(do (set! pos (inc pos))
(parse-list tokens "rparen"))
"lbracket"
(do (set! pos (inc pos))
(parse-list tokens "rbracket"))
"lbrace"
(do (set! pos (inc pos))
(parse-dict tokens))
"string" (do (set! pos (inc pos)) (nth tok 1))
"number" (do (set! pos (inc pos)) (nth tok 1))
"boolean" (do (set! pos (inc pos)) (nth tok 1))
"nil" (do (set! pos (inc pos)) nil)
"keyword"
(do (set! pos (inc pos))
(make-keyword (nth tok 1)))
"symbol"
(do (set! pos (inc pos))
(make-symbol (nth tok 1)))
:else (error (str "Unexpected token: " (inspect tok)))))))
(define parse-list
(fn (tokens close-type)
;; Parse expressions until close-type token.
(let ((items (list)))
(define parse-list-loop
(fn ()
(if (>= pos (len tokens))
(error "Unterminated list")
(if (= (first (nth tokens pos)) close-type)
(do (set! pos (inc pos)) nil) ;; done
(do (append! items (parse-expr tokens))
(parse-list-loop))))))
(parse-list-loop)
items)))
(define parse-dict
(fn (tokens)
;; Parse {key val key val ...} until "rbrace" token.
;; Returns a dict (plain object).
(let ((result (dict)))
(define parse-dict-loop
(fn ()
(if (>= pos (len tokens))
(error "Unterminated dict")
(if (= (first (nth tokens pos)) "rbrace")
(do (set! pos (inc pos)) nil) ;; done
(let ((key-expr (parse-expr tokens))
(key-str (if (= (type-of key-expr) "keyword")
(keyword-name key-expr)
(str key-expr)))
(val-expr (parse-expr tokens)))
(dict-set! result key-str val-expr)
(parse-dict-loop))))))
(parse-dict-loop)
result)))
;; -- Entry point: parse all top-level expressions --
(let ((exprs (list)))
(define parse-loop
(fn ()
(skip-ws)
(when (< pos len-src)
(append! exprs (read-expr))
(parse-loop))))
(parse-loop)
exprs))))
;; --------------------------------------------------------------------------
;; Serializer — AST → SX source text
;; --------------------------------------------------------------------------
(define serialize
(define sx-serialize
(fn (val)
(case (type-of val)
"nil" "nil"
@@ -333,46 +279,41 @@
"string" (str "\"" (escape-string val) "\"")
"symbol" (symbol-name val)
"keyword" (str ":" (keyword-name val))
"list" (str "(" (join " " (map serialize val)) ")")
"dict" (serialize-dict val)
"list" (str "(" (join " " (map sx-serialize val)) ")")
"dict" (sx-serialize-dict val)
"sx-expr" (sx-expr-source val)
:else (str val))))
(define serialize-dict
(define sx-serialize-dict
(fn (d)
(str "(dict "
(str "{"
(join " "
(reduce
(fn (acc key)
(concat acc (list (str ":" key) (serialize (dict-get d key)))))
(concat acc (list (str ":" key) (sx-serialize (dict-get d key)))))
(list)
(keys d)))
")")))
"}")))
;; --------------------------------------------------------------------------
;; Platform parser interface
;; --------------------------------------------------------------------------
;;
;; Character classification:
;; (whitespace? ch) → boolean
;; (digit? ch) → boolean
;; (ident-start? ch) → boolean (letter, _, ~, *, +, -, etc.)
;; (ident-char? ch) → boolean (ident-start + digits, ., :)
;; Character classification (implemented natively per target):
;; (ident-start? ch) → boolean
;; True for: a-z A-Z _ ~ * + - > < = / ! ? &
;;
;; Constructors:
;; (make-symbol name) → Symbol value
;; (make-keyword name) → Keyword value
;; (parse-number s) → number (int or float from string)
;; (ident-char? ch) → boolean
;; True for: ident-start chars plus: 0-9 . : / [ ] # ,
;;
;; Constructors (provided by the SX runtime):
;; (make-symbol name) → Symbol value
;; (make-keyword name) → Keyword value
;; (parse-number s) → number (int or float from string)
;;
;; String utilities:
;; (escape-string s) → string with " and \ escaped
;; (sx-expr-source e) → unwrap SxExpr to its source string
;;
;; Cursor state (mutable — each target manages its own way):
;; pos, line, col — current position in source
;; (advance-pos!) → increment pos, update line/col
;; (skip-to-eol!) → advance past end of line
;; (scan-ident-chars) → consume and return identifier string
;; (escape-string s) → string with " and \ escaped
;; (sx-expr-source e) → unwrap SxExpr to its source string
;; --------------------------------------------------------------------------