# test.ludic — the top-level regression suite. Replaces test.sh. The compiler is # written in Ludic (selfhost/) and built from a checked-in LLVM-IR seed with # clang; there is no C compiler in the repo. This suite proves the compiler # builds from the seed, compiles every example to its byte-exact golden render, # self-hosts to a fixpoint, and rebuilds itself with no C compiler. # # It drives the other commands through `bin/x ` exactly where test.sh used # to spawn a sibling script, so each stage stays an independent, observable run. # compile a feature example as a game, run it (optional keys on stdin) and match # its stdout against the expected space-joined output. function feat_case(path: pointer, keys: pointer, exp: pointer, label: pointer) -> void { let nm = flat(path) if not game_build_ok("bin/ludicc", `examples/{path}.ludic`, `/tmp/x_e_{nm}`) { bad2(label, capture_line("grep -i error /tmp/x_gb.err | head -1")); return } var got = "" if (keys == "") { got = capture_line(`/tmp/x_e_{nm} < /dev/null`) } else { got = capture_line(`printf '{keys}' | /tmp/x_e_{nm}`) } if (got == exp) { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) } } # a "does it still compile" smoke test (parse -> lower -> link), no run function qsmoke(path: pointer) -> void { let nm = flat(path) if game_build_ok("bin/ludicc", `examples/{path}.ludic`, `/tmp/x_q_{nm}`) { ok(`{path} compiles (@Queries desugars to S_QUERY)`) } else { bad2(path, capture_line("grep -i error /tmp/x_gb.err | head -1")) } } # a plain compile-only rot guard for a showcase example that has no self-asserting # `entry` (so net_case can't drive it): prove it still parses, lowers and links. function smoke(path: pointer) -> void { let nm = flat(path) if not shq(`bin/ludicc --headless examples/{path}.ludic --emit-llvm -o /tmp/x_sm_{nm}.ll > /tmp/x_sm_{nm}.err 2>&1`) { bad2(path, capture_line(`tail -1 /tmp/x_sm_{nm}.err`)); return } ok(`{path} still compiles`) } # a pure-Ludic example that drives and asserts itself: compile headless to IR, # let clang assemble it, run, and compare stdout. No C host and no C compiled. function net_case(path: pointer, exp: pointer) -> void { let nm = flat(path) let ll = `/tmp/x_n_{nm}.ll` let log = `/tmp/x_n_{nm}.out` if not shq(`bin/ludicc --headless examples/{path}.ludic --emit-llvm -o {ll} > {log} 2>&1`) { let t = capture_line(`tail -1 {log}`) bad2(path, `build ({t})`); return } if not shq(`{cc()} -O2 {ll} -o /tmp/x_nb_{nm} >> {log} 2>&1`) { let t = capture_line(`tail -1 {log}`) bad2(path, `build ({t})`); return } let got = capture_line(`/tmp/x_nb_{nm} < /dev/null`) if (got == exp) { ok(`{path} ({got})`) } else { bad2(path, `got [{got}] want [{exp}]`) } } # a test spec (issue #12): `test "name" { expect… }` blocks with no `entry`. The # compiler synthesises a runner @main, so we just build, run, and require a clean # exit (0) plus the expected `== N passed, M failed ==` summary on the last line. function spec_case(path: pointer, exp: pointer) -> void { let nm = flat(path) let ll = `/tmp/x_spec_{nm}.ll` let er = `/tmp/x_spec_{nm}.err` if not shq(`bin/ludicc examples/{path}.ludic > {ll} 2>{er}`) { bad2(path, capture_line(`tail -1 {er}`)); return } if not shq(`{cc()} -O2 {ll} -o /tmp/x_spec_{nm} 2>{er}`) { bad2(path, capture_line(`tail -1 {er}`)); return } let rc = sh(`/tmp/x_spec_{nm} > /tmp/x_spec_{nm}.out 2>&1`) let summary = capture_line(`tail -1 /tmp/x_spec_{nm}.out`) if (rc == 0) and (summary == exp) { ok(`{path} ({summary})`) } else { bad2(path, `rc={string(rc)} last=[{summary}]`) } } # issue #8: the failure path — a panic must abort with a non-zero exit and a # `file:line: panic: ` line on stderr (stdout is unaffected up to the panic). function panic_case() -> void { write_file("/tmp/x_panic.ludic", "program P { entry { print(7); panic(\"boom\") } }\n") if not shq("bin/ludicc /tmp/x_panic.ludic > /tmp/x_panic.ll 2>/dev/null") { bad("panic example did not compile"); return } if not shq(`{cc()} -O2 /tmp/x_panic.ll -o /tmp/x_panic 2>/dev/null`) { bad("panic example did not link"); return } let rc = sh("/tmp/x_panic > /tmp/x_panic.out 2>/tmp/x_panic.err") let msg = capture_line("cat /tmp/x_panic.err") if (rc != 0) and shq("grep -q 'panic: boom' /tmp/x_panic.err") { ok(`panic aborts non-zero with a located stderr message ({msg})`) } else { bad2("panic", `rc={string(rc)} err=[{msg}]`) } } # issue #45: `bin/x test --coverage`. Compile each test-spec with `--coverage`, # run it with LUDIC_COVERAGE pointed at a per-file dump, then aggregate the dumps # into a clean per-file line-coverage report. The instrumentation is flag-gated, # so this reuses the same seed-built bin/ludicc the rest of the suite does. function cov_one(path: pointer, dir: pointer) -> int { let nm = flat(path) let ll = `/tmp/x_cov_{nm}.ll` let bin = `/tmp/x_cov_{nm}` let cov = `{dir}/{nm}.cov` if not shq(`bin/ludicc --coverage examples/{path}.ludic > {ll} 2>/tmp/x_cov.err`) { bad2(path, capture_line("tail -1 /tmp/x_cov.err")); return 1 } if not shq(`{cc()} -O2 {ll} -o {bin} 2>/tmp/x_cov.err`) { bad2(path, capture_line("tail -1 /tmp/x_cov.err")); return 1 } run(`rm -f {ll}`) # run the spec; its atexit hook writes the dump to $LUDIC_COVERAGE if not shq(`LUDIC_COVERAGE={cov} {bin} > /dev/null 2>&1`) { bad2(path, "spec exited non-zero"); return 1 } if not file_exists(cov) { bad2(path, "no coverage dump written"); return 1 } # aggregate: total instrumented lines, how many were hit, and which were missed. # each row is ` `, so an unhit line ends in " 0" — grep counts and # lists them (avoiding awk, whose braces collide with string interpolation). let total = str_to_int(capture_line(`grep -c '^[0-9]' {cov}`)) let nmiss = str_to_int(capture_line(`grep -c ' 0$' {cov}`)) let covered = total - nmiss let missed = capture_line(`grep ' 0$' {cov} | cut -d' ' -f1 | tr '\n' ' '`) var pct = 100 if total > 0 { pct = (covered * 100) / total } var line = ` examples/{path}.ludic {string(covered)}/{string(total)} lines {string(pct)}%` if not (missed == "") { line = `{line} uncovered: {missed}` } print(line) COV_COVERED = COV_COVERED + covered COV_TOTAL = COV_TOTAL + total return 0 } var COV_COVERED: int = 0 var COV_TOTAL: int = 0 function cmd_test_coverage() -> int { # the suite normally rebuilds bin/ludicc from the seed; do the same here so the # instrumentation path is exactly what a clean checkout ships. run("mkdir -p bin build /tmp/x_cov_out") if (not is_exec("bin/ludicc")) or newer("selfhost/ludicc.seed.ll", "bin/ludicc") { if not shq(`{cc()} selfhost/ludicc.seed.ll -o bin/ludicc`) { err("x: cannot build bin/ludicc from the seed\n"); return 1 } } COV_COVERED = 0 COV_TOTAL = 0 print("== line coverage (bin/x test --coverage) ==") cov_one("library/coverage", "/tmp/x_cov_out") cov_one("library/testing", "/tmp/x_cov_out") var pct = 100 if COV_TOTAL > 0 { pct = (COV_COVERED * 100) / COV_TOTAL } print(` ----`) print(` total: {string(COV_COVERED)}/{string(COV_TOTAL)} lines {string(pct)}%`) return 0 } function cmd_test() -> int { PASS = 0 FAIL = 0 print("== the compiler is written in Ludic, not C ==") if shq("test -e compiler") or shq("ls compiler/*.c >/dev/null 2>&1") { bad("compiler/ C sources still present") } else { ok("no C compiler in the repo (the compiler is selfhost/*.ludic)") } let cleft = capture_line("find tools -name '*.c' 2>/dev/null | wc -l") ok(`the only C in the tree is any editor tooling ({cleft} .c files)`) print("== the compiler builds from the IR seed (no C compiler) ==") run("rm -f bin/ludicc") if shq("bin/x app examples/games/snake.ludic --headless >/dev/null 2>&1") and is_exec("bin/ludicc") { ok("clang selfhost/ludicc.seed.ll -> bin/ludicc") } else { bad("compiler did not build from the seed") } print("== every example compiles to its golden render ==") game_case("games/snake", "ddssaawwddss") game_case("games/menu", "ssss") game_case("games/chronorift", "ddddwwwwaassK") print("== self-hosting and the bootstrap fixpoints ==") run("bin/x selfhost-test > /tmp/x_sh.out 2>&1") if shq("grep -q 'gen2.ll == gen3.ll' /tmp/x_sh.out") { ok("the compiler reproduces itself (fixpoint)") } else { bad("fixpoint") } if shq("grep -q 'no C compiler' /tmp/x_sh.out") { ok("the compiler rebuilds itself from the seed, C-free") } else { bad("C-free bootstrap") } let corr = capture_line("grep -c PASS /tmp/x_sh.out") ok(`self-host suite: {corr} checks passed (see: x selfhost-test)`) print("== documented syntax stays compilable (guards against spec/compiler drift) ==") qsmoke("lang/qdecl") feat_case("lang/annotations", "", "3 25 0 0", "annotations.ludic (@Queries / @Computed / @OnSpawn / @Handles)") feat_case("lang/lifecycle", "", "1 700 50 950 2", "lifecycle.ludic (@OnStart/@OnAttach/@OnSpawn/@OnDespawn/@OnQuit in order)") feat_case("lang/toggle", "", "6 0 7 1 0", "toggle.ludic (enable/disable + @OnDisable/@OnEnable across property/model)") feat_case("lang/strings", "", "1 2 3 4 5 6 7 8 9", "strings.ludic (str ops, interpolation, slicing)") feat_case("lang/rng_demo", "", "69 89 6 -1 0", "rng_demo.ludic (Random.value/int/sign)") feat_case("lang/time_demo", "aaaaaa", "0 1 2 3 4 16", "time_demo.ludic (Time.frame/elapsed/delta)") feat_case("lang/scenes", "aaaaaaaaaa", "1000 1 101 102 2 3 900 201 900 202 900", "scenes.ludic (scene/layer/on enter/on exit/become)") feat_case("lang/detach", "", "15 1 25 0", "detach.ludic (attach/detach + @OnAttach/@OnDetach)") feat_case("lang/reason", "", "503 1009", "reason.ludic (@OnDespawn reason: Despawned vs Quit)") # standard-library namespaces, each a self-contained `entry` program asserted # against known-answer vectors (crypto) or structural invariants (uuid/noise). feat_case("library/crypto", "", "1 2 3 4 5 6 7 8 9", "crypto.ludic (Crypto SHA-256/HMAC/base64 KAT + CSPRNG shape)") feat_case("library/uuid", "", "1 2 3 4 5 6 7 8 9 10", "uuid.ludic (Uuid v4/v7 format, version/variant, parse/equals)") feat_case("library/noise", "", "1 2 3 4 5 6 7 8 9 10 11", "noise.ludic (Noise value/perlin/simplex/fbm/cellular determinism + range)") feat_case("library/regex", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "regex.ludic (Regex match/find/groups/classes/quantifiers/replace + linear-time safety)") feat_case("library/grid", "", "1 2 3 4 5 6 7 8 9 10 11 12 13", "grid.ludic (Grid line/flood/line_of_sight + A* pathfinding over the tilemap)") feat_case("library/anim", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34", "anim.ludic (Anim frame/once/pingpong/cell + Tween progress/loop/yoyo/ease/number/round/point/tint)") feat_case("library/query", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "query.ludic (Query count/first/nearest/within — ECS spatial queries over the reflection ABI)") feat_case("library/reflect", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "reflect.ludic (Reflect prop/field enumeration + type + get/set/has/kind — runtime reflection over the world schema)") feat_case("library/render", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "render.ludic (Screen pixel/oval/camera/clip/blend_mode/measure_text + Camera set/follow/shake, verified by pixel readback)") feat_case("library/lighting", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "lighting.ludic (Light ambient/point radial falloff + occluder hard shadows — 2D light accumulation, verified by pixel readback)") spec_case("library/testing", "== 6 passed, 0 failed ==") spec_case("library/coverage", "== 3 passed, 0 failed ==") feat_case("library/errors", "", "5 10 0 7 1", "errors.ludic (assert guards an invariant, holds -> runs to the end; issue #8 success path)") feat_case("library/recover", "", "2600 100 1 1 42 7", "recover.ludic (try/else recovers a result value + fallback, error binding, is_ok/is_err; issue #46)") panic_case() feat_case("library/logging", "", "0 5 2 1", "logging.ludic (Log levels, set_level/level threshold, structured fields)") # Os known-folders/arch and Fs.list read the BSD utsname/dirent layout, so # their asserted values are macOS-specific; skip off Darwin (see is_darwin). if is_darwin() { feat_case("library/os", "", "1 2 3 4 5 6 7 8 9 10 11 12 13", "os.ludic (Os args/env round-trip, platform/arch, known dirs)") } else { skip("os.ludic (Os known-folder / uname ABI is macOS/BSD-specific)") } feat_case("library/unicode", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "unicode.ludic (UTF-8 len/validate/char_at/chars/case/truncate/graphemes)") if is_darwin() { feat_case("library/fs", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32", "fs.ludic (Path join/dir/base/ext/normalize, Fs read/write/list/copy, Mime of/sniff)") } else { skip("fs.ludic (Fs.list reads the macOS/BSD dirent layout)") } # issue #9: the Time/Date/Duration/Clock stdlib, driven from its own `entry`. net_case("lang/offline_rewards", "13 650 2026-08-30 0") # EV0b + NETWORKING N0–N6: each a self-contained pure-Ludic program, driven and # asserted from its own `entry`. The transport is the compiler's built-in # loopback, so a networked game runs with zero foreign code. net_case("events/mod_events", "10 32 42") net_case("networking/net_echo", "4 10 20 30 42") net_case("networking/net_snapshot", "50 7 50") net_case("networking/net_sync", "12 3 4 50 999") net_case("networking/net_owner", "-1 7 0 1") net_case("networking/net_rpc", "0 8") net_case("networking/net_roles", "1 102") net_case("networking/net_demo", "5 999 5") feat_case("events/promote", "", "100 101 200 201", "promote.ludic (@Public -> model__spawn/despawn events)") feat_case("events/cancel", "", "0 1 92", "cancel.ludic (cancellable event + cancel + emit-as-expression)") # EV2 the world table: the mod reflection ABI, callable from Ludic by name. net_case("ecs/world_get", "50 1 7") net_case("ecs/world_scan", "2 110") net_case("ecs/world_spawn", "1 42 1") net_case("ecs/world_mixed", "99") net_case("ecs/world_query", "2 110") feat_case("events/recurse", "", "16", "recurse.ludic (EV6: re-entrant emit is depth-bounded, no runaway cycle)") net_case("events/scoped", "2") net_case("ecs/world_dyn", "0 1 30 100 1 30") feat_case("events/prop_events", "", "300 400", "prop_events.ludic (@Public -> prop_

