Ctrl K
ComunidadGuías…

CONTRIBUTING.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Contributing to revl

revl gets two kinds of contributor, and this guide is written for both:

- **Humans**: reading the design, fixing the checker, adding a tier, writing
  docs.
- **AI agents**: the language's primary *author* is increasingly an agent, and
  agents also *contribute to the compiler itself*, in parallel, in isolated
  worktrees. The workflow below was built for that and is described literally,
  because an agent cannot infer it from repo vibes.

If you only want to *write* revl (not change the compiler), you are in the wrong
file: read [docs/guide-humans.md](docs/guide-humans.md) or
[docs/guide-ai-agents.md](docs/guide-ai-agents.md) instead. This file is about
changing revl.

Everything here is enforced, not aspirational: the pre-commit hook and CI run
the same checks, and a change that skips them will be caught at the gate.

## The one rule that shapes everything

> **The checker is the product.** revl sells exactly one promise: a component
> that would corrupt a running system cannot compile. Every contribution is
> measured against whether it keeps that promise provable. That is why the
> rejection suite is an executable spec, why emitted output never changes
> unreviewed, and why emitted code is handed to real compilers instead of
> trusted.

Read [DESIGN.md](DESIGN.md) §4 for the guarantee table (generated from
`revl.diagnostics.GUARANTEES`, so it cannot drift), and
[docs/rejections.md](docs/rejections.md) for the full set (the lifecycle rules
A1, A2, A3, A5, A6, A8 and A9, the confidentiality rules `G-SECRET` and
`G-SECRET-FLOW`, and the typing rules T1–T3) before changing the frontend. Read
[docs/backend-ir-v1.md](docs/backend-ir-v1.md) and
[docs/backend-ir-v3.md](docs/backend-ir-v3.md) before changing an emitter.

## Setup

```bash
git clone https://github.com/inso1337/revl && cd revl
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[test]"
pytest tests/                      # the frontend suite: should be green
```

(`uv venv && source .venv/bin/activate && uv pip install -e ".[test]"` is the
equivalent with uv. The PyPI name `revl` is an unrelated project: install from
source, never `pip install revl`.)

<!-- docgen:suite-size begin -->
The root suite, `tests/`, defines more than 10,000 test
functions (parametrization collects far more tests than that), and it
takes tens of CI minutes of test time across the shards, the sum of
`tests/shard_weights.json`. `tools/docgen.py --check` fails when either
claim stops being true; `--show suite-size` prints the exact figures.
<!-- docgen:suite-size end -->

