Contributing to Keal

Keal holds itself to a small set of non-negotiable rules; a change that respects them is welcome from anyone. This file is the whole procedure.

Build and verify

cargo build --release            # the Rust toolchain (the oracle)
cargo test --release             # the whole suite, native tests included
./bootstrap.sh                   # the self-hosted compiler, to its fixed point

A JDK and a C compiler unlock the interop tests; without them those tests skip, they do not fail. keal doctor lists what this machine has, next to the versions the interop suite was last verified against — Keal pins versions, it does not vendor toolchains: compilers are hundreds of platform-specific megabytes that every OS packages better than a language repository can, and a differing version is not an error, just a fact cargo test --release settles.

The rules every change must respect

  1. Oracle and twin, byte for byte. Every compiler-stage change lands in the Rust oracle (src/lexer.rs, parser.rs, checker.rs, cbackend.rs, and constfold.rs / macros.rs for the two passes that rewrite the tree) and in the self-hosted twin (selfhost/lexing.keal, parsing.keal, checking.keal, cbackend.keal, constfold.keal, macros.keal), and the four dump commands must agree byte-for-byte over the whole corpus:

``sh keal tokens f.keal ↔ keal selfhost/lexer.keal f.keal keal ast f.keal ↔ keal selfhost/parser.keal f.keal keal types f.keal ↔ keal selfhost/checker.keal f.keal keal cgen f.keal ↔ keal selfhost/cbackend.keal f.keal ``

The suite runs this comparison over every .keal file in the repository — programs, examples, and the error corpora, where the diagnostics must match too, exit codes included.

  1. Three engines, one behavior. The tree-walking evaluator is the specification; the bytecode VM and the native backend must match it exactly — output, panic messages, everything. When the C backend cannot compile something yet, it refuses by name with a clear message; it never mis-compiles. Runtime programs must be leak-free: leaks --atExit on the native binary says zero.
  1. Generated files regenerate, never edit. After touching src/prelude.keal or src/runtime.c, rebuild (cargo build --release) and regenerate selfhost/preludesrc.keal / selfhost/runtimesrc.keal (each wraps the file in a raw-string function; the header comment in each says so).
  1. A new AST node gets stamped with its file. The twin's loader walks a parsed tree and writes each node's file id onto it (selfhost/loader.keal). A node added without a line there defaults to file 0, which is the prelude — so a diagnostic blames <prelude>, and a type written in that node resolves against a file that declares none of the program's classes. This has been shipped twice: once on a catch clause's handler, once on a macro declaration. When you add a field that holds a node, add the line that stamps it.
  1. New behavior comes with tests. tests/programs/ — self-checking programs (assert, silent, exit 0), run on both interpreters. tests/native/ — programs with printed output and an .expected snapshot, run on all three engines. tests/selfhost/parse-errors|type-errors|errors/ — programs that must be refused, named for what they prove. tests/fuzz/fuzz.py <keal> <count> [seed] — the differential fuzzer; run a few thousand programs when you touch the checker. * UPDATE_EXPECT=1 cargo test --release rewrites snapshots.

Removing a refusal is new behavior, and the easiest kind to ship untested — nothing was added, so nothing looks like it needs proving. But a refusal is load-bearing while it stands: it was keeping programs out, and some of those programs were being kept out of trouble. Letting a lambda capture this was right and is not in question; it also made the reference cycle in tests/audit/closure-cycle.keal writable in one line, in the exact shape the feature exists for. Nobody could have written that cycle the day before.

So: a refusal removed is a burden of proof moved, not lifted. Ask what the refusal was preventing besides the thing you wanted, and write the test for that before the one for the feature.

  1. A test may skip because it cannot run, never because it would rather not. Plenty of tests here stand down when a machine has no C compiler, no JDK, no git, no Python — the check genuinely cannot happen, and saying so is right. That is not the same as a test that could run and takes an easier road: the site's drift check linked what it needed and skipped where symlinks want elevation, so the one platform where the generator was broken was the one platform the test did not look at. It copies now. A test that skips on a platform is a test that platform does not have, and the bug it was written for will be found there first.
  1. Nothing decodes bytes without saying how. Every open() and every subprocess call in site/*.py names encoding="utf-8", and every write names newline="". Python takes both from the machine's locale otherwise, and a machine whose codepage is cp1252 will mis-decode the compiler's own output into a page and exit 0 — the failure that does not fail. The same rule is why a diagnostic never embeds std::io::Error's text: it is the operating system's sentence, in the operating system's language.
  1. An output is witnessed when something CONSUMES it. Of every generated artefact the question is not "is there a test" but "what uses this". A snapshot on its own attests that an output has not changed; it never attests that the output was ever right, so a thing that has always been wrong keeps a green test and a stable snapshot to go with it. The reason to care is that a symmetric error CANCELS. When the same code both writes and reads, a mistake made one way is undone the other, the round trip agrees with itself, and the test does not merely miss the bug — it reports success. The file-system primitives spent a release writing UTF-8 path names through Windows' ANSI entry points: a program that made a directory called 日本 listed 日本 back, while what sat on disk was 日本 and no other tool on the machine could open it. Every test written in Keal agreed with the bug. So: where the same code is on both ends, put something else on one of them. tests/programs/filesystem.keal has a shell create the directory and asks listDir what it sees — runCommand is what made an outside witness reachable from inside the corpus. kealdoc_matches_snapshot compares bytes and then READS the page back, checking its tags balance and that a doc comment's < arrived escaped; breaking the generator and updating the snapshot to match is caught by the second half and not the first. Most snapshots here are already witnessed without anyone planning it — the diagnostics are compared against the self-hosted compiler too, the bindings are compiled and run, the layouts are used by native programs that would crash on a wrong one. Look for the ones that are not.

The other half of the same rule: a test must assert the answer, not the fact of an answer. tests/audit/closure-cycle.keal exists to tell two objects apart — one whose closure captured this and one whose closure captured a value — and the harness checked only that the audit had spoken. It would have stayed green if the audit stopped finding cycles altogether. It now names which of the two is one.

Watch for this hardest where a program cannot check itself. The audit reports after the last statement, so no assertion inside the program can ever read it: by the time the answer exists there is no program left to do anything with it. A version of such a file ending in assert(true, "no leaks") looks like a test, passes forever, and verifies nothing. Something outside has to read what it printed.

  1. Say it plainly. Diagnostics explain and suggest (-- note: with the fix). Comments state constraints, not narration. Costs and limits go in the docs, not under the rug: see docs/types.md (the type rules), docs/memory.md (the memory model), docs/drop.md (unwinding and deinit), docs/threads.md (the actor plan), docs/interop.md (the boundary).

Where things live

src/            the Rust oracle: lexer, parser, checker, interp, VM,
                cbackend, runtime.c, prelude.keal, tools (doc, bindgen,
                jbind)
selfhost/       the same compiler, written in Keal, held byte-identical
lib/            libraries beyond the prelude (the JVM gateway)
tests/          the corpora described above
examples/       runnable programs, including the interop demos
docs/           the design decisions, honestly stated

Proposing a change

Fork, branch, keep the commit story readable (one concern per commit), and make sure the three commands at the top are green before opening a pull request. A PR that changes semantics should quote the rule in docs/ it follows — or start by proposing the doc change, which is often the real discussion.