@geonosis/lint-parity 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,100 @@
1
+ // src/envelope.ts
2
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
3
+ import { dirname, join } from "path";
4
+ import { fileURLToPath } from "url";
5
+ var ENVELOPES_DIR = ".geonosis/envelopes";
6
+ var envelopePath = (root, tool) => join(root, ENVELOPES_DIR, `${tool}.json`);
7
+ var UnbalancedEnvelope = class extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "UnbalancedEnvelope";
11
+ }
12
+ };
13
+ var isCount = (value) => Number.isSafeInteger(value) && value >= 0;
14
+ var unbalancedMessage = (envelope, next) => `${envelope.tool}: considered ${envelope.considered} but accounts for ${envelope.read + envelope.refused.length + envelope.excused.length} \u2014 ${envelope.read} read + ${envelope.refused.length} refused + ${envelope.excused.length} excused. A run that has lost count of its own inputs cannot say what it measured, so no verdict was rendered and no envelope was written. Next: ${next}`;
15
+ var writeEnvelope = ({
16
+ envelope,
17
+ next,
18
+ root
19
+ }) => {
20
+ if (envelope.tool.trim() === "") {
21
+ throw new UnbalancedEnvelope(
22
+ `an envelope with no tool name cannot be filed or reported against. Next: ${next}`
23
+ );
24
+ }
25
+ if (envelope.version.trim() === "") {
26
+ throw new UnbalancedEnvelope(
27
+ `${envelope.tool}: an envelope that cannot name the build that wrote it dates nothing, and a stale one reads exactly like a fresh one. Next: ${next}`
28
+ );
29
+ }
30
+ if (!isCount(envelope.considered) || !isCount(envelope.read)) {
31
+ throw new UnbalancedEnvelope(
32
+ `${envelope.tool}: considered ${envelope.considered} and read ${envelope.read} \u2014 a census is a whole number of things, and arithmetic over anything else balances by accident. Next: ${next}`
33
+ );
34
+ }
35
+ if (envelope.considered !== envelope.read + envelope.refused.length + envelope.excused.length) {
36
+ throw new UnbalancedEnvelope(unbalancedMessage(envelope, next));
37
+ }
38
+ const at = envelopePath(root, envelope.tool);
39
+ mkdirSync(dirname(at), { recursive: true });
40
+ writeFileSync(at, `${JSON.stringify(envelope, void 0, 2)}
41
+ `);
42
+ return at;
43
+ };
44
+ var UNKNOWN = "unknown";
45
+ var versionIn = (dir) => {
46
+ try {
47
+ const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
48
+ return typeof manifest.version === "string" ? manifest.version : void 0;
49
+ } catch {
50
+ return void 0;
51
+ }
52
+ };
53
+ var versionOf = (moduleUrl) => {
54
+ let dir = dirname(fileURLToPath(moduleUrl));
55
+ for (; ; ) {
56
+ const found = versionIn(dir);
57
+ if (found !== void 0) return found;
58
+ const up = dirname(dir);
59
+ if (up === dir) return UNKNOWN;
60
+ dir = up;
61
+ }
62
+ };
63
+ var PARITY_TOOL = "lint-parity";
64
+ var PARITY_NEXT = "oxlint --debug=files --config <each config> <paths> and diff the two lists \u2014 a parity run only compares the files BOTH configs opened, and the ones only one of them opened are the silence this refuses over";
65
+ var parityEnvelope = (census, findings, durationMs) => ({
66
+ considered: census.both.length + census.onlyInA.length + census.onlyInB.length,
67
+ durationMs,
68
+ excused: [
69
+ ...census.onlyInA.map((path) => ({
70
+ path,
71
+ reason: "config B would not lint this file, so the two runs cannot be compared over it"
72
+ })),
73
+ ...census.onlyInB.map((path) => ({
74
+ path,
75
+ reason: "config A would not lint this file, so the two runs cannot be compared over it"
76
+ }))
77
+ ],
78
+ findings,
79
+ read: census.both.length,
80
+ refused: [],
81
+ tool: PARITY_TOOL,
82
+ version: versionOf(import.meta.url)
83
+ });
84
+
1
85
  // src/parity.ts
