Spec explorer: fix SxExpr rendering bugs, add drill-in UX, Playwright tests

Fix 3 OCaml bugs that caused spec explorer to hang:
- sx_types: inspect outputs quoted string for SxExpr (not bare symbol)
- sx_primitives: serialize/to_string extract SxExpr/RawHTML content
- sx_render: handle SxExpr in both render-to-html paths

Restructure spec explorer for performance:
- Lightweight overview: name + kind only (was full source for 141 defs)
- Drill-in detail: click definition → params, effects, signature
- explore() page function accepts optional second arg for drill-in
- spec() passes through non-string slugs from nested routing

Fix aser map result wrapping:
- aser-special map now wraps results in fragment (<> ...) via aser-fragment
- Prevents ((div ...) (div ...)) nested lists that caused client "Not callable"

5 Playwright tests: overview load, no errors, SPA nav, drill-in detail+params

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 23:46:13 +00:00
parent b3718c06d0
commit 6e885f49b6
9 changed files with 447 additions and 217 deletions

View File

@@ -0,0 +1,22 @@
(defhandler
spec-detail
:path "/sx/api/spec-detail"
:method :get
:returns "element"
(&key)
(let
((filename (helper "request-arg" "file" ""))
(def-name (helper "request-arg" "name" "")))
(if
(or (= filename "") (= def-name ""))
(div
:class "text-sm text-stone-400 p-2"
"Missing file or name parameter")
(let
((d (spec-explore-define filename def-name)))
(if
d
(~specs-explorer/spec-explorer-define-detail :d d :filename filename)
(div
:class "text-sm text-stone-400 p-2"
(str "Definition '" def-name "' not found in " filename)))))))

View File

