@geonosis/ratchet 0.5.0 → 1.0.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
@@ -178,12 +178,30 @@ counter that reads a file rather than running a command (`lawLineCount`) prints
178
178
 
179
179
  ### `testFailures` reads the runner's summary, never the exit code
180
180
 
181
- The counter looks for the runner's own count — `N failed` — and **refuses when it cannot parse
182
- one**. It never reads the process exit code, because a test runner exiting 0 over a red suite is
183
- commoner than anyone expects: `@cloudflare/vitest-pool-workers` 0.22 on vitest 4.1 exited 0 with
184
- failing tests on every workerd suite of a consumer, and every gate that trusted the exit code
185
- reported green over red for weeks. A refusal is loud and stops the run; a trusted 0 is silent and
186
- banks the red as a win.
181
+ The counter looks for the runner's own count and **refuses when it cannot parse one**. It never
182
+ reads the process exit code, because a test runner exiting 0 over a red suite is commoner than
183
+ anyone expects: `@cloudflare/vitest-pool-workers` 0.22 on vitest 4.1 exited 0 with failing tests on
184
+ every workerd suite of a consumer, and every gate that trusted the exit code reported green over red
185
+ for weeks. A refusal is loud and stops the run; a trusted 0 is silent and banks the red as a win.
186
+
187
+ **The lines it reads are the runner's summary lines, whole, and nothing else** — two dialects, both
188
+ captured from the binary rather than guessed at:
189
+
190
+ | Runner | The line | Read as |
191
+ | --- | --- | --- |
192
+ | vitest 3.2.7 | ` Tests 1 failed \| 1 passed (2)` | 1 |
193
+ | vitest 3.2.7 | ` Tests 3 passed (3)` · ` Tests 2 skipped (2)` | 0 |
194
+ | vitest 3.2.7 | ` Tests no tests` (a suite that threw before collecting) | **refused** |
195
+ | bun 1.4.0 | ` 1 fail` on its own line | 1 |
196
+ | bun 1.4.0 | ` 0 fail` | 0 |
197
+
198
+ Nothing else in the output counts. `Test Files 1 failed (1)` is a count of files, not of tests;
199
+ `(fail) one [11.71ms]` is bun naming one; `naming 4 failing test(s)` is some wrapper counting its
200
+ own findings. All three used to be read as a failure count — a **green run regressing a baseline**,
201
+ the false RED mirroring the false green above (backlog #44). A run that prints no summary line at
202
+ all is unmeasured, and the refusal names the two dialects so a third runner's output is a message
203
+ rather than a wrong number. Summaries **add up**: a command that invokes the runner twice prints
204
+ two, and reading the first and stopping banks the second suite's failures as a win.
187
205
 
188
206
  For the same reason, **give every test package its own `testFailures` entry**, each with its own
189
207
  `key`. One entry over one workspace measures one workspace; the suites it does not run are not zero
@@ -223,9 +241,10 @@ The counter then reads `numFailedTests`, and **refuses** rather than returning a
223
241
  mid-run writes. The run did not finish, so there is no number to bank.
224
242
 
225
243
  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.
244
+ `--prove` proves all three: `testFailures` ships one probe per reading mode `summary (vitest)`,
245
+ `summary (bun)`, `vitest-json` and prints them by name (`PROVEN testFailuresApi (vitest-json)`),
246
+ because proving the mode nobody configured says nothing about the mode they did, and a summary
247
+ dialect nobody proved is a dialect nobody has been shown to read.
229
248
 
230
249
  ### `oxlintRule` counts a warned rule twice, on purpose
231
250
 
@@ -262,6 +281,11 @@ been shown to work — and this one guards the running time of every other gate.
262
281
  nothing more. If a child command needs `NODE_OPTIONS` — a TypeScript shim, a loader — put it on
263
282
  the script that invokes `geonosis-ratchet`, not on the counter's own line, and not only in your
264
283
  interactive shell.
284
+ - **A path in a counter's command must be absolute, or `--prove` cannot run it.** A probe runs in a
285
+ scratch directory with none of your repo in it, so a loader or shim named relatively — `node
286
+ --import ./scripts/ts5-shim.mjs …` — resolves to nothing there and the counter comes back
287
+ "command did not run". The command is the same string in both places; write the path as
288
+ `$PWD/scripts/ts5-shim.mjs` and it works in the repo and in the probe alike.
265
289
  - **A `typecheckErrors` counter needs the same precondition CI gives it: build the workspace
266
290
  packages first.** In a fresh worktree the `dist/*.d.ts` files do not exist yet, and `tsc` reports
267
291
  a false +N of missing-module errors that has nothing to do with the change under test. `pnpm build
@@ -321,10 +345,55 @@ Every counter takes its `command` from the config, so the toolchain stays the re
321
345
  | `cloneCount` | jscpd's `Found N clones` | `command` |
322
346
  | `knipIssues` | the totals under knip's unused-* headings | `command`, `headings` |
323
347
  | `boundaryIssues` | `N issues found` from a boundary scan | `command` |
324
- | `archViolations` | lines matching a marker your own architecture scan prints | `command`, `match` |
348
+ | `archViolations` | lines matching a marker your own architecture scan prints, refusing a non-zero exit that printed none of them — a scan that could not run is not a clean scan | `command`, `match` |
325
349
  | `sumOfCounts` | the total of one capture group across a per-file census (`grep -rc`) | `command`, `match` |
326
350
  | `lawLineCount` | the lines of the law file — a ceiling that can only come down | `path` |
327
351
  | `runtimeCodeShipped` | 0 when a change shipped runtime code, 1 when it shipped none | `command`, `patterns` |
352
+ | `disabledCiJobs` | lines of `if: false` across the workflow files — a job switched off to get a release through, still off. A condition that merely mentions `false` is not one. No workflows directory at all reads 0 | `dir` |
353
+ | `bundleBytes` | one integer out of whatever your sizing command printed, separators and all; with `match`, the group that pattern names rather than the last integer, refusing when it matches nothing. **Tolerates.** | `command`, `match`, `tolerance` |
354
+ | `fastTierMs` | `finishedAt − startedAt` from the gate report `geonosis-verify` wrote, refusing a report of another tier rather than timing the wrong gate. **Tolerates.** | `report`, `tier`, `tolerance` |
355
+ | `testsWithoutRunner` | workspaces holding `*.test.*`, `*.spec.*` or `__tests__/` with no `test` script — the suites nobody runs, which read exactly like suites that pass | `script` |
356
+ | `packagesWithoutTypecheck` | workspaces with no `typecheck` script | `script` |
357
+ | `walkFindings` | the defects in the report `geonosis-walk` wrote, over every page; with `classes`, only those classes, refusing a class the walk does not have. A missing or unparsable report is a refusal — the walk writes none when it could not run | `report`, `classes` |
358
+
359
+ ### `tolerance`, and the two counters that accept one
360
+
361
+ Most counters count findings, where one more is one too many. `bundleBytes` and `fastTierMs` measure
362
+ something that moves on a dependency patch nobody chose, and a gate that fails on +40 bytes is a
363
+ gate that gets switched off within a week. `tolerance` is the fraction of the baseline such a number
364
+ may drift up before the ratchet calls it growth:
365
+
366
+ ```jsonc
367
+ { "counter": "bundleBytes", "command": "…", "tolerance": 0.1 } // +10 % is noise, +11 % is debt
368
+ ```
369
+
370
+ **Only a counter that declares `tolerates` accepts one, and only `bundleBytes` and `fastTierMs` do.**
371
+ Anything else stops the run:
372
+
373
+ ```
374
+ geonosis-ratchet: "oxlintErrors" does not accept a tolerance — only a counter that measures a
375
+ quantity declares one
376
+ ```
377
+
378
+ That refusal is the point. A band around a findings count is not a tolerance, it is the gate turned
379
+ off from the config file: `"tolerance": 100` on `oxlintErrors` and the run still prints PASS. It is
380
+ law 2 — never downgrade a rule — wearing a friendlier name, and it would be the easiest edit in the
381
+ repo to get past a reviewer. Whether a number is a quantity or a count is the counter's to know, not
382
+ the config's, so the counter declares it.
383
+
384
+ It forgives noise upward, never downward: a shrink of any size is still a shrink and still lowers
385
+ the baseline, or the number would stop following the artefact down. A `tolerance` that is not a
386
+ non-negative number stops the run and says which entry it was — one that silently became `NaN`
387
+ would make every comparison false, which reads exactly like a counter that can never grow.
388
+
389
+ The `geonosis` Claude Code plugin puts any write that introduces or raises one in front of a human
390
+ (`permissionDecision: "ask"`): a band somebody chose and a band somebody's agent chose are not the
391
+ same thing.
392
+
393
+ Tolerance is also why a rewrite lowers only the numbers that **shrank**. While `held` implied
394
+ `now === baseline`, writing every measured key back was a harmless no-op; with a tolerance it is
395
+ not, and a counter that grew inside its tolerance would have had that growth laundered into its new
396
+ floor by the first unrelated win.
328
397
 
329
398
  Two example configs from real repos live in `examples/` in the repository.
330
399
 
@@ -339,4 +408,18 @@ process.stdout.write(formatReport(result))
339
408
 
340
409
  `counters` is a plain array, so a repo can add its own `{ id, run }` beside the built-ins.
341
410
 
411
+ ## Two things this cannot see about itself
412
+
413
+ A baseline is lowered **in place** when a number shrinks; that is what locks a win in, and it is
414
+ also what lets a branch write any number it likes and stay green on every gate it runs. And a
415
+ workspace whose suite no `testFailures` entry covers is unmeasured, which reads exactly like green.
416
+
417
+ [`@geonosis/doctor`](https://www.npmjs.com/package/@geonosis/doctor) asks both from outside — the
418
+ baseline at HEAD against another ref, and which workspaces have a test script nothing reads a report
419
+ from:
420
+
421
+ ```bash
422
+ npx geonosis-doctor --baseline-against origin/main --strict
423
+ ```
424
+
342
425
  Apache-2.0.
@@ -140,7 +140,9 @@ import { execSync } from "child_process";
140
140
  var ANSI = /\[[0-9;]*m/g;
141
141
  var runCommand = (cwd, counterId, env) => (command) => {
142
142
  try {
143
- const output = execSync(`${command} 2>&1`, {
143
+ const output = execSync(`(
144
+ ${command}
145
+ ) 2>&1`, {
144
146
  cwd,
145
147
  encoding: "utf8",
146
148
  env,
@@ -343,11 +345,26 @@ var recorded = (run) => {
343
345
  }
344
346
  };
345
347
  };
346
- var verdictOf = (now, baseline) => {
347
- if (now > baseline) return "grew";
348
+ var verdictOf = (now, baseline, tolerance) => {
349
+ if (now > baseline * (1 + tolerance)) return "grew";
348
350
  if (now < baseline) return "shrank";
349
351
  return "held";
350
352
  };
353
+ var toleranceOf = (entry, key, counter) => {
354
+ const declared = entry.tolerance;
355
+ if (declared === void 0) return 0;
356
+ if (counter.tolerates !== true) {
357
+ throw new Error(
358
+ `"${key}" does not accept a tolerance \u2014 only a counter that measures a quantity declares one`
359
+ );
360
+ }
361
+ if (typeof declared !== "number" || !Number.isFinite(declared) || declared < 0) {
362
+ throw new Error(
363
+ `"${key}" has a "tolerance" that is not a non-negative number: ${JSON.stringify(declared)}`
364
+ );
365
+ }
366
+ return declared;
367
+ };
351
368
  var outsideTier2 = (entry, tier) => entry.tiers !== void 0 && !entry.tiers.includes(tier);
352
369
  var runRatchet = async ({
353
370
  counters,
@@ -380,9 +397,10 @@ var runRatchet = async ({
380
397
  `${config.baseline} has no number for "${key}" \u2014 add it before enabling the counter`
381
398
  );
382
399
  }
400
+ const tolerance = toleranceOf(entry, key, counter);
383
401
  const recorder = recorded(runCommand(cwd, entry.counter));
384
402
  const now = await counter.run({ cwd, key, params: entry, run: recorder.run });
385
- const verdict = verdictOf(now, limit);
403
+ const verdict = verdictOf(now, limit, tolerance);
386
404
  measurements.push({
387
405
  baseline: limit,
388
406
  evidence: verdict === "grew" ? recorder.last() : [],
@@ -396,7 +414,7 @@ var runRatchet = async ({
396
414
  if (shrank && !grew) {
397
415
  const next = { ...baseline };
398
416
  for (const one of measurements) {
399
- if (one.verdict !== "skipped") next[one.key] = one.now;
417
+ if (one.verdict === "shrank") next[one.key] = one.now;
400
418
  }
401
419
  writeFileSync2(baselinePath, `${JSON.stringify(next, null, 2)}
402
420
  `);
@@ -434,13 +452,120 @@ var captured = (sample) => ({
434
452
  });
435
453
 
436
454
  // src/counters/arch.ts
455
+ var refuse = (command, { code, output }) => {
456
+ const tail = output.trim().split("\n").slice(-10).join("\n");
457
+ throw new CounterError(
458
+ "archViolations",
459
+ `\`${command}\` exited ${code} and printed no findings \u2014 a scan that could not run is not a clean scan:
460
+ ${tail === "" ? "(it printed nothing at all)" : tail}`
461
+ );
462
+ };
463
+ var scannerProbe = {
464
+ command: () => "node scanner.mjs",
465
+ input: (dir) => {
466
+ plant(
467
+ dir,
468
+ "scanner.mjs",
469
+ [
470
+ "process.stdout.write('\\u2717 packages/a/src/cells/one.tsx cell imports cell\\n')",
471
+ "process.stdout.write('\\u2717 packages/a/src/tissues/two.tsx tissue holds state\\n')",
472
+ "process.exit(1)",
473
+ ""
474
+ ].join("\n")
475
+ );
476
+ }
477
+ };
437
478
  var archViolations = {
438
479
  id: "archViolations",
439
- probe: { ...captured("\u2717 cell imports cell\nok\n"), expect: 1 },
480
+ probe: { ...scannerProbe, expect: 2 },
440
481
  run: async ({ params, run }) => {
441
482
  const command = stringParam("archViolations", params, "command");
442
483
  const match = new RegExp(stringParam("archViolations", params, "match", "^\u2717"));
443
- return run(command).output.split("\n").filter((line) => match.test(line)).length;
484
+ const result = run(command);
485
+ const found = result.output.split("\n").filter((line) => match.test(line)).length;
486
+ if (found === 0 && result.code !== 0) refuse(command, result);
487
+ return found;
488
+ }
489
+ };
490
+
491
+ // src/counters/bundle.ts
492
+ var INTEGER = /\d[\d,_]*/g;
493
+ var asBytes = (digits) => Number(digits.replaceAll(/[,_]/g, ""));
494
+ var bundleBytes = {
495
+ id: "bundleBytes",
496
+ tolerates: true,
497
+ probe: { ...captured("dist/index.js\n minified 4096\n"), expect: 4096 },
498
+ run: async ({ params, run }) => {
499
+ const command = stringParam("bundleBytes", params, "command");
500
+ const output = run(command).output;
501
+ const pattern = params.match;
502
+ if (pattern !== void 0) {
503
+ if (typeof pattern !== "string") {
504
+ throw new CounterError("bundleBytes", `"match" must be a regular expression source`);
505
+ }
506
+ const found = new RegExp(pattern).exec(output);
507
+ if (found === null) {
508
+ throw new CounterError(
509
+ "bundleBytes",
510
+ `nothing matched /${pattern}/ in what \`${command}\` printed`
511
+ );
512
+ }
513
+ const digits = found[1] ?? found[0];
514
+ const value = asBytes(digits);
515
+ if (!Number.isFinite(value)) {
516
+ throw new CounterError(
517
+ "bundleBytes",
518
+ `/${pattern}/ matched "${digits}", which is no number`
519
+ );
520
+ }
521
+ return value;
522
+ }
523
+ const integers = output.match(INTEGER);
524
+ if (integers === null || integers.length === 0) {
525
+ throw new CounterError("bundleBytes", `\`${command}\` printed no number to read as bytes`);
526
+ }
527
+ return asBytes(integers[integers.length - 1] ?? "");
528
+ }
529
+ };
530
+
531
+ // src/counters/ci.ts
532
+ import { readdirSync, readFileSync as readFileSync4 } from "fs";
533
+ import { join as join4, resolve as resolve5 } from "path";
534
+ var DISABLED = /\bif:[ \t]*false\b/;
535
+ var WORKFLOW = /\.ya?ml$/;
536
+ var disabledCiJobs = {
537
+ id: "disabledCiJobs",
538
+ probe: {
539
+ expect: 1,
540
+ input: (dir) => plant(
541
+ dir,
542
+ ".github/workflows/ci.yml",
543
+ ["jobs:", " build:", " if: false", " steps: []", ""].join("\n")
544
+ )
545
+ },
546
+ run: async ({ cwd, params }) => {
547
+ const relative = stringParam("disabledCiJobs", params, "dir", ".github/workflows");
548
+ const dir = resolve5(cwd, relative);
549
+ let files;
550
+ try {
551
+ files = readdirSync(dir, { withFileTypes: true }).filter((entry) => WORKFLOW.test(entry.name)).map((entry) => join4(dir, entry.name));
552
+ } catch {
553
+ return 0;
554
+ }
555
+ let disabled = 0;
556
+ for (const file of files) {
557
+ let contents;
558
+ try {
559
+ contents = readFileSync4(file, "utf8");
560
+ } catch (error) {
561
+ throw new CounterError(
562
+ "disabledCiJobs",
563
+ `could not read ${relative}/${file.split("/").pop() ?? file}: ${error.message}`
564
+ );
565
+ }
566
+ disabled += contents.split("\n").filter((line) => DISABLED.test(line)).length;
567
+ }
568
+ return disabled;
444
569
  }
445
570
  };