2
86
  import { spawnSync } from "child_process";
3
87
  import {
4
88
  cpSync,
5
89
  existsSync,
6
- mkdirSync,
90
+ mkdirSync as mkdirSync2,
7
91
  mkdtempSync,
8
- readFileSync,
92
+ readFileSync as readFileSync2,
9
93
  rmSync,
10
- writeFileSync
94
+ writeFileSync as writeFileSync2
11
95
  } from "fs";
12
96
  import { tmpdir } from "os";
13
- import { dirname, join, resolve } from "path";
97
+ import { dirname as dirname2, join as join2, resolve } from "path";
14
98
  var ANSI = /\[[0-9;]*m/g;
15
99
  var SUMMARY_COUNT = /^(\d+) problems?$/m;
16
100
  var RULE_IN_LINE = /\[(?:Error|Warning)\/([^\]]+)\]$/;
@@ -21,7 +105,7 @@ var resolveOxlint = (from) => {
21
105
  for (; ; ) {
22
106
  const bin = resolve(dir, "node_modules/.bin/oxlint");
23
107
  if (existsSync(bin)) return bin;
24
- const parent = dirname(dir);
108
+ const parent = dirname2(dir);
25
109
  if (parent === dir) return "oxlint";
26
110
  dir = parent;
27
111
  }
@@ -156,8 +240,13 @@ var formatSummary = (parity) => {
156
240
  ].join("\n");
157
241
  };
158
242
  var rulesNamedBy = (config) => {
159
- const parsed = JSON.parse(readFileSync(config, "utf8"));
160
- return Object.keys(parsed.rules ?? {});
243
+ const parsed = JSON.parse(readFileSync2(config, "utf8"));
244
+ return [
245
+ .../* @__PURE__ */ new Set([
246
+ ...Object.keys(parsed.rules ?? {}),
247
+ ...(parsed.overrides ?? []).flatMap((one) => Object.keys(one.rules ?? {}))
248
+ ])
249
+ ];
161
250
  };
