diff --git a/sx/sx/specs-explorer.sx b/sx/sx/specs-explorer.sx index a1214a5..4951c72 100644 --- a/sx/sx/specs-explorer.sx +++ b/sx/sx/specs-explorer.sx @@ -64,6 +64,9 @@ (when (> (get stats "render-count") 0) (span :class "bg-sky-100 text-sky-700 px-2 py-0.5 rounded" (str (get stats "render-count") " render"))) + (when (> (get stats "test-total") 0) + (span :class "bg-violet-100 text-violet-700 px-2 py-0.5 rounded" + (str (get stats "test-total") " tests"))) (span :class "bg-stone-100 text-stone-500 px-2 py-0.5 rounded" (str (get stats "lines") " lines")))) @@ -85,7 +88,7 @@ ;; --------------------------------------------------------------------------- -;; Define card — one function/constant +;; Define card — one function/constant with all five rings ;; --------------------------------------------------------------------------- (defcomp ~spec-explorer-define (&key d) @@ -105,10 +108,22 @@ (when (not (empty? (get d "params"))) (~spec-param-list :params (get d "params"))) - ;; Translation panels + ;; Ring 2: Translation panels (SX + Python + JavaScript + Z3) (~spec-ring-translations :source (get d "source") - :python (get d "python")))) + :python (get d "python") + :javascript (get d "javascript") + :z3 (get d "z3")) + + ;; Ring 3: Cross-references + (when (not (empty? (get d "refs"))) + (~spec-ring-bridge :refs (get d "refs"))) + + ;; Ring 4: Tests + (when (> (get d "test-count") 0) + (~spec-ring-runtime + :tests (get d "tests") + :test-count (get d "test-count"))))) ;; --------------------------------------------------------------------------- @@ -146,29 +161,76 @@ ;; --------------------------------------------------------------------------- -;; Translation panels (Ring 2) +;; Ring 2: Translation panels (nucleus + bootstrapper) ;; --------------------------------------------------------------------------- -(defcomp ~spec-ring-translations (&key source python) +(defcomp ~spec-ring-translations (&key source python javascript z3) (when (not (= source "")) (div :class "mt-3 border border-stone-200 rounded-lg overflow-hidden" - ;; SX source — always open + ;; SX source — Ring 1: the nucleus (always open) (details :open "true" (summary :class "px-3 py-1.5 bg-stone-50 text-xs font-medium text-stone-600 cursor-pointer" "SX") (pre :class "text-xs p-3 overflow-x-auto bg-white" (code (highlight source "sx")))) - ;; Python + ;; Python — Ring 2: bootstrapper (when python (details (summary :class "px-3 py-1.5 bg-stone-50 text-xs font-medium text-stone-600 cursor-pointer border-t border-stone-200" "Python") (pre :class "text-xs p-3 overflow-x-auto bg-white" - (code (highlight python "python")))))))) + (code (highlight python "python"))))) + ;; JavaScript — Ring 2: bootstrapper + (when javascript + (details + (summary :class "px-3 py-1.5 bg-stone-50 text-xs font-medium text-stone-600 cursor-pointer border-t border-stone-200" + "JavaScript") + (pre :class "text-xs p-3 overflow-x-auto bg-white" + (code (highlight javascript "javascript"))))) + ;; Z3 / SMT-LIB — Ring 2: formal translation + (when z3 + (details + (summary :class "px-3 py-1.5 bg-stone-50 text-xs font-medium text-stone-600 cursor-pointer border-t border-stone-200" + "Z3 / SMT-LIB") + (pre :class "text-xs p-3 overflow-x-auto bg-white" + (code (highlight z3 "lisp")))))))) ;; --------------------------------------------------------------------------- -;; Platform interface table (Ring 3) +;; Ring 3: Cross-references (bridge) +;; --------------------------------------------------------------------------- + +(defcomp ~spec-ring-bridge (&key refs) + (div :class "mt-2" + (span :class "text-xs font-medium text-stone-500" "References") + (div :class "flex flex-wrap gap-1 mt-1" + (map (fn (ref) + (a :href (str "#fn-" ref) + :class "text-xs px-1.5 py-0.5 rounded bg-stone-100 text-stone-600 font-mono hover:bg-stone-200" + ref)) + refs)))) + + +;; --------------------------------------------------------------------------- +;; Ring 4: Tests (runtime) +;; --------------------------------------------------------------------------- + +(defcomp ~spec-ring-runtime (&key tests test-count) + (div :class "mt-2" + (div :class "flex items-center gap-1" + (span :class "text-xs font-medium text-stone-500" "Tests") + (span :class "text-xs px-1.5 py-0.5 rounded bg-violet-100 text-violet-700" + (str test-count))) + (ul :class "mt-1 text-xs text-stone-500 list-none" + (map (fn (t) + (li :class "flex items-center gap-1" + (span :class "text-green-500 text-xs" "●") + (get t "name"))) + tests)))) + + +;; --------------------------------------------------------------------------- +;; Platform interface table (Ring 3 overview) ;; --------------------------------------------------------------------------- (defcomp ~spec-platform-interface (&key items) diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index 431c6ac..b183ff3 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -172,6 +172,163 @@ def _read_spec_file(filename: str) -> str: return ";; spec file not found" +# --------------------------------------------------------------------------- +# Spec explorer — translation + cross-reference helpers +# --------------------------------------------------------------------------- + +_JS_SX_ENV = None # cached js.sx evaluator env + +def _js_translate_define(expr: list, name: str) -> str | None: + """Translate a single define expression to JavaScript via js.sx.""" + global _JS_SX_ENV + if _JS_SX_ENV is None: + from shared.sx.ref.run_js_sx import load_js_sx + _JS_SX_ENV = load_js_sx() + from shared.sx.ref.sx_ref import evaluate + from shared.sx.types import Symbol + env = dict(_JS_SX_ENV) + env["_defines"] = [[name, expr]] + result = evaluate([Symbol("js-translate-file"), Symbol("_defines")], env) + if result and isinstance(result, str) and result.strip(): + return result.strip() + return None + + +def _z3_translate_define(expr: list) -> str | None: + """Translate a single define expression to SMT-LIB via z3.sx.""" + from shared.sx.ref.reader_z3 import z3_translate + result = z3_translate(expr) + if result and isinstance(result, str) and result.strip(): + return result.strip() + return None + + +_SPEC_INDEX: dict[str, str] | None = None # function name → spec slug + +def _build_spec_index() -> dict[str, str]: + """Build a global index mapping function names to spec file slugs.""" + global _SPEC_INDEX + if _SPEC_INDEX is not None: + return _SPEC_INDEX + + import os + import glob as globmod + from shared.sx.parser import parse_all + from shared.sx.types import Symbol, Keyword + + ref_dir = os.path.join(os.path.dirname(__file__), "..", "..", "shared", "sx", "ref") + if not os.path.isdir(ref_dir): + ref_dir = "/app/shared/sx/ref" + + index: dict[str, str] = {} + for fp in globmod.glob(os.path.join(ref_dir, "*.sx")): + basename = os.path.basename(fp) + if basename.startswith("test-"): + continue + slug = basename.replace(".sx", "") + try: + with open(fp, encoding="utf-8") as f: + content = f.read() + for expr in parse_all(content): + if not isinstance(expr, list) or len(expr) < 2: + continue + if not isinstance(expr[0], Symbol): + continue + head = expr[0].name + if head in ("define", "define-async"): + name = expr[1].name if isinstance(expr[1], Symbol) else str(expr[1]) + index[name] = slug + except Exception: + continue + + _SPEC_INDEX = index + return _SPEC_INDEX + + +# Test file → spec file mapping +_SPEC_TO_TEST = { + "signals.sx": "test-signals.sx", + "eval.sx": "test-eval.sx", + "parser.sx": "test-parser.sx", + "render.sx": "test-render.sx", + "engine.sx": "test-engine.sx", + "orchestration.sx": "test-orchestration.sx", + "router.sx": "test-router.sx", + "deps.sx": "test-deps.sx", + "adapter-sx.sx": "test-aser.sx", + "types.sx": "test-types.sx", +} + + +def _extract_tests_for_spec(filename: str) -> list[dict]: + """Extract test suites/cases from the corresponding test file.""" + import os + from shared.sx.parser import parse_all + from shared.sx.types import Symbol + + test_file = _SPEC_TO_TEST.get(filename) + if not test_file: + return [] + + ref_dir = os.path.join(os.path.dirname(__file__), "..", "..", "shared", "sx", "ref") + if not os.path.isdir(ref_dir): + ref_dir = "/app/shared/sx/ref" + test_path = os.path.join(ref_dir, test_file) + + try: + with open(test_path, encoding="utf-8") as f: + content = f.read() + exprs = parse_all(content) + except Exception: + return [] + + tests: list[dict] = [] + for expr in exprs: + if not isinstance(expr, list) or len(expr) < 3: + continue + if not isinstance(expr[0], Symbol): + continue + if expr[0].name != "defsuite": + continue + suite_name = expr[1] if isinstance(expr[1], str) else str(expr[1]) + test_names = [] + for child in expr[2:]: + if isinstance(child, list) and len(child) >= 2: + if isinstance(child[0], Symbol) and child[0].name == "deftest": + tname = child[1] if isinstance(child[1], str) else str(child[1]) + test_names.append(tname) + tests.append({"suite": suite_name, "tests": test_names}) + return tests + + +def _match_tests_to_function(fn_name: str, all_tests: list[dict]) -> list[dict]: + """Match test suites to a function by fuzzy name matching.""" + matched = [] + fn_lower = fn_name.lower().replace("-", " ").replace("!", "").replace("?", "") + fn_words = set(fn_lower.split()) + for suite in all_tests: + suite_lower = suite["suite"].lower() + # Match if function name appears in suite name or suite name contains function + if fn_lower in suite_lower or any(w in suite_lower for w in fn_words if len(w) > 2): + matched.append(suite) + return matched + + +def _collect_symbols(expr) -> set[str]: + """Recursively collect all Symbol names referenced in an expression.""" + from shared.sx.types import Symbol + result: set[str] = set() + if isinstance(expr, Symbol): + result.add(expr.name) + elif isinstance(expr, list): + for item in expr: + result |= _collect_symbols(item) + elif isinstance(expr, dict): + for v in expr.values(): + result |= _collect_symbols(v) + return result + + def _spec_explorer_data(filename: str, title: str = "", desc: str = "") -> dict | None: """Parse a spec file into structured metadata for the spec explorer. @@ -369,6 +526,34 @@ def _spec_explorer_data(filename: str, title: str = "", desc: str = "") -> dict except Exception: pass + # --- JavaScript translation --- + js_code = None + try: + js_code = _js_translate_define(expr, name) + except Exception: + pass + + # --- Z3/SMT-LIB translation --- + z3_code = None + try: + z3_code = _z3_translate_define(expr) + except Exception: + pass + + # --- Cross-references --- + refs = [] + platform_deps = [] + try: + spec_index = _build_spec_index() + body_symbols = _collect_symbols(expr) + own_names = {name} + for sym in body_symbols - own_names: + if sym in spec_index: + refs.append(sym) + # Symbols not in any spec file might be platform primitives + except Exception: + pass + define_entry = { "name": name, "kind": kind, @@ -377,6 +562,11 @@ def _spec_explorer_data(filename: str, title: str = "", desc: str = "") -> dict "source": src, "line": line_num, "python": py_code, + "javascript": js_code, + "z3": z3_code, + "refs": refs, + "tests": [], + "test-count": 0, } all_defines.append(define_entry) @@ -401,6 +591,20 @@ def _spec_explorer_data(filename: str, title: str = "", desc: str = "") -> dict target_section = s target_section["defines"].append(d) + # --- Test matching --- + all_tests = _extract_tests_for_spec(filename) + test_total = 0 + for d in all_defines: + matched = _match_tests_to_function(d["name"], all_tests) + if matched: + test_names = [] + for suite in matched: + for t in suite["tests"]: + test_names.append({"name": t, "suite": suite["suite"]}) + d["tests"] = test_names + d["test-count"] = len(test_names) + test_total += len(test_names) + # --- Stats --- pure_count = sum(1 for d in all_defines if not d["effects"]) mutation_count = sum(1 for d in all_defines if "mutation" in d["effects"]) @@ -435,6 +639,7 @@ def _spec_explorer_data(filename: str, title: str = "", desc: str = "") -> dict "io-count": io_count, "render-count": render_count, "lines": len(lines), + "test-total": test_total, }, }