Go to file T
Code
.github Gate native entropy and wasm compilation without another CI slot
assets assets: the wolf mark; readme carries it
ci Clarify the incidental shim-search match
docs Bump lupin to 0.1.56
examples docs: manual chapter 6, the place trace — schema version 1 and examples/pack.lu (doc-truth checked)
src Keep the site portability patch reversibly applied
tests Record the three newly served random corpus rows
tools test: keystroke evidence — rexpect PTY e2e (unix; Up recalls, Alt-. cycles, Ctrl-C survives #46) and the asked->bound->verified probe over every binding
vendor pin: re-vendor spec and corpus at wolf-lang v0.2.24 (294d626d) — two releases forward, an ancestor of the released line
.gitattributes seed: licenses, LF enforcement, readme
.gitignore ci: fmt/clippy/test/corpus/fixtures on three platforms plus an independence gate; conventions doc with the compiler track's platform lessons
.gitmodules org migration: tenseleyFlow -> wolffe-lang
CHANGELOG.md Describe entropy and the complete 0.1.56 delta
CONTRIBUTING.md CONTRIBUTING: CI and the slot budget after is78, tl29 and tl30b (the PR's own run is the gate; the footprints; #266)
Cargo.lock Bump lupin to 0.1.56
Cargo.toml Bump lupin to 0.1.56
LICENSE license: GPL-3.0-or-later (D41 as amended)
README.md Refresh the README version and corpus counts
build.rs build: stamp the host triple, LUPIN_HOST_TRIPLE (#182, #174)
rust-toolchain.toml is00: crate sources — omitted from the skeleton commit by an aborted pathspec batch
upstream @ 294d626 pin: re-vendor spec and corpus at wolf-lang v0.2.24 (294d626d) — two releases forward, an ancestor of the released line

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 against Cargo.lock.
  • Never read upstream/crates/ or upstream/xtask/. Not for reference, not to check an edge case, not to settle an argument. Read upstream/spec and 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/spec and upstream/corpus trees, 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:

  1. the input, reduced;
  2. both records, verbatim (conform-run --json from each side);
  3. the clause each reading claims to follow, by anchor;
  4. the class the comparison assigned (compare::Class).

Four registries keep filing cheap. They live in the code:

  • parse::CHOICES: every place spec/01 underdetermines 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 the run rung names one of these, and the anchor comes attached (conform-run --trace, and x-trap-clause on 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_meaning carries the six rows [conf.trap.map] states: E1001 for use-after-move, E1002/E1013/E1014 for exclusivity, E1004/E1005 for region-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 on x-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 are unsupported, 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 for acquire() or re"…" 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, not Added some validation for the trap kinds so that we can....
  • Never git checkout files 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 in tests/.
  • 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. .gitattributes pins * text=auto eol=lf and 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 (see wolf_interp::slash_path).
  • Sort read_dir output 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.toml names the toolchain, and CI installs it with an explicit rustup step 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.