162
251
  var ruleIdOf = (line) => {
163
252
  const token = RULE_IN_LINE.exec(line)?.[1];
@@ -192,10 +281,10 @@ var verdictOf = (changed, claimed) => {
192
281
  return claimed ? "unproven" : "unclaimed";
193
282
  };
194
283
  var readManifest = (corpus) => {
195
- const path = join(corpus, MANIFEST_FILE);
284
+ const path = join2(corpus, MANIFEST_FILE);
196
285
  let parsed;
197
286
  try {
198
- parsed = JSON.parse(readFileSync(path, "utf8"));
287
+ parsed = JSON.parse(readFileSync2(path, "utf8"));
199
288
  } catch (error) {
200
289
  throw new ParityError(`could not read ${path}: ${error.message}`);
201
290
  }
@@ -214,8 +303,8 @@ var corpusOf = ({
214
303
  }) => {
215
304
  const manifest = readManifest(corpus);
216
305
  const exercised = new Set(manifest.rules);
217
- const root = mkdtempSync(join(tmpdir(), "geonosis-corpus-"));
218
- const here = join(root, "corpus");
306
+ const root = mkdtempSync(join2(tmpdir(), "geonosis-corpus-"));
307
+ const here = join2(root, "corpus");
219
308
  const foundIn = (config, side) => {
220
309
  const counts = /* @__PURE__ */ new Map();
221
310
  for (const finding of readRun(
@@ -390,6 +479,44 @@ var lint = ({
390
479
  throw new ParityError(`could not run ${oxlint}: ${run.error.message}`);
391
480
  return { code: run.status ?? -1, output: `${run.stdout ?? ""}${run.stderr ?? ""}` };
392
481
  };
482
+ var filesLintedBy = ({
483
+ config,
484
+ cwd,
485
+ oxlint,
486
+ paths
487
+ }) => {
488
+ const run = spawnSync(oxlint, ["--debug=files", "--config", config, ...paths], {
489
+ cwd,
490
+ encoding: "utf8",
491
+ env: { ...process.env, NO_COLOR: "1" },
492
+ maxBuffer: 64 * 1024 * 1024
493
+ });
494
+ if (run.error !== void 0) {
495
+ throw new ParityError(`could not run ${oxlint}: ${run.error.message}`);
496
+ }
497
+ if (run.status !== 0) {
498
+ throw new ParityError(
499
+ `oxlint --debug=files exited ${String(run.status)} under ${config}, so which files it would read is not known:
500
+ ${`${run.stdout ?? ""}${run.stderr ?? ""}`.trim()}`
501
+ );
502
+ }
503
+ return (run.stdout ?? "").split("\n").map((line) => line.trim().replace(`${cwd}/`, "").replace(/^\.\//, "")).filter((line) => line !== "").toSorted();
504
+ };
505
+ var fileCensus = ({
506
+ configA,
507
+ configB,
508
+ cwd,
509
+ oxlint,
510
+ paths
511
+ }) => {
512
+ const inA = filesLintedBy({ config: configA, cwd, oxlint, paths });
513
+ const inB = new Set(filesLintedBy({ config: configB, cwd, oxlint, paths }));
514
+ return {
515
+ both: inA.filter((file) => inB.has(file)),
516
+ onlyInA: inA.filter((file) => !inB.has(file)),
517
+ onlyInB: [...inB].filter((file) => !inA.includes(file)).toSorted()
518
+ };
519
+ };
393
520
  var parityOf = ({
394
521
  configA,
395
522
  configB,
@@ -403,18 +530,21 @@ var parityOf = ({
403
530
  b: readRun(lint({ config: configB, cwd, oxlint, paths }), cwd, "B")
404
531
  });
405
532
  if (out !== void 0) {
406
- mkdirSync(out, { recursive: true });
407
- writeFileSync(join(out, "a.txt"), asFile(parity.a));
408
- writeFileSync(join(out, "b.txt"), asFile(parity.b));
409
- writeFileSync(join(out, "only-in-a.txt"), asFile(parity.onlyInA));
410
- writeFileSync(join(out, "only-in-b.txt"), asFile(parity.onlyInB));
411
- writeFileSync(join(out, "changed.txt"), asFile(changedLines(parity.messageChanged)));
412
- writeFileSync(join(out, "SUMMARY.md"), formatSummary(parity));
533
+ mkdirSync2(out, { recursive: true });
534
+ writeFileSync2(join2(out, "a.txt"), asFile(parity.a));
535
+ writeFileSync2(join2(out, "b.txt"), asFile(parity.b));
536
+ writeFileSync2(join2(out, "only-in-a.txt"), asFile(parity.onlyInA));
537
+ writeFileSync2(join2(out, "only-in-b.txt"), asFile(parity.onlyInB));
538
+ writeFileSync2(join2(out, "changed.txt"), asFile(changedLines(parity.messageChanged)));
539
+ writeFileSync2(join2(out, "SUMMARY.md"), formatSummary(parity));
413
540
  }
414
541
  return parity;
415
542
  };
416
543
 
417
544
  export {
545
+ writeEnvelope,
546
+ PARITY_NEXT,
547
+ parityEnvelope,
418
548
  resolveOxlint,
419
549
  ParityError,
420
550
  normaliseFindings,
@@ -429,5 +559,7 @@ export {
429
559
  corpusOf,
430
560
  formatCorpus,
431
561
  parseParityArgs,
562
+ filesLintedBy,
563
+ fileCensus,
432
564
  parityOf
433
565
  };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,10 @@
1
+ /** The files each config would actually read, split three ways. */
2
+ type FileCensus = {
3
+ both: string[];
4
+ onlyInA: string[];
5
+ onlyInB: string[];
6
+ };
7
+
1
8
  /**
2
9
  * The consumer's own oxlint, walking up from a directory, not one `npx` might fetch: a run under a
3
10
  * different binary from the one the repo gates with compares two things nobody ships. Exported
@@ -97,7 +104,15 @@ type CorpusReport = {
97
104
  plugin: string;
98
105
  reach: RuleReach[];
99
106
  };
100
- /** The rule ids an oxlint config enables, exactly as it wrote them — plugin prefix included. */
107
+ /**
108
+ * The rule ids an oxlint config enables, exactly as it wrote them — plugin prefix included, and
109
+ * from `overrides[]` as well as from `rules`.
110
+ *
111
+ * A config's base block is only its first layer: an overrides entry may name a rule the base block
112
+ * never mentions, and oxlint runs it on every file that entry's globs claim. Reading `rules` alone
113
+ * left such a rule out of the reach scope entirely — it fired, and the report about which rules
114
+ * reach anything did not have a row for it.
115
+ */
101
116
  declare const rulesNamedBy: (config: string) => string[];
102
117
  /**
103
118
  * The plugin-qualified id behind a finding's `plugin(rule)` token, in the `plugin/rule` shape a
@@ -166,6 +181,35 @@ type ParityArgs = {
166
181
  paths: string[];
167
182
  };
168
183
  declare const parseParityArgs: (argv: string[]) => ParityArgs;
184
+ /**
185
+ * The files a config would actually lint, asked of oxlint itself.
186
+ *
187
+ * `--debug=files` prints one path per line and exits 0 without linting anything: measured here,
188
+ * `--config narrow.json` (whose `ignorePatterns` name `b.ts`) printed one file where
189
+ * `--config wide.json` printed two. Nothing else in a `--format=unix` run carries this — that
190
+ * output is findings and only findings, which is why a parity run could report agreement over a
191
+ * tree the second config never opened.
192
+ *
193
+ * The argv is deliberately the same SHAPE `lint` uses, config and paths passed through exactly as
194
+ * they arrived. It has to be: oxlint's ignore resolution is sensitive to that shape — an absolute
195
+ * `--config` with a relative path argument applies no `ignorePatterns` at all, where the same
196
+ * config named relatively does. A census taken under different arguments would describe a run
197
+ * nobody made, which is the failure this whole envelope is about.
198
+ */
199
+ declare const filesLintedBy: ({ config, cwd, oxlint, paths, }: {
200
+ config: string;
201
+ cwd: string;
202
+ oxlint: string;
203
+ paths: string[];
204
+ }) => string[];
205
+ /** Which files both configs would read, and which each would read alone. */
206
+ declare const fileCensus: ({ configA, configB, cwd, oxlint, paths, }: {
207
+ configA: string;
208
+ configB: string;
209
+ cwd: string;
210
+ oxlint: string;
211
+ paths: string[];
212
+ }) => FileCensus;
169
213
  declare const parityOf: ({ configA, configB, cwd, out, oxlint, paths, }: {
170
214
  configA: string;
171
215
  configB: string;
@@ -175,4 +219,4 @@ declare const parityOf: ({ configA, configB, cwd, out, oxlint, paths, }: {
175
219
  paths: string[];
176
220
  }) => Parity;
177
221
 
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 };
222
+ export { type CorpusClaim, type CorpusManifest, type CorpusReport, type FileCensus, MANIFEST_FILE, type MessageChange, type Parity, type ParityArgs, ParityError, type RuleReach, type RuleTally, compareFindings, corpusOf, fileCensus, filesLintedBy, formatCorpus, formatSummary, manifestOf, normaliseFindings, parityOf, parseParityArgs, readManifest, readRun, resolveOxlint, ruleIdOf, rulesNamedBy };
package/dist/index.js CHANGED
@@ -3,6 +3,8 @@ import {
3
3
  ParityError,
4
4
  compareFindings,
5
5
  corpusOf,
6
+ fileCensus,
7
+ filesLintedBy,
6
8
  formatCorpus,
7
9
  formatSummary,
8
10
  manifestOf,
@@ -14,12 +16,14 @@ import {
14
16
  resolveOxlint,
15
17
  ruleIdOf,
16
18
  rulesNamedBy
17
- } from "./chunk-3PCNMLVE.js";
19
+ } from "./chunk-D7OGKQ2C.js";
18
20
  export {
19
21
  MANIFEST_FILE,
20
22
  ParityError,
21
23
  compareFindings,
22
24
  corpusOf,
25
+ fileCensus,
26
+ filesLintedBy,
23
27
  formatCorpus,
24
28
  formatSummary,
25
29
  manifestOf,
@@ -1,11 +1,15 @@
1
1
  import {
2
+ PARITY_NEXT,
2
3
  corpusOf,
4
+ fileCensus,
3
5
  formatCorpus,
4
6
  formatSummary,
7
+ parityEnvelope,
5
8
  parityOf,
6
9
  parseParityArgs,
7
- resolveOxlint
8
- } from "./chunk-3PCNMLVE.js";
10
+ resolveOxlint,
11
+ writeEnvelope
12
+ } from "./chunk-D7OGKQ2C.js";
9
13
 
10
14
  // src/parity-cli.ts
11
15
  import { mkdirSync, writeFileSync } from "fs";
@@ -37,6 +41,16 @@ path \u2014 a config in a temp dir cannot find a plugin installed in the workspa
37
41
  refused rather than counted as zero findings.`;
38
42
  var main = () => {
39
43
  const argv = process.argv.slice(2);
44
+ if (argv.includes("--print-config-shape")) {
45
+ process.stdout.write(
46
+ `geonosis-lint-parity reads no configuration file.
47
+
48
+ The two configs it compares are .oxlintrc.json files named on the command line with --a and --b,
49
+ and it reads them as oxlint does rather than interpreting them itself.
50
+ `
51
+ );
52
+ return 0;
53
+ }
40
54
  if (argv.includes("--help") || argv.includes("-h") || argv.length === 0) {
41
55
  process.stdout.write(`${USAGE}
42
56
  `);
@@ -66,6 +80,7 @@ var main = () => {
66
80
  }
67
81
  return report.neither.length > 0 || !report.claimsHeld ? 1 : 0;
68
82
  }
83
+ const startedAt = Date.now();
69
84
  const parity = parityOf({
70
85
  configA,
71
86
  configB,
@@ -75,6 +90,17 @@ var main = () => {
75
90
  paths: args.paths
76
91
  });
77
92
  process.stdout.write(`${formatSummary(parity)}
93
+ `);
94
+ const at = writeEnvelope({
95
+ envelope: parityEnvelope(
96
+ fileCensus({ configA, configB, cwd, oxlint, paths: args.paths }),
97
+ parity.onlyInA,
98
+ Date.now() - startedAt
99
+ ),
100
+ next: PARITY_NEXT,
101
+ root: cwd
102
+ });
103
+ process.stdout.write(`envelope: ${at}
78
104
  `);
79
105
  return parity.lost ? 1 : 0;
80
106
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geonosis/lint-parity",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "types": "./dist/index.d.ts",
5
5
  "description": "Findings parity and rule reach over any two oxlint configs — the diff a fork is deleted on.",
6
6
  "keywords": [
@@ -43,6 +43,9 @@
43
43
  "publishConfig": {
44
44
  "access": "public"
45
45
  },
46
+ "devDependencies": {
47
+ "@geonosis/ratchet": "1.4.0"
48
+ },
46
49
  "scripts": {
47
50
  "build": "tsup",
48
51
  "typecheck": "tsc --noEmit"