@geonosis/ratchet 1.2.0 → 1.4.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
@@ -172,10 +172,17 @@ lines) is refused **even on a clean exit**. `npm ERR!` is not stripped — that
172
172
  command never ran, and it must never read as zero. Unset is the default and changes nothing, so a repo that never opts in keeps exactly
173
173
  the behaviour it had. Valid values: `unix`, `agent`, `default`.
174
174
 
175
- When a number **grew**, the last ten lines of that counter's command output print under the
175
+ When a number **grew**, up to ten lines of that counter's command output print under the
176
176
  `<-- REGRESSED` line, indented — so the report says what grew, not only that something did. A
177
177
  counter that reads a file rather than running a command (`lawLineCount`) prints nothing extra.
178
178
 
179
+ The lines are the ones the counter says its number came from, not the tail of the run. `oxlintRule`
180
+ cites the findings attributed to ITS rule; `oxlintErrors` cites errors and `oxlintWarnings`
181
+ warnings. The tail was wrong for exactly the run that needs it most: a consumer's +2 on one rule
182
+ printed two unrelated **warnings** as its evidence, because those were the last lines oxlint wrote
183
+ and the two real errors sat higher up. A counter written outside this package that names nothing —
184
+ or that recognises nothing in a run — falls back to the tail, as before.
185
+
179
186
  ### `testFailures` reads the runner's summary, never the exit code
180
187
 
181
188
  The counter looks for the runner's own count and **refuses when it cannot parse one**. It never
@@ -281,6 +288,19 @@ been shown to work — and this one guards the running time of every other gate.
281
288
  nothing more. If a child command needs `NODE_OPTIONS` — a TypeScript shim, a loader — put it on
282
289
  the script that invokes `geonosis-ratchet`, not on the counter's own line, and not only in your
283
290
  interactive shell.
291
+ - **`NODE_OPTIONS` in the parent script + a counter that shells out to `pnpm` = the counter dies.**
292
+ The nested `pnpm` INHERITS the option. dielime preloads a TypeScript-5 shim through
293
+ `NODE_OPTIONS` in its `lint` script; run the ratchet from inside that script and the nested pnpm
294
+ goes looking for a `.pnpmfile.mjs` that is not there and exits non-zero. The ratchet refuses —
295
+ correctly, a command that cannot run is never a silent zero — but nothing in the message is near
296
+ the cause. Run the ratchet outside that script, or unset `NODE_OPTIONS` for the nested call:
297
+
298
+ ```json
299
+ { "counter": "archViolations", "command": "env -u NODE_OPTIONS pnpm --silent verify:arch" }
300
+ ```
301
+
302
+ `geonosis-doctor --only drift` asks for this shape by name: a manifest script carrying
303
+ `NODE_OPTIONS` beside a counter whose command shells to `pnpm`.
284
304
  - **A path in a counter's command must be absolute, or `--prove` cannot run it.** A probe runs in a
285
305
  scratch directory with none of your repo in it, so a loader or shim named relatively — `node
286
306
  --import ./scripts/ts5-shim.mjs …` — resolves to nothing there and the counter comes back
@@ -423,3 +443,10 @@ npx geonosis-doctor --baseline-against origin/main --strict
423
443
  ```
424
444
 
425
445
  Apache-2.0.
446
+
447
+ ## The pairing rule (#136)
448
+
449
+ A scoped fast tier (changed files only) is safe exactly when the full tier is TOTAL: every hard cap
450
+ — max-lines, bundle bytes, suppression counts — needs a full-tier counter watching the whole tree,
451
+ or it is a cap in prose that a file can sit over indefinitely. `oxlintRule`, `suppressionCount`,
452
+ `bundleBytes` and friends exist to be that counter.
@@ -1,13 +1,76 @@
1
+ // src/core/envelope.ts
2
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
3
+ import { dirname, join } from "path";
4
+ import { fileURLToPath } from "url";
5
+ var ENVELOPES_DIR = ".geonosis/envelopes";
6
+ var envelopePath = (root, tool) => join(root, ENVELOPES_DIR, `${tool}.json`);
7
+ var UnbalancedEnvelope = class extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "UnbalancedEnvelope";
11
+ }
12
+ };
13
+ var isCount = (value) => Number.isSafeInteger(value) && value >= 0;
14
+ var unbalancedMessage = (envelope, next) => `${envelope.tool}: considered ${envelope.considered} but accounts for ${envelope.read + envelope.refused.length + envelope.excused.length} \u2014 ${envelope.read} read + ${envelope.refused.length} refused + ${envelope.excused.length} excused. A run that has lost count of its own inputs cannot say what it measured, so no verdict was rendered and no envelope was written. Next: ${next}`;
15
+ var writeEnvelope = ({
16
+ envelope,
17
+ next,
18
+ root
19
+ }) => {
20
+ if (envelope.tool.trim() === "") {
21
+ throw new UnbalancedEnvelope(
22
+ `an envelope with no tool name cannot be filed or reported against. Next: ${next}`
23
+ );
24
+ }
25
+ if (envelope.version.trim() === "") {
26
+ throw new UnbalancedEnvelope(
27
+ `${envelope.tool}: an envelope that cannot name the build that wrote it dates nothing, and a stale one reads exactly like a fresh one. Next: ${next}`
28
+ );
29
+ }
30
+ if (!isCount(envelope.considered) || !isCount(envelope.read)) {
31
+ throw new UnbalancedEnvelope(
32
+ `${envelope.tool}: considered ${envelope.considered} and read ${envelope.read} \u2014 a census is a whole number of things, and arithmetic over anything else balances by accident. Next: ${next}`
33
+ );
34
+ }
35
+ if (envelope.considered !== envelope.read + envelope.refused.length + envelope.excused.length) {
36
+ throw new UnbalancedEnvelope(unbalancedMessage(envelope, next));
37
+ }
38
+ const at = envelopePath(root, envelope.tool);
39
+ mkdirSync(dirname(at), { recursive: true });
40
+ writeFileSync(at, `${JSON.stringify(envelope, void 0, 2)}
41
+ `);
42
+ return at;
43
+ };
44
+ var UNKNOWN = "unknown";
45
+ var versionIn = (dir) => {
46
+ try {
47
+ const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
48
+ return typeof manifest.version === "string" ? manifest.version : void 0;
49
+ } catch {
50
+ return void 0;
51
+ }
52
+ };
53
+ var versionOf = (moduleUrl) => {
54
+ let dir = dirname(fileURLToPath(moduleUrl));
55
+ for (; ; ) {
56
+ const found = versionIn(dir);
57
+ if (found !== void 0) return found;
58
+ const up = dirname(dir);
59
+ if (up === dir) return UNKNOWN;
60
+ dir = up;
61
+ }
62
+ };
63
+
1
64
  // src/core/lock.ts
2
65
  import { randomUUID } from "crypto";
3
- import { linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
66
+ import { linkSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
4
67
  import { homedir } from "os";
5
- import { dirname, join } from "path";
6
- var heavyLockPath = () => process.env.GEONOSIS_HEAVY_LOCK ?? join(homedir(), ".cache", "geonosis", "heavy.lock");
68
+ import { dirname as dirname2, join as join2 } from "path";
69
+ var heavyLockPath = () => process.env.GEONOSIS_HEAVY_LOCK ?? join2(homedir(), ".cache", "geonosis", "heavy.lock");
7
70
  var sleep = (ms) => new Promise((done) => setTimeout(done, ms));
8
71
  var holderOf = (path) => {
9
72
  try {
10
- const parsed = JSON.parse(readFileSync(path, "utf8"));
73
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
11
74
  return typeof parsed.pid === "number" ? {
12
75
  cwd: parsed.cwd ?? "somewhere",
13
76
  pid: parsed.pid,
@@ -31,7 +94,7 @@ var heldFor = (holder) => {
31
94
  return `${Math.round((Date.now() - since) / 1e3)}s`;
32
95
  };
33
96
  var write = (path) => {
34
- mkdirSync(dirname(path), { recursive: true });
97
+ mkdirSync2(dirname2(path), { recursive: true });
35
98
  const mine = {
36
99
  cwd: process.cwd(),
37
100
  pid: process.pid,
@@ -39,7 +102,7 @@ var write = (path) => {
39
102
  };
40
103
  const staging = `${path}.${process.pid}.${randomUUID()}`;
41
104
  try {
42
- writeFileSync(staging, JSON.stringify(mine));
105
+ writeFileSync2(staging, JSON.stringify(mine));
43
106
  linkSync(staging, path);
44
107
  return true;
45
108
  } catch (error) {
@@ -89,7 +152,7 @@ var acquireExclusive = async ({
89
152
  };
90
153
 
91
154
  // src/core/config.ts
92
- import { existsSync, readFileSync as readFileSync2 } from "fs";
155
+ import { existsSync, readFileSync as readFileSync3 } from "fs";
93
156
  import { resolve } from "path";
94
157
  var CONFIG_FILE = "geonosis.ratchet.json";
95
158
  var keyOf = (entry) => entry.key ?? entry.counter;
@@ -98,7 +161,7 @@ var loadConfig = (cwd) => {
98
161
  if (!existsSync(path)) {
99
162
  throw new Error(`no ${CONFIG_FILE} in ${cwd} \u2014 the ratchet has nothing to count`);
100
163
  }
101
- const parsed = JSON.parse(readFileSync2(path, "utf8"));
164
+ const parsed = JSON.parse(readFileSync3(path, "utf8"));
102
165
  if (!Array.isArray(parsed.counters)) {
103
166
  throw new Error(`${CONFIG_FILE} has no "counters" array`);
104
167
  }
@@ -124,6 +187,14 @@ var loadConfig = (cwd) => {
124
187
  }
125
188
  return { baseline: parsed.baseline ?? "gate-baseline.json", counters: parsed.counters };
126
189
  };
190
+ var resolveBaseline = (cwd) => {
191
+ try {
192
+ const parsed = JSON.parse(readFileSync3(resolve(cwd, CONFIG_FILE), "utf8"));
193
+ return typeof parsed.baseline === "string" ? parsed.baseline : "gate-baseline.json";
194
+ } catch {
195
+ return "gate-baseline.json";
196
+ }
197
+ };
127
198
 
128
199
  // src/core/types.ts
129
200
  var CounterError = class extends Error {
@@ -136,23 +207,21 @@ var CounterError = class extends Error {
136
207
  };
137
208
 
138
209
  // src/core/shell.ts
139
- import { execSync } from "child_process";
210
+ import { spawnSync } from "child_process";
140
211
  var ANSI = /\[[0-9;]*m/g;
141
212
  var runCommand = (cwd, counterId, env) => (command) => {
142
- try {
143
- const output = execSync(`(
144
- ${command}
145
- ) 2>&1`, {
213
+ {
214
+ const run = spawnSync(command, {
146
215
  cwd,
147
216
  encoding: "utf8",
148
217
  env,
149
218
  maxBuffer: 64 * 1024 * 1024,
219
+ shell: true,
150
220
  stdio: ["ignore", "pipe", "pipe"]
151
221
  });
152
- return { code: 0, output: output.replaceAll(ANSI, "") };
153
- } catch (error) {
154
- const failed = error;
155
- const output = `${failed.stdout ?? ""}${failed.stderr ?? ""}`.replaceAll(ANSI, "");
222
+ const output = `${run.stdout ?? ""}${run.stderr ?? ""}`.replaceAll(ANSI, "");
223
+ if (run.error === void 0 && (run.status ?? 0) === 0) return { code: 0, output };
224
+ const failed = { status: run.status ?? 1, stderr: "", stdout: "" };
156
225
  const code = failed.status ?? -1;
157
226
  if (code === 126 || code === 127 || code === -1) {
158
227
  throw new CounterError(
@@ -166,13 +235,13 @@ ${output.trim()}`
166
235
  };
