ludic/tools/ludic-cli/pkg_test.ludic
Orkuncakilkaya 320ce42626 ludic remove <module> and ludic get --json (R9)
remove is the inverse of add: the require line leaves package.ludic; the lock keeps exactly what the
remaining requires still reach, read from the store's copy of each locked package.ludic (no
network), so a package another still requires stays locked and what only the removed one brought in
leaves with it; each leaving package loses the ludic_modules/ symlink add made, never the shared
store entry. Refused (exit 1) when package.ludic does not require the module; source still
importing a removed package is a warning. With no store copy to read, only the named module leaves.

get --json diffs the lock before and after in memory and prints {added, removed, changed,
unchanged} on stdout (an entry as the lock records it: name, version, hash, kind, provides; a change
as name, from, to, from_hash, to_hash), the resolver's lines on stderr. Cases added to test-pkg,
not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:24:49 +03:00

271 lines
20 KiB
Text

# pkg_test.ludic — the package-manager suite (issue #63), hermetic and offline.
#
# It stands up throwaway git repositories under /tmp and points the package
# manager at them with LUDIC_PKG_PROXY, so the whole fetch → MVS resolve →
# content-addressed store → project link → compile → run → verify pipeline runs
# with no network. Every check drives a fresh `bin/ludic` / `bin/ludicc` process the
# way a real project would.
# write `body` to `path`, creating parent directories
function pt_write(path: pointer, body: pointer) -> void {
shell(`mkdir -p "$(dirname {path})"`)
write_file(path, body)
}
# a file's text, "" when it is not there
function pt_read(path: pointer) -> pointer {
let t = read_file(path)
if t == null { return "" }
return t
}
# commit the current tree of `dir` and tag it `tag`
function pt_commit_tag(dir: pointer, tag: pointer) -> void {
shell(`git -C {dir} add -A`)
shell(`git -C {dir} -c user.email=t@t.test -c user.name=tester commit -q -m {tag}`)
shell(`git -C {dir} tag {tag}`)
}
function cmd_test_pkg() -> int {
PASS = 0
FAIL = 0
print("== package manager (issue #63): fetch + MVS resolve + store + link + build ==")
if not shq("command -v git >/dev/null 2>&1") {
skip("git unavailable — package-manager suite needs git")
return report()
}
let root = capture_line("pwd")
let work = `{tmp_dir()}/pkg_test`
let proxy = `{work}/proxy`
let store = `{work}/store`
let proj = `{work}/proj`
shell(`rm -rf {work}`)
shell(`mkdir -p {proxy} {store} {proj}`)
# ---- fixture packages -----------------------------------------------------
# example.test/util: a leaf source package, two published versions.
let util = `{proxy}/example.test/util`
shell(`git -C {util} init -q 2>/dev/null || ( mkdir -p {util} && git -C {util} init -q )`)
pt_write(`{util}/package.ludic`, `package "example.test/util"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "Util"` + nl())
pt_write(`{util}/util.ludic`, "function util_tag() -> pointer { return \"u1\" }\n")
pt_commit_tag(util, "v1.0.0")
pt_write(`{util}/package.ludic`, `package "example.test/util"` + nl() + `version "1.2.0"` + nl() + "kind source" + nl() + `provides "Util"` + nl())
pt_write(`{util}/util.ludic`, "function util_tag() -> pointer { return \"u12\" }\n")
pt_commit_tag(util, "v1.2.0")
# example.test/greeter: requires util >=1.0.0, provides Greet.
let greeter = `{proxy}/example.test/greeter`
shell(`mkdir -p {greeter} && git -C {greeter} init -q`)
pt_write(`{greeter}/package.ludic`, `package "example.test/greeter"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "Greet"` + nl() + `require "example.test/util" "1.0.0"` + nl())
pt_write(`{greeter}/greet.ludic`, "function greet_hello() -> pointer { return \"hi\" }\n")
pt_commit_tag(greeter, "v1.0.0")
# ---- the consumer project -------------------------------------------------
# requires util 1.2.0 directly; greeter (which requires util 1.0.0) is added
# too. MVS must pick util 1.2.0 — the greatest required minimum.
pt_write(`{proj}/package.ludic`, `package "app"` + nl() + `version "0.0.0"` + nl() + `require "example.test/greeter" "1.0.0"` + nl() + `require "example.test/util" "1.2.0"` + nl())
pt_write(`{proj}/app.ludic`, "program App {\n import \"example.test/greeter/greet.ludic\"\n import \"example.test/util/util.ludic\"\n entry { print(greet_hello()); print(util_tag()) }\n}\n")
let envp = `LUDIC_PKG_PROXY={proxy} LUDIC_STORE={store}`
# ---- ludic get: resolve, fetch, store, link, lock -----------------------------
if shq(`( cd {proj} && {envp} {root}/bin/ludic get > {work}/get.out 2>&1 )`) {
ok("ludic get resolves + fetches the dependency graph")
} else {
bad2("ludic get failed", capture_line(`tail -2 {work}/get.out`))
}
var lock = read_file(`{proj}/package.lock.ludic`)
if lock == null { lock = "" }
if s_contains(lock, "example.test/greeter") and s_contains(lock, "example.test/util") {
ok("package.lock.ludic pins the whole build list")
} else { bad("lockfile is missing a resolved package") }
# MVS: util resolves to 1.2.0 (max of the 1.0.0 and 1.2.0 minimums), not 1.0.0
if s_contains(lock, `"example.test/util" version "1.2.0"`) {
ok("MVS selects util 1.2.0 (greatest required minimum)")
} else { bad2("MVS picked the wrong util version", capture_line(`grep util {proj}/package.lock.ludic`)) }
if s_contains(lock, "sha256:") { ok("lockfile records content hashes") } else { bad("lockfile has no content hash") }
# the project view links into the content-addressed store
if file_exists(`{proj}/ludic_modules/example.test/greeter/greet.ludic`) and file_exists(`{proj}/ludic_modules/example.test/util/util.ludic`) {
ok("ludic_modules/ links the store entries into the project")
} else { bad("ludic_modules view was not linked") }
# ---- compile + run the consumer against the fetched packages --------------
if shq(`( cd {proj} && LUDIC_MODULES=ludic_modules {root}/bin/ludicc app.ludic -o app > {work}/build.out 2>&1 )`) {
ok("consumer compiles against the linked packages (do_import module-root fallback)")
let got = capture_line(`( cd {proj} && ./app )`)
if got == "hi u12" { ok("the running program uses the MVS-selected package code (\"hi u12\")") }
else { bad2("program output mismatch", `got [{got}] want [hi u12]`) }
} else {
bad2("consumer build failed", capture_line(`tail -2 {work}/build.out`))
}
# ---- ludic verify: content integrity against the store ------------------------
if shq(`( cd {proj} && {envp} {root}/bin/ludic verify > {work}/verify.out 2>&1 )`) {
ok("ludic verify passes on an untouched store")
} else { bad2("ludic verify failed unexpectedly", capture_line(`tail -2 {work}/verify.out`)) }
# tamper with a stored file — verify must now flag it
let uhash = capture_line(`grep 'example.test/util' {proj}/package.lock.ludic | sed 's/.*sha256://; s/\".*//'`)
shell(`echo tampered >> {store}/{uhash}/util.ludic`)
if not shq(`( cd {proj} && {envp} {root}/bin/ludic verify > {work}/verify2.out 2>&1 )`) {
ok("ludic verify detects a tampered store entry")
} else { bad("ludic verify missed a tampered store entry") }
# heal the store: a content-addressed entry is keyed by its hash-named dir, so
# `ludic get` trusts an existing one — drop it first, then re-fetch a clean copy.
shell(`rm -rf {store}/{uhash}`)
shell(`( cd {proj} && {envp} {root}/bin/ludic get > /dev/null 2>&1 )`)
if shq(`( cd {proj} && {envp} {root}/bin/ludic verify > {work}/verify3.out 2>&1 )`) {
ok("re-fetching heals a tampered store entry (ludic verify green again)")
} else { bad2("store did not heal after re-fetch", capture_line(`tail -1 {work}/verify3.out`)) }
# ---- a package that exports ECS: a component, a model and a system --------
# Ludic is AOT, so a source package's declarations compile straight into the
# consumer's compile-time ECS — no ABI seam. This proves a consumer *game* can
# use a package's property (component), model, @OnSpawn handler (system) and a
# plain function together. The engine runtime (runtime/native/*.ludic) resolves
# relative to the compile CWD, so — exactly like `ludic build` — the game is compiled
# from the repo root with the package linked in via an absolute LUDIC_MODULES.
let epkg = `{proxy}/example.test/enginepkg`
shell(`mkdir -p {epkg} && git -C {epkg} init -q`)
pt_write(`{epkg}/package.ludic`, `package "example.test/enginepkg"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "EnginePkg"` + nl())
pt_write(`{epkg}/lib.ludic`, "property Health { hp: int = 0, max: int = 0 }\nmodel Unit { Health }\n@Public @OnSpawn(Unit) handler Init { Health.hp = Health.max }\nfunction bonus() -> int { return 7 }\n")
pt_commit_tag(epkg, "v1.0.0")
let proj2 = `{work}/proj2`
pt_write(`{proj2}/package.ludic`, `package "game"` + nl() + `version "0.0.0"` + nl() + `require "example.test/enginepkg" "1.0.0"` + nl())
pt_write(`{proj2}/game.ludic`, "program G {\n import \"example.test/enginepkg/lib.ludic\"\n entry {\n spawn Unit { Health { max: 50 } }\n let H = world_prop_id(\"Health\")\n let hp = world_field_id(H, \"hp\")\n let e = world_query_next(H, 0)\n print(world_get(e, H, hp))\n print(bonus())\n }\n}\n")
if shq(`( cd {proj2} && {envp} {root}/bin/ludic get > {work}/get2.out 2>&1 )`) {
if shq(`( cd {root} && LUDIC_MODULES={proj2}/ludic_modules {root}/bin/ludicc --headless {proj2}/game.ludic -o {proj2}/game > {work}/gbuild.out 2>&1 )`) {
let got = capture_line(`{proj2}/game`)
if got == "50 7" {
ok("a consumer game uses the package's component + model + system + function (\"50 7\")")
} else { bad2("imported ECS produced wrong output", `got [{got}] want [50 7]`) }
} else { bad2("game consuming an ECS package failed to build", capture_line(`tail -2 {work}/gbuild.out`)) }
} else { bad2("ludic get for the ECS package failed", capture_line(`tail -1 {work}/get2.out`)) }
# ---- namespace collision policy (v1: hard error) --------------------------
let dupe = `{proxy}/example.test/dupe`
shell(`mkdir -p {dupe} && git -C {dupe} init -q`)
pt_write(`{dupe}/package.ludic`, `package "example.test/dupe"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "Util"` + nl())
pt_write(`{dupe}/dupe.ludic`, "function dupe_tag() -> pointer { return \"d\" }\n")
pt_commit_tag(dupe, "v1.0.0")
if not shq(`( cd {proj} && {envp} {root}/bin/ludic add example.test/dupe@1.0.0 > {work}/dupe.out 2>&1 )`) and shq(`grep -q collision {work}/dupe.out`) {
ok("two packages claiming the same Foo.* namespace is a hard error")
} else { bad2("collision not rejected", capture_line(`tail -1 {work}/dupe.out`)) }
# drop the bad dep again so the manifest is clean
pt_write(`{proj}/package.ludic`, `package "app"` + nl() + `version "0.0.0"` + nl() + `require "example.test/greeter" "1.0.0"` + nl() + `require "example.test/util" "1.2.0"` + nl())
# ---- prebuilt target matrix ----------------------------------------------
let pb = `{proxy}/example.test/prebuilt`
shell(`mkdir -p {pb} && git -C {pb} init -q`)
pt_write(`{pb}/package.ludic`, `package "example.test/prebuilt"` + nl() + `version "1.0.0"` + nl() + "kind prebuilt" + nl() + `provides "Blob"` + nl() + `targets "made-up-target"` + nl())
pt_write(`{pb}/README`, "a prebuilt lib shipping no artifact for this host\n")
pt_commit_tag(pb, "v1.0.0")
if not shq(`( cd {proj} && {envp} {root}/bin/ludic add example.test/prebuilt@1.0.0 > {work}/pb.out 2>&1 )`) and shq(`grep -q 'prebuilt lib' {work}/pb.out`) {
ok("a prebuilt lib with no artifact for the build target is a hard error")
} else { bad2("prebuilt target matrix not enforced", capture_line(`tail -1 {work}/pb.out`)) }
pt_write(`{proj}/package.ludic`, `package "app"` + nl() + `version "0.0.0"` + nl() + `require "example.test/greeter" "1.0.0"` + nl() + `require "example.test/util" "1.2.0"` + nl())
# ---- ludic vendor: hermetic copy ---------------------------------------------
shell(`( cd {proj} && {envp} {root}/bin/ludic get > /dev/null 2>&1 )`)
if shq(`( cd {proj} && {envp} {root}/bin/ludic vendor > {work}/vendor.out 2>&1 )`) and file_exists(`{proj}/vendor/example.test/util/util.ludic`) {
ok("ludic vendor copies the resolved packages into ./vendor")
} else { bad2("ludic vendor failed", capture_line(`tail -1 {work}/vendor.out`)) }
# ---- for editors (R9): get --json says what changed in the lock; remove undoes add ----
let proj5 = `{work}/proj5`
pt_write(`{proj5}/package.ludic`, `package "app"` + nl() + `version "0.0.0"` + nl() + `require "example.test/greeter" "1.0.0"` + nl())
pt_write(`{proj5}/src/app.ludic`, "program App {\n import \"example.test/greeter/greet.ludic\"\n}\n")
shell(`( cd {proj5} && {envp} {root}/bin/ludic get --json > {work}/gj.json 2> {work}/gj.err )`)
let gj = pt_read(`{work}/gj.json`)
if s_contains(gj, `"name": "example.test/greeter", "version": "1.0.0"`) and s_contains(gj, `"name": "example.test/util", "version": "1.0.0"`) and s_contains(gj, `"unchanged": 0`) and shq(`python3 -m json.tool {work}/gj.json > /dev/null`) and not s_contains(gj, "resolving") {
ok("ludic get --json: a fresh lock is two added packages, one JSON object on stdout")
} else { bad2("ludic get --json (fresh)", gj) }
pt_write(`{proj5}/package.ludic`, `package "app"` + nl() + `version "0.0.0"` + nl() + `require "example.test/greeter" "1.0.0"` + nl() + `require "example.test/util" "1.2.0"` + nl())
let gj2 = capture(`( cd {proj5} && {envp} {root}/bin/ludic get --json 2>/dev/null )`)
if s_contains(gj2, `"added": []`) and s_contains(gj2, `"name": "example.test/util", "from": "1.0.0", "to": "1.2.0"`) and s_contains(gj2, `"unchanged": 1`) {
ok("ludic get --json: a raised minimum is a change from one version to the other")
} else { bad2("ludic get --json (changed)", gj2) }
if not shq(`( cd {proj5} && {root}/bin/ludic remove example.test/nothere > {work}/rm0.out 2>&1 )`) and shq(`grep -q 'does not require' {work}/rm0.out`) {
ok("ludic remove refuses a module package.ludic does not require")
} else { bad2("ludic remove of an unrequired module", capture_line(`cat {work}/rm0.out`)) }
let store_n = capture_line(`ls {store} | wc -l`)
shell(`( cd {proj5} && LUDIC_STORE={store} {root}/bin/ludic remove example.test/util > {work}/rm1.out 2>&1 )`)
let lk1 = pt_read(`{proj5}/package.lock.ludic`)
if not s_contains(pt_read(`{proj5}/package.ludic`), "example.test/util") and s_contains(lk1, "example.test/util") and file_exists(`{proj5}/ludic_modules/example.test/util`) {
ok("ludic remove keeps what another package still requires locked and linked")
} else { bad2("ludic remove dropped a package still required", capture_line(`cat {work}/rm1.out`)) }
shell(`( cd {proj5} && LUDIC_STORE={store} {root}/bin/ludic remove example.test/greeter > {work}/rm2.out 2>&1 )`)
let lk2 = pt_read(`{proj5}/package.lock.ludic`)
if not s_contains(lk2, "example.test/") and not file_exists(`{proj5}/ludic_modules`) and capture_line(`ls {store} | wc -l`) == store_n {
ok("ludic remove drops the require, what only it locked and its links, and leaves the shared store alone")
} else { bad2("ludic remove of the last require", capture_line(`cat {work}/rm2.out`)) }
if shq(`grep -q 'still imported by' {work}/rm2.out`) and shq(`grep -q 'src/app.ludic' {work}/rm2.out`) {
ok("ludic remove warns about source still importing what it removed")
} else { bad("ludic remove said nothing about src/app.ludic importing the package") }
# ---- package-declarable engine-system + namespace (issue #62) -------------
# A source package registers a compile-time engine system (@EngineSystem) and a
# Foo.* namespace (@Namespace) with no compiler edit; a consumer game imports it
# and both take effect. Source path → runs everywhere (not dylib/macOS-gated).
let cp = `{proxy}/example.test/coachpack`
shell(`mkdir -p {cp} && git -C {cp} init -q`)
pt_write(`{cp}/package.ludic`, `package "example.test/coachpack"` + nl() + `version "1.0.0"` + nl() + "kind source" + nl() + `provides "Coach"` + nl())
pt_write(`{cp}/coach.ludic`, "property Score { value: int = 0 }\n@EngineSystem(Score, Update) function esys_score() -> void {\n let P = world_prop_id(\"Score\")\n let e = world_query_next(P, 0)\n if e >= 0 { world_set(e, P, 0, world_get(e, P, 0) + 1) }\n}\n@Namespace(Coach) function coach_bonus() -> int { return 99 }\n")
pt_commit_tag(cp, "v1.0.0")
let proj4 = `{work}/proj4`
pt_write(`{proj4}/package.ludic`, `package "game"` + nl() + `version "0.0.0"` + nl() + `require "example.test/coachpack" "1.0.0"` + nl())
pt_write(`{proj4}/game.ludic`, "program Game {\n import \"example.test/coachpack/coach.ludic\"\n model Player { Score }\n @OnSpawn(Player) handler Init { }\n state Clock { frames: int = 0 }\n handler Boot phase Start { let e = world_spawn(world_model_id(\"Player\")) }\n handler Run(c: mut Clock) phase Update {\n c.frames = c.frames + 1\n if c.frames >= 5 {\n let P = world_prop_id(\"Score\")\n let e = world_query_next(P, 0)\n print(world_get(e, P, 0))\n print(Coach.bonus())\n quit()\n }\n }\n}\n")
if shq(`( cd {proj4} && {envp} {root}/bin/ludic get > {work}/get4.out 2>&1 )`) {
shell(`LUDIC_HOME={root} LUDIC_MODULES={proj4}/ludic_modules {root}/bin/ludicc --headless {proj4}/game.ludic -o {proj4}/game 2>{work}/gc4.err`)
if file_exists(`{proj4}/game`) {
let got = capture_line(`printf 'aaaaa' | {proj4}/game`)
if got == "4 99" {
ok("a package registers an engine-system (@EngineSystem) + a namespace (@Namespace) with no compiler edit (\"4 99\")")
} else { bad2("package hooks produced wrong output", `got [{got}] want [4 99]`) }
} else { bad2("consumer of a @EngineSystem/@Namespace package failed to build", capture_line(`tail -1 {work}/gc4.err`)) }
} else { bad2("ludic get for the coach package failed", capture_line(`tail -1 {work}/get4.out`)) }
# ---- prebuilt binary package (issue #64): exported function + system + component
# A module is compiled to a dylib (ludic build-lib), fetched as a `kind prebuilt`
# dependency, and linked into a consumer game that never saw its source. At load
# the module registers a dynamic component and an @System(Update); the consumer
# also calls an exported function via `extern`. Native/dylib only → macOS-gated.
if not is_darwin() {
skip("prebuilt binary packages (dylib) — native/macOS only")
return report()
}
let tgt = `native-{capture_line("uname -m")}`
let mp = `{proxy}/example.test/manapack`
shell(`mkdir -p {mp} && git -C {mp} init -q`)
pt_write(`{mp}/package.ludic`, `package "example.test/manapack"` + nl() + `version "1.0.0"` + nl() + "kind prebuilt" + nl() + `provides "Mana"` + nl() + `targets "{tgt}"` + nl())
pt_write(`{mp}/manapack.ludic`, "program ManaPack {\n function module_init() -> void { world_register_prop(\"Mana\", 2) }\n @System(Update) function mana_regen() -> void {\n let M = world_prop_id(\"Mana\")\n let e = world_query_next(M, 0)\n if e >= 0 { let v = world_get(e, M, 0); world_set(e, M, 0, v + 1) }\n }\n function mana_bonus() -> int { return 42 }\n}\n")
if shq(`( cd {mp} && LUDICC={root}/bin/ludicc {root}/bin/ludic build-lib manapack.ludic > {work}/bl.out 2>&1 )`) and file_exists(`{mp}/lib/{tgt}/libmanapack.dylib`) {
ok("ludic build-lib compiles a package module to a per-target dylib")
} else { bad2("ludic build-lib failed", capture_line(`tail -1 {work}/bl.out`)) }
pt_commit_tag(mp, "v1.0.0")
let proj3 = `{work}/proj3`
pt_write(`{proj3}/package.ludic`, `package "game"` + nl() + `version "0.0.0"` + nl() + `require "example.test/manapack" "1.0.0"` + nl())
pt_write(`{proj3}/game.ludic`, "program Game {\n property Dummy { x: int = 0 }\n model Ent { Dummy }\n @OnSpawn(Ent) handler Init { }\n extern function mana_bonus() -> int = \"fn_mana_bonus\"\n state Clock { frames: int = 0 }\n handler Boot phase Start {\n let e = world_spawn(world_model_id(\"Ent\"))\n let M = world_prop_id(\"Mana\")\n world_attach_dyn(e, M)\n world_set(e, M, 0, 0)\n }\n handler Run(c: mut Clock) phase Update {\n c.frames = c.frames + 1\n if c.frames >= 4 {\n let M = world_prop_id(\"Mana\")\n let e = world_query_next(M, 0)\n print(world_get(e, M, 0))\n print(mana_bonus())\n quit()\n }\n }\n}\n")
if shq(`( cd {proj3} && {envp} {root}/bin/ludic get > {work}/get3.out 2>&1 )`) and file_exists(`{proj3}/ludic_modules/example.test/manapack/lib/{tgt}/libmanapack.dylib`) {
ok("ludic get fetches + links a prebuilt (kind prebuilt) package for the target")
} else { bad2("prebuilt ludic get failed", capture_line(`tail -1 {work}/get3.out`)) }
# compile the ECS consumer from the repo root (so runtime/native resolves), then
# link the module dylib via `ludic link-flags` (a real external build's link step).
shell(`LUDIC_HOME={root} {root}/bin/ludicc --headless {proj3}/game.ludic --emit-llvm -o {proj3}/game.ll 2>{work}/gc.err`)
let flags = capture_line(`( cd {proj3} && LUDIC_STORE={store} {root}/bin/ludic link-flags )`)
if shq(`{cc()} -O2 -Wno-override-module {proj3}/game.ll {flags} -o {proj3}/game 2>{work}/gl.err`) {
let got = capture_line(`printf 'aaaaaa' | {proj3}/game`)
if got == "3 42" {
ok("a consumer links the prebuilt module: its system mutates the world + its function is callable (\"3 42\")")
} else { bad2("prebuilt consumer output mismatch", `got [{got}] want [3 42]`) }
} else { bad2("prebuilt consumer link failed", capture_line(`tail -1 {work}/gl.err`)) }
return report()
}