@geonosis/lint-parity 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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,
@@ -152,6 +152,27 @@ var manifestOf = (plugin) => ({
152
152
  plugin: plugin.meta.name,
153
153
  rules: Object.keys(plugin.rules).map((rule) => `${plugin.meta.name}/${rule}`).toSorted()
154
154
  });
155
+ var namesRule = (claim, rule) => {
156
+ if (claim === rule) return true;
157
+ const at = rule.indexOf("/");
158
+ return at !== -1 && rule.slice(at + 1) === claim;
159
+ };
160
+ var claimsOver = (reach, expectChanged) => {
161
+ if (expectChanged === void 0) return [];
162
+ const claimed = (rule) => expectChanged.some((one) => namesRule(one, rule));
163
+ const judged = reach.filter((one) => one.changed || claimed(one.rule)).map((one) => ({
164
+ changed: one.changed,
165
+ claimed: claimed(one.rule),
166
+ rule: one.rule,
167
+ verdict: verdictOf(one.changed, claimed(one.rule))
168
+ }));
169
+ const outsideReach = expectChanged.filter((claim) => !reach.some((one) => namesRule(claim, one.rule))).map((rule) => ({ changed: false, claimed: true, rule, verdict: "unproven" }));
170
+ return [...judged, ...outsideReach];
171
+ };
172
+ var verdictOf = (changed, claimed) => {
173
+ if (changed && claimed) return "held";
174
+ return claimed ? "unproven" : "unclaimed";
175
+ };
155
176
  var readManifest = (corpus) => {
156
177
  const path = join(corpus, MANIFEST_FILE);
157
178
  let parsed;
@@ -170,14 +191,16 @@ var corpusOf = ({
170
191
  configA,
171
192
  configB,
172
193
  corpus,
194
+ expectChanged,
173
195
  oxlint
174
196
  }) => {
175
197
  const manifest = readManifest(corpus);
176
198
  const exercised = new Set(manifest.rules);
177
199
  const root = mkdtempSync(join(tmpdir(), "geonosis-corpus-"));
178
200
  const here = join(root, "corpus");
179
- const firedIn = (config, side) => new Set(
180
- readRun(
201
+ const foundIn = (config, side) => {
202
+ const counts = /* @__PURE__ */ new Map();
203
+ for (const finding of readRun(
181
204
  lint({
182
205
  config,
183
206
  cwd: root,
@@ -187,29 +210,67 @@ var corpusOf = ({
187
210
  }),
188
211
  root,
189
212
  side
190
- ).map(ruleIdOf)
191
- );
192
- let inA = /* @__PURE__ */ new Set();
193
- let inB = /* @__PURE__ */ new Set();
213
+ )) {
214
+ const rule = ruleIdOf(finding);
215
+ counts.set(rule, (counts.get(rule) ?? 0) + 1);
216
+ }
217
+ return counts;
218
+ };
219
+ let inA = /* @__PURE__ */ new Map();
220
+ let inB = /* @__PURE__ */ new Map();
194
221
  try {
195
222
  cpSync(corpus, here, { recursive: true });
196
- inA = firedIn(configA, "A");
197
- inB = firedIn(configB, "B");
223
+ inA = foundIn(configA, "A");
224
+ inB = foundIn(configB, "B");
198
225
  } finally {
199
226
  rmSync(root, { force: true, recursive: true });
200
227
  }
201
228
  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 }));
229
+ const reach = configured.filter((rule) => exercised.has(rule)).map((rule) => {
230
+ const foundInA = inA.get(rule) ?? 0;
231
+ const foundInB = inB.get(rule) ?? 0;
232
+ return {
233
+ changed: foundInA !== foundInB,
234
+ firedInA: foundInA > 0,
235
+ firedInB: foundInB > 0,
236
+ foundInA,
237
+ foundInB,
238
+ rule
239
+ };
240
+ });
241
+ const claims = claimsOver(reach, expectChanged);
203
242
  return {
243
+ claims,
244
+ claimsHeld: claims.every((one) => one.verdict === "held"),
204
245
  neither: reach.filter((one) => !one.firedInA && !one.firedInB).map((one) => one.rule),
205
246
  outside: configured.filter((rule) => !exercised.has(rule)),
206
247
  plugin: manifest.plugin,
207
248
  reach
208
249
  };
209
250
  };
