← Playground docs/BUILDING.md
raw

Building NURL

The bootstrap uses clang / LLVM 15+, the host shell and standard system utilities. It needs no Python, make or language-specific package manager. The full POSIX test suite also requires timeout or gtimeout; the Windows suite uses PowerShell 7. Clone the repo and run ./build.sh (or build.bat on Windows). The committed compiler/nurlc_lastgood.ll snapshot boots the self-hosted chain.

Optional stdlib features (sqlite3, X11, asound, opus, cuda, nvrtc, sdl2) pull in their own system libraries at link time only when a program imports them; each degrades with a clear diagnostic when absent (TLS, QUIC, HTTP/1–3, Postgres, deflate, and Zstandard are pure NURL with no C library dependency).

Prerequisites

ToolPurpose
clang / LLVM 15+Compile LLVM IR (.ll) to a native binary
Bash + standard POSIX utilitiesRun build.sh and the POSIX test suite
timeout or gtimeoutBound compiler and runtime execution in POSIX tests; Homebrew coreutils on macOS
PowerShell 7 (Windows)Run run_tests.ps1

Windows — install LLVM from llvm.org/releases (the Windows installer adds clang.exe to PATH). Command Prompt, PowerShell, or Git Bash all work.

Linux (Debian/Ubuntu) — sudo apt install clang

Linux (Fedora/RHEL) — sudo dnf install clang

macOS — brew install llvm, then put it ahead of Xcode's clang:

export PATH="$(brew --prefix llvm)/bin:$PATH"   # add to ~/.zshrc to persist

Homebrew's LLVM is required, not a preference. Xcode's clang cannot build NURL: Apple clang version 15.0.0 (Xcode 15.4) is not upstream LLVM 15 — Apple numbers its releases independently — and its IR parser rejects the mix of i8* and ptr that nurlc emits, with or without -opaque-pointers. The build detects this by probing the compiler and stops with this instruction rather than leaking an LLVM parse error about a .ll file you never wrote. If you keep Xcode's clang first on PATH, point the build at Homebrew's explicitly instead:

export CLANG="$(brew --prefix llvm)/bin/clang"

Also brew install coreutils — the test runner's hang watchdog is timeout(1), which macOS ships under no name (Homebrew installs it as gtimeout, which the runner looks for). macOS host builds are covered by CI on Apple Silicon; Intel host builds remain unverified. See PLATFORMS.md.

FreeBSD — the base system already ships clang; build.sh needs bash (pkg install -y bash), plus pkgconf + sqlite3 for the SQLite FFI tests. The shipped Linux archives use the glibc ABI and do not load under musl. Alpine/musl requires a source build; a libc-only dependency list does not imply compatibility between libc ABIs (see PLATFORMS.md).

Step 1 — Build the C runtime

./build.sh (Step 2) compiles the C runtime for you, so most contributors can skip straight to Step 2. Build it by hand only for the manual bootstrap below:

clang -c stdlib/runtime.c -o stdlib/runtime.o      # Linux / macOS
clang -c stdlib\runtime.c -o stdlib\runtime.o      # Windows
stdlib/runtime.o is not checked in (it is gitignored). ./build.sh regenerates it; note that the build compiles it with -flto, so after a build runtime.o is LLVM bitcode and the build also emits a plain-ELF stdlib/runtime.native.o for non-LTO linking (see below).

Step 2 — Bootstrap the self-hosting compiler

./build.sh        # Linux / macOS
build.bat         # Windows

The build script performs a complete bootstrap:

  1. Links the committed compiler/nurlc_lastgood.ll snapshot →

