ludic/tools/ludic-cli/test.ludic
Orkuncakilkaya be74b4de6f feat(bundle): ship a game as a macOS .app, with a splash it controls
`ludic build` produces a program. Double-clicked it opens a Terminal window, it
wears the generic executable icon, it calls itself whatever the file is called,
and it carries none of its assets. `ludic bundle` produces an application.

Everything it needs is in package.ludic, so the command takes no arguments: an
Info.plist and PkgInfo from `app` lines, an .icns built by sips and iconutil at
all ten sizes macOS asks for from a single source PNG, the asset pack in
Contents/Resources, and an ad-hoc signature - which is not optional on Apple
silicon, where an unsigned binary is killed rather than warned about. The bundle
identifier falls back to the package path reversed, so a project that never
thinks about it still gets a defensible one instead of two apps sharing a key
Launch Services hangs the Dock, saved state and permissions off.

A bundled game is moved to ~/Library/Application Support/<name> before main,
because Finder starts a .app with its working directory at "/" where no save
could ever be written. Reads come out of the pack, writes land somewhere real
and per-user, and the game's save code needs no change and no platform
knowledge.

The splash is the other half of looking like an application. A game that loads
165 MB spends a visible moment doing it with nothing on screen, which from the
outside is indistinguishable from a launch that failed. splash_show puts a
borderless window up from the same constructor that mounts the pack - before
main, so it appears while the process is still starting rather than after the
slow part it exists to cover - and reads the artwork out of the pack like any
other asset. It turns the run loop enough times to be mapped and composited
there and then; once composited the backing store survives a busy main thread,
so it stays up for the whole load.

Nothing hides it automatically. Only the game knows when its first real frame is
ready, and a splash that vanishes before that leaves the same black gap it was
covering, so the game calls App.splash_hide(). Headless there is no splash and
the call lowers to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:57:27 +03:00

