@geonosis/lint-parity 0.4.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
@@ -18,6 +18,7 @@ pnpm add -D @geonosis/lint-parity oxlint
18
18
  ```bash
19
19
  geonosis-lint-parity --a <config-a.json> --b <config-b.json> [--oxlint <path>] [--out <dir>] -- <paths…>
20
20
  geonosis-lint-parity --corpus <dir> --a <config-a.json> --b <config-b.json> [--out <dir>]
21
+ [--expect-changed <rule,rule,…>]
21
22
  ```
22
23
 
23
24
  **Write both configs inside the workspace being linted** — `<ws>/.oxlintrc.parity-a.json`, deleted
@@ -63,6 +64,33 @@ import { manifestOf, MANIFEST_FILE } from '@geonosis/lint-parity'
63
64
  writeFileSync(join(corpus, MANIFEST_FILE), `${JSON.stringify(manifestOf(plugin), null, 2)}\n`)
64
65
  ```
65
66
 
67
+ ## And a release's claims, checked
68
+
69
+ A changelog line that names a rule is a claim. `--expect-changed` makes it data the corpus judges:
70
+
71
+ ```bash
72
+ geonosis-lint-parity --corpus node_modules/<plugin>/corpus \
73
+ --a oxlintrc.old.json --b oxlintrc.new.json \
74
+ --expect-changed some-rule,another-rule
75
+ ```
76
+
77
+ It exits 1 unless the corpus shows **exactly** those rules behaving differently under the two
78
+ configs — different reach, or a different number of findings:
79
+
80
+ | verdict | means |
81
+ | --- | --- |
82
+ | `HELD` | claimed, and the corpus shows it changing |
83
+ | `UNPROVEN` | claimed, and the corpus shows nothing — including a rule the corpus does not exercise |
84
+ | `UNCLAIMED` | the corpus shows it changing and the release never said so |
85
+
86
+ The report prints the claims table beside the reach table. Without the flag no claim is made and none
87
+ is judged, which is what a corpus run has always done.
88
+
89
+ Why it exists: a release once announced a behaviour widening for **ten** rules while its corpus
90
+ carried evidence for **two**, and the parity run offered as proof went over a consumer tree that had
91
+ no such violation in it — so every number matched. The claim is the thing to check, and the corpus is
92
+ what checks it.
93
+
66
94
  ## Then parity
67
95
 
68
96
  It runs oxlint twice with `--format=unix` over the same paths, strips the working-directory prefix,
@@ -1,13 +1,31 @@
1
1
  // src/parity.ts
2
2
  import { spawnSync } from "child_process";
3
- import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
3
+ import {
4
+ cpSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ rmSync,
10
+ writeFileSync
11
+ } from "fs";
4
12
  import { tmpdir } from "os";
5
- import { join } from "path";
13
+ import { dirname, join, resolve } from "path";
6
14
  var ANSI = /\[[0-9;]*m/g;
7
15
  var SUMMARY_COUNT = /^(\d+) problems?$/m;
8
16
  var RULE_IN_LINE = /\[(?:Error|Warning)\/([^\]]+)\]$/;
9
17
  var UNIX_FINDING = /^\S[^\n]*:\d+:\d+: .*\[(?:Error|Warning)\/[^\]\n]+\]$/;
10
18
  var FINDING_PLACE = /^(\S[^\n]*?:\d+:\d+): .*\[(?:Error|Warning)\/([^\]\n]+)\]$/;
19
+ var resolveOxlint = (from) => {
20
+ let dir = from;
21
+ for (; ; ) {
22
+ const bin = resolve(dir, "node_modules/.bin/oxlint");
23
+ if (existsSync(bin)) return bin;
24
+ const parent = dirname(dir);
25
+ if (parent === dir) return "oxlint";
26
+ dir = parent;
27
+ }
28
+ };
11
29
  var ParityError = class extends Error {
12
30
  constructor(message) {
13
31
  super(message);
@@ -152,6 +170,27 @@ var manifestOf = (plugin) => ({
152
170
  plugin: plugin.meta.name,
153
171
  rules: Object.keys(plugin.rules).map((rule) => `${plugin.meta.name}/${rule}`).toSorted()
154
172
  });
173
+ var namesRule = (claim, rule) => {
174
+ if (claim === rule) return true;
175
+ const at = rule.indexOf("/");
176
+ return at !== -1 && rule.slice(at + 1) === claim;
177
+ };
178
+ var claimsOver = (reach, expectChanged) => {
179
+ if (expectChanged === void 0) return [];
180
+ const claimed = (rule) => expectChanged.some((one) => namesRule(one, rule));
181
+ const judged = reach.filter((one) => one.changed || claimed(one.rule)).map((one) => ({
182
+ changed: one.changed,
183
+ claimed: claimed(one.rule),
184
+ rule: one.rule,
185
+ verdict: verdictOf(one.changed, claimed(one.rule))
186
+ }));
187
+ const outsideReach = expectChanged.filter((claim) => !reach.some((one) => namesRule(claim, one.rule))).map((rule) => ({ changed: false, claimed: true, rule, verdict: "unproven" }));
188
+ return [...judged, ...outsideReach];
189
+ };
190
+ var verdictOf = (changed, claimed) => {
191
+ if (changed && claimed) return "held";
192
+ return claimed ? "unproven" : "unclaimed";
193
+ };
155
194
  var readManifest = (corpus) => {
156
195
  const path = join(corpus, MANIFEST_FILE);
157
196
  let parsed;
@@ -170,14 +209,16 @@ var corpusOf = ({
170
209
  configA,
171
210
  configB,
172
211
  corpus,
212
+ expectChanged,
173
213
  oxlint
174
214
  }) => {
175
215
  const manifest = readManifest(corpus);
176
216
  const exercised = new Set(manifest.rules);
177
217
  const root = mkdtempSync(join(tmpdir(), "geonosis-corpus-"));
178
218
  const here = join(root, "corpus");
179
- const firedIn = (config, side) => new Set(
180
- readRun(
219
+ const foundIn = (config, side) => {
220
+ const counts = /* @__PURE__ */ new Map();
221
+ for (const finding of readRun(
181
222
  lint({
182
223
  config,
183
224
  cwd: root,
@@ -187,29 +228,67 @@ var corpusOf = ({
187
228
  }),
188
229
  root,
189
230
  side
190
- ).map(ruleIdOf)
191
- );
192
- let inA = /* @__PURE__ */ new Set();
193
- let inB = /* @__PURE__ */ new Set();
231
+ )) {
232
+ const rule = ruleIdOf(finding);
233
+ counts.set(rule, (counts.get(rule) ?? 0) + 1);
234
+ }
235
+ return counts;
236
+ };
237
+ let inA = /* @__PURE__ */ new Map();
238
+ let inB = /* @__PURE__ */ new Map();
194
239
  try {
195
240
  cpSync(corpus, here, { recursive: true });
196
- inA = firedIn(configA, "A");
197
- inB = firedIn(configB, "B");
241
+ inA = foundIn(configA, "A");
242
+ inB = foundIn(configB, "B");
198
243
  } finally {
199
244
  rmSync(root, { force: true, recursive: true });
200
245
  }
201
246
  const configured = [.../* @__PURE__ */ new Set([...rulesNamedBy(configA), ...rulesNamedBy(configB)])].toSorted();
202
- const reach = configured.filter((rule) => exercised.has(rule)).map((rule) => ({ firedInA: inA.has(rule), firedInB: inB.has(rule), rule }));
247
+ const reach = configured.filter((rule) => exercised.has(rule)).map((rule) => {
248
+ const foundInA = inA.get(rule) ?? 0;
249
+ const foundInB = inB.get(rule) ?? 0;
250
+ return {
251
+ changed: foundInA !== foundInB,
252
+ firedInA: foundInA > 0,
253
+ firedInB: foundInB > 0,
254
+ foundInA,
255
+ foundInB,
256
+ rule
257
+ };
258
+ });
259
+ const claims = claimsOver(reach, expectChanged);
203
260
  return {
261
+ claims,
262
+ claimsHeld: claims.every((one) => one.verdict === "held"),
204
263
  neither: reach.filter((one) => !one.firedInA && !one.firedInB).map((one) => one.rule),
205
264
  outside: configured.filter((rule) => !exercised.has(rule)),
206
265
  plugin: manifest.plugin,
207
266
  reach
208
267
  };
209
268
  };
269
+ var VERDICTS = {
270
+ held: "HELD",
271
+ unclaimed: "UNCLAIMED \u2014 the corpus shows this changing and the release did not say so",
272
+ unproven: "UNPROVEN \u2014 claimed, and the corpus shows no change"
273
+ };
274
+ var claimsSection = (report) => {
275
+ if (report.claims.length === 0) return [];
276
+ return [
277
+ "## claims",
278
+ "",
279
+ report.claimsHeld ? "**HELD** \u2014 every rule the release claims is a rule this corpus shows changing, and no other." : "**BROKEN** \u2014 a claim this corpus does not show, or a change the release did not claim.",
280
+ "",
281
+ "| rule | claimed | observed | verdict |",
282
+ "|---|---|---|---|",
283
+ ...report.claims.map(
284
+ (one) => `| \`${one.rule}\` | ${one.claimed ? "changed" : "\u2014"} | ${one.changed ? "changed" : "unchanged"} | ${VERDICTS[one.verdict]} |`
285
+ ),
286
+ ""
287
+ ];
288
+ };
210
289
  var formatCorpus = (report) => {
211
290
  const rows = report.reach.map(
212
- (one) => `| \`${one.rule}\` | ${one.firedInA ? "yes" : "NO"} | ${one.firedInB ? "yes" : "NO"} |`
291
+ (one) => `| \`${one.rule}\` | ${one.firedInA ? "yes" : "NO"} (${one.foundInA}) | ${one.firedInB ? "yes" : "NO"} (${one.foundInB}) |`
213
292
  );
214
293
  const outside = report.outside.length === 0 ? "None \u2014 this corpus speaks for every rule the configs enable.\n" : `${report.outside.map((rule) => `- \`${rule}\``).join("\n")}
