feat(cli): ludic build --check / ludicc --check - check without building

The parse, the types and the module rules (export, uses, layers, ports,
registries) run and nothing is emitted or linked: about three seconds
on Maroon Lake. In this mode the checker asks vis_check at each
reference it resolves, with a global's initializer and a registry's
entries seen from the files the emitter would use. Reseed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 13:13:42 +03:00
parent 7ef6c7ec75
commit 8892f51096
12 changed files with 15546 additions and 14721 deletions

View file

@ -350,6 +350,7 @@ The self-hosted `ludicc`/`ludic` (built with `bin/ludic-dev build-cli`) accept:
--windowed force a windowed (Cocoa) build --windowed force a windowed (Cocoa) build
--headless force a headless build (stdin input, out.ppm output) --headless force a headless build (stdin input, out.ppm output)
--emit-llvm stop at LLVM IR — write it and exit, no clang --emit-llvm stop at LLVM IR — write it and exit, no clang
--check parse and check only (types, modules, uses, layers, ports); write nothing
--fmt lex + parse only; exit 0 if it parses, 1 on a parse error --fmt lex + parse only; exit 0 if it parses, 1 on a parse error
(the check-docs gate; canonical formatting not yet restored) (the check-docs gate; canonical formatting not yet restored)
--save-temps keep the intermediate .ll --save-temps keep the intermediate .ll

View file

