Guidance for AI agents (Claude Code and others) working in this repo. Human
contributors: see CONTRIBUTING.md — this file does not replace it, it sharpens
the parts agents get wrong.
A distributed orchestrator for Firecracker microVMs, written on the BEAM
(Elixir ~> 1.20, OTP 28+). A small privileged Rust setuid helper
(native/suidhelper/, crate hyper_suidhelper) performs the Linux operations
the BEAM cannot do safely (losetup, dmsetup, chroot jails, device nodes).
Postgres is the only external runtime dependency (the image database).
mix check # THE gate. Must pass before any PR. Runs, in order:
# format --check-formatted (formatting is not optional)
# compile --warnings-as-errors --force
# credo --strict
# test --warnings-as-errors
# dialyzer (strict; @specs required)
mix test # Elixir suite (needs Postgres for DB tests)
mix test test/unit test/controls # pure tests, no Postgres/Firecracker needed
cargo nextest run # Rust suite (run inside native/suidhelper/)Pure tests under test/unit and test/controls need neither Postgres nor
Firecracker. DB-backed tests need mix ecto.create && mix ecto.migrate first.
lib/— Elixir source. Tests intest/mirror this tree.lib/unit/,lib/controls/,lib/sys/linux/proc/,lib/hyper/redist/— pure cores: units algebra, EWMA/rate controls,/procparsers, hashing. No processes, no I/O. These are where property tests pay off most.native/suidhelper/— the privileged Rust helper. Source insrc/, tests intests/(see Rust rules below).- Generated, do not hand-edit:
lib/hyper/firecracker/api/{operations,schemas}(regenmix firecracker.gen) andlib/hyper/grpc/v0/hyper.pb.ex(regenmix grpc.gen). Both are gitignored and rebuilt by a Mix compiler.
A good test proves the spec, not that the code ran. Writing a passing test is not the goal; writing a test that would fail if the behavior were wrong is. Before adding a test, ask: "what does this module promise, and what input could break that promise?" If a test cannot fail for any realistic implementation bug, it proves nothing — delete it.
StreamData (Elixir) and proptest (Rust) are already wired in. For any pure function — parsers, codecs, algebra, validators, hashing, scheduling math — reach for a property test first. The work is identifying the invariant. Hunt for these families (the existing suites are worked examples of each):
- Round-trip / inverse —
parse(render(x)) == x,decode(encode(x)) == x,with_value(value(q)) == q. Seetest/unit/quantity_properties_test.exs,test/sys/linux/proc/stat_properties_test.exs,native/suidhelper/tests/util/safe_path.rs(relative_toreconstructs). - Algebraic laws — commutativity, associativity, identity, inverse, total
order. See the additive-group laws in
quantity_properties_test.exs. - Oracle / model — the result equals an independent reference computation
(
CpuTimes.total == Enum.sum(cols)). - Invariant preserved — a property that holds for every output regardless
of input (a confined path never contains
..; a refcount is never negative). - Idempotence / metamorphic —
f(f(x)) == f(x); or a known input change produces a known output change. - Error & refusal contracts — invalid input always raises/returns the
specific error, and is never silently accepted. This is a property too: see
"mixing two dimensions always raises" and
rejects_any_loose_component. For security-sensitive code (the setuid helper, path confinement) the refusal property is the most important test in the file.
State the laws under test in the module's @moduledoc (or a Rust //! doc),
the way the existing property suites do — it forces you to name the contract.
Property tests are not a religion. Use a plain example/smoke test when:
- the behavior has no meaningful input space to generate over (a specific
parse of one real
/proc/meminfofixture, one gRPC round-trip); - you are pinning one concrete edge case or regression;
- generating valid inputs would be more code (and more bugs) than the thing under test.
A few good examples that exercise real logic beat a generator that only ever hits the happy path. Pair them: properties for the laws, examples for the representative cases and the nasty edges.
These will be asked to be removed (per CONTRIBUTING.md):
- one-assertion-per-getter / setter tests that restate the struct definition;
- tests asserting on mocks you set up in the same test (proves the mock, not the code);
- tautologies —
assert f(x) == f(x), or recomputing the implementation inside the assertion; - snapshot/coverage-padding tests with no invariant behind them;
- a
propertyblock whose generator is so narrow it only emits one value.
Coverage is a side effect of good tests, never the target.
use ExUnit.Case, async: trueanduse ExUnitPropertiesfor property suites.- Naming convention already in the tree:
*_properties_test.exsfor the property suite of a module,*_test.exsfor its example tests (often both exist side by side, e.g.sha256_test.exs+sha256_properties_test.exs). - Build generators by composing the module's own constructors (see how
quantity/0maps scalars throughInformation.bytes/1etc.) — generate valid inputs by construction rather than generating-then-filtering. StreamDatagotcha: an emptyinteger(a..b)range (whena > b) raises at generation time and aborts the wholecheck all, it does not re-generate. Bound the parent generator so the range is always non-empty.
- Tests live in
tests/, never inline#[cfg(test)]insrc/. The crate is split into a lib (src/lib.rs, cratehyper_suidhelper) and a thin bin (src/main.rs) precisely so integration tests can reach the internals. - Each test file must be registered as a
[[test]]target inCargo.toml(tests/subdirectories are not auto-discovered) — copy an existing[[test]]block when adding one. proptestis the dev-dependency; useproptest!{ #[test] fn ... }withprop_assert!. CI runscargo nextest run --profile ci(one retry to damp flakes — do not write tests that rely on the retry).
- NEVER write section-divider banners — comments like
// --- foo ---,# === bar ===,// ---- Tests ----, or any comment whose job is to label a region of a file. They are a code smell: reaching for one is a signal that the file is doing too many things at once. When you feel the urge, do NOT write the banner — make the underlying decision instead:- Split it out. If the regions are genuinely distinct responsibilities, they belong in separate modules/files (or, for tests, separate test files or submodules). Extract them.
- Or drop the comment entirely. If the code is already cohesive, the banner adds nothing a reader can't see from the names — delete it. A blank line is sufficient separation. There is no third option where the banner stays.
- Comments earn their place. No comment that just restates what the next
line does. A comment explains a non-obvious why a reader cannot recover
from the code — a workaround, an invariant, a deliberate trade-off. Prefer
self-documenting code: a named function, a
Unit.*quantity instead of a bare1024 * 1024, a descriptive variable — over a comment narrating the mechanics. If you reach for a comment to explain what, rename the thing instead. - Don't hand-roll magic numbers for sizes/durations/bandwidth: use the
Unit.*types (Unit.Information.mib(8), not8 * 1024 * 1024) anduse Unit.Operatorsfor unit-aware arithmetic. - Add
@specto public functions. Dialyzer runs with:unmatched_returns,:extra_return,:missing_returnand will fail the gate on a missing/wrong spec. - Zero compiler warnings —
--warnings-as-errorsis enforced. - Conventional Commits, scoped to subsystem:
feat(fire_vmm): ...,fix(redist): ...,test(unit): ...,docs: .... - Do not commit generated bindings or hand-edit them; commit the source
artifact (the
.proto/ OpenAPI spec), not the output.