@@ -213,10 +213,12 @@
spec
(fn
(slug)
(if
(cond
(nil? slug)
(quote (~specs/architecture-content))
(case
(not (= (type-of slug) "string"))
slug
:else (case
slug
"core"
(let
@@ -278,22 +280,34 @@
(define
explore
(fn
(slug)
(slug defname)
(if
(nil? slug)
(quote (~specs/architecture-content))
(let
((found-spec (find-spec slug)))
(if
found-spec
(let
((data (spec-explore (get found-spec "filename") (get found-spec "title") (get found-spec "desc"))))
(if
data
(quasiquote
(~specs-explorer/spec-explorer-content :data (unquote data)))
(quasiquote (~specs/not-found :slug (unquote slug)))))
(quasiquote (~specs/not-found :slug (unquote slug))))))))
(not found-spec)
(quasiquote (~specs/not-found :slug (unquote slug)))
(if
defname
(let
((d (spec-explore-define (get found-spec "filename") defname)))
(if
d
(quasiquote
(~specs-explorer/spec-explorer-define-detail
:d (unquote d)
:filename (unquote (get found-spec "filename"))))
(quasiquote
(~specs/not-found :slug (unquote (str slug "." defname))))))
(let
((data (spec-explore (get found-spec "filename") (get found-spec "title") (get found-spec "desc"))))
(if
data
(quasiquote
(~specs-explorer/spec-explorer-content :data (unquote data)))
(quasiquote (~specs/not-found :slug (unquote slug)))))))))))
(define
make-spec-files

View File

@@ -1,186 +1,238 @@
;; ---------------------------------------------------------------------------
;; Spec Introspection — SX macro that reads a spec file and produces
;; structured explorer data. The spec examines itself.
;; ---------------------------------------------------------------------------
;;
;; Usage: (spec-explore "evaluator.sx" "Evaluator" "CEK machine evaluator")
;;
;; Returns a dict with :title, :filename, :desc, :sections, :stats
;; suitable for (~specs-explorer/spec-explorer-content :data result)
;; Extract the name from a define/defcomp/defmacro/defisland form
(define spec-form-name
(fn (form)
(if (< (len form) 2) nil
(let ((head (symbol-name (first form)))
(name-part (nth form 1)))
(define
spec-form-name
(fn
(form)
(if
(< (len form) 2)
nil
(let
((head (symbol-name (first form))) (name-part (nth form 1)))
(cond
(= head "define")
(if (= (type-of name-part) "symbol")
(symbol-name name-part)
nil)
(if (= (type-of name-part) "symbol") (symbol-name name-part) nil)
(or (= head "defcomp") (= head "defisland") (= head "defmacro"))
(if (= (type-of name-part) "symbol")
(symbol-name name-part)
nil)
(if (= (type-of name-part) "symbol") (symbol-name name-part) nil)
:else nil)))))
;; Extract effects annotation from a define form
;; (define name :effects [mutation io] (fn ...))
(define spec-form-effects
(fn (form)
(let ((result (list))
(found false))
(when (> (len form) 3)
(define
spec-form-effects
(fn
(form)
(let
((result (list)) (found false))
(when
(> (len form) 3)
(for-each
(fn (item)
(if found
(when (and (list? item) (empty? result))
(fn
(item)
(if
found
(when
(and (list? item) (empty? result))
(for-each
(fn (eff)
(append! result (if (= (type-of eff) "symbol")
(symbol-name eff)
(str eff))))
(fn
(eff)
(append!
result
(if
(= (type-of eff) "symbol")
(symbol-name eff)
(str eff))))
item)
(set! found false))
(when (and (= (type-of item) "keyword")
(= (keyword-name item) "effects"))
(when
(and
(= (type-of item) "keyword")
(= (keyword-name item) "effects"))
(set! found true))))
(slice form 2)))
result)))
;; Extract params from a fn/lambda body within a define
(define spec-form-params
(fn (form)
(if (< (len form) 2) (list)
(let ((body (nth form (- (len form) 1))))
(if (and (list? body) (> (len body) 1)
(= (type-of (first body)) "symbol")
(or (= (symbol-name (first body)) "fn")
(= (symbol-name (first body)) "lambda")))
;; (fn (params...) body)
(let ((raw-params (nth body 1)))
(if (list? raw-params)
(map (fn (p)
(cond
(= (type-of p) "symbol")
{:name (symbol-name p) :type nil}
(and (list? p) (>= (len p) 3)
(= (type-of (first p)) "symbol"))
{:name (symbol-name (first p))
:type (if (and (>= (len p) 3)
(= (type-of (nth p 2)) "symbol"))
(symbol-name (nth p 2))
nil)}
:else {:name (str p) :type nil}))
(define
spec-form-params
(fn
(form)
(if
(< (len form) 2)
(list)
(let
((body (nth form (- (len form) 1))))
(if
(and
(list? body)
(> (len body) 1)
(= (type-of (first body)) "symbol")
(or
(= (symbol-name (first body)) "fn")
(= (symbol-name (first body)) "lambda")))
(let
((raw-params (nth body 1)))
(if
(list? raw-params)
(map
(fn
(p)
(cond
(= (type-of p) "symbol")
{:type nil :name (symbol-name p)}
(and
(list? p)
(>= (len p) 3)
(= (type-of (first p)) "symbol"))
{:type (if (and (>= (len p) 3) (= (type-of (nth p 2)) "symbol")) (symbol-name (nth p 2)) nil) :name (symbol-name (first p))}
:else {:type nil :name (str p)}))
raw-params)
(list)))
(list))))))
;; Classify a form: "function", "constant", "component", "macro", "island"
(define spec-form-kind
(fn (form)
(let ((head (symbol-name (first form))))
(define
spec-form-kind
(fn
(form)
(let
((head (symbol-name (first form))))
(cond
(= head "defcomp") "component"
(= head "defisland") "island"
(= head "defmacro") "macro"
(= head "defcomp")
"component"
(= head "defisland")
"island"
(= head "defmacro")
"macro"
(= head "define")
(let ((body (last form)))
(if (and (list? body) (> (len body) 0)
(= (type-of (first body)) "symbol")
(or (= (symbol-name (first body)) "fn")
(= (symbol-name (first body)) "lambda")))
"function"
"constant"))
(let
((body (last form)))
(if
(and
(list? body)
(> (len body) 0)
(= (type-of (first body)) "symbol")
(or
(= (symbol-name (first body)) "fn")
(= (symbol-name (first body)) "lambda")))
"function"
"constant"))
:else "unknown"))))
;; Serialize a form back to SX source for display
(define spec-form-source
(fn (form)
(serialize form)))
(define
spec-form-signature
(fn
(form)
(let
((head (symbol-name (first form))) (name (spec-form-name form)))
(cond
(or
(= head "define")
(= head "defcomp")
(= head "defisland")
(= head "defmacro"))
(let
((body (last form)))
(if
(and
(list? body)
(> (len body) 0)
(= (type-of (first body)) "symbol")
(or
(= (symbol-name (first body)) "fn")
(= (symbol-name (first body)) "lambda")))
(let
((params (nth body 1)))
(str "(" head " " name " (fn " (serialize params) " …))"))
(str "(" head " " name " …)")))
:else (str "(" head " " name " …)")))))
;; Group forms into sections based on comment headers
;; Returns list of {:title :comment :defines}
(define spec-group-sections
(fn (forms source)
(let ((sections (list))
(current-title "Definitions")
(current-comment nil)
(current-defines (list)))
;; Extract section comments from source
;; Look for ";; section-name" or ";; --- section ---" patterns
(define
spec-group-sections
(fn
(forms source)
(let
((sections (list))
(current-title "Definitions")
(current-comment nil)
(current-defines (list)))
(for-each
(fn (form)
(when (and (list? form) (> (len form) 1))
(let ((name (spec-form-name form)))
(when name
(append! current-defines
{:name name
:kind (spec-form-kind form)
:effects (spec-form-effects form)
:params (spec-form-params form)
:source (spec-form-source form)
:python nil
:javascript nil
:z3 nil
:refs (list)
:tests (list)
:test-count 0})))))
(fn
(form)
(when
(and (list? form) (> (len form) 1))
(let
((name (spec-form-name form)))
(when name (append! current-defines {:kind (spec-form-kind form) :name name})))))
forms)
;; Flush last section
(when (not (empty? current-defines))
(append! sections
{:title current-title
:comment current-comment
:defines current-defines}))
(when (not (empty? current-defines)) (append! sections {:defines current-defines :title current-title :comment current-comment}))
sections)))
;; Compute stats from sections
(define spec-compute-stats
(fn (sections source)
(let ((total 0)
(pure 0)
(mutation 0)
(io 0)
(render 0)
(lines (len (split source "\n"))))
(define
spec-compute-stats
(fn
(sections source)
(let
((total 0)
(pure 0)
(mutation 0)
(io 0)
(render 0)
(lines (len (split source "\n"))))
(for-each
(fn (section)
(fn
(section)
(for-each
(fn (d)
(fn
(d)
(set! total (inc total))
(if (empty? (get d "effects"))
(if
(empty? (get d "effects"))
(set! pure (inc pure))
(for-each
(fn (eff)
(fn
(eff)
(cond
(= eff "mutation") (set! mutation (inc mutation))
(= eff "io") (set! io (inc io))
(= eff "render") (set! render (inc render))))
(= eff "mutation")
(set! mutation (inc mutation))
(= eff "io")
(set! io (inc io))
(= eff "render")
(set! render (inc render))))
(get d "effects"))))
(get section "defines")))
sections)
{:total-defines total
:pure-count pure
:mutation-count mutation
:io-count io
:render-count render
:test-total 0
:lines lines})))
{:lines lines :io-count io :render-count render :pure-count pure :mutation-count mutation :test-total 0 :total-defines total})))
;; Main entry point: read, parse, analyze a spec file
(define spec-explore :effects [io]
(fn (filename title desc)
(let ((source (helper "read-spec-file" filename)))
(if (starts-with? source ";; spec file not found")
(define
spec-explore-define
:effects (io)
(fn
(filename def-name)
(let
((source (helper "read-spec-file" filename)))
(if
(starts-with? source ";; spec file not found")
nil
(let ((forms (sx-parse source))
(sections (spec-group-sections forms source))
(stats (spec-compute-stats sections source)))
{:filename filename
:title title
:desc desc
:sections sections
:stats stats
:platform-interface (list)})))))
(let
((forms (sx-parse source)) (found nil))
(for-each
(fn
(form)
(when
(and (not found) (list? form) (> (len form) 1))
(let
((name (spec-form-name form)))
(when (= name def-name) (set! found {:kind (spec-form-kind form) :effects (spec-form-effects form) :params (spec-form-params form) :source (spec-form-signature form) :name name})))))
forms)
found)))))
(define
spec-explore
:effects (io)
(fn
(filename title desc)
(let
((source (helper "read-spec-file" filename)))
(if
(starts-with? source ";; spec file not found")
nil
(let
((forms (sx-parse source))
(sections (spec-group-sections forms source))
(stats (spec-compute-stats sections source)))
{:stats stats :desc desc :title title :filename filename :platform-interface (list) :sections sections})))))

View File

@@ -11,7 +11,11 @@
:slug (replace (get data "filename") ".sx" ""))
(~specs-explorer/spec-explorer-stats :stats (get data "stats"))
(map
(fn (section) (~specs-explorer/spec-explorer-section :section section))
(fn
(section)
(~specs-explorer/spec-explorer-section
:section section
:filename (get data "filename")))
(get data "sections"))
(when
(not (empty? (get data "platform-interface")))
@@ -78,56 +82,95 @@
(defcomp
~specs-explorer/spec-explorer-section
(&key section)
(&key section filename)
(div
:class "mb-8"
:class "mb-6"
(h2
:class "text-lg font-semibold text-stone-700 border-b border-stone-200 pb-1 mb-3"
:class "text-base font-semibold text-stone-600 mb-2 border-b border-stone-200 pb-1"
:id (replace (lower (get section "title")) " " "-")
(get section "title"))
(when
(get section "comment")
(p :class "text-sm text-stone-500 mb-3" (get section "comment")))
(p :class "text-sm text-stone-500 mb-2" (get section "comment")))
(div
:class "space-y-4"
:class "space-y-0.5"
(map
(fn (d) (~specs-explorer/spec-explorer-define :d d))
(fn
(d)
(~specs-explorer/spec-explorer-define :d d :filename filename))
(get section "defines")))))
(defcomp
~specs-explorer/spec-explorer-define
(&key d)
(&key d filename)
(div
:class "rounded border border-stone-200 p-4"
:class "flex items-center gap-2 py-1.5 px-2 rounded hover:bg-stone-50 cursor-pointer group"
:id (str "fn-" (get d "name"))
:sx-get (str
"/sx/(language.(spec.(explore."
(replace filename ".sx" "")
"."
(get d "name")
")))")
:sx-target "#sx-content"
:sx-select "#sx-content"
:sx-swap "innerHTML"
:sx-push-url "true"
(span
:class "font-mono text-sm font-medium text-stone-800 group-hover:text-violet-700"
(get d "name"))
(span :class "text-xs text-stone-400" (get d "kind"))))
(defcomp
~specs-explorer/spec-explorer-define-detail
(&key d filename)
:affinity :server
(div
:class "max-w-3xl mx-auto"
(div
:class "flex items-center gap-2 flex-wrap"
(span :class "font-mono font-semibold text-stone-800" (get d "name"))
(span :class "text-xs text-stone-400" (get d "kind"))
(if
(empty? (get d "effects"))
:class "mb-4"
(a
:class "text-sm text-violet-600 hover:text-violet-800"
:href (str
"/sx/(language.(spec.(explore."
(replace filename ".sx" "")
")))")
:sx-get (str
"/sx/(language.(spec.(explore."
(replace filename ".sx" "")
")))")
:sx-target "#sx-content"
:sx-select "#sx-content"
:sx-swap "innerHTML"
:sx-push-url "true"
(str "← Back to " (replace filename ".sx" ""))))
(div
:class "rounded border border-violet-200 bg-violet-50/30 p-4"
(div
:class "flex items-center gap-2 flex-wrap mb-3"
(span
:class "text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700"
"pure")
(map
(fn (eff) (~specs-explorer/spec-effect-badge :effect eff))
(get d "effects"))))
(when
(not (empty? (get d "params")))
(~specs-explorer/spec-param-list :params (get d "params")))
(~specs-explorer/spec-ring-translations
:source (get d "source")
:python (get d "python")
:javascript (get d "javascript")
:z3 (get d "z3"))
(when
(not (empty? (get d "refs")))
(~specs-explorer/spec-ring-bridge :refs (get d "refs")))
(when
(> (get d "test-count") 0)
(~specs-explorer/spec-ring-runtime
:tests (get d "tests")
:test-count (get d "test-count")))))
:class "font-mono text-lg font-semibold text-stone-800"
(get d "name"))
(span :class "text-xs text-stone-400" (get d "kind"))
(if
(empty? (get d "effects"))
(span
:class "text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700"
"pure")
(map
(fn (eff) (~specs-explorer/spec-effect-badge :effect eff))
(get d "effects"))))
(when
(not (empty? (get d "params")))
(~specs-explorer/spec-param-list :params (get d "params")))
(details
:open "true"
(summary
:class "px-3 py-1.5 bg-stone-50 text-xs font-medium text-stone-600 cursor-pointer select-none mt-3 rounded"
"SX Source")
(pre
:class "text-xs p-3 overflow-x-auto bg-white rounded mt-1 border border-stone-200"
(code :class "language-sx" (get d "source")))))))
(defcomp
~specs-explorer/spec-effect-badge
@@ -178,13 +221,12 @@
(div
:class "mt-3 border border-stone-200 rounded-lg overflow-hidden"
(details
:open "true"
(summary
:class "px-3 py-1.5 bg-stone-50 text-xs font-medium text-stone-600 cursor-pointer"
"SX")
:class "px-3 py-1.5 bg-stone-50 text-xs font-medium text-stone-600 cursor-pointer select-none"
"SX Source")
(pre
:class "text-xs p-3 overflow-x-auto bg-white"
(code (highlight source "sx"))))
(code :class "language-sx" source)))
(when
python
(details