build/nurlc_lastgood.bin (stage 0 — the only step that doesn't already need a working nurlc).

  1. nurlc_lastgood.bin compiles compiler/nurlc.nu → build/nurlc_self (stage 1).
  2. nurlc_self compiles compiler/nurlc.nu again → build/nurlc_self2 (stage 2).
  3. Verifies stages 1 and 2 produce byte-identical LLVM IR (the bootstrap fixed point).
  4. Atomically replaces build/nurlc with the completed stage 2 executable

and updates the compiler entry at the repo root.

  1. Runs the snapshot test suite (compiler/tests/run_tests.sh; on

Windows run_tests.ps1, which needs PowerShell 7) and diffs against the golden baseline. Each run's artifacts go under build/tests/run.* (build/tests-san/ for sanitizer builds); a run keeps the newest $NURL_TEST_KEEP_RUNS (default 3) and removes older ones as it starts, never one still in progress.

It prints BUILD SUCCESS & TESTS PASSED on success, or the full log / diff on failure. All artefacts land under build/.

POSIX compiler, formatter and package-manager builds preserve their previous executables until replacement succeeds. A failed required tool still fails the complete build. The POSIX program driver also links beside its destination and then renames the completed executable, so a running process can retain its old binary while a new one is published. Failed links preserve the previous executable and its coverage notes or macOS debug bundle. The Windows program driver also stages the linked executable and preserves the previous output on failure. MSVC debug builds publish an adjacent PDB with a stable embedded filename; failed publication restores the prior PDB.

When a grammar / runtime-ABI change leaves the committed snapshot unable to compile the current nurlc.nu, refresh it with `./build.sh --refresh-bootstrap (uses the existing build/nurlc` to regenerate both compiler/nurlc_lastgood.nu and .ll; commit both files together).

Clean: ./clean.sh / clean.bat.

Manual bootstrap (if needed):

mkdir -p build
# Stage 0: link the committed snapshot IR → a working boot compiler.
clang compiler/nurlc_lastgood.ll stdlib/runtime.o -lm -lpthread -o build/nurlc_lastgood.bin
# Stage 1: boot compiler self-compiles → fresh self-hosted nurlc.
build/nurlc_lastgood.bin compiler/nurlc.nu > build/nurlc.ll
clang build/nurlc.ll stdlib/runtime.o -lm -lpthread -o build/nurlc

Compile a .nu file

Recommended (automated):

./nurl.sh myprogram.nu              # → ./myprogram          (Linux/macOS)
./nurl.sh myprogram.nu myoutput     # → ./myoutput
nurl.bat  myprogram.nu              # → myprogram.exe         (Windows)

Manual (two-step):

./nurlc myprogram.nu > myprogram.ll                          # or build/nurlc
clang myprogram.ll stdlib/runtime.native.o -lm -lpthread -o myprogram
./myprogram
Link against stdlib/runtime.native.o (a real ELF object), not stdlib/runtime.o — after ./build.sh the latter is LLVM bitcode and a plain clang link rejects it with "file format not recognized". The -lm -lpthread libraries are required.

What ends up in the .ll

nurlc emits only the functions main can reach. An import brings in a whole module — a program that imports stdlib/ext/json.nu for json_parse also parses json_clone, the pretty-printer and the float formatter — and emitting all of it means clang runs its -O2 pipeline over code LTO then deletes at link time. Dropping it at the source of the IR instead is worth 30–40% of the clang step on a stdlib-heavy program (examples/static_server.nu: 1,416 emitted functions down to 469, 6.6 s of clang down to 3.8 s). The final binary is unchanged — LTO was already removing the same code, just later.

Since 0.71.0 nurlc does not even compile a library function nothing reaches. The signature prescan records which names each function body spells; following them from the project's own files (every function of those is compiled, used or not — their diagnostics are the author's), main, every % block and the hooks the compiler instantiates, a function of stdlib/ or deps/ that nothing reaches is skipped before code generation (an empty main beside the json import: 556 M → 154 M compiler instructions). Should generated code still name a skipped function, it is compiled late from its kept source; NURL_LAZY_TRACE reports such a late compile. It is off under --no-dce, --lint, -g and for files without main.

The pass runs over the finished IR text, so it needs no special knowledge of closures, generic monomorphisations, drop glue or dyn vtables: a function is live when something emitted names it. Roots are main plus anything named at module scope (a @__vt.… vtable constant holds its method thunks there). A file with no main is a module compiled for something else to link against, so the pass leaves it whole.

Two things make it safe to have on by default: the output is a sub-sequence of the unfiltered IR — nothing is rewritten, reordered or renamed — and dropping something still referenced is an undefined symbol at link time, never a wrong binary.

nurlc --no-dce <file> emits everything, which is what you want when diffing IR against an older compiler or linking a NURL object into a C program by hand.

Parallel lowering (--split)

Even after dead-function elimination, the clang step is what a NURL build waits on, and it is one single-threaded LLVM -O2 pipeline over one module. Lowering the compiler's own 3.2 MB of IR takes 11.3 s while emitting it takes 0.46 s, and idle cores stay idle: -flto with -plugin-opt=jobs=12 parallelises only the codegen tail and still measures 11.1 s.

