@geonosis/ratchet 0.1.0 → 0.1.2

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
@@ -54,7 +54,52 @@ code, and its baseline survives a rewrite another counter earned: printing OK fo
54
54
  took is how a red main goes unnoticed for three commits.
55
55
 
56
56
  A counter whose command cannot run is a hard error, never a silent zero. A gate that cannot measure
57
- has not passed.
57
+ has not passed. Nor is a number believed on its own: `oxlintErrors` reads the finding lines AND the
58
+ summary oxlint printed, and raises when they disagree.
59
+
60
+ ### The warnings-only window, and `expectFormat`
61
+
62
+ The last-resort backstop under the oxlint counters — output in no shape we know *and* a non-zero
63
+ exit means the format moved, so refuse — has one gap, and it is worth naming rather than pretending
64
+ otherwise. **oxlint exits 0 when a run found only warnings.** On such a run the backstop cannot
65
+ fire: an unrecognised format reads as zero warnings, and a zero that only shrinks is a zero the
66
+ ratchet banks by rewriting the baseline down.
67
+
68
+ `--deny-warnings` is **not** the fix. It makes warnings exit non-zero by making them errors, which
69
+ moves every warning into `oxlintErrors` and changes what both numbers mean — a bigger lie than the
70
+ one it patches, told to the whole baseline.
71
+
72
+ The fix is to say which shape you asked for:
73
+
74
+ ```jsonc
75
+ { "counter": "oxlintWarnings", "command": "npx oxlint --format=unix .", "expectFormat": "unix" }
76
+ ```
77
+
78
+ With `expectFormat` set, output that matches none of the three shapes and is not empty once npm's
79
+ own chatter is stripped (`> …` script echoes, `npm warn …`, `npm notice …` update nags, blank
80
+ lines) is refused **even on a clean exit**. `npm ERR!` is not stripped — that is npm saying the
81
+ 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
82
+ the behaviour it had. Valid values: `unix`, `agent`, `default`.
83
+
84
+ When a number **grew**, the last ten lines of that counter's command output print under the
85
+ `<-- REGRESSED` line, indented — so the report says what grew, not only that something did. A
86
+ counter that reads a file rather than running a command (`lawLineCount`) prints nothing extra.
87
+
88
+ ## Running it where it will actually run
89
+
90
+ - **Counters run in the caller's environment.** They inherit the shell the ratchet was started in,
91
+ nothing more. If a child command needs `NODE_OPTIONS` — a TypeScript shim, a loader — put it on
92
+ the script that invokes `geonosis-ratchet`, not on the counter's own line, and not only in your
93
+ interactive shell.
94
+ - **A `typecheckErrors` counter needs the same precondition CI gives it: build the workspace
95
+ packages first.** In a fresh worktree the `dist/*.d.ts` files do not exist yet, and `tsc` reports
96
+ a false +N of missing-module errors that has nothing to do with the change under test. `pnpm build
97
+ && geonosis-ratchet` is the shape; a bare `geonosis-ratchet` in a clean checkout is not.
98
+ - **A release-age cooldown will refuse a package published minutes ago.** If pnpm's
99
+ `minimumReleaseAge` blocks `@geonosis/*`, exclude the scope for that project — pnpm writes
100
+ `minimumReleaseAgeExclude` into `pnpm-workspace.yaml` — rather than lowering the cooldown. The
101
+ cooldown is protecting every other dependency in the tree; the exclusion is scoped to the one you
102
+ chose to trust.
58
103
 
59
104
  ## Counters
60
105
 
@@ -62,8 +107,8 @@ Every counter takes its `command` from the config, so the toolchain stays the re
62
107
 
63
108
  | id | counts | params |
64
109
  | --- | --- | --- |
65
- | `oxlintErrors` / `oxlintWarnings` | `: error ` / `: warning ` lines | `command` |
66
- | `oxlintRule` | one named rule's findings; with `config`, after forcing it to `error` in a temp copy, so debt cannot grow behind a downgrade | `rule`, `config`, `command` |
110
+ | `oxlintErrors` / `oxlintWarnings` | findings under any shape oxlint prints — `--format=unix`, the compact `agent` format, the graphical `default` cross-checked against the tool's own summary | `command`, `expectFormat` |
111
+ | `oxlintRule` | one named rule's findings, counted only on lines the run reported as findings and refused when none of them attributes itself readably; with `config`, after forcing the rule to `error` in a temp copy — `"warn"`, `"off"` and the `["off", { … }]` array form alike — so debt cannot grow behind a downgrade | `rule`, `config`, `command`, `expectFormat` |
67
112
  | `typecheckErrors` | `error TS` occurrences | `command` |
