@geonosis/ratchet 2.3.1 → 2.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,630 @@
1
+ # @geonosis/ratchet
2
+
3
+ ## 2.4.2
4
+
5
+ ## 2.4.1
6
+
7
+ ## 2.4.0
8
+
9
+ ### Minor Changes
10
+
11
+ - 3b7b2ae: A `doctorWarnings` counter: the warnings `geonosis-doctor` reports about the repo it runs in, as a number that may only fall. The kit lints itself with its own preset and ratchets itself with its own counters, and pointed the doctor at consumers only — so its findings reached a session-start hook, where nothing fails a gate, and an unfalsifiable FAIL sat unread for as long as the marker had existed.
12
+
13
+ Its unit is the RULE, not the finding. The doctor reports a rule that cannot be exercised once per config that enables it, which is right for a reader — each config is its own edit — and wrong for a ratchet: a repo loading the plugin in twelve configs would read thirteen rules as one hundred and fifty-six, and adding a legitimate twelfth config would fail the gate for a good change. That is the shape that retired `openBacklogRows`, where a shrink-only number over a quantity that rises honestly teaches people to stop being honest. Measured here, it is the difference between 30 and 17. The check stays part of a warning's identity, so two checks with something to say about one file remain two pieces of debt.
14
+
15
+ The FAIL count is deliberately not this counter's business: the doctor's own exit code is, and a repo runs the doctor as its own gate step so the reader gets its tail rather than a counter's summary of it.
16
+ - 56e0f33: A counter the baseline has never held is ADOPTED rather than refused. It used to throw `has no number for "<key>" — add it before enabling the counter`, and the D-007 write guard refuses the agent exactly that edit, because everything that scores the agent is written by the runner — so two of the kit's own rails together made a counter impossible to enable without breaking one of them. The runner measures it, writes it, and reports `ADOPTED` on its own line.
17
+
18
+ What it does NOT do is call that a pass. The run exits 1 and says the number was measured for the first time and not judged: a first reading can be anything (a mis-escaped `match` reading 0 was banked as a floor once), and a floor nobody has looked at is not a floor anybody agreed to. The second run is the one that judges, against a number a reader has now seen. Nothing is adopted over a refusal, because a run that could not measure one counter has not earned the right to write any number.
19
+
20
+ This loosens a refusal rather than tightening one, so no configuration that worked before stops working.
21
+
22
+ ### Patch Changes
23
+
24
+ - bef73fb: Every shim tells a tree that was never built from a build that fell behind. `newest(dist)` answers 0 for a directory that is not there and for one that is merely old, so both conditions were one comparison and one sentence — and the staleness sentence was printed over trees that had never been built at all. It blocked a session's turn in a fresh git worktree, which has no `dist` because `dist` is not committed, with a message about a build going stale. Only one of the two is evidence of a mistake, and the two send a reader to different places.
25
+
26
+ The test that holds it plants BOTH cases and requires different sentences, which the previous one never did: it planted only a stale `dist`, so the check had never been shown a case whose answer should differ. Named for what it is, since the same gap produced two wrong published numbers the same day — a probe never shown a differing case has not been shown to discriminate, which is this repo's own "a gate never seen red has not been shown to measure", one layer down, applied to the thing doing the observing.
27
+
28
+ (This changeset names four packages of the fixed group deliberately: the group versions as one number and a changeset's description lands only in the packages it names, so naming several puts it in more than one place a reader might look. See #356.)
29
+
30
+ ## 2.3.1
31
+
32
+ ## 2.3.0
33
+
34
+ ### Minor Changes
35
+
36
+ - 3dac9a7: **`archViolations` declares `match` as a reading param (#30).** Its shipped probe plants a scanner
37
+ printing `✗` lines, so it proves the command and never a `match` a repo configured for its own
38
+ scanner's shape — the one counter with a reading param that escaped #140's mechanism. A configured
39
+ `match` now reads UNPROVEN until the entry declares `probe: { sample, expect }` beside it, and the
40
+ README says what a probe does and does not prove.
41
+ - 3dac9a7: **A `dx` counter family, fed from the kit's own reports (#93).** `bumpDurationMs` and
42
+ `timeToGreenDoctorMs` read `.geonosis/bump-report.json` — which now records how long the bump took
43
+ and how long each step that RAN took; `openBacklogRows` counts the rows of a register nobody has
44
+ closed, `path` required and `done` the repo's own word. A bump whose doctor was red has no
45
+ time-to-green, and all three refuse rather than reading a zero.
46
+ - 3dac9a7: **One `--since`, and a report a machine can read (#97).** `--since` names a plan number, a day and
47
+ a git ref in three commands, and all three took any string: `plan check --since garbage` exempted
48
+ nothing and printed the verdict it would have printed with no flag, `stats --since notadate` put
49
+ every recorded day outside the window and printed `0 turns` as a pass rate. One reader now declares
50
+ which kind each means and refuses an unreadable value by name. `geonosis-ratchet`,
51
+ `geonosis-verify-arch` and `geonosis-lint-parity` take `--json`: the same report, on stdout,
52
+ whatever the verdict — `--json` with an explicit `--format` is refused rather than resolved.
53
+
54
+ ### Patch Changes
55
+
56
+ - 3dac9a7: **Portability measured rather than assumed (#100).** The machine-wide heavy lock honours
57
+ `XDG_CACHE_HOME` instead of hardcoding `~/.cache`, ignoring a relative value as the spec requires.
58
+ `onPath` replaces the containment lookup that joined its argument into a `shell: true` command
59
+ line: a `sandbox-exec`/`bwrap` under a directory with a space in its name read as absent and failed
60
+ the run open.
61
+ - 3dac9a7: **One documented spelling (#90).** Every README of a package the metapackage fronts now names
62
+ `geonosis <tool>` — the door that pins the whole toolchain at one version — beside the bare bin, and
63
+ a `tooling/` test holds it. `#230`'s stamp test lands with it: every flag `docs/orchestration.md`'s
64
+ adoption loop names has to carry the version it first ships in, because the loop once told a
65
+ consumer to run a flag the published version did not have.
66
+
67
+ ## 2.2.0
68
+
69
+ ### Minor Changes
70
+
71
+ - d15a654: **A floor adoption is measured (#280).** The user's rule — a floor is adopted the day it deletes
72
+ more than it adds — had no instrument: during.day's four db floors came out +222/−225 and its
73
+ workflows floor +1807/−1493, and both numbers were read by nobody until they were asked for. A
74
+ consumer declares `adoption.seams: [globs]` in `geonosis.json` (default none, and a repo that
75
+ declares none gets no figure rather than a figure about everything). `geonosis update` then reports
76
+ lines deleted against lines added over those globs, `git diff --numstat` between the commit the bump
77
+ started from and the working tree, in the verdict and in the bump report. `geonosis-ratchet` gains
78
+ `adoptionSeamLines`, the lines those globs still hold — a number the ratchet already refuses to let
79
+ grow. A snapshot records the same figure for its consumer and `geonosis-release adoption` carries it
80
+ per consumer (MEASURED: a consumer's `.geonosis/` is an excluded directory, so their own bump report
81
+ never reaches a recording — the figure is taken where the tree is read).
82
+ - d15a654: **`floorSurface`: a floor's public surface is a ratchet (#294).** `createConnections` has five doors
83
+ where a consumer needs two, and #248 was fixed by making one of them open its own frame rather than
84
+ by removing a door — every added door is a line to learn, a hole to exam and a promise the next
85
+ major has to keep, and nothing made the growth visible while it happened. The counter reads the
86
+ exported names out of the built declaration file each door in `exports` promises — declared in
87
+ place, re-exported from a chunk, or listed in a brace group, since a bundler exposes a name all
88
+ three ways — and refuses a package whose promised `.d.ts` is not built rather than reading an
89
+ unbuilt surface as empty.
90
+
91
+ ### Patch Changes
92
+
93
+ - d15a654: **An envelope writer refuses a root it has been told is not its (#241).** A suite ran one over the
94
+ SHARED repo root mid-run: parallel suites made the comparison racy, and the file it left behind is
95
+ one the next run reads as real. Every one of the seven writer copies now refuses to write when
96
+ `GEONOSIS_ENVELOPES_FORBIDDEN_ROOT` names the root it was pointed at, saying so and pointing at a
97
+ scratch root instead. The kit's own vitest config sets it to the repo root, so a test that writes an
98
+ envelope there names itself — which is how the offender that filed this row was found again.
99
+ - d15a654: **`knipIssues` cites the issues, not just the count (#269).** The same tree read 5 on CI and 6 on a
100
+ laptop and the envelope carried the number alone, so nobody could see which line had moved. The
101
+ counter now cites every issue knip printed under the heading it printed it under — quoted, never
102
+ re-parsed — and the `Unresolved imports` class names what did the resolving: the command and the
103
+ runtime that ran it, because an unresolved import is a fact about a resolver and not about a tree.
104
+
105
+ ## 2.1.0
106
+
107
+ ## 2.0.0
108
+
109
+ ### Minor Changes
110
+
111
+ - 97bc8e9: **A debt marker names the plan that owns it.** New rule `todo-names-its-plan` (preset `tooling`): a marker must cite a plan — `TODO(025):` — or it is work nobody scheduled. The marker words are an option and the plan-id shape is an option, both empty-by-default, and enabled with no markers the rule refuses the run. Comments come from the PARSER, so the word in a string literal or after the `//` of a URL is not a finding. New counter `orphanTodos` counts what a repo already has — markers naming no plan, and markers naming a plan that is not on disk — so the law can be adopted as a ratchet rather than as a sweep. The kit enables the rule on itself; every example in the rule's own prose cites a real plan, because exempting a rule's source from the rule is the exemption that never comes back.
112
+
113
+ ### Patch Changes
114
+
115
+ - 74ae3ec: **`knipIssues`'s refusal names the third way out (#206).** A configured `headings` list that drops a heading knip printed is refused — right, #178 — but the text offered only "add it" or "excuse it", and a repo that never meant to narrow anything read it as an instruction to enumerate every heading it has. The refusal now also says: remove `"headings"` altogether and count every heading knip prints.
116
+
117
+ ## 1.4.0
118
+
119
+ ### Minor Changes
120
+
121
+ - ce2167f: **`suppressionCount` — lawLineCount's sibling (#134).** Occurrences of eslint/oxlint/biome-disable, @ts-expect-error and @ts-ignore across source files (fixtures, deps and prose excluded; `roots` scopes it), shrink-only like every number. A repo arriving with 261 suppressions cannot adopt law 3 as a wall, but it can adopt it as a ratchet.
122
+ - 0d99139: **`--prove` exercises the reading the config asks for (#140), and a match that reads nothing refuses (#141).** during.day's live incident: a mis-escaped `match` read 0 from the real command, prove said PROVEN off a plant the configured match never saw, and the ratchet lowered the baseline — ten pieces of debt erased with a congratulatory message. Now: a counter declares its `readingParams`; a config customizing one must declare `probe: { sample, expect }` beside it (the sample its reading must count) or the prove line says UNPROVEN and the run does not pass; and `sumOfCounts` throws, naming the match and the first line, when output is present and the match reads none of it — nothing-found and could-not-read are different answers.
123
+ - 5df33fd: **The report envelope, balanced at write time.** `writeEnvelope` writes `{ tool, version, considered, read, refused[], excused[], findings[], durationMs }` to `.geonosis/envelopes/<tool>.json` and refuses — throwing, writing nothing — unless `considered === read + refused.length + excused.length`. Four denominator bugs landed in one day, all of them green, all of them green because the tool published the numerator and nobody published the denominator. A run that has lost count of its own inputs now renders no verdict at all rather than a wrong one. `versionOf(import.meta.url)` answers the same in `src/` and in the bundled `dist/`, so an envelope names the build that wrote it.
124
+ - a430c81: **A counter that blew up no longer hides the ones behind it, and the run writes its envelope.** `runRatchet` collects every counter it could not measure into `refusals` instead of throwing on the first, so one broken tool no longer leaves the rest of the gate unrun and unreported — the fix `migrations` already made for an unreadable file. The run still fails, at exit 2, and each refusal now names its cause AND the command that reproduces it, read back off the run rather than guessed from the config. The baseline rewrite is suppressed whenever anything refused: a win locked in over a census that only half happened buys a lower floor with a number nobody took. `geonosis-ratchet` writes `.geonosis/envelopes/ratchet.json` — every configured counter leaves by exactly one door, measured, left out by `--tier`, or refused — and refuses to render a verdict at all if those do not add up.
125
+ - 3de7f28: **One answer to "where does the baseline live" (#120).** `resolveBaseline(cwd)` is exported from `@geonosis/ratchet` — the ratchet config's `baseline`, else the default, never a throw. The rails deny rail now denies the baseline where `geonosis.ratchet.json` says it LIVES (the default alone let a tampering write land, measured on sandbox-exec, on a repo whose baseline is `scripts/gate-baseline.json`); the mcp preamble reads counters from the declared path instead of saying "no counter is ratcheted" over twenty. The layer walls forbid rails and mcp importing ratchet, so each carries a ten-line reader pinned behaviour-equal to `resolveBaseline` by a conformance test — which also pins the class: the literal filename is greppable only in the resolver, the pinned readers, the writers of the default pair, and documented prose.
126
+
127
+ ### Patch Changes
128
+
129
+ - 8f5dc0c: **`cloneCount` says when its tool printed nothing at all.** The refusal used to read `no "Found N clones" line:` followed by an empty tail, which tells a reader neither what happened nor what to run — a tool that printed nothing and a tool that printed a paragraph this cannot parse are two different problems. Found by the new refusal-law contract test, which drives each refusal the envelope law introduced and requires the sentence to carry a cause worth acting on and the command that reproduces it.
130
+ - d23aef3: **Each tier records its own gate report (#121).** `geonosis-verify <tier>` writes `.geonosis/gate-report.<tier>.json` beside the shared latest-run report (which stays, for every reader that wants "what ran last"). `fastTierMs` reads its own tier's file first — `fast` straight after `full` refused deterministically while every step printed ok (dielime, reproduced on purpose). The shared file remains a fallback for trees written by an older verify, but a wrong-tier fallback is a refusal naming the file the right tier writes.
131
+ - 565db59: **Regression evidence cites the lines the counter counted (#122).** A counter may now declare `evidence(output, params)`, and the report prints THOSE lines under `<-- REGRESSED` instead of the tail of the run. `oxlintRule` cites findings attributed to its own rule, `oxlintErrors` cites errors and `oxlintWarnings` warnings — dielime read a +2 on one rule with two unrelated WARNING findings underneath it, because those were the last lines oxlint printed while the two real errors sat higher up. The count was right the whole time; the lines sent the reader to the wrong files. A counter that names nothing, or recognises nothing in a run, still falls back to the tail.
132
+ - ef29fc0: READMEs: a tool catalog publishes value vocabularies, not just field names — a zero from a wrong filter reads as good news (#135); and the pairing rule — a scoped fast tier is safe exactly when the full tier is total, every hard cap needs a full-tier counter (#136). Both measured in dielime.
133
+
134
+ ## 1.3.0
135
+
136
+ ### Minor Changes
137
+
138
+ - 2d1d841: **`geonosis-ratchet --help` ran the full ratchet (#66).** There was no usage, no `--help` branch and
139
+ no unknown-flag rejection: everything unrecognised fell through to a complete measurement — a run
140
+ that can rewrite `gate-baseline.json`, started by a typo. Measured at 141 s on the kit's own tree.
141
+ It now prints its usage at exit 0 for `--help`/`-h`, and refuses any flag outside the known set by
142
+ name at exit 2, before the lock is taken.
143
+
144
+ **It also answers `--print-config-shape` (#75)**: the shape of `geonosis.ratchet.json` and `gate-baseline.json`, including why two counters may not write one key.
145
+
146
+ **The README names the NODE_OPTIONS/nested-pnpm trap (#63)**, with the `env -u NODE_OPTIONS` shape and the doctor line that asks for it.
147
+
148
+ ### Patch Changes
149
+
150
+ - db1cebd: **The "a scan that could not run is not a clean scan" answer is pinned, permanently (#80).**
151
+ during.day removed `geonosis.verify-arch.json` deliberately to see what the gate would say, and
152
+ `archViolations` refused rather than reading zero findings. That answer may never regress: the
153
+ failure it prevents is silent and self-reinforcing — a zero the ratchet BANKS, rewriting the
154
+ baseline down off a scan that never happened, green for ever after. The fixture runs the real
155
+ scanner in a directory with no config and the real counter over exactly what it printed; a captured
156
+ sample would prove the regex and not the pair, and the pair is what broke. Shown to fail against a
157
+ planted regression before it was kept.
158
+ - f37950f: Counter output capture no longer drops the tail of large outputs: `spawnSync` replaces the merged subshell, both streams arrive whole (stdout then stderr — cross-stream interleave was never promised on the error path and is no longer promised on success), and a 200k-line output keeps its summary line (dielime Block D, #111).
159
+
160
+ ## 1.2.0
161
+
162
+ ## 1.1.0
163
+
164
+ ## 1.0.1
165
+
166
+ ### Patch Changes
167
+
168
+ - 1c2ee0b: **Every package ships its declarations and says where they are.** `@geonosis/visual-diff` and
169
+ `@geonosis/verify` emitted no `.d.ts` at all (TS7016 in a consumer: the comparator every visual test
170
+ calls became `any` at the one seam that decides whether a screenshot regressed), and no package
171
+ declared `types` — the nineteen that worked did so through the sibling-file fallback, which ends the
172
+ day `exports` gains a condition. Every export now carries a `types` condition beside `default`, every
173
+ manifest a `types` field, and `tooling/types.test.ts` holds all twenty-one to it after the build.
174
+
175
+ `geonosis-verify-arch` prints a check once when its label is its id ("direction, orphan", not
176
+ "direction direction").
177
+
178
+ ## 1.0.0
179
+
180
+ ### Major Changes
181
+
182
+ - e6a54d5: **`archViolations` stops reading "could not measure" as zero.** A scanner that found something exits
183
+ non-zero. So does a scanner that could not run at all, and the counter told them apart by neither —
184
+ it counted marker lines and returned the number, so a run that printed nothing because it never
185
+ happened came back 0.
186
+
187
+ That is the silent zero at its most expensive, because the ratchet BANKS it: a baseline of 4 gets
188
+ rewritten down to 0 and the commit that "earned" it is a config with a typo in it.
189
+ `geonosis-verify-arch` exits **2** for exactly that — no config, an unknown pack, an unknown format
190
+ — and under `--format ratchet` prints nothing while doing so. Its exit 2 is now visible.
191
+
192
+ A non-zero exit with zero matching lines is a `CounterError` naming the command, the status and the
193
+ tail of what it printed. A non-zero exit WITH findings still counts them, which is the normal
194
+ reading and the half that must not become collateral: a scanner exiting 1 because it found four
195
+ things is the tool working.
196
+
197
+ The probe changed with it. It used to `cat` a captured sample, which proved the regex and nothing
198
+ else — a real scanner printing another shape still read 0 and the probe still said PROVEN. It now
199
+ plants a scanner and runs it: findings on stdout, a non-zero exit because it found them, and the
200
+ counter has to come back with the number.
201
+ - 9858ee5: `error-page-status`, a ninth probe from a production incident: a page whose body is an error surface
202
+ and whose status is healthy. Every curl-style health check, uptime monitor and load balancer reads
203
+ such a page as fine while nobody can use it. The status comes from the page's own response, so
204
+ `ProbeContext` gains `status` — a second request would ask a different question of a server that may
205
+ not answer twice the same way. `patterns` is required (D-031); a page that answered 400 or worse is
206
+ a server telling the truth, and the walk refuses that URL on its own. `walkFindings` counts the new
207
+ class.
208
+ - c575f44: Five counters, `tolerance`, and a baseline rewrite that only ever goes down.
209
+
210
+ - `disabledCiJobs` — lines of `if: false` across the workflow files (`dir`). A job switched off to
211
+ get a release through is debt exactly like a lint error: invisible, because the pipeline goes
212
+ green. A condition that merely mentions `false` is not one, and a repo with no workflows
213
+ directory reads 0 rather than refusing; a workflow that is there and cannot be read refuses.
214
+ - `bundleBytes` — one integer out of whatever the repo's sizing command printed (`command`,
215
+ `match`), separators and all. With `match` it takes the group that pattern names and refuses when
216
+ it matches nothing, rather than falling back to any integer and reporting the wrong number as
217
+ confidently as the right one.
218
+ - `fastTierMs` — `finishedAt − startedAt` from the gate report `geonosis-verify` wrote (`report`,
219
+ `tier`). It refuses a report of another tier: the full tier takes minutes, and timing it against
220
+ the fast tier's baseline would report a regression of the wrong thing.
221
+ - `testsWithoutRunner` — workspaces holding tests with no `test` script. Unmeasured and passing are
222
+ indistinguishable from outside, so the count of unmeasured things has to be a number of its own.
223
+ - `packagesWithoutTypecheck` — workspaces with no `typecheck` script (`script`).
224
+
225
+ `tolerance` is new on the verdict: the fraction of the baseline a number may drift UP before the
226
+ ratchet calls it growth. Bytes and milliseconds move on a dependency patch nobody chose, and a gate
227
+ that fails on +40 bytes is a gate that gets switched off. It forgives noise upward only — a shrink
228
+ of any size still lowers the baseline — and a `tolerance` that is not a non-negative number stops
229
+ the run naming the entry, because one that silently became `NaN` reads exactly like a counter that
230
+ can never grow.
231
+
232
+ **Fix:** a baseline rewrite now lowers only the numbers that SHRANK. Writing every measured key back
233
+ was a harmless no-op while `held` implied `now === baseline`. Tolerance breaks that implication, and
234
+ the blanket rewrite would have laundered growth-within-tolerance into the new floor on the first
235
+ unrelated win — a baseline going up on a pass.
236
+
237
+ `examples/dielime.ratchet.json` is dielime's Phase 3 config: 36 keys, one `testFailures` entry per
238
+ workspace. It keeps `--format=unix` and `expectFormat` on the two oxlint counters, which dielime's
239
+ own config drops — the summary cross-check can tell a misparse from a clean run only when the run
240
+ printed a summary, and a warnings-only run in an unreadable shape prints one that says nothing
241
+ about errors. Pinning the format is what closes that window.
242
+ - 90d38ec: **`geonosis-backend` — the landmine list as the mechanical layer for backend work.** Forty-six
243
+ failures that read as success, each as symptom → cause → detection → fix, grouped by the engine it
244
+ is TRUE of: nineteen `medusa`, two `sagaflow-cf`, twenty-five `any`. You arrive with a symptom and
245
+ leave with a fix; that is the whole design, and it is why the entries are a table and not prose.
246
+
247
+ Every entry points at the kit rule or counter that catches it — `emit-declares-attempts`,
248
+ `plugin-route-namespaced`, `named-when-block`, `mutating-step-declares-retry`,
249
+ `store-route-scopes-tenant-data`, `no-d1-transaction`, `step-opens-its-own-cell`,
250
+ `queries-require-org-scope`, `no-unregistered-plugin-dir`, `no-mock-db-in-integration`,
251
+ `layer-walls`, `ssot-no-process-env`, `disabledCiJobs`, `testsWithoutRunner`, `knipIssues` — or says
252
+ **`no gate yet`**, and those rows are the backlog's next rules said out loud rather than omitted
253
+ into what would read as full coverage.
254
+
255
+ Every entry cites a document in this repo: Medusa's seventeen (`docs/analysis-medusa-2026-08.md`
256
+ §4), Midday's eighteen (`docs/analysis-midday-2026-08.md` §4), nine consumer landmines from
257
+ `docs/rules-backlog.md`, and two engine facts `RULES.md` states as the reason two presets exist. A
258
+ landmine that could not be cited was dropped.
259
+
260
+ Pinned by a test that PARSES it rather than reading it: the count, all four fields per entry, a
261
+ source path that exists on disk, and a gate name that the plugin or the ratchet actually exports —
262
+ so a rule renamed in the plugin fails the suite instead of leaving the skill pointing at a gate
263
+ nobody has. The two analyses' own numbering is read from the analyses, so an eighteenth Medusa
264
+ landmine fails the build until the skill carries it.
265
+
266
+ Version-only entry for `@geonosis/ratchet`: the plugin's manifest takes its version from that
267
+ package, and the release group moves together.
268
+ - c418f91: The `geonosis` Claude Code plugin — the method as enforcement rather than advice.
269
+
270
+ `plugin/` ships five hooks, Node ≥ 22, zero dependencies, every tool resolved from the CONSUMER's
271
+ `node_modules/.bin` so a repo that has not adopted a tool is never blocked on a tool it lacks:
272
+
273
+ - **SessionStart** runs `geonosis-doctor` if it is installed and prints a one-line digest of
274
+ `.oxlintrc.json` — which plugins are loaded, how many architecture rules are at `error`. It never
275
+ fails a session.
276
+ - **PreToolUse / Bash** refuses `prettier` (law 4) and `--no-verify` (law 7), and hands a
277
+ `git commit` to `geonosis-ledger` — the message its `-m` flags compose, and whether the staged
278
+ diff delivers anything but comments. The detectors are the ledger's, never reimplemented here; a
279
+ repo without it gets a note and no opinion.
280
+ - **PreToolUse / Edit|Write|MultiEdit** refuses a write that ADDS `eslint-disable`,
281
+ `oxlint-disable`, `@ts-ignore` or `@ts-expect-error` (law 3), any write to `gate-baseline.json` or
282
+ under `.geonosis/` (D-007 — the scored agent never writes the scoreboard), and a lint config that
283
+ sets a rule to `"warn"`, `"off"`, `0` or `1` (law 2). It reads what the call would ADD, never what
284
+ it removes, so the edit that DELETES a suppression is not refused by the guard against them.
285
+ - **PostToolUse / Edit|Write** runs oxfmt on the file just written. It never blocks.
286
+ - **Stop** runs `geonosis-verify fast` and will not let the turn end on a red one, capped at five
287
+ blocks per session — an agent retrying the same broken thing five times has a problem it will not
288
+ solve on the sixth, so the sixth prints the report and lets go.
289
+
290
+ `.claude-plugin/marketplace.json` offers it from `./plugin`. `pnpm generate:skill` now writes the
291
+ plugin's copy of the mechanical skill, the engineer agent with the machine-specific path taken out,
292
+ and `plugin.json` carrying the kit's own version; staleness tests fail when any of the four drift.
293
+ - 5f09ddc: Every hook is driven end to end, as Claude Code drives it.
294
+
295
+ Each script is SPAWNED with real stdin JSON over a throwaway repo carrying a `geonosis.json`, a
296
+ `gate-baseline.json`, an `.oxlintrc.json` and a `node_modules/.bin` linked to the BUILT
297
+ `geonosis-verify` and `geonosis-ratchet`. A hook tested by calling its policy function proves the
298
+ policy; only a spawn proves the exit code, the JSON on stdout and the tool resolution — which is
299
+ the whole of what Claude Code actually reads.
300
+
301
+ The Stop hook is exercised against a real fast tier that really goes red: a law file one line over
302
+ its baseline, so the ratchet grows and the hook blocks with exit 2, the failing step and its tail on
303
+ stderr. Then the sixth block prints the report and exits 0; a green run clears the session's count;
304
+ `stop_hook_active` short-circuits; and a repo with a BACKLOG that did not regress is not blocked at
305
+ all — which is the whole adoption argument, tested rather than asserted.
306
+
307
+ The suite was falsified before it was trusted: making Stop exit 0 instead of 2, dropping the
308
+ delivery check, and raising the cap to 50 each turned exactly the tests that should have gone red,
309
+ red.
310
+ - 60f4d8f: **The law ceiling in both consumers, and an instrument for finding out whether cutting the law
311
+ helped.** `lawLineCount` is now in `examples/dielime.ratchet.json` and
312
+ `examples/during-day.ratchet.json`, in the **fast** tier — it reads one file, and a law that grew is
313
+ exactly the regression a turn should not be allowed to end on. Measured read-only 2026-08-30: those
314
+ two laws are **382** and **1,104** lines against guidance of 200, and those are the baselines each
315
+ repo starts from.
316
+
317
+ `geonosis-verify stats [--since <YYYY-MM-DD>] [--json]` reports the **first-try pass rate**: the
318
+ share of turn-ends whose fast tier was green on the first run. `--since` prints that window AND
319
+ everything before it, so a before/after comes from one command and one instrument.
320
+
321
+ The Stop hook now records what it gated. It had to: `stop-blocks.json` is the block cap's working
322
+ memory and a **green turn deletes its own row from it**, so a session that never blocked and one
323
+ that blocked and recovered are both absent — there was no history to read. The new
324
+ `.geonosis/stop-turns.json` is two integers a day, and the hook writes it itself because a hook is
325
+ fetched as a git tree with no install step and may import nothing; a test reads the hook's own output
326
+ back through `readStats` so the two spellings of the format cannot drift apart.
327
+
328
+ Three refusals, all the same refusal — **an empty record is not a perfect score.** No record says so
329
+ and gives no rate rather than 100 %; a record that will not parse is an error rather than zero turns;
330
+ and a turn the hook never gated (`stop_hook_active`, where the gate does not run) is not counted,
331
+ because there was no first try to have.
332
+
333
+ `docs/law-diet.md` is the M6 procedure end to end: arm the ceiling, record a before, cut in one
334
+ commit on a day you write down, read the after against the same instrument — and write down what
335
+ happened even when nothing moved, because a cut that changed nothing says the law was not the
336
+ binding constraint.
337
+ - 89c6769: The ratchet's own layers are walled, and the walls have a door that is proved open.
338
+
339
+ The three ratchet layers named FILE paths only, so `packages/ratchet/src/index.ts` — the published
340
+ entry — sat in no layer and could import anything, the CLI included, with lint green. It is
341
+ `ratchet-api` now, and the formatter it shares with the CLI has moved into a `ratchet-report` layer
342
+ of its own rather than being counted as part of the CLI it is not.
343
+
344
+ The dogfood suite gains a second kind of plant. A wall that refused everything would be as useless
345
+ as one that refused nothing, so each layer's permitted import — `verify` reaching the lock through
346
+ `@geonosis/ratchet` — is planted and asserted to stay CLEAN. Without that, the next engineer widens
347
+ `mayImport` to get their work through and the wall goes with it.
348
+ - b2d915f: **Fix:** a counter's command no longer loses what it wrote to stderr.
349
+
350
+ `<command> 2>&1` binds the redirect to the LAST command of the string. `echo boom >&2; echo fine`
351
+ therefore merged nothing, and on a ZERO exit `execSync` hands back stdout alone — so a tool that
352
+ prints its findings to stderr and exits 0 read as a tool that found nothing. That is the exact shape
353
+ of a gate that has quietly stopped gating, and it is the one direction in which this kind of bug is
354
+ dangerous: the number comes back lower than the truth and the baseline banks it.
355
+
356
+ On a NON-ZERO exit the line survived, because the failure path concatenates both captured buffers —
357
+ but concatenation put every stderr line after every stdout line, moving each diagnostic away from
358
+ the line it was about, which is what the evidence tail on a regression is for.
359
+
360
+ The command now runs in a subshell, so the shell itself merges the streams in the order they were
361
+ written. Measured before and after: `echo boom >&2` on a zero exit read `""` and now reads `boom`;
362
+ `echo one; echo two >&2; echo three; exit 4` read `one three two` and now reads `one two three`.
363
+ - 7762a01: **`testFailures` in summary mode reads the runner's summary LINE, not the word "fail" anywhere in
364
+ the output.** The regex was unanchored, so anything a command printed on its way to the runner was a
365
+ candidate count. Reproduced: a command that printed `naming 4 failing test(s)` and then a green
366
+ `Tests 3 passed (3)` was read as **four failures** — a green run regressing a baseline. That is the
367
+ false RED mirroring the false green `report: "vitest-json"` exists for; both come from reading
368
+ something that is not the runner's own last word.
369
+
370
+ Two dialects now, and both were captured from the binary rather than guessed at. vitest 3.2.7's
371
+ ` Tests 1 failed | 1 passed (2)` — its own label, in its own column, closed by the total in
372
+ parentheses — and bun 1.4.0's ` 1 fail` on a line of its own, which it prints on a green run too,
373
+ saying 0. Nothing else counts: `Test Files 1 failed (1)` counts FILES, `(fail) one [11.71ms]` is
374
+ bun naming one, and a wrapper's prose is a wrapper's prose. All three used to be read as failures.
375
+
376
+ Three consequences beyond the fix. A suite vitest skipped whole (`Tests 2 skipped (2)`) reads 0
377
+ instead of refusing — it is a run that happened and found nothing red. A suite that threw before
378
+ collecting (`Tests no tests`, beside `Test Files 1 failed (1)`) is now **refused** instead of
379
+ being read as one failing test: nothing there is a count of failing tests, and a gate that cannot
380
+ measure has not passed. And summaries **add up**, so a command that invokes the runner twice no
381
+ longer banks the second suite's reds as a win.
382
+
383
+ The refusal names both dialects, so a repo whose runner speaks a third one gets a message instead of
384
+ a wrong number. `--prove` grew a probe: `summary (vitest)`, `summary (bun)` and `vitest-json` are
385
+ each shown reading their own planted finding, because a dialect nobody proved is a dialect nobody
386
+ has been shown to read.
387
+ - 48bce0c: **Fix:** only a counter that measures a quantity accepts a `tolerance`.
388
+
389
+ Any counter took one. `{"counter":"lawLineCount","tolerance":0.5}` over a two-line law with a
390
+ baseline of 2 printed `ratchet PASS` at three lines — a 50 % growth, waved through. The same edit is
391
+ `"tolerance": 100` on `oxlintErrors`: the gate switched off from the config file, with the run still
392
+ reporting green. That is law 2 — never downgrade a rule — wearing a friendlier name, and it was the
393
+ easiest edit in the repo to get past a reviewer.
394
+
395
+ `Counter` gains `tolerates?: true`, declared only by `bundleBytes` and `fastTierMs`. Whether a
396
+ number is a measured quantity or a count of findings is the counter's to know, not the config's.
397
+ Anything else now stops the run, naming the entry:
398
+
399
+ ```
400
+ geonosis-ratchet: "oxlintErrors" does not accept a tolerance — only a counter that measures a
401
+ quantity declares one
402
+ ```
403
+
404
+ The plugin's write guard gains its first **ask**. A write to `geonosis.ratchet.json` that introduces
405
+ or raises a `"tolerance"` is neither refused nor allowed: it goes in front of the human as
406
+ `permissionDecision: "ask"`. Widening a band is legitimate and is not the agent's call — a band
407
+ somebody chose and a band somebody's agent chose are not the same thing. A tolerance carried through
408
+ an edit unchanged is not a change and says nothing.
409
+ - 461233c: Docs for the two new things: `docs/plugin.md` and `packages/verify/README.md`.
410
+
411
+ `docs/plugin.md` covers installing the plugin from a local path and from the remote once it is
412
+ pushed, what each of the five hooks does and the reason it does it that way, the five-block cap,
413
+ which files the runner owns and why the agent may not write them, what to put in `geonosis.json`,
414
+ and — the section every README skips — what the plugin CANNOT do. A `fast` tier of `["true"]` passes
415
+ for ever; only `geonosis-ratchet --prove` shows a counter can go red.
416
+
417
+ `packages/verify/README.md` is the runner's own: `--prove` first, the config with no default tier,
418
+ the three exit codes and why a report that could not be written is exit 2, the report shape with the
419
+ `<tier>/<n>` id rule, and `--exclusive`.
420
+
421
+ `docs/README.md` indexes both. The ratchet README gains rows for the five new counters, a section on
422
+ `tolerance` and the shrank-only rewrite it forces, and the landmine that cost an afternoon: a
423
+ loader or shim named RELATIVELY in a counter's command resolves to nothing under `--prove`, whose
424
+ probes run in a scratch directory. Write it `$PWD/…` and it works in both.
425
+ - e48501b: `@geonosis/verify` — one gate runner, with a report a hook can read.
426
+
427
+ `geonosis-verify <tier>` runs the shell steps a repo declared under `verify` in `geonosis.json`,
428
+ serially, stopping at the first red, and writes `.geonosis/gate-report.json`:
429
+ `{ tier, startedAt, finishedAt, ok, steps: [{ id, command, ok, exitCode, ms, tail, skipped? }] }`.
430
+ A step whose text is another tier's name is that tier, inlined in place; the inlined steps keep the
431
+ tier that declared them, so `fast/2` is the same command whether the operator ran `fast` or ran the
432
+ `full` tier that inlines it.
433
+
434
+ There is no default tier and there never will be one: no config, no `verify` object, or an unknown
435
+ tier is refused by name with exit 2, and so is a green run whose report could not be written — a
436
+ gate that cannot record has not passed. A failed step is exit 1.
437
+
438
+ `--exclusive` takes the ratchet's machine-wide lock through `@geonosis/ratchet`'s published entry
439
+ (`--exclusive-timeout <seconds>` bounds the wait), so a verify and a ratchet on one laptop serialise
440
+ against each other rather than each against itself. `--prove` plants a tier with one passing step
441
+ and one that exits 3, runs the real binary over it in a temp directory, and checks the failure
442
+ reached the report — a runner that reports green is worth nothing until it has been watched going
443
+ red on purpose.
444
+
445
+ The kit walls the new package with its own `layer-walls` rule: `verify` may import `ratchet-api`
446
+ and nothing behind it, planted against in the dogfood suite like the other walls.
447
+ - 7e8d2c1: `walkFindings`: the defects `geonosis-walk` reported, as debt that may only shrink. Reads the walk's
448
+ JSON report (`report`), counts findings across every page, and scopes to named classes with
449
+ `classes` — refusing a class the walk does not have rather than reporting a confident zero for a
450
+ typo. A missing or unparsable report is a refusal, never a zero (D-030): the walk writes no report
451
+ when it could not run, and answering zero there would make its loudest failure the best number on
452
+ the board.
453
+
454
+ ## 0.6.0
455
+
456
+ ### Minor Changes
457
+
458
+ - **No counter changed.** The version moves because the packages release as one fixed group.
459
+
460
+ - **Two things this package cannot see about itself now have somewhere to be seen from.** A baseline
461
+ is lowered in place when a number shrinks, which is also what lets a branch raise one back up and
462
+ stay green on every gate it runs; and a workspace whose suite no `testFailures` entry covers is
463
+ unmeasured, which reads exactly like green. `geonosis-doctor --baseline-against <ref>` asks the
464
+ first from outside the checkout, and its `runner` check names the workspaces the second applies
465
+ to. Measured on the two consumers the day it shipped: 28 such workspaces in one, 8 in the other.
466
+ See [`@geonosis/doctor`](https://www.npmjs.com/package/@geonosis/doctor).
467
+
468
+ ## 0.5.0
469
+
470
+ ### Minor Changes
471
+
472
+ - **New rule `cell-no-cross-feature-organelles` (preset `frontend`).** `organelle-dependency` is scoped
473
+ to `organelles/` and `no-cross-feature-stores` only judges stores, so a CELL importing another
474
+ feature's ORGANELLE passed both while both stayed green. Option `sharedFeatures`, default
475
+ `['shared']` — the same option and the same default as `organelle-dependency`'s, because the two
476
+ rules draw one boundary from two sides. Measured on a consumer: three findings in two files.
477
+
478
+ **The corpus carries both spellings of every direction rule.** A direction rule judges where an
479
+ import lands, so `@/features/billing/x` and `../../billing/x` are one breach; the corpus previously
480
+ carried whichever spelling each rule's author happened to write. Fifteen fixtures added. No rule
481
+ changed.
482
+
483
+ **`geonosis-lint-parity --corpus --expect-changed <rule,…>`.** A changelog line that names a rule is
484
+ a claim; this makes it data the corpus judges, and exits 1 on a claim the corpus is silent about or a
485
+ change nobody claimed. Mandatory for any release whose notes name a rule.
486
+
487
+ **`testFailures` reads the runner's JSON report** with `report: "vitest-json"`, refusing when the
488
+ report is absent, unparsable, missing `numFailedTests`, or says `success: false` while naming none.
489
+ `Counter.probe` takes a list, so `--prove` proves each reading mode by name.
490
+
491
+ **`geonosis-ratchet --prove` proves the lock**, by running two children of the CLI over a slow
492
+ `--hold` and requiring the second to start after the first finished.
493
+
494
+ `@geonosis/ratchet`'s source is layered — `core/`, `counters/`, `report.ts`, `cli.ts` — with no
495
+ change to what `@geonosis/ratchet` exports or to a single line the CLI prints.
496
+
497
+ ## 0.4.0
498
+
499
+ ### Patch Changes
500
+
501
+ - `--exclusive` did not serialise, and the test that should have said so was blamed for it.
502
+
503
+ `openSync(path, 'wx')` is atomic about the NAME only: the file it creates is empty until the holder
504
+ lands on the next syscall, and a run polling in that window reads a lock nobody can parse, calls it
505
+ stale — `taking over a stale heavy lock (pid unreadable is gone)` — removes it and takes it. Two runs,
506
+ one lock, both certain they held it. It reproduced three times in five on a loaded laptop, which is
507
+ the only kind of machine the lock is for.
508
+
509
+ The holder is now written under a private name and hard-linked into place: `link` fails `EEXIST`
510
+ exactly as `'wx'` did, and when it succeeds there was never an instant at which the lock existed
511
+ without its holder inside it. The staging name is dropped either way, so a contended run leaves
512
+ nothing behind in `~/.cache/geonosis/`.
513
+
514
+ The test's ordering is causal now rather than temporal: the holding run leaves its critical section
515
+ only when the test opens a barrier, and the test opens it when the OTHER run says it is waiting.
516
+
517
+ ## 0.3.0
518
+
519
+ ## 0.2.1
520
+
521
+ ### Patch Changes
522
+
523
+ - Gates prove they can fail (plan 019, K2.1–K2.3).
524
+
525
+ - **`geonosis-ratchet --prove`** — every counter ships a falsification probe: a known-bad input it
526
+ must be able to read. `--prove` runs each configured counter's probe in a temp directory, never in
527
+ the repo, and exits 2 on the first counter that reads 0, misreads, throws, or ships no probe.
528
+ A counter that has never been seen read a planted finding has not been shown to measure (D-029).
529
+ - **`--exclusive`** — a machine-wide lock at `~/.cache/geonosis/heavy.lock` before anything heavy,
530
+ with a bounded printed wait (`--exclusive-timeout`, default 1800s), stale-holder takeover, and
531
+ release on SIGINT/SIGTERM.
532
+ - **`typecheckErrors` refuses on an unbuilt workspace** — TS2305/TS2307 naming a package of this
533
+ workspace is a missing build, not debt, and a number there would be wrong (D-030).
534
+
535
+ The plugin and `@geonosis/lint-parity` carry no change in this release; they move because the three
536
+ packages are a fixed version group.
537
+
538
+ ## 0.2.0
539
+
540
+ ### Minor Changes
541
+
542
+ - Loose coupling, first slice — and a posture change that will break a config on purpose.
543
+
544
+ **`@geonosis/lint-parity` is a new package.** Findings parity, rule reach, the corpus-manifest
545
+ contract and the `geonosis-lint-parity` bin now live on their own. The tool knows no rule name and
546
+ no plugin: two oxlint configs and a tree are the whole input, and a corpus arrives as a directory
547
+ whose own `manifest.json` says which rules it speaks for. A parity tool welded to one rule plugin
548
+ can only ever check that plugin.
549
+
550
+ **BREAKING (plugin): the `geonosis-lint-parity` bin and the `./parity` export are gone.** Install
551
+ `@geonosis/lint-parity` and pass `--corpus node_modules/@geonosis/oxlint-plugin-biological-architecture/corpus`
552
+ — the plugin still ships the corpus, which is the part that is genuinely its own. `--corpus` now
553
+ requires its directory.
554
+
555
+ **BREAKING (plugin): a rule enabled without the options it needs refuses the run.** `no-brand-names`,
556
+ `no-raw-html-atoms`, `documents-share-one-table`, `document-sagas-are-generic`,
557
+ `step-opens-its-own-cell` and `layer-walls` throw from `create()` naming the missing option, instead
558
+ of returning an empty visitor. A consumer that enabled one bare will fail lint until it configures
559
+ the rule or turns it off — which is the point: one consumer ran `no-raw-html-atoms` at "error" for
560
+ months with no element→atom map, over 136 raw elements, and the gate was green. An enabled rule that
561
+ cannot fire is indistinguishable from compliance. A rule nobody enabled still does nothing.
562
+
563
+ **New rule `layer-walls`, new preset `tooling`.** A repo declares its own layers as data —
564
+ `{ name, paths, mayImport }` — and the rule enforces the direction between them: relative sources
565
+ resolved against the importing file first, `import()`, `require()` and both re-export forms visited,
566
+ externals not walls unless asked. The kit enables it on itself.
567
+
568
+ The plugin also ships type declarations now, for all three entries.
569
+
570
+ ## 0.1.2
571
+
572
+ ### Patch Changes
573
+
574
+ - Four findings from the 0.1.1 code review, plus a version that disagreed with itself.
575
+
576
+ **`--corpus` scopes its verdict to the rules its corpus can speak for.** It built the list of
577
+ configured rules from every rule id in both configs, but the shipped corpus only exercises this
578
+ plugin's. Any real consumer config also enables `eqeqeq`, `no-throw-literal` and
579
+ `typescript/no-explicit-any`, so `--corpus` reported `FAIL — fired under neither` on every
580
+ adoption. A corpus now declares its own scope in a `manifest.json` generated from the plugin's
581
+ rules export; rules it does not name are listed under "outside this corpus" with no verdict, and
582
+ neither the exit code nor `neither` considers them. `--corpus <dir>` points the run at another
583
+ plugin's corpus. Matching is on full `plugin/rule` ids on both sides, so
584
+ `typescript(no-unused-vars)` can never stand as evidence for somebody else's `no-unused-vars`.
585
+
586
+ **Parity identifies a finding by its place and its rule, not its wording.** `path:line:col` plus
587
+ `plugin(rule)`; message and severity are excluded. Diffing whole lines meant a rule whose message
588
+ was copy-edited read as one finding lost and one gained — exit 1, a stopped adoption over a
589
+ sentence. Those pair up in a new `messageChanged` list, reported under its own heading and written
590
+ to `changed.txt`, and never reach the exit code. Multiset semantics are unchanged.
591
+
592
+ **`oxlintRule` gets the cross-check the error and warning counters already had.** It counted
593
+ `(rule)` anywhere in the output, so a format that printed rule ids differently read as 0 and the
594
+ ratchet banked the "win" by rewriting the baseline down — and a quoted source line containing
595
+ `(a-rule-name)` counted as a finding. The count now comes off recognised finding lines only, and a
596
+ run with findings where none attributes itself readably is refused. The strict-config copy also
597
+ upgrades the `["off", { … }]` array form, which is how every rule with options is parked
598
+ mid-migration.
599
+
600
+ **The warnings-only window is documented and closable.** oxlint exits 0 when a run found only
601
+ warnings, so the counters' non-zero-exit backstop cannot catch an unrecognised format on such a
602
+ run. The README states this plainly and says why `--deny-warnings` is not the fix. New optional
603
+ counter param `expectFormat: "unix" | "agent" | "default"` refuses output in no known shape even on
604
+ a clean exit, once npm's own chatter is stripped. Unset is the default and changes nothing.
605
+
606
+ **The plugin reported `meta.version` 0.1.0 while shipping 0.1.1.** It is read from `package.json`
607
+ now, and a test asserts the two agree.
608
+
609
+ ## 0.1.1
610
+
611
+ ### Patch Changes
612
+
613
+ - The defects the first real adoption exposed.
614
+
615
+ - **The oxlint counters had never measured anything.** `oxlintErrors` and `oxlintWarnings` matched
616
+ `/: error /`, which is the shape of oxlint's compact `agent` format alone, while every config asks
617
+ for `--format=unix`. They now read all three shapes oxlint prints and cross-check the count
618
+ against the tool's own summary, raising `CounterError` rather than returning a number they cannot
619
+ corroborate — a blind counter reports 0 and lets the ratchet rewrite the baseline DOWN as a win.
620
+ - **`cell-must-not-compose-cell` fired on the barrel `cells-folder-index-is-barrel` demands.**
621
+ dielime's adoption reported 32 errors, every one a `cells/index.ts` re-exporting siblings, which
622
+ is the shape the other rule requires at the same severity. The two rules now read one shared list
623
+ of barrel filenames. A real cell importing or re-exporting another cell still fires.
624
+ - **`geonosis-lint-parity`.** The adoption proof — same tree, two configs, a findings diff — as a
625
+ bin instead of forty tool calls. Exits 1 when a finding the old config caught is missing under the
626
+ new one. `--corpus` answers the prior question first: does each configured rule fire anywhere at
627
+ all? The package now ships the reach corpus that makes that answerable, with a file for every one
628
+ of its 54 rules.
629
+ - **The ratchet shows what grew.** A regressed counter prints the last ten lines of its own command
630
+ output under the `<-- REGRESSED` line, so the report answers the question it used to raise.
@@ -17,11 +17,25 @@ const newest = (dir) =>
17
17
  }, 0)
18
18
  : 0
19
19
  const src = join(here, '..', 'src')
20
- if (existsSync(src) && newest(src) > newest(join(here, '..', 'dist'))) {
21
- process.stderr.write(
22
- 'geonosis-ratchet: dist is older than srcrun pnpm build before trusting this bin.\n',
23
- )
24
- process.exit(2)
20
+ const dist = join(here, '..', 'dist')
21
+ // `newest` answers 0 for a directory that is NOT THERE and for one that is merely old, so the
22
+ // two conditions were one comparison and one sentence and the staleness sentence was printed
23
+ // over trees that had never been built at all, blocking a turn in a fresh git worktree, which
24
+ // has no dist because dist is not committed (#357). Only the second is evidence of a mistake.
25
+ if (existsSync(src)) {
26
+ const built = newest(dist)
27
+ if (built === 0) {
28
+ process.stderr.write(
29
+ 'geonosis-ratchet: this tree has never been built — run pnpm build. No dist here at all, which is what a fresh clone or a linked git worktree starts with; nothing is stale.\n',
30
+ )
31
+ process.exit(2)
32
+ }
33
+ if (newest(src) > built) {
34
+ process.stderr.write(
35
+ 'geonosis-ratchet: dist is older than src — run pnpm build before trusting this bin.\n',
36
+ )
37
+ process.exit(2)
38
+ }
25
39
  }
26
40
 
27
41
  await import('../dist/cli.js')
@@ -516,11 +516,6 @@ var runRatchet = async ({
516
516
  continue;
517
517
  }
518
518
  const limit = baseline[key];
519
- if (typeof limit !== "number") {
520
- throw new Error(
521
- `${config.baseline} has no number for "${key}" \u2014 add it before enabling the counter`
522
- );
523
- }
524
519
  const tolerance = toleranceOf(entry, key, counter);
525
520
  const recorder = recorded(counter, entry, runCommand(cwd, entry.counter));
526
521
  let now;
@@ -534,6 +529,10 @@ var runRatchet = async ({
534
529
  });
535
530
  continue;
536
531
  }
532
+ if (typeof limit !== "number") {
533
+ measurements.push({ baseline: now, evidence: [], key, now, verdict: "adopted" });
534
+ continue;
535
+ }
537
536
  const verdict = verdictOf(now, limit, tolerance);
538
537
  measurements.push({
539
538
  baseline: limit,
@@ -545,16 +544,24 @@ var runRatchet = async ({
545
544
  }
546
545
  const grew = measurements.some((one) => one.verdict === "grew");
547
546
  const shrank = measurements.some((one) => one.verdict === "shrank");
548
- if (shrank && !grew && refusals.length === 0) {
547
+ const measuredNew = measurements.flatMap((one) => one.verdict === "adopted" ? [one.key] : []);
548
+ const adopted = refusals.length === 0 ? measuredNew : [];
549
+ if (adopted.length > 0 || shrank && !grew && refusals.length === 0) {
549
550
  const next = { ...baseline };
550
551
  for (const one of measurements) {
551
- if (one.verdict === "shrank") next[one.key] = one.now;
552
+ if (one.verdict === "adopted" || one.verdict === "shrank" && !grew) next[one.key] = one.now;
552
553
  }
553
554
  writeFileSync4(baselinePath, `${JSON.stringify(next, null, 2)}
554
555
  `);
555
- return { considered: config.counters.length, measurements, refusals, rewritten: true };
556
+ return {
557
+ adopted,
558
+ considered: config.counters.length,
559
+ measurements,
560
+ refusals,
561
+ rewritten: true
562
+ };
556
563
  }
557
- return { considered: config.counters.length, measurements, refusals, rewritten: false };
564
+ return { adopted, considered: config.counters.length, measurements, refusals, rewritten: false };
558
565
  };
559
566
 
560
567
  // src/counters/params.ts
@@ -707,6 +714,62 @@ var disabledCiJobs = {
707
714
  }
708
715
  };
709
716
 
717
+ // src/counters/doctor.ts
718
+ var ID = "doctorWarnings";
719
+ var reportIn = (output) => {
720
+ let found;
721
+ for (let at = output.indexOf("{"); at !== -1; at = output.indexOf("{", at + 1)) {
722
+ try {
723
+ const parsed = JSON.parse(output.slice(at));
724
+ if (typeof parsed === "object" && parsed !== null) found = parsed;
725
+ } catch {
726
+ continue;
727
+ }
728
+ }
729
+ return found;
730
+ };
731
+ var CONFIG_AND_RULE = " \u2192 ";
732
+ var about = (one) => {
733
+ const subject = String(one.subject ?? "");
734
+ const at = subject.indexOf(CONFIG_AND_RULE);
735
+ return `${String(one.check ?? "")}:${at === -1 ? subject : subject.slice(at + CONFIG_AND_RULE.length)}`;
736
+ };
737
+ var doctorWarnings = {
738
+ id: ID,
739
+ probe: {
740
+ command: () => `node -p "require('fs').readFileSync('doctor.json','utf8')"`,
741
+ expect: 2,
742
+ input: (dir) => plant(
743
+ dir,
744
+ "doctor.json",
745
+ `${JSON.stringify({
746
+ findings: [
747
+ { check: "exercised", subject: "a/.oxlintrc.json \u2192 ns/one", verdict: "WARN" },
748
+ { check: "exercised", subject: "b/.oxlintrc.json \u2192 ns/one", verdict: "WARN" },
749
+ { check: "drift", subject: "LAW.md", verdict: "WARN" },
750
+ { check: "drift", subject: "fine.md", verdict: "OK" }
751
+ ]
752
+ })}
753
+ `
754
+ )
755
+ },
756
+ run: async ({ params, run }) => {
757
+ const command = stringParam(ID, params, "command", "npx geonosis-doctor --json");
758
+ const output = run(command).output;
759
+ const findings = reportIn(output)?.findings;
760
+ if (!Array.isArray(findings)) {
761
+ throw new CounterError(
762
+ ID,
763
+ `\`${command}\` printed no doctor report with a findings list \u2014 a gate that cannot measure has not passed. Its output began: ${output.trim().slice(0, 200)}`
764
+ );
765
+ }
766
+ const warned = new Set(
767
+ findings.filter((one) => typeof one === "object" && one !== null).filter((one) => one.verdict === "WARN").map(about)
768
+ );
769
+ return warned.size;
770
+ }
771
+ };
772
+
710
773
  // src/counters/duplication.ts
711
774
  var CLONES = /Found (\d+) clones?/;
712
775
  var cloneCount = {
@@ -1191,7 +1254,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync10 } from "fs";
1191
1254
  import { createRequire } from "module";
1192
1255
  import { join as join6, resolve as resolve12 } from "path";
1193
1256
  import { pathToFileURL } from "url";
1194
- var ID = "probelessRules";
1257
+ var ID2 = "probelessRules";
1195
1258
  var OFF = /* @__PURE__ */ new Set([0, "0", "allow", "off", false]);
1196
1259
  var severityOf2 = (level) => Array.isArray(level) ? level[0] : level;
1197
1260
  var enabledIn = (config, namespace) => {
@@ -1214,7 +1277,7 @@ var probedBy = async (cwd, plugin) => {
1214
1277
  entry = createRequire(join6(cwd, "noop.js")).resolve(plugin);
1215
1278
  } catch (error) {
1216
1279
  throw new CounterError(
1217
- ID,
1280
+ ID2,
1218
1281
  `could not resolve ${plugin} from ${cwd} \u2014 ${String(error.message).split("\n")[0]}. A plugin nobody can load says nothing about which rules ship a probe, and it is not zero of them`
1219
1282
  );
1220
1283
  }
@@ -1222,12 +1285,12 @@ var probedBy = async (cwd, plugin) => {
1222
1285
  try {
1223
1286
  loaded = await import(pathToFileURL(entry).href);
1224
1287
  } catch (error) {
1225
- throw new CounterError(ID, `could not import ${entry} \u2014 ${error.message}`);
1288
+ throw new CounterError(ID2, `could not import ${entry} \u2014 ${error.message}`);
1226
1289
  }
1227
1290
  const namespace = loaded.default?.meta?.name;
1228
1291
  if (typeof namespace !== "string" || namespace === "") {
1229
1292
  throw new CounterError(
1230
- ID,
1293
+ ID2,
1231
1294
  `${plugin} declares no meta.name, so nothing says which rule ids in the config are its own`
1232
1295
  );
1233
1296
  }
@@ -1240,7 +1303,7 @@ var probedBy = async (cwd, plugin) => {
1240
1303
  };
1241
1304
  var PLUGIN = "@geonosis/oxlint-plugin-biological-architecture";
1242
1305
  var probelessRules = {
1243
- id: ID,
1306
+ id: ID2,
1244
1307
  probe: {
1245
1308
  expect: 1,
1246
1309
  input: (dir) => {
@@ -1266,16 +1329,16 @@ var probelessRules = {
1266
1329
  params: { plugin: "probe-plugin" }
1267
1330
  },
1268
1331
  run: async ({ cwd, params }) => {
1269
- const relative = stringParam(ID, params, "config", ".oxlintrc.json");
1332
+ const relative = stringParam(ID2, params, "config", ".oxlintrc.json");
1270
1333
  const path = resolve12(cwd, relative);
1271
- if (!existsSync9(path)) throw new CounterError(ID, `no oxlint config at ${relative}`);
1334
+ if (!existsSync9(path)) throw new CounterError(ID2, `no oxlint config at ${relative}`);
1272
1335
  let config;
1273
1336
  try {
1274
1337
  config = JSON.parse(readFileSync10(path, "utf8"));
1275
1338
  } catch (error) {
1276
- throw new CounterError(ID, `${relative} does not parse: ${error.message}`);
1339
+ throw new CounterError(ID2, `${relative} does not parse: ${error.message}`);
1277
1340
  }
1278
- const { namespace, probed } = await probedBy(cwd, stringParam(ID, params, "plugin", PLUGIN));
1341
+ const { namespace, probed } = await probedBy(cwd, stringParam(ID2, params, "plugin", PLUGIN));
1279
1342
  return enabledIn(config, namespace).filter((rule) => !probed.has(rule)).length;
1280
1343
  }
1281
1344
  };
@@ -1956,6 +2019,7 @@ var COUNTERS = [
1956
2019
  bundleBytes,
1957
2020
  cloneCount,
1958
2021
  disabledCiJobs,
2022
+ doctorWarnings,
1959
2023
  fastTierMs,
1960
2024
  floorSurface,
1961
2025
  knipIssues,
@@ -1989,7 +2053,12 @@ var counterById = (id) => {
1989
2053
 
1990
2054
  // src/report.ts
1991
2055
  var WIDTH = 28;
1992
- var formatReport = ({ measurements, refusals, rewritten }) => {
2056
+ var formatReport = ({
2057
+ adopted,
2058
+ measurements,
2059
+ refusals,
2060
+ rewritten
2061
+ }) => {
1993
2062
  const lines = measurements.map((one) => {
1994
2063
  if (one.verdict === "skipped") {
1995
2064
  return ` SKIP ${one.key}: not measured by --tier ${one.tier}`;
@@ -2000,6 +2069,9 @@ var formatReport = ({ measurements, refusals, rewritten }) => {
2000
2069
  return [regressed, ...one.evidence.map((line) => ` ${line}`)].join("\n");
2001
2070
  }
2002
2071
  if (one.verdict === "shrank") return `${head} <-- improved -${one.baseline - one.now}`;
2072
+ if (one.verdict === "adopted") {
2073
+ return `${head} <-- ADOPTED: measured for the first time and written, not judged`;
2074
+ }
2003
2075
  return head;
2004
2076
  });
2005
2077
  lines.push(...refusals.map((one) => ` CANNOT MEASURE ${one.key}: ${one.reason}`));
@@ -2014,6 +2086,11 @@ var formatReport = ({ measurements, refusals, rewritten }) => {
2014
2086
  "",
2015
2087
  "ratchet FAIL \u2014 debt grew. Fix it, or say in the commit message why the baseline goes up."
2016
2088
  );
2089
+ } else if (adopted.length > 0) {
2090
+ lines.push(
2091
+ "",
2092
+ `ratchet DID NOT PASS \u2014 ${adopted.join(", ")} measured for the first time and written, not judged. Nothing shrank and nothing was lowered. Read the number, commit it, and run again.`
2093
+ );
2017
2094
  } else if (rewritten) {
2018
2095
  lines.push("", "ratchet PASS \u2014 debt shrank; the baseline was lowered to lock the win in.");
2019
2096
  } else {
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  runRatchet,
8
8
  versionOf,
9
9
  writeEnvelope
10
- } from "./chunk-E55APSHM.js";
10
+ } from "./chunk-2EAYMSC5.js";
11
11
 
12
12
  // src/cli.ts
13
13
  import process from "process";
@@ -143,6 +143,13 @@ var measure = async () => {
143
143
  ` : `envelope: ${at}
144
144
  `);
145
145
  if (result.refusals.length > 0) return 2;
146
+ if (result.adopted.length > 0) {
147
+ process.stdout.write(
148
+ `adopted ${result.adopted.join(", ")} \u2014 measured for the first time and written to the baseline, not judged. Read the number, commit it, and run again: this run does not pass on a floor nobody has seen.
149
+ `
150
+ );
151
+ return 1;
152
+ }
146
153
  return result.measurements.some((one) => one.verdict === "grew") ? 1 : 0;
147
154
  };
148
155
  try {
package/dist/index.d.ts CHANGED
@@ -116,7 +116,11 @@ type Measurement = {
116
116
  evidence: string[];
117
117
  key: string;
118
118
  now: number;
119
- verdict: 'grew' | 'held' | 'shrank';
119
+ /**
120
+ * `adopted` is a key the baseline had never held: measured, written by the runner, and NOT
121
+ * judged. It carries no `baseline` of its own, because there was none to compare against.
122
+ */
123
+ verdict: 'adopted' | 'grew' | 'held' | 'shrank';
120
124
  } | {
121
125
  key: string;
122
126
  tier: string;
@@ -176,6 +180,12 @@ type Refusal = {
176
180
  reason: string;
177
181
  };
178
182
  type RatchetResult = {
183
+ /**
184
+ * The keys this run measured for the FIRST time and wrote into the baseline. Never empty on a run
185
+ * that passed: an adopted key makes the run refuse, because a first number nobody has looked at
186
+ * is not a floor anybody agreed to.
187
+ */
188
+ adopted: string[];
179
189
  /**
180
190
  * How many counters the config named. The denominator: `considered === measurements.length +
181
191
  * refusals.length`, and a run whose two do not add up has lost one somewhere between the config
@@ -348,7 +358,7 @@ declare const runRatchet: ({ counters, cwd, tier, }: {
348
358
  * the tier left out says so by name — printing a number for it would be the stale OK this exists
349
359
  * to prevent.
350
360
  */
351
- declare const formatReport: ({ measurements, refusals, rewritten }: RatchetResult) => string;
361
+ declare const formatReport: ({ adopted, measurements, refusals, rewritten, }: RatchetResult) => string;
352
362
  declare const formatProve: ({ proofs, proven }: ProveResult) => string;
353
363
 
354
364
  /**
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  runRatchet,
19
19
  versionOf,
20
20
  writeEnvelope
21
- } from "./chunk-E55APSHM.js";
21
+ } from "./chunk-2EAYMSC5.js";
22
22
  export {
23
23
  CONFIG_FILE,
24
24
  COUNTERS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geonosis/ratchet",
3
- "version": "2.3.1",
3
+ "version": "2.4.2",
4
4
  "types": "./dist/index.d.ts",
5
5
  "description": "Debt as a number that may only shrink — one ratchet, pluggable counters.",
6
6
  "keywords": [
@@ -31,6 +31,7 @@
31
31
  }
32
32
  },
33
33
  "files": [
34
+ "CHANGELOG.md",
34
35
  "bin",
35
36
  "dist"
36
37
  ],