# Contributing

hecks is a language whose declarations are meant to be checked, and
that sets what a contribution should look like. The interesting bugs here
are rarely "it crashed"; they are "the declaration says one thing and
the runtime quietly did another." Read
[Verification](docs/implemented/guides/verification.md) before you
open a PR that touches the language surface, the IR, or the runtime —
it explains the four tools this project uses instead of trusting a
green suite by itself, and a PR that skips them is a PR someone else
has to re-verify by hand.

## Getting running

```sh
git clone https://github.com/heckslabs/hecks
cd hecks
bundle install
bundle exec hecks console   # boots the pizzas example, drops you into IRB with its Ruby adapter installed
```

Postgres is optional for most of the codebase — the suite and
`hecks console` both default to the in-memory adapter. You only need a
local Postgres for `PostgresEra`-flavored specs and the schema-evolution
guide's own live example; those specs check their own reachability and
skip themselves quietly if nothing answers on `localhost`.

## Running the suite

```sh
bundle exec rspec                 # the whole suite, split across your machine's cores
bundle exec rspec spec/foo_spec.rb:12   # any argument runs serially, in this process
HECKS_SERIAL=1 bundle exec rspec  # the whole suite, in one process
bundle exec rubocop -c .rubocop.yml
```

`.rubocop.yml` is RuboCop's defaults plus a short list of deliberate
overrides, each with its reason beside it and the whole list argued in
`docs/decisions/0091-rubocop-defaults-with-deliberate-overrides.md`.
A cop that is not on that list runs at its default, so new and changed
code is held to it, and no file is excluded: fix an offense rather than
parking it. Fix `Style/GuardClause`, `Style/Next` and the other cops
that move control flow by hand, not with autocorrect.

Three tags are excluded from a plain `rspec` run and worth knowing about
before you assume a red suite everywhere:

- `fuzzing: true` — `spec/fuzzing/`, live-generated-history replays,
  ~18-20s on their own. Run with `bundle exec rspec spec/fuzzing --tag
  fuzzing`.
