wolf-interp Public
trunk
Code
Use Git or checkout with SVN using the web URL.
No matching headings.
Contributing to wolf-interp
Orientation
src/ is the implementation: the frontend, sema-lite, the evaluator with
its region and provenance machines, and the differential tools. tests/ is
the suite that makes the implementation defensible. upstream/ and
vendor/upstream/ are the pinned spec and corpus, consumed as data. docs/
holds the engineering documents and the user manual, indexed by
docs/README.md. The package is wolf-interp; the binary it builds is named
lupin (is12).
The independence doctrine in two sentences: this repository shares no code with the wolf compiler, and the two implementations meet only through the pinned spec, the corpus, and the observation protocol. Disagreement between them is the product.
The gates, all green before any push:
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
cargo run -- corpus
Touched a #[cfg(...)] arm? Run clippy for the other hosts too.
cargo clippy --target x86_64-pc-windows-msvc --all-targets -- -D warnings
cargo clippy --target x86_64-unknown-linux-gnu --all-targets -- -D warnings
CI is a three-host matrix and -D warnings is a gate on each of them, so an
import or a binding used only inside #[cfg(unix)] is dead code on windows
and red there while every local gate is green. Add the targets once
(rustup target add x86_64-pc-windows-msvc x86_64-unknown-linux-gnu); each
check then takes about a minute, far less than a round trip through the
runner. is36 learned this landing [os.net.unix]; is35 learned the same
thing one lint over, from a walk that read GitHub's checkout path. Local
green is not green.
cargo test includes the doc-truth harness (tests/doc_truth.rs). Every
command/output pair in README.md and docs/manual/ runs against the built
binary and is byte-compared, so a doc edit that invents output fails CI.
The independence doctrine
If you are tempted to import it, reimplement it.
wolf-interp shares no code with the wolf compiler. Not the lexer, not the
parser, not the type checker, not the directive parser, not the protocol
types. The two implementations are comparable only through
spec/06-differential-protocol.md, and that comparison is worthless if both
sides run the same code. A divergence is the product. Convenience sharing is
how the product dies.
What that means in practice:
- Never add a dependency on a
wolf_*crate or a git dependency on the wolf-lang repository. CI enforces this againstCargo.lock. - Never read
upstream/crates/orupstream/xtask/. Not for reference, not to check an edge case, not to settle an argument. Readupstream/specand reimplement. If the spec is silent or ambiguous, that ambiguity is the finding:[proto.cmp.triage]makes the spec document the defendant first. - The only permitted coupling is shared data: the pinned
upstream/specandupstream/corpustrees, and the protocol schema they define. - No shared test infrastructure with wolf-lang. The directive parser least of all: its independent reimplementation is the thing that catches a grammar drift.
When your reimplementation disagrees with the compiler, do not open the compiler's source to find out why. Write the disagreement down. That is a sprint deliverable.
The divergence-filing rule (standing)
Any input where this parser and the compiler disagree becomes a wolf-lang spec issue, with both readings attached. The grammar document is amended and both implementations follow the amendment. The two parsers never reconcile by private agreement, and neither one is patched to match the other before the clause is fixed.
Triage order is normative ([proto.cmp.triage]): the spec document is the
defendant first. An ambiguous clause is presumed the root cause until the
clause is shown unambiguous; only then is the implementation that disagrees
with it the defendant. This is the mechanism by which differential testing
hardens the spec. Skip it and a spec bug turns into two implementations
quietly agreeing on something undocumented.
What to attach to the issue:
- the input, reduced;
- both records, verbatim (
conform-run --jsonfrom each side); - the clause each reading claims to follow, by anchor;
- the
classthe comparison assigned (compare::Class).
Four registries keep filing cheap. They live in the code:
parse::CHOICES: every placespec/01underdetermines the parse, and what this implementation chose instead. A divergence that lands on one of these is a spec gap with a candidate amendment already written.parse::CHOICES_RESOLVED: the choices a spec amendment has since closed, each naming the clause that now decides it. Filed gaps get a visible fate; deleting a resolved row would erase the evidence that the pipeline works.diag::UNPINNED_CODES: every diagnostic code this implementation invented. The corpus pins a handful (check: fail(CODE)); the rest are guesses for the s10 catalog to overrule. Disagreement here is expected, and it is the intended input to that catalog.eval::rules::Rule: every dynamic rule, with the clause it enforces. A divergence at therunrung names one of these, and the anchor comes attached (conform-run --trace, andx-trap-clauseon trap records).
The pipeline itself is exercised in tests/divergence.rs against a seeded
counterparty, because a filing rule nobody has run is a filing rule that does
not work.
Verdict classes that are not divergences
src/ledger.rs sorts an observation against a corpus expectation into five
classes. Three of them are neither a match nor a finding:
- dynamic-counterpart: the corpus pins a static code whose dynamic meaning
the spec states, and this machine trapped with exactly that kind.
ledger::dynamic_meaningcarries the six rows[conf.trap.map]states:E1001foruse-after-move,E1002/E1013/E1014forexclusivity,E1004/E1005forregion-fault. Nothing else may be added without a clause to cite. - conservatism: the corpus expects a static rejection this implementation does not perform, and the program ran. Expected by construction (the sema boundary), ledgered, never green-washed.
- out-of-scope: verdict
unsupported, with the reason onx-unsupported.
A mismatch is a finding and is never explained away in code. Do not "fix"
a mismatch by editing the corpus: the corpus is a pinned input, and
[proto.cmp.triage] puts the spec in the dock first.
The sema boundary
The interpreter implements full dynamic semantics and only the static analysis needed to run programs. There is no type checker, no borrow checker, no region checker. Consequences worth stating for anyone adding code:
- A property the static tier owns never becomes a trap. The trap
vocabulary is closed at twelve kinds, and each one is a fault of a defined
execution (
[conf.trap.map]). Wrong arity, a wrong-typed operand, an unknown field: those areunsupported, with a reason naming the code that owns them. Turning one into a trap would put a static rejection in the run rung and corrupt the differential comparison in the worst direction. - The dynamic machine is deliberately more permissive than the compiler's static checkers. The approximation is one-way: the compiler accepting a program obliges this machine not to fault on it, and the converse is never implied.
- A std name with no pinned semantics is declined, not guessed. The
ambient prelude (
eval::builtin) mirrors the compiler's names-only stub. Inventing behavior foracquire()orre"…"would set this implementation's guesses against the compiler's guesses, and the resulting "divergence" would be about nothing.
Commits
Mirroring the compiler track's conventions:
- Commit in chunks. One logical change per commit. A refactor and the feature it enables are two commits. A toolchain bump or a submodule pin bump is always its own commit.
- Terse, imperative subjects, under ~250 characters unless the change
genuinely needs elaboration:
directive: reject unknown trap kinds, notAdded some validation for the trap kinds so that we can.... - Never
git checkoutfiles that were written but not yet committed. Stage or stash first. (This has eaten work before.)
Tests are first class
- Every behavior worth having is worth a test. Every bug fix arrives with the test that would have caught it.
- Unit tests live beside the code in
#[cfg(test)] mod tests. Tests that need the pinned corpus, the built binary, or a snapshot live intests/. - CI counts the same way: a red CI is a stop-the-line event, never a thing to merge around.
The snapshot ritual (insta)
Snapshots live in tests/snapshots/. When a snapshot legitimately changes:
INSTA_UPDATE=always cargo test # rewrite the .snap files
git diff tests/snapshots/ # READ the diff. This is the review
cargo test # verify-clean run against the new snaps
The middle step is the whole point. INSTA_UPDATE=always makes any diff
disappear, including the one that was a real regression, and reading the diff
is what turns that hazard into a workflow. Never commit a .snap.new or
.pending-snap file.
Design snapshot content deliberately. Corpus bytes are canonical output of
wolf fmt (STYLE_VERSION 1), so a snapshot embedding corpus text churns
wholesale when the style version bumps, for reasons having nothing to do with
this repo. Prefer test-owned inputs, as tests/snapshot_exemplar.rs does.
tests/frontend_snapshots.rs splits the difference on purpose. Its fixtures
are test-owned, so they churn only when the lexer changes, and a handful of
corpus-derived snapshots are kept deliberately, because "the tokenizer agrees
with the canonical program" is worth a regression test. When those churn on a
STYLE_VERSION bump, review the diff for span shifts only, then accept.
Spec-derived tests
tests/spec_extract.rs re-reads the pinned spec/01-grammar.md at test time
and diffs it against the transcriptions in lex.rs and parse.rs: the
keyword list and its checksum, the contextual keywords, the §3.2 precedence
table, the counter-examples, the §9 code reservations. Do not "fix" a
failure there by editing the test. A failure means the pinned spec and this
implementation disagree, which is either a pin bump you have not absorbed or a
transcription error. Absorb it or fix the transcription.
Platform lessons (inherited from the compiler track)
These were paid for once already. Do not re-derive them.
- Line endings are protocol surface.
.gitattributespins* text=auto eol=lfand it stays that way: spans are byte-exact, and a Windows CRLF checkout silently shifts every offset in the file. - Never seed an RNG from a raw file path. Normalize separators first. A
Windows
\explored an unvetted seed space compiler-side. More generally: no raw platform path ever reaches a record, a report key, or a hash (seewolf_interp::slash_path). - Sort
read_diroutput before it can influence any generated artifact. Directory order is platform noise; the corpus walk sorts at every level and again at the end. - Pin CI toolchains explicitly.
rust-toolchain.tomlnames the toolchain, and CI installs it with an explicitrustupstep instead of trusting the runner image's default or an implicit proxy install. - The corpus is read-only. wolf-interp never reformats, rewrites, or
"fixes" a file under
upstream/. A corpus file that looks wrong is a finding to report upstream, not a patch to apply locally.
CI and the slot budget
The org is on GitHub's Team plan since 2026-10-20: 60 concurrent jobs
org-wide, still only 5 of them macOS, shared by every repo. wolf-interp's
whole matrix is 21 jobs (ubuntu, macOS and windows, three test shards and a
ladder job each, the windows smoke, the bundle compared across the OSes;
about 1,400 job-minutes and three hours a run), so which event runs what
is decided by the plan job in .github/workflows/ci.yml (ci/tiers.py,
held by ci/tiers-test.py; is75, is78, tl29, tl30b):
| event | tier | what runs |
|---|---|---|
every push of a pull request that changes code (opened, synchronize, reopened), a push to trunk, workflow_dispatch |
tier full, the merge gate |
ubuntu's three shards and ladder in full, one macOS and one windows smoke (build, fmt, clippy, every test target except the heavy ones SMOKE_OMITS names, the fast ladder rungs; 30-minute cap), shard coverage, independence, the dist floor: 13 jobs, about 400 job-minutes, two hours |
the nightly schedule (04:23 UTC), gh workflow run CI --ref <branch> -f tier=release, a pull request carrying the release-matrix label |
tier release |
every job on every OS: the macOS and windows heavy shards and ladders, and the bundle compared across all three |
| a pull request that changes only prose, labelled or not | tier noop |
nothing: the plan and the verdict |
any label added but release-matrix (full-matrix included) |
tier noop |
nothing |
gh workflow run CI --ref <branch> -f jobs=plan-test |
tier partial |
the jobs it names (a plant); with -f entries="windows/2", only those test entries |
A merge therefore does not prove that the heavy targets pass on windows or
macOS, nor that the OSes' bundles are identical: the nightly does, every
night, and a red nightly opens a nightly-red issue that the next lane on
wolf-interp owes before its own merge. A release lane runs tier release
at the head it tags.
A push to a pull request cancels that branch's older run of the same group (the gate's, or the release run's); a label's noop run has a group of its own and cancels nothing; a trunk push, the nightly and a dispatch each run in a group of their own, so none is cancelled and none is replaced while it waits.
The tiers and the merge rule (tl21, tl30b)
Every run's last job is named for what the run was, and is red unless the
plan succeeded, every job the plan chose succeeded and every other job
was skipped: tier full (the merge gate), tier release (every
job on every OS), tier noop (nothing ran), tier partial (a
dispatch that named jobs).
A PR merges only with a green tier full (or tier release) at its
head, and that is the pull request's own run at its final head
(ruling #101 C): no label is needed, and wolf/tools/lane-audit.sh reads
wolf-interp as a verdict-only repository. Until tl30b the pull request's
push ran tier pr, the same jobs and entries since tl29, and the
full-matrix label ran them again for the merge; adding the label now
starts a noop.
A PR declares its footprint on a Footprint: line in its body (ruling
#104; sprints/RULES.md §6–§7 in the planning repo): notes (prose only:
the audit alone, no CI wait), module (one module or the program: the
lane's local gauntlet and the pull request's green run; the trunk push's
run after the merge is the definitive answer), or cross-cutting (the
spec, the runtime, the loader, CI itself; the default when the line is
missing). On wolf-interp every code PR's run is the full gate, so module
and cross-cutting wait for the same run; notes waits for none.
Prose is narrow, and decided by path (ci/tiers.py, held by
ci/tiers-test.py): CHANGELOG.md, CONTRIBUTING.md, docs/*.md and
the lane notes ci/*.md. README.md, docs/manual/ and docs/repl.md
are not prose: cargo test runs their commands and reads their anchors.
Nothing under vendor/, upstream, src/ or tests/ is prose, whatever
its extension. A changed path no rule names runs the full gate; add the
rule in the same PR.
Before you push
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
cargo run -- corpus
All four green, on a clean tree, with the submodule initialized.