So nurl.sh asks nurlc for the module *as several independent modules*, lowers them at once, and links them with ThinLTO — which carries cross-module inlining through its bitcode summaries and runs its own backend in parallel. On twelve cores, the compiler's clang step:

one module, -flto12 parts, -flto=thin
clang step11.3 s2.0 s

The whole module still reaches stdout, so myprogram.ll is byte for byte what it always was; the parts are written beside it and removed after the link.

The trade. ThinLTO imports across a module boundary, but not unconditionally, so a caller separated from a callee it wanted inlined stays separated. The split-built compiler retires **3.4% more instructions** than the single-module one (3.242e9 vs 3.135e9 under perf stat, self-compile). A 5.6× cheaper build for 3.4% of the program's speed — worth it while you iterate, not worth it for what you ship. (That figure predates 0.73.0, which copies small functions into every part that calls them — below — so they inline across parts again; the copies cost a large program's build about a fifth: anomaly 48 s → 58 s, against 192 s unsplit.) The drivers follow that rule rather than a global default:

stage-2 one it installs as build/nurlc as a single module.

The bootstrap applies the same rule to the optimisation level, not just the partition — see Only stage 2 is optimised.

Controls:

NURL_SPLIT=0never split — the old behaviour exactly. Use it for a release build.
NURL_SPLIT=Nat most N parts (default: one per core, capped at 16).
NURL_SPLIT_MIN=BYTESsmallest a part may be (default 131072).

N is a ceiling: nurlc splits only as far as it can keep every part above the floor, so a program under ~256 KB of IR is never split at all. That floor is not about build time — cutting a small module up makes it slower. bench/sort_window.nu (10 KB of IR) runs 20–42% slower split at any part count, and bench/hash_join.nu (27 KB) 23% slower at twelve, while the compiler's 3.2 MB is unaffected at every count tried.

simd functions stay whole. A simd function (spec §3.3b) gets a wide clone so that its callees are inlined into it and vectorised there, and a callee in another part is one ThinLTO imports back only if it is small. So the partition keeps each simd function — its dispatcher, both clones, and the transitive closure of everything they call — in one part, whole, and cuts the rest of the module as usual. The closure is small next to the program that carries it: in packages/agora, 233 of 4665 functions, 6.7% of the IR. Measured on fixed-input ML-DSA-65 sign + verify:

instructionscycles
one module0.689e90.27e9
12 parts, simd closure kept whole0.685e90.26e9
12 parts, cut like any other code1.101e90.38e9

Before this, a module holding any simd function was not split at all — and every program that speaks TLS or post-quantum crypto holds some, so the largest NURL programs were exactly the ones lowered on one core. Measured on a 6-core / 12-thread Haswell-E:

programIRcold buildrebuild after a one-line edit
beforeafterbeforeafter
packages/agora11 MB61.3 s16.6 s59.4 s10.9 s
packages/anomaly18 MB106.7 s22.9 s102.6 s16.6 s

Splitting is off for --emit-ir, --emit-asm, -O0, NURL_SAN=1, and --debug / --coverage — nurlc refuses --split together with --g, because DWARF is a per-module metadata graph that a function's !dbg attachments point into, and partitioning would strand them.

The partition itself is a text-level pass over the finished IR, on the same footing as dead-function elimination above: a function goes to one part, everything a part does not define it declares — except a small function (up to 1600 bytes of IR), which is also copied as linkonce_odr into each part that calls it, with its own small callees. ThinLTO's import cannot stand in for the copy: it maps an imported function's struct types onto structurally equal ones of the importing module (every Vec is { ptr } under opaque pointers), and a call whose type differs from its callee's is never inlined — mlp's slice loop ran 4× slower split than unsplit. private globals follow the functions that name them, module-level globals are defined in part 0 and declared external in the rest — except a linkonce_odr one, which part 0 may discard when its own code does not use it, so it is replicated instead. A reference that lands in the wrong part is an unparseable module or an undefined symbol — loud, never a silently wrong binary — and compiler/tests/split_equivalence.sh rebuilds a structurally varied corpus both ways on every ./build.sh to prove the programs match.

Windows (nurl.bat) links a single module and is unaffected.

The ThinLTO cache