657 lines
43 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/ludic <sub>` 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_dir()}/e_{nm}`) {
bad2(label, capture_line(`grep -i error {tmp_dir()}/gb.err | head -1`)); return
}
var got = ""
if (keys == "") { got = capture_line(`{tmp_dir()}/e_{nm} < /dev/null`) }
else { got = capture_line(`printf '{keys}' | {tmp_dir()}/e_{nm}`) }
if (got == exp) { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) }
}
# a feature example that consumes an in-repo controller package (packages/): same
# as feat_case, but the compiler is pointed at packages/ via LUDIC_MODULES so a
# `import "ludic.foo/bar.ludic"` resolves, and LUDIC_HOME anchors the engine
# runtime. This is exactly how `ludic build` / an external project builds against a
# fetched source package (the do_import module-root fallback, #63).
function controller_case(path: pointer, keys: pointer, exp: pointer, label: pointer) -> void {
let nm = flat(path)
let root = capture_line("pwd")
let ll = `{tmp_dir()}/c_{nm}.ll`
if not shq(`LUDIC_HOME={root} LUDIC_MODULES={root}/packages bin/ludicc examples/{path}.ludic > {ll} 2>{tmp_dir()}/gb.err`) {
bad2(label, capture_line(`grep -i error {tmp_dir()}/gb.err | head -1`)); return
}
if not shq(`{cc()} -O2 {ll} -o {tmp_dir()}/c_{nm} 2>{tmp_dir()}/gb.err`) {
bad2(label, capture_line(`grep -i error {tmp_dir()}/gb.err | head -1`)); return
}
var got = ""
if (keys == "") { got = capture_line(`{tmp_dir()}/c_{nm} < /dev/null`) }
else { got = capture_line(`printf '{keys}' | {tmp_dir()}/c_{nm}`) }
if (got == exp) { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) }
}
# #75 — an external project (its CWD is not the toolchain repo, with no runtime/
# under it and no ludic_modules) builds an ECS game by resolving the engine runtime
# from $LUDIC_HOME (the toolchain install), independent of the package module root.
function runtime_home_case() -> void {
let root = capture_line("pwd")
let dir = `{tmp_dir()}/ext75`
shq(`rm -rf {dir} && mkdir -p {dir}`)
write_file(`{dir}/game.ludic`, "program Ext {\n property Mob { hp: int = 0 }\n model M { Mob }\n entry {\n spawn M { Mob { hp: 7 } }\n let pm = World.prop_id(\"Mob\")\n let e = World.query_next(pm, 0)\n print(World.get(e, pm, World.field_id(pm, \"hp\")))\n }\n}\n")
let lbl = "runtime ships with the toolchain, resolved from LUDIC_HOME (#75)"
# compiled from the external CWD; there is deliberately no runtime/ or
# ludic_modules/ under {dir}, so the engine runtime can only come from LUDIC_HOME.
if not shq(`cd {dir} && LUDIC_HOME={root} {root}/bin/ludicc game.ludic -o {dir}/game 2>{dir}/err`) {
bad2(lbl, capture_line(`grep -i 'error|cannot open' {dir}/err | head -1`)); return
}
let got = capture_line(`{dir}/game < /dev/null`)
if (got == "7") { ok(lbl) } else { bad2(lbl, `got [{got}] want [7]`) }
}
# The install layout: bin/ beside runtime/ and packages/, reached through $PATH
# from an unrelated directory, with nothing in the environment. This is what
# `curl https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh` lays down and the only shape most
# users will ever run, so it is asserted rather than assumed: a staged copy of
# the toolchain compiles and runs a project created by `ludic new`.
function install_layout_case() -> void {
let root = capture_line("pwd")
let home = `{tmp_dir()}/install`
let lbl = "an installed layout (bin/ + runtime/ + packages/, found on PATH) builds a new project"
run(`rm -rf {home} && mkdir -p {home}/bin`)
run(`cp bin/ludic bin/ludicc bin/ludic-fmt {home}/bin/`)
run(`cp -R runtime {home}/runtime && cp -R packages {home}/packages && cp VERSION {home}/VERSION`)
let work = `{tmp_dir()}/installwork`
run(`rm -rf {work} && mkdir -p {work}`)
# PATH only — no LUDIC_HOME, and the current directory has no bin/ of its own.
let envp = `env -u LUDIC_HOME PATH={home}/bin:/usr/bin:/bin:/usr/sbin:/sbin`
if not shq(`cd {work} && {envp} ludic new demo > {work}/new.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/new.out`)); return
}
if not shq(`cd {work}/demo && {envp} ludic build --headless > {work}/build.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/build.out`)); return
}
if not shq(`cd {work}/demo && {envp} ludic test > {work}/test.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/test.out`)); return
}
# from the project directory, never the repo root: `ludic version` used to
# answer from a ./bin and ./VERSION next to the *current* directory, which made
# this check pass in the repo while a real install said "(version unknown)".
let ver = capture_line(`cd {work}/demo && {envp} ludic version`)
if ver != `ludic {s_trim(read_file("VERSION"))}` {
bad2("an installed `ludic version` reports the install's VERSION", `got [{ver}]`); return
}
# and the shipped CLI carries none of the contributor tasks: `ludic dev …` is
# not a command there, it is a pointer at the tool that has them.
let devlog = `{work}/dev.out`
let devrc = sh(`cd {work}/demo && {envp} ludic dev build > {devlog} 2>&1`)
var devout = read_file(devlog)
if devout == null { devout = "" }
if devrc == 0 or not s_contains(devout, "ludic-dev") {
bad2("an installed `ludic` must not carry the toolchain's own tasks", `rc {string(devrc)}, said [{s_trim(devout)}]`)
return
}
ok(lbl)
}
# `ludic new` must scaffold something that compiles, for any name a user might
# reasonably pick. `my-game` produced `program My-Game`, which is a subtraction,
# and `2048` produced an identifier starting with a digit — both scaffolded a
# project that failed on the very first `ludic run`.
# The asset pack, end to end: `ludic pack` writes it, the runtime mounts it
# before main from packs.index, and the game reads its assets out of it while
# running from a directory where none of them exist on disk. That last part is
# the whole feature - a built game used to run only from its project root.
function pack_roundtrip_case() -> void {
let lbl = "ludic pack -> a game reads its assets with none of them on disk"
let work = `{tmp_dir()}/packrt`
let root = capture_line("pwd")
run(`rm -rf {work} && mkdir -p {work}/assets/sub {work}/ship`)
write_file(`{work}/assets/a.txt`, "alpha")
write_file(`{work}/assets/sub/b.txt`, "beta")
write_file(`{work}/game.ludic`, pack_probe_src())
if not shq(`cd {work} && {root}/bin/ludic pack --out ship/game.lpak assets > pack.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/pack.out`)); return
}
if not shq(`cd {work} && {root}/bin/ludic pack --verify ship/game.lpak > verify.out 2>&1`) {
bad2(lbl, "the pack it just wrote does not verify"); return
}
if not shq(`cd {work} && {root}/bin/ludicc game.ludic -o ship/probe > cc.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/cc.out`)); return
}
write_file(`{work}/ship/packs.index`, "pack game.lpak" + nl())
# the assets exist only inside the pack from here on
run(`rm -rf {work}/assets`)
# run from somewhere with no relation to the project at all
let got = s_trim(capture(`cd / && {work}/ship/probe 2>&1`))
if got != "alpha|beta|1|5|0" {
bad2(lbl, `read back [{got}], wanted [alpha|beta|1|5|0]`); return
}
ok(lbl)
}
# the probe game: reads two packed files and asks after a third that is in no
# pack, so a false hit would show up as loudly as a miss
function pack_probe_src() -> pointer {
var s = "program PackProbe {" + nl()
s = s + " entry {" + nl()
s = s + " var a = Fs.read_text(\"assets/a.txt\")" + nl()
s = s + " var b = Fs.read_text(\"assets/sub/b.txt\")" + nl()
s = s + " if a == null { a = \"MISS\" }" + nl()
s = s + " if b == null { b = \"MISS\" }" + nl()
s = s + " print(`{a}|{b}|{string(Fs.exists(\"assets/a.txt\"))}|{string(Fs.size(\"assets/a.txt\"))}|{string(Fs.exists(\"assets/gone.txt\"))}`)" + nl()
s = s + " }" + nl()
s = s + "}" + nl()
return s
}
# `ludic bundle` has to produce something macOS actually accepts as an
# application, not a directory that looks like one: a plist that plutil parses,
# an executable where Info.plist says it is, an .icns, and a signature. The
# app's own assets go in the pack beside it, and the display name is allowed a
# space - which is where the shell quoting in the bundler earns its keep.
function bundle_case() -> void {
let lbl = "ludic bundle -> a .app with metadata, icon, assets and a signature"
if not is_darwin() { skip(lbl); return }
let work = `{tmp_dir()}/bundle`
let root = capture_line("pwd")
run(`rm -rf {work} && mkdir -p {work}/src {work}/assets`)
write_file(`{work}/assets/hello.txt`, "packed")
# a 64x64 source icon: small, but sips will scale it and iconutil accept it
run(`sips -s format png --resampleHeightWidth 64 64 /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns --out {work}/assets/icon.png > /dev/null 2>&1`)
write_file(`{work}/package.ludic`, bundle_manifest_src())
write_file(`{work}/src/main.ludic`, bundle_probe_src())
if not shq(`cd {work} && {root}/bin/ludic bundle > bundle.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/bundle.out`)); return
}
let app = `{work}/build/Probe App.app`
# quoted: the display name has a space in it, which is the whole point, and
# the prelude's file_exists builds an unquoted `test -e`
if not there(`{app}/Contents/MacOS/Probe App`) { bad2(lbl, "no executable in Contents/MacOS"); return }
if not there(`{app}/Contents/Resources/game.lpak`) { bad2(lbl, "no asset pack"); return }
if not there(`{app}/Contents/Resources/packs.index`) { bad2(lbl, "no packs.index"); return }
if not there(`{app}/Contents/Resources/AppIcon.icns`) { bad2(lbl, "no icon"); return }
if not there(`{app}/Contents/PkgInfo`) { bad2(lbl, "no PkgInfo"); return }
if not shq(`plutil -lint "{app}/Contents/Info.plist" > /dev/null 2>&1`) {
bad2(lbl, "Info.plist does not parse"); return
}
# the identifier is derived from the package path when the manifest omits it
let id = capture_line(`plutil -extract CFBundleIdentifier raw "{app}/Contents/Info.plist" 2>/dev/null`)
if id != "io.workshopsoft.probe-app" { bad2(lbl, `bundle id came out [{id}]`); return }
if not shq(`codesign --verify "{app}" > /dev/null 2>&1`) { bad2(lbl, "the signature does not verify"); return }
# and it runs from a directory with none of its assets, reading them from the pack
let got = s_trim(capture(`cd / && "{app}/Contents/MacOS/Probe App" 2>&1`))
if got != "packed" { bad2(lbl, `the bundled app printed [{got}], wanted [packed]`); return }
ok(lbl)
}
# `test -e` with the path quoted, so a bundle whose name contains a space can
# be inspected at all
function there(path: pointer) -> bool { return shq(`test -e "{path}"`) }
function bundle_manifest_src() -> pointer {
var s = "package \"git.workshopsoft.io/workshopsoft/probe-app\"" + nl()
s = s + "version \"2.1.0\"" + nl()
s = s + "kind source" + nl()
s = s + "app name \"Probe App\"" + nl()
s = s + "app icon \"assets/icon.png\"" + nl()
s = s + "app copyright \"(c) 2026 A & B\"" + nl()
s = s + "pack \"assets\"" + nl()
return s
}
function bundle_probe_src() -> pointer {
var s = "program Probe {" + nl()
s = s + " entry {" + nl()
s = s + " var t = Fs.read_text(\"assets/hello.txt\")" + nl()
s = s + " if t == null { t = \"MISS\" }" + nl()
s = s + " print(t)" + nl()
s = s + " }" + nl()
s = s + "}" + nl()
return s
}
function scaffold_names_case() -> void {
let lbl = "ludic new scaffolds a project that compiles (hyphens, digits, dots)"
let work = `{tmp_dir()}/scaffold`
run(`rm -rf {work} && mkdir -p {work}`)
let root = capture_line("pwd")
let names = new []pointer
push(names, "my-game")
push(names, "2048")
push(names, "a.b.c")
push(names, "UPPER_case")
var i = 0
while i < len(names) {
let nm = names[i]
i += 1
if not shq(`cd {work} && {root}/bin/ludic new {nm} > {work}/new.out 2>&1`) {
bad2(lbl, `ludic new {nm} failed`); return
}
if not shq(`cd {work}/{nm} && {root}/bin/ludic build --headless > {work}/build.out 2>&1`) {
let why = capture_line(`tail -1 {work}/build.out`)
bad2(lbl, `{nm}: {why}`); return
}
if not shq(`cd {work}/{nm} && {root}/bin/ludic test > {work}/test.out 2>&1`) {
bad2(lbl, `{nm}: tests failed`); return
}
}
# and a name that cannot be a directory or a package is refused, not mangled
if shq(`cd {work} && {root}/bin/ludic new 'two words' > {work}/bad.out 2>&1`) {
bad2(lbl, "a name with a space was accepted"); return
}
ok(lbl)
}
# 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_dir()}/q_{nm}`) {
ok(`{path} compiles (@Queries desugars to S_QUERY)`)
} else { bad2(path, capture_line(`grep -i error {tmp_dir()}/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_dir()}/sm_{nm}.ll > {tmp_dir()}/sm_{nm}.err 2>&1`) {
bad2(path, capture_line(`tail -1 {tmp_dir()}/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_dir()}/n_{nm}.ll`
let log = `{tmp_dir()}/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_dir()}/nb_{nm} >> {log} 2>&1`) {
let t = capture_line(`tail -1 {log}`)
bad2(path, `build ({t})`); return
}
let got = capture_line(`{tmp_dir()}/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_dir()}/spec_{nm}.ll`
let er = `{tmp_dir()}/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_dir()}/spec_{nm} 2>{er}`) {
bad2(path, capture_line(`tail -1 {er}`)); return
}
let rc = sh(`{tmp_dir()}/spec_{nm} > {tmp_dir()}/spec_{nm}.out 2>&1`)
let summary = capture_line(`tail -1 {tmp_dir()}/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: <msg>` line on stderr (stdout is unaffected up to the panic).
function panic_case() -> void {
write_file(`{tmp_dir()}/panic.ludic`, "program P { entry { print(7); panic(\"boom\") } }\n")
if not shq(`bin/ludicc {tmp_dir()}/panic.ludic > {tmp_dir()}/panic.ll 2>/dev/null`) { bad("panic example did not compile"); return }
if not shq(`{cc()} -O2 {tmp_dir()}/panic.ll -o {tmp_dir()}/panic 2>/dev/null`) { bad("panic example did not link"); return }
let rc = sh(`{tmp_dir()}/panic > {tmp_dir()}/panic.out 2>{tmp_dir()}/panic.err`)
let msg = capture_line(`cat {tmp_dir()}/panic.err`)
if (rc != 0) and shq(`grep -q 'panic: boom' {tmp_dir()}/panic.err`) { ok(`panic aborts non-zero with a located stderr message ({msg})`) }
else { bad2("panic", `rc={string(rc)} err=[{msg}]`) }
}
# issue #45: `bin/ludic-dev 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_dir()}/cov_{nm}.ll`
let bin = `{tmp_dir()}/cov_{nm}`
let cov = `{dir}/{nm}.cov`
if not shq(`bin/ludicc --coverage examples/{path}.ludic > {ll} 2>{tmp_dir()}/cov.err`) {
bad2(path, capture_line(`tail -1 {tmp_dir()}/cov.err`)); return 1
}
if not shq(`{cc()} -O2 {ll} -o {bin} 2>{tmp_dir()}/cov.err`) {
bad2(path, capture_line(`tail -1 {tmp_dir()}/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 `<line> <hits>`, 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 += covered
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_dir()}/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("ludic-dev: cannot build bin/ludicc from the seed\n"); return 1 }
}
COV_COVERED = 0
COV_TOTAL = 0
print("== line coverage (bin/ludic-dev test --coverage) ==")
cov_one("library/coverage", `{tmp_dir()}/cov_out`)
cov_one("library/testing", `{tmp_dir()}/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_dev_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/ludic build 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/ludic-dev selfhost-test > {tmp_dir()}/sh.out 2>&1`)
if shq(`grep -q 'gen2.ll == gen3.ll' {tmp_dir()}/sh.out`) { ok("the compiler reproduces itself (fixpoint)") } else { bad("fixpoint") }
if shq(`grep -q 'no C compiler' {tmp_dir()}/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_dir()}/sh.out`)
ok(`self-host suite: {corr} checks passed (see: ludic-dev 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/operators", "", "1 2 3 4 5 6 7 8 9 10", "operators.ludic (compound assignment on fixed/string/long, -fixed, char escapes, list literals)")
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/bignum", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16", "bignum.ludic (BigInt arbitrary-precision + Decimal exact base-10 money)")
feat_case("library/containers", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "containers.ludic (Dict string-keyed hash map + Set string set)")
feat_case("library/numeric", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "numeric.ludic (Huge idle big-numbers + Angle wrapping radians + Percent clamped [0,1])")
feat_case("library/jobs", "", "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", "jobs.ludic (Job background compute/defer/fulfill/cancel + Promise all/race/progress + Sync mutex/atomic/channel; issue #14)")
feat_case("library/optionresult", "", "1 2 3 4 5 6 7 8 9 10 11 12", "optionresult.ludic (option some/none + result ok/err/try safety types)")
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/anim_sugar", "", "4 8 2 1 0 100 100 0 0 1 20 20 30 0 1", "anim_sugar.ludic (Anim.clip/play/on_frame/fired + Motion.to + fluent Tween.to/chain/delay/parallel handles; issue #48)")
feat_case("library/namespace_block", "", "15 42 100", "namespace_block.ludic (#76 namespace Name { export/internal function } block — declares the namespace once, controls the public surface)")
feat_case("library/preload", "", "3 0 0 0 1 33 66 1 100 1", "preload.ludic (#82 Assets.enqueue/pump/progress/ready — incremental asset preload for a loading scene)")
feat_case("library/pool", "", "0 0 3 3 0 2 1 3 0 3 1", "pool.ludic (#80 Pool.live/free/reserved/capacity — the ECS freelist recycles despawned slots, reserved stays flat)")
feat_case("library/atlas", "", "16 16 32 48 1 1 1 16 1", "atlas.ludic (#81 Sprite.sheet/cell/cell_span/define/named + Assets.image/get — namespaced spritesheet/atlas with multi-cell sprites)")
feat_case("library/camera_zoom", "", "1 0 0 1 1", "camera_zoom.ludic (Camera.zoom deterministic Q16.16 render-time zoom about the screen centre, verified by pixel readback; issue #78)")
feat_case("library/clear_color", "q", "1 1", "clear_color.ludic (@ClearColor: the Render phase auto-clears to the declared colour + auto-presents, no Screen.clear/show in the handler; issue #86)")
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/serialize", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16", "serialize.ludic (Value tree + Json encode/parse + Reflect.serialize/apply — bit-exact save/load; issue #44)")
feat_case("library/tiled_p0", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21", "tiled_p0.ludic (Tiled P0: Xml reader + Base64 decode/encode + gzip framing over inflate; issue #67)")
feat_case("library/tiled_p05", "", "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", "tiled_p05.ludic (Tiled P0.5: TMX/TSX reader -> same Value-tree intermediate as TMJ; Kenney sampleMap.tmx; issue #68)")
feat_case("library/tiled_p1", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p1.ludic (Tiled P1: rt_tmap model + GID resolver/flip decode + render; loads+draws Kenney map identically from .tmx and .tmj; issue #69)")
feat_case("library/tiled_p2", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p2.ludic (Tiled P2: per-tile objectgroup / property-convention / collision-layer -> Solids feed; A* baseline; issue #70)")
feat_case("library/tiled_p3", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p3.ludic (Tiled P3: animated tiles -> deterministic frame clock + tile objects w/ flips; beach_tileset 33 anims; issue #71)")
feat_case("library/tiled_p4", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "tiled_p4.ludic (Tiled P4: object shapes/text + custom props/types + templates + opt-in spawn; issue #72)")
feat_case("library/tiled_p5", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p5.ludic (Tiled P5: image/group layers + iso/hex/staggered coords + Wang GID resolution; issue #73)")
feat_case("library/tiled_p6", "", "1 2 3 4 5 6 7 8 9 10 11 12", "tiled_p6.ludic (Tiled P6: infinite/chunked maps + .world stitching + base64+zstd decompression; issue #74)")
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)")
feat_case("library/light_tiers", "", "1 1 1 1 1 1 1 1 1", "light_tiers.ludic (Light spot/falloff/soft/gel/normal/time_of_day — render-quality tiers 3-4; issue #49)")
feat_case("library/physics", "", "184 1 1 284 1 1 50 51 104 1 134 1 120 0 1 1 1 0 1 1 1", "physics.ludic (Body + Collider + esys_move: gravity/land, wall stop, sub-pixel, entity platform, one-way, triggers — entity broadphase; issue #65)")
feat_case("library/physics_tiles", "", "112 1 80 1 48 1 60", "physics_tiles.ludic (esys_move tile-grid broadphase: tile floor/wall + one-way tile land-from-above / rise-through; issue #65)")
runtime_home_case() # #75: engine runtime resolves from LUDIC_HOME, not the project
# builtin gameplay controllers, consumed as in-repo packages (issues #57-#61)
controller_case("library/core_components", "", "110 30 184 1", "core_components.ludic (ludic.core #77: canonical Position/Body/Collider imported from a package, driving esys_move, composed with a game Health component)")
controller_case("library/sprite_render", "q", "1 0", "sprite_render.ludic (ludic.core #85: engine sprite-render system auto-draws a Sprite entity from its Position; respects hidden)")
controller_case("library/sprite_atlas", "q", "32 1 1 1 0", "sprite_atlas.ludic (#90 Sprite { atlas: 1 } routes esys_sprite through atlas_draw_ex: a 16x32 cell_span draws both cells, tint applies, atlas: 0 stays a table id)")
controller_case("library/world_bounds", "", "184 -16 184 1 0", "world_bounds.ludic (ludic.core #84: engine world-bounds system — clamp/wrap/bounce/kill keep a Body in the play area)")
controller_case("library/gameplay_foundation", "", "2 0 1 5 24 0 2 1 15 85 7 1 1", "gameplay_foundation.ludic (ludic.gameplay #57: Cooldown timer + Stats/modifier stack + Faction table + Combat cancel/mutable pipeline)")
controller_case("games/platformer_demo", "", "1 1 1 1 1 1 1 1", "platformer_demo.ludic (ludic.platformer #58: gravity/land, jump apex, coyote, veto-gated double jump, wall collision + disable-system lever)")
controller_case("games/platformer_scaffolding", "", "1 1 1 1 1 1", "platformer_scaffolding.ludic (ludic.platformer #58 layers 4-5: moving-platform rider carry, pickup->score, spring, hazard+Life i-frames)")
controller_case("games/shooter_demo", "", "1 1 1 1 1 1 1 1 1 1 1", "shooter_demo.ludic (ludic.shooter #60: decoupled move/aim, weapon registry, projectile faction-hit, spread/ring, homing, wave spawner)")
controller_case("games/npcai_demo", "", "1 1 1 1 1 1 1 1", "npcai_demo.ludic (ludic.npcai #61: perception/spot, FSM chase+attack driving the shooter weapon, utility flee, companion follower)")
controller_case("library/prefabs", "", "230159 30 0 7 44 -1 2", "prefabs.ludic (prefab chains, spawn as an expression, Prefab.spawn / spawn_at by name, self() in @OnSpawn)")
controller_case("library/component_access", "xxxxxxxx", "9 15 10 33 1 0 0 1 0 1 101 100 102 1 2 1 0", "component_access.ludic (Prop.of/has/count/despawn_all, countdown fields, enum-typed machine)")
controller_case("library/scene_menus", "xxxxxxxxxxxx", "100 200 201 1 202 300 6 7 8", "scene_menus.ludic (become from a listener, layer @Queries, scene shows / lasts, goto: buttons)")
controller_case("library/managers", "xxxxxxxxxxxx", "35046 46 251 53 900 -1 20 324 0 10 102", "managers.ludic (Map cell API, Sprite move/face/flash/blink, IVec2 geometry, List.sample, Input.move_i, Fx, audio bank, Camera.shake_for)")
controller_case("library/combat_kit", "xxxxxxxxxxxx", "6846 153196 31 107110 6000 5002 1 80 97 11 10 700 110", "combat_kit.ludic (Dash, Melee + MeleeHit, build stats in Combat.damage + Crit, Stats.add, Dungeon arena / exits / entry points, Prefs.max)")
controller_case("games/rpg_demo", "", "1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1", "rpg_demo.ludic (ludic.rpg #59 all 7 modules: grid move+veto+interact, inventory+equip, crafting, quests, dialog graph, sokoban+signal gate, poison status)")
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 14", "os.ludic (Os args/env round-trip, platform/arch, known dirs, pid)")
} 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_<M>_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")
# #43: engine-owned systems auto-advance the SpriteAnim / Motion components a
# game merely declares and carries — no handler wired. Entry-driven so it can
# assert the auto-advanced fields at known tick counts.
net_case("library/anim_ecs", "1 5 2 10 0 2 10 1 1")
# #47: the engine light pass consumes Light2D / Occluder / Ambient components at
# the end of the Render phase — ambient tint, additive glow, hard shadow — with
# no Light.* calls wired. Sampled back off the framebuffer with Screen.pixel.
net_case("library/light_ecs", "32 1 32 1")
# #7: action maps (read named actions, not keys), runtime rebinding, and
# deterministic input record/replay — fed one key per poll from stdin.
feat_case("library/input_actions", " xwa", "1 0 1 1 0 1 0", "input_actions.ludic (#7 action maps + rebinding + deterministic replay)")
feat_case("library/input_device", "", "1 1 0 1 0 1 71 -71 5 1 3 1 2 1 0 0 1", "input_device.ludic (#50 multi-key held state + analog axis/vector + mouse + gamepad/touch + full-state replay)")
feat_case("library/input_manager", "", "1 1 1 0 1 0 0 1", "input_manager.ludic (#83 Input Manager: default bindings + device-agnostic actions across keyboard/pad + on-press/on-release edges)")
feat_case("library/input_auto", "llq", "2", "input_auto.ludic (#83 the frame loop auto-commits the device layer: Input.active fires with no manual Input.poll)")
feat_case("library/input_edge", "xkkxq", "0 11 1 100 0", "input_edge.ludic (#87 Input.key_pressed/key_released edges fire on the transition frame; no double-commit under the frame loop)")
feat_case("library/input_movement", "", "1 0 -1 -1 0", "input_movement.ludic (#79 Input.axis_i turns WASD into a -1/0/1 movement intent, no bool->int glue)")
feat_case("library/audio", "", "0 0 0 0 1", "audio.ludic (#22 Audio.* load/play/music/volume/pitch/stop/is_playing — headless no-op)")
# #6 Http.* — the client links Foundation (macOS-only), so build it through the
# canonical `ludicc -o` path (which wires the framework) and gate on Darwin. The
# run exercises the pure-Ludic response parser; no network is touched.
if is_darwin() {
if shq(`LUDIC_HOME=. bin/ludicc examples/library/http.ludic -o {tmp_dir()}/http_demo > {tmp_dir()}/http.out 2>&1`) {
let got = capture_line(`{tmp_dir()}/http_demo < /dev/null`)
if (got == "200 1 5 hello 42 text/plain 404 0") { ok("http.ludic (#6 Http.parse/status/ok/text/header — pure response parser)") }
else { bad2("http.ludic", `got [{got}]`) }
} else { bad2("http.ludic build", capture_line(`tail -1 {tmp_dir()}/http.out`)) }
}
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_<P>_attach/detach events)")
feat_case("events/scene_events", "", "10 1 20 2 30 3", "scene_events.ludic (public scene -> scene_<S>_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_<L>_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")
smoke("library/cursor_capture") # #89 Input.cursor_mode compiles (no-op headless; windowed links cocoa.ll)
smoke("rendering/gl_triangle") # Gl.* (OpenGL 4.1 core) compiles headless; the run needs a GPU context
print("== the compiler and the CLI (ludicc / ludic) ==")
# ludicc comes out of the IR seed with clang alone; the CLI is then compiled
# by it, from Ludic.
if shq(`bin/ludic-dev build-cli > {tmp_dir()}/cli.out 2>&1`) and is_exec("bin/ludicc") {
ok("ludic-dev build-cli builds bin/ludicc from the seed (clang-only)")
} else { bad2("ludic-dev build-cli", capture_line(`tail -1 {tmp_dir()}/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_dir()}/cli_snake > {tmp_dir()}/cli.out 2>&1`) and shq(`file {tmp_dir()}/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_dir()}/cli.out`)) }
# #51 — a windowed build must load GameController so the native gamepad
# polling (win_pad, reached on the is_windowed branch) can see controllers.
if shq(`otool -L {tmp_dir()}/cli_snake 2>/dev/null | grep -q GameController`) {
ok("windowed link loads GameController (#51 gamepad binding)")
} else { bad("windowed binary is missing the GameController load command") }
} else {
if shq(`LUDIC_HOME=. bin/ludicc --headless examples/games/snake.ludic -o {tmp_dir()}/cli_snake > {tmp_dir()}/cli.out 2>&1`) and shq(`test -x {tmp_dir()}/cli_snake`) {
ok("ludicc app.ludic -o bin -> native executable (headless)")
} else { bad2("ludicc -o", capture_line(`tail -1 {tmp_dir()}/cli.out`)) }
}
# --emit-llvm stops at the IR.
if shq(`bin/ludicc examples/games/snake.ludic --emit-llvm -o {tmp_dir()}/cli_snake.ll > {tmp_dir()}/cli.out 2>&1`) and shq(`head -1 {tmp_dir()}/cli_snake.ll | grep -q 'LLVM IR'`) {
ok("ludicc --emit-llvm -> LLVM IR")
} else { bad2("ludicc --emit-llvm", capture_line(`tail -1 {tmp_dir()}/cli.out`)) }
# `ludic <file>` compiles, runs, and forwards the program's exit code.
write_file(`{tmp_dir()}/cli_exit.ludic`, "entry { exit(42) }\n")
let rc = sh(`bin/ludic {tmp_dir()}/cli_exit.ludic --headless > {tmp_dir()}/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)}`) }
# The CLI has to work as an install does: from another directory, with no
# ./bin, finding its compiler, the engine runtime and the bundled packages
# from its own location. This is the shape `curl … | sh` produces.
install_layout_case()
scaffold_names_case()
pack_roundtrip_case()
bundle_case()
# install.sh is what the landing page tells people to pipe into sh, and it is
# published with the docs site — so it is checked here rather than discovered
# broken by a first-time user. `sh -n` parses it without running anything.
if not file_exists("install.sh") { bad("install.sh is missing") }
else if shq("sh -n install.sh 2>/dev/null") and shq("sh install.sh --help >/dev/null 2>&1") {
ok("install.sh parses under POSIX sh and answers --help")
} else { bad("install.sh does not parse under POSIX sh") }
# the package manager (issue #63): fetch + MVS resolve + content-addressed
# store + ludic_modules/ links + build, run as its own hermetic suite (it
# stands up throwaway git repos, so like selfhost-test it runs as a sub-process
# and this stage checks its footer).
print("== package manager (ludic-dev test-pkg) ==")
run(`bin/ludic-dev test-pkg > {tmp_dir()}/pkg.out 2>&1`)
if shq(`grep -q '0 failed' {tmp_dir()}/pkg.out`) {
let pc = capture_line(`grep -c PASS {tmp_dir()}/pkg.out`)
ok(`package-manager suite: {pc} checks passed (see: ludic-dev test-pkg)`)
} else { bad2("ludic-dev test-pkg", capture_line(`grep -i fail {tmp_dir()}/pkg.out | head -1`)) }
# 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 --check mode regenerates the tracked
# emit_color.ludic + palette.json into scratch files and compares).
print("== docs site (Ludic generator, no Python) ==")
if shq(`bin/ludic-dev docs-gen --out {tmp_dir()}/docs > {tmp_dir()}/docs.out 2>&1`) {
ok("ludic-dev docs-gen -> the pages-branch payload")
} else { bad2("ludic-dev docs-gen", capture_line(`tail -1 {tmp_dir()}/docs.out`)) }
if shq(`bin/ludic-dev docs-check {tmp_dir()}/docs > {tmp_dir()}/docs_chk.out 2>&1`) {
ok("ludic-dev docs-check passes on the generated site")
} else { bad2("ludic-dev docs-check", capture_line(`tail -1 {tmp_dir()}/docs_chk.out`)) }
if shq(`bin/ludic-dev docs-palette --check > {tmp_dir()}/pal.out 2>&1`) {
ok("ludic-dev docs-palette regenerates emit_color.ludic + palette.json byte-identically")
} else { bad2("ludic-dev docs-palette --check", capture_line(`tail -1 {tmp_dir()}/pal.out`)) }
# the OpenGL binding generator is the same shape: gl_api.ludic + gl_thunks.ll are
# tracked, and --check regenerates them from the platform gl3.h and compares. The
# header is macOS-only, so this guard cannot run off Darwin.
if is_darwin() {
if shq(`bin/ludic-dev glgen --check > {tmp_dir()}/glgen.out 2>&1`) {
ok("ludic-dev glgen regenerates gl_api.ludic + gl_thunks.ll byte-identically")
} else { bad2("ludic-dev glgen --check", capture_line(`tail -1 {tmp_dir()}/glgen.out`)) }
} else { skip("ludic-dev glgen --check (needs the macOS OpenGL headers)") }
return report()
}