./mvnw clean spring-javaformat:apply compile # compile
./mvnw clean spring-javaformat:apply package # executable JAR (-exec classifier)
./mvnw spring-javaformat:apply test # all testsTwo modules live OUTSIDE the root reactor, each with its own pom.xml, because each needs
a dependency the core libraries may not have. ./mvnw test does not build either:
./mvnw -f docs-tool/pom.xml test # the documentation site generator
./mvnw -f rontolisp-maven-plugin/pom.xml test # the compile-src/main/lisp pluginrontolisp-maven-plugin depends on the rontolisp artifact by coordinates, so a SNAPSHOT
build needs ./mvnw install -DskipTests first, and its own install is what
MavenBuildE2eTest (-Drontolisp.plugin.e2e=true) needs.
Three documentation layers, no duplication between them:
doc/en/**+doc/ja/**-- user-facing behavior and examples (rendered bydocs-tool/, verified byDocExamplesTest).- This file -- architecture, package rules, workflows.
.kb/*.md-- one file per topic: the invariant plus its full mechanics. Index:.kb/README.md. Before changing behavior in any area, grep.kb/for the topic and read the matching file -- this file does not list the constraints.
A premise recorded in .kb/ is a measurement, not a law: it was true when it was written
and the code has moved since. When a number you measure contradicts one, the finding is
the deliverable -- write the new numbers and the date into that file rather than working
around it or forcing the planned change through. A change that measurement says is not
worth its blast radius is a result; land the measurement, not the change.
Source string
-> LispReader (reader pkg) -> List<LispVal> (AST)
-> [LispMacroExpander] -> expanded AST # cond/and/or/setf -> if/let/progn/setq/rplaca/rplacd
-> LispEvaluator (eval pkg) # interpret
-> JvmLispCompiler (codegen.jvm) -> byte[] (.class)
-> WasmLispCompiler (codegen.wasm) -> byte[] (.wasm)
am.ik.jvm, am.ik.wasm, am.ik.wit, am.ik.gpu and am.ik.objc are language-independent
libraries; none may import rontolisp packages or external dependencies. am.ik.gpu is the
device half of --gpu -- CUDA and Metal behind one sealed GpuDevice seam -- and imports
nothing at all (.kb/gpu.md); the interpreter reaches it
through eval/LinalgGpu -> eval/LinalgGpuKernels, and the JVM backend EMBEDS its class
files in the compiled output (codegen/jvm/JvmGpuRuntimeBuilder) -- so a class added to
that package must be added to the list that travels. am.ik.objc is the Objective-C runtime and
AppKit through FFM (.kb/objc.md), reached from eval/ObjcInterop -> eval/ObjcBridge only,
so -Pweb substitutes the one entry class; the JVM backend EMBEDS it the same way as am.ik.gpu
(codegen/jvm/JvmObjcRuntimeBuilder, whose class list must follow the package, in an order the
verifier accepts); the appkit widget layer is appkit.lisp, the metal drawing surface
metal.lisp and the scene 3D viewer scene.lisp, all shipped like linalg.lisp and spliced
on the compile path by AppKitLibrary / MetalLibrary / SceneLibrary's process (in dependency
order, .kb/geom.md). None of the four compiles to WASM -- AppKitLibrary.firstObjcReference
answers for all of them and CompileFrontend refuses by the reference.
Package dependency direction (no cycles allowed):
cli -> eval, compiler, codegen.*, macro, reader, format, am.ik.wit
codegen.jvm -> compiler, macro, runtime, am.ik.jvm, am.ik.gpu, am.ik.objc
codegen.wasm -> compiler, macro, am.ik.wasm, am.ik.wit
compiler -> macro, runtime, rontolisp (AST types only), am.ik.wit
eval -> macro, compiler, reader, runtime, rontolisp (AST types only), am.ik.gpu, am.ik.objc
macro -> reader, rontolisp (AST types only)
reader -> rontolisp (AST types only)
format -> (nothing)
runtime -> (nothing)
am.ik.gpu -> (nothing)
am.ik.objc -> (nothing)
runtimeimports nothing at all, project or otherwise — not even the build's@Nullable, which isRuntimeVisibleand would follow the class out. Its classes are COPIED into a compiled program's output (beside a.class, inside a.jar, into the Maven plugin'starget/classes), so anything they imported would become that artifact's dependency. What travels and when:.kb/jvm-export.md, "What travels" — therontolisp:jvm-exporthandle boundary types, the embedded HTTP server arontolisp:http-handlerprogram serves through (.kb/http-server.md), and — ONE stated exception to importing nothing — thejakarta.servlettransport pair only a-o app.waroutput carries (providedscope; the container supplies it by definition). A class added to this package must be added to a travelling list (JvmRuntimeClassFilesTestfails otherwise).formatdepends on nothing, not evenreader: it needs the source verbatim and has its own lossless CST front end (.kb/formatter.md).compilerholds backend-shared, backend-FREE front-ends and depends on no backend.macrosits ABOVEreaderso an expander may build injected AST by reading Lisp source; therefore the rootrontolisppackage must never importmacro.- A compile-time AST pass that reads a file belongs in
eval, notcli, and must read throughSourceLoader-- the browser playground (src/web/java) never touchescliand has no filesystem (.kb/wit.md). - The compile path's front end is
cli/CompileFrontend, not a stretch ofRontoLispCli-- the read, the(load ...)inlining, the library splice chain, the WIT lowerings and the tree-shaker, in one order-critical place. Every backend and every embedder goes through it; a JVM embedder goes throughcli/JvmSourceCompiler, which is the same backend half the CLI's-o out.classruns (.kb/jvm-export.md).
Where behavior must be identical across the interpreter, the JVM and both WASM backends,
the topic's .kb file says so and names the pinning test -- change the file and the test
together, never one backend in isolation.
LispNamesconstant +PackageRegistry.CL_SYMBOLSentry (else it is misclassified as a user symbol).Environment.createGlobal():env.define("name", new LispFunction(...))->LispEvaluatorTestJvm<Name>Compiler+ a case inJvmExprCompiler.compileCons()->JvmLispCompilerTest. Arontolisp:-package name does NOT go through that switch -- it never reaches acl:symbol there, sincecompileConsLocateddispatches everyrontolisp:member through a SEPARATE qualified-name if-chain first (keyed onPackageRegistry.splitQualified(sym.name()); every existingrontolisp:primitive, e.g.version/tcp-connect/bfloat16-bits, is a case there). Adding arontolisp:name's case to thecompileCons()switch instead compiles clean and then silently falls through to "undefined function" at the call site -- it never gets a chance to match, since the qualified if-chain already returned.Wasm<Name>Compiler+ a case inWasmExprCompiler.compileCons()->WasmLispCompilerIntegrationTest(WasmEmitHelper.castI31GetS()to unbox,ref.i31to re-box). The same split as step 3 applies here:WasmExprCompiler.compileConsLocatedhas its ownPackageRegistry.splitQualified-keyed if-chain forrontolisp:members, separate from thecl:-symbol switch.BuiltinFunctionWrappers.WRAPPER_DEFSentry so it works as a first-class value.- A case in
src/test/resources/ci-spec.yamlif it deserves end-to-end coverage. - Docs: a per-operator page under
reference/{functions,macros,special-forms}/(H1 = name, signature, one runnable ```lisp example with a; => value), a `_catalog.yaml` entry, and a row in that package's function page (`reference/functions/.md` -- `cl.md` for the standard package; see the category's `index_page` in `_catalog.yaml`). - If its trailing arguments are a BODY, an
am.ik.rontolisp.format.IndentRulesentry -- without onerontolisp formatlays the body out as a function call (.kb/formatter.md).
Macros expand into existing primitives at the AST level; LispMacroExpander is shared by the
evaluator and both compilers, so no per-compiler class is needed.
LispMacroExpander.expand<Name>(LispCons), plusLispNames/PackageRegistry.CL_SYMBOLS.LispEvaluator.evalCons()case ->eval(LispMacroExpander.expand<Name>(cons), env).Jvm/WasmExprCompilercase ->compileExpr(LispMacroExpander.expand<Name>(cons), ...).- To pass it to
map/reduce/funcall: register as aLispFunctioninEnvironmentAND add aBuiltinFunctionWrappersentry. Both -- omittingEnvironmentcausesUndefined symbolin interpreter / native-image mode.
LispEvaluator.evalCons() case (arguments arrive unevaluated), plus
Jvm/Wasm<Form>Compiler wired into Jvm/WasmExprCompiler.compileCons().
Every doc change is mirrored across doc/en/** and doc/ja/** in the same commit -- same
file set, same headings, byte-identical code fences; only prose and titles are translated.
Layout and preview: .kb/documentation-site.md. docs-tool/ is not in the root reactor, so
run ./mvnw -f docs-tool/pom.xml test after touching doc/ layout.
./mvnw -Drontolisp.doc.fix=true -Dtest=DocExamplesTest#fixShownResults test # rewrite shown results
./mvnw -Dtest=DocExamplesTest test # verifyA program is "verified" only when it has run on all four. The component path uses a different I/O adapter (and entropy/clock source), so it can diverge from Preview 1. Assumes wasmtime 47+, which enables wasm-GC and exception-handling by default.
JAR=target/rontolisp-0.1.0-SNAPSHOT-exec.jar
echo '(print (+ 1 2))' > test.lisp
java -jar $JAR test.lisp # interpreter
java -jar $JAR test.lisp -o Prog.class && java Prog # JVM (path-free name, or --class-name)
java -jar $JAR test.lisp -o test.wasm && wasmtime run test.wasm # WASM preview 1
java -jar $JAR test.lisp -o test-comp.wasm --component && \
wasmtime run test-comp.wasm # WASM component (WASI 0.3)handler-case/ignore-errors/unwind-protect/catch/throw, an async component
(incl. every fetch/serve program), and a cross-lambda return-from/go all compile in EH
mode. A fetch component also needs -S http=y.
./mvnw test skips CiSpecE2eTest (-Drontolisp.binary unset), so a stale
ci-spec.yaml expectation only fails in CI. Reproduce it after editing ci-spec.yaml or
changing anything that can shift cross-backend output:
./mvnw -Pnative clean package -DskipTests
./mvnw -Dtest=CiSpecE2eTest -DfailIfNoTests=false -Drontolisp.binary="$PWD/target/rontolisp" testA failure prints [case '<name>' on <BACKEND>; re-run step 2 only unless Java sources changed.
ExamplesE2eTest runs every example in examples/examples.yaml on every backend it
declares. ./mvnw test skips it, so run it after touching an example or a surface they
exercise:
./mvnw clean package -DskipTests
./mvnw -Dtest=ExamplesE2eTest -DfailIfNoTests=false -Drontolisp.examples=true test
# narrow it while iterating: -Drontolisp.examples.only=cloudflareIt is the longest run in the repo. Split it with -Drontolisp.examples.only= from the
start -- one slice per surface -- and run each slice to completion in the foreground. A
run detached into the background loses its result if the session ends before it finishes,
which is indistinguishable from never having run it.
Never detach a test run and end the turn to wait for it. The turn ends, the run keeps going, and nothing wakes up to read the result -- a stall indistinguishable from a crash, and the usual reflex (poll again) reproduces it. What works is a blocking foreground wait on the process:
./mvnw ... test > run.log 2>&1 & # or an already-running build
tail -f --pid=$! /dev/null # blocks until it exits; raise the tool timeout
echo "exit=$?"; grep -E 'BUILD (SUCCESS|FAILURE)' run.logThree ways a run reports green when it is not:
- Two maven runs in one worktree corrupt
target/and void BOTH results. One run per tree, always. - Editing
src/while a run is in flight means the compile phase saw a mixed tree. Freeze the sources until it finishes. - A truncated run looks clean: an orphaned build from an earlier interrupted turn can
kill it early, leaving zero failures and a short report set. Count
target/surefire-reports/*.txt-- a full./mvnw testwrites ~218 -- and check the exit code, not just the failure counts.
When a session driving subagents hits this, the reliable division of labour is for the PARENT to run the suite (capturing the exit code) and hand the summary back, with the worker told not to invoke maven itself.
Several sessions push to develop at once, so what you tested is not what you push.
- Take upstream in once, immediately before the final test run (
git fetch origin,git merge origin/develop), so the suite runs over the merged tree. - A merge you do AFTER that run does not require re-running the suite. Merge the conflict, push, and let CI cover the combination; a second full pass per push costs more than it finds.
- In a worktree,
developis held by the main tree:git checkout developandgit pull --rebaseare unavailable. The sequence isgit fetch origin->git merge origin/develop->git push origin HEAD:develop, retried from the fetch if the push is rejected. - A semantic conflict passes
git mergecleanly. When both sides touched one mechanism, read the other side's diff before pushing -- two changes to the same representation can each be correct alone and emit nonsense together. - Claim a
.todo/NNNnumber, never pick one: run.todo/claim-number.sh "<why>" [count]and use what it prints. Reading.todo/for the highest number cannot work however fresh the fetch is -- two differently-namedNNN-*.mdfiles MERGE cleanly, so a duplicate survives to be found days later (633 and 634 both happened on 2026-09-02). The counter is one file,NEXT, on the orphan branchtodo-seq: claiming is a push to it, so racers fight over the same file and git rejects the loser, which retries. It also reads develop's live files and.todo/history/rows on every claim and skips anything already taken, so a number filed without the script heals itself -- do NOT cross-check by hand afterwards.todo-seqshares no history withdevelopand must never be merged into it. Row format and the duplicate-resolution rule:.todo/.history.md.
- Java 25+
- No external dependencies in the core libraries (reader, eval, codegen,
am.ik.*).docs-tool/is a separate Maven project and may use flexmark/snakeyaml. - Modern Java (records, pattern matching, sealed types, text blocks). The package graph
is a DAG, and a class-level reference cycle is allowed only inside the designed
mutual-recursion clusters (sealed hierarchies, dispatch/re-entrancy hubs) --
PackageCycleTestpins both halves and names each allowed cluster with its reason. src/test/resources/ci-spec.yamlis the single source of truth forCiSpecE2eTest. Cases share global state and run IN ORDER: the driver concatenates them into one program, runs the binary once per backend, and slices the output back per case.
- Format Lisp:
java -jar target/rontolisp-0.1.0-SNAPSHOT-exec.jar format examples/ src/main/resources/ size-report/programs/ bench-report/programs/ - A GUI change (
objc:/appkit:,RontoLispCli.main's thread hand-over, the embedded JVM blob) is verified by hand onjava -jar, the native binary AND the compiled outputs (-o Counter.class --class-name Counterunderjava Counter,-o counter.jarunderjava -jar) withexamples/macos/counter.lisp-- no test opens a window. - Web profile:
./mvnw -Pweb compilewheneversrc/web/javaor a signature it overrides changed --./mvnw testdoes not compile it. Run it AFTER the test suite (orcleanin between): it leaves the web source set intarget/classes, and a later./mvnw testwithoutcleanthen fails withNoClassDefFoundErroron excluded classes, which looks like a regression and is not one. - Native E2E (above) whenever
ci-spec.yamlor cross-backend output changed.