446
571
 
@@ -491,7 +616,7 @@ var boundaryIssues = {
491
616
 
492
617
  // src/counters/format.ts
493
618
  import { existsSync as existsSync4 } from "fs";
494
- import { resolve as resolve5 } from "path";
619
+ import { resolve as resolve6 } from "path";
495
620
  var unformattedFiles = {
496
621
  id: "unformattedFiles",
497
622
  probe: {
@@ -506,13 +631,67 @@ var unformattedFiles = {
506
631
  "command",
507
632
  "npx oxfmt --config .oxfmtrc.json --list-different ."
508
633
  );
509
- return run(command).output.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && existsSync4(resolve5(cwd, line))).length;
634
+ return run(command).output.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && existsSync4(resolve6(cwd, line))).length;
635
+ }
636
+ };
637
+
638
+ // src/counters/gate-report.ts
639
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
640
+ import { resolve as resolve7 } from "path";
641
+ var DEFAULT_REPORT = ".geonosis/gate-report.json";
642
+ var fastTierMs = {
643
+ id: "fastTierMs",
644
+ tolerates: true,
645
+ probe: {
646
+ expect: 4500,
647
+ input: (dir) => plant(
648
+ dir,
649
+ DEFAULT_REPORT,
650
+ JSON.stringify({
651
+ finishedAt: "2026-08-30T10:00:04.500Z",
652
+ ok: true,
653
+ startedAt: "2026-08-30T10:00:00.000Z",
654
+ steps: [],
655
+ tier: "fast"
656
+ })
657
+ )
658
+ },
659
+ run: async ({ cwd, params }) => {
660
+ const relative = stringParam("fastTierMs", params, "report", DEFAULT_REPORT);
661
+ const wanted = stringParam("fastTierMs", params, "tier", "fast");
662
+ const path = resolve7(cwd, relative);
663
+ if (!existsSync5(path)) {
664
+ throw new CounterError(
665
+ "fastTierMs",
666
+ `no gate report at ${relative} \u2014 run \`geonosis-verify ${wanted}\` before measuring it`
667
+ );
668
+ }
669
+ let report;
670
+ try {
671
+ report = JSON.parse(readFileSync5(path, "utf8"));
672
+ } catch (error) {
673
+ throw new CounterError(
674
+ "fastTierMs",
675
+ `${relative} does not parse: ${error.message}`
676
+ );
677
+ }
678
+ if (report.tier !== wanted) {
679
+ throw new CounterError(
680
+ "fastTierMs",
681
+ `${relative} is a report of tier "${String(report.tier)}", not "${wanted}"`
682
+ );
683
+ }
684
+ const spent = Date.parse(String(report.finishedAt)) - Date.parse(String(report.startedAt));
685
+ if (!Number.isFinite(spent)) {
686
+ throw new CounterError("fastTierMs", `${relative} has no timestamps a reader can subtract`);
687
+ }
688
+ return spent;
510
689
  }
511
690
  };
