# @geonosis/ratchet

Through the front door: `geonosis ratchet` — the metapackage pins this and every other kit tool at
ONE version, and passes the exit code through unchanged.

Debt as a number that may only shrink. It measures a repo's counters, compares each against a
committed baseline, and fails **only** on regression — so a repo can adopt a gate the day it is
written, with the existing debt named rather than forgiven, and nothing new can land behind it.

## Install

```bash
pnpm add -D @geonosis/ratchet
```

```bash
geonosis ratchet --prove      # first: prove every counter CAN report a finding
geonosis ratchet              # then: every counter
geonosis ratchet --tier fast  # only the counters in that tier
geonosis ratchet --exclusive  # one heavy run at a time on this machine
geonosis ratchet --cwd apps/web
```

Exit 1 when a number grew or moved in a way the run cannot vouch for, 2 when a counter could not
measure at all, 0 otherwise.

## `--prove` is the first thing to run after installing

A counter that reads 0 because the tree is clean and a counter that reads 0 because it can no longer
read the tool are the same number. The second is worse than no gate at all: it reports green, and
when it shrinks the ratchet writes the lie into the baseline.

This is not hypothetical. On one day, 2026-08-29, five gates were found reporting green while
measuring nothing: this package's own `oxlintErrors` read 0 under the `--format=unix` its config
asked for, and had since it was written; a lint rule sat at `"error"` for months with no
configuration to fire on; a parity run said PASS about rules it never exercised; a test runner
exited 0 over failing suites for weeks; and a `git rebase | tail` hid a conflict behind a pipe's
exit status.

So every counter ships a **probe**: a known-bad input it must be able to read.

```bash
geonosis ratchet --prove
```

```
  PROVEN oxlintErrors: reads 1 on a planted finding
  PROVEN oxlintErrors (refuses): refused — oxlintErrors: ...
  PROVEN typecheckErrors: reads 1 on a planted finding
  PROVEN lawLineCount: reads 3 on a planted finding

prove PASS — 5 halves, and every one held: a planted finding was read, and a failing tool was refused.
```

A counter with a configured reading (`match`) has two halves to prove, and the report names each:
`PROVEN archViolations (command)` is the shipped probe, `UNPROVEN archViolations (match)` is the
configured match with no sample of its own. A pass counts halves; a failure counts counters and
names them: `prove FAIL — 2 of 41 counters unproven: archViolations, sumOfCounts`.

`--prove` attempts every configured counter and every half of each, whatever failed before it, so a
counter absent from the output is a counter that is not configured (or is outside `--tier`, and
printed as `SKIP`). It exits 2 if any half is not proven.

Each configured counter's probe plants its input in a temp directory — never in your repo — and the
counter runs there exactly as it runs in a real measurement. The tools come from your repo's
`node_modules/.bin`, so what is proven is the toolchain you will actually measure with.

- `PROVEN <key>: reads N on a planted finding` — the gate has been seen red.
- `CANNOT FAIL <key>: read 0` — the input was planted and the counter still read nothing.
- `CANNOT MEASURE <key>: <error>` — the probe threw, or the counter ships none.
- `PROVEN <key> (refuses): refused — <why>` — the tool was made to fail and the counter refused
  instead of reading a number.
- `CANNOT REFUSE <key> (refuses): read N over a failing tool` — the counter returned a number over a
  tool that did not run, which is the one reading a ratchet exists to refuse.
- `UNPROVEN <key> (refuses): no refusal probe` — the counter ships none.
- `PROVEN <key> (new finding): refused — <the finding>` — for a counter that keys its findings:
  the plant is read twice, and the second reading must hold every key of the first. Then the record
  is replaced by one missing the planted finding and holding one the tree does not, at the same
  total, and the planted finding must read as new. `UNPROVEN <key> (new finding)` says which of the
  two did not hold.

Any half that is not proven exits **2**. Put it in front of the ratchet in whatever script your CI
runs:

```jsonc
{ "verify": "… && geonosis ratchet --prove && geonosis ratchet" }
```

A counter you wrote yourself takes a `probe` beside its `run`: `{ input: (dir) => void, command?:
(dir) => string, params?, expect: number }`. `expect` is what the planted input is worth, and it is
at least 1 — a probe that plants nothing proves nothing. It also takes a `refuses` beside it:
`{ input?, command?, params? }` plants the tool failing, and the counter must answer with a
`CounterError`. Any other error is the counter's own crash, not its refusal.

### A probe proves the command, not the regex

A shipped probe plants an input in the shape the counter's OWN default reads. `archViolations`
plants a scanner that prints `✗ …` lines and exits non-zero; `sumOfCounts` plants a line its default
`match` counts. That proves the command is spawned, its output is captured and its exit code is told
apart from its findings — and it proves nothing whatever about a `match` YOUR config supplies,
because the planted input was written for the default one.