Parallel lowering makes one build use every core; the cache makes the next build skip most of the work. On native Linux, nurl.sh links with ThinLTO by default and keeps three caches under ~/.cache/nurl:

  1. The link's backend codegen (thinlto-a64/): ThinLTO keys each

module's post-import optimisation and codegen by a hash of the module, everything it imports, and the entire flag set (LLVM bakes its own version in). stdlib/runtime.o is thin bitcode, so the runtime — re-optimised from scratch by every full-LTO link since the language existed — is a cache hit for every build after the first, of any program. Auto-pruned to 2 GB.

  1. The pre-link compile (objs/): the -c step that runs the -O2

pipeline over the emitted .ll is a pure function of the IR bytes and the flags, so its object is cached by content hash. A rebuild whose IR didn't change — and, on a split build, every part an edit didn't touch — is a file copy. Pruned after two weeks unused, and capped at $NURL_OBJ_CACHE_MB (default 2048), least recently used first, checked at most once an hour.

  1. The toolchain probes (probes.*): the opaque-pointer parse

probe and the feature-lib trial links (~190 ms, before every build, answering questions about the toolchain) are cached keyed by the compiler binary, the system library directories' mtimes, and the stdlib feature sentinels — so installing a library or swapping clang re-probes, and nothing else does.

The O3 link backend. Plain ThinLTO at -O2 loses full LTO's second optimisation round: bench/sort_window's compare/exchange mill came out 2.3× the instructions (branches where full LTO's second pass had found 50 cmovs), and this is exactly the penalty the split-floor note above documents. Running the ThinLTO backend at O3 (-plugin-opt,O3; the pre-link compile stays at your -O; since 0.73.0 with -align-all-functions=6, so one function's size no longer moves another's hot loop across a Skylake JCC boundary) restores every such case measured — identical instruction counts suite-wide — and improves one outright: bench/nbody retires 36% fewer instructions than the full-LTO default it replaces. The cached path is not a build-speed/run-speed trade; it is at worst run-speed-neutral.

Measured end to end (12-core Haswell-E, warm cache, unchanged source):

buildbeforefirst (cold)rebuild (warm)
empty program305 ms~310 ms99 ms
bench/json_parse840 ms~780 ms156 ms
bench/json_parse, after a one-line edit840 ms—574 ms
bench/http_server (splits ×12)1.96 s1.96 s0.67 s

The edit-rebuild row is the honest one: a changed module is re-optimised (that is the correctness contract), so what the cache buys there is the runtime, the probes, and every split part the edit didn't touch. For the fastest possible edit loop on a mid-sized program, NURL_SPLIT_MIN=16384 cuts finer parts so less is re-optimised per edit (json_parse edit rebuild: 574 ms → 310 ms) — opt-in, because parts that small cost run speed (+7% instructions on json_parse; see the split-floor note).

Controls:

NURL_LTO=fullone full-LTO module, nothing cached. Slower code than the default on Linux, not faster: runtime.o is ThinLTO bitcode, which a full-LTO link optimises apart from the program, so no runtime function inlines into it (json_parse: 6.9% more instructions).
NURL_CACHE=0ThinLTO still on, all three caches off.
NURL_CACHE_DIR=DIRmove the caches (default $XDG_CACHE_HOME/nurl).
NURL_OBJ_CACHE_MB=Ncap the pre-link object cache at N MB (default 2048).

Scope: native Linux clang builds (GNU ld/gold load LLVMgold.so, and lld accepts the same -plugin-opt spellings). The zig-cc cross paths, macOS's ld64, and Windows (nurl.bat) keep the previous behaviour: their linkers spell these flags differently, and wiring each one up is tracked follow-up work.

Debugging with gdb / lldb (DWARF)

NURL emits DWARF debug info, so a NURL binary drives under gdb / lldb like any C-toolchain ELF: source-level breakpoints, single-step by .nu line, info locals, print x with the NURL type name.

./nurl.sh -O0 --debug examples/fizzbuzz.nu            # DWARF, no optimisation
gdb -ex 'break fizzbuzz' -ex run ./examples/fizzbuzz  # break by name
gdb -ex 'break examples/fizzbuzz.nu:18' …             # break by source line

Pass -O0 (or -O1): at the default -O2, optimisation inlines and reorders enough that source-line breakpoints and info locals become unreliable — the breakpoint may never fire. nurl.sh writes the binary next to the source, so a program under examples/ lands at ./examples/fizzbuzz, not ./fizzbuzz.

Two knobs cooperate:

!DILocation / !DILocalVariable metadata into the IR.

freshly-built non-LTO runtime_debug.o (LTO silently drops DWARF in the current LLVM/gcc-ld pipeline, hence the side-by-side runtime build).

Runtime panics print a stack backtrace before aborting; pipe each frame's binary+0xOFFSET through addr2line -e <binary> to recover .nu:LINE locations. Combine NURL_SAN=1 with --debug for .nu locations in ASan reports; ./build.sh --san alone does not request NURL DWARF metadata. End-to-end regression: ./tools/dwarf_test.sh (no-op when gdb isn't installed).