_attach/detach events)") feat_case("events/scene_events", "", "10 1 20 2 30 3", "scene_events.ludic (public scene -> scene__enter/exit events)") feat_case("events/program_events", "", "1 100 2 200", "program_events.ludic (@Public @OnStart/@OnQuit -> program_start/quit)") feat_case("events/layer_events", "aaaa", "50 2 1 50", "layer_events.ludic (layer toggle + public layer -> layer__show/hide)") # showcase examples with no self-asserting `entry` — compile-only so they can't # silently rot after a compiler or reorg change. print("== showcase examples still compile ==") smoke("ecs/hello") smoke("events/events") smoke("networking/net_rt") print("== self-hosted front-end binaries (ludicc / ludic) ==") # the two commands are one multi-call native binary built from the seed with # clang alone. ludicc compiles; ludic compiles-and-runs. if shq("bin/x build-cli > /tmp/x_cli.out 2>&1") and is_exec("bin/ludicc") and is_exec("bin/ludic") { ok("x build-cli builds bin/ludicc and bin/ludic from the seed (clang-only)") } else { bad2("x build-cli", capture_line("tail -1 /tmp/x_cli.out")) } # ludicc -o drives clang to a native binary. A game links windowed by default # (LUDIC_HOME points the linker at runtime/native/cocoa.ll) — that path is # macOS-only (Cocoa), so off Darwin we prove the same "-o yields a runnable # native binary" with a headless link, which needs no window backend. if is_darwin() { if shq("LUDIC_HOME=. bin/ludicc examples/games/snake.ludic -o /tmp/x_cli_snake > /tmp/x_cli.out 2>&1") and shq("file /tmp/x_cli_snake 2>/dev/null | grep -q Mach-O") { ok("ludicc app.ludic -o bin -> native executable") } else { bad2("ludicc -o", capture_line("tail -1 /tmp/x_cli.out")) } } else { if shq("LUDIC_HOME=. bin/ludicc --headless examples/games/snake.ludic -o /tmp/x_cli_snake > /tmp/x_cli.out 2>&1") and shq("test -x /tmp/x_cli_snake") { ok("ludicc app.ludic -o bin -> native executable (headless)") } else { bad2("ludicc -o", capture_line("tail -1 /tmp/x_cli.out")) } } # --emit-llvm stops at the IR. if shq("bin/ludicc examples/games/snake.ludic --emit-llvm -o /tmp/x_cli_snake.ll > /tmp/x_cli.out 2>&1") and shq("head -1 /tmp/x_cli_snake.ll | grep -q 'LLVM IR'") { ok("ludicc --emit-llvm -> LLVM IR") } else { bad2("ludicc --emit-llvm", capture_line("tail -1 /tmp/x_cli.out")) } # ludic compiles, runs, and forwards the program's exit code. write_file("/tmp/x_cli_exit.ludic", "entry { exit(42) }\n") let rc = sh("LUDIC_HOME=. bin/ludic /tmp/x_cli_exit.ludic > /tmp/x_cli.out 2>&1") if (rc == 42) { ok("ludic app.ludic -> compiles, runs, forwards exit code") } else { bad2("ludic run: expected exit 42", `got {string(rc)}`) } # docs site generator + guard, in Ludic (no Python). docs-gen emits the whole # pages payload; docs-check is its coverage/integrity guard; docs-palette is # the named-colour source of truth (its output is the tracked emit_color.ludic # + palette.json, so a clean git diff proves it stayed byte-identical). print("== docs site (Ludic generator, no Python) ==") if shq("bin/x docs-gen --out /tmp/x_docs > /tmp/x_docs.out 2>&1") { ok("x docs-gen -> the pages-branch payload") } else { bad2("x docs-gen", capture_line("tail -1 /tmp/x_docs.out")) } if shq("bin/x docs-check /tmp/x_docs > /tmp/x_docs_chk.out 2>&1") { ok("x docs-check passes on the generated site") } else { bad2("x docs-check", capture_line("tail -1 /tmp/x_docs_chk.out")) } if shq("bin/x docs-palette > /dev/null 2>&1") and shq("git diff --quiet selfhost/backend/stdlib/emit_color.ludic docs/language/colors/palette.json") { ok("x docs-palette regenerates emit_color.ludic + palette.json byte-identically") } else { bad("x docs-palette drifted (git diff not empty)") } return report() }