This was measured, not imagined: a consumer's mis-escaped `match` read 0 from a real scan, `--prove`
said PROVEN off the shipped plant, and the ratchet lowered the baseline — ten pieces of debt erased
with a congratulatory message (#140).

So the counters that take a reading param — `archViolations` (`match`), `sumOfCounts` (`match`),
`duplication` (`headings`), `todos` (`markers`) — require a sample from the config that supplied it:

```jsonc
{
  "counter": "archViolations",
  "command": "node scripts/scan-architecture.mjs",
  "match": "^VIOLATION ",
  "probe": { "sample": "VIOLATION a\nVIOLATION b\nfine\n", "expect": 2 }
}
```

Without the `probe` block the line reads `UNPROVEN`, naming the param and its value. With it, the
sample is run through YOUR match and must count `expect` — a sample your match cannot read is a
refusal that names the match, not a zero.

### A baseline is not proof the gate still gates

The ratchet compares a number against a number. Swapping the tool that produces it — a vendored lint
plugin for a published one, one version for the next — can hold every number and still have stopped
firing, because a rule that fires nowhere counts zero exactly like a rule with nothing to find. Check
that separately, with [`@geonosis/lint-parity`](https://www.npmjs.com/package/@geonosis/lint-parity):

```bash
npx geonosis lint-parity \
  --corpus node_modules/@geonosis/oxlint-plugin-biological-architecture/corpus \
  --a oxlintrc.old.json --b oxlintrc.new.json --out proofs/reach
npx geonosis lint-parity --a oxlintrc.old.json --b oxlintrc.new.json --out proofs/parity -- apps packages
```

Reach first — parity between two configs that both fire nothing is perfect parity — then the tree.

### A counter that reads a kit bin through a pipe

Before 2.11.0, `geonosis-doctor`, `geonosis`, `geonosis-release` and `geonosis-observability` called
`process.exit()` after an asynchronous `stdout.write`. Through a pipe on macOS, anything past 64 KiB
was dropped with a clean exit code, and the cut fell between findings, so a count over the output
read as a stable smaller number. One consumer's `migrationWarnings` baseline read 51 for eleven days
over a report that holds 58. A shrunk reading cannot be told from a tree that improved.

Any counter that reads a kit bin's piped output, where that output could exceed 64 KiB, has a
baseline recorded before the fix that is a floor. After upgrading, re-measure the counter and adopt
the true value: delete the key, run `geonosis ratchet` so it is adopted at the real number, and give
the reason in the commit. `sumOfCounts` over `geonosis release migrations` is the measured case.

## `--exclusive`: one heavy run at a time on a machine

Three sessions on one laptop each started their own verify, and a 25-minute run took two hours and
came back with failures that were about the load, not the code. "Announce before anything heavy" is
a habit that works right up until the moment somebody is concentrating; `--exclusive` is the same
agreement as code.

```bash
geonosis ratchet --exclusive --exclusive-timeout 1800
```

It takes the machine-wide heavy lock of `@geonosis/gate` before measuring anything and gives it
back when the run ends, on SIGINT and SIGTERM too. A second run waits instead of contending, and
says every 15 seconds who has the lock and for how long. The wait is bounded:
`--exclusive-timeout` seconds (default 1800) and then exit 2, because a run that hangs silently
looks exactly like a run that is broken. Where the lock lives, how a stale holder is taken over and
`GEONOSIS_HEAVY_LOCK` are in the "heavy lock" section of that package's README.

**Put it on the script your sessions actually run**, so nothing has to remember:

```jsonc
{ "verify": "… && geonosis ratchet --prove && geonosis ratchet --exclusive" }
```

## The heavy slots, the lock and the gate daemon

The heavy budget the ratchet takes a slot from, the machine-wide lock `--exclusive` holds and the
`geonosis-gate` daemon are `@geonosis/gate`, which this package installs. Its README says how each
works.

## Config

`geonosis.ratchet.json` at the repo root names the counters and how to run them:

```jsonc
{
  "baseline": "gate-baseline.json",
  "counters": [
    { "counter": "oxlintErrors", "command": "npx oxlint --format=unix ." },
    { "counter": "typecheckErrors", "command": "npx tsc --noEmit" },
    { "counter": "cloneCount", "command": "npx jscpd .", "tiers": ["full"] },
    { "counter": "lawLineCount", "path": "CLAUDE.md" }
  ]
}
```

- `key` — the baseline key this entry writes. Defaults to `counter`; required when one counter is
  used twice. Two entries writing one key is a config error, not a race.
- `tiers` — the tiers this entry belongs to. Absent means every tier, so a repo that never passes
  `--tier` is unaffected. Names are free-form; `fast` and `full` are convention.
- `okExits` — the exit codes this entry's command finishes with when it has a reading. Each
  counter declares its own tool's set (`[0, 1]` for a linter that exits 1 over findings, `[0, 1, 2]`
  for tsc, `[0]` where nothing is declared); an entry overrides it for a command it knows better.
  Any other exit is "the tool did not run" and the counter is refused — never a number. So is a
  non-zero exit whose output is a Node stack trace, whatever the set says: a counter command that
  threw once had its trace's line numbers summed as findings (#18).

`gate-baseline.json` holds one number per key (plus any `_comment`, which is preserved):

```json
{ "oxlintErrors": 21, "typecheckErrors": 0, "cloneCount": 40, "lawLineCount": 137 }
```

A number that **grew** fails. A number that **shrank** rewrites the baseline down, in place, so the
win is locked in by the same commit that earned it. Equal passes.

A number that **fell while the measurement behind the baseline changed** is `UNPROVEN`: the fall is
not banked, the baseline is left exactly as it was, and the run does not pass. A rise is
recoverable — it fails, someone looks, and `geonosis ratchet --raise` (below) raises a number with the
reason in the commit. A fall is not: once banked, the lower number is the ceiling from then on,
and a fall that came from the instrument seeing less is never found. So the refusal names the way to
accept it: confirm the fall is real, then `geonosis ratchet --bank <key,…>` (or `--bank-unproven` for
every fall the run found unproven). It runs `--prove` for exactly those counters first and refuses
any that do not prove, then writes this run's numbers and their identities in the same run, so no
second full run is needed to record them. A key that grew, or could not be measured, is refused, and
nothing is written. Lowering the key in `gate-baseline.json` by hand, with the reason in the commit,
still works, and the run that meets the number records its identity. "The measurement changed" is what `gate-baseline.identity.json` records beside
each number: the command the reading was taken with, the params the counter was configured with, a
fingerprint of the counter's own code (the package version is recorded and not compared, so a release
that left a counter alone does not call its measurement changed; a record from before fingerprints is
compared by version), and a digest of every settings file its tool reads — a rule switched off in
`.oxlintrc.json` moves the instrument as surely as a new command does. The tool counters carry their
own `reads` globs (oxlint's configs and ignore files, `tsconfig*.json`, oxfmt's, jscpd's, knip's,
`turbo.json`); an entry's `reads` replaces them. A counter configured by params alone — one that
runs no command — is reconfigured by editing its options, so the options are part of what a fall is
vouched against; they are compared as configuration rather than as text, and the same options in
another key order are the same measurement. A fall whose identity is UNKNOWN — nothing recorded, an
unreadable file, a number edited by hand — is refused the same way: absence vouches for nothing. A
run that meets a hand-edited number exactly records the measurement behind it, so the next fall is
judged.

