Contributing¶
This is the contributor's guide: how to set up a checkout, run the tests the way the project expects, read the ratchets that will fail on you, and get a change merged. It is written for a Go developer who does not yet know Magic engine internals or Forge. Unfamiliar words (IR, primitive, ratchet, chain head) are in the glossary.
Commands on this page were run on develop @ 513a2a0c3, 2026-10-21,
unless marked otherwise.
flowchart LR
setup["clone +<br/>fetch-cards"] --> wt["worktree<br/>(.cards linked)"]
wt --> test["failing test<br/>first"]
test --> fix["fix, keeping<br/>the invariants"]
fix --> gates["focused tests,<br/>ratchets, sim,<br/>TestHeads"]
gates --> pr["PR to develop"]
pr --> ci["ci green,<br/>review, merge"]
Prerequisites¶
| Need | Version | Why |
|---|---|---|
| Go | the go line in go.mod (go 1.25.8) |
with an older Go installed, GOTOOLCHAIN=go1.25.8 fetches the right one |
git, make, bash |
any recent | the Makefile and scripts |
| Node | 22.12 or newer, only for web/ |
the browser client build and its tests (web/README.md) |
No cgo and no C toolchain are needed; gorge has no third-party Go dependencies.
Clone and fetch the card corpus¶
git clone https://github.com/adams-shaun/gorge && cd gorge
make fetch-cards # Forge card + token scripts at FORGE_REF into .cards/ (about 140 MB on disk)
make compile-cards # parse them into the IR cache, .cards/ir-<fingerprint>.gob.gz
make report # corpus coverage against the implemented primitives
Getting started
shows what make report prints. The scripts come from gorge's fork
adams-shaun/forge (FORGE_REPO) at
the commit pinned by FORGE_REF in the Makefile; every coverage number and
golden test is relative to that pin. They are GPL-3.0, gorge is Apache-2.0,
so they live only in the gitignored .cards/ and are never committed,
not even a line pasted into a test. See
The three repositories.
When the pin moves, make fetch-cards also clears Go's test cache, because
the corpus lives outside the module and go test cannot see that it changed.
Optionally enable the repository's git hooks:
pre-commit (and pre-merge-commit) runs go run ./cmd/pkgmap -check when
you add, delete or rename a Go file, so a new package without a tier entry in
docs/site/architecture/packages.tiers.json fails at commit time rather than
in CI. pre-push runs the race detector, one package at a time, only when
you push main with RACE=1 set.
Work in a worktree¶
Make each change in its own branch, and if you keep several going at once,
in its own git worktree, so your main
checkout stays clean. Branch from develop, which is where changes land:
git fetch origin
git worktree add ../gorge-myfix -b myfix origin/develop
ln -s "$PWD/.cards" ../gorge-myfix/.cards # the corpus is untracked: link it in
cd ../gorge-myfix
A worktree without .cards/ reads green and tests nothing
A worktree carries only tracked files. Every corpus-dependent test loads
cards through testutil.CorpusRegistry(t), which skips when .cards/
is missing, so the package still prints ok, in a couple of
milliseconds, having run almost nothing. If a run looks suspiciously
fast, rerun with -v and look for
SKIP ... no .cards/ corpus present. CI refuses to run without the
corpus for exactly this reason.
Run tests focused, inside the budget¶
Run one package and one -run pattern at a time:
go test -run 'TestOmniscienceInHandGrantsNothing' ./engine/rules/
go test -v -run '^TestHeads$/^2seats$' ./engine/rules/
On a shared Linux machine with systemd, the maintainers cap every run at the per-test budget, and you can do the same:
systemd-run --user --scope -q -p MemoryMax=4G -p CPUQuota=400% \
env GOMAXPROCS=4 GOMEMLIMIT=3GiB \
go test -timeout 2m -run 'TestEveryRepoDeckIsFullySupported' -v ./engine/rules/
- The budget. Every single test fits 4 GB RSS, 4 vCPU and 1 minute of
wall time. A test outside it is a defect: split it into chunk tests that
share fixtures, never trim what it checks.
cmd/testbudgetenforces it in the post-merge suite (invariant 11). - Do not use
go test ./...as your inner loop.make testruns exactly that (with-p=2): the full suite, over 12,000 top-level tests, which CI and the post-merge run cover. - Never
-count=1. It defeats the test cache and buys nothing: Go already reruns a test whose inputs changed. - Load the corpus only through
testutil.CorpusRegistry(t), nevercards.LoadRegistry: the shared registry opens once per test binary, while a fresh one costs hundreds of MB.
Testing covers the test helpers, oracle scenarios, and what each ratchet failure means.
Lint¶
gofmt -l $(git ls-files '*.go') # must print nothing
go vet ./engine/rules/ # the packages you touched
go run ./cmd/gentypes -check # if you changed server/protocol
make lint runs all three across the tree, plus the web client's
svelte-check and eslint, so it needs Node and web/node_modules. CI's
lint job runs the Go half (gofmt, go vet ./..., gentypes -check).
The ratchets you will hit¶
A ratchet is a test whose expected value is a committed table and which fails in both directions: a new gap fails, and a gap your change closed is now a stale entry that fails too. You delete the entry in the same commit; you never add one. The why behind each is on Invariants.
| Fails as | Means | Do |
|---|---|---|
TestEveryRepoDeckIsFullySupported |
a card in the 30 repo decks (testkit/testutil/decks/) needs a primitive this build lacks, or a card listed in engine/rules/testdata/known-unsupported/ is now supported |
fix the regression, or delete the stale file |
TestEveryRepoDeckParamsAreRead |
the same, for script parameters no code reads (engine/rules/testdata/paramcensus/) |
same |
TestValueHeadRegistryMatchesEvaluator |
a count: value head is in the evaluator but not modelledValueHeads, or the reverse |
add it to both |
TestHeads |
the golden bot games at 2/4/6/8 seats ended with a different chain head | if intended, re-pin with GORGE_UPDATE_HEADS=1 go test ./engine/rules/ -run '^TestHeads$' and name the first diverging event (cmd/headdiff) in the commit message |
TestRepoDeckGamesReplayExactly |
a game's replay diverged from the live run | something wrote state outside events.Apply, or something nondeterministic reached an event |
internal/archtest (TestDependencyOrderHolds, TestTimeIsImportedOnlyByTheHost, TestRootModuleHasNoRequires, the layering_* tests) |
a forbidden import edge, a time import outside the allowlist, or a require in go.mod |
restructure the code; do not edit the allowlists |
TestClassificationIsComplete / go run ./cmd/pkgmap -check |
a package has no entry in packages.tiers.json |
classify it (Repository layout) |
TestKnownApproximationsOnlyShrinks |
AGENTS.md's Known approximations table grew |
it only shrinks; put a deviation in the commit message |
internal/codeshape (TestCodeShapeOnlyShrinks, TestLongFunctionsOnlyShrink) |
function length and interface width ceilings in engine/rules, engine/effects |
currently skipped (since 2026-10-06), but keep functions under 300 lines; go run ./cmd/codeshape -table measures |
TestNoForgeScriptsTracked |
a Forge script is tracked in git | remove it |
Commits and pull requests¶
Branches. Changes land on develop by pull request. When ci is green
on develop, a workflow opens a develop → main pull request that a
maintainer merges; nothing is pushed to main directly. Open your PR
against develop. (Branch flow.)
Commit messages.
- Subject
kind(area): what changed, as in the history:fix(effects): …,feat(bots): …,test(spellbench): …,docs(design): …. - The body says why: the rule you implemented with its Comprehensive
Rules number (
CR 113.6), what was wrong and where, and what you measured. If you counted how many corpus cards share the bug's shape, give the numbers (measure the class, not the card). - If
TestHeadsmoved, name the first diverging event and its cause. - Plain messages: no attribution trailers (
Co-Authored-By:, "Generated with …") and no issue-tracker references.
The pull request. The template (.github/pull_request_template.md) asks
for an overview, the changes by area, how you tested, and notes for
reviewers. make pr PR_BASE=develop pushes your branch and opens (or
refreshes) the PR with a generated change overview; it needs an
authenticated gh. (Not run for this page: it pushes.)
Checklist. Modelled on the first outside contribution,
786510b3f:
- [ ] A test that fails without your change, in the package you changed, named for the behaviour, and a control that shows the card still works where it should.
- [ ]
gofmt -lis empty for your files;go veton the packages you touched. - [ ]
go run ./cmd/gentypes -checkif you changedserver/protocol. - [ ] Focused
go testof the packages you touched, with.cards/present. - [ ]
make sim: every game printsreplay OK. - [ ]
go test -run '^TestHeads$' ./engine/rules/passes, or the heads were re-pinned with a named cause. - [ ] Any ratchet entry your change closed is deleted in the same commit.
What CI runs on your PR. lint (gofmt, go vet ./...,
gentypes -check) and test: the corpus is fetched at FORGE_REF, the
packages are split across six shards, Go's test cache from the base branch
lets unchanged packages report (cached), and TestHeads runs as its own
step. A PR that touches docs/site/ or mkdocs.yml also gets a strict
docs build.
Reproduce a feedback report¶
A player bug report filed from a game on a gorge server carries a replayable
snapshot (match.json, log.json, the seat's view.json). cmd/repro
turns it into a failing test:
go run ./cmd/repro <feedback-dir> # replay, verify the chain head, print the board
go run ./cmd/repro -list <feedback-dir> # the timeline of recorded answers (intents)
go run ./cmd/repro -at N <feedback-dir> # stop at intent N
go run ./cmd/repro -omniscient <feedback-dir> # show every hand
go run ./cmd/repro -emit-test ./engine/rules <feedback-dir> # copy the snapshot, write a failing test skeleton
Try it on the committed fixture:
== feedback report 20260914T120000Z-fb01 (table t1, match 1) ==
snapshot: captured: match.json log.json view.json
replayed 26 of 26 recorded intents (151 events); recorded head 67b55d2eb519629d
turn 2, round 1, main1 (step main1) — active: seat 0, priority: seat 0
...
Exit status 0 means the rebuilt event stream matched the recording event for
event. DIVERGED names the first mismatching event; a corpus pin change
since the report was filed is a real and expected cause, so read that event
before assuming an engine bug. The -emit-test skeleton uses
feedback.EngineAt(t, dir, n) from testkit/testutil/feedback; replace its
TODO with your assertion.
Documentation¶
This site is docs/site/, built by MkDocs Material from the root
mkdocs.yml and published from main. To preview it:
go run ./cmd/pkgmap > docs/site/architecture/_generated_packages.md # the generated package tables
uvx --with mkdocs-material==9.7.7 mkdocs serve # or: pip install -r docs/site/requirements.txt
The build is --strict: a broken link fails it. CI also parses every
Mermaid diagram in a headless browser (scripts/docs_mermaid_check.py),
because --strict does not. Link between pages with relative .md paths,
and to code as https://github.com/adams-shaun/gorge/blob/main/<path>.
Where next¶
- Your first card fix: one bug from report to merged fix.
- Testing: helpers, oracle scenarios and the ratchets in depth.
- The card pipeline: how a script becomes behaviour, and how coverage is measured.
- Repository layout: the tree, the tiers, and where a change goes.
- Invariants: the rules every change keeps.
- The three repositories: what lives in gorge-compliance and why gorge never imports a companion.
- Glossary.