@geonosis/ratchet 0.1.2 → 0.2.1

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/README.md CHANGED
@@ -11,13 +11,104 @@ pnpm add -D @geonosis/ratchet
11
11
  ```
12
12
 
13
13
  ```bash
14
- geonosis-ratchet # every counter
14
+ geonosis-ratchet --prove # first: prove every counter CAN report a finding
15
+ geonosis-ratchet # then: every counter
15
16
  geonosis-ratchet --tier fast # only the counters in that tier
17
+ geonosis-ratchet --exclusive # one heavy run at a time on this machine
16
18
  geonosis-ratchet --cwd apps/web
17
19
  ```
18
20
 
19
21
  Exit 1 when a number grew, 2 when a counter could not measure at all, 0 otherwise.
20
22
 
23
+ ## `--prove` is the first thing to run after installing
24
+
25
+ A counter that reads 0 because the tree is clean and a counter that reads 0 because it can no longer
26
+ read the tool are the same number. The second is worse than no gate at all: it reports green, and
27
+ when it shrinks the ratchet writes the lie into the baseline.
28
+
29
+ This is not hypothetical. On one day, 2026-08-29, five gates were found reporting green while
30
+ measuring nothing: this package's own `oxlintErrors` read 0 under the `--format=unix` its config
31
+ asked for, and had since it was written; a lint rule sat at `"error"` for months with no
32
+ configuration to fire on; a parity run said PASS about rules it never exercised; a test runner
33
+ exited 0 over failing suites for weeks; and a `git rebase | tail` hid a conflict behind a pipe's
34
+ exit status.
35
+
36
+ So every counter ships a **probe**: a known-bad input it must be able to read.
37
+
38
+ ```bash
39
+ geonosis-ratchet --prove
40
+ ```
41
+
42
+ ```
43
+ PROVEN oxlintErrors: reads 1 on a planted finding
44
+ PROVEN typecheckErrors: reads 1 on a planted finding
45
+ PROVEN lawLineCount: reads 3 on a planted finding
46
+
47
+ prove PASS — every counter read the finding its probe planted.
48
+ ```
49
+
50
+ Each configured counter's probe plants its input in a temp directory — never in your repo — and the
51
+ counter runs there exactly as it runs in a real measurement. The tools come from your repo's
52
+ `node_modules/.bin`, so what is proven is the toolchain you will actually measure with.
53
+
54
+ - `PROVEN <key>: reads N on a planted finding` — the gate has been seen red.
55
+ - `CANNOT FAIL <key>: read 0` — the input was planted and the counter still read nothing.
56
+ - `CANNOT MEASURE <key>: <error>` — the probe threw, or the counter ships none.
57
+
58
+ The run stops at the first counter that reads 0 or throws, and exits **2**. Put it in front of the
59
+ ratchet in whatever script your CI runs:
60
+
61
+ ```jsonc
62
+ { "verify": "… && geonosis-ratchet --prove && geonosis-ratchet" }
63
+ ```
64
+
65
+ A counter you wrote yourself takes a `probe` beside its `run`: `{ input: (dir) => void, command?:
66
+ (dir) => string, params?, expect: number }`. `expect` is what the planted input is worth, and it is
67
+ at least 1 — a probe that plants nothing proves nothing.
68
+
69
+ ### A baseline is not proof the gate still gates
70
+
71
+ The ratchet compares a number against a number. Swapping the tool that produces it — a vendored lint
72
+ plugin for a published one, one version for the next — can hold every number and still have stopped
73
+ firing, because a rule that fires nowhere counts zero exactly like a rule with nothing to find. Check
74
+ that separately, with [`@geonosis/lint-parity`](https://www.npmjs.com/package/@geonosis/lint-parity):
75
+
76
+ ```bash
77
+ npx geonosis-lint-parity \
78
+ --corpus node_modules/@geonosis/oxlint-plugin-biological-architecture/corpus \
79
+ --a oxlintrc.old.json --b oxlintrc.new.json --out proofs/reach
80
+ npx geonosis-lint-parity --a oxlintrc.old.json --b oxlintrc.new.json --out proofs/parity -- apps packages
81
+ ```
82
+
83
+ Reach first — parity between two configs that both fire nothing is perfect parity — then the tree.
84
+
85
+ ## `--exclusive`: one heavy run at a time on a machine
86
+
87
+ Three sessions on one laptop each started their own verify, and a 25-minute run took two hours and
88
+ came back with failures that were about the load, not the code. "Announce before anything heavy" is
89
+ a habit that works right up until the moment somebody is concentrating; `--exclusive` is the same
90
+ agreement as code.
91
+
92
+ ```bash
93
+ geonosis-ratchet --exclusive --exclusive-timeout 1800
94
+ ```
95
+
96
+ It takes `~/.cache/geonosis/heavy.lock` before measuring anything, writing the pid, cwd and start
97
+ time into it, and gives it back in a `finally` and on SIGINT/SIGTERM. A second run waits instead of
98
+ contending, and says every 15 seconds who has the lock and for how long. A holder whose process is
99
+ gone — killed, crashed, a lid closed mid-run — is stale, and is taken over with a printed note
100
+ rather than making everyone wait for a pid that no longer exists. The wait is bounded:
101
+ `--exclusive-timeout` seconds (default 1800) and then exit 2, because a run that hangs silently
102
+ looks exactly like a run that is broken.
103
+
104
+ **Put it on the script your sessions actually run**, so nothing has to remember:
105
+
106
+ ```jsonc
107
+ { "verify": "… && geonosis-ratchet --prove && geonosis-ratchet --exclusive" }
108
+ ```
109
+
110
+ `GEONOSIS_HEAVY_LOCK` moves the lock file — for a sandbox with no writable home, or for a test.
111
+
21
112
  ## Config
22
113
 
23
114
  `geonosis.ratchet.json` at the repo root names the counters and how to run them:
@@ -85,6 +176,36 @@ When a number **grew**, the last ten lines of that counter's command output prin
85
176
  `<-- REGRESSED` line, indented — so the report says what grew, not only that something did. A
86
177
  counter that reads a file rather than running a command (`lawLineCount`) prints nothing extra.
87
178
 
179
+ ### `testFailures` reads the runner's summary, never the exit code
180
+
181
+ The counter looks for the runner's own count — `N failed` — and **refuses when it cannot parse
182
+ one**. It never reads the process exit code, because a test runner exiting 0 over a red suite is
183
+ commoner than anyone expects: `@cloudflare/vitest-pool-workers` 0.22 on vitest 4.1 exited 0 with
184
+ failing tests on every workerd suite of a consumer, and every gate that trusted the exit code
185
+ reported green over red for weeks. A refusal is loud and stops the run; a trusted 0 is silent and
186
+ banks the red as a win.
187
+
188
+ For the same reason, **give every test package its own `testFailures` entry**, each with its own
189
+ `key`. One entry over one workspace measures one workspace; the suites it does not run are not zero
190
+ failures, they are unmeasured — and unmeasured reads exactly like green.
191
+
192
+ ```jsonc
193
+ { "counter": "testFailures", "key": "testFailuresApi", "command": "bun --cwd apps/api test", "tiers": ["full"] }
194
+ ```
195
+
196
+ `examples/during-day.ratchet.json` is four test entries for that reason: `apps/web` plus the three
197
+ workerd packages that were previously outside every gate.
198
+
199
+ ### `oxlintRule` counts a warned rule twice, on purpose
200
+
201
+ A rule parked at `"warn"` as ratcheted debt appears in two numbers: once inside `oxlintWarnings`,
202
+ and once under its own `oxlintRule` key, which measures it at `"error"` through a strict temp copy of
203
+ the config. So arming a rule at warn raises **both**, and fixing one finding lowers **both**.
204
+
205
+ That is not double-counting the total; the second key exists so the debt is visible per rule instead
206
+ of hidden inside a lump sum that a different rule's warning could mask. dielime today: `oxlintWarnings`
207
+ 12 → 135 when `no-raw-html-atoms` was armed, with its own key at 123.
208
+
88
209
  ## Running it where it will actually run
89
210
 
90
211
  - **Counters run in the caller's environment.** They inherit the shell the ratchet was started in,
@@ -95,11 +216,46 @@ counter that reads a file rather than running a command (`lawLineCount`) prints
95
216
  packages first.** In a fresh worktree the `dist/*.d.ts` files do not exist yet, and `tsc` reports
96
217
  a false +N of missing-module errors that has nothing to do with the change under test. `pnpm build
97
218
  && geonosis-ratchet` is the shape; a bare `geonosis-ratchet` in a clean checkout is not.
219
+
220
+ The counter no longer reports that number. When tsc's TS2307 or TS2305 diagnostics name a package
221
+ of this workspace — read from `pnpm-workspace.yaml`, or `workspaces` in the root `package.json` —
222
+ it refuses:
223
+
224
+ ```
225
+ geonosis-ratchet: typecheckErrors: workspace packages not built: @dielime/shared —
226
+ build them before measuring typecheckErrors
227
+ ```
228
+
229
+ Mixed output refuses too. Counting the readable half would produce a number that is neither the
230
+ debt nor the build error, banked into the baseline as though somebody had measured it. An
231
+ unresolved module that is NOT a workspace package — `lodash` — is counted as before: that is a
232
+ dependency to fix, not a build to run.
98
233
  - **A release-age cooldown will refuse a package published minutes ago.** If pnpm's
99
234
  `minimumReleaseAge` blocks `@geonosis/*`, exclude the scope for that project — pnpm writes
100
235
  `minimumReleaseAgeExclude` into `pnpm-workspace.yaml` — rather than lowering the cooldown. The
101
236
  cooldown is protecting every other dependency in the tree; the exclusion is scoped to the one you
102
237
  chose to trust.
238
+ - **Bumping to a new version: keep BOTH exclusion pairs — old and new — through the install, then
239
+ drop the old one and re-verify.** The policy check is applied to the entries in the LOCKFILE, not
240
+ only to what you asked for, so removing the old pair first makes the resolver walk back through
241
+ the version you are leaving and refuse it — an install that fails for the version you are
242
+ replacing, not the one you are taking. The order that works, learned under load on dielime's 0.2.0
243
+ bump:
244
+
245
+ ```bash
246
+ # both pairs in pnpm-workspace.yaml's minimumReleaseAgeExclude
247
+ pnpm install
248
+ grep -c "@geonosis/.*@<old>" pnpm-lock.yaml # must be 0 before the old pair goes
249
+ # now delete the old pair
250
+ pnpm install --frozen-lockfile
251
+ ```
252
+
253
+ An exclusion left behind is a cooldown quietly off for a package nobody is watching any more, so
254
+ the deletion belongs in the same commit as the bump.
255
+
256
+ - **That policy check walks every entry in the lockfile and looks hung. It is not.** ~2,300 entries
257
+ took ~90 seconds on a loaded laptop with no output while it worked. Wait for it rather than
258
+ killing the install and re-running into the same wait.
103
259
 
104
260
  ## Counters
105
261
 
@@ -109,7 +265,7 @@ Every counter takes its `command` from the config, so the toolchain stays the re
109
265
  | --- | --- | --- |
110
266
  | `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 | `command`, `expectFormat` |
111
267
  | `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`, `config`, `command`, `expectFormat` |
112
- | `typecheckErrors` | `error TS` occurrences | `command` |
268
+ | `typecheckErrors` | `error TS` occurrences, refusing when TS2305/TS2307 name a workspace package of this repo — an unbuilt sibling is a missing build, not debt | `command` |
113
269
  | `testFailures` | the runner's own failure summary; throws when neither a pass nor a fail count is readable | `command` |
114
270
  | `unformattedFiles` | paths `--list-different` names that exist on disk | `command` |
115
271
  | `cloneCount` | jscpd's `Found N clones` | `command` |
@@ -23,9 +23,23 @@ var stringsParam = (params, name, fallback) => {
23
23
  var countMatches = (text, pattern) => text.match(new RegExp(pattern.source, `${pattern.flags.replace("g", "")}g`))?.length ?? 0;
24
24
  var escapeForRegex = (value) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
25
25
 
26
+ // src/counters/plant.ts
27
+ import { mkdirSync, writeFileSync } from "fs";
28
+ import { dirname, join } from "path";
29
+ var plant = (dir, relative, contents) => {
30
+ const path = join(dir, relative);
31
+ mkdirSync(dirname(path), { recursive: true });
32
+ writeFileSync(path, contents);
33
+ };
34
+ var captured = (sample) => ({
35
+ command: () => "cat sample.txt",
36
+ input: (dir) => plant(dir, "sample.txt", sample)
37
+ });
38
+
26
39
  // src/counters/arch.ts
27
40
  var archViolations = {
28
41
  id: "archViolations",
42
+ probe: { ...captured("\u2717 cell imports cell\nok\n"), expect: 1 },
29
43
  run: async ({ params, run }) => {
30
44
  const command = stringParam("archViolations", params, "command");
31
45
  const match = new RegExp(stringParam("archViolations", params, "match", "^\u2717"));
@@ -37,6 +51,7 @@ var archViolations = {
37
51
  var CLONES = /Found (\d+) clones?/;
38
52
  var cloneCount = {
39
53
  id: "cloneCount",
54
+ probe: { ...captured("Found 1 clone.\n"), expect: 1 },
40
55
  run: async ({ params, run }) => {
41
56
  const command = stringParam("cloneCount", params, "command", "npx jscpd .");
42
57
  const output = run(command).output;
@@ -60,6 +75,7 @@ var DEFAULT_HEADINGS = [
60
75
  ];
61
76
  var knipIssues = {
62
77
  id: "knipIssues",
78
+ probe: { ...captured("Unused files (1)\nsrc/gone.ts\n"), expect: 1 },
63
79
  run: async ({ params, run }) => {
64
80
  const command = stringParam("knipIssues", params, "command", "npx knip --reporter compact");
65
81
  const output = run(command).output;
@@ -69,6 +85,7 @@ var knipIssues = {
69
85
  var ISSUES = /(\d+) issues? found/;
70
86
  var boundaryIssues = {
71
87
  id: "boundaryIssues",
88
+ probe: { ...captured("Checking...\n1 issue found\n"), expect: 1 },
72
89
  run: async ({ params, run }) => {
73
90
  const command = stringParam("boundaryIssues", params, "command", "npx turbo boundaries");
74
91
  return Number(run(command).output.match(ISSUES)?.[1] ?? 0);
@@ -80,6 +97,11 @@ import { existsSync } from "fs";
80
97
  import { resolve } from "path";
81
98
  var unformattedFiles = {
82
99
  id: "unformattedFiles",
100
+ probe: {
101
+ command: () => "oxfmt --list-different .",
102
+ expect: 1,
103
+ input: (dir) => plant(dir, "a.ts", "export const a = {b:1,\n c:2}\n")
104
+ },
83
105
  run: async ({ cwd, params, run }) => {
84
106
  const command = stringParam(
85
107
  "unformattedFiles",
@@ -96,6 +118,11 @@ import { existsSync as existsSync2, readFileSync } from "fs";
96
118
  import { resolve as resolve2 } from "path";
97
119
  var lawLineCount = {
98
120
  id: "lawLineCount",
121
+ probe: {
122
+ expect: 3,
123
+ input: (dir) => plant(dir, "CLAUDE.md", "one\ntwo\nthree\n"),
124
+ params: { path: "CLAUDE.md" }
125
+ },
99
126
  run: async ({ cwd, params }) => {
100
127
  const relative = stringParam("lawLineCount", params, "path", "CLAUDE.md");
101
128
  const path = resolve2(cwd, relative);
@@ -105,9 +132,23 @@ var lawLineCount = {
105
132
  };
106
133
 
107
134
  // src/counters/oxlint.ts
108
- import { existsSync as existsSync3, readFileSync as readFileSync2, rmSync, writeFileSync } from "fs";
135
+ import { existsSync as existsSync3, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
109
136
  import { resolve as resolve3 } from "path";
110
137
  var DEFAULT_COMMAND = "npx oxlint --format=unix --config .oxlintrc.json .";
138
+ var PROBE_COMMAND = "oxlint --format=unix --config .oxlintrc.json .";
139
+ var oxlintProbe = (severity, rule, source) => ({
140
+ command: () => PROBE_COMMAND,
141
+ input: (dir) => {
142
+ plant(
143
+ dir,
144
+ ".oxlintrc.json",
145
+ JSON.stringify({ plugins: ["typescript"], rules: { [rule]: severity } })
146
+ );
147
+ plant(dir, "src/a.ts", source);
148
+ }
149
+ });
150
+ var ONE_ANY = "export const cast = (value: any): string => String(value)\n";
151
+ var ONE_LOOSE = "export const loose = (a: unknown, b: unknown): boolean => a == b\n";
111
152
  var UNIX_FINDING = /^\S[^\n]*:\d+:\d+: .*\[(Error|Warning)\/[^\]\n]+\]$/gm;
112
153
  var UNIX_SUMMARY = /^(\d+) problems?$/m;
113
154
  var AGENT_FINDING = /^\S[^\n]*:\d+:\d+: (error|warning) /gm;
@@ -177,6 +218,11 @@ ${output.trim()}`);
177
218
  };
178
219
  var oxlintErrors = {
179
220
  id: "oxlintErrors",
221
+ probe: {
222
+ ...oxlintProbe("error", "typescript/no-explicit-any", ONE_ANY),
223
+ expect: 1,
224
+ params: { expectFormat: "unix" }
225
+ },
180
226
  run: async ({ params, run }) => {
181
227
  const command = stringParam("oxlintErrors", params, "command", DEFAULT_COMMAND);
182
228
  const expect = expectedFormat("oxlintErrors", params);
@@ -185,6 +231,13 @@ var oxlintErrors = {
185
231
  };
186
232
  var oxlintWarnings = {
187
233
  id: "oxlintWarnings",
234
+ // A warning, so the run exits 0 — the window `expectFormat` exists to close is also the window a
235
+ // probe has to survive.
236
+ probe: {
237
+ ...oxlintProbe("warn", "eqeqeq", ONE_LOOSE),
238
+ expect: 1,
239
+ params: { expectFormat: "unix" }
240
+ },
188
241
  run: async ({ params, run }) => {
189
242
  const command = stringParam("oxlintWarnings", params, "command", DEFAULT_COMMAND);
190
243
  const expect = expectedFormat("oxlintWarnings", params);
@@ -206,6 +259,13 @@ ${result.output.trim()}`
206
259
  };
207
260
  var oxlintRule = {
208
261
  id: "oxlintRule",
262
+ // The probe names its OWN rule: which rule a repo tracks is its business, and a probe that had to
263
+ // make the repo's rule fire would need the repo's plugin loadable from a scratch directory.
264
+ probe: {
265
+ ...oxlintProbe("error", "typescript/no-explicit-any", ONE_ANY),
266
+ expect: 1,
267
+ params: { expectFormat: "unix", rule: "no-explicit-any" }
268
+ },
209
269
  run: async ({ cwd, key, params, run }) => {
210
270
  const rule = stringParam("oxlintRule", params, "rule");
211
271
  const command = stringParam("oxlintRule", params, "command", DEFAULT_COMMAND);
@@ -219,7 +279,7 @@ var oxlintRule = {
219
279
  new RegExp(`("[^"]*${escapeForRegex(rule)}"\\s*:\\s*\\[?\\s*)"(warn|off)"`),
220
280
  '$1"error"'
221
281
  );
222
- writeFileSync(resolve3(cwd, strictName), strict);
282
+ writeFileSync2(resolve3(cwd, strictName), strict);
223
283
  try {
224
284
  return countRule(run(command.replace("{config}", strictName)), rule, expect);
225
285
  } finally {
@@ -233,6 +293,9 @@ var DEFAULT_PATTERNS = ["^(apps|packages)/[^/]+/src/"];
233
293
  var NOT_RUNTIME = /(\.test\.|\.spec\.|__tests__\/|__fixtures__\/|\.d\.ts$)/;
234
294
  var runtimeCodeShipped = {
235
295
  id: "runtimeCodeShipped",
296
+ // Its failing reading is 1, not 0: the finding to plant is a diff of documentation with no
297
+ // runtime file in it, which is exactly the tick this counter exists to refuse.
298
+ probe: { ...captured("docs/design.md\nplans/019.md\n"), expect: 1 },
236
299
  run: async ({ params, run }) => {
237
300
  const command = stringParam(
238
301
  "runtimeCodeShipped",
@@ -251,10 +314,11 @@ var runtimeCodeShipped = {
251
314
  // src/counters/sum-of-counts.ts
252
315
  var sumOfCounts = {
253
316
  id: "sumOfCounts",
317
+ probe: { ...captured("src/a.ts:1\nsrc/b.ts:0\n"), expect: 1 },
254
318
  run: async ({ params, run }) => {
255
319
  const command = stringParam("sumOfCounts", params, "command");
256
320
  const match = new RegExp(stringParam("sumOfCounts", params, "match", ":(\\d+)$"), "gm");
257
- return [...run(command).output.matchAll(match)].map(([, captured]) => Number(captured ?? 0)).filter((count) => Number.isFinite(count)).reduce((sum, count) => sum + count, 0);
321
+ return [...run(command).output.matchAll(match)].map(([, digits]) => Number(digits ?? 0)).filter((count) => Number.isFinite(count)).reduce((sum, count) => sum + count, 0);
258
322
  }
259
323
  };
260
324
 
@@ -263,6 +327,7 @@ var FAILED = /(\d+)\s+fail(?:ed|ing|s)?\b/;
263
327
  var PASSED = /(\d+)\s+pass(?:ed|ing|es)?\b/;
264
328
  var testFailures = {
265
329
  id: "testFailures",
330
+ probe: { ...captured(" Tests 1 failed | 0 passed (1)\n"), expect: 1 },
266
331
  run: async ({ params, run }) => {
267
332
  const command = stringParam("testFailures", params, "command", "npx vitest run");
268
333
  const output = run(command).output;
@@ -277,12 +342,102 @@ ${output.trim().slice(-500)}`
277
342
  }
278
343
  };
279
344
 
345
+ // src/counters/workspace.ts
346
+ import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync3 } from "fs";
347
+ import { join as join2, resolve as resolve4 } from "path";
348
+ var SKIP = /^(node_modules|\.)/;
349
+ var childDirs = (dir) => {
350
+ try {
351
+ return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !SKIP.test(entry.name)).map((entry) => join2(dir, entry.name));
352
+ } catch {
353
+ return [];
354
+ }
355
+ };
356
+ var descendants = (dir, depth) => depth === 0 ? [dir] : [dir, ...childDirs(dir).flatMap((child) => descendants(child, depth - 1))];
357
+ var expand = (root, pattern) => {
358
+ const segments = pattern.split("/").filter((one) => one !== "" && one !== ".");
359
+ let dirs = [root];
360
+ for (const segment of segments) {
361
+ dirs = segment === "*" ? dirs.flatMap((dir) => childDirs(dir)) : segment === "**" ? dirs.flatMap((dir) => descendants(dir, 3)) : dirs.map((dir) => join2(dir, segment)).filter((dir) => existsSync4(dir));
362
+ }
363
+ return dirs;
364
+ };
365
+ var QUOTED = /^['"]|['"]$/g;
366
+ var cleaned = (value) => value.replace(/#.*$/, "").trim().replaceAll(QUOTED, "");
367
+ var pnpmPatterns = (path) => {
368
+ const lines = readFileSync3(path, "utf8").split("\n");
369
+ const at = lines.findIndex((line) => line.startsWith("packages:"));
370
+ if (at === -1) return [];
371
+ const inline = lines[at]?.slice("packages:".length).trim() ?? "";
372
+ if (inline.startsWith("[")) {
373
+ return inline.replace(/^\[|\]$/g, "").split(",").map(cleaned).filter((one) => one !== "");
374
+ }
375
+ const patterns = [];
376
+ for (const line of lines.slice(at + 1)) {
377
+ const item = /^\s*-\s*(.+)$/.exec(line);
378
+ if (item?.[1] !== void 0) {
379
+ patterns.push(cleaned(item[1]));
380
+ continue;
381
+ }
382
+ if (line.trim() !== "") break;
383
+ }
384
+ return patterns;
385
+ };
386
+ var npmPatterns = (path) => {
387
+ const parsed = JSON.parse(readFileSync3(path, "utf8"));
388
+ const declared = Array.isArray(parsed.workspaces) ? parsed.workspaces : parsed.workspaces?.packages ?? [];
389
+ return declared.filter((one) => typeof one === "string");
390
+ };
391
+ var nameOf = (dir) => {
392
+ const manifest = join2(dir, "package.json");
393
+ if (!existsSync4(manifest)) return void 0;
394
+ try {
395
+ const { name } = JSON.parse(readFileSync3(manifest, "utf8"));
396
+ return typeof name === "string" && name !== "" ? name : void 0;
397
+ } catch {
398
+ return void 0;
399
+ }
400
+ };
401
+ var workspacePackageNames = (cwd) => {
402
+ const root = resolve4(cwd);
403
+ const pnpm = join2(root, "pnpm-workspace.yaml");
404
+ const manifest = join2(root, "package.json");
405
+ const patterns = existsSync4(pnpm) ? pnpmPatterns(pnpm) : existsSync4(manifest) ? npmPatterns(manifest) : [];
406
+ const names = patterns.filter((pattern) => !pattern.startsWith("!")).flatMap((pattern) => expand(root, pattern)).map((dir) => nameOf(dir)).filter((name) => name !== void 0);
407
+ return [...new Set(names)];
408
+ };
409
+
280
410
  // src/counters/typecheck.ts
411
+ var UNRESOLVED = /error TS(?:2305|2307): ([^\n]*)/g;
412
+ var SPECIFIER = /'([^']+)'/g;
413
+ var specifiersIn = (message) => [...message.matchAll(SPECIFIER)].map(([, quoted]) => (quoted ?? "").replaceAll('"', ""));
414
+ var unbuiltIn = (cwd, output) => {
415
+ const messages = [...output.matchAll(UNRESOLVED)].map(([, message]) => message ?? "");
416
+ if (messages.length === 0) return [];
417
+ const packages = workspacePackageNames(cwd);
418
+ const named = messages.flatMap((message) => specifiersIn(message)).flatMap(
419
+ (specifier) => packages.filter((name) => specifier === name || specifier.startsWith(`${name}/`))
420
+ );
421
+ return [...new Set(named)].toSorted();
422
+ };
281
423
  var typecheckErrors = {
282
424
  id: "typecheckErrors",
283
- run: async ({ params, run }) => {
425
+ probe: {
426
+ command: () => "tsc --noEmit a.ts",
427
+ expect: 1,
428
+ input: (dir) => plant(dir, "a.ts", "export const n: number = 'not a number'\n")
429
+ },
430
+ run: async ({ cwd, params, run }) => {
284
431
  const command = stringParam("typecheckErrors", params, "command", "npx tsc --noEmit");
285
- return countMatches(run(command).output, /error TS/);
432
+ const output = run(command).output;
433
+ const unbuilt = unbuiltIn(cwd, output);
434
+ if (unbuilt.length > 0) {
435
+ throw new CounterError(
436
+ "typecheckErrors",
437
+ `workspace packages not built: ${unbuilt.join(", ")} \u2014 build them before measuring typecheckErrors`
438
+ );
439
+ }
440
+ return countMatches(output, /error TS/);
286
441
  }
287
442
  };
288
443
 
@@ -312,17 +467,104 @@ var counterById = (id) => {
312
467
  return counter;
313
468
  };
314
469
 
470
+ // src/lock.ts
471
+ import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync4, rmSync as rmSync2, writeSync } from "fs";
472
+ import { homedir } from "os";
473
+ import { dirname as dirname2, join as join3 } from "path";
474
+ var heavyLockPath = () => process.env.GEONOSIS_HEAVY_LOCK ?? join3(homedir(), ".cache", "geonosis", "heavy.lock");
475
+ var sleep = (ms) => new Promise((done) => setTimeout(done, ms));
476
+ var holderOf = (path) => {
477
+ try {
478
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
479
+ return typeof parsed.pid === "number" ? {
480
+ cwd: parsed.cwd ?? "somewhere",
481
+ pid: parsed.pid,
482
+ startedAt: parsed.startedAt ?? "unknown"
483
+ } : void 0;
484
+ } catch {
485
+ return void 0;
486
+ }
487
+ };
488
+ var alive = (pid) => {
489
+ try {
490
+ process.kill(pid, 0);
491
+ return true;
492
+ } catch (error) {
493
+ return error.code === "EPERM";
494
+ }
495
+ };
496
+ var heldFor = (holder) => {
497
+ const since = Date.parse(holder.startedAt);
498
+ if (Number.isNaN(since)) return "an unknown time";
499
+ return `${Math.round((Date.now() - since) / 1e3)}s`;
500
+ };
501
+ var write = (path) => {
502
+ mkdirSync2(dirname2(path), { recursive: true });
503
+ try {
504
+ const handle = openSync(path, "wx");
505
+ const mine = {
506
+ cwd: process.cwd(),
507
+ pid: process.pid,
508
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
509
+ };
510
+ writeSync(handle, JSON.stringify(mine));
511
+ closeSync(handle);
512
+ return true;
513
+ } catch (error) {
514
+ if (error.code === "EEXIST") return false;
515
+ throw error;
516
+ }
517
+ };
518
+ var acquireExclusive = async ({
519
+ noticeMs = 15e3,
520
+ path = heavyLockPath(),
521
+ pollMs = 250,
522
+ say = (line) => process.stderr.write(`${line}
523
+ `),
524
+ timeoutSeconds = 1800
525
+ } = {}) => {
526
+ const until = Date.now() + timeoutSeconds * 1e3;
527
+ let told = 0;
528
+ const release = () => {
529
+ if (holderOf(path)?.pid === process.pid) rmSync2(path, { force: true });
530
+ };
531
+ for (; ; ) {
532
+ if (write(path)) return release;
533
+ const holder = holderOf(path);
534
+ if (holder === void 0 || !alive(holder.pid)) {
535
+ say(
536
+ `geonosis-ratchet: taking over a stale heavy lock (pid ${holder?.pid ?? "unreadable"} is gone)`
537
+ );
538
+ rmSync2(path, { force: true });
539
+ continue;
540
+ }
541
+ if (Date.now() >= until) {
542
+ throw new Error(
543
+ `waited ${timeoutSeconds}s for the heavy lock held by pid ${holder.pid} in ${holder.cwd} (${heldFor(holder)}) \u2014 give it longer with --exclusive-timeout, or stop that run`
544
+ );
545
+ }
546
+ const now = Date.now();
547
+ if (now - told >= noticeMs) {
548
+ told = now;
549
+ say(
550
+ `geonosis-ratchet: waiting for the heavy lock \u2014 pid ${holder.pid} in ${holder.cwd}, held for ${heldFor(holder)}`
551
+ );
552
+ }
553
+ await sleep(pollMs);
554
+ }
555
+ };
556
+
315
557
  // src/config.ts
316
- import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
317
- import { resolve as resolve4 } from "path";
558
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
559
+ import { resolve as resolve5 } from "path";
318
560
  var CONFIG_FILE = "geonosis.ratchet.json";
319
561
  var keyOf = (entry) => entry.key ?? entry.counter;
320
562
  var loadConfig = (cwd) => {
321
- const path = resolve4(cwd, CONFIG_FILE);
322
- if (!existsSync4(path)) {
563
+ const path = resolve5(cwd, CONFIG_FILE);
564
+ if (!existsSync5(path)) {
323
565
  throw new Error(`no ${CONFIG_FILE} in ${cwd} \u2014 the ratchet has nothing to count`);
324
566
  }
325
- const parsed = JSON.parse(readFileSync3(path, "utf8"));
567
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
326
568
  if (!Array.isArray(parsed.counters)) {
327
569
  throw new Error(`${CONFIG_FILE} has no "counters" array`);
328
570
  }
@@ -352,11 +594,12 @@ var loadConfig = (cwd) => {
352
594
  // src/shell.ts
353
595
  import { execSync } from "child_process";
354
596
  var ANSI = /\[[0-9;]*m/g;
355
- var runCommand = (cwd, counterId) => (command) => {
597
+ var runCommand = (cwd, counterId, env) => (command) => {
356
598
  try {
357
599
  const output = execSync(`${command} 2>&1`, {
358
600
  cwd,
359
601
  encoding: "utf8",
602
+ env,
360
603
  maxBuffer: 64 * 1024 * 1024,
361
604
  stdio: ["ignore", "pipe", "pipe"]
362
605
  });
@@ -376,9 +619,99 @@ ${output.trim()}`
376
619
  }
377
620
  };
378
621
 
622
+ // src/prove.ts
623
+ import { existsSync as existsSync6, mkdtempSync, rmSync as rmSync3 } from "fs";
624
+ import { tmpdir } from "os";
625
+ import { delimiter, dirname as dirname3, join as join4, resolve as resolve6 } from "path";
626
+ var toolPath = (cwd) => {
627
+ const dirs = [];
628
+ let dir = resolve6(cwd);
629
+ for (; ; ) {
630
+ const bin = join4(dir, "node_modules", ".bin");
631
+ if (existsSync6(bin)) dirs.push(bin);
632
+ const parent = dirname3(dir);
633
+ if (parent === dir) break;
634
+ dir = parent;
635
+ }
636
+ return [...dirs, process.env.PATH ?? ""].join(delimiter);
637
+ };
638
+ var NO_PROBE = "no probe \u2014 a counter nobody has seen read a planted finding has not been shown to measure";
639
+ var proofOf = async (counter, key, path) => {
640
+ const probe = counter.probe;
641
+ if (probe === void 0)
642
+ return { counter: counter.id, key, reason: NO_PROBE, verdict: "cannot-measure" };
643
+ const dir = mkdtempSync(join4(tmpdir(), "geonosis-prove-"));
644
+ try {
645
+ probe.input(dir);
646
+ const command = probe.command?.(dir);
647
+ const reading = await counter.run({
648
+ cwd: dir,
649
+ key,
650
+ params: { ...probe.params, ...command === void 0 ? {} : { command } },
651
+ run: runCommand(dir, counter.id, { ...process.env, PATH: path })
652
+ });
653
+ if (reading === 0) return { counter: counter.id, key, reading, verdict: "cannot-fail" };
654
+ if (reading < probe.expect) {
655
+ return { counter: counter.id, expected: probe.expect, key, reading, verdict: "misread" };
656
+ }
657
+ return { counter: counter.id, key, reading, verdict: "proven" };
658
+ } catch (error) {
659
+ return { counter: counter.id, key, reason: error.message, verdict: "cannot-measure" };
660
+ } finally {
661
+ rmSync3(dir, { force: true, recursive: true });
662
+ }
663
+ };
664
+ var outsideTier = (entry, tier) => entry.tiers !== void 0 && !entry.tiers.includes(tier);
665
+ var runProve = async ({
666
+ counters,
667
+ cwd,
668
+ tier
669
+ }) => {
670
+ const config = loadConfig(cwd);
671
+ const byId = new Map(counters.map((one) => [one.id, one]));
672
+ const path = toolPath(cwd);
673
+ const proofs = [];
674
+ for (const entry of config.counters) {
675
+ const counter = byId.get(entry.counter);
676
+ if (counter === void 0) {
677
+ throw new Error(
678
+ `no counter implements "${entry.counter}" \u2014 known ids: ${[...byId.keys()].toSorted().join(", ")}`
679
+ );
680
+ }
681
+ const key = keyOf(entry);
682
+ if (tier !== void 0 && outsideTier(entry, tier)) {
683
+ proofs.push({ key, tier, verdict: "skipped" });
684
+ continue;
685
+ }
686
+ const proof = await proofOf(counter, key, path);
687
+ proofs.push(proof);
688
+ if (proof.verdict !== "proven" && proof.verdict !== "skipped") {
689
+ return { proofs, proven: false };
690
+ }
691
+ }
692
+ return { proofs, proven: true };
693
+ };
694
+ var formatProve = ({ proofs, proven }) => {
695
+ const lines = proofs.map((one) => {
696
+ if (one.verdict === "skipped") return ` SKIP ${one.key}: not measured by --tier ${one.tier}`;
697
+ if (one.verdict === "cannot-measure") return ` CANNOT MEASURE ${one.key}: ${one.reason}`;
698
+ if (one.verdict === "cannot-fail") return ` CANNOT FAIL ${one.key}: read 0`;
699
+ if (one.verdict === "misread") {
700
+ return ` MISREAD ${one.key}: read ${one.reading} where its probe planted ${one.expected}`;
701
+ }
702
+ return ` PROVEN ${one.key}: reads ${one.reading} on a planted finding`;
703
+ });
704
+ lines.push(
705
+ "",
706
+ proven ? "prove PASS \u2014 every counter read the finding its probe planted." : "prove FAIL \u2014 a gate that has never been seen red has not been shown to measure."
707
+ );
708
+ return `${lines.join("\n")}
709
+ `;
710
+ };
711
+
379
712
  // src/ratchet.ts
380
- import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
381
- import { resolve as resolve5 } from "path";
713
+ import { existsSync as existsSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
714
+ import { resolve as resolve7 } from "path";
382
715
  var EVIDENCE_LINES = 10;
383
716
  var recorded = (run) => {
384
717
  let output = "";
@@ -396,18 +729,18 @@ var verdictOf = (now, baseline) => {
396
729
  if (now < baseline) return "shrank";
397
730
  return "held";
398
731
  };
399
- var outsideTier = (entry, tier) => entry.tiers !== void 0 && !entry.tiers.includes(tier);
732
+ var outsideTier2 = (entry, tier) => entry.tiers !== void 0 && !entry.tiers.includes(tier);
400
733
  var runRatchet = async ({
401
734
  counters,
402
735
  cwd,
403
736
  tier
404
737
  }) => {
405
738
  const config = loadConfig(cwd);
406
- const baselinePath = resolve5(cwd, config.baseline);
407
- if (!existsSync5(baselinePath)) {
739
+ const baselinePath = resolve7(cwd, config.baseline);
740
+ if (!existsSync7(baselinePath)) {
408
741
  throw new Error(`no ${config.baseline} in ${cwd} \u2014 nothing to ratchet against`);
409
742
  }
410
- const baseline = JSON.parse(readFileSync4(baselinePath, "utf8"));
743
+ const baseline = JSON.parse(readFileSync6(baselinePath, "utf8"));
411
744
  const byId = new Map(counters.map((one) => [one.id, one]));
412
745
  const measurements = [];
413
746
  for (const entry of config.counters) {
@@ -418,7 +751,7 @@ var runRatchet = async ({
418
751
  );
419
752
  }
420
753
  const key = keyOf(entry);
421
- if (tier !== void 0 && outsideTier(entry, tier)) {
754
+ if (tier !== void 0 && outsideTier2(entry, tier)) {
422
755
  measurements.push({ key, tier, verdict: "skipped" });
423
756
  continue;
424
757
  }
@@ -446,7 +779,7 @@ var runRatchet = async ({
446
779
  for (const one of measurements) {
447
780
  if (one.verdict !== "skipped") next[one.key] = one.now;
448
781
  }
449
- writeFileSync2(baselinePath, `${JSON.stringify(next, null, 2)}
782
+ writeFileSync3(baselinePath, `${JSON.stringify(next, null, 2)}
450
783
  `);
451
784
  return { measurements, rewritten: true };
452
785
  }
@@ -485,10 +818,14 @@ export {
485
818
  CounterError,
486
819
  COUNTERS,
487
820
  counterById,
821
+ heavyLockPath,
822
+ acquireExclusive,
488
823
  CONFIG_FILE,
489
824
  keyOf,
490
825
  loadConfig,
491
826
  runCommand,
827
+ runProve,
828
+ formatProve,
492
829
  runRatchet,
493
830
  formatReport
494
831
  };
package/dist/cli.js CHANGED
@@ -1,21 +1,52 @@
1
1
  import {
2
2
  COUNTERS,
3
+ acquireExclusive,
4
+ formatProve,
3
5
  formatReport,
6
+ runProve,
4
7
  runRatchet
5
- } from "./chunk-BTWIR7DK.js";
8
+ } from "./chunk-XYTUTPQI.js";
6
9
 
7
10
  // src/cli.ts
8
11
  var cwdFlag = process.argv.indexOf("--cwd");
9
12
  var cwd = cwdFlag === -1 ? process.cwd() : process.argv[cwdFlag + 1] ?? process.cwd();
10
13
  var tierFlag = process.argv.indexOf("--tier");
11
14
  var tier = tierFlag === -1 ? void 0 : process.argv[tierFlag + 1];
15
+ var proving = process.argv.includes("--prove");
16
+ var exclusive = process.argv.includes("--exclusive");
17
+ var timeoutFlag = process.argv.indexOf("--exclusive-timeout");
18
+ var timeoutSeconds = timeoutFlag === -1 ? void 0 : Number(process.argv[timeoutFlag + 1] ?? Number.NaN);
19
+ var measure = async () => {
20
+ if (proving) {
21
+ const proof = await runProve({ counters: COUNTERS, cwd, tier });
22
+ process.stdout.write(formatProve(proof));
23
+ return proof.proven ? 0 : 2;
24
+ }
25
+ const result = await runRatchet({ counters: COUNTERS, cwd, tier });
26
+ process.stdout.write(formatReport(result));
27
+ return result.measurements.some((one) => one.verdict === "grew") ? 1 : 0;
28
+ };
12
29
  try {
13
30
  if (tierFlag !== -1 && (tier === void 0 || tier.startsWith("--"))) {
14
31
  throw new Error("--tier needs a tier name");
15
32
  }
16
- const result = await runRatchet({ counters: COUNTERS, cwd, tier });
17
- process.stdout.write(formatReport(result));
18
- process.exit(result.measurements.some((one) => one.verdict === "grew") ? 1 : 0);
33
+ if (timeoutSeconds !== void 0 && !(timeoutSeconds > 0)) {
34
+ throw new Error("--exclusive-timeout needs a number of seconds");
35
+ }
36
+ const release = exclusive ? await acquireExclusive({ timeoutSeconds }) : () => void 0;
37
+ const giveBack = () => {
38
+ release();
39
+ process.exit(130);
40
+ };
41
+ process.on("SIGINT", giveBack);
42
+ process.on("SIGTERM", giveBack);
43
+ let code = 2;
44
+ try {
45
+ code = await measure();
46
+ } finally {
47
+ release();
48
+ }
49
+ process.exit(code);
19
50
  } catch (error) {
20
51
  process.stderr.write(`geonosis-ratchet: ${error.message}
21
52
  `);
package/dist/index.js CHANGED
@@ -2,21 +2,29 @@ import {
2
2
  CONFIG_FILE,
3
3
  COUNTERS,
4
4
  CounterError,
5
+ acquireExclusive,
5
6
  counterById,
7
+ formatProve,
6
8
  formatReport,
9
+ heavyLockPath,
7
10
  keyOf,
8
11
  loadConfig,
9
12
  runCommand,
13
+ runProve,
10
14
  runRatchet
11
- } from "./chunk-BTWIR7DK.js";
15
+ } from "./chunk-XYTUTPQI.js";
12
16
  export {
13
17
  CONFIG_FILE,
14
18
  COUNTERS,
15
19
  CounterError,
20
+ acquireExclusive,
16
21
  counterById,
22
+ formatProve,
17
23
  formatReport,
24
+ heavyLockPath,
18
25
  keyOf,
19
26
  loadConfig,
20
27
  runCommand,
28
+ runProve,
21
29
  runRatchet
22
30
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geonosis/ratchet",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Debt as a number that may only shrink — one ratchet, pluggable counters.",
5
5
  "keywords": [
6
6
  "ratchet",