A counter outside the requested `--tier` is not measured and says so —
`SKIP cloneCount: not measured by --tier fast`. It never counts as held, never reaches the exit
code, and its baseline survives a rewrite another counter earned: printing OK for a census nobody
took is how a red main goes unnoticed for three commits.

A counter whose command cannot run is a hard error, never a silent zero. A gate that cannot measure
has not passed. Nor is a number believed on its own: `oxlintErrors` reads the finding lines AND the
summary oxlint printed, and raises when they disagree.

### Each finding, not only the number

A number alone lets one fix pay for one new finding: the total holds and the run passes. The
counters that read findings (`oxlintErrors`, `oxlintWarnings`, `oxlintRule`) also key each finding
they count, by its file, its rule and a hash of the whole line it is on (with whitespace
collapsed), and never by its line number. A finding whose line moved or whose indentation changed
keeps its key. The keys sit beside the baseline in `gate-baseline.findings.json`, written by the
ratchet with the number: a run that lowers a number, a first reading, `--bank` and `--raise` write
them, and `gate-baseline.json` keeps one number per counter.

- A finding the file does not hold fails the run even when the total did not grow. The report names
  it as oxlint printed it: file, line, column, rule and message.
- A fixed finding shrinks the number, and the number and the findings left are written in the same
  run.
- A counter with a number and no findings yet has its findings written, not judged, and the run
  passes, so a repo that upgrades is not failed by the upgrade. Findings kept under an older keying
  scheme are read as none and written again the same way.
- A findings file that does not parse refuses those counters, exit 2. It is never read as empty.

The design follows Betterer's file test, which keys an issue by file and a hash of its text and
matches a moved issue by that hash. Betterer hashes only the issue's span, so two `any` in one file
are interchangeable to it, and fixing one while adding another in the same file passes. oxlint's
unix output gives no span length, so the key is the whole line: fixing `id: any` while adding
`customer: any` in the same file fails. Identical lines in one file share a key, and the key holds
how many there are, so a second copy of a flagged line is a new finding. The price is that any edit
of a flagged line, or a renamed file, reads as a new finding. That is the door below, used at the
same number.

oxlint prints paths from the directory it ran in. An entry whose command lints from a subdirectory
(`bun --cwd apps/web lint`) names it as `"pathsFrom": "apps/web"`. A finding in a file that is not
there is refused, never keyed from what is left.

### Raising a baseline: `--raise <key>=<n> --because "<why>"`

```
geonosis ratchet --raise lawLineCount=214 --because "the cell rule file grew by the 14 lines it states"
lawLineCount 200 -> 214 raised: the cell rule file grew by the 14 lines it states
```

It measures that one counter the way a normal run does (its `--tier` and params from the config)
and writes `<n>` and the counter's identity (`gate-baseline.identity.json`) only when the counter
reads exactly `<n>` and `<n>` is above the baseline, so nothing higher than a real reading is ever
written, and the raised number vouches for itself on the next run. It prints the sentence above;
put it in the commit message. Where a session runs under the geonosis plugin, the person is asked
before it runs, and Allow runs it in the session.

For a counter that keys its findings, `<n>` may also be the baseline or below it, when the counter
reads a finding the findings file does not hold: a renamed file or an edited line records its
findings this way, with the reason. The findings it read are written with the number. With no new
finding, a raise to the same number is refused like any other.

It refuses, exit 2 and nothing written: a reading that is not `<n>`, an `<n>` not above the baseline
(a fall is banked by a normal run, or `--bank`), a key the config does not name or the baseline
never held (a first reading is adopted by a normal run), a missing or blank `--because`, a counter
outside the given `--tier`, a counter that cannot measure, and a counter with `earnedBy` whose raise
no deletion has paid for. It stands alone: `--prove`, `--record`, `--bank`, `--bank-unproven` and
`--measure` are refused beside it.

### The warnings-only window, and `expectFormat`

The last-resort backstop under the oxlint counters — output in no shape we know *and* a non-zero
exit means the format moved, so refuse — has one gap, and it is worth naming rather than pretending
otherwise. **oxlint exits 0 when a run found only warnings.** On such a run the backstop cannot
fire: an unrecognised format reads as zero warnings, and a zero that only shrinks is a zero the
ratchet banks by rewriting the baseline down.

`--deny-warnings` is **not** the fix. It makes warnings exit non-zero by making them errors, which
moves every warning into `oxlintErrors` and changes what both numbers mean — a bigger lie than the
one it patches, told to the whole baseline.

The fix is to say which shape you asked for:

```jsonc
{ "counter": "oxlintWarnings", "command": "npx oxlint --format=unix .", "expectFormat": "unix" }
```

With `expectFormat` set, output that matches none of the three shapes and is not empty once npm's
own chatter is stripped (`> …` script echoes, `npm warn …`, `npm notice …` update nags, blank
lines) is refused **even on a clean exit**. `npm ERR!` is not stripped — that is npm saying the
command never ran, and it must never read as zero. Unset is the default and changes nothing, so a repo that never opts in keeps exactly
the behaviour it had. Valid values: `unix`, `agent`, `default`.