Line and branch coverage

--coverage instruments generated NURL code with LLVM's GCOV pass. It implies --debug and disables split lowering and LTO. Use -O0 when investigating individual source lines, and --no-dce when the question is how much of the code the tests reach:

./nurl.sh --coverage --no-dce -O0 src/myprog.nu build/myprog
./build/myprog
cd build
llvm-cov gcov -b myprog.gcda

--no-dce is not optional for a coverage report. Dead-code elimination removes functions nothing calls, which is exactly the code a coverage report exists to find: without the flag such a function is gone before it can be counted, and the report omits the gap instead of showing it as never executed. On a four-function sample the difference was 88.9% against an honest 72.7%. The driver forwards --no-dce and --keep=a,b to nurlc for this reason.

For a package's whole test suite rather than one program, nurl-cov (packages/nurl-cov) does the build, the run, the merge across test binaries and the report, and reads the coverage graphs in pure NURL.

Compilation writes build/myprog.gcno; a normal process exit writes build/myprog.gcda. Runs accumulate counters. Both paths are absolute in the generated module, so launching from another directory retains the requested destination. Reports include imported NURL source files. The C runtime is linked without coverage instrumentation.

--emit-ir --coverage preserves the mapping for a later clang --coverage compile; --emit-asm --coverage emits instrumented assembly and its notes. The compiler interface is nurlc --coverage=PREFIX source.nu: it emits !llvm.gcov and DWARF metadata, and requires clang --coverage to lower the IR and link the profiling runtime. Standard output always names the final notes and data destinations, including the .ll artifact retained after a linked build; that IR remains usable for a later coverage compile.

Drivers that stage executable publication can additionally pass the pair --coverage-notes=STAGED_GCNO --coverage-link-ir=STAGED_MODULE_LL. From the same source snapshot and compilation, the compiler writes a second module with the staged notes destination and the final runtime data destination. The driver links this module and publishes completed notes with the executable. Failed compilation or linking leaves the previous executable and notes intact. Both staging options require --coverage=PREFIX and each other; --check creates no module artifacts.

The regression gate checks actual line and branch counts at -O0 and -O2, cumulative runs, imported files, and output paths containing spaces, quotes and non-ASCII bytes: python3 tools/tests/test_driver_coverage.py.

Sanitizer coverage

./build.sh --san --no-tests
python3 tools/sanitizer_controls.py
./compiler/tests/run_san_tests.sh
./tools/leakgate.sh
# Or instrument a single program with an ordinary built compiler:
NURL_SAN=1 ./nurl.sh -O0 --debug examples/fizzbuzz.nu

A sanitized build needs a clang with compiler-rt, the library that holds the ASan runtime. NURL_SAN=1 never builds with the bundled zig, even on an installed toolchain where zig is the default compiler, because zig cc ships no ASan runtime: asked for -fsanitize=address,undefined it keeps UBSan, drops ASan and exits 0. A use-after-free in that binary would run clean. Without a system clang, nurl.sh stops and says so; set CLANG=/path/to/clang for one under another name. The driver also rebuilds a stdlib/runtime_san.o that lacks ASan (one zig built before this check existed), and fails a build whose runtime or linked program has no __asan_init. ./build.sh --san checks its stage 0 binary the same way. tools/tests/test_sanitizer_toolchain.py covers these cases.

nurlc --sanitize-address marks every emitted function for LLVM's ASan pass, including closures, drop glue, dyn/SIMD thunks and split definitions. The driver, bootstrap, corpus and fuzz runners use this compiler option. The committed bootstrap IR carries the attribute too, so stage 0 is covered when linked with sanitizer instrumentation. Normal builds do not run that pass, and ordinary compiler output omits the attribute.