167
236
 
168
237
  // src/core/prove.ts
169
- import { existsSync as existsSync2, mkdtempSync, rmSync as rmSync2 } from "fs";
238
+ import { existsSync as existsSync2, mkdtempSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
170
239
  import { tmpdir } from "os";
171
- import { delimiter, dirname as dirname3, join as join2, resolve as resolve3 } from "path";
240
+ import { delimiter, dirname as dirname4, join as join3, resolve as resolve3 } from "path";
172
241
 
173
242
  // src/core/exclusive.ts
174
243
  import { spawn } from "child_process";
175
- import { dirname as dirname2, resolve as resolve2 } from "path";
244
+ import { dirname as dirname3, resolve as resolve2 } from "path";
176
245
  var MARK = /^exclusive-hold (start|end) (\d+)$/gm;
177
246
  var KEY = "exclusive";
178
247
  var spanOf = (output) => {
@@ -187,7 +256,7 @@ var hold = (cli, holdMs, lockPath) => new Promise((done) => {
187
256
  process.execPath,
188
257
  [cli, "--exclusive", "--exclusive-timeout", "60", "--hold", String(holdMs)],
189
258
  {
190
- cwd: dirname2(resolve2(cli)),
259
+ cwd: dirname3(resolve2(cli)),
191
260
  env: { ...process.env, GEONOSIS_HEAVY_LOCK: lockPath },
192
261
  stdio: ["ignore", "pipe", "pipe"]
193
262
  }
@@ -239,9 +308,9 @@ var toolPath = (cwd) => {
239
308
  const dirs = [];
240
309
  let dir = resolve3(cwd);
241
310
  for (; ; ) {
242
- const bin = join2(dir, "node_modules", ".bin");
311
+ const bin = join3(dir, "node_modules", ".bin");
243
312
  if (existsSync2(bin)) dirs.push(bin);
244
- const parent = dirname3(dir);
313
+ const parent = dirname4(dir);
245
314
  if (parent === dir) break;
246
315
  dir = parent;
247
316
  }
@@ -249,7 +318,7 @@ var toolPath = (cwd) => {
249
318
  };
250
319
  var NO_PROBE = "no probe \u2014 a counter nobody has seen read a planted finding has not been shown to measure";
251
320
  var oneProofOf = async (counter, key, path, probe) => {
252
- const dir = mkdtempSync(join2(tmpdir(), "geonosis-prove-"));
321
+ const dir = mkdtempSync(join3(tmpdir(), "geonosis-prove-"));
253
322
  try {
254
323
  probe.input(dir);
255
324
  const command = probe.command?.(dir);
@@ -316,11 +385,43 @@ var runProve = async ({
316
385
  if (taken.some((one) => one.verdict !== "proven" && one.verdict !== "skipped")) {
317
386
  return { proofs, proven: false };
318
387
  }
388
+ const customized = (counter.readingParams ?? []).filter((one) => entry[one] !== void 0);
389
+ if (customized.length > 0) {
390
+ const declared = entry.probe;
391
+ const named = customized.map((one) => `${one} ${JSON.stringify(entry[one])}`).join(", ");
392
+ if (declared === void 0 || typeof declared.sample !== "string" || typeof declared.expect !== "number") {
393
+ proofs.push({
394
+ counter: counter.id,
395
+ key,
396
+ reason: `the configured ${named} is not exercised by this counter's shipped probe \u2014 declare probe: { sample, expect } beside it, a sample this reading must count`,
397
+ verdict: "unproven"
398
+ });
399
+ return { proofs, proven: false };
400
+ }
401
+ const sample = declared.sample;
402
+ const entryProbe = {
403
+ command: () => "cat sample.txt",
404
+ expect: declared.expect,
405
+ input: (dir) => writeFileSync3(join3(dir, "sample.txt"), sample),
406
+ name: "configured reading",
407
+ params: Object.fromEntries(customized.map((one) => [one, entry[one]]))
408
+ };
409
+ const proof = await oneProofOf(counter, key, path, entryProbe);
410
+ const said = proof.verdict === "cannot-fail" ? {
411
+ counter: counter.id,
412
+ expected: declared.expect,
413
+ key,
414
+ reading: 0,
415
+ verdict: "misread"
416
+ } : proof;
417
+ proofs.push(said);
418
+ if (said.verdict !== "proven") return { proofs, proven: false };
419
+ }
319
420
  }
320
421
  if (exclusiveVia !== void 0) {
321
- const dir = mkdtempSync(join2(tmpdir(), "geonosis-lock-"));
422
+ const dir = mkdtempSync(join3(tmpdir(), "geonosis-lock-"));
322
423
  try {
323
- const proof = await proveExclusive({ cli: exclusiveVia, lockPath: join2(dir, "heavy.lock") });
424
+ const proof = await proveExclusive({ cli: exclusiveVia, lockPath: join3(dir, "heavy.lock") });
324
425
  proofs.push(proof);
325
426
  if (proof.verdict !== "serialised") return { proofs, proven: false };
326
427
  } finally {
@@ -331,16 +432,23 @@ var runProve = async ({
331
432
  };
332
433
 
333
434
  // src/core/ratchet.ts
334
- import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
435
+ import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
335
436
  import { resolve as resolve4 } from "path";
336
437
  var EVIDENCE_LINES = 10;
337
- var recorded = (run) => {
438
+ var recorded = (counter, params, run) => {
338
439
  let output = "";
440
+ let lastCommand;
441
+ const trimmed = (lines) => lines.map((line) => line.trimEnd()).filter((line) => line !== "").slice(-EVIDENCE_LINES);
339
442
  return {
340
- last: () => output.split("\n").map((line) => line.trimEnd()).filter((line) => line !== "").slice(-EVIDENCE_LINES),
443
+ cited: () => {
444
+ const counted = counter.evidence === void 0 ? [] : counter.evidence({ output, params });
445
+ return trimmed(counted.length > 0 ? counted : output.split("\n"));
446
+ },
447
+ lastCommand: () => lastCommand,
341
448
  run: (command) => {
342
449
  const result = run(command);
343
450
  output = result.output;
451
+ lastCommand = command;
344
452
  return result;
345
453
  }
346
454
  };
@@ -376,9 +484,10 @@ var runRatchet = async ({
376
484
  if (!existsSync3(baselinePath)) {
377
485
  throw new Error(`no ${config.baseline} in ${cwd} \u2014 nothing to ratchet against`);
378
486
  }
379
- const baseline = JSON.parse(readFileSync3(baselinePath, "utf8"));
487
+ const baseline = JSON.parse(readFileSync4(baselinePath, "utf8"));
380
488
  const byId = new Map(counters.map((one) => [one.id, one]));
381
489
  const measurements = [];
490
+ const refusals = [];
382
491
  for (const entry of config.counters) {
383
492
  const counter = byId.get(entry.counter);
384
493
  if (counter === void 0) {
@@ -398,12 +507,22 @@ var runRatchet = async ({
398
507
  );
399
508
  }
400
509
  const tolerance = toleranceOf(entry, key, counter);
401
- const recorder = recorded(runCommand(cwd, entry.counter));
402
- const now = await counter.run({ cwd, key, params: entry, run: recorder.run });
510
+ const recorder = recorded(counter, entry, runCommand(cwd, entry.counter));
511
+ let now;
512
+ try {
513
+ now = await counter.run({ cwd, key, params: entry, run: recorder.run });
514
+ } catch (error) {
515
+ const ran = recorder.lastCommand();
516
+ refusals.push({
517
+ key,
518
+ reason: `${error.message}${ran === void 0 ? "" : `. Next: run \`${ran}\` in ${cwd} and read what it prints`}`
519
+ });
520
+ continue;
521
+ }
403
522
  const verdict = verdictOf(now, limit, tolerance);
404
523
  measurements.push({
405
524
  baseline: limit,
406
- evidence: verdict === "grew" ? recorder.last() : [],
525
+ evidence: verdict === "grew" ? recorder.cited() : [],
407
526
  key,
408
527
  now,
409
528
  verdict
@@ -411,16 +530,16 @@ var runRatchet = async ({
411
530
  }
412
531
  const grew = measurements.some((one) => one.verdict === "grew");
413
532
  const shrank = measurements.some((one) => one.verdict === "shrank");
414
- if (shrank && !grew) {
533
+ if (shrank && !grew && refusals.length === 0) {
415
534
  const next = { ...baseline };
416
535
  for (const one of measurements) {
417
536
  if (one.verdict === "shrank") next[one.key] = one.now;
418
537
  }
419
- writeFileSync2(baselinePath, `${JSON.stringify(next, null, 2)}
538
+ writeFileSync4(baselinePath, `${JSON.stringify(next, null, 2)}
420
539
  `);
421
- return { measurements, rewritten: true };
540
+ return { considered: config.counters.length, measurements, refusals, rewritten: true };
422
541
  }
423
- return { measurements, rewritten: false };
542
+ return { considered: config.counters.length, measurements, refusals, rewritten: false };
424
543
  };
425
544
 
426
545
  // src/counters/params.ts
@@ -439,12 +558,12 @@ var countMatches = (text, pattern) => text.match(new RegExp(pattern.source, `${p
439
558
  var escapeForRegex = (value) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
440
559
 
441
560
  // src/counters/plant.ts
442
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
443
- import { dirname as dirname4, join as join3 } from "path";
561
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync5 } from "fs";
562
+ import { dirname as dirname5, join as join4 } from "path";
444
563
  var plant = (dir, relative, contents) => {
445
- const path = join3(dir, relative);
446
- mkdirSync2(dirname4(path), { recursive: true });
447
- writeFileSync3(path, contents);
564
+ const path = join4(dir, relative);
565
+ mkdirSync3(dirname5(path), { recursive: true });
566
+ writeFileSync5(path, contents);
448
567
  };
449
568
  var captured = (sample) => ({
450
569
  command: () => "cat sample.txt",
@@ -529,8 +648,8 @@ var bundleBytes = {
529
648
  };
530
649
 
531
650
  // src/counters/ci.ts
532
- import { readdirSync, readFileSync as readFileSync4 } from "fs";
533
- import { join as join4, resolve as resolve5 } from "path";
651
+ import { readdirSync, readFileSync as readFileSync5 } from "fs";
652
+ import { join as join5, resolve as resolve5 } from "path";
534
653
  var DISABLED = /\bif:[ \t]*false\b/;
535
654
  var WORKFLOW = /\.ya?ml$/;
536
655
  var disabledCiJobs = {
@@ -548,7 +667,7 @@ var disabledCiJobs = {
548
667
  const dir = resolve5(cwd, relative);
549
668
  let files;
550
669
  try {
551
- files = readdirSync(dir, { withFileTypes: true }).filter((entry) => WORKFLOW.test(entry.name)).map((entry) => join4(dir, entry.name));
670
+ files = readdirSync(dir, { withFileTypes: true }).filter((entry) => WORKFLOW.test(entry.name)).map((entry) => join5(dir, entry.name));
552
671
  } catch {
553
672
  return 0;
554
673
  }
@@ -556,7 +675,7 @@ var disabledCiJobs = {
556
675
  for (const file of files) {
557
676
  let contents;
558
677
  try {
559
- contents = readFileSync4(file, "utf8");
678
+ contents = readFileSync5(file, "utf8");
560
679
  } catch (error) {
561
680
  throw new CounterError(
562
681
  "disabledCiJobs",
@@ -579,10 +698,11 @@ var cloneCount = {
579
698
  const output = run(command).output;
580
699
  const found = output.match(CLONES)?.[1];
581
700
  if (found === void 0) {
701
+ const said = output.trim();
582
702
  throw new CounterError(
583
703
  "cloneCount",
584
- `no "Found N clones" line:
585
- ${output.trim().slice(-500)}`
704
+ said === "" ? 'the command printed nothing at all, so there is no "Found N clones" line to read and no output to show \u2014 it is not installed, or it never ran' : `no "Found N clones" line in what it printed:
705
+ ${said.slice(-500)}`
586
706
  );
587
707
  }
588
708
  return Number(found);
@@ -636,7 +756,7 @@ var unformattedFiles = {
636
756
  };
637
757
 
638
758
  // src/counters/gate-report.ts
639
- import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
759
+ import { existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
640
760
  import { resolve as resolve7 } from "path";
641
761
  var DEFAULT_REPORT = ".geonosis/gate-report.json";
642
762
  var fastTierMs = {
@@ -646,7 +766,7 @@ var fastTierMs = {
646
766
  expect: 4500,
647
767
  input: (dir) => plant(
648
768
  dir,
649
- DEFAULT_REPORT,
769
+ ".geonosis/gate-report.fast.json",
650
770
  JSON.stringify({
651
771
  finishedAt: "2026-08-30T10:00:04.500Z",
652
772
  ok: true,
@@ -657,8 +777,10 @@ var fastTierMs = {
657
777
  )
658
778
  },
659
779
  run: async ({ cwd, params }) => {
660
- const relative = stringParam("fastTierMs", params, "report", DEFAULT_REPORT);
661
780
  const wanted = stringParam("fastTierMs", params, "tier", "fast");
781
+ const own = `.geonosis/gate-report.${wanted}.json`;
782
+ const asked = stringParam("fastTierMs", params, "report", "");
783
+ const relative = asked !== "" ? asked : existsSync5(resolve7(cwd, own)) ? own : DEFAULT_REPORT;
662
784
  const path = resolve7(cwd, relative);
663
785
  if (!existsSync5(path)) {
664
786
  throw new CounterError(
@@ -668,7 +790,7 @@ var fastTierMs = {
668
790
  }
669
791
  let report;
670
792
  try {
671
- report = JSON.parse(readFileSync5(path, "utf8"));
793
+ report = JSON.parse(readFileSync6(path, "utf8"));
672
794
  } catch (error) {
673
795
  throw new CounterError(
674
796
  "fastTierMs",
@@ -678,7 +800,7 @@ var fastTierMs = {
678
800
  if (report.tier !== wanted) {
679
801
  throw new CounterError(
680
802
  "fastTierMs",
681
- `${relative} is a report of tier "${String(report.tier)}", not "${wanted}"`
803
+ `${relative} is a report of tier "${String(report.tier)}", not "${wanted}" \u2014 run \`geonosis-verify ${wanted}\`, which records ${own}`
682
804
  );
683
805
  }
684
806
  const spent = Date.parse(String(report.finishedAt)) - Date.parse(String(report.startedAt));
@@ -690,7 +812,7 @@ var fastTierMs = {
690
812
  };
691
813
 
692
814
  // src/counters/law.ts
693
- import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
815
+ import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
694
816
  import { resolve as resolve8 } from "path";
695
817
  var lawLineCount = {
696
818
  id: "lawLineCount",
@@ -703,12 +825,12 @@ var lawLineCount = {
703
825
  const relative = stringParam("lawLineCount", params, "path", "CLAUDE.md");
704
826
  const path = resolve8(cwd, relative);
705
827
  if (!existsSync6(path)) throw new CounterError("lawLineCount", `no law file at ${relative}`);
706
- return readFileSync6(path, "utf8").replace(/\n$/, "").split("\n").length;
828
+ return readFileSync7(path, "utf8").replace(/\n$/, "").split("\n").length;
707
829
  }
708
830
  };
709
831
 
710
832
  // src/counters/oxlint.ts
711
- import { existsSync as existsSync7, readFileSync as readFileSync7, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
833
+ import { existsSync as existsSync7, readFileSync as readFileSync8, rmSync as rmSync3, writeFileSync as writeFileSync6 } from "fs";
712
834
  import { resolve as resolve9 } from "path";
713
835
  var DEFAULT_COMMAND = "npx oxlint --format=unix --config .oxlintrc.json .";
714
836
  var PROBE_COMMAND = "oxlint --format=unix --config .oxlintrc.json .";
@@ -729,12 +851,32 @@ var UNIX_FINDING = /^\S[^\n]*:\d+:\d+: .*\[(Error|Warning)\/[^\]\n]+\]$/gm;
729
851
  var UNIX_SUMMARY = /^(\d+) problems?$/m;
730
852
  var AGENT_FINDING = /^\S[^\n]*:\d+:\d+: (error|warning) /gm;
731
853
  var DEFAULT_SUMMARY = /^Found (\d+) warnings? and (\d+) errors?\.$/m;
732
- var DEFAULT_FINDING = /^\s*[x!] [^\s(]+\([^)\n]+\): /m;
854
+ var DEFAULT_FINDING = /^\s*([x!]) [^\s(]+\([^)\n]+\): /m;
733
855
  var FINDING_SHAPES = [UNIX_FINDING, AGENT_FINDING, DEFAULT_FINDING].map(
734
856
  (shape) => new RegExp(shape.source)
735
857
  );
736
858
  var RULE_TOKEN = /\([^()\n]+\)/;
737
- var findingLinesOf = (output) => output.split("\n").filter((line) => FINDING_SHAPES.some((shape) => shape.test(line)));
859
+ var SEVERITY_BY_TOKEN = {
860
+ "!": "warning",
861
+ Error: "error",
862
+ error: "error",
863
+ Warning: "warning",
864
+ warning: "warning",
865
+ x: "error"
866
+ };
867
+ var severityOf = (line) => {
868
+ for (const shape of FINDING_SHAPES) {
869
+ const token = shape.exec(line)?.[1];
870
+ if (token !== void 0) return SEVERITY_BY_TOKEN[token];
871
+ }
872
+ return void 0;
873
+ };
874
+ var findingLinesOf = (output) => output.split("\n").filter((line) => severityOf(line) !== void 0);
875
+ var linesOfSeverity = (output, want) => output.split("\n").filter((line) => severityOf(line) === want);
876
+ var linesOfRule = (output, rule) => {
877
+ const named = new RegExp(`\\(${escapeForRegex(rule)}\\)`);
878
+ return findingLinesOf(output).filter((line) => named.test(line));
879
+ };
738
880
  var FORMATS = ["agent", "default", "unix"];
739
881
  var isFormat = (value) => typeof value === "string" && FORMATS.some((one) => one === value);
740
882
  var expectedFormat = (counter, params) => {
@@ -793,6 +935,7 @@ ${output.trim()}`);
793
935
  return refuse2(`the tool exited ${code} and printed no findings and no summary`);
794
936
  };
795
937
  var oxlintErrors = {
938
+ evidence: ({ output }) => linesOfSeverity(output, "error"),
796
939
  id: "oxlintErrors",
797
940
  probe: {
798
941
  ...oxlintProbe("error", "typescript/no-explicit-any", ONE_ANY),
@@ -806,6 +949,7 @@ var oxlintErrors = {
806
949
  }
807
950
  };
808
951
  var oxlintWarnings = {
952
+ evidence: ({ output }) => linesOfSeverity(output, "warning"),
809
953
  id: "oxlintWarnings",
810
954
  // A warning, so the run exits 0 — the window `expectFormat` exists to close is also the window a
811
955
  // probe has to survive.
@@ -830,10 +974,12 @@ var countRule = (result, rule, expect) => {
830
974
  ${result.output.trim()}`
831
975
  );
832
976
  }
833
- const named = new RegExp(`\\(${escapeForRegex(rule)}\\)`);
834
- return lines.filter((line) => named.test(line)).length;
977
+ return linesOfRule(result.output, rule).length;
835
978
  };
836
979
  var oxlintRule = {
980
+ // The rule is a required param, so a run that got here always has one; a citation is not the
981
+ // place to raise that, and the report falls back to the tail rather than losing the regression.
982
+ evidence: ({ output, params }) => typeof params.rule === "string" ? linesOfRule(output, params.rule) : [],
837
983
  id: "oxlintRule",
838
984
  // The probe names its OWN rule: which rule a repo tracks is its business, and a probe that had to
839
985
  // make the repo's rule fire would need the repo's plugin loadable from a scratch directory.
@@ -851,11 +997,11 @@ var oxlintRule = {
851
997
  const source = resolve9(cwd, config);
852
998
  if (!existsSync7(source)) throw new CounterError("oxlintRule", `no config at ${config}`);
853
999
  const strictName = `.oxlintrc.ratchet-${key}.json`;
854
- const strict = readFileSync7(source, "utf8").replace(
1000
+ const strict = readFileSync8(source, "utf8").replace(
855
1001
  new RegExp(`("[^"]*${escapeForRegex(rule)}"\\s*:\\s*\\[?\\s*)"(warn|off)"`),
856
1002
  '$1"error"'
857
1003
  );
858
- writeFileSync4(resolve9(cwd, strictName), strict);
1004
+ writeFileSync6(resolve9(cwd, strictName), strict);
859
1005
  try {
860
1006
  return countRule(run(command.replace("{config}", strictName)), rule, expect);
861
1007
  } finally {
@@ -889,15 +1035,15 @@ var runtimeCodeShipped = {
889
1035
 
890
1036
  // src/counters/scripts.ts
891
1037
  import { readdirSync as readdirSync3 } from "fs";
892
- import { join as join6 } from "path";
1038
+ import { join as join7 } from "path";
893
1039
 
894
1040
  // src/counters/workspace.ts
895
- import { existsSync as existsSync8, readdirSync as readdirSync2, readFileSync as readFileSync8 } from "fs";
896
- import { join as join5, resolve as resolve10 } from "path";
1041
+ import { existsSync as existsSync8, readdirSync as readdirSync2, readFileSync as readFileSync9 } from "fs";
1042
+ import { join as join6, resolve as resolve10 } from "path";
897
1043
  var SKIP = /^(node_modules|\.)/;
898
1044
  var childDirs = (dir) => {
899
1045
  try {
900
- return readdirSync2(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !SKIP.test(entry.name)).map((entry) => join5(dir, entry.name));
1046
+ return readdirSync2(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !SKIP.test(entry.name)).map((entry) => join6(dir, entry.name));
901
1047
  } catch {
902
1048
  return [];
903
1049
  }
@@ -907,14 +1053,14 @@ var expand = (root, pattern) => {
907
1053
  const segments = pattern.split("/").filter((one) => one !== "" && one !== ".");
908
1054
  let dirs = [root];
909
1055
  for (const segment of segments) {
910
- dirs = segment === "*" ? dirs.flatMap((dir) => childDirs(dir)) : segment === "**" ? dirs.flatMap((dir) => descendants(dir, 3)) : dirs.map((dir) => join5(dir, segment)).filter((dir) => existsSync8(dir));
1056
+ dirs = segment === "*" ? dirs.flatMap((dir) => childDirs(dir)) : segment === "**" ? dirs.flatMap((dir) => descendants(dir, 3)) : dirs.map((dir) => join6(dir, segment)).filter((dir) => existsSync8(dir));
911
1057
  }
912
1058
  return dirs;
913
1059
  };
914
1060
  var QUOTED = /^['"]|['"]$/g;
915
1061
  var cleaned = (value) => value.replace(/#.*$/, "").trim().replaceAll(QUOTED, "");
916
1062
  var pnpmPatterns = (path) => {
917
- const lines = readFileSync8(path, "utf8").split("\n");
1063
+ const lines = readFileSync9(path, "utf8").split("\n");
918
1064
  const at = lines.findIndex((line) => line.startsWith("packages:"));
919
1065
  if (at === -1) return [];
920
1066
  const inline = lines[at]?.slice("packages:".length).trim() ?? "";
@@ -933,15 +1079,15 @@ var pnpmPatterns = (path) => {
933
1079
  return patterns;
934
1080
  };
935
1081
  var npmPatterns = (path) => {
936
- const parsed = JSON.parse(readFileSync8(path, "utf8"));
1082
+ const parsed = JSON.parse(readFileSync9(path, "utf8"));
937
1083
  const declared = Array.isArray(parsed.workspaces) ? parsed.workspaces : parsed.workspaces?.packages ?? [];
938
1084
  return declared.filter((one) => typeof one === "string");
939
1085
  };
940
1086
  var nameOf = (dir) => {
941
- const manifest = join5(dir, "package.json");
1087
+ const manifest = join6(dir, "package.json");
942
1088
  if (!existsSync8(manifest)) return void 0;
943
1089
  try {
944
- const { name } = JSON.parse(readFileSync8(manifest, "utf8"));
1090
+ const { name } = JSON.parse(readFileSync9(manifest, "utf8"));
945
1091
  return typeof name === "string" && name !== "" ? name : void 0;
946
1092
  } catch {
947
1093
  return void 0;
@@ -949,15 +1095,15 @@ var nameOf = (dir) => {
949
1095
  };
950
1096
  var workspaceDirs = (cwd) => {
951
1097
  const root = resolve10(cwd);
952
- const pnpm = join5(root, "pnpm-workspace.yaml");
953
- const manifest = join5(root, "package.json");
1098
+ const pnpm = join6(root, "pnpm-workspace.yaml");
1099
+ const manifest = join6(root, "package.json");
954
1100
  const patterns = existsSync8(pnpm) ? pnpmPatterns(pnpm) : existsSync8(manifest) ? npmPatterns(manifest) : [];
955
- const dirs = patterns.filter((pattern) => !pattern.startsWith("!")).flatMap((pattern) => expand(root, pattern)).filter((dir) => existsSync8(join5(dir, "package.json")));
1101
+ const dirs = patterns.filter((pattern) => !pattern.startsWith("!")).flatMap((pattern) => expand(root, pattern)).filter((dir) => existsSync8(join6(dir, "package.json")));
956
1102
  return [...new Set(dirs)];
957
1103
  };
958
1104
  var manifestOf = (dir) => {
959
1105
  try {
960
- const parsed = JSON.parse(readFileSync8(join5(dir, "package.json"), "utf8"));
1106
+ const parsed = JSON.parse(readFileSync9(join6(dir, "package.json"), "utf8"));
961
1107
  return typeof parsed === "object" && parsed !== null ? parsed : void 0;
962
1108
  } catch {
963
1109
  return void 0;
@@ -987,7 +1133,7 @@ var holdsTests = (dir, depth = 6) => {
987
1133
  if (entry.isDirectory()) {
988
1134
  if (entry.name === TEST_DIR) return true;
989
1135
  if (SKIP2.test(entry.name) || depth === 0) continue;
990
- if (holdsTests(join6(dir, entry.name), depth - 1)) return true;
1136
+ if (holdsTests(join7(dir, entry.name), depth - 1)) return true;
991
1137
  continue;
992
1138
  }
993
1139
  if (TEST_FILE.test(entry.name)) return true;
@@ -1028,17 +1174,67 @@ var packagesWithoutTypecheck = {
1028
1174
  var sumOfCounts = {
1029
1175
  id: "sumOfCounts",
1030
1176
  probe: { ...captured("src/a.ts:1\nsrc/b.ts:0\n"), expect: 1 },
1177
+ readingParams: ["match"],
1031
1178
  run: async ({ params, run }) => {
1032
1179
  const command = stringParam("sumOfCounts", params, "command");
1033
- const match = new RegExp(stringParam("sumOfCounts", params, "match", ":(\\d+)$"), "gm");
1034
- return [...run(command).output.matchAll(match)].map(([, digits]) => Number(digits ?? 0)).filter((count) => Number.isFinite(count)).reduce((sum, count) => sum + count, 0);
1180
+ const pattern = stringParam("sumOfCounts", params, "match", ":(\\d+)$");
1181
+ const match = new RegExp(pattern, "gm");
1182
+ const output = run(command).output;
1183
+ const found = [...output.matchAll(match)];
1184
+ if (found.length === 0 && output.trim() !== "") {
1185
+ throw new CounterError(
1186
+ "sumOfCounts",
1187
+ `its match ${pattern} read nothing from ${output.trim().split("\n").length} line(s) of output (first: ${JSON.stringify(output.trim().split("\n")[0])}) \u2014 a match that cannot read the tool is not a zero`
1188
+ );
1189
+ }
1190
+ return found.map(([, digits]) => Number(digits ?? 0)).filter((count) => Number.isFinite(count)).reduce((sum, count) => sum + count, 0);
1191
+ }
1192
+ };
1193
+
1194
+ // src/counters/suppressions.ts
1195
+ import { readdirSync as readdirSync4, readFileSync as readFileSync10 } from "fs";
1196
+ import { join as join8, resolve as resolve11 } from "path";
1197
+ var CODE_FILE = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts|vue|svelte|astro)$/;
1198
+ var SKIP3 = /^(?:node_modules|dist|coverage|\.[^.]|__fixtures__)/;
1199
+ var SUPPRESSION = /(?:eslint|oxlint|biome)-(?:disable|ignore)(?:-next-line|-line)?|@ts-expect-error|@ts-ignore/g;
1200
+ var countIn = (dir, depth = 12) => {
1201
+ let entries;
1202
+ try {
1203
+ entries = readdirSync4(dir, { withFileTypes: true });
1204
+ } catch {
1205
+ return 0;
1206
+ }
1207
+ let found = 0;
1208
+ for (const entry of entries) {
1209
+ if (SKIP3.test(entry.name)) continue;
1210
+ if (entry.isDirectory()) {
1211
+ if (depth > 0) found += countIn(join8(dir, entry.name), depth - 1);
1212
+ continue;
1213
+ }
1214
+ if (!CODE_FILE.test(entry.name)) continue;
1215
+ try {
1216
+ found += readFileSync10(join8(dir, entry.name), "utf8").match(SUPPRESSION)?.length ?? 0;
1217
+ } catch {
1218
+ }
1219
+ }
1220
+ return found;
1221
+ };
1222
+ var suppressionCount = {
1223
+ id: "suppressionCount",
1224
+ probe: {
1225
+ expect: 2,
1226
+ input: (dir) => plant(dir, "src/planted.ts", "// eslint-disable-next-line x\n// @ts-expect-error y\n")
1227
+ },
1228
+ run: async ({ cwd, params }) => {
1229
+ const roots = stringsParam(params, "roots", ["."]);
1230
+ return roots.reduce((sum, root) => sum + countIn(resolve11(cwd, root)), 0);
1035
1231
  }
1036
1232
  };
1037
1233
 
1038
1234
  // src/counters/tests.ts
1039
- import { existsSync as existsSync9, mkdtempSync as mkdtempSync2, readFileSync as readFileSync9, rmSync as rmSync4 } from "fs";
1235
+ import { existsSync as existsSync9, mkdtempSync as mkdtempSync2, readFileSync as readFileSync11, rmSync as rmSync4 } from "fs";
1040
1236
  import { tmpdir as tmpdir2 } from "os";
1041
- import { join as join7, resolve as resolve11 } from "path";
1237
+ import { join as join9, resolve as resolve12 } from "path";
1042
1238
  var COUNTER = "testFailures";
1043
1239
  var VITEST_LINE = /^\s*Tests {2,}(.+?)\s*$/;
1044
1240
  var VITEST_TOTAL = /\(\d+\)$/;
@@ -1078,7 +1274,7 @@ var fromReport = (path) => {
1078
1274
  }
1079
1275
  let report;
1080
1276
  try {
1081
- report = JSON.parse(readFileSync9(path, "utf8"));
1277
+ report = JSON.parse(readFileSync11(path, "utf8"));
1082
1278
  } catch (error) {
1083
1279
  throw new CounterError(
1084
1280
  COUNTER,
@@ -1102,14 +1298,14 @@ var fromReport = (path) => {
1102
1298
  };
1103
1299
  var reportPathFor = (cwd, params, command) => {
1104
1300
  const named = params.reportPath;
1105
- if (typeof named === "string" && named !== "") return { own: false, path: resolve11(cwd, named) };
1301
+ if (typeof named === "string" && named !== "") return { own: false, path: resolve12(cwd, named) };
1106
1302
  if (!command.includes(PLACEHOLDER)) {
1107
1303
  throw new CounterError(
1108
1304
  COUNTER,
1109
1305
  `report: "${VITEST_JSON}" needs somewhere to put the report \u2014 write ${PLACEHOLDER} into the command (--outputFile=${PLACEHOLDER}) or give the entry a "reportPath"`
1110
1306
  );
1111
1307
  }
1112
- return { own: true, path: join7(mkdtempSync2(join7(tmpdir2(), "geonosis-report-")), "report.json") };
1308
+ return { own: true, path: join9(mkdtempSync2(join9(tmpdir2(), "geonosis-report-")), "report.json") };
1113
1309
  };
1114
1310
  var testFailures = {
1115
1311
  id: COUNTER,
@@ -1158,7 +1354,7 @@ var testFailures = {
1158
1354
  run(command.replaceAll(PLACEHOLDER, path));
1159
1355
  return fromReport(path);
1160
1356
  } finally {
1161
- if (own) rmSync4(join7(path, ".."), { force: true, recursive: true });
1357
+ if (own) rmSync4(join9(path, ".."), { force: true, recursive: true });
1162
1358
  }
1163
1359
  }
1164
1360
  };
@@ -1198,8 +1394,8 @@ var typecheckErrors = {
1198
1394
  };
1199
1395
 
1200
1396
  // src/counters/walk.ts
1201
- import { existsSync as existsSync10, readFileSync as readFileSync10 } from "fs";
1202
- import { resolve as resolve12 } from "path";
1397
+ import { existsSync as existsSync10, readFileSync as readFileSync12 } from "fs";
1398
+ import { resolve as resolve13 } from "path";
1203
1399
  var DEFAULT_REPORT2 = ".geonosis/walk-report.json";
1204
1400
  var CLASSES = /* @__PURE__ */ new Set([
1205
1401
  "buy-box-above-fold",
@@ -1243,7 +1439,7 @@ var walkFindings = {
1243
1439
  },
1244
1440
  run: async ({ cwd, params }) => {
1245
1441
  const relative = stringParam("walkFindings", params, "report", DEFAULT_REPORT2);
1246
- const path = resolve12(cwd, relative);
1442
+ const path = resolve13(cwd, relative);
1247
1443
  if (!existsSync10(path)) {
1248
1444
  throw new CounterError(
1249
1445
  "walkFindings",
@@ -1252,7 +1448,7 @@ var walkFindings = {
1252
1448
  }
1253
1449
  let report;
1254
1450
  try {
1255
- report = JSON.parse(readFileSync10(path, "utf8"));
1451
+ report = JSON.parse(readFileSync12(path, "utf8"));
1256
1452
  } catch (error) {
1257
1453
  throw new CounterError(
1258
1454
  "walkFindings",
@@ -1282,6 +1478,7 @@ var COUNTERS = [
1282
1478
  fastTierMs,
1283
1479
  knipIssues,
1284
1480
  lawLineCount,
1481
+ suppressionCount,
1285
1482
  oxlintErrors,
1286
1483
  oxlintRule,
1287
1484
  oxlintWarnings,
@@ -1306,7 +1503,7 @@ var counterById = (id) => {
1306
1503
 
1307
1504
  // src/report.ts
1308
1505
  var WIDTH = 28;
1309
- var formatReport = ({ measurements, rewritten }) => {
1506
+ var formatReport = ({ measurements, refusals, rewritten }) => {
1310
1507
  const lines = measurements.map((one) => {
1311
1508
  if (one.verdict === "skipped") {
1312
1509
  return ` SKIP ${one.key}: not measured by --tier ${one.tier}`;
@@ -1319,8 +1516,14 @@ var formatReport = ({ measurements, rewritten }) => {
1319
1516
  if (one.verdict === "shrank") return `${head} <-- improved -${one.baseline - one.now}`;
1320
1517
  return head;
1321
1518
  });
1519
+ lines.push(...refusals.map((one) => ` CANNOT MEASURE ${one.key}: ${one.reason}`));
1322
1520
  const grew = measurements.filter((one) => one.verdict === "grew");
1323
- if (grew.length > 0) {
1521
+ if (refusals.length > 0) {
1522
+ lines.push(
1523
+ "",
1524
+ `ratchet FAIL \u2014 ${refusals.length} counter(s) could not be measured, and a gate that cannot measure has not passed. The baseline was left exactly as it was. Next: run each counter's own command by hand \u2014 ${refusals.map((one) => one.key).join(", ")} \u2014 and fix what it prints.`
1525
+ );
1526
+ } else if (grew.length > 0) {
1324
1527
  lines.push(
1325
1528
  "",
1326
1529
  "ratchet FAIL \u2014 debt grew. Fix it, or say in the commit message why the baseline goes up."
@@ -1342,6 +1545,7 @@ var formatProve = ({ proofs, proven }) => {
1342
1545
  }
1343
1546
  if (one.verdict === "cannot-measure") return ` CANNOT MEASURE ${one.key}: ${one.reason}`;
1344
1547
  if (one.verdict === "cannot-fail") return ` CANNOT FAIL ${one.key}: read 0`;
1548
+ if (one.verdict === "unproven") return ` UNPROVEN ${one.key}: ${one.reason}`;
1345
1549
  if (one.verdict === "misread") {
1346
1550
  return ` MISREAD ${one.key}: read ${one.reading} where its probe planted ${one.expected}`;
1347
1551
  }
@@ -1356,11 +1560,17 @@ var formatProve = ({ proofs, proven }) => {
1356
1560
  };
1357
1561
 
1358
1562
  export {
1563
+ ENVELOPES_DIR,
1564
+ envelopePath,
1565
+ UnbalancedEnvelope,
1566
+ writeEnvelope,
1567
+ versionOf,
1359
1568
  heavyLockPath,
1360
1569
  acquireExclusive,
1361
1570
  CONFIG_FILE,
1362
1571
  keyOf,
1363
1572
  loadConfig,
1573
+ resolveBaseline,
1364
1574
  CounterError,
1365
1575
  runCommand,
1366
1576
  runProve,
package/dist/cli.js CHANGED
@@ -4,10 +4,83 @@ import {
4
4
  formatProve,
5
5
  formatReport,
6
6
  runProve,
7
- runRatchet
8
- } from "./chunk-LSYVFUP4.js";
7
+ runRatchet,
8
+ versionOf,
9
+ writeEnvelope
10
+ } from "./chunk-ESN6ULZZ.js";
9
11
 
10
12
  // src/cli.ts
13
+ import process from "process";
14
+ var USAGE = `geonosis-ratchet [--cwd <dir>] [--tier <name>] [--prove] [--exclusive]
15
+
16
+ Debt as a number that may only shrink. Runs the counters named in geonosis.ratchet.json, compares
17
+ each against gate-baseline.json, and REWRITES the baseline down when a number shrank \u2014 so the win is
18
+ locked in the same commit that earned it.
19
+
20
+ --cwd <dir> the repo to measure (default: the working directory)
21
+ --tier <name> only the counters in that tier (default: every counter)
22
+ --prove plant a finding for each counter and require it to be read
23
+ --exclusive hold the machine-wide heavy lock for the run
24
+ --exclusive-timeout <secs> how long to wait for that lock
25
+ --hold <ms> take the lock, wait, give it back \u2014 the --prove self-test's slow thing
26
+ --help, -h this text
27
+
28
+ Exit codes: 0 no counter grew \xB7 1 a counter grew \xB7 2 the run could not measure.
29
+
30
+ Every flag it does not know is REFUSED. This bin used to let anything unrecognised fall through to a
31
+ full measurement, so \`--help\` ran the whole ratchet \u2014 a run that can rewrite the baseline, started
32
+ by a typo.`;
33
+ var KNOWN = /* @__PURE__ */ new Set([
34
+ "--cwd",
35
+ "--exclusive",
36
+ "--exclusive-timeout",
37
+ "--hold",
38
+ "--prove",
39
+ "--tier"
40
+ ]);
41
+ var TAKES_A_VALUE = /* @__PURE__ */ new Set(["--cwd", "--exclusive-timeout", "--hold", "--tier"]);
42
+ var CONFIG_SHAPE = `geonosis-ratchet reads geonosis.ratchet.json and gate-baseline.json
43
+
44
+ geonosis.ratchet.json
45
+ baseline string? the file holding the numbers (default "gate-baseline.json")
46
+ counters CounterEntry[] required, and every entry is:
47
+ counter string required \u2014 the id of a counter this build ships
48
+ key string? the baseline key it writes (default: the counter id).
49
+ Two entries writing one key are refused: the loser's debt
50
+ would vanish into the baseline
51
+ tiers string[]? the tiers it runs in (default: all of them). A malformed
52
+ list is refused, never quietly matched to no tier
53
+ \u2026 each counter reads its own further keys
54
+
55
+ gate-baseline.json
56
+ <key> number one number per counter key. Written DOWN in place when a
57
+ number shrank, so the win lands in the commit that earned it`;
58
+ var argv = process.argv.slice(2);
59
+ if (argv.includes("--help") || argv.includes("-h")) {
60
+ process.stdout.write(`${USAGE}
61
+ `);
62
+ process.exit(0);
63
+ }
64
+ if (argv.includes("--print-config-shape")) {
65
+ process.stdout.write(`${CONFIG_SHAPE}
66
+ `);
67
+ process.exit(0);
68
+ }
69
+ var refuse = (message) => {
70
+ process.stderr.write(`geonosis-ratchet: ${message}
71
+ `);
72
+ process.exit(2);
73
+ };
74
+ for (let at = 0; at < argv.length; at += 1) {
75
+ const arg = argv[at];
76
+ if (KNOWN.has(arg)) {
77
+ if (TAKES_A_VALUE.has(arg)) at += 1;
78
+ continue;
79
+ }
80
+ refuse(
81
+ `${arg} is not an option it takes. Run geonosis-ratchet --help for the ones it does \u2014 an unrecognised argument is refused rather than measured, because a run here can rewrite the baseline.`
82
+ );
83
+ }
11
84
  var numberAfter = (flag) => {
12
85
  const at = process.argv.indexOf(flag);
13
86
  return at === -1 ? void 0 : Number(process.argv[at + 1] ?? Number.NaN);
@@ -29,6 +102,19 @@ var holdTheLock = async (ms) => {
29
102
  `);
30
103
  return 0;
31
104
  };
105
+ var ENVELOPE_NEXT = "geonosis-ratchet --print-config-shape, then count the entries in geonosis.ratchet.json \u2014 a run that measured fewer counters than the config named is a bug in this tool, not in the tree it measured";
106
+ var envelopeOf = (result, durationMs) => ({
107
+ considered: result.considered,
108
+ durationMs,
109
+ excused: result.measurements.flatMap(
110
+ (one) => one.verdict === "skipped" ? [{ path: one.key, reason: `not measured by --tier ${one.tier}` }] : []
111
+ ),
112
+ findings: result.measurements.filter((one) => one.verdict === "grew"),
113
+ read: result.measurements.filter((one) => one.verdict !== "skipped").length,
114
+ refused: result.refusals.map((one) => ({ path: one.key, reason: one.reason })),
115
+ tool: "ratchet",
116
+ version: versionOf(import.meta.url)
117
+ });
32
118
  var measure = async () => {
33
119
  if (holdMs !== void 0) return holdTheLock(holdMs);
34
120
  if (proving) {
@@ -41,8 +127,17 @@ var measure = async () => {
41
127
  process.stdout.write(formatProve(proof));
42
128
  return proof.proven ? 0 : 2;
43
129
  }
130
+ const startedAt = Date.now();
44
131
  const result = await runRatchet({ counters: COUNTERS, cwd, tier });
45
132
  process.stdout.write(formatReport(result));
133
+ const at = writeEnvelope({
134
+ envelope: envelopeOf(result, Date.now() - startedAt),
135
+ next: ENVELOPE_NEXT,
136
+ root: cwd
137
+ });
138
+ process.stdout.write(`envelope: ${at}
139
+ `);
140
+ if (result.refusals.length > 0) return 2;
46
141
  return result.measurements.some((one) => one.verdict === "grew") ? 1 : 0;
47
142
  };
48
143
  try {
package/dist/index.d.ts CHANGED
@@ -41,6 +41,22 @@ type CounterProbe = {
41
41
  params?: Record<string, unknown>;
42
42
  };
43
43
  type Counter = {
44
+ /**
45
+ * Which lines of a run this counter actually COUNTED, for the report to cite when the number
46
+ * grew. Absent means the tail of the run is shown, which is right for a counter whose tool prints
47
+ * nothing but findings and wrong for every counter that reads one rule or one severity out of a
48
+ * mixed run: the tail is whatever printed LAST, and dielime got two unrelated warnings under a
49
+ * +2 on one rule while the two real errors sat further up.
50
+ *
51
+ * It takes the output rather than being handed the lines during the run so that it stays a pure
52
+ * function of what the tool said — the same reading the count was taken from, testable on its
53
+ * own. Returning nothing falls back to the tail: a citation nobody could make is no reason to
54
+ * show the reader nothing at all.
55
+ */
56
+ evidence?: (context: {
57
+ output: string;
58
+ params: Record<string, unknown>;
59
+ }) => string[];
44
60
  id: string;
45
61
  /**
46
62
  * Whether this counter's number is a measured QUANTITY — bytes, milliseconds — rather than a
@@ -60,6 +76,14 @@ type Counter = {
60
76
  * nobody configured says nothing about the mode they did.
61
77
  */
62
78
  probe?: CounterProbe | CounterProbe[];
79
+ /**
80
+ * The params that change WHAT this counter reads (a `match`), as opposed to where it looks.
81
+ * #140: a configured reading the probe never exercises can be wrong in a way the probe cannot
82
+ * see — during.day's mis-escaped match read 0, was certified PROVEN, and the false win was
83
+ * banked into the baseline. A config customizing one of these must declare `probe: { sample,
84
+ * expect }` beside it, or the prove line says UNPROVEN.
85
+ */
86
+ readingParams?: string[];
63
87
  run: (context: CounterContext) => Promise<number>;
64
88
  };
65
89
  /** One line of `geonosis.ratchet.json`'s `counters` array. */
@@ -118,6 +142,11 @@ type Proof = {
118
142
  key: string;
119
143
  reason: string;
120
144
  verdict: 'cannot-measure';
145
+ } | {
146
+ counter: string;
147
+ key: string;
148
+ reason: string;
149
+ verdict: 'unproven';
121
150
  }
122
151
  /**
123
152
  * Not a counter: `--exclusive` itself, measured by running two of it. A lock is a claim about the
@@ -141,8 +170,24 @@ type ProveResult = {
141
170
  /** False as soon as one counter could not be shown to read its own planted finding. */
142
171
  proven: boolean;
143
172
  };
173
+ /** A counter whose tool could not be read. Never a number, and never a silent zero. */
174
+ type Refusal = {
175
+ key: string;
176
+ reason: string;
177
+ };
144
178
  type RatchetResult = {
179
+ /**
180
+ * How many counters the config named. The denominator: `considered === measurements.length +
181
+ * refusals.length`, and a run whose two do not add up has lost one somewhere between the config
182
+ * and the report.
183
+ */
184
+ considered: number;
145
185
  measurements: Measurement[];
186
+ /**
187
+ * Every counter that blew up, not just the first. The throw that used to leave the run at the
188
+ * first failure also left every counter behind it unrun and unreported.
189
+ */
190
+ refusals: Refusal[];
146
191
  /** True when the baseline file was rewritten because something shrank. */
147
192
  rewritten: boolean;
148
193
  };
@@ -151,6 +196,90 @@ declare const CONFIG_FILE = "geonosis.ratchet.json";
151
196
  /** The baseline key an entry writes. Defaults to the counter's own id. */
152
197
  declare const keyOf: (entry: CounterConfig) => string;
153
198
  declare const loadConfig: (cwd: string) => RatchetConfig;
199
+ /**
200
+ * #120: the ONE answer to "where does the baseline live". Three tools guessed instead of asking —
201
+ * rails' deny rail rendered `<root>/gate-baseline.json` over a repo whose baseline is
202
+ * `scripts/gate-baseline.json` (measured on sandbox-exec: the tampering write LANDED), and mcp's
203
+ * preamble said "no gate-baseline.json here" over twenty ratcheted counters. Anything that SPEAKS
204
+ * about the baseline resolves it here; `loadConfig` stays the strict door for anything that RUNS
205
+ * counters. Never throws: a missing or unreadable config means the default, because naming the
206
+ * baseline must work in repos that have not adopted the ratchet yet.
207
+ */
208
+ declare const resolveBaseline: (cwd: string) => string;
209
+
210
+ /**
211
+ * The report envelope: what a tool CONSIDERED, against what it actually read.
212
+ *
213
+ * Four denominator bugs landed in one day — a migrations run that reported on three of four files,
214
+ * a plan check that printed `PASS — 0 plan(s)` over a directory of twenty-one, a parity run over a
215
+ * tree the second config ignored, a chains validator that walked a list it had already filtered.
216
+ * Every one of them was GREEN, and every one of them was green because the tool published the
217
+ * numerator and nobody published the denominator.
218
+ *
219
+ * So every gate writes this, and the law is arithmetic:
220
+ *
221
+ * considered === read + refused.length + excused.length
222
+ *
223
+ * checked HERE, at write time. A tool whose own accounting does not add up has not measured what it
224
+ * says it measured, so it renders no verdict at all: the write throws, the file is not created, and
225
+ * the bin exits 2. That is deliberately harsher than a failing gate — a wrong verdict is worse than
226
+ * no verdict, and a tool that has lost count of its own inputs cannot tell which it is.
227
+ *
228
+ * `refused` and `excused` are both "considered and NOT read". They differ in whose fault it is:
229
+ * refused is the tool unable to read the thing (an unparseable migration, a counter whose command
230
+ * blew up), excused is the thing legitimately exempt (a contract-migration marker the gate believed,
231
+ * a step the run never reached, a plan written before the contract). Both carry a reason, because a
232
+ * bucket without reasons is just a smaller silence.
233
+ */
234
+ type EnvelopeEntry = {
235
+ /** What was not read — a file, a counter key, a step id. Whatever the tool names its inputs by. */
236
+ path: string;
237
+ /** Why it was not read, in a sentence a reader can act on. */
238
+ reason: string;
239
+ };
240
+ type ReportEnvelope = {
241
+ /** Everything this run took as its input. The denominator. */
242
+ considered: number;
243
+ durationMs: number;
244
+ excused: EnvelopeEntry[];
245
+ /** Whatever the tool found. Shape is the tool's own; nothing here reads into it. */
246
+ findings: unknown[];
247
+ /** How many of `considered` the run actually read. The numerator. */
248
+ read: number;
249
+ refused: EnvelopeEntry[];
250
+ /** The name the envelope is filed under, and the name a refusal is reported against. */
251
+ tool: string;
252
+ /** The build that wrote it. An envelope that cannot name its build dates nothing. */
253
+ version: string;
254
+ };
255
+ /** Where every tool writes, and the one directory the doctor's envelope check reads. */
256
+ declare const ENVELOPES_DIR = ".geonosis/envelopes";
257
+ declare const envelopePath: (root: string, tool: string) => string;
258
+ /** A run whose accounting does not add up. Never a verdict — the refusal to render one. */
259
+ declare class UnbalancedEnvelope extends Error {
260
+ constructor(message: string);
261
+ }
262
+ /**
263
+ * Writes the envelope, or refuses. Returns the path written so a caller can print it.
264
+ *
265
+ * The check is before the write and not after it, because a file on disk is a verdict: the doctor
266
+ * would read a half-true envelope and report on numbers nobody stands behind.
267
+ */
268
+ declare const writeEnvelope: ({ envelope, next, root, }: {
269
+ envelope: ReportEnvelope;
270
+ /** The command a reader should run to see the accounting for themselves. */
271
+ next: string;
272
+ root: string;
273
+ }) => string;
274
+ /**
275
+ * The version of the package a module belongs to, found by walking up to the nearest manifest.
276
+ *
277
+ * Called with `import.meta.url`, it answers the same in `src/` under vitest and in the bundled
278
+ * `dist/` a bin loads — which is the whole point, because the envelope a consumer's CI writes is
279
+ * written by the build, and a version baked in at compile time would be the version of whoever
280
+ * bundled it.
281
+ */
282
+ declare const versionOf: (moduleUrl: string) => string;
154
283
 
155
284
  declare const COUNTERS: Counter[];
156
285
  declare const counterById: (id: string) => Counter;
@@ -225,7 +354,7 @@ declare const runRatchet: ({ counters, cwd, tier, }: {
225
354
  * the tier left out says so by name — printing a number for it would be the stale OK this exists
226
355
  * to prevent.
227
356
  */
228
- declare const formatReport: ({ measurements, rewritten }: RatchetResult) => string;
357
+ declare const formatReport: ({ measurements, refusals, rewritten }: RatchetResult) => string;
229
358
  declare const formatProve: ({ proofs, proven }: ProveResult) => string;
230
359
 
231
360
  /**
@@ -244,4 +373,4 @@ declare const formatProve: ({ proofs, proven }: ProveResult) => string;
244
373
  */
245
374
  declare const runCommand: (cwd: string, counterId: string, env?: NodeJS.ProcessEnv) => (command: string) => CommandResult;
246
375
 
247
- export { CONFIG_FILE, COUNTERS, type CommandResult, type Counter, type CounterConfig, type CounterContext, CounterError, type CounterProbe, type Holder, type Measurement, type Proof, type ProveResult, type RatchetConfig, type RatchetResult, type Verdict, acquireExclusive, counterById, formatProve, formatReport, heavyLockPath, keyOf, loadConfig, runCommand, runProve, runRatchet };
376
+ export { CONFIG_FILE, COUNTERS, type CommandResult, type Counter, type CounterConfig, type CounterContext, CounterError, type CounterProbe, ENVELOPES_DIR, type EnvelopeEntry, type Holder, type Measurement, type Proof, type ProveResult, type RatchetConfig, type RatchetResult, type Refusal, type ReportEnvelope, UnbalancedEnvelope, type Verdict, acquireExclusive, counterById, envelopePath, formatProve, formatReport, heavyLockPath, keyOf, loadConfig, resolveBaseline, runCommand, runProve, runRatchet, versionOf, writeEnvelope };
package/dist/index.js CHANGED
@@ -2,29 +2,41 @@ import {
2
2
  CONFIG_FILE,
3
3
  COUNTERS,
4
4
  CounterError,
5
+ ENVELOPES_DIR,
6
+ UnbalancedEnvelope,
5
7
  acquireExclusive,
6
8
  counterById,
9
+ envelopePath,
7
10
  formatProve,
8
11
  formatReport,
9
12
  heavyLockPath,
10
13
  keyOf,
11
14
  loadConfig,
15
+ resolveBaseline,
12
16
  runCommand,
13
17
  runProve,
14
- runRatchet
15
- } from "./chunk-LSYVFUP4.js";
18
+ runRatchet,
19
+ versionOf,
20
+ writeEnvelope
21
+ } from "./chunk-ESN6ULZZ.js";
16
22
  export {
17
23
  CONFIG_FILE,
18
24
  COUNTERS,
19
25
  CounterError,
26
+ ENVELOPES_DIR,
27
+ UnbalancedEnvelope,
20
28
  acquireExclusive,
21
29
  counterById,
30
+ envelopePath,
22
31
  formatProve,
23
32
  formatReport,
24
33
  heavyLockPath,
25
34
  keyOf,
26
35
  loadConfig,
36
+ resolveBaseline,
27
37
  runCommand,
28
38
  runProve,
29
- runRatchet
39
+ runRatchet,
40
+ versionOf,
41
+ writeEnvelope
30
42
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geonosis/ratchet",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "types": "./dist/index.d.ts",
5
5
  "description": "Debt as a number that may only shrink — one ratchet, pluggable counters.",
6
6
  "keywords": [