Compiling Erlang source to BEAM¶
1.x reads the .beam files that erlc produces. 2.0 is the version where
JErlang also produces them: an Erlang source compiler that emits standard OTP
29 .beam files, so a JVM-only deployment does not need an Erlang installation
to build its own modules.
This document is the plan for that work. It is written to be executed by agents, so it records the decisions that would otherwise be re-litigated in every session, and it defines done as an executable check rather than a judgement. It records the completed compiler track that shipped in 2.0.0.
Two things called a compiler¶
They are not the same project and only one is in scope:
- Source to BEAM — the subject of this document. JErlang becomes a toolchain, and the interpreter gains a second, independent implementation of the format it already parses.
- BEAM to JVM bytecode — a JIT. This was the original roadmap's "0.5 Compiler", deferred because nothing measured the interpreter as too slow and because it freezes semantics work. It is still deferred; it is performance work, not compatibility work, and it is tracked separately.
Definition of done¶
"BEAM compatible" here means exactly these, in this order. Anything weaker is a disassembler, not a compiler.
beam_lib:chunks/2on OTP 29 accepts the file.code:load_binary/3on a real OTP 29 node loads it without an error.- A module built by JErlang behaves like the same module built by
erlc: same exports, same return values, sametry/catchclasses and reasons. - Stacktraces name the same file, line, function and arity.
beam_lib:md5/1of the file agrees with the digest the compiler records for its own funs, so afuncreated in a JErlang-compiled module survives a round trip through distribution andterm_to_binary.- JErlang itself loads and runs the file. The interpreter is the second consumer and a regression net, never the judge of correctness.
Byte-identical output is not a goal. Matching erlc byte for byte would
mean reimplementing beam_ssa_opt exactly, for no semantic gain. Semantic
equivalence plus OTP acceptance is the bar, and any deviation from it is a bug.
What already exists¶
The output half of a compiler is largely specified by code that reads the format, which is why the first milestone is cheap.
| Component | Where | What it gives the compiler |
|---|---|---|
| Opcode table | src/main/java/org/jerlang/Opcode.java |
All 191 numbers from genop.tab with declared arity, which is the table the emitter indexes. |
| Operand codec, decode direction | AbstractReader.decodeArg, InstructionReader |
The packed small/11-bit/27-bit integer forms, a as a 1-based atom index with 0 meaning nil, and the z extended forms (inline list, float register, allocation list, literal index, typed register, float). The encoder is its inverse. |
| Chunk readers | src/main/java/org/jerlang/stdlib/beam_lib/*ChunkReader.java |
One reader per chunk: Atom/AtU8 (including OTP 28's negative count and compact lengths), Attr, CInf, Code, ExpT, FunT, ImpT, Line, LitT, LocT, StrT, Abst. Each one is the writer's specification. |
| Term encoding | ExternalTermWriter |
Literal table terms, and the Abst chunk, which is term-encoded abstract forms. |
| Fun digest | BeamLibMD5, ChunkId.sortOrder |
The beam_lib:md5/1 digest and the chunk ordering it depends on. Emitting a wrong digest makes every fun in the module fail with badfun on a peer. |
| Loader and model | ModuleLoader.loadBeamData, BeamData, Module |
A read path from file to instructions that the assembler and the disassembler both build on. |
| Conformance harness | ConformanceReport, PassRateTest, src/test/erl/corpus |
The classification and skip-list pattern the compiler's harness should copy. |
What does not exist¶
- No writer of any kind. There is no
*Writerinbeam_lib, no IFF container writer, and no instruction encoder. This is the whole of milestone A1. - No carrier for unknown chunks.
BeamLibInfodrops every chunk whoseChunkId.skip()is true (Dbgi,Meta,Type, the HiPE chunks), andBeamDatahas no field for them. A round trip of any real OTP file will therefore lose bytes and the md5 will not match untilBeamData(or a writer-only carrier) holds those chunks as opaque payloads. Do this first; the exit test finds it immediately otherwise. - No front end. There is no scanner, preprocessor, parser, abstract format,
or linter: a search for
Lexer|Parser|Scanner|Ast|ErlParserfinds nothing. This is milestones A3 and A4, and most of the work. - No command. There is no
jerlc, noJerlangCompilerentry point and no-o/-I/-Doption handling.
Pipeline and where the code goes¶
.erl source
| scan org.jerlang.compiler.scan
| preprocess org.jerlang.compiler.preprocess (-define, -include, -ifdef)
v parse org.jerlang.compiler.parse -> erl_parse abstract forms
forms
| lint org.jerlang.compiler.lint -> errors, warnings
| codegen org.jerlang.compiler.codegen -> instructions + tables
v emit org.jerlang.stdlib.beam_lib -> .beam bytes
.beam
Format code stays in org.jerlang.stdlib.beam_lib, next to the readers it
mirrors, so a reader and its writer are always edited together. Compiler logic
lives in the new org.jerlang.compiler package. That package is public API, so
it lands on 2.0.0 and not before.
The abstract format is the internal IR, not a hand-rolled node hierarchy: it is
documented, stable, what OTP's own back end consumes, and it makes "is my
parser right" a term-by-term diff against erl_parse:parse_form/1 instead of a
judgement call.
Core Erlang is skipped in the first cut. Abstract forms go to instructions directly. That is defensible because the interpreter does not care how the module was optimized, and it avoids implementing a full intermediate language before anything runs. Revisit only if a correctness problem turns out to need it.
Milestones¶
Each milestone has one exit test, and it is done when that test runs in CI. The existing roadmap's discipline applies unchanged.
A1 — Assembler: read a .beam, write it back¶
Scope: IFF container (FOR1, length, BEAM, 4-byte-aligned chunks with the
ChunkId.sortOrder order), one writer per chunk, the inverse operand encoder,
opaque pass-through for skipped chunks, and the fun digest.
Exit test: for every module in a checked-in corpus of files built by OTP
29.1.2, the re-emitted file has the same beam_lib:md5/1, and a real OTP node
loads and runs it. Corpus targets the 1366 modules of an OTP installation,
filtered only by "the reader can load it today".
This is the highest-value milestone in the plan: it forces the container, atom table, literal table, line table, lambda table and operand encoding to be right, needs no parser, and its oracle is a single hash comparison that cannot be argued with.
Submilestones. Each one is a session-sized item and leaves the previous tests green.
| # | Item | Exit |
|---|---|---|
| A1.1 | Container: read every chunk in file order, including the ones the loader skips (Dbgi, Meta, Type, HiPE), and write FOR1/BEAM back with the length and 4-byte padding recomputed |
Every checked-in .beam re-emits byte for byte |
| A1.2 | Operand codec: the encoder for the compact integer forms, a, x, y, f and the z extended tags |
A property test that re-encodes every operand of every instruction in the corpus to the bytes it came from |
| A1.3 | Atom and AtU8, including OTP 28's negative count and compact lengths |
Byte identity with the atom chunk re-encoded from its atoms |
| A1.4 | ExpT, ImpT, LocT, StrT |
Byte identity for the four table chunks |
| A1.5 | Term chunks: LitT uncompressed, Attr, CInf, Abst through ExternalTermWriter |
Byte identity, or a written reason a term cannot round trip |
| A1.6 | FunT lambda table, including OldUniq |
Byte identity, and beam_lib:md5/1 unchanged with FunT re-encoded |
| A1.7 | Line: filename table and the a/i items |
Byte identity for modules with line information |
| A1.8 | Code: header, instruction stream, and the operand tags and widths the decoder currently discards |
beam_lib:md5/1 identity for the whole corpus with Code re-encoded |
| A1.9 | Corpus and CI gate | A job that asserts md5 identity over the checked-in fixtures and loads the re-emitted files on OTP 29. BeamAssemblerTest is the digest gate; OtpLoadTest runs every fixture through code:load_binary/3 and skips itself when no Erlang is installed |
Status: A1 is done. The four A1 tests re-emit all 104 fixtures with the same beam_lib:md5/1, re-encode every operand in the corpus, and write the atoms, the tables, FunT, Line and Code back byte for byte. LitT, Attr, CInf and Abst are carried unchanged; the reasons are under "What A1 found". The OTP 29 load check is described below. A2 followed.
A1.1 and A1.8 were the two that carried risk, and both landed as expected. A1.1 because Meta is part of the digest, so a writer that drops the chunks the loader ignores produces a different module identity; A1.8 because AbstractReader.decodeArg returned a Term and not the bytes it read, so the original tag and width of an operand did not survive decoding. A1.2 answered that question: the decoder has to keep the encoding. Two operand kinds lose more than the width, which no canonical encoder can recover: a literal operand decodes to the literal itself, so its index into the literal table is gone, and a typed register decodes to the register, so its type is gone.
The load check. OtpLoadTest writes every assembled fixture to a temporary directory and asks code:load_binary/3 to accept it, with the original file as the control: a rewrite is only a defect if OTP takes the original and refuses the rewrite, and a refusal has to carry the same reason. On OTP 29 (erts 17.1.1) 47 of the 104 fixtures load, one is skipped because it is the OTP lists module, and 56 are refused by both versions with badfile: the corpus reaches back to releases whose instructions this validator no longer accepts, and several of those files have no CInf chunk at all. Nothing that loads before the rewrite fails after it. The test finds Erlang through ERL, ERLANG_HOME or PATH and skips itself when there is none, so mvn test stays green on a machine without OTP.
What A1 found¶
Four things about the format are not visible in the loader, and all four were found by comparing the writers against the corpus:
- The atom table has no terminator. The reader used to read one byte past the last atom and never failed, because the loader gives each reader a stream over the whole file, so the byte it took was the next chunk id. A writer that did not know this grew the chunk by one byte.
Attr,CInfandAbstcannot be re-encoded yet. The compiler writes atoms asATOM_EXTwith a two byte length;ExternalTermWriterwritesSMALL_ATOM_UTF8_EXTwith a one byte length. A 40 byteAttrchunk came back as 39.- A map keeps its pair order on the wire and
Mapdoes not. A literal#{a := 10, b := 2}came back as#{b := 2, a := 10}: the same term, different bytes. This is what blocksLitTeven where it is uncompressed, and it is a decision for A5: the compiler needs a map that keeps insertion order, or literal order has to be chosen and never re-derived. LocalFunctionTableChunk.locals()returns its records reversed, becauseList.ofprepends. That is harmless to the loader and fatal to a writer;records()returns file order.
The lesson is the shape of the milestone rather than the four bugs: a writer is only correct when it is compared with a file it did not produce. Every one of these would have passed a test that only loads the module.
A2 — Disassembler¶
Scope: .beam to a textual assembly form and back. Purpose is a debuggable IR
and golden files, so a codegen bug shows up as a diff instead of a mystery.
Exit test: round trip to byte-identical output for the corpus, and every disassembly is a stable checked-in fixture.
Status: done. Disassembler decodes the container, the atom table, ExpT, ImpT, LocT, FunT, Line and every instruction in Code with its operand encoding; TextAssembler reads all of it back. LitT, Attr, CInf, Abst, StrT and the chunks nobody interprets stay hex, and are copied, for the reasons A1 recorded. DisassemblyRoundTripTest proves all 104 fixtures survive the trip byte for byte, and all 104 listings are checked in under src/test/resources/disassembly.
A3 — Front end: source to abstract forms¶
Scope, in this order: scanner; preprocessor; parser; -file and line
annotation parity.
Traps worth naming up front, because each one is a session of its own: OTP 27+
tokens (numeric separators, sigils, triple-quoted strings); macro arity,
??Arg stringification, -include_lib resolution and -feature; the
{attribute,_,file,_} and {eof,_} forms epp emits; ten operator precedence
levels; guards in every clause position; record access, update and nested
update; binary and map comprehensions; maybe and ?=; named funs and
fun M:F/A.
Exit test: for every .erl under an OTP lib tree, the forms match
erl_parse:parse_form/1 term by term, or the case is in a checked-in skip list
with an ID. The pass count is the metric and it is published, the way the
conformance pass rate already is.
Status: done. The scanner is in
org.jerlang.compiler.scan; ScannerOtpTest matches every token with
erl_scan:string/3 on OTP 29: 110 files, no disagreement. The preprocessor is
in org.jerlang.compiler.preprocess and PreprocessOtpTest matches every
preprocessed form with epp:scan_file/2: 110 sources, 110 forms, 0 skipped.
The parser is in org.jerlang.compiler.parse; ParserOtpTest feeds the same
tokens to erl_parse:parse_form/1 and compares the abstract forms: 110
sources, 110 form streams, 0 skipped. It covers the expression grammar,
attributes, records, binaries, comprehensions, try/receive/fun/maybe,
and the type and spec grammar.
The OTP lib exit test ran over 1404 .erl sources: 1019 form streams matched
epp and erl_parse term by term, none disagreed, and 385 are named skips in
src/test/resources/otp/parse_skips.txt. Those 385 are sources this installed
tree cannot preprocess: 373 need build-only headers, 5 need build-only macros,
and 7 need doc sources absent from the installation. Each has a stable hashed
ID. None is a parser gap. The preprocessor also reproduces epp's CRLF include
quirk: after an include, a CRLF parent keeps the include line, while an LF
parent reports the next token's line.
What A3 found. The preprocessor is small; the behaviour that is easy to
miss is not. A macro body is re-annotated with the line of the ? that used
it, not the line it was written on, or every warning inside a macro points at
the header. Macro names are variables as well as atoms, so ?MODULE is a
var token named MODULE and a table keyed only by atom finds nothing.
??Arg is the argument's tokens joined with one space, each written as it
appeared, quotes included. Finally, filename:join/2 lowercases the drive
letter on Windows, so epp's include -file name starts c:/ even when the
source path started C:/; matching it is part of the token stream, not
politeness.
A4 — Lint: accept what erlc accepts¶
Scope: unsafe variables across clauses and branches, unbound variables in
patterns, illegal guard expressions, duplicate map keys, ambiguous binary
patterns, -export of a missing function, arity and redefinition conflicts,
and the warnings that make a build noisy in practice (unused, shadowed).
This is the step that is routinely underestimated: the bar is not "compiles",
it is "accepts and rejects exactly what erlc does". Every deviation gets a
documented line in this file, not a silent pass.
Status: complete. The module pass indexes definitions, exports, imports,
on_load, and the nowarn_*/export_all/no_auto_import options.
FunctionScope owns branch merging, unsafe-variable contexts, patterns,
guards, maps, binaries, comprehensions, named funs, try/catch and
maybe. It reports unbound and unsafe variables, undefined local calls and
fun references, illegal guards and patterns, ambiguous binaries, duplicate
map keys, and the unused, shadowed and export warnings. Obsolete OTP 29
type-test guards (port/1, atom/1, ...) are accepted with the same warning
erlc emits, so -Werror agrees.
LinterOtpTest compares 37 clean, error and warning cases against erlc in
both normal and -Werror modes. LinterLibOtpTest is the A4 exit test: over
the installed OTP 29 tree it walks 1,404 .erl files, applies the 385 named
A3 parse skips, and compares the remaining 1,019 verdicts with erlc.
Result on 2026-11-03: 1,019 accepted by both, 0 rejected, 0 disagreements.
The corpus test is opt-in because it needs an OTP installation and takes
about two minutes.
Known limits: the abstract format loses columns, so diagnostics are line
based. The corpus table compares accept/reject only; warning parity is
contract-tested by the 37-case table, not by comparing every erlc warning.
Exit test: a two-column table over the corpus (accepted, rejected) that
matches erlc's verdict on every file, plus -Werror behaviour on the warning
cases.
A5 — Codegen: abstract forms to instructions¶
Scope: an assembly IR, label allocation, X/Y register allocation from liveness,
guard chains and fail labels, func_info entry labels for function_clause,
try/catch/try_case/raise/build_stacktrace, one and several receive
blocks with the 28+ markers, binary matching as bs_start_match3/4 plus a
bs_match command list, bs_create_bin with string segments as StrT
offsets, make_fun3 with its lambda table and free-variable list, and line
tables.
The traps that produce wrong answers rather than crashes: a value live across a
call must be in a Y register or it is clobbered; a fun's OldUniq must be zero
before the digest is taken; a string segment is an offset, not inline bytes.
Status: complete. The first vertical slice emits a complete BEAM container
for functions, clauses, literal patterns (integer, atom, tuple, list, string,
map, record and nil), guard sequences and alternatives, arithmetic BIFs,
auto-imported BIFs, local and remote calls, tuple/list/string/map/record
construction, case/if, try ... of ... catch with or without after,
receive with selective clauses and an optional after timeout, anonymous,
named and remote funs (fun M:F/A), binary generators, short-circuit
andalso/orelse, and unary +/-. Dynamic remote calls, old-style catch,
!, list ++/--, expression comparisons, alias and variable-key map
patterns, and the guards not, is_map_key/2, is_record/2 and
is_function/2 are emitted as well.
Remote calls go through ImpT and
call_ext; maps start from maps:new/0 and update with put_map_assoc. Values
that survive a call are kept in Y registers; every operation result gets a
fresh Y slot, and X registers are used only for arguments and BIF results.
Labels are allocated before lowering so forward local calls resolve, and
case/if branches restore their pre-branch variable scope while joining
variables bound in every branch.
Two end-to-end tests consume the same file through independent paths.
CodegenOtpTest writes a module and asks OTP 29 to load it and call exported
functions: recursion, guards, tuple/list/string/map construction, list/string/
map patterns, case/if branches, guard alternatives, local and remote calls,
try/catch and try/of/catch error and throw paths, try-after cleanup on normal,
throw, error, catch-body and try_clause paths, receive with queued messages,
selective scanning, timed wakeups and zero timeouts, fun captures, arity zero,
multiple lambda clauses, nested lambdas and a fun passed into a compiled call,
binary construction and matching (including explicit sizes and used tails),
short-circuit booleans, unary arithmetic, and unmatched function/case/if
expressions (which must raise
function_clause, case_clause and if_clause) all agree with erlc
behaviour.
CodegenInterpreterTest loads that file through ModuleLoader, registers it,
and makes the same calls through JErlang. Both are green as of 2026-11-04.
Binary construction uses bs_create_bin; matching uses bs_start_match3 and a
flat bs_match command list. Non-empty literal tables are written as OTP 28+
uncompressed LitT. A binary tail match keeps its context in X1 so get_tail
can write the tail over it, saves the position in X0, and extracts fixed fields
into X0 and X2+. Sizes in match commands are emitted as plain integers, while
bs_create_bin constants keep the signed {integer,N} operand tag OTP expects.
Tuple and list construction emit test_heap first; test_heap is a no-op in
JErlang, but OTP corrupts the heap without it. These changes are covered by
bin_lit, bin_empty, bin_construct, bin_match, bin_tail,
bin_tail_used, bin_string and bin_size in both consumer tests.
Line tables are emitted, and erlang:get_module_info/2 exposes module,
exports, functions, attributes, compile, md5, native and
native_addresses. The checked-in corpus now recompiles and runs through both
CodegenOtpTest's OTP oracle and CodegenCorpusTest, which loads the same
beams in JErlang and checks run/0 =:= ok. Named funs, fun M:F/A, record
construction/update/access/patterns, binary generators, arbitrary = patterns
and normalized catch stacktraces are covered by those paths. The A5 exit test,
CodegenBootTest, compiles gen_server, supervisor, proc_lib,
application, logger and code from the installed OTP 29 sources, puts those
beams ahead of the installed ones, boots the milestone 6 application, calls the
worker and stops the application again. It is green as of 2026-11-04. StrT is
still unused, module_info(attributes) does not yet match erlc's vsn entry,
and expression-level line parity is coarser than erlc. Unsupported forms raise
CodegenException; they are not silently omitted.
Two format facts cost a debugging cycle and belong in the next agent's notes.
The module atom must be first in AtU8: OTP derives the module identity from
that position, even when Attr names the right module. Raw non-negative
integer operands are emitted with the unsigned u tag, not i; func_info's
arity in particular is rejected by OTP when written as i. JErlang's
is_nil and is_nonempty_list originally read only X registers; generated
pattern code stores arguments in Y registers, so both now resolve operands
through the normal register reader. A third trap is List.of(Collection):
the runtime helper prepends, so map update and get_map_elements operand
pairs must be reversed before encoding or the map is built with the wrong
keys. A receive after 0 has no timer to wake it, so waitForMessage queues
the process itself when its timeout has already expired; otherwise the
scheduler sleeps forever. A receive clause's message arrives in x(0), so it is
copied to a Y slot before the clause body is compiled: a body expression such
as a call can clobber the X registers, and a variable bound to the whole message
must still read the message afterwards. A lambda's FunT label is its body label, not its
func_info entry label, and its arity includes the captured variables. BEAM
calls a fun with the arguments first and the captured values after them, so the
lambda body binds its patterns to the first Y slots and its free names to the
slots after them. The free-variable operand of make_fun3 is an operand list
even when it is empty; passing nil encodes an atom and OTP rejects the
instruction. Finally, the Code chunk's function count includes lambdas, not
just exported and local functions. Auto-imported BIF names such as throw/1 and error/1 must become
call_ext to erlang, not local calls; the code generator uses the same OTP
29 table as the linter. A fourth trap is the f operand of a test
instruction: it is the failure target, not the success target. Putting the
short-circuit result there inverted every andalso/orelse; the direct truth
table through OTP exposed it before the tests were accepted. A fifth is handler
allocation: reserve handler Y slots below result slots, initialize them at
function entry, and allocate them downward as try nesting is entered. OTP
requires the outer handler above the inner one; with the opposite order the
inner catch is bypassed, and without initialization a second call can restore a
stale handler.
Exit test: recompile the checked-in corpus with JErlang, run the existing
suite against the recompiled modules, and diff behaviour against the erlc
build. Then the strong form of the same test: the otp CI job compiles
gen_server, supervisor, application, proc_lib, logger and code with
JErlang and boots them.
A6 — Command and surface¶
Scope: jerlang compile (or jerlc), -o, -I, -pa, -D, -W,
+term options, .hrl includes, a public org.jerlang.compiler.Compiler
entry point, and documentation in getting-started.md.
2.0.0 is the release that may break API, so this is also where Instruction,
Opcode.arity() and ModuleLoader get reshaped if the emitter wants them
different. After 2.0.0 the japicmp gate freezes them again.
Status: complete. org.jerlang.compiler.Compiler is the public entry point;
CompilerCommand is the compile subcommand of the runnable jar
(java -jar jerlang-<version>-all.jar compile ...). It accepts -o, -I, -pa, -D,
-W, -W0, -Werror, +debug_info and +warnings_as_errors. -Dname
expands to true and -Dname=value scans the value as Erlang tokens, so it
agrees with erlc. -W runs the A4 linter; lint errors stop the beam, and
-Werror makes warnings stop it too. Unknown options are errors, not silently
ignored flags. The command never invokes erlc.
Exit test: CI compiles the corpus with the shipped command and no erlc on
the machine, then runs the suite. CompilerCommandTest is that test: it
compiles src/test/erl/corpus through CompilerCommand, loads every beam in
JErlang and runs run/0; a second case covers an -include header, an
-include_lib application root, -D macros, -W, -Werror, -o and
+debug_info.
A7 — Parse transforms¶
Scope: run -compile({parse_transform, Module}). and entries in a
-compile([...]) option list after parsing and before linting or code
generation, load the transform from -pa or the code path, execute it in
JErlang, and report a missing or malformed transform as a compile error.
Status: complete. A transform receives the parsed forms and an OTP-shaped
compiler option list containing debug_info, warnings_as_errors,
{i, Dir} and {d, Name, Value} entries. -pa roots are searched directly,
under ebin, and under versioned application directories; the working
directory and the ordinary code path are the fallbacks. The directory holding
the transform is put on the code path for the call, so helper modules beside it
load as well.
The compiler starts a VM only when a transform is present and shuts it down
again if it was the one that started it. A transform that has to spawn an
Erlang process is bounded by parseTransformTimeoutMillis (30 seconds by
default; zero disables the bound in the Java API). The timeout is a compile
error, not a warning, and no beam is written. This is not an OS sandbox:
trusted transform code can still call erlang:halt/1 or leave native work
behind.
Exit test: ParseTransformTest compiles a transform module with JErlang,
compiles a subject through jerlang compile -pa ..., loads the resulting beam
and checks the rewritten function. It also covers ordered transforms, a
transform helper loaded from -pa, compiler options, timeout failure, and a
missing transform. CompilerApiTest covers failed Results and preservation
of an existing beam after a failed compile.
Timeline, re-estimated for agent execution¶
The human estimate for A1–A6 was 9–15 months. Agents change the shape of that, not just its size, because the work splits cleanly into two kinds.
| Milestone | Human | Agent | Why the ratio differs |
|---|---|---|---|
| A1 assembler | 1–2 months | 2–3 weeks | Pure transcription: every writer has a reader next to it, and the oracle is one hash comparison. |
| A2 disassembler | ~1 month | 4–6 days | Little ambiguity, golden files, no semantics. |
| A3 front end | 4–5 months | 6–9 weeks | Large but mechanical, parallelizable, and erl_parse gives a term-exact oracle. epp is the long tail. |
| A4 lint | included above | 3–5 weeks | A large set of small independent rules, each with a erlc verdict to match. |
| A5 codegen | 6–9 months | 3–4 months | Judgment per failure: every bug is a semantics question and the oracle (does OTP boot it) is slow. |
| A6 command | 1–2 months | 2–3 weeks | Plumbing. |
| Total | 9–15 months | 4.5–6 months |
In agent terms that is roughly 110–150 agent-days of active work, and 4.5–6 months of calendar time with one workstream. Two workstreams can overlap after A1 lands — back end (A2, then the emitters) and front end (A3, A4) are independent until A5 — which buys maybe three to four weeks of wall clock, not half the schedule, because A5 is on the critical path and cannot be split far without integration cost.
The line count does not explain the schedule. A1–A6 is roughly 25–35k lines, which at a few thousand verified lines a day is two to three weeks of writing. The other four and a half months are oracles: building them, waiting on them, and reading their failures.
What gets faster, and what does not¶
Faster than the human estimate, by a lot, when the oracle is exact and offline:
- Transcription of a format that another file in the same package already reads. A1 is the extreme case.
- Breadth with no coupling: 60 token kinds, 120 productions, 80 lint rules. These parallelize across sessions and do not need one author's mental model.
- Test scaffolding, fixtures, golden files, skip lists, CI wiring.
- The edit-compile-test loop itself, which is where most human time goes on mechanical code.
Roughly the human speed, or worse, where the work is judgement:
- Register allocation, guard chains,
tryandreceiveshapes. A plausible wrong answer passes a smoke test and fails two hundred modules later. eppcorner cases where OTP's behaviour is arguably a bug and the plan has to say whether to match it. Agents pick the locally reasonable option unless the document decides first, which is what this document is for.- Debugging across layers: a wrong atom index manifests three subsystems away.
- Review. Volume of generated code outruns what review absorbs, and the failure mode is silent divergence rather than a slow build.
Rules that keep the estimate true¶
- Oracle first. The first commit of every milestone is its harness, not its feature. A milestone with no oracle is a milestone with no schedule.
- Check in the fixtures. The corpus of
erlc-built.beamfiles lives in the repository, so the assembler oracle is offline, deterministic and runs in seconds. Anything that needs a live OTP install stays in the nightlyotpjob, not in the inner loop. - Asymmetric oracles only. Never validate a writer with its own reader, or
a parser with its own printer. The oracle is an
erlcbuild, a real OTP node, or OTP's own output, every time. A round trip through code the same session wrote proves nothing and is the most likely way this plan produces a compiler that only agrees with itself. - Skip lists, not silence. Every unfixed case is a named entry with an ID and a reason, counted in the exit test. Progress is a number that moves.
- No milestone without a CI job. A check that only runs on a developer's machine is not an exit test.
How the work is cut for agents¶
A work item is one milestone row that can be finished and verified in a single
session: a chunk writer, one epp directive, one lint rule, one instruction
shape. Larger than that and the session ends with half a subsystem and no green
test; smaller and the harness costs more than the change.
Every item follows the same contract:
- Read the exit test first. If it does not exist yet, writing it is the item.
- Land the oracle with the feature, in the same commit. A feature whose check arrives later is a feature of unknown correctness.
- Record the gaps. Every case the item does not handle becomes a skip-list entry with an ID, or a line in this document. A silently skipped case is a false pass, and false passes are what make the schedule meaningless.
- Never weaken a check to finish. No skipped test, no relaxed assertion, no
suppressed warning, no
catchadded to turn a failure green. If the check itself is wrong, fix it in its own commit and say why in the message.
After A1 the work splits into two tracks that touch different files:
| Track | Milestones | Owns |
|---|---|---|
| Back end | A2, then the emitters for A5 | org.jerlang.stdlib.beam_lib, org.jerlang.compiler.codegen |
| Front end | A3, A4 | org.jerlang.compiler.scan, .preprocess, .parse, .lint |
BeamData and Instruction are shared by both. A1 changes both, so those
changes land before the split; afterwards an edit to either is a coordination
point rather than a local choice.
Review, by a person or by a separate review pass with the exit test in hand, is the gate rather than the writing. A change that cannot be shown to match OTP's behaviour is rejected even when its tests pass. That is deliberately slower than merging, because a compiler that agrees only with itself is worse than none.
Decisions to pin now¶
These are the choices that would otherwise be made differently in each session:
- Target OTP 29 formats only.
AtU8atoms with compact lengths, uncompressedLitT, the lambda table inFunT,make_fun3. Declare the maximum opcode the emitter actually uses, so an older runtime can still load a simple module. - No optimization pass. Emit straightforward code.
beam_ssa_optparity is explicitly out of scope, and any time spent there is time not spent on A5. - Parse transforms run in a started VM with OTP-shaped compiler options.
They are loaded from
-paor the code path and run before linting and code generation. The options carrydebug_info,warnings_as_errors, include directories and macro definitions; the form interface is already OTP's. - Native records are gated on the runtime. The compiler may only emit the native-record opcodes once the data type exists; today constructing one raises a named error by design.
-spec,-type,-callbackare stored, not checked.dialyzeris not in scope.- Version. The compiler API is public and shipped with 2.0.0. The published artifact,
download instructions and example builds now target 3.0.0. Future version
bumps follow
docs/release.md; no session bumps the version on its own.
Non-goals¶
Unchanged from 1.x: no NIFs, no hot code upgrade, no OTP release handling, no
global, no TLS distribution. Added for 2.0: no byte-identical output, no type
checker, no Elixir source compilation (Elixir-compiled BEAM modules can run on
the runtime), no BEAM-to-JVM JIT, and no compiler feature that
exists only to make the compiler's own tests pass.
Risks¶
- The
epplong tail. Includes, macro arity and-ifarithmetic are where a front end appears finished long before it is. Budget for it in A3 and keep the acceptance number visible. - X/Y register bugs. They do not crash; they return garbage under load. The only defence is the corpus, which is why A5's exit test recompiles and runs everything rather than a handful of modules.
- Fun identity. A wrong
OldUniqor digest ordering shows up asbadfunover distribution, far from the cause. A1 proves the digest before any fun is generated, which is the cheap order. - Lint scope creep.
erl_lintis large. Match the corpus and the common warnings, publish the deviations, and do not chase exotic diagnostics. - Implementation-mirroring tests. The specific failure mode of agent-written compilers: tests that assert what the code does rather than what OTP does. Rule 3 exists for this.
- Documentation drift. Every decision made mid-milestone goes into this file in the same commit. Otherwise the next session re-derives it differently.
Working on this branch¶
- Status: merged to
masterand released as 2.0.0. This section is retained as a record for follow-up compiler work. - Build and test:
mvn -B test. CI runsmvn -B verify, which adds the packaged jars, the runnable all-jar compile smoke test and the japicmp API gate. A single milestone's tests should be runnable with-Dtest=...and stay under two minutes. - If
mvnis not on the PATH, the toolchain used here is in../.tools: a JDK 21 and Maven 3.9.9. PointJAVA_HOMEat the JDK and run thatmvn.cmd;-oworks once the local repository is populated. - Format code goes in
org.jerlang.stdlib.beam_lib; compiler code goes inorg.jerlang.compiler. Tests mirror the package, as they already do. - Comments follow
CODE.md: brackets always, AsciiDoc-style blocks, and a comment explains why the code is shaped that way, not what the next line does. - Update this document in the same commit as the decision it records. A milestone's status line changes only when its exit test is green in CI.