215
294
  `;
@@ -223,9 +302,10 @@ var formatCorpus = (report) => {
223
302
  report.neither.length === 0 ? "**PASS** \u2014 every in-scope rule has at least one corpus file that fires it." : `**FAIL** \u2014 these rules fire nowhere in the corpus, so nothing shows they reach anything:
224
303
  ${report.neither.map((rule) => `- \`${rule}\``).join("\n")}`,
225
304
  "",
305
+ ...claimsSection(report),
226
306
  "## per rule",
227
307
  "",
228
- "| rule | fired under A | fired under B |",
308
+ "| rule | fired under A (findings) | fired under B (findings) |",
229
309
  "|---|---|---|",
230
310
  ...rows,
231
311
  "",
@@ -239,12 +319,14 @@ ${report.neither.map((rule) => `- \`${rule}\``).join("\n")}`,
239
319
  };
240
320
  var FLAGS = { "--a": "configA", "--b": "configB", "--out": "out", "--oxlint": "oxlint" };
241
321
  var CORPUS_FLAG = "--corpus";
322
+ var CLAIM_FLAG = "--expect-changed";
242
323
  var parseParityArgs = (argv) => {
243
324
  const at = argv.indexOf("--");
244
325
  const flags = at === -1 ? argv : argv.slice(0, at);
245
326
  const paths = at === -1 ? [] : argv.slice(at + 1);
246
327
  const read = {};
247
328
  let corpus;
329
+ let expectChanged;
248
330
  for (let index = 0; index < flags.length; index += 1) {
249
331
  const flag = flags[index];
250
332
  const value = flags[index + 1];
@@ -258,6 +340,16 @@ var parseParityArgs = (argv) => {
258
340
  index += 1;
259
341
  continue;
260
342
  }
343
+ if (flag === CLAIM_FLAG) {
344
+ if (value === void 0 || value.startsWith("--")) {
345
+ throw new Error(
346
+ `geonosis-lint-parity: ${CLAIM_FLAG} needs the rules the release claims it changes, comma separated \u2014 a claim about no rules is not a claim`
347
+ );
348
+ }
349
+ expectChanged = value.split(",").map((one) => one.trim()).filter((one) => one !== "");
350
+ index += 1;
351
+ continue;
352
+ }
261
353
  const name = FLAGS[flag];
262
354
  if (name === void 0) throw new Error(`geonosis-lint-parity: unknown argument "${flag}"`);
263
355
  if (value === void 0 || value.startsWith("--")) {
@@ -275,6 +367,7 @@ var parseParityArgs = (argv) => {
275
367
  configA: read.configA,
276
368
  configB: read.configB,
277
369
  ...corpus === void 0 ? {} : { corpus },
370
+ ...expectChanged === void 0 ? {} : { expectChanged },
278
371
  ...read.out === void 0 ? {} : { out: read.out },
279
372
  ...read.oxlint === void 0 ? {} : { oxlint: read.oxlint },
280
373
  paths: paths.length === 0 ? ["."] : paths
@@ -322,6 +415,7 @@ var parityOf = ({
322
415
  };
323
416
 
324
417
  export {
418
+ resolveOxlint,
325
419
  ParityError,
326
420
  normaliseFindings,
327
421
  readRun,
package/dist/index.d.ts CHANGED
@@ -1,3 +1,10 @@
1
+ /**
2
+ * The consumer's own oxlint, walking up from a directory, not one `npx` might fetch: a run under a
3
+ * different binary from the one the repo gates with compares two things nobody ships. Exported
4
+ * because every tool that spawns oxlint has to answer this, and a second copy of the answer is a
5
+ * second answer.
6
+ */
7
+ declare const resolveOxlint: (from: string) => string;
1
8
  /** A run that could not be read. Never a number — 0 findings against 0 findings is a PASS. */
2
9
  declare class ParityError extends Error {
3
10
  constructor(message: string);
@@ -50,11 +57,34 @@ declare const compareFindings: ({ a, b }: {
50
57
  }) => Parity;
51
58
  declare const formatSummary: (parity: Parity) => string;
52
59
  type RuleReach = {
60
+ /**
61
+ * The corpus saw this rule behave differently under the two configs — it started firing, stopped
62
+ * firing, or fired a different number of times. What a release CLAIMS about a rule is checked
63
+ * against this, and nothing else.
64
+ */
65
+ changed: boolean;
53
66
  firedInA: boolean;
54
67
  firedInB: boolean;
68
+ /** How many findings each side made, so a rule that gained a case is a change and not a shrug. */
69
+ foundInA: number;
70
+ foundInB: number;
71
+ rule: string;
72
+ };
73
+ /**
74
+ * One line of a release's claim, judged. `held` — claimed and shown; `unproven` — claimed and the
75
+ * corpus shows nothing; `unclaimed` — the corpus shows a change nobody named.
76
+ */
77
+ type CorpusClaim = {
78
+ changed: boolean;
79
+ claimed: boolean;
55
80
  rule: string;
81
+ verdict: 'held' | 'unclaimed' | 'unproven';
56
82
  };
57
83
  type CorpusReport = {
84
+ /** Empty when the run claimed nothing — a corpus run without a claim judges no claim. */
85
+ claims: CorpusClaim[];
86
+ /** False as soon as one claim is unproven or one change went unclaimed. */
87
+ claimsHeld: boolean;
58
88
  /** Rules no file in the corpus fires under either config — rules shipping with no evidence. */
59
89
  neither: string[];
60
90
  /**
@@ -105,10 +135,15 @@ declare const readManifest: (corpus: string) => CorpusManifest;
105
135
  * consumer inherits every one of those as a claim it cannot check. The corpus is the evidence, and
106
136
  * `neither` is the list that has to stay empty.
107
137
  */
108
- declare const corpusOf: ({ configA, configB, corpus, oxlint, }: {
138
+ declare const corpusOf: ({ configA, configB, corpus, expectChanged, oxlint, }: {
109
139
  configA: string;
110
140
  configB: string;
111
141
  corpus: string;
142
+ /**
143
+ * The rules a release SAYS it changes. Absent means no claim, and no claim is judged; present
144
+ * means the corpus must show exactly these rules changing and no others.
145
+ */
146
+ expectChanged?: string[];
112
147
  oxlint: string;
113
148
  }) => CorpusReport;
114
149
  declare const formatCorpus: (report: CorpusReport) => string;
@@ -121,6 +156,11 @@ type ParityArgs = {
121
156
  * default would weld this tool to whichever plugin happened to be beside it.
122
157
  */
123
158
  corpus?: string;
159
+ /**
160
+ * The rules a release claims it changes. Absent means the run claims nothing and judges nothing;
161
+ * an empty list is not expressible, because a claim about no rules is not a claim.
162
+ */
163
+ expectChanged?: string[];
124
164
  out?: string;
125
165
  oxlint?: string;
126
166
  paths: string[];
@@ -135,4 +175,4 @@ declare const parityOf: ({ configA, configB, cwd, out, oxlint, paths, }: {
135
175
  paths: string[];
136
176
  }) => Parity;
137
177
 
138
- export { type CorpusManifest, type CorpusReport, MANIFEST_FILE, type MessageChange, type Parity, type ParityArgs, ParityError, type RuleReach, type RuleTally, compareFindings, corpusOf, formatCorpus, formatSummary, manifestOf, normaliseFindings, parityOf, parseParityArgs, readManifest, readRun, ruleIdOf, rulesNamedBy };
178
+ export { type CorpusClaim, type CorpusManifest, type CorpusReport, MANIFEST_FILE, type MessageChange, type Parity, type ParityArgs, ParityError, type RuleReach, type RuleTally, compareFindings, corpusOf, formatCorpus, formatSummary, manifestOf, normaliseFindings, parityOf, parseParityArgs, readManifest, readRun, resolveOxlint, ruleIdOf, rulesNamedBy };
package/dist/index.js CHANGED
@@ -11,9 +11,10 @@ import {
11
11
  parseParityArgs,
12
12
  readManifest,
13
13
  readRun,
14
+ resolveOxlint,
14
15
  ruleIdOf,
15
16
  rulesNamedBy
16
- } from "./chunk-OLCJQ6JC.js";
17
+ } from "./chunk-3PCNMLVE.js";
17
18
  export {
18
19
  MANIFEST_FILE,
19
20
  ParityError,
@@ -27,6 +28,7 @@ export {
27
28
  parseParityArgs,
28
29
  readManifest,
29
30
  readRun,
31
+ resolveOxlint,
30
32
  ruleIdOf,
31
33
  rulesNamedBy
32
34
  };
@@ -3,14 +3,16 @@ import {
3
3
  formatCorpus,
4
4
  formatSummary,
5
5
  parityOf,
6
- parseParityArgs
7
- } from "./chunk-OLCJQ6JC.js";
6
+ parseParityArgs,
7
+ resolveOxlint
8
+ } from "./chunk-3PCNMLVE.js";
8
9
 
9
10
  // src/parity-cli.ts
10
- import { existsSync, mkdirSync, writeFileSync } from "fs";
11
- import { dirname, join, resolve } from "path";
11
+ import { mkdirSync, writeFileSync } from "fs";
12
+ import { join, resolve } from "path";
12
13
  var USAGE = `geonosis-lint-parity --a <config-a.json> --b <config-b.json> [--oxlint <path>] [--out <dir>] -- <paths\u2026>
