@geonosis/ratchet 0.4.0 → 0.5.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
@@ -196,6 +196,37 @@ failures, they are unmeasured — and unmeasured reads exactly like green.
196
196
  `examples/during-day.ratchet.json` is four test entries for that reason: `apps/web` plus the three
197
197
  workerd packages that were previously outside every gate.
198
198
 
199
+ ### …or the runner's JSON report, which is stronger
200
+
201
+ `report: "vitest-json"` reads the runner's own machine-readable answer instead of its prose:
202
+
203
+ ```jsonc
204
+ {
205
+ "counter": "testFailures",
206
+ "key": "testFailuresApi",
207
+ "command": "cd apps/api && bunx vitest run --reporter=json --outputFile={report}",
208
+ "report": "vitest-json"
209
+ }
210
+ ```
211
+
212
+ `{report}` is replaced with a path in a temp directory the counter makes and removes; give the entry
213
+ a `reportPath` instead when the report belongs somewhere your CI already collects. A command in this
214
+ mode with neither is refused, naming what is missing — a run whose report goes nowhere is a run
215
+ nobody can read.
216
+
217
+ The counter then reads `numFailedTests`, and **refuses** rather than returning a number when:
218
+
219
+ - the file is not there — a crash before the reporter wrote is not a pass, and it prints no summary
220
+ line either, so the summary parser had nothing to refuse on;
221
+ - the file is not JSON, or has no `numFailedTests` in it;
222
+ - the report says `success: false` and names **0** failing tests — the shape a pool that dies
223
+ mid-run writes. The run did not finish, so there is no number to bank.
224
+
225
+ The summary mode stays the default; nothing changes for an entry that does not ask for a report.
226
+ `--prove` proves BOTH: `testFailures` ships one probe per reading mode and prints them by name
227
+ (`PROVEN testFailuresApi (vitest-json)`), because proving the mode nobody configured says nothing
228
+ about the mode they did.
229
+
199
230
  ### `oxlintRule` counts a warned rule twice, on purpose
200
231
 
201
232
  A rule parked at `"warn"` as ratcheted debt appears in two numbers: once inside `oxlintWarnings`,
@@ -206,6 +237,25 @@ That is not double-counting the total; the second key exists so the debt is visi
206
237
  of hidden inside a lump sum that a different rule's warning could mask. dielime today: `oxlintWarnings`
207
238
  12 → 135 when `no-raw-html-atoms` was armed, with its own key at 123.
208
239
 
240
+ ### `--prove` also proves the lock
241
+
242
+ `--exclusive` is a claim about the machine, so it is measured on the machine, every `--prove`:
243
+
244
+ ```
245
+ PROVEN exclusive: two runs of 400ms serialised, the second starting 161ms after the first finished
246
+ ```
247
+
248
+ The self-test runs two children of this CLI over `--hold <ms>` — an instrument that takes the lock,
249
+ says when it started, waits, says when it finished, and gives the lock back — and requires the second
250
+ to have started after the first finished. Anything else prints `CANNOT FAIL exclusive: interleaved`
251
+ and exits 2. It uses a lock file of its own in a temp directory, so proving the lock never takes the
252
+ real one out from under the runs it exists to serialise.
253
+
254
+ It is here because the lock did not work for two releases and every gate was green throughout:
255
+ `openSync(path, 'wx')` is atomic about the NAME and not about the holder, and a run polling in that
256
+ window read an unparsable lock, called it stale, and took it. A lock nobody has watched fail has not
257
+ been shown to work — and this one guards the running time of every other gate.
258
+
209
259
  ## Running it where it will actually run
210
260
 
211
261
  - **Counters run in the caller's environment.** They inherit the shell the ratchet was started in,
@@ -266,7 +316,7 @@ Every counter takes its `command` from the config, so the toolchain stays the re
266
316
  | `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` |
267
317
  | `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` |
268
318
  | `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` |
269
- | `testFailures` | the runner's own failure summary; throws when neither a pass nor a fail count is readable | `command` |
319
+ | `testFailures` | the runner's own failure summary — or, with `report: "vitest-json"`, `numFailedTests` out of the JSON report the command wrote; throws when nothing is readable, when the report is absent, and when the report failed with nothing failing | `command`, `report`, `reportPath` |
270
320
  | `unformattedFiles` | paths `--list-different` names that exist on disk | `command` |
271
321
  | `cloneCount` | jscpd's `Found N clones` | `command` |
272
322
  | `knipIssues` | the totals under knip's unused-* headings | `command`, `headings` |
