@geonosis/ratchet 0.2.0 → 0.3.0

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,61 @@ 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
+
21
69
  ### A baseline is not proof the gate still gates
22
70
 
23
71
  The ratchet compares a number against a number. Swapping the tool that produces it — a vendored lint
@@ -34,6 +82,33 @@ npx geonosis-lint-parity --a oxlintrc.old.json --b oxlintrc.new.json --out proof
34
82
 
35
83
  Reach first — parity between two configs that both fire nothing is perfect parity — then the tree.
36
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
+
37
112
  ## Config
38
113
 
39
114
  `geonosis.ratchet.json` at the repo root names the counters and how to run them:
@@ -141,17 +216,46 @@ of hidden inside a lump sum that a different rule's warning could mask. dielime
141
216
  packages first.** In a fresh worktree the `dist/*.d.ts` files do not exist yet, and `tsc` reports
142
217
  a false +N of missing-module errors that has nothing to do with the change under test. `pnpm build
143
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.
144
233
  - **A release-age cooldown will refuse a package published minutes ago.** If pnpm's
145
234
  `minimumReleaseAge` blocks `@geonosis/*`, exclude the scope for that project — pnpm writes
146
235
  `minimumReleaseAgeExclude` into `pnpm-workspace.yaml` — rather than lowering the cooldown. The
147
236
  cooldown is protecting every other dependency in the tree; the exclusion is scoped to the one you
148
237
  chose to trust.
149
- - **Bumping to a new version: keep both exclusions until the install is done, then drop the old one
150
- in the same commit.** `minimumReleaseAgeExclude` is matched against what the lockfile is being
151
- asked to resolve, so removing the old pair first makes the resolver walk back through the version
152
- it is replacing and refuse it — an install that fails for the version you are leaving, not the one
153
- you are taking. Old and new together, install, then delete the old line before committing: an
154
- exclusion left behind is a cooldown quietly off for a package nobody is watching any more.
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.
155
259
 
156
260
  ## Counters
157
261
 
@@ -161,7 +265,7 @@ Every counter takes its `command` from the config, so the toolchain stays the re
161
265
  | --- | --- | --- |
162
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` |
163
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` |
164
- | `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` |
165
269
  | `testFailures` | the runner's own failure summary; throws when neither a pass nor a fail count is readable | `command` |
166
270
  | `unformattedFiles` | paths `--list-different` names that exist on disk | `command` |
167
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.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Debt as a number that may only shrink — one ratchet, pluggable counters.",
5
5
  "keywords": [
6
6
  "ratchet",