Detection controls require the expected ASan report and a failing exit for heap out-of-bounds access, use-after-free and stack-use-after-return; valid counterparts must produce the expected output without reports. They also compile and execute split output. Optimisation can eliminate an invalid access or inline a stack frame, so the stack-return control runs at -O0. See LLVM's ASan guide.

NURL does not yet emit lexical llvm.lifetime boundaries, so -fsanitize-address-use-after-scope does not establish detection after a NURL block ends. Also, -fsanitize=undefined adds UBSan checks to the C runtime, not source-level checks to previously generated NURL IR. NURL separately emits checked panic paths for invalid dynamic shift counts, integer division/remainder by zero, signed MIN / -1 and MIN % -1, and out-of-range/NaN/infinite float-to-integer casts. These checks also apply without sanitizers and even under the deprecated --no-borrowck. Integer +, - and * wrap at their width. tools/tests/test_arithmetic_safety.py checks the source panic behavior and valid boundaries in normal, instrumented and split builds. Remaining lifetime and safety work is tracked in the v1 ledger.

The whole runtime corpus runs with leak detection on (detect_leaks=1, use_stacks=0; LSAN_DETECT_LEAKS=0 opts out for a memory-safety-only pass), so a leak fails a test like a use-after-free does. tools/leakgate.sh additionally requires zero compiler leaks for the self-compile, recursive drop generation and nested field stores, in both module emission modes.

Bootstrap chain

Stage 0 links the committed compiler/nurlc_lastgood.ll snapshot — pre-compiled IR of compiler/nurlc_lastgood.nu, a checked-in mirror of the last self-host-stable nurlc.nu. Anyone with clang can clone and build; no other-language toolchain is required.

identical to nurlc.nu at the point it was captured).

target-triple-agnostic, so the same blob bootstraps Linux / macOS / Windows.

Snapshot refresh: ./build.sh --refresh-bootstrap (re-runs the existing build/nurlc on the current nurlc.nu and overwrites both .nu and .ll; commit them together).

Only stage 2 is optimised

Stages 0 and 1 are scaffolding. Neither binary is shipped or benchmarked, and each one does exactly one job — compile nurlc.nu once — before the build deletes it. build.sh therefore links them -O0 and keeps -O2 for stage 2, the one copied to build/nurlc.

The trade is the same one the split above makes, pushed further: a throwaway compiler is allowed to be slow, because what it costs to make it fast dwarfs what the speed gives back. Stage 0 is the extreme case — it links the committed snapshot, which is one file by definition, so it is the one link the split cannot help.

step-O2-O0
stage 0 link12.89 s0.75 s
stage 1 link (split)1.93 s0.30 s
stage 1 ir (emit)0.38 s0.88 s
stage 2 ir (emit)0.35 s0.78 s
stage 2 link12.54 s12.49 s
build.sh --no-tests, end to end36.3 s23.6 s

Twelve cores, clang 18. The two stages get ~2× slower at the one thing they do and the build still comes out 35% shorter.

This cannot change what gets built. The IR a stage emits is byte-identical at either level — the bootstrap fixed point (stage 1 ≡ stage 2, byte for byte) is checked on every build and would fail loudly otherwise, and stage 2's own link flags are untouched, so build/nurlc is the same binary it always was.

Sanitized builds (--san) stay -O2 throughout: LTO is already off there, so the link is not the expensive part, and the instrumented self-compile is the stage that runs closest to CI's memory and time limits — -O0 would slow down the thing that is already slowest.

Continuous integration