@@ -1,4 +1,131 @@
1
- // src/types.ts
1
+ // src/core/lock.ts
2
+ import { randomUUID } from "crypto";
3
+ import { linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
4
+ import { homedir } from "os";
5
+ import { dirname, join } from "path";
6
+ var heavyLockPath = () => process.env.GEONOSIS_HEAVY_LOCK ?? join(homedir(), ".cache", "geonosis", "heavy.lock");
7
+ var sleep = (ms) => new Promise((done) => setTimeout(done, ms));
8
+ var holderOf = (path) => {
9
+ try {
10
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
11
+ return typeof parsed.pid === "number" ? {
12
+ cwd: parsed.cwd ?? "somewhere",
13
+ pid: parsed.pid,
14
+ startedAt: parsed.startedAt ?? "unknown"
15
+ } : void 0;
16
+ } catch {
17
+ return void 0;
18
+ }
19
+ };
20
+ var alive = (pid) => {
21
+ try {
22
+ process.kill(pid, 0);
23
+ return true;
24
+ } catch (error) {
25
+ return error.code === "EPERM";
26
+ }
27
+ };
28
+ var heldFor = (holder) => {
29
+ const since = Date.parse(holder.startedAt);
30
+ if (Number.isNaN(since)) return "an unknown time";
31
+ return `${Math.round((Date.now() - since) / 1e3)}s`;
32
+ };
33
+ var write = (path) => {
34
+ mkdirSync(dirname(path), { recursive: true });
35
+ const mine = {
36
+ cwd: process.cwd(),
37
+ pid: process.pid,
38
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
39
+ };
40
+ const staging = `${path}.${process.pid}.${randomUUID()}`;
41
+ try {
42
+ writeFileSync(staging, JSON.stringify(mine));
43
+ linkSync(staging, path);
44
+ return true;
45
+ } catch (error) {
46
+ if (error.code === "EEXIST") return false;
47
+ throw error;
48
+ } finally {
49
+ rmSync(staging, { force: true });
50
+ }
51
+ };
52
+ var acquireExclusive = async ({
53
+ noticeMs = 15e3,
54
+ path = heavyLockPath(),
55
+ pollMs = 250,
56
+ say = (line) => process.stderr.write(`${line}
57
+ `),
58
+ timeoutSeconds = 1800
59
+ } = {}) => {
60
+ const until = Date.now() + timeoutSeconds * 1e3;
61
+ let told = 0;
62
+ const release = () => {
63
+ if (holderOf(path)?.pid === process.pid) rmSync(path, { force: true });
64
+ };
65
+ for (; ; ) {
66
+ if (write(path)) return release;
67
+ const holder = holderOf(path);
68
+ if (holder === void 0 || !alive(holder.pid)) {
69
+ say(
70
+ `geonosis-ratchet: taking over a stale heavy lock (pid ${holder?.pid ?? "unreadable"} is gone)`
71
+ );
72
+ rmSync(path, { force: true });
73
+ continue;
74
+ }
75
+ if (Date.now() >= until) {
76
+ throw new Error(
77
+ `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`
78
+ );
79
+ }
80
+ const now = Date.now();
81
+ if (now - told >= noticeMs) {
82
+ told = now;
83
+ say(
84
+ `geonosis-ratchet: waiting for the heavy lock \u2014 pid ${holder.pid} in ${holder.cwd}, held for ${heldFor(holder)}`
85
+ );
86
+ }
87
+ await sleep(pollMs);
88
+ }
89
+ };
90
+
91
+ // src/core/config.ts
92
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
93
+ import { resolve } from "path";
94
+ var CONFIG_FILE = "geonosis.ratchet.json";
95
+ var keyOf = (entry) => entry.key ?? entry.counter;
96
+ var loadConfig = (cwd) => {
97
+ const path = resolve(cwd, CONFIG_FILE);
98
+ if (!existsSync(path)) {
99
+ throw new Error(`no ${CONFIG_FILE} in ${cwd} \u2014 the ratchet has nothing to count`);
100
+ }
101
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
102
+ if (!Array.isArray(parsed.counters)) {
103
+ throw new Error(`${CONFIG_FILE} has no "counters" array`);
104
+ }
105
+ for (const entry of parsed.counters) {
106
+ if (typeof entry.counter !== "string") {
107
+ throw new Error(`${CONFIG_FILE}: every counter entry needs a "counter" id`);
108
+ }
109
+ if (entry.tiers !== void 0) {
110
+ const named = Array.isArray(entry.tiers) && entry.tiers.length > 0 && entry.tiers.every((tier) => typeof tier === "string" && tier.length > 0);
111
+ if (!named) {
112
+ throw new Error(
113
+ `${CONFIG_FILE}: "${keyOf(entry)}" has "tiers" that is not a non-empty list of tier names`
114
+ );
115
+ }
116
+ }
117
+ }
118
+ const keys = parsed.counters.map(keyOf);
119
+ const duplicate = keys.find((key, at) => keys.indexOf(key) !== at);
120
+ if (duplicate !== void 0) {
121
+ throw new Error(
122
+ `${CONFIG_FILE}: two counters both write "${duplicate}" \u2014 give one of them a distinct "key"`
123
+ );
124
+ }
125
+ return { baseline: parsed.baseline ?? "gate-baseline.json", counters: parsed.counters };
126
+ };
127
+
128
+ // src/core/types.ts
2
129
  var CounterError = class extends Error {
3
130
  constructor(counter, message) {
4
131
  super(`${counter}: ${message}`);
@@ -8,6 +135,276 @@ var CounterError = class extends Error {
8
135
  counter;
9
136
  };
10
137
 
138
+ // src/core/shell.ts
139
+ import { execSync } from "child_process";
140
+ var ANSI = /\[[0-9;]*m/g;
141
+ var runCommand = (cwd, counterId, env) => (command) => {
142
+ try {
143
+ const output = execSync(`${command} 2>&1`, {
144
+ cwd,
145
+ encoding: "utf8",
146
+ env,
147
+ maxBuffer: 64 * 1024 * 1024,
148
+ stdio: ["ignore", "pipe", "pipe"]
149
+ });
150
+ return { code: 0, output: output.replaceAll(ANSI, "") };
151
+ } catch (error) {
152
+ const failed = error;
153
+ const output = `${failed.stdout ?? ""}${failed.stderr ?? ""}`.replaceAll(ANSI, "");
154
+ const code = failed.status ?? -1;
155
+ if (code === 126 || code === 127 || code === -1) {
156
+ throw new CounterError(
157
+ counterId,
158
+ `command did not run (exit ${code}): ${command}
159
+ ${output.trim()}`
160
+ );
161
+ }
162
+ return { code, output };
163
+ }
164
+ };
165
+
166
+ // src/core/prove.ts
167
+ import { existsSync as existsSync2, mkdtempSync, rmSync as rmSync2 } from "fs";
168
+ import { tmpdir } from "os";
169
+ import { delimiter, dirname as dirname3, join as join2, resolve as resolve3 } from "path";
170
+
171
+ // src/core/exclusive.ts
172
+ import { spawn } from "child_process";
173
+ import { dirname as dirname2, resolve as resolve2 } from "path";
174
+ var MARK = /^exclusive-hold (start|end) (\d+)$/gm;
175
+ var KEY = "exclusive";
176
+ var spanOf = (output) => {
177
+ const marks = [...output.matchAll(MARK)];
178
+ const start = marks.find((one) => one[1] === "start")?.[2];
179
+ const end = marks.find((one) => one[1] === "end")?.[2];
180
+ if (start === void 0 || end === void 0) return void 0;
181
+ return { end: Number(end), start: Number(start) };
182
+ };
183
+ var hold = (cli, holdMs, lockPath) => new Promise((done) => {
184
+ const child = spawn(
185
+ process.execPath,
186
+ [cli, "--exclusive", "--exclusive-timeout", "60", "--hold", String(holdMs)],
187
+ {
188
+ cwd: dirname2(resolve2(cli)),
189
+ env: { ...process.env, GEONOSIS_HEAVY_LOCK: lockPath },
190
+ stdio: ["ignore", "pipe", "pipe"]
191
+ }
192
+ );
193
+ let output = "";
194
+ child.stdout.on("data", (chunk) => {
195
+ output += chunk.toString();
196
+ });
197
+ child.stderr.on("data", (chunk) => {
198
+ output += chunk.toString();
199
+ });
200
+ child.on("close", (code) => done({ code, output }));
201
+ });
202
+ var proveExclusive = async ({
203
+ cli,
204
+ holdMs = 400,
205
+ lockPath
206
+ }) => {
207
+ const runs = await Promise.all([hold(cli, holdMs, lockPath), hold(cli, holdMs, lockPath)]);
208
+ const spans = runs.map((one) => spanOf(one.output));
209
+ const [first, second] = spans;
210
+ if (first === void 0 || second === void 0) {
211
+ return {
212
+ counter: KEY,
213
+ key: KEY,
214
+ reason: `a run under --exclusive printed no start/end to compare:
215
+ ${runs.map((one) => `exit ${String(one.code)}: ${one.output.trim().slice(-300)}`).join("\n")}`,
216
+ verdict: "cannot-measure"
217
+ };
218
+ }
219
+ const [early, late] = first.start <= second.start ? [first, second] : [second, first];
220
+ const gap = late.start - early.end;
221
+ if (gap < 0) {
222
+ return {
223
+ key: KEY,
224
+ reason: `the second run started ${-gap}ms BEFORE the first finished \u2014 two runs, one lock, both holding it`,
225
+ verdict: "interleaved"
226
+ };
227
+ }
228
+ return {
229
+ key: KEY,
230
+ reason: `two runs of ${holdMs}ms serialised, the second starting ${gap}ms after the first finished`,
231
+ verdict: "serialised"
232
+ };
233
+ };
234
+
235
+ // src/core/prove.ts
236
+ var toolPath = (cwd) => {
237
+ const dirs = [];
238
+ let dir = resolve3(cwd);
239
+ for (; ; ) {
240
+ const bin = join2(dir, "node_modules", ".bin");
241
+ if (existsSync2(bin)) dirs.push(bin);
242
+ const parent = dirname3(dir);
243
+ if (parent === dir) break;
244
+ dir = parent;
245
+ }
246
+ return [...dirs, process.env.PATH ?? ""].join(delimiter);
247
+ };
248
+ var NO_PROBE = "no probe \u2014 a counter nobody has seen read a planted finding has not been shown to measure";
249
+ var oneProofOf = async (counter, key, path, probe) => {
250
+ const dir = mkdtempSync(join2(tmpdir(), "geonosis-prove-"));
251
+ try {
252
+ probe.input(dir);
253
+ const command = probe.command?.(dir);
254
+ const reading = await counter.run({
255
+ cwd: dir,
256
+ key,
257
+ params: { ...probe.params, ...command === void 0 ? {} : { command } },
258
+ run: runCommand(dir, counter.id, { ...process.env, PATH: path })
259
+ });
260
+ if (reading === 0) return { counter: counter.id, key, reading, verdict: "cannot-fail" };
261
+ if (reading < probe.expect) {
262
+ return { counter: counter.id, expected: probe.expect, key, reading, verdict: "misread" };
263
+ }
264
+ return { counter: counter.id, key, reading, verdict: "proven" };
265
+ } catch (error) {
266
+ return { counter: counter.id, key, reason: error.message, verdict: "cannot-measure" };
267
+ } finally {
268
+ rmSync2(dir, { force: true, recursive: true });
269
+ }
270
+ };
271
+ var probesOf = (counter) => {
272
+ if (counter.probe === void 0) return [];
273
+ return Array.isArray(counter.probe) ? counter.probe : [counter.probe];
274
+ };
275
+ var proofOf = async (counter, key, path) => {
276
+ const probes = probesOf(counter);
277
+ if (probes.length === 0) {
278
+ return [{ counter: counter.id, key, reason: NO_PROBE, verdict: "cannot-measure" }];
279
+ }
280
+ const proofs = [];
281
+ for (const probe of probes) {
282
+ const named = probe.name === void 0 ? key : `${key} (${probe.name})`;
283
+ const proof = await oneProofOf(counter, named, path, probe);
284
+ proofs.push(proof);
285
+ if (proof.verdict !== "proven") break;
286
+ }
287
+ return proofs;
288
+ };
289
+ var outsideTier = (entry, tier) => entry.tiers !== void 0 && !entry.tiers.includes(tier);
290
+ var runProve = async ({
291
+ counters,
292
+ cwd,
293
+ exclusiveVia,
294
+ tier
295
+ }) => {
296
+ const config = loadConfig(cwd);
297
+ const byId = new Map(counters.map((one) => [one.id, one]));
298
+ const path = toolPath(cwd);
299
+ const proofs = [];
300
+ for (const entry of config.counters) {
301
+ const counter = byId.get(entry.counter);
302
+ if (counter === void 0) {
303
+ throw new Error(
304
+ `no counter implements "${entry.counter}" \u2014 known ids: ${[...byId.keys()].toSorted().join(", ")}`
305
+ );
306
+ }
307
+ const key = keyOf(entry);
308
+ if (tier !== void 0 && outsideTier(entry, tier)) {
309
+ proofs.push({ key, tier, verdict: "skipped" });
310
+ continue;
311
+ }
312
+ const taken = await proofOf(counter, key, path);
313
+ proofs.push(...taken);
314
+ if (taken.some((one) => one.verdict !== "proven" && one.verdict !== "skipped")) {
315
+ return { proofs, proven: false };
316
+ }
317
+ }
318
+ if (exclusiveVia !== void 0) {
319
+ const dir = mkdtempSync(join2(tmpdir(), "geonosis-lock-"));
320
+ try {
321
+ const proof = await proveExclusive({ cli: exclusiveVia, lockPath: join2(dir, "heavy.lock") });
322
+ proofs.push(proof);
323
+ if (proof.verdict !== "serialised") return { proofs, proven: false };
324
+ } finally {
325
+ rmSync2(dir, { force: true, recursive: true });
326
+ }
327
+ }
328
+ return { proofs, proven: true };
329
+ };
330
+
331
+ // src/core/ratchet.ts
332
+ import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
333
+ import { resolve as resolve4 } from "path";
334
+ var EVIDENCE_LINES = 10;
335
+ var recorded = (run) => {
336
+ let output = "";
337
+ return {
338
+ last: () => output.split("\n").map((line) => line.trimEnd()).filter((line) => line !== "").slice(-EVIDENCE_LINES),
339
+ run: (command) => {
340
+ const result = run(command);
341
+ output = result.output;
342
+ return result;
343
+ }
344
+ };
345
+ };
346
+ var verdictOf = (now, baseline) => {
347
+ if (now > baseline) return "grew";
348
+ if (now < baseline) return "shrank";
349
+ return "held";
350
+ };
351
+ var outsideTier2 = (entry, tier) => entry.tiers !== void 0 && !entry.tiers.includes(tier);
352
+ var runRatchet = async ({
353
+ counters,
354
+ cwd,
355
+ tier
356
+ }) => {
357
+ const config = loadConfig(cwd);
358
+ const baselinePath = resolve4(cwd, config.baseline);
359
+ if (!existsSync3(baselinePath)) {
360
+ throw new Error(`no ${config.baseline} in ${cwd} \u2014 nothing to ratchet against`);
361
+ }
362
+ const baseline = JSON.parse(readFileSync3(baselinePath, "utf8"));
363
+ const byId = new Map(counters.map((one) => [one.id, one]));
364
+ const measurements = [];
365
+ for (const entry of config.counters) {
366
+ const counter = byId.get(entry.counter);
367
+ if (counter === void 0) {
368
+ throw new Error(
369
+ `no counter implements "${entry.counter}" \u2014 known ids: ${[...byId.keys()].toSorted().join(", ")}`
370
+ );
371
+ }
372
+ const key = keyOf(entry);
373
+ if (tier !== void 0 && outsideTier2(entry, tier)) {
374
+ measurements.push({ key, tier, verdict: "skipped" });
375
+ continue;
376
+ }
377
+ const limit = baseline[key];
378
+ if (typeof limit !== "number") {
379
+ throw new Error(
380
+ `${config.baseline} has no number for "${key}" \u2014 add it before enabling the counter`
381
+ );
382
+ }
383
+ const recorder = recorded(runCommand(cwd, entry.counter));
384
+ const now = await counter.run({ cwd, key, params: entry, run: recorder.run });
385
+ const verdict = verdictOf(now, limit);
386
+ measurements.push({
387
+ baseline: limit,
388
+ evidence: verdict === "grew" ? recorder.last() : [],
389
+ key,
390
+ now,
391
+ verdict
392
+ });
393
+ }
394
+ const grew = measurements.some((one) => one.verdict === "grew");
395
+ const shrank = measurements.some((one) => one.verdict === "shrank");
396
+ if (shrank && !grew) {
397
+ const next = { ...baseline };
398
+ for (const one of measurements) {
399
+ if (one.verdict !== "skipped") next[one.key] = one.now;
400
+ }
401
+ writeFileSync2(baselinePath, `${JSON.stringify(next, null, 2)}
402
+ `);
403
+ return { measurements, rewritten: true };
404
+ }
405
+ return { measurements, rewritten: false };
406
+ };
407
+
11
408
  // src/counters/params.ts
12
409
  var stringParam = (counter, params, name, fallback) => {
13
410
  const value = params[name];
@@ -24,12 +421,12 @@ var countMatches = (text, pattern) => text.match(new RegExp(pattern.source, `${p
24
421
  var escapeForRegex = (value) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
25
422
 
26
423
  // src/counters/plant.ts
27
- import { mkdirSync, writeFileSync } from "fs";
28
- import { dirname, join } from "path";
424
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
425
+ import { dirname as dirname4, join as join3 } from "path";
29
426
  var plant = (dir, relative, contents) => {
30
- const path = join(dir, relative);
31
- mkdirSync(dirname(path), { recursive: true });
32
- writeFileSync(path, contents);
427
+ const path = join3(dir, relative);
428
+ mkdirSync2(dirname4(path), { recursive: true });
429
+ writeFileSync3(path, contents);
33
430
  };
34
431
  var captured = (sample) => ({
35
432
  command: () => "cat sample.txt",
@@ -93,8 +490,8 @@ var boundaryIssues = {
93
490
  };
94
491
 
95
492
  // src/counters/format.ts
96
- import { existsSync } from "fs";
97
- import { resolve } from "path";
493
+ import { existsSync as existsSync4 } from "fs";
494
+ import { resolve as resolve5 } from "path";
98
495
  var unformattedFiles = {
99
496
  id: "unformattedFiles",
100
497
  probe: {
@@ -109,13 +506,13 @@ var unformattedFiles = {
109
506
  "command",
110
507
  "npx oxfmt --config .oxfmtrc.json --list-different ."
111
508
  );
112
- return run(command).output.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && existsSync(resolve(cwd, line))).length;
509
+ return run(command).output.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && existsSync4(resolve5(cwd, line))).length;
113
510
  }
114
511
  };
115
512
 
116
513
  // src/counters/law.ts
117
- import { existsSync as existsSync2, readFileSync } from "fs";
118
- import { resolve as resolve2 } from "path";
514
+ import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
515
+ import { resolve as resolve6 } from "path";
119
516
  var lawLineCount = {
120
517
  id: "lawLineCount",
121
518
  probe: {
@@ -125,15 +522,15 @@ var lawLineCount = {
125
522
  },
126
523
  run: async ({ cwd, params }) => {
127
524
  const relative = stringParam("lawLineCount", params, "path", "CLAUDE.md");
128
- const path = resolve2(cwd, relative);
129
- if (!existsSync2(path)) throw new CounterError("lawLineCount", `no law file at ${relative}`);
130
- return readFileSync(path, "utf8").replace(/\n$/, "").split("\n").length;
525
+ const path = resolve6(cwd, relative);
526
+ if (!existsSync5(path)) throw new CounterError("lawLineCount", `no law file at ${relative}`);
527
+ return readFileSync4(path, "utf8").replace(/\n$/, "").split("\n").length;
131
528
  }
132
529
  };
133
530
 
134
531
  // src/counters/oxlint.ts
135
- import { existsSync as existsSync3, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
136
- import { resolve as resolve3 } from "path";
532
+ import { existsSync as existsSync6, readFileSync as readFileSync5, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
533
+ import { resolve as resolve7 } from "path";
137
534
  var DEFAULT_COMMAND = "npx oxlint --format=unix --config .oxlintrc.json .";
138
535
  var PROBE_COMMAND = "oxlint --format=unix --config .oxlintrc.json .";
139
536
  var oxlintProbe = (severity, rule, source) => ({
@@ -272,18 +669,18 @@ var oxlintRule = {
272
669
  const config = typeof params.config === "string" ? params.config : "";
273
670
  const expect = expectedFormat("oxlintRule", params);
274
671
  if (config === "") return countRule(run(command), rule, expect);
275
- const source = resolve3(cwd, config);
276
- if (!existsSync3(source)) throw new CounterError("oxlintRule", `no config at ${config}`);
672
+ const source = resolve7(cwd, config);
673
+ if (!existsSync6(source)) throw new CounterError("oxlintRule", `no config at ${config}`);
277
674
  const strictName = `.oxlintrc.ratchet-${key}.json`;
278
- const strict = readFileSync2(source, "utf8").replace(
675
+ const strict = readFileSync5(source, "utf8").replace(
279
676
  new RegExp(`("[^"]*${escapeForRegex(rule)}"\\s*:\\s*\\[?\\s*)"(warn|off)"`),
280
677
  '$1"error"'
281
678
  );
282
- writeFileSync2(resolve3(cwd, strictName), strict);
679
+ writeFileSync4(resolve7(cwd, strictName), strict);
283
680
  try {
284
681
  return countRule(run(command.replace("{config}", strictName)), rule, expect);
285
682
  } finally {
286
- rmSync(resolve3(cwd, strictName), { force: true });
683
+ rmSync3(resolve7(cwd, strictName), { force: true });
287
684
  }
288
685
  }
289
686
  };
@@ -323,471 +720,231 @@ var sumOfCounts = {
323
720
  };
324
721
 
325
722
  // src/counters/tests.ts
723
+ import { existsSync as existsSync7, mkdtempSync as mkdtempSync2, readFileSync as readFileSync6, rmSync as rmSync4 } from "fs";
724
+ import { tmpdir as tmpdir2 } from "os";
725
+ import { join as join4, resolve as resolve8 } from "path";
326
726
  var FAILED = /(\d+)\s+fail(?:ed|ing|s)?\b/;
327
727
  var PASSED = /(\d+)\s+pass(?:ed|ing|es)?\b/;
328
- var testFailures = {
329
- id: "testFailures",
330
- probe: { ...captured(" Tests 1 failed | 0 passed (1)\n"), expect: 1 },
331
- run: async ({ params, run }) => {
332
- const command = stringParam("testFailures", params, "command", "npx vitest run");
333
- const output = run(command).output;
334
- const failed = output.match(FAILED)?.[1];
335
- if (failed !== void 0) return Number(failed);
336
- if (PASSED.test(output)) return 0;
337
- throw new CounterError(
338
- "testFailures",
339
- `no pass/fail summary in the runner output:
728
+ var COUNTER = "testFailures";
729
+ var VITEST_JSON = "vitest-json";
730
+ var PLACEHOLDER = "{report}";
731
+ var fromSummary = (output) => {
732
+ const failed = output.match(FAILED)?.[1];
733
+ if (failed !== void 0) return Number(failed);
734
+ if (PASSED.test(output)) return 0;
735
+ throw new CounterError(
736
+ COUNTER,
737
+ `no pass/fail summary in the runner output:
340
738
  ${output.trim().slice(-500)}`
341
- );
342
- }
343
- };
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
-
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
739
  );
421
- return [...new Set(named)].toSorted();
422
- };
423
- var typecheckErrors = {
424
- id: "typecheckErrors",
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 }) => {
431
- const command = stringParam("typecheckErrors", params, "command", "npx tsc --noEmit");
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/);
441
- }
442
740
  };
443
-
444
- // src/counters/index.ts
445
- var COUNTERS = [
446
- archViolations,
447
- boundaryIssues,
448
- cloneCount,
449
- knipIssues,
450
- lawLineCount,
451
- oxlintErrors,
452
- oxlintRule,
453
- oxlintWarnings,
454
- runtimeCodeShipped,
455
- sumOfCounts,
456
- testFailures,
457
- typecheckErrors,
458
- unformattedFiles
459
- ];
460
- var counterById = (id) => {
461
- const counter = COUNTERS.find((one) => one.id === id);
462
- if (counter === void 0) {
463
- throw new Error(
464
- `no counter implements "${id}" \u2014 known ids: ${COUNTERS.map((one) => one.id).join(", ")}`
741
+ var fromReport = (path) => {
742
+ if (!existsSync7(path)) {
743
+ throw new CounterError(
744
+ COUNTER,
745
+ `the runner wrote no report at ${path} \u2014 a crash before the reporter is not a pass`
465
746
  );
466
747
  }
467
- return counter;
468
- };
469
-
470
- // src/lock.ts
471
- import { randomUUID } from "crypto";
472
- import { linkSync, mkdirSync as mkdirSync2, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
473
- import { homedir } from "os";
474
- import { dirname as dirname2, join as join3 } from "path";
475
- var heavyLockPath = () => process.env.GEONOSIS_HEAVY_LOCK ?? join3(homedir(), ".cache", "geonosis", "heavy.lock");
476
- var sleep = (ms) => new Promise((done) => setTimeout(done, ms));
477
- var holderOf = (path) => {
478
- try {
479
- const parsed = JSON.parse(readFileSync4(path, "utf8"));
480
- return typeof parsed.pid === "number" ? {
481
- cwd: parsed.cwd ?? "somewhere",
482
- pid: parsed.pid,
483
- startedAt: parsed.startedAt ?? "unknown"
484
- } : void 0;
485
- } catch {
486
- return void 0;
487
- }
488
- };
489
- var alive = (pid) => {
748
+ let report;
490
749
  try {
491
- process.kill(pid, 0);
492
- return true;
750
+ report = JSON.parse(readFileSync6(path, "utf8"));
493
751
  } catch (error) {
494
- return error.code === "EPERM";
752
+ throw new CounterError(
753
+ COUNTER,
754
+ `the report at ${path} is not JSON: ${error.message}`
755
+ );
495
756
  }
496
- };
497
- var heldFor = (holder) => {
498
- const since = Date.parse(holder.startedAt);
499
- if (Number.isNaN(since)) return "an unknown time";
500
- return `${Math.round((Date.now() - since) / 1e3)}s`;
501
- };
502
- var write = (path) => {
503
- mkdirSync2(dirname2(path), { recursive: true });
504
- const mine = {
505
- cwd: process.cwd(),
506
- pid: process.pid,
507
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
508
- };
509
- const staging = `${path}.${process.pid}.${randomUUID()}`;
510
- try {
511
- writeFileSync3(staging, JSON.stringify(mine));
512
- linkSync(staging, path);
513
- return true;
514
- } catch (error) {
515
- if (error.code === "EEXIST") return false;
516
- throw error;
517
- } finally {
518
- rmSync2(staging, { force: true });
757
+ const failed = report.numFailedTests;
758
+ if (typeof failed !== "number") {
759
+ throw new CounterError(
760
+ COUNTER,
761
+ `the report at ${path} has no "numFailedTests" \u2014 it is not a vitest JSON report`
762
+ );
519
763
  }
520
- };
521
- var acquireExclusive = async ({
522
- noticeMs = 15e3,
523
- path = heavyLockPath(),
524
- pollMs = 250,
525
- say = (line) => process.stderr.write(`${line}
526
- `),
527
- timeoutSeconds = 1800
528
- } = {}) => {
529
- const until = Date.now() + timeoutSeconds * 1e3;
530
- let told = 0;
531
- const release = () => {
532
- if (holderOf(path)?.pid === process.pid) rmSync2(path, { force: true });
533
- };
534
- for (; ; ) {
535
- if (write(path)) return release;
536
- const holder = holderOf(path);
537
- if (holder === void 0 || !alive(holder.pid)) {
538
- say(
539
- `geonosis-ratchet: taking over a stale heavy lock (pid ${holder?.pid ?? "unreadable"} is gone)`
540
- );
541
- rmSync2(path, { force: true });
542
- continue;
543
- }
544
- if (Date.now() >= until) {
545
- throw new Error(
546
- `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`
547
- );
548
- }
549
- const now = Date.now();
550
- if (now - told >= noticeMs) {
551
- told = now;
552
- say(
553
- `geonosis-ratchet: waiting for the heavy lock \u2014 pid ${holder.pid} in ${holder.cwd}, held for ${heldFor(holder)}`
554
- );
555
- }
556
- await sleep(pollMs);
764
+ if (report.success === false && failed === 0) {
765
+ throw new CounterError(
766
+ COUNTER,
767
+ `the report at ${path} says success: false and names 0 failing tests \u2014 the run did not finish, so there is no number to bank`
768
+ );
557
769
  }
770
+ return failed;
558
771
  };
559
-
560
- // src/config.ts
561
- import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
562
- import { resolve as resolve5 } from "path";
563
- var CONFIG_FILE = "geonosis.ratchet.json";
564
- var keyOf = (entry) => entry.key ?? entry.counter;
565
- var loadConfig = (cwd) => {
566
- const path = resolve5(cwd, CONFIG_FILE);
567
- if (!existsSync5(path)) {
568
- throw new Error(`no ${CONFIG_FILE} in ${cwd} \u2014 the ratchet has nothing to count`);
569
- }
570
- const parsed = JSON.parse(readFileSync5(path, "utf8"));
571
- if (!Array.isArray(parsed.counters)) {
572
- throw new Error(`${CONFIG_FILE} has no "counters" array`);
573
- }
574
- for (const entry of parsed.counters) {
575
- if (typeof entry.counter !== "string") {
576
- throw new Error(`${CONFIG_FILE}: every counter entry needs a "counter" id`);
577
- }
578
- if (entry.tiers !== void 0) {
579
- const named = Array.isArray(entry.tiers) && entry.tiers.length > 0 && entry.tiers.every((tier) => typeof tier === "string" && tier.length > 0);
580
- if (!named) {
581
- throw new Error(
582
- `${CONFIG_FILE}: "${keyOf(entry)}" has "tiers" that is not a non-empty list of tier names`
583
- );
584
- }
585
- }
586
- }
587
- const keys = parsed.counters.map(keyOf);
588
- const duplicate = keys.find((key, at) => keys.indexOf(key) !== at);
589
- if (duplicate !== void 0) {
590
- throw new Error(
591
- `${CONFIG_FILE}: two counters both write "${duplicate}" \u2014 give one of them a distinct "key"`
772
+ var reportPathFor = (cwd, params, command) => {
773
+ const named = params.reportPath;
774
+ if (typeof named === "string" && named !== "") return { own: false, path: resolve8(cwd, named) };
775
+ if (!command.includes(PLACEHOLDER)) {
776
+ throw new CounterError(
777
+ COUNTER,
778
+ `report: "${VITEST_JSON}" needs somewhere to put the report \u2014 write ${PLACEHOLDER} into the command (--outputFile=${PLACEHOLDER}) or give the entry a "reportPath"`
592
779
  );
593
780
  }
594
- return { baseline: parsed.baseline ?? "gate-baseline.json", counters: parsed.counters };
781
+ return { own: true, path: join4(mkdtempSync2(join4(tmpdir2(), "geonosis-report-")), "report.json") };
595
782
  };
596
-
597
- // src/shell.ts
598
- import { execSync } from "child_process";
599
- var ANSI = /\[[0-9;]*m/g;
600
- var runCommand = (cwd, counterId, env) => (command) => {
601
- try {
602
- const output = execSync(`${command} 2>&1`, {
603
- cwd,
604
- encoding: "utf8",
605
- env,
606
- maxBuffer: 64 * 1024 * 1024,
607
- stdio: ["ignore", "pipe", "pipe"]
608
- });
609
- return { code: 0, output: output.replaceAll(ANSI, "") };
610
- } catch (error) {
611
- const failed = error;
612
- const output = `${failed.stdout ?? ""}${failed.stderr ?? ""}`.replaceAll(ANSI, "");
613
- const code = failed.status ?? -1;
614
- if (code === 126 || code === 127 || code === -1) {
615
- throw new CounterError(
616
- counterId,
617
- `command did not run (exit ${code}): ${command}
618
- ${output.trim()}`
783
+ var testFailures = {
784
+ id: COUNTER,
785
+ probe: [
786
+ { ...captured(" Tests 1 failed | 0 passed (1)\n"), expect: 1, name: "summary" },
787
+ {
788
+ // `cat` stands in for the runner: the placeholder is substituted into it, so a counter that
789
+ // stopped substituting would hand `cat` the literal token and read nothing.
790
+ command: () => `cat ${PLACEHOLDER}`,
791
+ expect: 1,
792
+ input: (dir) => plant(
793
+ dir,
794
+ "planted-report.json",
795
+ `${JSON.stringify({ numFailedTests: 1, numTotalTests: 1, success: false })}
796
+ `
797
+ ),
798
+ name: VITEST_JSON,
799
+ params: { report: VITEST_JSON, reportPath: "planted-report.json" }
800
+ }
801
+ ],
802
+ run: async ({ cwd, params, run }) => {
803
+ const command = stringParam(COUNTER, params, "command", "npx vitest run");
804
+ const mode = params.report;
805
+ if (mode === void 0) return fromSummary(run(command).output);
806
+ if (mode !== VITEST_JSON) {
807
+ throw new CounterError(
808
+ COUNTER,
809
+ `does not know the report format "${String(mode)}" \u2014 the one it reads is "${VITEST_JSON}"`
619
810
  );
620
811
  }
621
- return { code, output };
812
+ const { own, path } = reportPathFor(cwd, params, command);
813
+ try {
814
+ run(command.replaceAll(PLACEHOLDER, path));
815
+ return fromReport(path);
816
+ } finally {
817
+ if (own) rmSync4(join4(path, ".."), { force: true, recursive: true });
818
+ }
622
819
  }
623
820
  };
624
821
 
625
- // src/prove.ts
626
- import { existsSync as existsSync6, mkdtempSync, rmSync as rmSync3 } from "fs";
627
- import { tmpdir } from "os";
628
- import { delimiter, dirname as dirname3, join as join4, resolve as resolve6 } from "path";
629
- var toolPath = (cwd) => {
630
- const dirs = [];
631
- let dir = resolve6(cwd);
632
- for (; ; ) {
633
- const bin = join4(dir, "node_modules", ".bin");
634
- if (existsSync6(bin)) dirs.push(bin);
635
- const parent = dirname3(dir);
636
- if (parent === dir) break;
637
- dir = parent;
822
+ // src/counters/workspace.ts
823
+ import { existsSync as existsSync8, readdirSync, readFileSync as readFileSync7 } from "fs";
824
+ import { join as join5, resolve as resolve9 } from "path";
825
+ var SKIP = /^(node_modules|\.)/;
826
+ var childDirs = (dir) => {
827
+ try {
828
+ return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !SKIP.test(entry.name)).map((entry) => join5(dir, entry.name));
829
+ } catch {
830
+ return [];
638
831
  }
639
- return [...dirs, process.env.PATH ?? ""].join(delimiter);
640
832
  };
641
- var NO_PROBE = "no probe \u2014 a counter nobody has seen read a planted finding has not been shown to measure";
642
- var proofOf = async (counter, key, path) => {
643
- const probe = counter.probe;
644
- if (probe === void 0)
645
- return { counter: counter.id, key, reason: NO_PROBE, verdict: "cannot-measure" };
646
- const dir = mkdtempSync(join4(tmpdir(), "geonosis-prove-"));
647
- try {
648
- probe.input(dir);
649
- const command = probe.command?.(dir);
650
- const reading = await counter.run({
651
- cwd: dir,
652
- key,
653
- params: { ...probe.params, ...command === void 0 ? {} : { command } },
654
- run: runCommand(dir, counter.id, { ...process.env, PATH: path })
655
- });
656
- if (reading === 0) return { counter: counter.id, key, reading, verdict: "cannot-fail" };
657
- if (reading < probe.expect) {
658
- return { counter: counter.id, expected: probe.expect, key, reading, verdict: "misread" };
659
- }
660
- return { counter: counter.id, key, reading, verdict: "proven" };
661
- } catch (error) {
662
- return { counter: counter.id, key, reason: error.message, verdict: "cannot-measure" };
663
- } finally {
664
- rmSync3(dir, { force: true, recursive: true });
833
+ var descendants = (dir, depth) => depth === 0 ? [dir] : [dir, ...childDirs(dir).flatMap((child) => descendants(child, depth - 1))];
834
+ var expand = (root, pattern) => {
835
+ const segments = pattern.split("/").filter((one) => one !== "" && one !== ".");
836
+ let dirs = [root];
837
+ for (const segment of segments) {
838
+ dirs = segment === "*" ? dirs.flatMap((dir) => childDirs(dir)) : segment === "**" ? dirs.flatMap((dir) => descendants(dir, 3)) : dirs.map((dir) => join5(dir, segment)).filter((dir) => existsSync8(dir));
665
839
  }
840
+ return dirs;
666
841
  };
667
- var outsideTier = (entry, tier) => entry.tiers !== void 0 && !entry.tiers.includes(tier);
668
- var runProve = async ({
669
- counters,
670
- cwd,
671
- tier
672
- }) => {
673
- const config = loadConfig(cwd);
674
- const byId = new Map(counters.map((one) => [one.id, one]));
675
- const path = toolPath(cwd);
676
- const proofs = [];
677
- for (const entry of config.counters) {
678
- const counter = byId.get(entry.counter);
679
- if (counter === void 0) {
680
- throw new Error(
681
- `no counter implements "${entry.counter}" \u2014 known ids: ${[...byId.keys()].toSorted().join(", ")}`
682
- );
683
- }
684
- const key = keyOf(entry);
685
- if (tier !== void 0 && outsideTier(entry, tier)) {
686
- proofs.push({ key, tier, verdict: "skipped" });
842
+ var QUOTED = /^['"]|['"]$/g;
843
+ var cleaned = (value) => value.replace(/#.*$/, "").trim().replaceAll(QUOTED, "");
844
+ var pnpmPatterns = (path) => {
845
+ const lines = readFileSync7(path, "utf8").split("\n");
846
+ const at = lines.findIndex((line) => line.startsWith("packages:"));
847
+ if (at === -1) return [];
848
+ const inline = lines[at]?.slice("packages:".length).trim() ?? "";
849
+ if (inline.startsWith("[")) {
850
+ return inline.replace(/^\[|\]$/g, "").split(",").map(cleaned).filter((one) => one !== "");
851
+ }
852
+ const patterns = [];
853
+ for (const line of lines.slice(at + 1)) {
854
+ const item = /^\s*-\s*(.+)$/.exec(line);
855
+ if (item?.[1] !== void 0) {
856
+ patterns.push(cleaned(item[1]));
687
857
  continue;
688
858
  }
689
- const proof = await proofOf(counter, key, path);
690
- proofs.push(proof);
691
- if (proof.verdict !== "proven" && proof.verdict !== "skipped") {
692
- return { proofs, proven: false };
693
- }
859
+ if (line.trim() !== "") break;
694
860
  }
695
- return { proofs, proven: true };
861
+ return patterns;
696
862
  };
697
- var formatProve = ({ proofs, proven }) => {
698
- const lines = proofs.map((one) => {
699
- if (one.verdict === "skipped") return ` SKIP ${one.key}: not measured by --tier ${one.tier}`;
700
- if (one.verdict === "cannot-measure") return ` CANNOT MEASURE ${one.key}: ${one.reason}`;
701
- if (one.verdict === "cannot-fail") return ` CANNOT FAIL ${one.key}: read 0`;
702
- if (one.verdict === "misread") {
703
- return ` MISREAD ${one.key}: read ${one.reading} where its probe planted ${one.expected}`;
704
- }
705
- return ` PROVEN ${one.key}: reads ${one.reading} on a planted finding`;
706
- });
707
- lines.push(
708
- "",
709
- 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."
710
- );
711
- return `${lines.join("\n")}
712
- `;
863
+ var npmPatterns = (path) => {
864
+ const parsed = JSON.parse(readFileSync7(path, "utf8"));
865
+ const declared = Array.isArray(parsed.workspaces) ? parsed.workspaces : parsed.workspaces?.packages ?? [];
866
+ return declared.filter((one) => typeof one === "string");
713
867
  };
714
-
715
- // src/ratchet.ts
716
- import { existsSync as existsSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
717
- import { resolve as resolve7 } from "path";
718
- var EVIDENCE_LINES = 10;
719
- var recorded = (run) => {
720
- let output = "";
721
- return {
722
- last: () => output.split("\n").map((line) => line.trimEnd()).filter((line) => line !== "").slice(-EVIDENCE_LINES),
723
- run: (command) => {
724
- const result = run(command);
725
- output = result.output;
726
- return result;
727
- }
728
- };
868
+ var nameOf = (dir) => {
869
+ const manifest = join5(dir, "package.json");
870
+ if (!existsSync8(manifest)) return void 0;
871
+ try {
872
+ const { name } = JSON.parse(readFileSync7(manifest, "utf8"));
873
+ return typeof name === "string" && name !== "" ? name : void 0;
874
+ } catch {
875
+ return void 0;
876
+ }
729
877
  };
730
- var verdictOf = (now, baseline) => {
731
- if (now > baseline) return "grew";
732
- if (now < baseline) return "shrank";
733
- return "held";
878
+ var workspacePackageNames = (cwd) => {
879
+ const root = resolve9(cwd);
880
+ const pnpm = join5(root, "pnpm-workspace.yaml");
881
+ const manifest = join5(root, "package.json");
882
+ const patterns = existsSync8(pnpm) ? pnpmPatterns(pnpm) : existsSync8(manifest) ? npmPatterns(manifest) : [];
883
+ const names = patterns.filter((pattern) => !pattern.startsWith("!")).flatMap((pattern) => expand(root, pattern)).map((dir) => nameOf(dir)).filter((name) => name !== void 0);
884
+ return [...new Set(names)];
734
885
  };
735
- var outsideTier2 = (entry, tier) => entry.tiers !== void 0 && !entry.tiers.includes(tier);
736
- var runRatchet = async ({
737
- counters,
738
- cwd,
739
- tier
740
- }) => {
741
- const config = loadConfig(cwd);
742
- const baselinePath = resolve7(cwd, config.baseline);
743
- if (!existsSync7(baselinePath)) {
744
- throw new Error(`no ${config.baseline} in ${cwd} \u2014 nothing to ratchet against`);
745
- }
746
- const baseline = JSON.parse(readFileSync6(baselinePath, "utf8"));
747
- const byId = new Map(counters.map((one) => [one.id, one]));
748
- const measurements = [];
749
- for (const entry of config.counters) {
750
- const counter = byId.get(entry.counter);
751
- if (counter === void 0) {
752
- throw new Error(
753
- `no counter implements "${entry.counter}" \u2014 known ids: ${[...byId.keys()].toSorted().join(", ")}`
754
- );
755
- }
756
- const key = keyOf(entry);
757
- if (tier !== void 0 && outsideTier2(entry, tier)) {
758
- measurements.push({ key, tier, verdict: "skipped" });
759
- continue;
760
- }
761
- const limit = baseline[key];
762
- if (typeof limit !== "number") {
763
- throw new Error(
764
- `${config.baseline} has no number for "${key}" \u2014 add it before enabling the counter`
886
+
887
+ // src/counters/typecheck.ts
888
+ var UNRESOLVED = /error TS(?:2305|2307): ([^\n]*)/g;
889
+ var SPECIFIER = /'([^']+)'/g;
890
+ var specifiersIn = (message) => [...message.matchAll(SPECIFIER)].map(([, quoted]) => (quoted ?? "").replaceAll('"', ""));
891
+ var unbuiltIn = (cwd, output) => {
892
+ const messages = [...output.matchAll(UNRESOLVED)].map(([, message]) => message ?? "");
893
+ if (messages.length === 0) return [];
894
+ const packages = workspacePackageNames(cwd);
895
+ const named = messages.flatMap((message) => specifiersIn(message)).flatMap(
896
+ (specifier) => packages.filter((name) => specifier === name || specifier.startsWith(`${name}/`))
897
+ );
898
+ return [...new Set(named)].toSorted();
899
+ };
900
+ var typecheckErrors = {
901
+ id: "typecheckErrors",
902
+ probe: {
903
+ command: () => "tsc --noEmit a.ts",
904
+ expect: 1,
905
+ input: (dir) => plant(dir, "a.ts", "export const n: number = 'not a number'\n")
906
+ },
907
+ run: async ({ cwd, params, run }) => {
908
+ const command = stringParam("typecheckErrors", params, "command", "npx tsc --noEmit");
909
+ const output = run(command).output;
910
+ const unbuilt = unbuiltIn(cwd, output);
911
+ if (unbuilt.length > 0) {
912
+ throw new CounterError(
913
+ "typecheckErrors",
914
+ `workspace packages not built: ${unbuilt.join(", ")} \u2014 build them before measuring typecheckErrors`
765
915
  );
766
916
  }
767
- const recorder = recorded(runCommand(cwd, entry.counter));
768
- const now = await counter.run({ cwd, key, params: entry, run: recorder.run });
769
- const verdict = verdictOf(now, limit);
770
- measurements.push({
771
- baseline: limit,
772
- evidence: verdict === "grew" ? recorder.last() : [],
773
- key,
774
- now,
775
- verdict
776
- });
917
+ return countMatches(output, /error TS/);
777
918
  }
778
- const grew = measurements.some((one) => one.verdict === "grew");
779
- const shrank = measurements.some((one) => one.verdict === "shrank");
780
- if (shrank && !grew) {
781
- const next = { ...baseline };
782
- for (const one of measurements) {
783
- if (one.verdict !== "skipped") next[one.key] = one.now;
784
- }
785
- writeFileSync4(baselinePath, `${JSON.stringify(next, null, 2)}
786
- `);
787
- return { measurements, rewritten: true };
919
+ };
920
+
921
+ // src/counters/index.ts
922
+ var COUNTERS = [
923
+ archViolations,
924
+ boundaryIssues,
925
+ cloneCount,
926
+ knipIssues,
927
+ lawLineCount,
928
+ oxlintErrors,
929
+ oxlintRule,
930
+ oxlintWarnings,
931
+ runtimeCodeShipped,
932
+ sumOfCounts,
933
+ testFailures,
934
+ typecheckErrors,
935
+ unformattedFiles
936
+ ];
937
+ var counterById = (id) => {
938
+ const counter = COUNTERS.find((one) => one.id === id);
939
+ if (counter === void 0) {
940
+ throw new Error(
941
+ `no counter implements "${id}" \u2014 known ids: ${COUNTERS.map((one) => one.id).join(", ")}`
942
+ );
788
943
  }
789
- return { measurements, rewritten: false };
944
+ return counter;
790
945
  };
946
+
947
+ // src/report.ts
791
948
  var WIDTH = 28;
792
949
  var formatReport = ({ measurements, rewritten }) => {
793
950
  const lines = measurements.map((one) => {
@@ -816,19 +973,40 @@ var formatReport = ({ measurements, rewritten }) => {
816
973
  return `${lines.join("\n")}
817
974
  `;
818
975
  };
976
+ var formatProve = ({ proofs, proven }) => {
977
+ const lines = proofs.map((one) => {
978
+ if (one.verdict === "skipped") return ` SKIP ${one.key}: not measured by --tier ${one.tier}`;
979
+ if (one.verdict === "serialised") return ` PROVEN ${one.key}: ${one.reason}`;
980
+ if (one.verdict === "interleaved") {
981
+ return ` CANNOT FAIL ${one.key}: interleaved \u2014 ${one.reason}`;
982
+ }
983
+ if (one.verdict === "cannot-measure") return ` CANNOT MEASURE ${one.key}: ${one.reason}`;
984
+ if (one.verdict === "cannot-fail") return ` CANNOT FAIL ${one.key}: read 0`;
985
+ if (one.verdict === "misread") {
986
+ return ` MISREAD ${one.key}: read ${one.reading} where its probe planted ${one.expected}`;
987
+ }
988
+ return ` PROVEN ${one.key}: reads ${one.reading} on a planted finding`;
989
+ });
990
+ lines.push(
991
+ "",
992
+ 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."
993
+ );
994
+ return `${lines.join("\n")}
995
+ `;
996
+ };
819
997
 
820
998
  export {
821
- CounterError,
822
- COUNTERS,
823
- counterById,
824
999
  heavyLockPath,
825
1000
  acquireExclusive,
826
1001
  CONFIG_FILE,
827
1002
  keyOf,
828
1003
  loadConfig,
1004
+ CounterError,
829
1005
  runCommand,
830
1006
  runProve,
831
- formatProve,
832
1007
  runRatchet,
833
- formatReport
1008
+ COUNTERS,
1009
+ counterById,
1010
+ formatReport,
1011
+ formatProve
834
1012
  };
package/dist/cli.js CHANGED
@@ -5,9 +5,13 @@ import {
5
5
  formatReport,
6
6
  runProve,
7
7
  runRatchet
8
- } from "./chunk-QVORWCUD.js";
8
+ } from "./chunk-FM5I6PRT.js";
9
9
 
10
10
  // src/cli.ts
11
+ var numberAfter = (flag) => {
12
+ const at = process.argv.indexOf(flag);
13
+ return at === -1 ? void 0 : Number(process.argv[at + 1] ?? Number.NaN);
14
+ };
11
15
  var cwdFlag = process.argv.indexOf("--cwd");
12
16
  var cwd = cwdFlag === -1 ? process.cwd() : process.argv[cwdFlag + 1] ?? process.cwd();
13
17
  var tierFlag = process.argv.indexOf("--tier");
@@ -16,9 +20,24 @@ var proving = process.argv.includes("--prove");
16
20
  var exclusive = process.argv.includes("--exclusive");
17
21
  var timeoutFlag = process.argv.indexOf("--exclusive-timeout");
18
22
  var timeoutSeconds = timeoutFlag === -1 ? void 0 : Number(process.argv[timeoutFlag + 1] ?? Number.NaN);
23
+ var holdMs = numberAfter("--hold");
24
+ var holdTheLock = async (ms) => {
25
+ process.stdout.write(`exclusive-hold start ${Date.now()}
26
+ `);
27
+ await new Promise((done) => setTimeout(done, ms));
28
+ process.stdout.write(`exclusive-hold end ${Date.now()}
29
+ `);
30
+ return 0;
31
+ };
19
32
  var measure = async () => {
33
+ if (holdMs !== void 0) return holdTheLock(holdMs);
20
34
  if (proving) {
21
- const proof = await runProve({ counters: COUNTERS, cwd, tier });
35
+ const proof = await runProve({
36
+ counters: COUNTERS,
37
+ cwd,
38
+ exclusiveVia: process.argv[1],
39
+ tier
40
+ });
22
41
  process.stdout.write(formatProve(proof));
23
42
  return proof.proven ? 0 : 2;
24
43
  }
@@ -33,6 +52,9 @@ try {
33
52
  if (timeoutSeconds !== void 0 && !(timeoutSeconds > 0)) {
34
53
  throw new Error("--exclusive-timeout needs a number of seconds");
35
54
  }
55
+ if (holdMs !== void 0 && !(holdMs > 0)) {
56
+ throw new Error("--hold needs a number of milliseconds");
57
+ }
36
58
  const release = exclusive ? await acquireExclusive({ timeoutSeconds }) : () => void 0;
37
59
  const giveBack = () => {
38
60
  release();
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  runCommand,
13
13
  runProve,
14
14
  runRatchet
15
- } from "./chunk-QVORWCUD.js";
15
+ } from "./chunk-FM5I6PRT.js";
16
16
  export {
17
17
  CONFIG_FILE,
18
18
  COUNTERS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geonosis/ratchet",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Debt as a number that may only shrink — one ratchet, pluggable counters.",
5
5
  "keywords": [
6
6
  "ratchet",