- `io: true` — real Rust builds, real Postgres, deploy-contract specs.
  CI runs these; locally, `bundle exec rspec spec/adapters/query_agreement_spec.rb
  --tag io` is the one cheap enough to run by hand (see its own header
  for why it earns a slot pre-push and the rest don't).
- `process: true` — an example whose subject is a process of its own: it
  starts a child (a Ruby, `node`, an MCP server, a launcher) and reads its
  pipes, exit status or environment. Each pays for an interpreter and a
  cold load of the library, about half the time of a plain run. CI runs
  these with everything else, and so does the pre-push gate
  (`HECKS_PROCESS_SPECS=1`); locally use `HECKS_PROCESS_SPECS=1 bundle
  exec rspec`, `CI=true bundle exec rspec`, or `bundle exec rspec --tag
  process`. A new example that starts a child process should say
  `:process`.

Install the pre-push hook once — it's the local bar a change has to
clear before it leaves your machine, and matching it locally means you
find out here instead of in CI. It is a local check, not a review; see
[How this project is built and reviewed](#how-this-project-is-built-and-reviewed):

```sh
git config core.hooksPath .githooks
```

It runs the parallel suite, `spec/fuzzing`, the query-agreement `io`
spec, the engine-agreement check, `hecks model_check`, `hecks conformance_run.measure_doc_coverage`
`rubocop` and `rust_fmt` (rustfmt over the Rust crates), concurrently, and reports the results in a fixed order. Bypass with `git push --no-verify` only when you mean to,
and say why in the push (or the PR).

`main` takes pushes with no gate, so a push of only `main` skips the suite but still runs the `unguarded_push` stage (rubocop, the
comment-style checks and `spec/fuzzing`); `HECKS_PRE_PUSH_GATE=1 git push` runs the whole suite for it. Any other push (a branch for a pull request) runs it as before. Which
lanes are unguarded is the `Lane` rows of the Vocabulary chapter, not this file.

### Fast local iteration

The pre-push hook above is the local bar a change has to clear before it
leaves your machine — but it's not the loop you should be running on
every edit. That loop should be scoped to what you're actually
touching:

```sh
bundle exec rspec spec/foo_spec.rb        # one file
bundle exec rspec spec/foo_spec.rb:42     # one example
bundle exec rspec --only-failures         # just what was red last time
bundle exec rspec --next-failure          # the tightest red/fix/re-run loop
```

`--only-failures`/`--next-failure` need `spec_helper.rb`'s
`example_status_persistence_file_path` (already set, writes to
`tmp/rspec_examples.txt` — gitignored, per-checkout state, never
shared). A plain `bundle exec rspec` with no path already excludes
`io: true`/`fuzzing: true` (see above), so a scoped run never pays for
Postgres, a cargo build, or a property-replay sweep you're not
touching.

For a quieter local loop without touching the shared `.rspec` (kept at
`--format documentation` for CI readability), copy
`.rspec-local.example` to `.rspec-local` — RSpec auto-merges it, and
it's gitignored:

```sh
cp .rspec-local.example .rspec-local
```

One thing NOT to fight: a single example in a domain-boot-heavy spec
(one that calls `boot_in_memory` or loads a real Bluebook) measures
noticeably above the "~50ms/example" baseline `spec_helper.rb`'s own
comment cites for the fast, tag-excluded loop — closer to ~200ms,
mostly a fresh `MetaValidator` pass and registry build per example.
That's the isolation the suite is built on, not a bug to route around;
scoping to the file/example you're changing (above) is what actually
keeps the loop tight, not trying to share boot state across examples.

For `PostgresEra`-flavored (`io: true`) work, keep a local Postgres
running persistently rather than starting one per run, and create the
scratch databases once:

```sh
createdb hecks_pizzas   # examples/pizzas/bluebook/pizzas.world wires straight at this by name
CI=true bundle exec rspec spec/adapters/driven/postgres_spec.rb   # or whichever file you're on
```

A bare `bundle exec rspec` (`spec/support/parallel_default.rb`) hands
over to `parallel_rspec spec`, one worker per core, grouped by the file
runtimes the last run left in `tmp/parallel_runtime_rspec.log`. It runs
the whole non-`io`/`fuzzing`/`process` suite in about 40s on a 12-core
machine; the first run has no log and groups by file size, so it is
slower. Any
argument (a file, `file:line`, `--tag`, `-e`, `--only-failures`), `CI` or
`HECKS_SERIAL=1` keeps the run in one process. It is your "did I break
anything nearby" check before a push — not something to run on every edit.

Specs that boot a domain share what a boot loads
(`spec/support/declaration_cache.rb`): the first boot of an unchanged
domain directory loads it, later ones get a fresh registry holding a copy
of the loaded declarations. A changed file is a different key;
`HECKS_NO_DECLARATION_CACHE=1` turns it off when you suspect it.

## Verification beyond the suite

A green suite only proves the paths someone thought to write. Before a
PR that touches a `.bluebook`, the DSL builder, the runtime, or the
IR:

```sh
bundle exec hecks model_check           # static analysis over the IR — unreachable states,
                                        # dead transitions, sagas nothing reaches
bundle exec hecks fuzz_run.fuzz                  # generated command/query sequences, checked against
                                        # declared properties and interpreter crashes
bundle exec hecks fuzz_run.mutate examples/pizzas  # mutates the domain's rules and lists every change the
                                        # properties, corpus script and behaviors tests let through
bundle exec hecks conformance_run.measure_doc_coverage  # every live DSL word ships with a running example
bundle exec hecks run examples/banking spec/corpus/banking.json   # the refusals someone already decided must hold
```

The `hecks` command is the one entry point for all of this. Its verbs are
the commands of the `Hecks` chapter (`lib/hecks/hecks/`), and its
attached chapters add theirs (`hecks deploy recipe.project`, `hecks quality_control
…`); `hecks` lists them and `hecks <verb> --help` prints one's usage.
`run`, `model_check`, `docs`, `narrate`, `ir`, `stores`, `smoke_test`,
`project_diagrams`, `project_cli` and `mcp` ship in the installed gem; the
Codebase verbs (`regenerate_corpus`, `check_comments`, `measure_doc_coverage`,
`publish` and the rest) need a checkout of this repository and refuse
without one. The logic lives under `lib/hecks/cli/` and `lib/hecks/tools/`,
so a change to a command goes there; there is no `bin/` directory. The fuzzing, bench,
corpus, codemod, query IR, grammar evolve and doc reference tooling ships in
the gem but loads only when a command asks for it; `require "hecks"` loads
none of it (`spec/gemspec_packaging_spec.rb`). The gem also ships `rust/`,
without `target/`, `rust/tests/` or `rust/src/generated/`; `Build` copies it to
`.hecks/rust/<version>/` in the client project and never writes into the gem.

`rust/src/generated/` is not committed, except `pizzas/` (its translations carry SQL that only the Ruby
toolchain compiles) and `mod.rs`, which lists the domains beside the Cargo features. A fresh checkout
materializes it with `exe/hecks regeneration_run.regenerate_corpus --confirm --wait`, about a minute with
`HECKS_ENVIRONMENT=memory`, before `cargo build` or any spec that reads it. CI does the same through the
`generated-rust` action, which caches the result on the bluebooks and generator sources it reads.

`spec/ir_golden_spec.rb` freezes the builder's `to_h` output per corpus
member. If your change is a deliberate shape change (not a bug), you
regenerate it explicitly and read the diff before trusting it — it's a
claim about the wire format, not a routine refresh:

```sh
GOLDEN=rewrite bundle exec rspec spec/ir_golden_spec.rb
```

**Docs are tests.** Every `ruby`-fenced block in the README and in
`docs/implemented/guides/` runs for real, against a real booted domain
— `spec/guides_spec.rb` is the harness. If you edit one of those code
fences, `bundle exec rspec spec/guides_spec.rb` either passes or tells
you the prose lied.

**Rust.** `rust/` is a second dispatch runtime, generated from the same
canonical IR and checked against Ruby continuously
(`hecks regenerate_corpus --check`, `spec/rust_conformance_spec.rb`). You
don't need a Rust toolchain to contribute Ruby-only changes — your pull
request's own CI builds and runs the conformance suite, and CI runs it again
on every push to `main`; `stable` moves onto a commit only after that run is
green. If you do touch anything
that changes what gets generated (`rust/codegen/src/*.rs`,
`hecks build.project_rust`, the kernel's hand-written half under
`rust/src/kernel/`), and you have `cargo` installed, run it yourself
before you find out from CI:

```sh
bundle exec hecks build.project_rust examples/banking
cd rust && cargo build --release && cargo test --lib
```

Every Rust crate (`rust/`, `rust/parser`, `rust/codegen`, `rust/lsp`, `rust/host`, `rust/web`, `rust/build`) is held to
`cargo fmt --check` and `cargo clippy --all-targets -- -D warnings`. The lint set is the `[lints.clippy]` table in each
crate's `Cargo.toml` (clippy's pedantic and nursery groups plus `unwrap_used`, `expect_used` and `panic`; the same table
in all seven, so change them together), with `clippy.toml` letting tests unwrap. CI runs both in the
`checks` job; `hecks gate_run.gate stage=ci only=rust_fmt,rust_clippy` runs them here, after `hecks regenerate_corpus
--confirm` on a fresh checkout (the main crate needs the generated modules). Generated Rust is laid out by its
generators (`Hecks::RustFormat`) and is clippy-clean by the same route, so fix a warning in a generated file in the
generator, never in the output. `rust/src/exemplar` is text templates, so it is neither formatted nor linted as code.
Allow a lint only at the one site that needs it, with a line saying why. An `unwrap`, `expect` or `panic` in library
code is either turned into an error or, for a true invariant, allowed on its function with the invariant named.

## Code-quality inspection

RuboCop's Metrics cops answer pass or fail against a limit. These tools rank and
find what RuboCop cannot. They read the tree and change nothing, and they are not
in CI or the pre-push gate. Each is a command of `InspectionRun`, so `exe/hecks`
lists them:

| Question | Command |
|---|---|
| Which methods are the most complex? | `exe/hecks inspection_run.flog! top.value=20 --wait` |
| Where is code copied or near-copied? | `exe/hecks inspection_run.flay! --wait` |
| Which classes carry design smells? | `exe/hecks inspection_run.reek! paths.value=lib/hecks/runtime --wait` |
| What is the overall picture, with git churn? | `exe/hecks inspection_run.rubycritic! --wait` (writes `tmp/rubycritic/overview.html`) |
| Which Rust functions are too branchy? | `cd rust && cargo clippy -- -W clippy::cognitive_complexity` |

`paths.value` takes comma-separated files or directories and defaults to `lib`;
`top.value` keeps only the first lines of the report. The files `.rubocop.yml`
excludes (generated output, vendored code) are left out of every tool, because a
finding in one belongs to its generator. `.reek.yml` turns off the detectors that
RuboCop's Metrics cops already own or that the repo's style makes noisy. The most
useful target is a method that both ranks high in flog and sits in a file that
changes often: rubycritic's churn column shows that. Clippy needs the generated
Rust modules on disk (they are untracked), so run the `project_rust` build above first.

## How this project is built and reviewed

This section separates what the repository and GitHub can show you from
what the maintainer says. The counts are a snapshot as of 2026-09-27 and
move with every commit.

### What the repository and GitHub show

- **Most commits are co-authored with an AI coding assistant.** Of 1,434
  commits on `main`, 987 carry a `Co-Authored-By:` trailer, and 425 of the
  425 commits in the last 30 days do, about 14 a day. Check it by counting
  `git log --format=%h`, then again with `--grep='Co-Authored-By:' -i`,
  and add `--since='30 days ago'` to either for the recent window.
- **`main` takes pushes with no gate; `stable` is the guarded line.** The
  branches are the `Lane` rows of the Vocabulary chapter, and the checks a
  commit must pass are its `RequiredCheck` rows. CI runs the full job set
  on every push to `main` (`.github/workflows/ci.yml`, header comment).
  `.github/workflows/promote.yml` then fast-forwards `stable` onto a commit
  only when every required check passed on it, through
  `hecks promotion_run.promote`; the ruleset on `stable` refuses deleting or
  rewinding it and takes only a commit on which every required check has already
  passed, with no bypass actor (`.github/rulesets/stable.json`). Releases and the `edge` tag
  come from `stable`. `.github/workflows/lane-watch.yml` runs hourly and, through
  `hecks promotion_run.watch`, files an issue (one a day) when `stable` has stood
  behind `main` for longer than its `Lane` row's `alert_after` hours.
  `hecks regeneration_run.project_lanes` writes these
  files from the rows, and with `--live` compares the rulesets GitHub holds
  with them.
- **GitHub does not require an approving review.** Branch protection and
  the ruleset's pull-request rule both require zero approving reviews, and
  branch protection requires no code-owner review. There is no CODEOWNERS
  file. The ruleset also sets `require_extra_approval_for_unattributed_changes`;
  the API does not show whether that setting has ever triggered.
- **The recent merged pull requests carry no recorded reviews.** The last
  60 merged (#803 to #865, merged 2026-09-25 to 2026-09-27) have zero
  reviews recorded on GitHub and a single author login. Check it with
  `gh pr list --state merged --limit 60 --json number,reviews,reviewDecision`.

### What the maintainer states

These four statements are the maintainer's own. Nothing in the repository
or on GitHub enforces or records them, and they stay true only while the
maintainer keeps doing them.

1. The maintainer's own pull requests and outside authors' pull requests are
   read before they are merged. A pull request an agent opened and merged
   itself (`hecks-merge`, or `gh pr merge`) lands on `main` without the
   maintainer's reading beforehand; `stable` follows only once CI is green
   on that commit.
2. Reviews run by an AI assistant (a code-review skill, subagents) are not
   claimed as a review step, because nothing records them.
3. Zero required approvals is intentional for a repository with one
   maintainer.
4. A pull request from an outside author gets the maintainer's personal
   review before it is merged.

### What CI proves mechanically

CI proves the following about a tree, and nothing about who read it. This
is the mechanical bar, and it is separate from the maintainer's reading
above:

- the whole suite, and the fuzzing specs;
- the Postgres and Rust `io` specs, against real databases and a real Rust
  build;
- `hecks model_check`, the engine-agreement check, `hecks conformance_run.measure_doc_coverage` and
  `rubocop`;
- the Ruby/Rust parity specs and the golden IR (`spec/codegen_planted_gaps_spec.rb`,
  `spec/parser_parity_spec.rb`, `spec/rust_conformance_spec.rb`,
  `spec/ir_golden_spec.rb`);
- every `ruby`-fenced block in the README and the guides, run as a doctest
  (`spec/guides_spec.rb`).

### What the pre-push hook is and is not

`.githooks/pre-push` can be bypassed with `git push --no-verify`, and a push of
only `main` skips it unless `HECKS_PRE_PUSH_GATE=1` is set. When it
runs and a signing key is present in the keychain, it writes an HMAC
attestation note keyed by the tree hash, and CI uses that note to skip the
checks the note names. The note is evidence that someone holding the key
ran those checks on that tree. It is not evidence that anyone reviewed the
code.

## What a PR should include

- Tests. A new keyword, argument, or resolution rule needs a corpus
  example or a spec exercising it, not just a passing existing suite.
- `bundle exec rubocop -c .rubocop.yml` clean.
- If you touched a `.bluebook` file that ships as part of the
  language's own definition (`lib/hecks/language/`) or added a DSL
  word: `hecks conformance_run.measure_doc_coverage` clean, and a real prose section in
  `docs/implemented/reference/` — not the `TODO` sentinel — with a
  runnable example.
- If you touched a lifecycle, saga, policy, or an attribute that copies another aggregate's key: `hecks model_check` clean (a `key_by_value` warning means `reference_to` fits better).
- A short note on *why*, not just *what* — this repo's own comments
  (Gemfile, `.rubocop.yml`, `.githooks/pre-push`) are the house style
  for that: explain the reasoning that would otherwise get silently
  reverted by someone who didn't have it.

Small, single-purpose PRs are easier to verify against all of the
above than one that reshapes several things at once. If a change
touches the language surface itself — new syntax, not just new
behavior behind existing syntax — read
[Extending Hecks](docs/implemented/guides/extending-hecks.md) first;
a new word is a declared row (`proposed → admitted`) before it's a
line of Ruby, and skipping that ordering is the most common way a
first PR here goes sideways.

## Where to start

- [Getting started](docs/implemented/guides/getting-started.md) — the
  whole shape of the language in one sitting.
- [Extending Hecks](docs/implemented/guides/extending-hecks.md) — how a
  new DSL word gets added without breaking every `.bluebook` that
  already boots.
- [`docs/decisions/`](docs/decisions/) and
  [`docs/implemented/decisions/`](docs/implemented/decisions/) — one
  document per architectural decision; read the relevant ones before
  arguing with a design choice that was already made deliberately.
- [`docs/HECKS_IMPLEMENTATION_PLAN.md`](docs/HECKS_IMPLEMENTATION_PLAN.md)
  — the full architecture in one document.

If you're not sure whether an idea fits before writing any code, open
an issue first — see the templates under `.github/ISSUE_TEMPLATE/`.

## Releasing

1. Bump `VERSION` in `lib/hecks/version.rb`, update the two
   `Current release:` lines in `README.md` to match (`spec/readme_version_spec.rb`
   fails until they do), and add a `CHANGELOG.md` entry, on a branch, as
   its own PR. Bump the JavaScript client to the same version in that PR
   (`npm version X.Y.Z --no-git-tag-version` in `packages/hecks-client`,
   which also updates its lockfile); `spec/hecks_client_version_spec.rb`
   fails until it matches. Label the entry as the release promise says
   ([What a release number promises](docs/1.0-readiness.md#what-a-release-number-promises)):
   a major carries a `Breaking:` entry, and a minor that changes how a
   running system behaves carries a `Behavior change` entry.
2. Once that PR merges to `main`, wait for `stable` to contain it (the
   `Promote` workflow moves it after CI is green on the merge commit; rehearse
   with `hecks promotion_run.promote lane=stable` to see what is still red or
   running). Then check out `stable`, pull, and run
   `hecks publishing_run.publish` (no `--confirm`) to see every check and build pass with
   nothing tagged or published. Then run `hecks publishing_run.publish --confirm`. It refuses unless the
   checkout is a clean `stable` (the release lane: the `Lane` row that feeds a tag) equal to `origin/stable`, the gem and
   `packages/hecks-client` are at one version, and `CHANGELOG.md` has a
   heading for it. It asks RubyGems and npm what is already published and
   skips that, so if a run stops partway, run it again. In order, it:
   - creates the annotated tag `vX.Y.Z` on the merge commit and pushes it;
   - runs `hecks publishing_run.publish_gem` to build the gem and push it to rubygems.org;
   - waits for CI to publish `@hecks/client`. Pushing the tag starts
     `.github/workflows/publish-client.yml`, which publishes the package
     with npm trusted publishing (no token, no code). `hecks publishing_run.publish` says
     so, then checks npm every 15 seconds for up to 10 minutes and reports
     success, or the timeout with the run to look at
     (`gh run list --workflow publish-client.yml`). `--no-wait` skips the
     wait.

   Without `--confirm` it only reports what it would do; `--confirm` is what
   tags and publishes. `--gem-only` skips the npm step; `--npm-only` skips the gem,
   so on its own it just waits for CI's publish again (use it after
   re-running the workflow with `gh workflow run publish-client.yml -f
   tag=vX.Y.Z`).

   The gem's push key comes from 1Password (`op run`, Touch ID-gated;
   `release/gem_push.env`), and its one-time setup is in the header of
   `lib/hecks/cli/release_gem.rb`.

   One-time setup for CI publishing, by an owner of the `@hecks` scope on
   npmjs.com, possible only once the package exists: package
   `@hecks/client` > Settings > Trusted Publisher > GitHub Actions >
   organization or user `heckslabs`, repository `hecks`, workflow filename
   `publish-client.yml`, environment blank.

   `--npm-local` (an option of `hecks publishing_run.publish`) is the fallback, and how the first publish is made
   (before a trusted publisher can exist): it publishes `@hecks/client`
   from this machine with a token from 1Password instead of leaving it to
   CI (with `--npm-only` it publishes only the package). The token is the
   "publish token" field on the "npmjs.com" item in the Hecks vault, next
   to the "RubyGems API Key" item (`release/npm_publish.env` names the
   vault, item and field, and can be edited). It must be a granular token
   scoped Read and write to the `@hecks` scope with "Bypass two-factor
   authentication" enabled, and short-lived: the account's second factor
   is a passkey, so a token that requires a one-time code cannot publish
   (npm answers `EOTP`). The header of `lib/hecks/cli/release.rb` has the setup. The
   publish passes `--auth-type=web` as an interactive fallback: if npm
   does ask for a passkey or security key, it prints an approval link and
   waits.

   `hecks publishing_run.publish` does not create the GitHub Release. Once the tag is
   pushed, create it with the version's own `CHANGELOG.md` section as its
   notes, so the Releases page lists every tag. Mark it Latest only when it
   is the newest version:

   ```sh
   awk -v v="X.Y.Z" 'BEGIN{h="## [" v "]"} index($0,h)==1{p=1;next} /^## \[/{p=0} p' CHANGELOG.md > notes.md
   gh release create vX.Y.Z --title "hecks X.Y.Z" --notes-file notes.md --verify-tag --latest
   ```

   Manual fallback, if `hecks publishing_run.publish` cannot be used: tag the merge commit
   (`git tag -a vX.Y.Z <sha>` and push it, which starts the CI publish),
   run `hecks publishing_run.publish_gem --confirm`, and if CI cannot publish, run
   `npm publish --access public` in `packages/hecks-client`.