512
691
 
513
692
  // src/counters/law.ts
514
- import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
515
- import { resolve as resolve6 } from "path";
693
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
694
+ import { resolve as resolve8 } from "path";
516
695
  var lawLineCount = {
517
696
  id: "lawLineCount",
518
697
  probe: {
@@ -522,15 +701,15 @@ var lawLineCount = {
522
701
  },
523
702
  run: async ({ cwd, params }) => {
524
703
  const relative = stringParam("lawLineCount", params, "path", "CLAUDE.md");
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;
704
+ const path = resolve8(cwd, relative);
705
+ if (!existsSync6(path)) throw new CounterError("lawLineCount", `no law file at ${relative}`);
706
+ return readFileSync6(path, "utf8").replace(/\n$/, "").split("\n").length;
528
707
  }
529
708
  };
530
709
 
531
710
  // src/counters/oxlint.ts
532
- import { existsSync as existsSync6, readFileSync as readFileSync5, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
533
- import { resolve as resolve7 } from "path";
711
+ import { existsSync as existsSync7, readFileSync as readFileSync7, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
712
+ import { resolve as resolve9 } from "path";
534
713
  var DEFAULT_COMMAND = "npx oxlint --format=unix --config .oxlintrc.json .";
535
714
  var PROBE_COMMAND = "oxlint --format=unix --config .oxlintrc.json .";
536
715
  var oxlintProbe = (severity, rule, source) => ({
@@ -584,21 +763,21 @@ var readFindings = (counter, { code, output }, expect) => {
584
763
  const agent = countFindings(output, AGENT_FINDING, "error");
585
764
  const problems = UNIX_SUMMARY.exec(output);
586
765
  const found = DEFAULT_SUMMARY.exec(output);
587
- const refuse = (why) => {
766
+ const refuse2 = (why) => {
588
767
  throw new CounterError(counter, `${why} \u2014 oxlint's output changed:
589
768
  ${output.trim()}`);
590
769
  };
591
770
  if (total(unix) > 0 || problems !== null) {
592
- if (problems?.[1] === void 0) return refuse('unix findings with no "N problems" summary');
771
+ if (problems?.[1] === void 0) return refuse2('unix findings with no "N problems" summary');
593
772
  if (Number(problems[1]) !== total(unix)) {
594
- return refuse(`the summary says ${problems[1]} problems, the lines say ${total(unix)}`);
773
+ return refuse2(`the summary says ${problems[1]} problems, the lines say ${total(unix)}`);
595
774
  }
596
775
  return unix;
597
776
  }
598
777
  if (found?.[1] !== void 0 && found[2] !== void 0) {
599
778
  const summary = { errors: Number(found[2]), warnings: Number(found[1]) };
600
779
  if (total(agent) > 0 && (agent.errors !== summary.errors || agent.warnings !== summary.warnings)) {
601
- return refuse(
780
+ return refuse2(
602
781
  `the summary says ${summary.errors} errors and ${summary.warnings} warnings, the lines say ${agent.errors} and ${agent.warnings}`
603
782
  );
604
783
  }
@@ -607,11 +786,11 @@ ${output.trim()}`);
607
786
  if (total(agent) > 0) return agent;
608
787
  if (code === 0) {
609
788
  if (expect !== void 0 && spokenByTheTool(output) !== "") {
610
- return refuse(`asked for --format=${expect} and got output in no shape this counter reads`);
789
+ return refuse2(`asked for --format=${expect} and got output in no shape this counter reads`);
611
790
  }
612
791
  return { errors: 0, warnings: 0 };
613
792
  }
614
- return refuse(`the tool exited ${code} and printed no findings and no summary`);
793
+ return refuse2(`the tool exited ${code} and printed no findings and no summary`);
615
794
  };
616
795
  var oxlintErrors = {
617
796
  id: "oxlintErrors",
@@ -669,18 +848,18 @@ var oxlintRule = {
669
848
  const config = typeof params.config === "string" ? params.config : "";
670
849
  const expect = expectedFormat("oxlintRule", params);
671
850
  if (config === "") return countRule(run(command), rule, expect);
672
- const source = resolve7(cwd, config);
673
- if (!existsSync6(source)) throw new CounterError("oxlintRule", `no config at ${config}`);
851
+ const source = resolve9(cwd, config);
852
+ if (!existsSync7(source)) throw new CounterError("oxlintRule", `no config at ${config}`);
674
853
  const strictName = `.oxlintrc.ratchet-${key}.json`;
675
- const strict = readFileSync5(source, "utf8").replace(
854
+ const strict = readFileSync7(source, "utf8").replace(
676
855
  new RegExp(`("[^"]*${escapeForRegex(rule)}"\\s*:\\s*\\[?\\s*)"(warn|off)"`),
677
856
  '$1"error"'
678
857
  );
679
- writeFileSync4(resolve7(cwd, strictName), strict);
858
+ writeFileSync4(resolve9(cwd, strictName), strict);
680
859
  try {
681
860
  return countRule(run(command.replace("{config}", strictName)), rule, expect);
682
861
  } finally {
683
- rmSync3(resolve7(cwd, strictName), { force: true });
862
+ rmSync3(resolve9(cwd, strictName), { force: true });
684
863
  }
685
864
  }
686
865
  };
@@ -708,6 +887,143 @@ var runtimeCodeShipped = {
708
887
  }
709
888
  };
710
889
 
890
+ // src/counters/scripts.ts
891
+ import { readdirSync as readdirSync3 } from "fs";
892
+ import { join as join6 } from "path";
893
+
894
+ // 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";
897
+ var SKIP = /^(node_modules|\.)/;
898
+ var childDirs = (dir) => {
899
+ try {
900
+ return readdirSync2(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !SKIP.test(entry.name)).map((entry) => join5(dir, entry.name));
901
+ } catch {
902
+ return [];
903
+ }
904
+ };
905
+ var descendants = (dir, depth) => depth === 0 ? [dir] : [dir, ...childDirs(dir).flatMap((child) => descendants(child, depth - 1))];
906
+ var expand = (root, pattern) => {
907
+ const segments = pattern.split("/").filter((one) => one !== "" && one !== ".");
908
+ let dirs = [root];
909
+ 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));
911
+ }
912
+ return dirs;
913
+ };
914
+ var QUOTED = /^['"]|['"]$/g;
915
+ var cleaned = (value) => value.replace(/#.*$/, "").trim().replaceAll(QUOTED, "");
916
+ var pnpmPatterns = (path) => {
917
+ const lines = readFileSync8(path, "utf8").split("\n");
918
+ const at = lines.findIndex((line) => line.startsWith("packages:"));
919
+ if (at === -1) return [];
920
+ const inline = lines[at]?.slice("packages:".length).trim() ?? "";
921
+ if (inline.startsWith("[")) {
922
+ return inline.replace(/^\[|\]$/g, "").split(",").map(cleaned).filter((one) => one !== "");
923
+ }
924
+ const patterns = [];
925
+ for (const line of lines.slice(at + 1)) {
926
+ const item = /^\s*-\s*(.+)$/.exec(line);
927
+ if (item?.[1] !== void 0) {
928
+ patterns.push(cleaned(item[1]));
929
+ continue;
930
+ }
931
+ if (line.trim() !== "") break;
932
+ }
933
+ return patterns;
934
+ };
935
+ var npmPatterns = (path) => {
936
+ const parsed = JSON.parse(readFileSync8(path, "utf8"));
937
+ const declared = Array.isArray(parsed.workspaces) ? parsed.workspaces : parsed.workspaces?.packages ?? [];
938
+ return declared.filter((one) => typeof one === "string");
939
+ };
940
+ var nameOf = (dir) => {
941
+ const manifest = join5(dir, "package.json");
942
+ if (!existsSync8(manifest)) return void 0;
943
+ try {
944
+ const { name } = JSON.parse(readFileSync8(manifest, "utf8"));
945
+ return typeof name === "string" && name !== "" ? name : void 0;
946
+ } catch {
947
+ return void 0;
948
+ }
949
+ };
950
+ var workspaceDirs = (cwd) => {
951
+ const root = resolve10(cwd);
952
+ const pnpm = join5(root, "pnpm-workspace.yaml");
953
+ const manifest = join5(root, "package.json");
954
+ 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")));
956
+ return [...new Set(dirs)];
957
+ };
958
+ var manifestOf = (dir) => {
959
+ try {
960
+ const parsed = JSON.parse(readFileSync8(join5(dir, "package.json"), "utf8"));
961
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
962
+ } catch {
963
+ return void 0;
964
+ }
965
+ };
966
+ var scriptsOf = (dir) => {
967
+ const scripts = manifestOf(dir)?.scripts;
968
+ return typeof scripts === "object" && scripts !== null ? Object.keys(scripts) : [];
969
+ };
970
+ var workspacePackageNames = (cwd) => {
971
+ const names = workspaceDirs(cwd).map((dir) => nameOf(dir)).filter((name) => name !== void 0);
972
+ return [...new Set(names)];
973
+ };
974
+
975
+ // src/counters/scripts.ts
976
+ var TEST_FILE = /(\.test\.|\.spec\.)/;
977
+ var TEST_DIR = "__tests__";
978
+ var SKIP2 = /^(node_modules|dist|\.)/;
979
+ var holdsTests = (dir, depth = 6) => {
980
+ let entries;
981
+ try {
982
+ entries = readdirSync3(dir, { withFileTypes: true });
983
+ } catch {
984
+ return false;
985
+ }
986
+ for (const entry of entries) {
987
+ if (entry.isDirectory()) {
988
+ if (entry.name === TEST_DIR) return true;
989
+ if (SKIP2.test(entry.name) || depth === 0) continue;
990
+ if (holdsTests(join6(dir, entry.name), depth - 1)) return true;
991
+ continue;
992
+ }
993
+ if (TEST_FILE.test(entry.name)) return true;
994
+ }
995
+ return false;
996
+ };
997
+ var testsWithoutRunner = {
998
+ id: "testsWithoutRunner",
999
+ probe: {
1000
+ expect: 1,
1001
+ input: (dir) => {
1002
+ plant(dir, "pnpm-workspace.yaml", "packages:\n - packages/*\n");
1003
+ plant(dir, "packages/lonely/package.json", '{"name":"lonely","scripts":{"build":"tsup"}}');
1004
+ plant(dir, "packages/lonely/src/thing.test.ts", 'test("x", () => {})\n');
1005
+ }
1006
+ },
1007
+ run: async ({ cwd, params }) => {
1008
+ const script = stringParam("testsWithoutRunner", params, "script", "test");
1009
+ return workspaceDirs(cwd).filter((dir) => holdsTests(dir) && !scriptsOf(dir).includes(script)).length;
1010
+ }
1011
+ };
1012
+ var packagesWithoutTypecheck = {
1013
+ id: "packagesWithoutTypecheck",
1014
+ probe: {
1015
+ expect: 1,
1016
+ input: (dir) => {
1017
+ plant(dir, "pnpm-workspace.yaml", "packages:\n - packages/*\n");
1018
+ plant(dir, "packages/untyped/package.json", '{"name":"untyped","scripts":{"build":"tsup"}}');
1019
+ }
1020
+ },
1021
+ run: async ({ cwd, params }) => {
1022
+ const script = stringParam("packagesWithoutTypecheck", params, "script", "typecheck");
1023
+ return workspaceDirs(cwd).filter((dir) => !scriptsOf(dir).includes(script)).length;
1024
+ }
1025
+ };
1026
+
711
1027
  // src/counters/sum-of-counts.ts
712
1028
  var sumOfCounts = {
713
1029
  id: "sumOfCounts",
@@ -720,26 +1036,41 @@ var sumOfCounts = {
720
1036
  };
721
1037
 
722
1038
  // src/counters/tests.ts
723
- import { existsSync as existsSync7, mkdtempSync as mkdtempSync2, readFileSync as readFileSync6, rmSync as rmSync4 } from "fs";
1039
+ import { existsSync as existsSync9, mkdtempSync as mkdtempSync2, readFileSync as readFileSync9, rmSync as rmSync4 } from "fs";
724
1040
  import { tmpdir as tmpdir2 } from "os";
725
- import { join as join4, resolve as resolve8 } from "path";
726
- var FAILED = /(\d+)\s+fail(?:ed|ing|s)?\b/;
727
- var PASSED = /(\d+)\s+pass(?:ed|ing|es)?\b/;
1041
+ import { join as join7, resolve as resolve11 } from "path";
728
1042
  var COUNTER = "testFailures";
1043
+ var VITEST_LINE = /^\s*Tests {2,}(.+?)\s*$/;
1044
+ var VITEST_TOTAL = /\(\d+\)$/;
1045
+ var BUN_LINE = /^\s*(\d+) fail\s*$/;
1046
+ var FAILED_IN = /(\d+) failed\b/;
1047
+ var DIALECTS = 'vitest ("Tests N failed | M passed (T)") and bun ("N fail")';
729
1048
  var VITEST_JSON = "vitest-json";
730
1049
  var PLACEHOLDER = "{report}";
1050
+ var summaries = (output) => {
1051
+ const found = [];
1052
+ for (const line of output.split("\n")) {
1053
+ const vitest = VITEST_LINE.exec(line)?.[1];
1054
+ if (vitest !== void 0 && VITEST_TOTAL.test(vitest)) {
1055
+ found.push(Number(FAILED_IN.exec(vitest)?.[1] ?? 0));
1056
+ continue;
1057
+ }
1058
+ const bun = BUN_LINE.exec(line)?.[1];
1059
+ if (bun !== void 0) found.push(Number(bun));
1060
+ }
1061
+ return found;
1062
+ };
731
1063
  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;
1064
+ const found = summaries(output);
1065
+ if (found.length > 0) return found.reduce((total2, one) => total2 + one, 0);
735
1066
  throw new CounterError(
736
1067
  COUNTER,
737
- `no pass/fail summary in the runner output:
1068
+ `no runner summary line in the output \u2014 this reads ${DIALECTS}:
738
1069
  ${output.trim().slice(-500)}`
739
1070
  );
740
1071
  };
741
1072
  var fromReport = (path) => {
742
- if (!existsSync7(path)) {
1073
+ if (!existsSync9(path)) {
743
1074
  throw new CounterError(
744
1075
  COUNTER,
745
1076
  `the runner wrote no report at ${path} \u2014 a crash before the reporter is not a pass`
@@ -747,7 +1078,7 @@ var fromReport = (path) => {
747
1078
  }
748
1079
  let report;
749
1080
  try {
750
- report = JSON.parse(readFileSync6(path, "utf8"));
1081
+ report = JSON.parse(readFileSync9(path, "utf8"));
751
1082
  } catch (error) {
752
1083
  throw new CounterError(
753
1084
  COUNTER,
@@ -771,19 +1102,32 @@ var fromReport = (path) => {
771
1102
  };
772
1103
  var reportPathFor = (cwd, params, command) => {
773
1104
  const named = params.reportPath;
774
- if (typeof named === "string" && named !== "") return { own: false, path: resolve8(cwd, named) };
1105
+ if (typeof named === "string" && named !== "") return { own: false, path: resolve11(cwd, named) };
775
1106
  if (!command.includes(PLACEHOLDER)) {
776
1107
  throw new CounterError(
777
1108
  COUNTER,
778
1109
  `report: "${VITEST_JSON}" needs somewhere to put the report \u2014 write ${PLACEHOLDER} into the command (--outputFile=${PLACEHOLDER}) or give the entry a "reportPath"`
779
1110
  );
780
1111
  }
781
- return { own: true, path: join4(mkdtempSync2(join4(tmpdir2(), "geonosis-report-")), "report.json") };
1112
+ return { own: true, path: join7(mkdtempSync2(join7(tmpdir2(), "geonosis-report-")), "report.json") };
782
1113
  };
783
1114
  var testFailures = {
784
1115
  id: COUNTER,
785
1116
  probe: [
786
- { ...captured(" Tests 1 failed | 0 passed (1)\n"), expect: 1, name: "summary" },
1117
+ {
1118
+ ...captured(" Tests 1 failed | 0 passed (1)\n"),
1119
+ expect: 1,
1120
+ name: "summary (vitest)"
1121
+ },
1122
+ {
1123
+ // Captured from bun 1.4.0 over one failing test of two, its per-test line included: the
1124
+ // summary is two lines below one that also says "fail", and only one of them is the count.
1125
+ ...captured(
1126
+ "(fail) one [11.71ms]\n\n 1 pass\n 1 fail\n 2 expect() calls\nRan 2 tests across 1 file. [16.00ms]\n"
1127
+ ),
1128
+ expect: 1,
1129
+ name: "summary (bun)"
1130
+ },
787
1131
  {
788
1132
  // `cat` stands in for the runner: the placeholder is substituted into it, so a counter that
789
1133
  // stopped substituting would hand `cat` the literal token and read nothing.
@@ -814,76 +1158,11 @@ var testFailures = {
814
1158
  run(command.replaceAll(PLACEHOLDER, path));
815
1159
  return fromReport(path);
816
1160
  } finally {
817
- if (own) rmSync4(join4(path, ".."), { force: true, recursive: true });
1161
+ if (own) rmSync4(join7(path, ".."), { force: true, recursive: true });
818
1162
  }
819
1163
  }
820
1164
  };
821
1165
 
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 [];
831
- }
832
- };
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));
839
- }
840
- return dirs;
841
- };
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]));
857
- continue;
858
- }
859
- if (line.trim() !== "") break;
860
- }
861
- return patterns;
862
- };
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");
867
- };
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
- }
877
- };
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)];
885
- };
886
-
887
1166
  // src/counters/typecheck.ts
888
1167
  var UNRESOLVED = /error TS(?:2305|2307): ([^\n]*)/g;
889
1168
  var SPECIFIER = /'([^']+)'/g;
@@ -918,21 +1197,102 @@ var typecheckErrors = {
918
1197
  }
919
1198
  };
920
1199
 
1200
+ // src/counters/walk.ts
1201
+ import { existsSync as existsSync10, readFileSync as readFileSync10 } from "fs";
1202
+ import { resolve as resolve12 } from "path";
1203
+ var DEFAULT_REPORT2 = ".geonosis/walk-report.json";
1204
+ var CLASSES = /* @__PURE__ */ new Set([
1205
+ "buy-box-above-fold",
1206
+ "error-page-status",
1207
+ "fabricated-claim",
1208
+ "fake-session",
1209
+ "light-on-light",
1210
+ "link-integrity",
1211
+ "ops-leakage",
1212
+ "placeholder-asset",
1213
+ "stub-only-entity"
1214
+ ]);
1215
+ var walkFindings = {
1216
+ id: "walkFindings",
1217
+ probe: {
1218
+ expect: 1,
1219
+ input: (dir) => plant(
1220
+ dir,
1221
+ DEFAULT_REPORT2,
1222
+ JSON.stringify({
1223
+ counts: { "fabricated-claim": 1 },
1224
+ finishedAt: "2026-08-30T10:00:01.000Z",
1225
+ pages: [
1226
+ {
1227
+ findings: [
1228
+ {
1229
+ class: "fabricated-claim",
1230
+ evidence: '"4.9/5" is on the page',
1231
+ severity: "blocking",
1232
+ url: "http://localhost:3000/"
1233
+ }
1234
+ ],
1235
+ url: "http://localhost:3000/"
1236
+ }
1237
+ ],
1238
+ probes: ["fabricated-claim"],
1239
+ startedAt: "2026-08-30T10:00:00.000Z",
1240
+ viewport: { height: 900, width: 1440 }
1241
+ })
1242
+ )
1243
+ },
1244
+ run: async ({ cwd, params }) => {
1245
+ const relative = stringParam("walkFindings", params, "report", DEFAULT_REPORT2);
1246
+ const path = resolve12(cwd, relative);
1247
+ if (!existsSync10(path)) {
1248
+ throw new CounterError(
1249
+ "walkFindings",
1250
+ `no walk report at ${relative} \u2014 run \`geonosis-walk\` before measuring it`
1251
+ );
1252
+ }
1253
+ let report;
1254
+ try {
1255
+ report = JSON.parse(readFileSync10(path, "utf8"));
1256
+ } catch (error) {
1257
+ throw new CounterError(
1258
+ "walkFindings",
1259
+ `${relative} does not parse: ${error.message}`
1260
+ );
1261
+ }
1262
+ if (!Array.isArray(report.pages)) {
1263
+ throw new CounterError("walkFindings", `${relative} has no pages \u2014 it is not a walk report`);
1264
+ }
1265
+ const wanted = stringsParam(params, "classes", []);
1266
+ for (const one of wanted) {
1267
+ if (!CLASSES.has(one)) {
1268
+ throw new CounterError("walkFindings", `"${one}" is not one of the walk's defect classes`);
1269
+ }
1270
+ }
1271
+ return report.pages.flatMap((page) => page.findings ?? []).filter((finding) => wanted.length === 0 || wanted.includes(String(finding.class))).length;
1272
+ }
1273
+ };
1274
+
921
1275
  // src/counters/index.ts
922
1276
  var COUNTERS = [
923
1277
  archViolations,
924
1278
  boundaryIssues,
1279
+ bundleBytes,
925
1280
  cloneCount,
1281
+ disabledCiJobs,
1282
+ fastTierMs,
926
1283
  knipIssues,
927
1284
  lawLineCount,
928
1285
  oxlintErrors,
929
1286
  oxlintRule,
930
1287
  oxlintWarnings,
1288
+ packagesWithoutTypecheck,
931
1289
  runtimeCodeShipped,
932
1290
  sumOfCounts,
933
1291
  testFailures,
1292
+ testsWithoutRunner,
934
1293
  typecheckErrors,
935
- unformattedFiles
1294
+ unformattedFiles,
1295
+ walkFindings
936
1296
  ];
937
1297
  var counterById = (id) => {
938
1298
  const counter = COUNTERS.find((one) => one.id === id);
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  formatReport,
6
6
  runProve,
7
7
  runRatchet
8
- } from "./chunk-FM5I6PRT.js";
8
+ } from "./chunk-LSYVFUP4.js";
9
9
 
10
10
  // src/cli.ts
11
11
  var numberAfter = (flag) => {
@@ -0,0 +1,247 @@
1
+ /** What a command did. `code` is the exit status; `output` is stdout and stderr, merged. */
2
+ type CommandResult = {
3
+ code: number;
4
+ output: string;
5
+ };
6
+ /**
7
+ * A tool that reports findings exits non-zero — that is the tool working, not failing. A tool that
8
+ * could not be spawned at all, or whose output no longer parses, is a gate that cannot measure, and
9
+ * a gate that cannot measure has not passed. Counters throw this rather than returning zero.
10
+ */
11
+ declare class CounterError extends Error {
12
+ readonly counter: string;
13
+ constructor(counter: string, message: string);
14
+ }
15
+ type CounterContext = {
16
+ /** The repo root every command runs in and every relative path resolves against. */
17
+ cwd: string;
18
+ /** The baseline key this instance writes — several instances of one counter can coexist. */
19
+ key: string;
20
+ /** Whatever the config gave this instance. Each counter validates its own. */
21
+ params: Record<string, unknown>;
22
+ run: (command: string) => CommandResult;
23
+ };
24
+ /**
25
+ * A counter's falsification probe: the known-bad input it must be able to read.
26
+ *
27
+ * `input` plants it in a scratch directory, `command` is the command shape to run THERE (absent for
28
+ * a counter that reads a file instead), `params` is whatever else that counter needs, and `expect`
29
+ * is the reading the planted input is worth — at least 1, because a probe that plants nothing
30
+ * proves nothing.
31
+ */
32
+ type CounterProbe = {
33
+ command?: (dir: string) => string;
34
+ expect: number;
35
+ input: (dir: string) => void;
36
+ /**
37
+ * What this probe proves, when the counter has more than one way of reading. It is printed beside
38
+ * the key, so a report says WHICH mode was seen red rather than that the counter was.
39
+ */
40
+ name?: string;
41
+ params?: Record<string, unknown>;
42
+ };
43
+ type Counter = {
44
+ id: string;
45
+ /**
46
+ * Whether this counter's number is a measured QUANTITY — bytes, milliseconds — rather than a
47
+ * count of findings, and may therefore carry a `tolerance`.
48
+ *
49
+ * Opt-in, and declared by the counter rather than by the config, because the config is the wrong
50
+ * place to decide it: `"tolerance": 100` on a findings counter switches that gate off and the run
51
+ * still prints PASS. One more lint error is always one too many; forty more bytes is a dependency
52
+ * patch nobody chose. Only the counter knows which of those its number is.
53
+ */
54
+ tolerates?: true;
55
+ /**
56
+ * Absent only for a counter written outside this package. A gate that has never been seen red has
57
+ * not been shown to measure, so `--prove` refuses a configured counter that ships no probe.
58
+ *
59
+ * A counter that reads its tool in more than one way ships one probe per way: proving the mode
60
+ * nobody configured says nothing about the mode they did.
61
+ */
62
+ probe?: CounterProbe | CounterProbe[];
63
+ run: (context: CounterContext) => Promise<number>;
64
+ };
65
+ /** One line of `geonosis.ratchet.json`'s `counters` array. */
66
+ type CounterConfig = {
67
+ counter: string;
68
+ /** The baseline key. Defaults to `counter`; required when a counter is used more than once. */
69
+ key?: string;
70
+ /**
71
+ * The tiers this counter belongs to — free-form names a repo chooses, `fast` and `full` by
72
+ * convention. Absent means every tier, so a repo that never asks for one is unaffected.
73
+ */
74
+ tiers?: string[];
75
+ } & Record<string, unknown>;
76
+ type RatchetConfig = {
77
+ baseline: string;
78
+ counters: CounterConfig[];
79
+ };
80
+ type Verdict = 'grew' | 'held' | 'shrank' | 'skipped';
81
+ /**
82
+ * A counter that ran carries its number; a counter the tier left out carries none — deliberately,
83
+ * so nothing downstream can mistake a census nobody took for one that came back equal.
84
+ */
85
+ type Measurement = {
86
+ baseline: number;
87
+ /**
88
+ * The tail of what this counter's command printed, kept only when the number GREW. A report
89
+ * that says a number went up and nothing else sends the reader back to re-run the tool; the
90
+ * lines that made it go up are the answer they were going to look for.
91
+ */
92
+ evidence: string[];
93
+ key: string;
94
+ now: number;
95
+ verdict: 'grew' | 'held' | 'shrank';
96
+ } | {
97
+ key: string;
98
+ tier: string;
99
+ verdict: 'skipped';
100
+ };
101
+ /**
102
+ * What one counter's probe did. A reading of 0 is the finding this exists to catch: the counter ran,
103
+ * the input was planted, and the number came back the same as it does on a clean tree.
104
+ */
105
+ type Proof = {
106
+ counter: string;
107
+ expected: number;
108
+ key: string;
109
+ reading: number;
110
+ verdict: 'misread';
111
+ } | {
112
+ counter: string;
113
+ key: string;
114
+ reading: number;
115
+ verdict: 'cannot-fail' | 'proven';
116
+ } | {
117
+ counter: string;
118
+ key: string;
119
+ reason: string;
120
+ verdict: 'cannot-measure';
121
+ }
122
+ /**
123
+ * Not a counter: `--exclusive` itself, measured by running two of it. A lock is a claim about the
124
+ * machine, and the only place that claim can be checked is the machine.
125
+ */
126
+ | {
127
+ key: string;
128
+ reason: string;
129
+ verdict: 'interleaved';
130
+ } | {
131
+ key: string;
132
+ reason: string;
133
+ verdict: 'serialised';
134
+ } | {
135
+ key: string;
136
+ tier: string;
137
+ verdict: 'skipped';
138
+ };
139
+ type ProveResult = {
140
+ proofs: Proof[];
141
+ /** False as soon as one counter could not be shown to read its own planted finding. */
142
+ proven: boolean;
143
+ };
144
+ type RatchetResult = {
145
+ measurements: Measurement[];
146
+ /** True when the baseline file was rewritten because something shrank. */
147
+ rewritten: boolean;
148
+ };
149
+
150
+ declare const CONFIG_FILE = "geonosis.ratchet.json";
151
+ /** The baseline key an entry writes. Defaults to the counter's own id. */
152
+ declare const keyOf: (entry: CounterConfig) => string;
153
+ declare const loadConfig: (cwd: string) => RatchetConfig;
154
+
155
+ declare const COUNTERS: Counter[];
156
+ declare const counterById: (id: string) => Counter;
157
+
158
+ /** Who is running the heavy thing, since when, and where from. */
159
+ type Holder = {
160
+ cwd: string;
161
+ pid: number;
162
+ startedAt: string;
163
+ };
164
+ /**
165
+ * One path for the whole machine, because the contention is for the machine: three sessions on one
166
+ * laptop, each starting a verify, each slowing the other two down until a 25-minute run took two
167
+ * hours and produced failures that were about the load and not the code.
168
+ */
169
+ declare const heavyLockPath: () => string;
170
+ /**
171
+ * Takes the machine-wide lock, waiting for whoever has it, and gives back the release.
172
+ *
173
+ * A holder whose process is gone — killed, crashed, a laptop closed mid-run — is stale and is taken
174
+ * over with a printed note; so is a lock file nobody can parse, because a lock that can never be
175
+ * cleared is worse than no lock. The wait is bounded and says who it is waiting for: a run that
176
+ * hangs silently for half an hour is indistinguishable from a run that is broken.
177
+ */
178
+ declare const acquireExclusive: ({ noticeMs, path, pollMs, say, timeoutSeconds, }?: {
179
+ noticeMs?: number;
180
+ path?: string;
181
+ pollMs?: number;
182
+ say?: (line: string) => void;
183
+ timeoutSeconds?: number;
184
+ }) => Promise<() => void>;
185
+
186
+ /**
187
+ * D-029: every gate ships its own falsification probe. The ratchet compares a number against a
188
+ * number and cannot tell a counter that found nothing from a counter that CAN find nothing — the
189
+ * kit's own `oxlintErrors` read 0 under the format its config asked for, from inception, and every
190
+ * baseline it wrote was a census nobody took.
191
+ *
192
+ * So each counter plants a known-bad input and must read it. The run stops at the first counter that
193
+ * reads 0 or throws: the rest of the report would be about a gate whose first gate is not a gate.
194
+ */
195
+ declare const runProve: ({ counters, cwd, exclusiveVia, tier, }: {
196
+ counters: Counter[];
197
+ cwd: string;
198
+ /**
199
+ * The CLI to run two of, to see whether `--exclusive` serialises on this machine. Absent means
200
+ * the caller is a library and there is no process to run twice, so the lock is not proved.
201
+ */
202
+ exclusiveVia?: string;
203
+ tier?: string;
204
+ }) => Promise<ProveResult>;
205
+
206
+ /**
207
+ * A binary gate has two bad modes: ignore the debt, or block every commit until it is zero. Neither
208
+ * works mid-migration. This measures the debt, compares it against the baseline, and fails ONLY on
209
+ * regression — so a repo that predates a rule can adopt it the same day, and no NEW debt can land
210
+ * behind it.
211
+ *
212
+ * A `tier` runs only the counters that declare it; the rest are reported as skipped and take no
213
+ * part in the verdict, the exit code or a baseline rewrite. An iteration gate that took eleven
214
+ * seconds gets run less often than one that takes four, and a gate nobody runs enforces nothing —
215
+ * but a skipped counter that printed OK would be worse than not running at all.
216
+ */
217
+ declare const runRatchet: ({ counters, cwd, tier, }: {
218
+ counters: Counter[];
219
+ cwd: string;
220
+ tier?: string;
221
+ }) => Promise<RatchetResult>;
222
+
223
+ /**
224
+ * One line per counter, then the verdict. Reads the same in a terminal and in a CI log. A counter
225
+ * the tier left out says so by name — printing a number for it would be the stale OK this exists
226
+ * to prevent.
227
+ */
228
+ declare const formatReport: ({ measurements, rewritten }: RatchetResult) => string;
229
+ declare const formatProve: ({ proofs, proven }: ProveResult) => string;
230
+
231
+ /**
232
+ * Runs a command and merges stderr into stdout — most of these tools write their summary line to
233
+ * stderr, and on a zero exit `execSync` would otherwise hand back only stdout and the parse would
234
+ * fail. A non-zero exit is NOT an error here: a linter that found something exits non-zero, and
235
+ * that is the whole reason we are asking. Counters decide what an unparseable answer means.
236
+ *
237
+ * The command runs inside a SUBSHELL, because `<command> 2>&1` binds the redirect to the last
238
+ * command of the string only. `echo boom >&2; echo fine` merged nothing, and on a zero exit
239
+ * execSync hands back stdout alone — so a tool that prints its findings to stderr and exits 0 read
240
+ * as a tool that found nothing. That is the exact shape of a gate that has quietly stopped gating.
241
+ * On a non-zero exit the two buffers were concatenated instead, which put every stderr line after
242
+ * every stdout line and moved each diagnostic away from the line it was about. The shell keeps
243
+ * them in the order they were written. The newlines let a command end in a comment.
244
+ */
245
+ declare const runCommand: (cwd: string, counterId: string, env?: NodeJS.ProcessEnv) => (command: string) => CommandResult;
246
+
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 };
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  runCommand,
13
13
  runProve,
14
14
  runRatchet
15
- } from "./chunk-FM5I6PRT.js";
15
+ } from "./chunk-LSYVFUP4.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.5.0",
3
+ "version": "1.0.0",
4
4
  "description": "Debt as a number that may only shrink — one ratchet, pluggable counters.",
5
5
  "keywords": [
6
6
  "ratchet",