@ -1841,6 +1841,7 @@ ludic test # compile and run the project's `test`
ludic test tests/math.ludic --test adds # just the test named "adds" (-v: every result line) ludic test tests/math.ludic --test adds # just the test named "adds" (-v: every result line)
ludic test -j 4 # four tests at once (default: one per CPU) ludic test -j 4 # four tests at once (default: one per CPU)
ludic test packages/ludic.base # the test programs under a directory (a package's) ludic test packages/ludic.base # the test programs under a directory (a package's)
ludic build --check # only check: types, modules, uses and ports - nothing emitted
ludic deps # how tangled the modules are, as the compiler resolved them ludic deps # how tangled the modules are, as the compiler resolved them
ludic deps --check tests/deps-baseline.txt # fail when a number rose (--baseline FILE writes them) ludic deps --check tests/deps-baseline.txt # fail when a number rose (--baseline FILE writes them)
@ -1848,6 +1849,12 @@ ludicc app.ludic -o build/app # the compiler directly: a native binar
ludicc app.ludic --emit-llvm -o app.ll # stop at LLVM IR ludicc app.ludic --emit-llvm -o app.ll # stop at LLVM IR
``` ```
`ludic build [file] --check` (the compiler's `ludicc --check`) runs everything the compiler checks
before it writes code - the parse, the types, `export`, `uses` and layers, ports and binds, registries
- and stops: no IR, no link. On Maroon Lake it takes about three seconds where a build takes about a
minute, for iterating on `uses` lines. Two checks belong to the code writer and only a build makes
them: a function that can reach its end without its result, and a local declared twice in a block.
`ludic deps` compiles the program (the package's entry, or a file) with the compiler recording every `ludic deps` compiles the program (the package's entry, or a file) with the compiler recording every
reference its visibility pass resolves - from the module it is written in to the module of what it reference its visibility pass resolves - from the module it is written in to the module of what it
names - and every assignment to another module's global. It prints five numbers: `modules` (the names - and every assignment to another module's global. It prints five numbers: `modules` (the

7
changes/check-build.md Normal file
View file

@ -0,0 +1,7 @@
bump: minor
type: feature
**`ludic build --check` / `ludicc --check`: check without building.** The parse, the type checker
and the module rules (`export`, `uses`, layers, ports, registries) run, and nothing is emitted or
linked - about three seconds on Maroon Lake where a build takes about a minute. In this mode the
checker asks the module rules at each reference it resolves, since the emitter that usually asks
them does not run.

View file

@ -180,6 +180,7 @@ function ck_call(e: Node) -> pointer {
if tf != null { ck_raw(e, `{b.s}.{c.s}, a C function`) } if tf != null { ck_raw(e, `{b.s}.{c.s}, a C function`) }
} }
if tf != null { if tf != null {
if ck_fn(g_al_target[al]) != null { ck_vis(tf, g_al_target[al], e) }
ck_extern_arg = ck_extern(g_al_target[al]) != null ck_extern_arg = ck_extern(g_al_target[al]) != null
let at = ck_call_alias(e, `{b.s}.{c.s}`, al, tf) let at = ck_call_alias(e, `{b.s}.{c.s}`, al, tf)
ck_extern_arg = false ck_extern_arg = false
@ -192,6 +193,7 @@ function ck_call(e: Node) -> pointer {
# so the Ludic function behind it gives the result's type and nothing about the arguments # so the Ludic function behind it gives the result's type and nothing about the arguments
ck_walk_args(e) ck_walk_args(e)
let nf = ck_fn(ns_lower(b.s) + "_" + c.s) let nf = ck_fn(ns_lower(b.s) + "_" + c.s)
if nf != null { ck_vis(nf, nf.s, e) }
if nf != null and nf.ty != null { return nf.ty } if nf != null and nf.ty != null { return nf.ty }
return "?" return "?"
} }
@ -218,9 +220,15 @@ function ck_call_named(e: Node, name: pointer) -> pointer {
let bt = ck_builtin(e, name) let bt = ck_builtin(e, name)
if bt != null { return bt } if bt != null { return bt }
let f = ck_fn(name) let f = ck_fn(name)
if f != null { return ck_call_fn(e, name, f) } if f != null {
ck_vis(f, name, e)
return ck_call_fn(e, name, f)
}
let gt = gen_template(g_gen_fns, name) let gt = gen_template(g_gen_fns, name)
if gt != null { return gen_call(e, name, gt) } if gt != null {
ck_vis(gt, name, e)
return gen_call(e, name, gt)
}
let x = ck_extern(name) let x = ck_extern(name)
if x != null { if x != null {
ck_raw(e, `the C function {name}`) ck_raw(e, `the C function {name}`)
@ -237,6 +245,7 @@ function ck_call_named(e: Node, name: pointer) -> pointer {
# emit E(field: v): each field gets the type the event declares for it # emit E(field: v): each field gets the type the event declares for it
function ck_emit(s: Node) -> void { function ck_emit(s: Node) -> void {
let ev = find_event(s.s) let ev = find_event(s.s)
ck_vis(ev, s.s, s)
if s.a == null { return } if s.a == null { return }
var i = 0 var i = 0
while i < len(s.a.kids) { while i < len(s.a.kids) {

View file

@ -47,6 +47,7 @@ function ck_expr(e: Node) -> pointer {
if k == E_ID { return ck_id(e) } if k == E_ID { return ck_id(e) }
if k == E_FNREF { if k == E_FNREF {
let f = ck_fn(e.s) let f = ck_fn(e.s)
ck_vis(f, e.s, e)
if f != null { return fn_sig_of(f) } if f != null { return fn_sig_of(f) }
return "?" return "?"
} }
@ -68,11 +69,30 @@ function ck_id(e: Node) -> pointer {
let li = ck_local(e.s) let li = ck_local(e.s)
if li >= 0 { return ck_tys[li] } if li >= 0 { return ck_tys[li] }
let g = ck_global(e.s) let g = ck_global(e.s)
if g != null { return g.ty } if g != null {
ck_vis(g, e.s, e)
return g.ty
}
return "?" return "?"
} }
# --check: the module rules (uses, export, ports - emit_vis.ludic) asked here, where every reference
# is resolved, since a check-only build stops before the emitter that asks them otherwise
var g_check_only: bool = false
var ck_vis_file: pointer = null # a global's initializer is its declaration's file's code (as emitted)
var ck_vis_skip: bool = false # a port's bind, checked where it was written (ports.ludic)
function ck_vis(d: Node, name: pointer, at: Node) -> void {
if not g_check_only or ck_vis_skip or d == null or at == null or at.file == null { return }
g_err_file = at.file
if ck_vis_file != null { g_err_file = ck_vis_file }
g_err_line = at.line
vis_check(d, name)
}
function ck_new(e: Node) -> pointer { function ck_new(e: Node) -> pointer {
let r = ck_record(e.s) let r = ck_record(e.s)
ck_vis(r, e.s, e)
if r == null and g_check_only and port_find(e.s) >= 0 {
ck_err("port", e, `{e.s} is a port: it is filled once with 'bind {e.s} {{ ... }}' where the program is put together, not made with new`)
}
if e.a != null { if e.a != null {
var i = 0 var i = 0
while i < len(e.a.kids) { while i < len(e.a.kids) {
@ -91,8 +111,12 @@ function ck_new(e: Node) -> pointer {
function ck_list(e: Node) -> pointer { function ck_list(e: Node) -> pointer {
var t: pointer = "?" var t: pointer = "?"
var i = 0 var i = 0
let saved = ck_vis_file
while i < len(e.kids) { while i < len(e.kids) {
# a registry's entries are their defs' files' code (emit_list_elem)
if saved != null and e.kids[i].file != null { ck_vis_file = e.kids[i].file }
let et = ck_expr(e.kids[i]) let et = ck_expr(e.kids[i])
ck_vis_file = saved
if ck_unknown(t) { t = et } if ck_unknown(t) { t = et }
else { ck_give(t, et, e.kids[i], `an element of a []{t}`) } else { ck_give(t, et, e.kids[i], `an element of a []{t}`) }
i += 1 i += 1

View file

@ -135,6 +135,7 @@ function ck_fn_body(d: Node) -> void {
} }
function ck_listener(l: Node) -> void { function ck_listener(l: Node) -> void {
let ev = find_event(l.s) let ev = find_event(l.s)
ck_vis(ev, l.s, l)
let m = ck_mark() let m = ck_mark()
if ev != null { if ev != null {
var i = 0 var i = 0
@ -177,7 +178,13 @@ function check_program() -> void {
let d = prog[i] let d = prog[i]
if d.kind == N_FN { ck_fn_body(d) } if d.kind == N_FN { ck_fn_body(d) }
if d.kind == N_MAIN or d.kind == N_TEST { ck_block(d.a) } if d.kind == N_MAIN or d.kind == N_TEST { ck_block(d.a) }
if (d.kind == N_VAR or d.kind == N_CONST) and d.a != null { ck_give(d.ty, ck_expr(d.a), d.a, d.s) } if (d.kind == N_VAR or d.kind == N_CONST) and d.a != null {
ck_vis_file = d.file
ck_vis_skip = d.kind == N_VAR and is_port_var(d)
ck_give(d.ty, ck_expr(d.a), d.a, d.s)
ck_vis_file = null
ck_vis_skip = false
}
i += 1 i += 1
} }
var j = 0 var j = 0

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -152,6 +152,7 @@ entry {
else if a == "--fmt" { fmt = true } else if a == "--fmt" { fmt = true }
else if a == "--save-temps" { save = true } else if a == "--save-temps" { save = true }
else if a == "--run" { run = true } else if a == "--run" { run = true }
else if a == "--check" { g_check_only = true } # parse, type, module and port checks; nothing emitted
else if a == "--coverage" { g_coverage = true } else if a == "--coverage" { g_coverage = true }
else if a == "--target" { ai += 1; if ai < arg_count() { target = arg(ai) } } else if a == "--target" { ai += 1; if ai < arg_count() { target = arg(ai) } }
else if a == "--gui" { gui = true } else if a == "--gui" { gui = true }
@ -207,6 +208,7 @@ entry {
check_duplicate_fns() # two declarations of one name: the cause, before its symptoms check_duplicate_fns() # two declarations of one name: the cause, before its symptoms
check_duplicate_decls() check_duplicate_decls()
check_program() # L4: the types agree before anything is emitted check_program() # L4: the types agree before anything is emitted
if g_check_only { exit(0) } # --check: the checks are all there is
emit_program() emit_program()
deps_flush() # LUDIC_DEPS=<file>: the module graph (ludic deps) deps_flush() # LUDIC_DEPS=<file>: the module graph (ludic deps)

View file

@ -42,6 +42,7 @@ program Ludic {
print(" run [file] [--headless] build the project and run it") print(" run [file] [--headless] build the project and run it")
print(" build [file] [--headless] [-o out]") print(" build [file] [--headless] [-o out]")
print(" compile to build/<name> (a native binary, nothing to ship beside it)") print(" compile to build/<name> (a native binary, nothing to ship beside it)")
print(" build [file] --check only check it: types, modules, uses and ports; nothing emitted")
print(" test [file|dir...] [-j N] [-v] [--test NAME]") print(" test [file|dir...] [-j N] [-v] [--test NAME]")
print(" compile and run the project's tests (-v: every test's line)") print(" compile and run the project's tests (-v: every test's line)")
print(" deps [file] [--graph|--dot|--writes|--uses MOD|--check F|--baseline F]") print(" deps [file] [--graph|--dot|--writes|--uses MOD|--check F|--baseline F]")

View file

@ -276,6 +276,7 @@ function parse_build_args(start: int) -> pointer {
else if a == "--windowed" { g_mode = 1 } else if a == "--windowed" { g_mode = 1 }
else if a == "--save-temps" { g_save = true } else if a == "--save-temps" { g_save = true }
else if a == "--unsafe" { g_unsafe_build = true } else if a == "--unsafe" { g_unsafe_build = true }
else if a == "--check" { g_check_build = true }
else if a == "-o" { else if a == "-o" {
ai += 1 ai += 1
if ai < arg_count() { g_out = arg(ai) } if ai < arg_count() { g_out = arg(ai) }
@ -283,7 +284,7 @@ function parse_build_args(start: int) -> pointer {
} }
else if a[0] == '-' { else if a[0] == '-' {
err(`ludic: unknown option {a}\n`) err(`ludic: unknown option {a}\n`)
err(" build/run take: [file] [--headless|--windowed] [-o out] [--save-temps]\n") err(" build/run take: [file] [--headless|--windowed] [-o out] [--save-temps] [--check]\n")
g_argerr = true g_argerr = true
} }
else { src = a } else { src = a }
@ -303,10 +304,20 @@ function output_path(entry: pointer) -> pointer {
} }
# ludic build [file] [--headless] [-o out] [--save-temps] # ludic build [file] [--headless] [-o out] [--save-temps]
# --check: parse, types, modules, uses, ports - everything the compiler checks before it emits -
# and nothing emitted or linked; a fraction of a build's time, for iterating on uses lines
var g_check_build: bool = false
function check_app(src: pointer) -> int {
ensure_ludicc()
if not shq(`{ludicc()} --check{title_flag()}{unsafe_flag()} {src}`) { return 1 }
print(`checked {src}`)
return 0
}
function cmd_build() -> int { function cmd_build() -> int {
let entry = parse_build_args(2) let entry = parse_build_args(2)
if g_argerr { return 1 } if g_argerr { return 1 }
if entry == "" { return no_entry() } if entry == "" { return no_entry() }
if g_check_build { return check_app(entry) }
let out = output_path(entry) let out = output_path(entry)
if not compile_app(entry, out, g_mode, g_save) { return 1 } if not compile_app(entry, out, g_mode, g_save) { return 1 }
if g_mode == 2 { if g_mode == 2 {

View file

@ -490,6 +490,23 @@ function deps_case() -> void {
ok(lbl) ok(lbl)
} }
# --check: the module rules, the ports and the types, with nothing emitted - a broken uses line is
# refused as a full build refuses it, and a sound program writes no file
function check_build_case() -> void {
let lbl = "ludicc --check / ludic build --check: modules, uses, ports and types without emitting"
let bad = ["rejected/uses_missing", "rejected/layer_reach", "rejected/private_module", "rejected/port_unbound", "rejected/registry_hidden", "rejected/port_new", "rejected/wrong_arity"]
let want = ["fishing uses items.inv_add", "hud uses items.item_count", "add is private to module bank", "port Clock is used here but never bound", "Tools is private to module kit", "Clock is a port", "leaves out h"]
for i in 0 .. len(bad) {
if shq(`bin/ludicc --check examples/{bad[i]}.ludic > {tmp_dir()}/chk.err 2>&1`) { bad2(lbl, `{bad[i]} passed the check`); return }
let said = capture(`cat {tmp_dir()}/chk.err`)
if not s_contains(said, want[i]) { bad2(lbl, `{bad[i]}: [{s_trim(said)}]`); return }
}
shell("rm -f build/checked_out")
if not shq(`bin/ludic build examples/modules/layers.ludic --check -o build/checked_out > {tmp_dir()}/chk.out 2>&1`) { bad2(lbl, capture_line(`tail -1 {tmp_dir()}/chk.out`)); return }
if file_exists("build/checked_out") { bad2(lbl, "--check wrote a binary"); return }
ok(lbl)
}
# every package's own tests, the way a package author runs them: `ludic test packages` # every package's own tests, the way a package author runs them: `ludic test packages`
function packages_test_case() -> void { function packages_test_case() -> void {
let lbl = "ludic test packages: every package's tests pass" let lbl = "ludic test packages: every package's tests pass"
@ -1079,6 +1096,7 @@ function cmd_dev_test() -> int {
test_dir_case() test_dir_case()
packages_test_case() packages_test_case()
deps_case() deps_case()
check_build_case()
lab_plate_case() lab_plate_case()
pack_roundtrip_case() pack_roundtrip_case()
packignore_case() packignore_case()