When a number **grew**, up to ten lines of that counter's command output print under the
`<-- REGRESSED` line, indented — so the report says what grew, not only that something did. A
counter that reads a file rather than running a command (`lawLineCount`) prints nothing extra.

The lines are the ones the counter says its number came from, not the tail of the run. `oxlintRule`
cites the findings attributed to ITS rule; `oxlintErrors` cites errors and `oxlintWarnings`
warnings. The tail was wrong for exactly the run that needs it most: a consumer's +2 on one rule
printed two unrelated **warnings** as its evidence, because those were the last lines oxlint wrote
and the two real errors sat higher up. A counter written outside this package that names nothing —
or that recognises nothing in a run — falls back to the tail, as before.

### `testFailures` reads the runner's summary, never the exit code

The counter looks for the runner's own count and **refuses when it cannot parse one**. It never
reads the process exit code, because a test runner exiting 0 over a red suite is commoner than
anyone expects: `@cloudflare/vitest-pool-workers` 0.22 on vitest 4.1 exited 0 with failing tests on
every workerd suite of a consumer, and every gate that trusted the exit code reported green over red
for weeks. A refusal is loud and stops the run; a trusted 0 is silent and banks the red as a win.

**The lines it reads are the runner's summary lines, whole, and nothing else** — two dialects, both
captured from the binary rather than guessed at:

| Runner | The line | Read as |
| --- | --- | --- |
| vitest 3.2.7 · 4.1.11 | `      Tests  1 failed \| 1 passed (2)` | 1 |
| vitest 3.2.7 · 4.1.11 | `      Tests  3 passed (3)` · `      Tests  2 skipped (2)` | 0 |
| vitest 3.2.7 · 4.1.11 | `      Tests  no tests` (a suite that threw before collecting) | **refused** |
| bun 1.4.0 | ` 1 fail` on its own line | 1 |
| bun 1.4.0 | ` 0 fail` | 0 |

