Contributing to Mutant
Thanks for looking. This document covers what the project expects of a change, the two constraints that are not negotiable, and the handful of house rules that are unusual enough to be worth reading…
Thanks for looking. This document covers what the project expects of a change, the two constraints that are not negotiable, and the handful of house rules that are unusual enough to be worth reading before you write code.
By participating you agree to the Code of Conduct. Security issues do not go through the normal issue tracker — see SECURITY.md.
Before you write code#
- Small fixes — typo, obvious bug, missing test — open a pull request directly.
- Anything larger — a new builtin family, a language feature, a change to the bytecode container, an LSP diagnostic — open an issue first. Scope and design are the maintainer's call, so saying what you intend before building it usually saves a rewrite.
Getting set up#
Go 1.26.9 or newer (go.mod pins the floor). Nothing else.
git clone https://github.com/aoiflux/mutant
cd mutant
go build ./...
go test ./...
go install # puts `mutant` on your PATH
The language server is a separate binary in the same module:
go build ./lsp/cmd/mlsp
./mlsp --version # the release and the builtin count this server teaches
Release packaging uses scripts/build.sh (Linux/macOS/WSL) or
scripts/build.ps1 (Windows). Both are Go-only; no Rust or cgo toolchain is
needed any more.
The two hard constraints#
There is no CI. Both constraints are enforced by commands you run before you open a pull request, and a change that breaks either will not be merged, regardless of what it adds.
- Pure Go, no cgo. The whole module must build and test under
CGO_ENABLED=0. A dependency that pulls in cgo is a dependency the project cannot take, however convenient. This is what makes cross-compilation to a single static binary work at all. - Cross-platform. It must build for
linux/amd64,linux/arm64,windows/amd64,windows/arm64,darwin/amd64anddarwin/arm64. Platform-specific code goes behind a build tag with a fallback, never as the only implementation.
Check both locally before you push:
CGO_ENABLED=0 go build ./... && CGO_ENABLED=0 go test ./...
for t in linux/amd64 linux/arm64 windows/amd64 windows/arm64 darwin/amd64 darwin/arm64; do
GOOS="${t%/*}" GOARCH="${t#*/}" CGO_ENABLED=0 go build ./... || echo "FAILED $t"
done
Those two are the minimum. The whole release gate -- the pinned toolchain, gofmt, vet on three platforms, every build target, the tests, the race detector, the generated documents, the example sweep against its recorded output, module verification, the changelog date against the tag, and govulncheck -- is one command, and it prints a table of what passed:
scripts/release_gate.sh --go go1.26.9 # Linux, macOS, WSL
scripts/release_gate.ps1 -Go go1.26.9 # Windows
The gates run on the toolchain go.mod pins, installed beside any newer one with
go install golang.org/dl/go1.26.9@latest and go1.26.9 download; the gate
reads the version from go.mod and refuses any other. Add --quick / -Quick
to skip the slow steps while iterating. A release needs the full gate: a
vulnerability govulncheck finds reachable fails it, and moving the toolchain to
the fixing patch release is how that is cleared. scripts/coverage.sh and
scripts/coverage.ps1 measure coverage per package; that is a measurement, not a
gate.
Configuration: no environment variables#
Mutant takes no configuration from the environment. Using it must never require anyone to set, or think about, a variable. For a forensic tool this is not a style preference: the environment is part of the untrusted thing under examination, and an environment switch is an unlogged control channel that does not appear in the command line an analyst records. A run must be reproducible from its invocation.
When you need a new knob, the preference order is:
- a CLI flag, for a per-run decision;
- a file path passed by flag for anything larger than a flag value — a key, an allowlist, a ruleset. The path, never the content, and never key material;
- a fixed default. Most things belong here.
Credentials are the exception that proves the rule: a password must not be
recorded, so it travels by interactive prompt, --password-file <path> or
--password-stdin — never as a flag value.
Reading a foreign environment variable is permitted where the value is
observed, never obeyed — sandbox and VM detection, debugger indicators, the
process_env forensic builtin. The test is whether a user would set the
variable to change what Mutant does. Each such site is individually allowlisted.
This is machine-enforced. go test ./policy/... parses every .go file in the
repository and fails on a MUTANT_-prefixed string literal anywhere, on an
environment read outside the allowlisted functions (each with a pinned line
budget, so a new read inside an already-allowlisted function still fails), and
on an environment write outside a build-time child go build. It runs as part
of the ordinary go test ./....
The full rule, the carve-outs, and what is deliberately not configurable are in docs/CONFIGURATION_POLICY.md.
House rules for particular areas#
Adding or changing a builtin#
A builtin is not done when it runs. Register the name in builtin/names.go and
the implementation in builtin/builtin.go, then declare its contract in
builtin/metadata.go: parameter kinds, arity, return shape, and a stability
tier if it is not the default stable. Regenerate the catalogue:
go run ./cmd/gendocs # rewrites the reference and the editor grammar
go run ./cmd/gendocs -check # what the generated-docs test asserts
Two files are generated, and neither is ever hand-edited:
docs/CAPABILITY_REFERENCE.md, and the builtin highlighting rule inside
mutant-vscode-extension/syntaxes/mutant.tmLanguage.json. Everything else in
that grammar — strings, keywords, operators — is hand-written and stays so.
Conformance probes in builtin/ call every builtin with deliberately wrong
arguments and assert that the metadata matches what the implementation actually
does. If a probe fails on a builtin you did not touch, the metadata was wrong
before you arrived — fix the declaration, don't weaken the probe.
The language server derives hover, completion, signature help and the
builtinArgType diagnostic from this metadata. Declaring it accurately is what
makes the editor correct; there is nothing to add on the LSP side.
Syntax highlighting used to be the exception — the grammar carried a hand-copied
list of names, and it had fallen 414 builtins behind before anyone noticed,
because no test had an opinion about it. It is generated now, and
TestGrammarHighlightsEveryBuiltin fails when it drifts. So a new builtin is
taught by the editor the moment it is registered and gendocs is run: a feature
is not done when the compiler accepts it, it is done when the editor teaches it.
Shipping the language server#
The mlsp binaries the extension carries are build output: six cross-compiled
targets in lsp/dist, staged into mutant-vscode-extension/bin at package
time, committed nowhere. That makes them the one editor surface that can sit
months behind the language while every file in the tree looks current — the
analyzer derives what it knows from builtin/metadata.go, so registering a
builtin changes what the server teaches without touching a single file under
lsp/.
npm run check:lsp-bins is what stands in the way, and
mutant-vscode-extension/scripts/package-targets.mjs runs it before it publishes anything. It compares
the staged binaries against the Go sources of the whole module, not just
lsp/**, and — for the one target the machine running it can execute — asks
the binary for --version and compares that with what the sources build.
Mtimes are a coverage net for the other five; they lie after a fresh clone,
which is exactly when a stale server is hardest to notice.
Rebuild with lsp/build.ps1 or lsp/build.sh when cutting a release.
Changing the language#
There are two engines: the compiler/VM (compiler/, vm/) and the
tree-walking evaluator (evaluator/), which runs the REPL and macro expansion.
A new operator, operand type or piece of semantics must land in both, and
parity/ compares them on a shared corpus. A feature implemented in one engine
only is an incomplete change, not a follow-up.
Bytecode format changes touch three separate gob registration lists —
runner/runner.go, generator/generate.go and compiler/linetable_test.go.
Missing one means an artifact that fails to round-trip.
Debug information#
Debug facilities — line tables, parameter names, source text — go into
ByteCode/CompiledFunction unconditionally, and get cleared in
StripDebugInfo(). Do not gate them behind a flag, a mutation level, or
--dev: that couples debuggability to a security downgrade. mutant release is
the only place stripping happens. Anything that exposes runtime values must key
its gate on the debug info having survived.
Documentation#
Docs under docs/ are treated as part of the code. A change that alters
documented behaviour and does not update the document is incomplete — and if a
document describes behaviour the code no longer has, re-derive it rather than
deleting the section.
Style and tests#
- Go:
gofmt. Nothing exotic; the standard tool, standard settings. - Mutant source:
mutant fmt(--checkto verify without writing). - Comments explain why, not what. The codebase's convention is that a comment earns its place by recording a decision, a constraint, or a trap — not by narrating the line below it.
- Tests live beside the code as
*_test.go. A bug fix comes with a test that fails without it.mutant testruns*_test.mutfiles for language-level tests.
The full suite must pass:
go test ./...
cd lsp && go test ./...
Windows note: the repository has no
.gitattributes, so a checkout withcore.autocrlf=truerewrites line endings and makesgofmt -l ./...report every file. Format the files you touched rather than trusting a repo-wide listing, or setcore.autocrlf=input.
Pull requests#
Branch from
dev.maintracks releases.One logical change per PR. A refactor bundled with a feature is two PRs.
Commit messages carry the reasoning. The subject line is imperative and under ~70 characters; the body says what was wrong, what the change does, and which alternatives were rejected and why. Read
git logfor the register the project uses — this is a real expectation here, not boilerplate.Run the gates yourself — nothing runs them for you. At minimum:
CGO_ENABLED=0 go build ./... && CGO_ENABLED=0 go vet ./... && CGO_ENABLED=0 go test ./... go mod verify go run ./cmd/sweep # every example still runsThen the cross-compile matrix, which is the only place the pure-Go constraint is actually proved:
for t in linux/amd64 linux/arm64 windows/amd64 windows/arm64 darwin/amd64 darwin/arm64; do CGO_ENABLED=0 GOOS="${t%/*}" GOARCH="${t#*/}" go build ./... || echo "FAILED $t" doneSay in the PR which of these you ran, and on which OS. One person's machine is not three platforms, so a change that touches
//go:build-tagged code needs a reviewer on the other platform.Say in the PR description how you tested it. "Tests pass" is not a test plan for a parser or a security path.
Compatibility and stability#
Artifacts. The bytecode container is versioned (ByteCode.Version). A newer
runtime runs older artifacts: the v2.4.0 builtin registry, and the one slot where
v2.3.x's differed from it, are frozen in builtin/legacy_ordinals.go precisely
so artifacts from v2.3.0 to v2.4.x keep resolving. Artifacts from v2.2.x and
earlier are not refused, but one that calls a builtin does not run correctly:
their instruction set differs (compiler/bytecode_version.go says how).
The reverse is not supported and fails loudly by design — an old runtime handed
a new artifact trips a bounds check rather than silently calling the wrong
builtin. Any change to the container needs a version bump and a round-trip test
against an artifact built by the previous release.
Builtins. Names are resolved by name, not by registry position, so the
registry is no longer append-only — builtins can be renamed, reordered or
retired. What cannot happen is a silent change: a builtin whose contract is
withdrawn is marked deprecated in its metadata, names its replacement, keeps
working, and surfaces as an editor hint. Removal comes in a later major version,
never in a patch. Additions are the normal case and are not breaking.
The language. Syntax and semantics are stable within a major version.
Something still moving is marked experimental and says so in its documentation.
The CLI. Flags are stable within a major version. A flag being retired warns for at least one minor release first.
Releases and governance#
Releases are cut from main and tagged vMAJOR.MINOR.PATCH, roughly every
four to eight weeks — cadence follows what is ready, not the calendar, and there
is no fixed release date. Each release updates CHANGELOG.md,
which is the canonical record; longer narrative notes live in plans/.
Mutant is maintained by @aoiflux (Gaurav Gogia),
who has final say on scope, design and merges. There is no formal committee.
Design decisions that shape the project are written down in docs/ rather than
left in review threads, so a contributor can read the reasoning without having
been present for it.
The project is licensed AGPL-3.0 (LICENSE); contributions are accepted under the same terms. There is no CLA.
One area is currently closed to contributions: capability configuration — how a Mutant program's access to the host is declared or restricted — is under design, and no interface is specified. Please don't open a PR proposing a mechanism for it; it will be closed regardless of quality. Observations and use cases in an issue are welcome.