**The fast frontend loop.** A single local run of the whole suite takes hours
on a loaded machine (253 test-minutes when issue #2217 measured it), so
frontend-only work (parser, checker, lowering, docs,
frontend-facing tests) does not pay for it every iteration. The same two files
cover most of it:

```bash
.venv/bin/pytest tests/test_frontend.py tests/test_doc_examples.py -q   # ~0.5s
```

Before committing, run the tests your change affects, not the whole tree
between every edit. The pre-commit hook already does this for you: it selects
the affected tests with `tools/affected_tests.py` and reports how many passed.
When the selector reports `FULL`, or is unavailable, the hook never runs the
whole suite: it runs a defined core subset (`CORE_TESTS` in
`tools/hooks/pre-commit`, override with `REVL_HOOK_CORE_TESTS`) under a
wall-clock ceiling (`REVL_HOOK_FULL_BUDGET`, default 60s), and says the full
suite did not run. If that subset fails, passes no test, or exceeds the
ceiling, the commit is refused with the selector's reason and the
`--no-verify` instruction. The hook never escalates to the whole suite. An
affected selection whose every test skips (a machine without a backend's
toolchain) does not block the commit, but the step prints `SKIPPED` and the
hook ends with "ok, but the affected tests verified nothing". The full suite
is CI's job, sharded there. For the fast inner-loop gate over the same
selection, run `make pre-merge-affected`; it prints the selector receipt and
stops before the full gate when the selector reports `FULL` or is unavailable.
Read the receipt, then run `make pre-merge` only when you deliberately want the
full gate.

**Where the code lives (a two-minute map).** The compiler is `src/revl/`
(`parser.py` → `typecheck.py` → `lower.py` → `apply.py`). The **emitters are
NOT under `src/`**: each backend is its own directory with its own `emit.py`,
and most have two or more *expression dispatchers* inside that file (component
vs fn-body renderers; wasm has three). When a change touches an IR expression
kind, you must patch every relevant dispatcher in
`backends/{python,typescript,rust,go,java,wasm}/emit.py`; each file declares
its dispatcher coverage in `EXPR_DISPATCHERS`/`EXPR_REFUSED`, checked by
`tests/test_expr_dispatcher_conformance.py`, so "did you patch both paths" is
a red test rather than a 15-minute grep. Tier runtimes and scenario harnesses
live in the same per-backend directory.

Install the pre-commit hook once per clone:

```bash
git config core.hooksPath tools/hooks
```

The hook (`tools/hooks/pre-commit`) is deliberately kept under ~20s: a hook
people wait a minute for is a hook people turn off. It runs the checks that have
actually broken CI (see [The pre-commit contract](#the-pre-commit-contract)).
Skip it for a single commit with `git commit --no-verify`; do not make that a
habit.

Per-tier toolchains are only needed if you touch that tier: the frontend suite
runs the pure-Python halves of every backend and skips the runtime-integration
tests cleanly when a toolchain is absent. To work on a tier you will want its
runtime:

- **python** tier: `sh backends/python/setup.sh` (builds the cordis-py venv)
- **typescript** tier: `cd backends/typescript && npm ci`
- **wasm** tier: `wasmtime` on `PATH` (CI pins v47.0.3)
- **rust** tier: a Rust toolchain (`cargo`), crates.io reachable
- **java** tier: JDK 21
- **go** tier: Go 1.26

## The branch model: waves, worktrees, integration into main

revl is developed in **waves**. A wave is one focused unit of work (a roadmap
item, a bug, a tier fix) done on its own branch, in its own git worktree, so
that many waves (human and agent) run in parallel without treading on each
other. The pattern, visible throughout the commit history:

1. **Branch from `origin/main` in a fresh worktree.**

   ```bash
   git fetch -q origin
   git worktree add /tmp/wave-<name> -b wave-<name> origin/main
   ```

   A worktree is a separate checkout sharing the one `.git`, but not its
   `.venv`: give it its own (`uv venv && uv pip install -e ".[test]"` inside
   it), or commit with a virtualenv active that has revl installed from this
   worktree. The pre-commit hook runs every check on one interpreter: the
   `REVL_HOOK_PYTHON` you set, else the active virtualenv, else the worktree's
   `.venv`, else `python3` on PATH. It never borrows the primary checkout's
   `.venv`, and it refuses an interpreter without pytest or one whose `revl` is
   installed from another checkout (`tools/hooks/resolve-python`). Work there;
   never on `main` directly.

2. **Do the work, commit on the wave branch.** Small, well-described commits.
   Commit messages are prose, present-tense, scoped
   (`fix(stdlib): …`, `docs(roadmap): …`, `test(swap): …`; match the existing
   log style).

3. **Integrate by merging `origin/main` into your branch, not by rebasing main
   onto you.** Before pushing:

   ```bash
   git fetch -q origin
   git merge --no-edit origin/main          # take origin's moves onto your branch
   # re-run the suite; a clean merge can still be a semantic conflict
   ```

   These integration merges are the `Merge remote-tracking branch 'origin/main'
   into wave…` commits you see in the log. Resolve conflicts on *your* branch,
   with the suite green, before the change reaches `main`.

4. **Fast-forward `main` only when it is truly an ancestor.** Guard the push so
   you never clobber a concurrent wave:

   ```bash
   git fetch -q origin && git merge --no-edit origin/main
   git merge-base --is-ancestor origin/main HEAD && git push origin HEAD:main
   ```

   If `origin/main` moved under you, re-fetch, re-merge, re-guard. **Never
   force-push `main`.**

5. **Clean up the worktree** when the wave lands:

   ```bash
   git worktree remove /tmp/wave-<name>
   ```

Human contributors from outside the core repo use the same shape through a
**fork + pull request**: branch, keep it merged up to date with `main`, and open
a PR. The pre-commit contract below is what a reviewer will check the PR
against, so run `./tools/pre_pr.sh --merge-ref` before the first push and again
before merge. The command checks the branch and a temporary merge with current
`origin/main`, including the tree-wide encoding guards, strict census
reproduction, and selector mapping pins; see [docs/process.md](docs/process.md)
for the output and failure handling.

## The pre-commit contract

Every change must pass these before it merges: the hook runs them locally and
CI (`.github/workflows/ci.yml`) runs the ones it covers again. A green local hook is the
minimum bar, not a substitute for CI, because the hook skips what your machine
cannot run.

1. **Frontend suite + emitted-code validation**: `pytest tests/ -q`. This is
   the core gate. It includes `tests/test_conformance_validate.py`, which hands
   each tier's emitted output to that tier's *real* compiler (`tsc`,
   `cargo check`, `javac`, `wasmtime`, a scope walk for python), because "the
   emitter did not raise" never meant "the code compiles." Runtime-integration
   tests skip cleanly when a toolchain is absent; **read the skips** (`-rs`), a
   skip is not a pass.

2. **The conformance matrix (emit)**: `python3 tools/conformance.py`. Pure
   Python, no toolchain, always runs: it catches a backend that stops emitting a
   construct entirely. See [docs/conformance.md](docs/conformance.md).

3. **The TypeScript backend suite**: `cd backends/typescript && npx vitest run`
   (and `npx tsc --noEmit`). Runs `tsc` over emitted code; the hook runs it only
   where `node_modules` exists.

4. **The backend golden tests**: each tier's emitter has a checked-in golden
   and a test asserting it reproduces it **byte-identically**:

   | tier | command |
   |---|---|
   | python | `sh backends/python/setup.sh && cd backends/python && .venv/bin/pytest -q` |
   | typescript | `cd backends/typescript && npm ci && npx vitest run` |
   | wasm | `pytest backends/wasm/test_v3_emit.py tests/test_wasm_backend.py -q` |
   | rust | `pytest backends/rust/test_emit_rust.py -q` |
   | java | `pytest backends/java/test_emit_java.py -q` |
   | go | `pytest backends/go/test_emit_go.py -q` |

   CI runs each tier in its own job with the toolchain pinned. If you touch an
   emitter, run its tier's goldens; the frontend suite alone does **not** cover
   every per-backend golden.

A change that adds or edits a test also carries its red-before / green-after
receipt: run `python3 tools/evidence_receipt.py --base origin/main --node
<node>` and put the block in the PR's Verification section. It names the base
failure line and the head pass line, and it refuses when the node passes on
base, so a reviewer never has to re-ask for hand-typed counts.

## The pre-merge gate

> **Run `make pre-merge` before a change reaches `main`.** It is the gate that
> catches the per-backend drift `pytest tests/` alone misses.

**If you are an agent working a branch, read
[docs/process.md](docs/process.md) first: it is authoritative for the
branch-and-PR flow and it says the opposite of what this section assumes.**
Under that process the PR jobs run in CI on the PR (the heavy jobs listed under
"Pull request descriptions" run after merge, or on the merge queue when it is
enabled), agents run only the
tests covering what they changed plus `./tools/pre_pr.sh`, and the orchestrator
merges on green. `make pre-merge` stays the local gate for anyone landing
without that CI round-trip, and `make pre-merge-affected` is the narrow
inner-loop form.

The pre-commit contract above is necessary but not sufficient, and the gap is
load-bearing: **the per-backend suites run OUTSIDE `pytest tests/`** (they are
their own CI jobs: `backend-python`, `backend-typescript`,
`backend-{go,rust,wasm,java}`, because each needs its own toolchain). So a
change that is green under `pytest tests/`, which is what a wave agent
self-verifies against, can still red CI on a per-backend suite and go unnoticed.
That is not hypothetical: on 2026-08-26 `main` stayed **red for ~2h across ~19
pushes** because item 247's A5 respec left a stale `backends/python`
semantics test and a ts fixture went uncommitted, both green under `tests/`,
both red on the per-backend CI jobs (roadmap item 327; see the
`revl-wave-backend-golden-gap` note).

There is a second, narrower version of the same gap, and it is the reason CI
has a `frontend-cordis` job as well as `frontend`. `pytest tests/` on a plain
`.[test]` install has no cordis-py runtime, so every test in `tests/` guarded on
`importlib.util.find_spec("cordis")` skips. Both halves are gated now: the
`frontend` job runs the suite WITHOUT a runtime (it must stay green on a bare
checkout) and `frontend-cordis` runs the same suite under
`backends/python/.venv/bin/python` after `sh backends/python/setup.sh`, which is
where the guarded tests actually execute. `make pre-merge` (and `--affected`)
runs that half locally too, in its `frontend-cordis` step (issue #2207, below).
Roadmap item 430 tracks what is excluded there and why.

`make pre-merge` (script: [`tools/pre_merge.sh`](tools/pre_merge.sh)) closes that
gap by mirroring the **fast half** of every per-backend CI job in one local
command, in order, reporting each step:

| step | what it runs |
|---|---|
| frontend | `pytest tests/ -q` (includes `test_conformance_validate.py`) |
| frontend-cordis | the files of every test that skipped naming cordis in the frontend step (tests/conftest.py logs them to `REVL_CORDIS_SKIP_LOG`), re-run by `tools/cordis_half.py` under the cordis-py venv: `$REVL_CORDIS_PYTHON`, else `backends/python/.venv/bin/python` of this tree, else of the primary checkout, whichever can import the suite's deps, `cordis` and `watchdog` and has its cordis-py checkout at `CORDIS_PY_PIN`. An empty log means nothing to re-run. With no such venv the step prints `MISSING, NOT OK` and the gate exits non-zero: a runtime half that did not run is never `ok` (issue #2207) |
| backend-python | `cd backends/python && .venv/bin/pytest -q`: the suite item 247 left stale |
| backend-go | `pytest backends/go/test_emit_go.py -q` |
| backend-rust | `pytest backends/rust/test_emit_rust.py` (emit/golden tests) |
| backend-wasm | `pytest backends/wasm/test_v3_emit.py backends/wasm/test_canonical_abi.py` |
| backend-java | `pytest backends/java/test_emit_java.py` (emit/golden tests) |
| backend-ts | `tools/regen_goldens.py --check typescript` (golden drift) |
| golden drift (all targets) | `tools/regen_goldens.py --check` (CI adds `--strict`: `lint` for the toolchain-free targets, `backend-go` for `go`) |
| conformance matrix + emit sweep | `tools/conformance.py --check-readme` |
| site wheel | `tools/check_site_wheel.py`: the playground wheel is NOT committed; `.github/workflows/pages.yml` builds it into the published site, and this checks that contract (also runs post-merge on main: `.github/workflows/site-wheel.yml`) |
| docs drift | `tools/docgen.py --check` (the source-derived doc blocks; `make docs-gen` regenerates) |
| lint | `ruff check` (pinned `ruff==0.16.4` via `uvx` if not on `PATH`) |
| census artifact | `tools/census_artifact.py --verify --strict`: CI's `census-artifact` job, run locally on the artifact's own input filter, so a diff that stales `docs/census-artifact/` reds here instead of on the PR. Adding a corpus document is enough to stale it (`corpus_dirs` includes `tests/fixtures`), and the test the selector used to pick for these paths skips the half that reds. The slowest step in this gate: a full census re-run, ~85 s on a shared dev box (issue #2103) |
| formal | `sh formal/scripts/run_gate.sh` (lake build + the axioms gate; loud-skips without lake) |
| ci-only suites | three kinds of CI step, read from `ci.yml` by `tools/ci_suites.py`: pytest on whole backend directories (`pytest backends/<tier>/`), vitest, and pytest under `REVL_CROSS_TIER_SLOW`, `REVL_SWEEP_ALL_TIERS` or a `REVL_REQUIRE_*` switch set by the step or its job (so the frontend job's `pytest tests/` under `REVL_REQUIRE_TIERS=go` is one). Each is opt-in by its CI job name and otherwise prints `SKIPPED (not opted in; CI job <job> runs it; opt in: --with=<job>)`. Opted in, it runs CI's command with CI's switches when the job's toolchain is present, else prints `SKIPPED` with what is missing (issue #2210) |

Two properties make it trustworthy rather than theatre:

- **It is the fast half on purpose, not a full CI replica.** The
  toolchain-*executing* tests (cargo build, javac + a cordis4j clone, wasmtime,
  `tsc`/`vitest`) stay CI's job; they are slow, networked, and SIGKILL on the
  shared dev box. The rust/wasm/java emit suites are run with the heavy compilers
  hidden from `PATH`, so their `which(<tool>)`-gated tests **skip loudly** and
  only the emit/golden half runs, which is the half that catches a stale golden
  or a respec'd emitter, the exact item-327 drift class. The typescript
  `vitest`/`tsc` suite needs `node_modules` and is not in the fast gate; its
  pure-Python goldens are (the `backend-ts (golden drift)` step). The
  toolchain-executing suites are still listed, one `SKIPPED` line each, and
  run on request: `sh tools/pre_merge.sh --with=backend-typescript,conformance`,
  `--with=all`, or `REVL_PREMERGE_WITH=all make pre-merge`.
  `sh tools/pre_merge.sh --plan` prints what each step would run and runs
  nothing.
- **Nothing is a silent skip.** Every step is either *ran* (and must pass) or
  *skipped* with the reason printed (`SKIPPED (no backends/python/.venv; run sh
  backends/python/setup.sh)`), because a silent gap is precisely what let CI stay
  red. The target exits non-zero if any step that ran failed; a skip does not
  make it pass green quietly: it tells you what CI will still check that you did
  not. On a fully-provisioned checkout nothing skips.

If you touched an emitter or a per-backend suite, `make pre-merge` is the
difference between "green on my machine" and "green on `main`". Run it before you
fast-forward `main` (step 4 of the branch model above). It is not a substitute
for the orchestrator's post-merge `gh run list --branch main` check; it shrinks
the window that check has to catch, it does not remove the check.

### Two invariants that will fail your change if you break them

**Goldens are snapshots, reviewed.** Every emitter has a checked-in golden for
the reference composition (`backends/<tier>/golden/user_cache.<ext>`), and the
suites assert the emitter reproduces it byte for byte. A change that alters
emitted bytes fails the suite until the golden is regenerated and its diff is
reviewed; the invariant is that output never changes *unreviewed*, not that it
never changes (see "Golden policy" in
[docs/conformance.md](docs/conformance.md) and
[docs/stability.md](docs/stability.md)). Do not "fix" a failing golden by
regenerating it to match your change; understand why it moved first. New IR
capability goes under a new `ir_version`, not into an existing one.

**Every rejection joins the executable spec.** This is the rule that keeps the
soundness promise self-proving. When you make the checker refuse something it
used to accept (closing a hole, adding a guarantee), you must:

1. add a minimal fixture under **`examples/rejections/`** (e.g.
   `g4_my_new_hole.rvl`) that triggers the rejection;
2. add a row to the **`REJECTIONS`** table in
   **`tests/test_frontend.py`** mapping that filename to the exact diagnostic
   substring the checker must produce.

`tests/test_frontend.py::test_every_rejection_file_is_covered` asserts the set
of files on disk **equals** the set of keys in `REJECTIONS`, so you cannot add a
fixture without a spec entry, or an entry without a fixture. The rejection suite
is therefore the checker's *definition* of sound, exhaustive by test, not a
sample. (The handful of guarantees that no source program can violate at compile
time (G5, G7, A3, A4, A5, A7) are documented in the block after the
`REJECTIONS` table, with where each is actually enforced. If you believe you
have a new such case, extend that block; do not leave it implicit.)

A diagnostic is itself a deliverable ([DESIGN.md](DESIGN.md) §9): it must name
its guarantee and state the fix, and it is written for the model that will read
it. A rejection whose message is a bare "error" is not done.

## Pull request descriptions

Describe validation precisely:

- Name the local command and its result. For CI, name the job and event, and
  report its result. A job skipped by its `if:` condition or changed-path
  filter is not a pass.
- Branch protection requires `lint` and the six `backend-*` jobs
  (`backend-python`, `backend-typescript`, `backend-go`, `backend-rust`,
  `backend-java`, and `backend-wasm`); `.github/workflows/ci.yml` defines them.
  The backend jobs are path-filtered and may skip when no changed path can
  affect them. `root-suite-affected` selects tests by changed paths and may fall
  back to the full set.
- The `ci.yml` jobs excluded from `pull_request` events are `frontend`,
  `frontend-cordis`, `conformance`, `formal`, `temporal-exit`,
  `sandbox-container`, and `sandbox-microvm`. They run on a `merge_group`
  candidate when the merge queue is enabled in branch protection. Without the
  queue, they run on pushes to `main`, the schedule, `workflow_dispatch`, and
  `workflow_call`, as their conditions allow. Other workflow files have their
  own triggers; check them before describing their results.
- If a check did not run locally, say why and where it runs, including whether
  it can run locally with the needed toolchain. For a check that runs only
  after merge, state whether a failure would be fixed forward or reverted.

Read the job's `if:` and the workflow's `on:` before claiming that a check
ran before merge. A skipped job is not evidence of a pass.

## Adding or changing a backend tier

- Read [docs/backend-ir-v3.md](docs/backend-ir-v3.md) (and
  [docs/backend-ir-v1.md](docs/backend-ir-v1.md) for the v1 core). The
  frontend is the single IR producer; a backend consumes IR and must gate on
  `ir_version`, refusing a version it does not implement rather than
  misemitting.
- Keep the tier honest with the conformance matrix: `tools/conformance.py` emits
  every construct through all backends, and `--validate` hands the output to the
  real toolchain. A new gap must show up there, and a baselined failure that
  starts passing must be un-baselined (`tests/test_conformance_validate.py`
  fails both on new breakage and on a baselined case that starts passing, so the
  list can only shrink).
- A tier's runtime-integration tests should **skip loudly** without their
  toolchain, never silently pass. CI installs the toolchain and, for the tiers
  where it matters, makes a missing runtime *fatal* (e.g.
  `REVL_REQUIRE_WASMTIME=1`) so coverage is real and not implied.
- **A test that drives `revl.test.RUNNERS` must be run by a CI step that sets
  `REVL_REQUIRE_TIERS`** for every tier it names (issue #266). The switch takes
  a comma-separated list of tiers (or `all`); on those tiers an *absent
  toolchain* stops being a skip and becomes a failure, while a tier's by-design
  refusal of a document still skips. Unset (a laptop, a fresh worktree) keeps
  the old behaviour, so nothing about local development changes.

  The `conformance` job is where both halves exist (`sh
  backends/python/setup.sh` for cordis-py, `npm ci` for cordis-ts), and it runs
  the tier tests with `REVL_REQUIRE_TIERS: "py,ts"`. Add a new tier test to
  that step's file list;
  `tests/test_env_gated_skips_run_somewhere.py::test_every_tier_test_is_required_to_actually_run_in_some_ci_job`
  fails if you do not. Without it, `test_..._executes[ts]` executes nothing and
  reports green everywhere the toolchain is absent, which is how a live
  `TypeError: Cannot mix BigInt and other types` sat under a green ts test.

Some Cordis runtime targets are upstream projects (cordis-py, cordis, cordis-rs,
cordis4j, stc-go): a genuine *runtime* defect there is fixed upstream
(fork + PR to that repo), while an *emitter* defect is fixed here in
`backends/<tier>/emit.py`. Fix the emitter for an emitter bug; do not fork a
runtime to paper over a lowering mistake.

## Documentation changes

Docs are load-bearing and public-facing: accuracy matters more than polish.
Match the existing tone: precise, honest about what is tested vs. demonstrated
vs. claimed, and specific about the command that backs a claim (a number with no
command behind it is the failure mode this project keeps hitting). The roadmap
([docs/v2.0-roadmap.md](docs/v2.0-roadmap.md)) is the ledger of what is done and
what is in flight; keep it current when you land a roadmap item.

## Tracking work: issues, the roadmap, and security findings

The roadmap decays because its state is prose that somebody has to remember to
edit. On 2026-09-02 twelve per-finding markers still read ``FIXING on
`fix/<branch>` `` for branches that had already merged, five items were closed
behind a stale in-progress marker, one open item was a duplicate of a landed
one, and two agents fixed the same defect in parallel because neither could see
the other's scope. `make roadmap-check` (`tools/check_roadmap_markers.py`, run
in the `lint` CI job) now fails when a marker contradicts git. Four rules:

Git only answers questions about branches and shas, and on 2026-09-15 five
agent reports each opened by correcting a roadmap sentence that git agreed with
and the working TREE did not: a test renamed out from under its citation, a
function lifted into a shared module while four paragraphs kept pointing at the
old file, a named xfail registry emptied because its gap closed. `make
roadmap-claims` (`tools/check_roadmap_claims.py`) resolves the roadmap's
citations against the tree instead, and its `--check` line runs in `lint`.
`tools/roadmap_claim_allowlist.json` carries the citations that name a sibling
project, each with a written reason. A cited path is judged whether or not it
carries a `:line`, and whether it names one file or a glob of them: 489 of the
document's 508 backticked path citations, against 75 before issue #1233. Line
numbers are still never judged, because a drifted coordinate is not a false
claim about the tree. The three shapes that are not judged, and the count that
decided each, are in the tool's docstring.

A citation that is stale and needs a roadmap EDIT does not go in the allow-list.
It goes in `tools/roadmap_claim_ratchet.json`, which records it with the
sentence and the edit that clears it, so that widening a rule does not red the
branch it lands on. That file is debt and is built to come down: its `count`
has to be restated on every change, and an entry that stops matching fails
`tests/test_roadmap_claims_gate.py`, so correcting the sentence forces the entry
out. At zero entries the file is deleted.

1. **State lives in GitHub issues. The roadmap holds the reasoning.** Whether
   something is open, assigned, in flight or closed is a tracker's job, and a
   tracker updates itself. The roadmap keeps what a tracker is bad at: the
   argument, the reproducer, the evidence, the negative results, and the
   cross-references between findings. The migration is done:
   `tools/check_roadmap_markers.py --require-issue` runs in the `lint` job, and
   every open or partial item has to cite its issue, or, for a security item
   whose finding must not be a public issue (rule 4), its private security
   advisory (a GHSA id or the advisory URL).

2. **A fix records which instances it covers, and which it does not.** This is
   the one thing no tracker fixes for you. A merged branch is not a closed
   finding: a cordis guard closed one test while 47 files stayed exposed, and a
   backend-suite fix de-collided one pair of roots while `pytest backends/`
   still died at collection. When you land a fix, write down the scope you
   actually swept and the instances you knowingly left, by name. "Fixed" with no
   scope is the sentence that costs the next person a full re-investigation.

3. **Say what is still open in the same breath as what is closed, and say
   which tiers.** Git can only answer questions about branches and shas, and
   five failures on 2026-09-02 were invisible to it. Five more checks exist
   for them, each behind its own flag, all five under `make roadmap-check-all`.
   What they ask of a writer:

   - A header that claims closure must not have open findings under it, and a
     closed finding must not contradict itself in its own body. Item 422's
     header read "ALL SEVEN FINDINGS FIXED" while its own body said F1-F4 were
     untouched on the typescript tier, and a disclosed CRITICAL sat unowned
     because the header was believed. Qualify the claim instead: "fixed on the
     py tier, F1-F4 still open on the ts tier" passes, because it is true.
   - "Folded into X" tracks something only if X exists, is open, and is owned.
     Item 425 F3 and item 427 F5 both pointed at item 416's block (c), a list
     of unexecuted hypotheses with no owner, so the residual was closed
     nowhere and nobody was working it.
   - An open finding names a branch, an issue, an owner, or the sha that fixed
     it. The sha you re-verified it on does not count: that records that the
     finding is still live, not that anybody is closing it.
   - One item NUMBER carries one block per section. Numbers restart per
     section by design, but two blocks under one number inside a section is
     always a defect: merge `2d2fa07f` kept both sides of a hunk and left a
     stale `106. 🚧` header above the `106. ✅` entry that had
     replaced it, and that one line was the only reason a landed item read as
     open.
   - A fix that touches one `backends/<tier>/` path says which other tiers it
     does not cover. Three defects were fixed on the python tier on 2026-09-02
     and left live everywhere else. No gate can decide semantic parity;
     naming the other tier is the whole discipline, and it is also the escape
     hatch that silences the check honestly.
   - **A marker must not say work is IN FLIGHT on the branch the PR is from.**
     ``FIXING on `fix/277-rust-vec-char` `` is green while that PR is open,
     because the branch exists; the merge deletes it, so the marker the PR
     introduced is stale on landing. `lint` runs on branch tips, so that reds
     main *and* every open PR whose merge-ref carries the new text, each of
     which then has to be retriggered by hand. Four occurrences on 2026-09-02.
     Cite the PR instead (`PR #175`), or once it has landed, the merge sha.
     `--head-branch` checks this: CI passes `github.head_ref`, which is empty
     on a push to main, and `tools/pre_pr.sh` passes your current branch
     before the PR is even opened. Naming your own branch in the PAST tense is
     fine and does not trip it: ``LANDED SO FAR (`fix/391-selfhost-parity`)``
     stays true after the branch is gone, and that is the whole difference.

   Rule 3 of `tools/check_roadmap_markers.py` (a cited sha must be a commit in
   this repo and reachable from the base ref) is the one check that needs
   HISTORY rather than the file alone, so it says out loud what it managed to
   look at. On a shallow checkout it deepens the clone once (`--no-fetch`
   declines) and prints `Checked N of the M commit citation(s) ...`; if it
   still cannot examine every citation it says so and says how many, and does
   not print `roadmap markers OK`. Do not read that sentence as a finding
   against your roadmap: it is a statement about the checkout.

4. **Security findings go to private GitHub Security Advisories, never public
   issues.** See [SECURITY.md](SECURITY.md). This repository is public and the
   roadmap already carries working reproducers, so this is a going-forward rule,
   not a claim about what is already out there. Note plainly: deleting a
   reproducer from HEAD does not unpublish it. Git history retains it, as do
   forks, clones and mirrors. Treat anything already committed as published, and
   decide about it on that basis rather than on a deletion that changes nothing.

5. **A closing keyword next to an issue reference closes that issue, even in the
   sentence that says it does not.** GitHub scans the PR body, the PR title and
   the commit message for `close`, `closes`, `closed`, `fix`, `fixes`, `fixed`,
   `resolve`, `resolves` and `resolved` followed by a `#` reference, and closes
   that issue on merge. The scan is not markdown-aware and does not read
   negation: backticks, a fenced block, an HTML comment and a "does not" in
   front of it are all invisible to it. On 2026-10-07 this fired twice here. PR
   #2128's body argued that its change was not the closure of a parser family;
   the family tracker is issue #2094, and it was closed two seconds after the
   merge. PR #2133's body carried the same denial about the replacement tracker,
   issue #2131, and closed it the same way. Both had to be re-opened by hand.
   The second did not stop at the body: #2133 was a single-commit PR, so its
   squash adopted that commit message, and the denying sentence now sits
   permanently in the history of `00fdd6059`. Put the reference where no keyword
   can reach it (`refs #2131`, `#2131 remains open`, `leaves #2131 open`) or
   phrase it with the reference first: "#2131 is not closed by this PR" is safe,
   because the keyword is no longer the thing nearest the `#`. The residual risk
   is replay rather than the merge itself: text that already fired once does not
   fire again on its own, but if that sha is carried into a new merge (a rebase,
   a cherry-pick, a branch based before it), the parser reads it as new. Nothing
   offline guards this: `tests/test_roadmap_issue_state.py` is deliberately
   network-free (issue #2100) and reports no verdict without evidence, so an
   issue closed this way reds no gate. If a tracking issue closes and no merge of
   yours explains it, read the commit message of whatever landed last.

## Reporting bugs and requesting features

Use the issue templates (bug report / feature request). A bug report that
arrives with the `.rvl` source, the target tier, and the emitted/expected output
is triageable in minutes; one that arrives as prose is a day of archaeology. The
templates ask for exactly what a maintainer needs; please fill them in.

For a **security or soundness escape** (a program the checker *accepts* that
violates a guarantee), do **not** open a public issue. Follow
[SECURITY.md](SECURITY.md).

## License

By contributing, you agree that your contributions are licensed under the
license that covers the path they touch: AGPL-3.0-only for the compiler, gate
and tooling, MIT for the stdlib, runtime shims and examples. The map is
[LICENSING.md](LICENSING.md). Copyright in the repository is held by Thomas
Moussajee; the names and logo are covered by [TRADEMARK.md](TRADEMARK.md).