Nothing else in the output counts. `Test Files  1 failed (1)` is a count of files, not of tests;
`(fail) one [11.71ms]` is bun naming one; `naming 4 failing test(s)` is some wrapper counting its
own findings. All three used to be read as a failure count — a **green run regressing a baseline**,
the false RED mirroring the false green above (backlog #44). A run that prints no summary line at
all is unmeasured, and the refusal names the two dialects so a third runner's output is a message
rather than a wrong number. Summaries **add up**: a command that invokes the runner twice prints
two, and reading the first and stopping banks the second suite's failures as a win.

For the same reason, **give every test package its own `testFailures` entry**, each with its own
`key`. One entry over one workspace measures one workspace; the suites it does not run are not zero
failures, they are unmeasured — and unmeasured reads exactly like green.

```jsonc
{ "counter": "testFailures", "key": "testFailuresApi", "command": "bun --cwd apps/api test", "tiers": ["full"] }
```

`examples/workers-app.ratchet.json` is four test entries for that reason: `apps/web` plus the three
workerd packages that were previously outside every gate.

### …or the runner's JSON report, which is stronger

`report: "vitest-json"` reads the runner's own machine-readable answer instead of its prose:

```jsonc
{
  "counter": "testFailures",
  "key": "testFailuresApi",
  "command": "cd apps/api && bunx vitest run --reporter=json --outputFile={report}",
  "report": "vitest-json"
}
```

`{report}` is replaced with the path `.geonosis/reports/<key>.vitest.json`, and the report is kept
there: it is never deleted after the run, so the tests behind a number can be read afterwards. Give
the entry a `reportPath` instead when the report belongs somewhere your CI already collects. A
command in this mode with neither `{report}` nor a `reportPath` is refused, naming what is missing —
a run whose report goes nowhere is a run nobody can read. A report left by an earlier run is removed
before the command runs, so a crash is never read as the run before it.

When the count rises, the run cites each failing test — `FAIL <file> > <full name> — <first line of
its message>`, at most eight, then how many more there were — and the path the report was kept at.

The counter then reads `numFailedTests`, and **refuses** rather than returning a number when:

- the file is not there — a crash before the reporter wrote is not a pass, and it prints no summary
  line either, so the summary parser had nothing to refuse on;
- the file is not JSON, or has no `numFailedTests` in it;
- the report says `success: false` and names **0** failing tests — the shape a pool that dies
  mid-run writes. The run did not finish, so there is no number to bank.

The summary mode stays the default; nothing changes for an entry that does not ask for a report.
`--prove` proves all three: `testFailures` ships one probe per reading mode — `summary (vitest)`,
`summary (bun)`, `vitest-json` — and prints them by name (`PROVEN testFailuresApi (vitest-json)`),
because proving the mode nobody configured says nothing about the mode they did, and a summary
dialect nobody proved is a dialect nobody has been shown to read.

### `oxlintRule` counts a warned rule twice, on purpose

A rule parked at `"warn"` as ratcheted debt appears in two numbers: once inside `oxlintWarnings`,
and once under its own `oxlintRule` key, which measures it at `"error"` through a strict temp copy of
the config. So arming a rule at warn raises **both**, and fixing one finding lowers **both**.

That is not double-counting the total; the second key exists so the debt is visible per rule instead
of hidden inside a lump sum that a different rule's warning could mask. A Medusa storefront today: `oxlintWarnings`
12 → 135 when `no-raw-html-atoms` was armed, with its own key at 123.

### `--prove` also proves the lock

`--exclusive` is a claim about the machine, so it is measured on the machine, every `--prove`:

```
PROVEN exclusive: two runs of 400ms serialised, the second starting 161ms after the first finished
```

The self-test runs two children of this CLI over `--hold <ms>` — an instrument that takes the lock,
says when it started, waits, says when it finished, and gives the lock back — and requires the second
to have started after the first finished. Anything else prints `CANNOT FAIL exclusive: interleaved`
and exits 2. It uses a lock file of its own in a temp directory, so proving the lock never takes the
real one out from under the runs it exists to serialise.

It is here because the lock did not work for two releases and every gate was green throughout:
`openSync(path, 'wx')` is atomic about the NAME and not about the holder, and a run polling in that
window read an unparsable lock, called it stale, and took it. A lock nobody has watched fail has not
been shown to work — and this one guards the running time of every other gate.

## Running it where it will actually run

- **Counters run in the caller's environment.** They inherit the shell the ratchet was started in,
  nothing more. If a child command needs `NODE_OPTIONS` — a TypeScript shim, a loader — put it on
  the script that invokes `geonosis ratchet`, not on the counter's own line, and not only in your
  interactive shell.
- **`NODE_OPTIONS` in the parent script + a counter that shells out to `pnpm` = the counter dies.**
  The nested `pnpm` INHERITS the option. A Medusa storefront preloads a TypeScript-5 shim through
  `NODE_OPTIONS` in its `lint` script; run the ratchet from inside that script and the nested pnpm
  goes looking for a `.pnpmfile.mjs` that is not there and exits non-zero. The ratchet refuses —
  correctly, a command that cannot run is never a silent zero — but nothing in the message is near
  the cause. Run the ratchet outside that script, or unset `NODE_OPTIONS` for the nested call:

  ```json
  { "counter": "archViolations", "command": "env -u NODE_OPTIONS pnpm --silent verify:arch" }
  ```

  `geonosis doctor --only drift` asks for this shape by name: a manifest script carrying
  `NODE_OPTIONS` beside a counter whose command shells to `pnpm`.
- **A path in a counter's command must be absolute, or `--prove` cannot run it.** A probe runs in a
  scratch directory with none of your repo in it, so a loader or shim named relatively — `node
  --import ./scripts/ts5-shim.mjs …` — resolves to nothing there and the counter comes back
  "command did not run". The command is the same string in both places; write the path as
  `$PWD/scripts/ts5-shim.mjs` and it works in the repo and in the probe alike.
- **A `typecheckErrors` counter needs the same precondition CI gives it: build the workspace
  packages first.** In a fresh worktree the `dist/*.d.ts` files do not exist yet, and `tsc` reports
  a false +N of missing-module errors that has nothing to do with the change under test. `pnpm build
  && geonosis ratchet` is the shape; a bare `geonosis ratchet` in a clean checkout is not.

  The counter no longer reports that number. When tsc's TS2307 or TS2305 diagnostics name a package
  of this workspace — read from `pnpm-workspace.yaml`, or `workspaces` in the root `package.json` —
  it refuses:

  ```
  geonosis-ratchet: typecheckErrors: workspace packages not built: @shop/shared —
  build them before measuring typecheckErrors
  ```

  Mixed output refuses too. Counting the readable half would produce a number that is neither the
  debt nor the build error, banked into the baseline as though somebody had measured it. An
  unresolved module that is NOT a workspace package — `lodash` — is counted as before: that is a
  dependency to fix, not a build to run.
- **A release-age cooldown will refuse a package published minutes ago.** If pnpm's
  `minimumReleaseAge` blocks `@geonosis/*`, exclude the scope for that project — pnpm writes
  `minimumReleaseAgeExclude` into `pnpm-workspace.yaml` — rather than lowering the cooldown. The
  cooldown is protecting every other dependency in the tree; the exclusion is scoped to the one you
  chose to trust.
- **Bumping to a new version: keep BOTH exclusion pairs — old and new — through the install, then
  drop the old one and re-verify.** The policy check is applied to the entries in the LOCKFILE, not
  only to what you asked for, so removing the old pair first makes the resolver walk back through
  the version you are leaving and refuse it — an install that fails for the version you are
  replacing, not the one you are taking. The order that works, learned under load on a Medusa storefront's 0.2.0
  bump:

  ```bash
  # both pairs in pnpm-workspace.yaml's minimumReleaseAgeExclude
  pnpm install
  grep -c "@geonosis/.*@<old>" pnpm-lock.yaml   # must be 0 before the old pair goes
  # now delete the old pair
  pnpm install --frozen-lockfile
  ```

  An exclusion left behind is a cooldown quietly off for a package nobody is watching any more, so
  the deletion belongs in the same commit as the bump.

- **That policy check walks every entry in the lockfile and looks hung. It is not.** ~2,300 entries
  took ~90 seconds on a loaded laptop with no output while it worked. Wait for it rather than
  killing the install and re-running into the same wait.

## Counters measured at the same time

Counters run one after the other unless the config or the command line says otherwise:

```jsonc
{ "jobs": 4, "counters": [ … ] }          // or "jobs": "heavy"
```

`geonosis ratchet --jobs 4` measures up to four independent counters at once, never more than the
machine's heavy budget (`@geonosis/gate` says how it is set), and prints how many groups it found. The report keeps the order of
the config. What runs together is decided by the entries alone:

- entries naming the same `command` form one group and run one after the other, so a command two
  counters share (`oxlintErrors` and `oxlintWarnings`) still runs once;
- entries naming the same `"lock": "<name>"` form one group too, for suites that share a database or
  a port;
- entries with no `command` form one group;
- an entry with `"exclusive": true` runs alone, after the others, and so does an `oxlintRule` entry
  with a `config`, which writes a strict copy of that config into the tree while it measures.

Each group is measured by a child process of this bin, so a counter keeps the synchronous `run` it
was written against. A child that fails is an error naming the counters it was given, never a number.

## `oxlintRule` counters share one lint pass

`oxlintRule` entries with the same `config` and the same `command` are measured by one oxlint run
over one strict config that raises all of their rules, and each key is counted from the lines of its
own rule. Raising a rule to `error` cannot change another rule's findings, and the counts equal those
of one run per rule (17 rules over this repo's 1,600 files: 17 equal counts, 132 s as 17 passes, 4–7 s
as one). An entry whose `command` differs keeps a pass of its own.

## What `--prove` plants for an `oxlintRule` counter

A counter of one rule is proven by a finding of that rule, never of another, read by the counter under
the entry's own `rule` spelling and through its `config` where it names one. The plant is the first
of these that exists:

1. the census rules `geonosis init` measures (`no-explicit-any` and nine react rules) ship a source
   file each, checked against oxlint to report exactly that rule;
2. a rule of `@geonosis/oxlint-plugin-biological-architecture`: the reach corpus the plugin
   INSTALLED in this repo ships. The corpus is linted under its own config, and the plant is a file
   that fires the rule (one that fires only that rule first), linted alone under that rule's corpus
   options; where no single file fires it alone, the whole corpus is planted;
3. a plant the entry declares, planted beside a copy of the config the counter reads (its plugins
   resolved from this repo), which the counter raises as it raises a parked rule:

```json
{
  "counter": "oxlintRule",
  "key": "looseEquality",
  "rule": "eqeqeq",
  "config": ".oxlintrc.json",
  "plant": { "path": "src/loose.ts", "source": "export const same = (a: unknown, b: unknown) => a == b\n" }
}
```

`path` is a file inside the probe (not absolute, no `..`); `source` is code oxlint reports under the
rule. A declared source oxlint does not report is CANNOT FAIL, so `--prove` fails. A rule with none of
the three is UNPROVEN, naming the rule and the `plant` to declare.

## A counter that reads the run a step already made

```json
{ "counter": "doctorWarnings", "command": "geonosis doctor --json", "output": ".geonosis/recorded/doctor.json" }
```

`geonosis ratchet --record doctorWarnings` runs the counter's tool once, through the counter itself
(its command, its accepted exits), prints what it said, writes the run to `output` tied to the tree
and exits as the tool did, so a chain stops on a doctor FAIL exactly as it did. Several keys can be
named at once (`--record typecheckErrors,testFailures:apps/web`); the exit is the worst of them. A
`report: "vitest-json"` entry is recorded with its report, so the ratchet counts and names the
failing tests from the recording alone. An entry that names
`output` reads that file and never runs its command; a recording of another tree, of another
command, a missing one, and a tree that changed while the tool ran are each a refusal that names
what differs. Entries that name one file and one command are served by one recording.

## Counters

Every counter takes its `command` from the config, so the toolchain stays the repo's own. A command
that starts with `pnpm exec <bin>`, `npx <bin>` or `bunx <bin>` runs `node_modules/.bin/<bin>` when it
exists: the runner costs 3 s of start-up per command at load, and `pnpm exec` installs when the
manifests and `node_modules` disagree, which swaps the kit under a running gate. A command that
starts with a kit tool, `geonosis-<tool>`, runs `node_modules/.bin/geonosis <tool>` when only the
door is installed (a pnpm repo links only the bins of its direct dependencies), and the identity
reads `geonosis-<tool>` and `geonosis <tool>` as the same instrument, so a fall measured through
either spelling under a baseline recorded with the other is banked. Every command also
runs with `pnpm_config_verify_deps_before_run=error` unless the environment or `pnpm-workspace.yaml`
(`verifyDepsBeforeRun`) already says otherwise, so a gate step that finds `node_modules` stale refuses
and does not install.

| id | counts | params |
| --- | --- | --- |
| `oxlintErrors` / `oxlintWarnings` | findings under any shape oxlint prints — `--format=unix`, the compact `agent` format, the graphical `default` — cross-checked against the tool's own summary. `ruledSeparately` names rules ratcheted under their own `oxlintRule` key, which this count leaves out: turning a stricter rule on means one new `oxlintRule` entry (adopted once, then shrink-only) while the aggregate holds. A rule named here that no `oxlintRule` entry counts is refused | `command`, `expectFormat`, `ruledSeparately` |
| `oxlintRule` | one named rule's findings, counted only on lines the run reported as findings and refused when none of them attributes itself readably; with `config`, after forcing the rule to `error` in a temp copy — `"warn"`, `"off"` and the `["off", { … }]` array form alike — so debt cannot grow behind a downgrade. `rule` is the bare name (`no-literal-copy`, any plugin) or the config's `plugin/rule` (`biological-architecture/no-literal-copy`, that plugin only), which oxlint prints as `plugin(rule)`; a `plugin/rule` oxlint accepts but prints under another name (`@typescript-eslint/no-explicit-any` is printed `typescript(no-explicit-any)`) is refused, naming the spelling it prints. `--prove` plants a finding of the entry's own `rule` (see below) | `rule`, `config`, `command`, `expectFormat`, `plant` |
| `typecheckErrors` | `error TS` occurrences, refusing when TS2305/TS2307 name a workspace package of this repo — an unbuilt sibling is a missing build, not debt. With a `report` instead of a `command` it runs nothing: it reads the `{ errors, tree }` file a typecheck step wrote and refuses one whose `tree` is not this working tree's digest (`treeHashOf`), naming both — a tier that already measured a tree state is read, never re-run (D-080) | `command`, `report` |
| `testFailures` | the runner's own failure summary — or, with `report: "vitest-json"`, `numFailedTests` out of the JSON report the command wrote; throws when nothing is readable, when the report is absent, and when the report failed with nothing failing | `command`, `report`, `reportPath` |
| `unformattedFiles` | paths `--list-different` names that exist on disk | `command` |
| `cloneCount` | jscpd's `Found N clones`, with `ignore` kept out of the scan — `["**/package.json", "**/CHANGELOG.md"]` by default, because npm fixes a manifest's shape and a release tool writes a changelog, so neither is duplication anyone can pay down. It is passed as jscpd's `-i`, which REPLACES the `ignore` of a `-c` config rather than adding to it: exclusions kept in `.jscpd.json` belong here instead, and `"ignore": []` scans everything. `formats` names the jscpd formats read — `["javascript", "jsx", "typescript", "tsx"]` by default, because prose and captured tool output are not code anyone deduplicates; `"formats": []` reads every format | `command`, `ignore`, `formats` |
| `knipIssues` | the total under EVERY heading knip's compact reporter emits — the shape `<Title> (N)`, not a list of titles, so a heading the kit has not heard of is debt rather than a silent zero. `headings` narrows it, and every printed heading the narrowing leaves out must be named under `excusedHeadings` with a reason or the run refuses | `command`, `headings`, `excusedHeadings` |
| `boundaryIssues` | `N issues found` from a boundary scan | `command` |
| `archViolations` | lines matching a marker your own architecture scan prints, refusing a non-zero exit that printed none of them — a scan that could not run is not a clean scan | `command`, `match` |
| `sumOfCounts` | the total of one capture group across a per-file census (`grep -rc`) | `command`, `match` |
| `lawLineCount` | the lines of the law file — a ceiling that can only come down | `path` |
| `probelessRules` | the rules an oxlint config ENABLES that the plugin it loads declares no `probe()` for. Those are the ones `geonosis doctor`'s `exercised` can only answer UNJUDGED about, which #137 makes a WARN naming the kit as owner — and a WARN the kit carries across releases is a downgraded rule by another name. The plugin is imported from the tree it is pointed at, and one that cannot be loaded is a refusal, never a zero | `config`, `plugin` |
| `unreachableTierRules` | the tier rules enabled over a workspace that ships tier folders but none of the tier the rule reaches — `0` findings there reads exactly like a clean tree. A workspace with no tier folder at all (a CLI) is not judged. The plugin says which tier a rule reaches (`rule.tier`); one that declares none is a refusal, not a zero. The config read is each workspace's nearest `.oxlintrc.json`, its `overrides` applied to the workspace's own files | `plugin` |
| `runtimeCodeShipped` | 0 when a change shipped runtime code, 1 when it shipped none | `command`, `patterns` |
| `disabledCiJobs` | lines of `if: false` across the workflow files — a job switched off to get a release through, still off. A condition that merely mentions `false` is not one. No workflows directory at all reads 0 | `dir` |
| `bundleBytes` | one integer out of whatever your sizing command printed, separators and all; with `match`, the group that pattern names rather than the last integer, refusing when it matches nothing. **Tolerates.** | `command`, `match`, `tolerance` |
| `fastTierMs` | `finishedAt − startedAt` from the gate report `geonosis verify` wrote, refusing a report of another tier rather than timing the wrong gate. It reads the previous completed run, so it cannot measure a fresh checkout from inside the tier it times: set that ceiling in `geonosis.json` `budgets.<tier>` (D-042), which `geonosis verify` checks when the run ends, and `geonosis doctor` warns when the two meet (it follows tier steps through root package scripts only; a script run in a workspace with `--filter`, `-C` or `-r` is not followed). **Tolerates.** | `report`, `tier`, `tolerance` |
| `testsWithoutRunner` | workspaces holding `*.test.*`, `*.spec.*` or `__tests__/` with no `test` script — the suites nobody runs, which read exactly like suites that pass | `script` |
| `packagesWithoutTypecheck` | workspaces with no `typecheck` script | `script` |
| `walkFindings` | the defects in the report `geonosis walk` wrote, over every page; with `classes`, only those classes, refusing a class the walk does not have. A missing or unparsable report is a refusal — the walk writes none when it could not run | `report`, `classes` |
| `orphanTodos` | debt markers outside the plan graph: one naming no plan, and one naming a plan that is not in the plans directory. Leading zeroes are a spelling, so `(021)` finds `21-…md`. Fixtures, dependencies and build output are not read | `markers`, `plans`, `roots` |
| `commentLines` | the source lines carrying a comment, as `oxc-parser` lists them: a `*` inside a template literal or JSX text is not a comment, a `// why` after code is one, and a line is counted once however many comments it holds. A file the parser refuses, or a `.vue`, `.svelte` or `.astro` file, stops the count and is named. Fixtures, dependencies and build output are not read, and `ignore` names the generated trees whose directory is not called `dist` — a path relative to the repo root, empty by default, because a number that moves with whether the build has run is measuring the build | `roots`, `ignore` |
| `suppressionCount` | the `eslint-disable`, `oxlint-disable`, `biome-ignore`, `@ts-expect-error` and `@ts-ignore` markers in code files — law 3 as a number that may only shrink, for a repo that arrives carrying hundreds and cannot adopt it as a wall on day one | `roots` |
| `mirroredRowTypes` | the TypeScript types kept by hand beside a D1 table: an interface or type literal whose every field is a column of one table (snake_case or camelCase), its primary key among them, at least three. The table is the one wrangler would build: every `release.migrations` entry of dialect `sqlite`, applied in an in-memory SQLite in wrangler's order. A type the schema derives (`typeof <table>.$inferSelect`) is not one. Refuses when no sqlite directory is declared or a migration will not apply | `roots` |
| `catalogueArguments` | the messages of a language that name other ICU template arguments than the source language's message under the same key (`{name}`, the subject of a `plural` or `select`, and everything inside their options; a rich-text tag `<b>…</b>` by its name, `<b>`, because the formatter needs a value for it and throws without one, and everything inside it; a `#` is no argument). Each message is read by `@formatjs/icu-messageformat-parser`. A message that is not valid ICU is counted. A key a language has not translated is not (that is completeness, not arguments). `exceptions` is `{ "<locale>": { "<dotted.key>": "<reason>" } }`: a message allowed to differ because its language's grammar asks for it, and an exception that stops differing, or names a key or language that is not there, is counted, so no reason outlives its case. Refuses when `source` or `catalogues` is missing, the pattern has no `{locale}`, the source language has no catalogue, or a catalogue is not JSON | `source`, `catalogues` (a path with one `{locale}`, `messages/{locale}.json`; `{locale}` is one path segment, so `messages/archive/en-old.json` is no language), `exceptions` |
| `adoptionSeamLines` | the lines of the files `geonosis.json` → `adoption.seams` names: what a consumer still writes to hold a floor's shape, which adoption brings down. No seams declared reads 0 | — |
| `floorSurface` | the names a floor package's published `.d.ts` files export, read off the entries its `exports` map promises — every door is a line to learn and a promise to keep. A map promising no declaration, or one that is not built, is a refusal, never an empty surface | `dir` |
| `doctorWarnings` | the distinct WARNs of `geonosis doctor --json` — one per check and rule, however many configs repeat it — refusing output with no findings list: a doctor that could not examine the tree is not a clean one. Its FAILs are the doctor's own exit code, not this number | `command` |
| `bumpDurationMs` | how long the last `geonosis update` took, from the `bump-report.json` it wrote; a repo that never ran one is a refusal, not a bump that took no time. **Tolerates.** | `report`, `tolerance` |
| `timeToGreenDoctorMs` | how long that bump took to reach a green doctor — the part a consumer feels. A bump whose doctor was red, or never ran, has no time-to-green. **Tolerates.** | `report`, `tolerance` |
| `openBacklogRows` | the rows of a register not marked done. **Report it; never ratchet it** — it rises every time an adoption is measured honestly, and a shrink-only gate over it would teach a repo to file less | `path`, `done` |

### `tolerance`, and the counters that accept one

Most counters count findings, where one more is one too many. `bundleBytes`, `fastTierMs`,
`bumpDurationMs` and `timeToGreenDoctorMs` measure something that moves on a dependency patch or a
slower machine nobody chose, and a gate that fails on +40 bytes is a gate that gets switched off
within a week. `tolerance` is the fraction of the baseline such a number
may drift up before the ratchet calls it growth:

```jsonc
{ "counter": "bundleBytes", "command": "…", "tolerance": 0.1 }  // +10 % is noise, +11 % is debt
```

**Only a counter that declares `tolerates` accepts one, and only those above do.**
Anything else stops the run:

```
geonosis-ratchet: "oxlintErrors" does not accept a tolerance — only a counter that measures a
quantity declares one
```

That refusal is the point. A band around a findings count is not a tolerance, it is the gate turned
off from the config file: `"tolerance": 100` on `oxlintErrors` and the run still prints PASS. It is
law 2 — never downgrade a rule — wearing a friendlier name, and it would be the easiest edit in the
repo to get past a reviewer. Whether a number is a quantity or a count is the counter's to know, not
the config's, so the counter declares it.

It forgives noise upward, never downward: a shrink of any size is still a shrink and still lowers
the baseline, or the number would stop following the artefact down. A `tolerance` that is not a
non-negative number stops the run and says which entry it was — one that silently became `NaN`
would make every comparison false, which reads exactly like a counter that can never grow.

The `geonosis` Claude Code plugin puts any write that introduces or raises one in front of a human
(`permissionDecision: "ask"`): a band somebody chose and a band somebody's agent chose are not the
same thing.

Tolerance is also why a rewrite lowers only the numbers that **shrank**. While `held` implied
`now === baseline`, writing every measured key back was a harmless no-op; with a tolerance it is
not, and a counter that grew inside its tolerance would have had that growth laundered into its new
floor by the first unrelated win.

Two example configs from real repos live in `examples/` in the repository.

## Programmatic use

```ts
import { COUNTERS, formatReport, runRatchet } from '@geonosis/ratchet'

const result = await runRatchet({ counters: COUNTERS, cwd: process.cwd(), tier: 'fast' })
process.stdout.write(formatReport(result))
```

`counters` is a plain array, so a repo can add its own `{ id, run }` beside the built-ins.
`parseJsonc` reads a JSONC file the way oxlint and wrangler do (`jsonc-parser`): a comment between
two tokens is refused, a line ending in a carriage return alone is read, and the `SyntaxError` it
throws names the error and its line.

## Two things this cannot see about itself

A baseline is lowered **in place** when a number shrinks; that is what locks a win in, and it is
also what lets a branch write any number it likes and stay green on every gate it runs. And a
workspace whose suite no `testFailures` entry covers is unmeasured, which reads exactly like green.

[`@geonosis/doctor`](https://www.npmjs.com/package/@geonosis/doctor) asks both from outside — the
baseline at HEAD against another ref, and which workspaces have a test script nothing reads a report
from:

```bash
npx geonosis doctor --baseline-against origin/main --strict
```

## The pairing rule (#136)

A scoped fast tier (changed files only) is safe exactly when the full tier is TOTAL: every hard cap
— max-lines, bundle bytes, suppression counts — needs a full-tier counter watching the whole tree,
or it is a cap in prose that a file can sit over indefinitely. Two max-lines caps were breached in a
Medusa storefront with nothing noticing, because the cap was a finding only a tool the full tier
never ran could see. `oxlintRule`, `suppressionCount`, `bundleBytes` and the other counters exist to
be that check, in a tier `geonosis verify` runs over the whole tree.

Apache-2.0.