13
14
  geonosis-lint-parity --corpus <dir> --a <config-a.json> --b <config-b.json> [--out <dir>]
15
+ [--expect-changed <rule,rule,\u2026>]
14
16
 
15
17
  Runs oxlint twice over the same paths under two configs and diffs the findings. Exits 1 when a
16
18
  finding config A reported is missing under config B \u2014 a rule that fired before and fires nowhere
@@ -23,20 +25,16 @@ is required: this tool ships no corpus, because a corpus is one plugin's evidenc
23
25
  one a plugin ships, e.g. node_modules/<plugin>/corpus. A corpus names the rules it speaks for in
24
26
  its own manifest.json, and rules outside that list are reported unjudged.
25
27
 
28
+ --expect-changed names the rules a release CLAIMS it changes, and exits 1 unless the corpus shows
29
+ exactly those rules behaving differently under the two configs \u2014 no claim the corpus is silent
30
+ about, no change the release never mentioned. Run it for every changelog line that names a rule: a
31
+ release once claimed a widening for ten rules with corpus evidence for two, and the parity run that
32
+ "proved" it went over a tree with no such violation in it.
33
+
26
34
  oxlint resolves a jsPlugins specifier relative to the CONFIG FILE's directory, not the working
27
35
  directory. Write both configs INSIDE the workspace being linted, or name the plugin by absolute