Every pull request, and every push to main, runs .github/workflows/ci.yml — plus, in parallel, the two host-platform workflows windows-tests.yml and macos-tests.yml. A PR that touches only webdocs/** skips the code gates and builds the docs instead, one that touches only formal/ skips the code gates too, and one that touches both code and either of them runs everything.

Job / stepWhat it checks
build + bootstrap fixed point + test corpusbuild.sh on Linux x86_64 — stage1 ≡ stage2 byte-identical IR, then the snapshot test suite
soundness hole probestools/fuzz/holes/check.sh — all h01–h388 soundness probes are rejected or run clean under sanitizers
formal soundness proof (Lean 4)formal.yml, when formal/ changes: formal/check.sh builds the A1 and A2 proofs (A1.memory_safe, A2.memory_safe, A2.no_leak_end) and fails on a sorry or an axiom beyond Lean's standard ones
diagnostic coverage, anchor & type-spelling gatestools/check_diag_coverage.sh, tools/check_diag_anchor.sh and tools/check_diag_nurl_types.sh — every compiler diagnostic is exercised by a test, points at the right token, and names types as NURL spells them (no lowered LLVM types in a golden)
self-compile peak-RSS gatetools/memgate.sh — compiling the compiler must stay under the memory budget
self-compile leak gatetools/leakgate.sh — nurlc compiler/nurlc.nu must leak nothing under LSan; runs when compiler/nurlc.nu changes
examples & package gatesevery program under examples/ compiles (tools/check_examples.sh); every nurl block in docs/, README.md and the package READMEs compiles, or is rejected with the diagnostic it is annotated with (tools/check_doc_examples.sh); package imports and versions are consistent (tools/check_package_imports.sh, tools/check_package_version_strings.sh); every package's src/main.nu and src/<name>.nu compiles as an installed consumer compiles it (tools/check_package_entries.py`)
stdlib symbol & builtins doc gatestools/check_stdlib_symbols.sh and tools/check_builtins_doc.sh
MCP servers from the checkoutpackages/nurl-mcp/tests/stdio_test.sh (nurl-mcp built from the tree and driven over stdio as an agent drives it) and nurlapi/e2e_local.sh --native-only (the playground server against the checkout's toolchain: native builds, the diagnostics contract, the MCP surface)
nurlfmt --checkthe whole tree is in canonical format (no drift)
HTTP leak + HTTP/2 & HTTP/3 conformancetools/leakcheck/run.sh, tools/h2spec_gate.sh (h2spec), and tools/h3spec_gate.sh (49/49 h3spec over QUIC)
AddressSanitizer + UndefinedBehaviorSanitizer + LeakSanitizerbuild.sh --san + the whole corpus under ASan/UBSan/LSan (CI: targeted controls in one job, the corpus in two parallel shards via NURL_SAN_SHARD=i/n)
FreeBSD (VM)full build + bootstrap + corpus on a real FreeBSD 14.2 guest (vmactions/freebsd-vm) — a hard gate that keeps libc/sh portability honest
unikernel: the corpus with no libcthe corpus built with no libc at all, plus the nolibc/runtime_bare unit gates — and the x86_64 PVH image under cloud-hypervisor as a second loader
unikernel: the guest under QEMUthe guest booted under QEMU on x86_64, AArch64 and RISC-V64 — devices, network demos, TLS, DNS, disk
unikernel: nurlc.wasm and the swarm appliancewasmc_gate.sh (the compiler as wasm, in the guest, byte-identical IR) and swarm_gate.sh (the swarm-mcp appliance)
Windows x86_64 (windows-latest)build.bat — bootstrap fixed point + the Windows golden corpus (compiler/tests/outputs-windows/), then nurl.bat builds and runs a program with the bundled zig and with clang, and builds piped source with nurl.bat --stdin NAME (diagnostics name NAME)
macOS ARM64 (macos-14, Apple Silicon)./build.sh — stage1 ≡ stage2 fixed point + the full corpus against the same goldens as Linux, plus the examples/ frontend gate

A local ./build.sh reproduces the first two gates; ./build.sh --san then ./compiler/tests/run_san_tests.sh reproduces the sanitizer gate; and ./compiler/tests/nurlfmt_check.sh reproduces the format gate. The unikernel gate is unikernel/run_nolibc_tests.sh followed by unikernel/run_qemu_tests.sh (and the _arm64 / _riscv64 variants — each needs the matching qemu-system-*); CI runs those as three parallel jobs, with NURL_QEMU_SKIP_APPLIANCE=1 leaving wasmc_gate.sh and swarm_gate.sh to a job of their own. The two host-platform gates need those hosts.

The Linux jobs run inside a pre-baked container (nurllang/ci:ubuntu24-<date>, built from containers/ci/Dockerfile) with clang/LLVM, the FFI dev libraries, qemu + grub, gdb, zstd and the zig toolchain already installed, so no CI run reaches the Ubuntu package mirror or ziglang.org. CI pins the dated tag; :latest is never referenced.