68
113
  | `testFailures` | the runner's own failure summary; throws when neither a pass nor a fail count is readable | `command` |
69
114
  | `unformattedFiles` | paths `--list-different` names that exist on disk | `command` |
@@ -108,19 +108,101 @@ var lawLineCount = {
108
108
  import { existsSync as existsSync3, readFileSync as readFileSync2, rmSync, writeFileSync } from "fs";
109
109
  import { resolve as resolve3 } from "path";
110
110
  var DEFAULT_COMMAND = "npx oxlint --format=unix --config .oxlintrc.json .";
111
+ var UNIX_FINDING = /^\S[^\n]*:\d+:\d+: .*\[(Error|Warning)\/[^\]\n]+\]$/gm;
112
+ var UNIX_SUMMARY = /^(\d+) problems?$/m;
113
+ var AGENT_FINDING = /^\S[^\n]*:\d+:\d+: (error|warning) /gm;
114
+ var DEFAULT_SUMMARY = /^Found (\d+) warnings? and (\d+) errors?\.$/m;
115
+ var DEFAULT_FINDING = /^\s*[x!] [^\s(]+\([^)\n]+\): /m;
116
+ var FINDING_SHAPES = [UNIX_FINDING, AGENT_FINDING, DEFAULT_FINDING].map(
117
+ (shape) => new RegExp(shape.source)
118
+ );
119
+ var RULE_TOKEN = /\([^()\n]+\)/;
120
+ var findingLinesOf = (output) => output.split("\n").filter((line) => FINDING_SHAPES.some((shape) => shape.test(line)));
121
+ var FORMATS = ["agent", "default", "unix"];
122
+ var isFormat = (value) => typeof value === "string" && FORMATS.some((one) => one === value);
123
+ var expectedFormat = (counter, params) => {
124
+ const value = params.expectFormat;
125
+ if (value === void 0) return void 0;
126
+ if (isFormat(value)) return value;
127
+ throw new CounterError(
128
+ counter,
129
+ `expectFormat must be one of ${FORMATS.join(", ")} \u2014 got ${JSON.stringify(value)}`
130
+ );
131
+ };
132
+ var NOISE = /^(> .*|npm warn .*|npm notice.*|\s*)$/;
133
+ var spokenByTheTool = (output) => output.split("\n").filter((line) => !NOISE.test(line)).join("\n");
134
+ var total = (reading) => reading.errors + reading.warnings;
135
+ var countFindings = (output, pattern, errorToken) => {
136
+ let errors = 0;
137
+ let warnings = 0;
138
+ for (const match of output.matchAll(new RegExp(pattern.source, pattern.flags))) {
139
+ if (match[1] === errorToken) errors += 1;
140
+ else warnings += 1;
141
+ }
142
+ return { errors, warnings };
143
+ };
144
+ var readFindings = (counter, { code, output }, expect) => {
145
+ const unix = countFindings(output, UNIX_FINDING, "Error");
146
+ const agent = countFindings(output, AGENT_FINDING, "error");
147
+ const problems = UNIX_SUMMARY.exec(output);
148
+ const found = DEFAULT_SUMMARY.exec(output);
149
+ const refuse = (why) => {
150
+ throw new CounterError(counter, `${why} \u2014 oxlint's output changed:
151
+ ${output.trim()}`);
152
+ };
153
+ if (total(unix) > 0 || problems !== null) {
154
+ if (problems?.[1] === void 0) return refuse('unix findings with no "N problems" summary');
155
+ if (Number(problems[1]) !== total(unix)) {
156
+ return refuse(`the summary says ${problems[1]} problems, the lines say ${total(unix)}`);
157
+ }
158
+ return unix;
159
+ }
160
+ if (found?.[1] !== void 0 && found[2] !== void 0) {
161
+ const summary = { errors: Number(found[2]), warnings: Number(found[1]) };
162
+ if (total(agent) > 0 && (agent.errors !== summary.errors || agent.warnings !== summary.warnings)) {
163
+ return refuse(
164
+ `the summary says ${summary.errors} errors and ${summary.warnings} warnings, the lines say ${agent.errors} and ${agent.warnings}`
165
+ );
166
+ }
167
+ return summary;
168
+ }
169
+ if (total(agent) > 0) return agent;
170
+ if (code === 0) {
171
+ if (expect !== void 0 && spokenByTheTool(output) !== "") {
172
+ return refuse(`asked for --format=${expect} and got output in no shape this counter reads`);
173
+ }
174
+ return { errors: 0, warnings: 0 };
175
+ }
176
+ return refuse(`the tool exited ${code} and printed no findings and no summary`);
177
+ };
111
178
  var oxlintErrors = {
112
179
  id: "oxlintErrors",
113
180
  run: async ({ params, run }) => {
114
181
  const command = stringParam("oxlintErrors", params, "command", DEFAULT_COMMAND);
115
- return countMatches(run(command).output, /: error /);
182
+ const expect = expectedFormat("oxlintErrors", params);
183
+ return readFindings("oxlintErrors", run(command), expect).errors;
116
184
  }
117
185
  };