28
36
  path \u2014 a config in a temp dir cannot find a plugin installed in the workspace, and the run is
29
37
  refused rather than counted as zero findings.`;
30
- var resolveOxlint = (from) => {
31
- let dir = from;
32
- for (; ; ) {
33
- const bin = resolve(dir, "node_modules/.bin/oxlint");
34
- if (existsSync(bin)) return bin;
35
- const parent = dirname(dir);
36
- if (parent === dir) return "oxlint";
37
- dir = parent;
38
- }
39
- };
40
38
  var main = () => {
41
39
  const argv = process.argv.slice(2);
42
40
  if (argv.includes("--help") || argv.includes("-h") || argv.length === 0) {
@@ -51,7 +49,13 @@ var main = () => {
51
49
  const oxlint = args.oxlint ?? resolveOxlint(cwd);
52
50
  if (args.corpus !== void 0) {
53
51
  const corpus = resolve(cwd, args.corpus);
54
- const report = corpusOf({ configA, configB, corpus, oxlint });
52
+ const report = corpusOf({
53
+ configA,
54
+ configB,
55
+ corpus,
56
+ ...args.expectChanged === void 0 ? {} : { expectChanged: args.expectChanged },
57
+ oxlint
58
+ });
55
59
  const text = formatCorpus(report);
56
60
  process.stdout.write(`${text}
57
61
  `);
@@ -60,7 +64,7 @@ var main = () => {
60
64
  mkdirSync(out, { recursive: true });
61
65
  writeFileSync(join(out, "CORPUS.md"), text);
62
66
  }
63
- return report.neither.length > 0 ? 1 : 0;
67
+ return report.neither.length > 0 || !report.claimsHeld ? 1 : 0;
64
68
  }
65
69
  const parity = parityOf({
66
70
  configA,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geonosis/lint-parity",
3
- "version": "0.4.0",
3
+ "version": "1.0.0",
4
4
  "description": "Findings parity and rule reach over any two oxlint configs — the diff a fork is deleted on.",
5
5
  "keywords": [
6
6
  "oxlint",