@geonosis/lint-parity 2.1.0 → 2.3.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
@@ -1,5 +1,8 @@
1
1
  # @geonosis/lint-parity
2
2
 
3
+ Through the front door: `geonosis lint-parity` — the metapackage pins this and every other kit tool
4
+ at ONE version, and passes the exit code through unchanged.
5
+
3
6
  A repo does not delete its vendored copy of a lint plugin because the version numbers match. It
4
7
  deletes it when the **findings** match: same tree, same rule ids, two configs, and a diff.
5
8
  `geonosis-lint-parity` is that diff — and, before it, the answer to whether either config's rules
@@ -1,6 +1,6 @@
1
1
  // src/envelope.ts
2
2
  import { mkdirSync, readFileSync, writeFileSync } from "fs";
3
- import { dirname, join } from "path";
3
+ import { dirname, join, resolve } from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  var ENVELOPES_DIR = ".geonosis/envelopes";
6
6
  var envelopePath = (root, tool) => join(root, ENVELOPES_DIR, `${tool}.json`);
@@ -12,6 +12,14 @@ var UnbalancedEnvelope = class extends Error {
12
12
  };
13
13
  var isCount = (value) => Number.isSafeInteger(value) && value >= 0;
14
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 FORBIDDEN_ROOT = "GEONOSIS_ENVELOPES_FORBIDDEN_ROOT";
16
+ var refuseForbiddenRoot = (root) => {
17
+ const forbidden = process.env[FORBIDDEN_ROOT];
18
+ if (forbidden === void 0 || resolve(forbidden) !== resolve(root)) return;
19
+ throw new UnbalancedEnvelope(
20
+ `${root} is off limits to envelope writers in this process (${FORBIDDEN_ROOT}) \u2014 a run that writes one into a shared root races every other run reading it, and leaves a file the next one takes for real. Point this at a scratch root of its own: tooling/scratch-dir.ts.`
21
+ );
22
+ };
15
23
  var writeEnvelope = ({
16
24
  envelope,
17
25
  next,
@@ -35,6 +43,7 @@ var writeEnvelope = ({
35
43
  if (envelope.considered !== envelope.read + envelope.refused.length + envelope.excused.length) {
36
44
  throw new UnbalancedEnvelope(unbalancedMessage(envelope, next));
37
45
  }
46
+ refuseForbiddenRoot(root);
38
47
  const at = envelopePath(root, envelope.tool);
39
48
  mkdirSync(dirname(at), { recursive: true });
40
49
  writeFileSync(at, `${JSON.stringify(envelope, void 0, 2)}
@@ -94,7 +103,7 @@ import {
94
103
  writeFileSync as writeFileSync2
95
104
  } from "fs";
96
105
  import { tmpdir } from "os";
97
- import { dirname as dirname2, join as join2, resolve } from "path";
106
+ import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
98
107
  var ANSI = /\[[0-9;]*m/g;
99
108
  var SUMMARY_COUNT = /^(\d+) problems?$/m;
100
109
  var RULE_IN_LINE = /\[(?:Error|Warning)\/([^\]]+)\]$/;
@@ -103,7 +112,7 @@ var FINDING_PLACE = /^(\S[^\n]*?:\d+:\d+): .*\[(?:Error|Warning)\/([^\]\n]+)\]$/
103
112
  var resolveOxlint = (from) => {
104
113
  let dir = from;
105
114
  for (; ; ) {
106
- const bin = resolve(dir, "node_modules/.bin/oxlint");
115
+ const bin = resolve2(dir, "node_modules/.bin/oxlint");
107
116
  if (existsSync(bin)) return bin;
108
117
  const parent = dirname2(dir);
109
118
  if (parent === dir) return "oxlint";
@@ -117,6 +126,8 @@ var ParityError = class extends Error {
117
126
  }
118
127
  };
119
128
  var normaliseFindings = (output, cwd) => output.replaceAll(ANSI, "").split("\n").map((line) => line.trim().replace(`${cwd}/`, "").replace(/^\.\//, "")).filter((line) => UNIX_FINDING.test(line)).toSorted();
129
+ var REFUSED_CONFIG = /Failed to parse oxlint configuration file|Failed to setup JS plugin options/;
130
+ var refusalIn = (output) => (/^\s*(?:x|Error:)\s*(.+)$/m.exec(output)?.[1] ?? output.trim().split("\n")[0] ?? "").trim();
120
131
  var readRun = ({ code, output }, cwd, side) => {
121
132
  const findings = normaliseFindings(output, cwd);
122
133
  const summary = SUMMARY_COUNT.exec(output);
@@ -127,6 +138,11 @@ ${output.trim()}`);
127
138
  if (summary?.[1] !== void 0 && Number(summary[1]) !== findings.length) {
128
139
  return refuse(`oxlint says ${summary[1]} problems, ${findings.length} lines parsed as findings`);
129
140
  }
141
+ if (REFUSED_CONFIG.test(output)) {
142
+ return refuse(
143
+ `oxlint refused to parse this config and linted nothing \u2014 ${refusalIn(output)}. That is not a run with no findings. For a corpus comparison, config A is the BASE COMMIT's own corpus config against the BASE build, never HEAD's config against an older build: see docs/releasing.md.`
144
+ );
145
+ }
130
146
  if (findings.length === 0 && code !== 0) {
131
147
  return refuse(`oxlint exited ${code} without reporting a single finding`);
132
148
  }
@@ -239,15 +255,22 @@ var formatSummary = (parity) => {
239
255
  changed
240
256
  ].join("\n");
241
257
  };
242
- var rulesNamedBy = (config) => {
258
+ var OFF = /* @__PURE__ */ new Set(["off", 0]);
259
+ var severityOf = (level) => Array.isArray(level) ? level[0] : level;
260
+ var layersOf = (config) => {
243
261
  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
- ];
262
+ return [parsed.rules ?? {}, ...(parsed.overrides ?? []).map((one) => one.rules ?? {})];
263
+ };
264
+ var namedBy = (config) => {
265
+ const layers = layersOf(config);
266
+ const named = [...new Set(layers.flatMap((one) => Object.keys(one)))];
267
+ const enabled = named.filter(
268
+ (rule) => layers.some((one) => rule in one && !OFF.has(severityOf(one[rule])))
269
+ );
270
+ return { enabled, off: named.filter((rule) => !enabled.includes(rule)) };
250
271
  };
272
+ var rulesNamedBy = (config) => namedBy(config).enabled;
273
+ var rulesTurnedOffBy = (config) => namedBy(config).off;
251
274
  var ruleIdOf = (line) => {
252
275
  const token = RULE_IN_LINE.exec(line)?.[1];
253
276
  if (token === void 0) return UNATTRIBUTED;
@@ -333,6 +356,7 @@ var corpusOf = ({
333
356
  rmSync(root, { force: true, recursive: true });
334
357
  }
335
358
  const configured = [.../* @__PURE__ */ new Set([...rulesNamedBy(configA), ...rulesNamedBy(configB)])].toSorted();
359
+ const off = [.../* @__PURE__ */ new Set([...rulesTurnedOffBy(configA), ...rulesTurnedOffBy(configB)])].filter((rule) => !configured.includes(rule)).toSorted();
336
360
  const reach = configured.filter((rule) => exercised.has(rule)).map((rule) => {
337
361
  const foundInA = inA.get(rule) ?? 0;
338
362
  const foundInB = inB.get(rule) ?? 0;
@@ -350,6 +374,7 @@ var corpusOf = ({
350
374
  claims,
351
375
  claimsHeld: claims.every((one) => one.verdict === "held"),
352
376
  neither: reach.filter((one) => !one.firedInA && !one.firedInB).map((one) => one.rule),
377
+ off,
353
378
  outside: configured.filter((rule) => !exercised.has(rule)),
354
379
  plugin: manifest.plugin,
355
380
  reach
@@ -387,6 +412,7 @@ var formatCorpus = (report) => {
387
412
  `- rules in scope of this corpus: **${report.reach.length}**`,
388
413
  `- fired under neither config: **${report.neither.length}**`,
389
414
  `- outside this corpus: **${report.outside.length}**`,
415
+ `- turned off by a config, so out of scope: **${report.off.length}**${report.off.length === 0 ? "" : ` (${report.off.map((rule) => `\`${rule}\``).join(", ")})`}`,
390
416
  "",
391
417
  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:
392
418
  ${report.neither.map((rule) => `- \`${rule}\``).join("\n")}`,
@@ -416,9 +442,14 @@ var parseParityArgs = (argv) => {
416
442
  const read = {};
417
443
  let corpus;
418
444
  let expectChanged;
445
+ let json = false;
419
446
  for (let index = 0; index < flags.length; index += 1) {
420
447
  const flag = flags[index];
421
448
  const value = flags[index + 1];
449
+ if (flag === "--json") {
450
+ json = true;
451
+ continue;
452
+ }
422
453
  if (flag === CORPUS_FLAG) {
423
454
  if (value === void 0 || value.startsWith("--")) {
424
455
  throw new Error(
@@ -457,6 +488,7 @@ var parseParityArgs = (argv) => {
457
488
  configB: read.configB,
458
489
  ...corpus === void 0 ? {} : { corpus },
459
490
  ...expectChanged === void 0 ? {} : { expectChanged },
491
+ json,
460
492
  ...read.out === void 0 ? {} : { out: read.out },
461
493
  ...read.oxlint === void 0 ? {} : { oxlint: read.oxlint },
462
494
  paths: paths.length === 0 ? ["."] : paths
@@ -552,6 +584,7 @@ export {
552
584
  compareFindings,
553
585
  formatSummary,
554
586
  rulesNamedBy,
587
+ rulesTurnedOffBy,
555
588
  ruleIdOf,
556
589
  MANIFEST_FILE,
557
590
  manifestOf,
package/dist/index.d.ts CHANGED
@@ -47,13 +47,6 @@ type Parity = {
47
47
  * thread-scheduling detail, not a finding.
48
48
  */
49
49
  declare const normaliseFindings: (output: string, cwd: string) => string[];
50
- /**
51
- * The findings of one run, or a refusal to guess. oxlint exits 1 both when it reported errors and
52
- * when it never linted a file at all — a rule name it does not know, options a rule has no schema
53
- * for, a plugin it could not load. All three print a paragraph and no findings, and "0 findings" on
54
- * one side of a parity run reads as agreement with the other. So: a summary counting findings no
55
- * line accounts for, or a non-zero exit with nothing readable on it, is an error, not a count.
56
- */
57
50
  declare const readRun: ({ code, output }: {
58
51
  code: number;
59
52
  output: string;
@@ -100,20 +93,31 @@ type CorpusReport = {
100
93
  * about anybody else's rule, and a verdict on one would be a guess.
101
94
  */
102
95
  outside: string[];
96
+ /**
97
+ * Rules a config names and turns off. Out of scope — a rule that does not run cannot be shown to
98
+ * reach anything — and counted, so "in scope" is never a number with a silent subtraction in it.
99
+ */
100
+ off: string[];
103
101
  /** The plugin the corpus declared itself to speak for. */
104
102
  plugin: string;
105
103
  reach: RuleReach[];
106
104
  };
107
105
  /**
108
- * The rule ids an oxlint config enables, exactly as it wrote them — plugin prefix included, and
106
+ * The rule ids an oxlint config ENABLES, exactly as it wrote them — plugin prefix included, and
109
107
  * from `overrides[]` as well as from `rules`.
110
108
  *
111
109
  * A config's base block is only its first layer: an overrides entry may name a rule the base block
112
110
  * never mentions, and oxlint runs it on every file that entry's globs claim. Reading `rules` alone
113
111
  * left such a rule out of the reach scope entirely — it fired, and the report about which rules
114
112
  * reach anything did not have a row for it.
113
+ *
114
+ * Severity is read (#16). A rule set to `"off"` does not run, so a corpus that never fires it has
115
+ * shown nothing about it: three rules read as "fire nowhere" under a consumer's config where the
116
+ * doctor, which does read severity, said one.
115
117
  */
116
118
  declare const rulesNamedBy: (config: string) => string[];
119
+ /** The rules a config names and turns off — out of the reach scope, and counted rather than hidden. */
120
+ declare const rulesTurnedOffBy: (config: string) => string[];
117
121
  /**
118
122
  * The plugin-qualified id behind a finding's `plugin(rule)` token, in the `plugin/rule` shape a
119
123
  * config names it by. Prefix included on purpose: `typescript(no-unused-vars)` is a different rule
@@ -176,6 +180,8 @@ type ParityArgs = {
176
180
  * an empty list is not expressible, because a claim about no rules is not a claim.
177
181
  */
178
182
  expectChanged?: string[];
183
+ /** Print the report as a document rather than as a summary a caller has to read by regex. */
184
+ json: boolean;
179
185
  out?: string;
180
186
  oxlint?: string;
181
187
  paths: string[];
@@ -219,4 +225,4 @@ declare const parityOf: ({ configA, configB, cwd, out, oxlint, paths, }: {
219
225
  paths: string[];
220
226
  }) => Parity;
221
227
 
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 };
228
+ 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, rulesTurnedOffBy };
package/dist/index.js CHANGED
@@ -15,8 +15,9 @@ import {
15
15
  readRun,
16
16
  resolveOxlint,
17
17
  ruleIdOf,
18
- rulesNamedBy
19
- } from "./chunk-D7OGKQ2C.js";
18
+ rulesNamedBy,
19
+ rulesTurnedOffBy
20
+ } from "./chunk-JPGZG2HA.js";
20
21
  export {
21
22
  MANIFEST_FILE,
22
23
  ParityError,
@@ -34,5 +35,6 @@ export {
34
35
  readRun,
35
36
  resolveOxlint,
36
37
  ruleIdOf,
37
- rulesNamedBy
38
+ rulesNamedBy,
39
+ rulesTurnedOffBy
38
40
  };
@@ -9,13 +9,13 @@ import {
9
9
  parseParityArgs,
10
10
  resolveOxlint,
11
11
  writeEnvelope
12
- } from "./chunk-D7OGKQ2C.js";
12
+ } from "./chunk-JPGZG2HA.js";
13
13
 
14
14
  // src/parity-cli.ts
15
15
  import { mkdirSync, writeFileSync } from "fs";
16
16
  import { join, resolve } from "path";
17
- var USAGE = `geonosis-lint-parity --a <config-a.json> --b <config-b.json> [--oxlint <path>] [--out <dir>] -- <paths\u2026>
18
- geonosis-lint-parity --corpus <dir> --a <config-a.json> --b <config-b.json> [--out <dir>]
17
+ var USAGE = `geonosis-lint-parity --a <config-a.json> --b <config-b.json> [--oxlint <path>] [--out <dir>] [--json] -- <paths\u2026>
18
+ geonosis-lint-parity --corpus <dir> --a <config-a.json> --b <config-b.json> [--out <dir>] [--json]
19
19
  [--expect-changed <rule,rule,\u2026>]
20
20
 
21
21
  Runs oxlint twice over the same paths under two configs and diffs the findings. Exits 1 when a
@@ -71,7 +71,8 @@ and it reads them as oxlint does rather than interpreting them itself.
71
71
  oxlint
72
72
  });
73
73
  const text = formatCorpus(report);
74
- process.stdout.write(`${text}
74
+ process.stdout.write(args.json ? `${JSON.stringify(report, void 0, 2)}
75
+ ` : `${text}
75
76
  `);
76
77
  if (args.out !== void 0) {
77
78
  const out = resolve(cwd, args.out);
@@ -89,7 +90,7 @@ and it reads them as oxlint does rather than interpreting them itself.
89
90
  oxlint,
90
91
  paths: args.paths
91
92
  });
92
- process.stdout.write(`${formatSummary(parity)}
93
+ if (!args.json) process.stdout.write(`${formatSummary(parity)}
93
94
  `);
94
95
  const at = writeEnvelope({
95
96
  envelope: parityEnvelope(
@@ -100,8 +101,11 @@ and it reads them as oxlint does rather than interpreting them itself.
100
101
  next: PARITY_NEXT,
101
102
  root: cwd
102
103
  });
103
- process.stdout.write(`envelope: ${at}
104
- `);
104
+ process.stdout.write(
105
+ args.json ? `${JSON.stringify(parity, void 0, 2)}
106
+ ` : `envelope: ${at}
107
+ `
108
+ );
105
109
  return parity.lost ? 1 : 0;
106
110
  };
107
111
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geonosis/lint-parity",
3
- "version": "2.1.0",
3
+ "version": "2.3.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": [
@@ -44,7 +44,7 @@
44
44
  "access": "public"
45
45
  },
46
46
  "devDependencies": {
47
- "@geonosis/ratchet": "2.1.0"
47
+ "@geonosis/ratchet": "2.3.0"
48
48
  },
49
49
  "scripts": {
50
50
  "build": "tsup",