251
+ var VERDICTS = {
252
+ held: "HELD",
253
+ unclaimed: "UNCLAIMED \u2014 the corpus shows this changing and the release did not say so",
254
+ unproven: "UNPROVEN \u2014 claimed, and the corpus shows no change"
255
+ };
256
+ var claimsSection = (report) => {
257
+ if (report.claims.length === 0) return [];
258
+ return [
259
+ "## claims",
260
+ "",
261
+ 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.",
262
+ "",
263
+ "| rule | claimed | observed | verdict |",
264
+ "|---|---|---|---|",
265
+ ...report.claims.map(
266
+ (one) => `| \`${one.rule}\` | ${one.claimed ? "changed" : "\u2014"} | ${one.changed ? "changed" : "unchanged"} | ${VERDICTS[one.verdict]} |`
267
+ ),
268
+ ""
269
+ ];
270
+ };
210
271
  var formatCorpus = (report) => {
211
272
  const rows = report.reach.map(
212
- (one) => `| \`${one.rule}\` | ${one.firedInA ? "yes" : "NO"} | ${one.firedInB ? "yes" : "NO"} |`
273
+ (one) => `| \`${one.rule}\` | ${one.firedInA ? "yes" : "NO"} (${one.foundInA}) | ${one.firedInB ? "yes" : "NO"} (${one.foundInB}) |`
213
274
  );
214
275
  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
276
  `;
@@ -223,9 +284,10 @@ var formatCorpus = (report) => {
223
284
  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
285
  ${report.neither.map((rule) => `- \`${rule}\``).join("\n")}`,
225
286
  "",
287
+ ...claimsSection(report),
226
288
  "## per rule",
227
289
  "",
228
- "| rule | fired under A | fired under B |",
290
+ "| rule | fired under A (findings) | fired under B (findings) |",
229
291
  "|---|---|---|",
230
292
  ...rows,
231
293
  "",
@@ -239,12 +301,14 @@ ${report.neither.map((rule) => `- \`${rule}\``).join("\n")}`,
239
301
  };
240
302
  var FLAGS = { "--a": "configA", "--b": "configB", "--out": "out", "--oxlint": "oxlint" };
241
303
  var CORPUS_FLAG = "--corpus";
304
+ var CLAIM_FLAG = "--expect-changed";
242
305
  var parseParityArgs = (argv) => {
243
306
  const at = argv.indexOf("--");
244
307
  const flags = at === -1 ? argv : argv.slice(0, at);
245
308
  const paths = at === -1 ? [] : argv.slice(at + 1);
246
309
  const read = {};
247
310
  let corpus;
311
+ let expectChanged;
248
312
  for (let index = 0; index < flags.length; index += 1) {
249
313
  const flag = flags[index];
250
314
  const value = flags[index + 1];
@@ -258,6 +322,16 @@ var parseParityArgs = (argv) => {
258
322
  index += 1;
259
323
  continue;
260
324
  }
325
+ if (flag === CLAIM_FLAG) {
326
+ if (value === void 0 || value.startsWith("--")) {
327
+ throw new Error(
328
+ `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`
329
+ );
330
+ }
331
+ expectChanged = value.split(",").map((one) => one.trim()).filter((one) => one !== "");
332
+ index += 1;
333
+ continue;
334
+ }
261
335
  const name = FLAGS[flag];
262
336
  if (name === void 0) throw new Error(`geonosis-lint-parity: unknown argument "${flag}"`);
263
337
  if (value === void 0 || value.startsWith("--")) {
@@ -275,6 +349,7 @@ var parseParityArgs = (argv) => {
275
349
  configA: read.configA,
276
350
  configB: read.configB,
277
351
  ...corpus === void 0 ? {} : { corpus },
352
+ ...expectChanged === void 0 ? {} : { expectChanged },
278
353
  ...read.out === void 0 ? {} : { out: read.out },
279
354
  ...read.oxlint === void 0 ? {} : { oxlint: read.oxlint },
280
355
  paths: paths.length === 0 ? ["."] : paths
package/dist/index.d.ts CHANGED
@@ -50,11 +50,34 @@ declare const compareFindings: ({ a, b }: {
50
50
  }) => Parity;
51
51
  declare const formatSummary: (parity: Parity) => string;
52
52
  type RuleReach = {
53
+ /**
54
+ * The corpus saw this rule behave differently under the two configs — it started firing, stopped
55
+ * firing, or fired a different number of times. What a release CLAIMS about a rule is checked
56
+ * against this, and nothing else.
57
+ */
58
+ changed: boolean;
53
59
  firedInA: boolean;
54
60
  firedInB: boolean;
61
+ /** How many findings each side made, so a rule that gained a case is a change and not a shrug. */
62
+ foundInA: number;
63
+ foundInB: number;
64
+ rule: string;
65
+ };
66
+ /**
67
+ * One line of a release's claim, judged. `held` — claimed and shown; `unproven` — claimed and the
68
+ * corpus shows nothing; `unclaimed` — the corpus shows a change nobody named.
69
+ */
70
+ type CorpusClaim = {
71
+ changed: boolean;
72
+ claimed: boolean;
55
73
  rule: string;
74
+ verdict: 'held' | 'unclaimed' | 'unproven';
56
75
  };
57
76
  type CorpusReport = {
77
+ /** Empty when the run claimed nothing — a corpus run without a claim judges no claim. */
78
+ claims: CorpusClaim[];
79
+ /** False as soon as one claim is unproven or one change went unclaimed. */
80
+ claimsHeld: boolean;
58
81
  /** Rules no file in the corpus fires under either config — rules shipping with no evidence. */
59
82
  neither: string[];
60
83
  /**
@@ -105,10 +128,15 @@ declare const readManifest: (corpus: string) => CorpusManifest;
105
128
  * consumer inherits every one of those as a claim it cannot check. The corpus is the evidence, and
106
129
  * `neither` is the list that has to stay empty.
107
130
  */
108
- declare const corpusOf: ({ configA, configB, corpus, oxlint, }: {
131
+ declare const corpusOf: ({ configA, configB, corpus, expectChanged, oxlint, }: {
109
132
  configA: string;
110
133
  configB: string;
111
134
  corpus: string;
135
+ /**
136
+ * The rules a release SAYS it changes. Absent means no claim, and no claim is judged; present
137
+ * means the corpus must show exactly these rules changing and no others.
138
+ */
139
+ expectChanged?: string[];
112
140
  oxlint: string;
113
141
  }) => CorpusReport;
114
142
  declare const formatCorpus: (report: CorpusReport) => string;
@@ -121,6 +149,11 @@ type ParityArgs = {
121
149
  * default would weld this tool to whichever plugin happened to be beside it.
122
150
  */
123
151
  corpus?: string;
152
+ /**
153
+ * The rules a release claims it changes. Absent means the run claims nothing and judges nothing;
154
+ * an empty list is not expressible, because a claim about no rules is not a claim.
155
+ */
156
+ expectChanged?: string[];
124
157
  out?: string;
125
158
  oxlint?: string;
126
159
  paths: string[];
@@ -135,4 +168,4 @@ declare const parityOf: ({ configA, configB, cwd, out, oxlint, paths, }: {
135
168
  paths: string[];
136
169
  }) => Parity;
137
170
 
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 };
171
+ 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, ruleIdOf, rulesNamedBy };
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  readRun,
14
14
  ruleIdOf,
15
15
  rulesNamedBy
16
- } from "./chunk-OLCJQ6JC.js";
16
+ } from "./chunk-YPSGSLHS.js";
17
17
  export {
18
18
  MANIFEST_FILE,
19
19
  ParityError,
@@ -4,13 +4,14 @@ import {
4
4
  formatSummary,
5
5
  parityOf,
6
6
  parseParityArgs
7
- } from "./chunk-OLCJQ6JC.js";
7
+ } from "./chunk-YPSGSLHS.js";
8
8
 
9
9
  // src/parity-cli.ts
10
10
  import { existsSync, mkdirSync, writeFileSync } from "fs";
11
11
  import { dirname, join, resolve } from "path";
12
12
  var USAGE = `geonosis-lint-parity --a <config-a.json> --b <config-b.json> [--oxlint <path>] [--out <dir>] -- <paths\u2026>