118
186
  var oxlintWarnings = {
119
187
  id: "oxlintWarnings",
120
188
  run: async ({ params, run }) => {
121
189
  const command = stringParam("oxlintWarnings", params, "command", DEFAULT_COMMAND);
122
- return countMatches(run(command).output, /: warning /);
190
+ const expect = expectedFormat("oxlintWarnings", params);
191
+ return readFindings("oxlintWarnings", run(command), expect).warnings;
192
+ }
193
+ };
194
+ var countRule = (result, rule, expect) => {
195
+ const reading = readFindings("oxlintRule", result, expect);
196
+ const lines = findingLinesOf(result.output);
197
+ if (total(reading) > 0 && !lines.some((line) => RULE_TOKEN.test(line))) {
198
+ throw new CounterError(
199
+ "oxlintRule",
200
+ `${total(reading)} findings, not one of them attributed to a "plugin(rule)" this counter can read \u2014 oxlint's output changed:
201
+ ${result.output.trim()}`
202
+ );
123
203
  }
204
+ const named = new RegExp(`\\(${escapeForRegex(rule)}\\)`);
205
+ return lines.filter((line) => named.test(line)).length;
124
206
  };
125
207
  var oxlintRule = {
126
208
  id: "oxlintRule",
@@ -128,19 +210,18 @@ var oxlintRule = {
128
210
  const rule = stringParam("oxlintRule", params, "rule");
129
211
  const command = stringParam("oxlintRule", params, "command", DEFAULT_COMMAND);
130
212
  const config = typeof params.config === "string" ? params.config : "";
131
- if (config === "")
132
- return countMatches(run(command).output, new RegExp(`\\(${escapeForRegex(rule)}\\)`));
213
+ const expect = expectedFormat("oxlintRule", params);
214
+ if (config === "") return countRule(run(command), rule, expect);
133
215
  const source = resolve3(cwd, config);
134
216
  if (!existsSync3(source)) throw new CounterError("oxlintRule", `no config at ${config}`);
135
217
  const strictName = `.oxlintrc.ratchet-${key}.json`;
136
218
  const strict = readFileSync2(source, "utf8").replace(
137
- new RegExp(`("[^"]*${escapeForRegex(rule)}"\\s*:\\s*)"(warn|off)"`),
219
+ new RegExp(`("[^"]*${escapeForRegex(rule)}"\\s*:\\s*\\[?\\s*)"(warn|off)"`),
138
220
  '$1"error"'
139
221
  );
140
222
  writeFileSync(resolve3(cwd, strictName), strict);
141
223
  try {
142
- const output = run(command.replace("{config}", strictName)).output;
143
- return countMatches(output, new RegExp(`\\(${escapeForRegex(rule)}\\)`));
224
+ return countRule(run(command.replace("{config}", strictName)), rule, expect);
144
225
  } finally {
145
226
  rmSync(resolve3(cwd, strictName), { force: true });
146
227
  }
@@ -298,6 +379,18 @@ ${output.trim()}`
298
379
  // src/ratchet.ts
299
380
  import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
300
381
  import { resolve as resolve5 } from "path";
382
+ var EVIDENCE_LINES = 10;
383
+ var recorded = (run) => {
384
+ let output = "";
385
+ return {
386
+ last: () => output.split("\n").map((line) => line.trimEnd()).filter((line) => line !== "").slice(-EVIDENCE_LINES),
387
+ run: (command) => {
388
+ const result = run(command);
389
+ output = result.output;
390
+ return result;
391
+ }
392
+ };
393
+ };
301
394
  var verdictOf = (now, baseline) => {
302
395
  if (now > baseline) return "grew";
303
396
  if (now < baseline) return "shrank";
@@ -335,13 +428,16 @@ var runRatchet = async ({
335
428
  `${config.baseline} has no number for "${key}" \u2014 add it before enabling the counter`
336
429
  );
337
430
  }
338
- const now = await counter.run({
339
- cwd,
431
+ const recorder = recorded(runCommand(cwd, entry.counter));
432
+ const now = await counter.run({ cwd, key, params: entry, run: recorder.run });
433
+ const verdict = verdictOf(now, limit);
434
+ measurements.push({
435
+ baseline: limit,
436
+ evidence: verdict === "grew" ? recorder.last() : [],
340
437
  key,
341
- params: entry,
342
- run: runCommand(cwd, entry.counter)
438
+ now,
439
+ verdict
343
440
  });
344
- measurements.push({ baseline: limit, key, now, verdict: verdictOf(now, limit) });
345
441
  }
346
442
  const grew = measurements.some((one) => one.verdict === "grew");
347
443
  const shrank = measurements.some((one) => one.verdict === "shrank");
@@ -363,7 +459,10 @@ var formatReport = ({ measurements, rewritten }) => {
363
459
  return ` SKIP ${one.key}: not measured by --tier ${one.tier}`;
364
460
  }
365
461
  const head = ` ${one.key.padEnd(WIDTH)} ${String(one.now).padStart(5)} (baseline ${one.baseline})`;
366
- if (one.verdict === "grew") return `${head} <-- REGRESSED +${one.now - one.baseline}`;
462
+ if (one.verdict === "grew") {
463
+ const regressed = `${head} <-- REGRESSED +${one.now - one.baseline}`;
464
+ return [regressed, ...one.evidence.map((line) => ` ${line}`)].join("\n");
465
+ }
367
466
  if (one.verdict === "shrank") return `${head} <-- improved -${one.baseline - one.now}`;
368
467
  return head;
369
468
  });
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  COUNTERS,
3
3
  formatReport,
4
4
  runRatchet
5
- } from "./chunk-VX2PL7QG.js";
5
+ } from "./chunk-BTWIR7DK.js";
6
6
 
7
7
  // src/cli.ts
8
8
  var cwdFlag = process.argv.indexOf("--cwd");
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  loadConfig,
9
9
  runCommand,
10
10
  runRatchet
11
- } from "./chunk-VX2PL7QG.js";
11
+ } from "./chunk-BTWIR7DK.js";
12
12
  export {
13
13
  CONFIG_FILE,
14
14
  COUNTERS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geonosis/ratchet",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Debt as a number that may only shrink — one ratchet, pluggable counters.",
5
5
  "keywords": [
6
6
  "ratchet",
@@ -21,7 +21,7 @@
21
21
  "type": "module",
22
22
  "main": "dist/index.js",
23
23
  "bin": {
24
- "geonosis-ratchet": "./bin/geonosis-ratchet.mjs"
24
+ "geonosis-ratchet": "bin/geonosis-ratchet.mjs"
25
25
  },
26
26
  "exports": {
27
27
  ".": "./dist/index.js"
@@ -30,14 +30,14 @@
30
30
  "bin",
31
31
  "dist"
32
32
  ],
33
- "scripts": {
34
- "build": "tsup",
35
- "typecheck": "tsc --noEmit"
36
- },
37
33
  "engines": {
38
34
  "node": ">=22"
39
35
  },
40
36
  "publishConfig": {
41
37
  "access": "public"
38
+ },
39
+ "scripts": {
40
+ "build": "tsup",
41
+ "typecheck": "tsc --noEmit"
42
42
  }
43
- }
43
+ }