13
13
  geonosis-lint-parity --corpus <dir> --a <config-a.json> --b <config-b.json> [--out <dir>]
14
+ [--expect-changed <rule,rule,\u2026>]
14
15
 
15
16
  Runs oxlint twice over the same paths under two configs and diffs the findings. Exits 1 when a
16
17
  finding config A reported is missing under config B \u2014 a rule that fired before and fires nowhere
@@ -23,6 +24,12 @@ is required: this tool ships no corpus, because a corpus is one plugin's evidenc
23
24
  one a plugin ships, e.g. node_modules/<plugin>/corpus. A corpus names the rules it speaks for in
24
25
  its own manifest.json, and rules outside that list are reported unjudged.
25
26
 
27
+ --expect-changed names the rules a release CLAIMS it changes, and exits 1 unless the corpus shows
28
+ exactly those rules behaving differently under the two configs \u2014 no claim the corpus is silent
29
+ about, no change the release never mentioned. Run it for every changelog line that names a rule: a
30
+ release once claimed a widening for ten rules with corpus evidence for two, and the parity run that
31
+ "proved" it went over a tree with no such violation in it.
32
+
26
33
  oxlint resolves a jsPlugins specifier relative to the CONFIG FILE's directory, not the working
27
34
  directory. Write both configs INSIDE the workspace being linted, or name the plugin by absolute
28
35
  path \u2014 a config in a temp dir cannot find a plugin installed in the workspace, and the run is
@@ -51,7 +58,13 @@ var main = () => {
51
58
  const oxlint = args.oxlint ?? resolveOxlint(cwd);
52
59
  if (args.corpus !== void 0) {
53
60
  const corpus = resolve(cwd, args.corpus);
54
- const report = corpusOf({ configA, configB, corpus, oxlint });
61
+ const report = corpusOf({
62
+ configA,
63
+ configB,
64
+ corpus,
65
+ ...args.expectChanged === void 0 ? {} : { expectChanged: args.expectChanged },
66
+ oxlint
67
+ });
55
68
  const text = formatCorpus(report);
56
69
  process.stdout.write(`${text}
57
70
  `);
@@ -60,7 +73,7 @@ var main = () => {
60
73
  mkdirSync(out, { recursive: true });
61
74
  writeFileSync(join(out, "CORPUS.md"), text);
62
75
  }
63
- return report.neither.length > 0 ? 1 : 0;
76
+ return report.neither.length > 0 || !report.claimsHeld ? 1 : 0;
64
77
  }
65
78
  const parity = parityOf({
66
79
  configA,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geonosis/lint-parity",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",