@geonosis/oxlint-plugin-biological-architecture 0.1.1 → 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
@@ -78,6 +78,13 @@ match. It deletes it when the **findings** match: same tree, same rule ids, two
78
78
  geonosis-lint-parity --a <config-a.json> --b <config-b.json> [--oxlint <path>] [--out <dir>] -- <paths…>
79
79
  ```
80
80
 
81
+ **Write both configs inside the workspace being linted** — `<ws>/.oxlintrc.parity-a.json`, deleted
82
+ afterwards — or name the plugin by absolute path in `jsPlugins`. oxlint resolves a `jsPlugins`
83
+ specifier relative to the **config file's** directory, not the working directory, so a config
84
+ written to a temp dir cannot find a plugin installed in the workspace: it fails with
85
+ `Cannot find module '@…/plugin'`, and the tool correctly refuses with
86
+ `config A: oxlint exited 1 without reporting a single finding`.
87
+
81
88
  ### Step 0 — reach, before parity
82
89
 
83
90
  Parity between two configs that both do nothing is perfect parity. In one source repo 34 of 51
@@ -86,13 +93,20 @@ nothing could check. So this package ships the **reach corpus** — the fixture
86
93
  against, one deliberately broken file per rule — and answers the question directly:
87
94
 
88
95
  ```bash
89
- geonosis-lint-parity --corpus --a oxlintrc.fork.json --b oxlintrc.published.json [--out <dir>]
96
+ geonosis-lint-parity --corpus [<dir>] --a oxlintrc.fork.json --b oxlintrc.published.json [--out <dir>]
90
97
  ```
91
98
 
92
- Both configs run over the shipped corpus (copied to a temp root first, so neither a `.gitignore` nor
99
+ Both configs run over the corpus (copied to a temp root first, so neither a `.gitignore` nor
93
100
  oxlint's `node_modules` skip can quietly turn "every rule fired nowhere" into the answer). It prints
94
- a fired-under-A / fired-under-B row per configured rule and **exits 1 when a rule fires under
95
- neither**. `--out` writes `CORPUS.md`.
101
+ a fired-under-A / fired-under-B row per in-scope rule and **exits 1 when one fires under neither**.
102
+ `--out` writes `CORPUS.md`.
103
+
104
+ **A corpus covers the rules its own `manifest.json` names, and only those.** The one this package
105
+ ships names `biological-architecture/*` — one deliberately broken file per rule, the list generated
106
+ from the plugin's own rules export at build time. A configured `eqeqeq`, `unicorn/*` or
107
+ `typescript/*` is listed under **outside this corpus** and gets no verdict: this tree is no evidence
108
+ either way about somebody else's rule, and judging one would fail every real config for saying
109
+ nothing. `--corpus <dir>` points the same run at another plugin's corpus; the scope moves with it.
96
110
 
97
111
  Every rule this package exports has at least one corpus file that fires it, and a test fails if that
98
112
  stops being true — so a rule cannot be added without evidence it reaches anything.
@@ -103,7 +117,14 @@ It runs oxlint twice with `--format=unix` over the same paths, strips the workin
103
117
  sorts, and prints the two counts, `only in A`, `only in B` and a per-rule tally. **It exits 1 when
104
118
  `only in A` is non-empty** — a finding the old config caught and the new one does not. New findings
105
119
  are what an upgrade is for; lost ones stop the adoption. `--out` writes `a.txt`, `b.txt`,
106
- `only-in-a.txt`, `only-in-b.txt` and `SUMMARY.md` for the proof file.
120
+ `only-in-a.txt`, `only-in-b.txt`, `changed.txt` and `SUMMARY.md` for the proof file.
121
+
122
+ **A finding is identified by where it is and which rule found it — `path:line:col` plus
123
+ `plugin(rule)`.** The message is not part of it, and neither is the severity. A release that only
124
+ rewords a rule's message would otherwise show as one finding lost and one gained, and stop an
125
+ adoption over a sentence. Those pair up instead under **changed**, reported in full and never
126
+ reaching the exit code; a severity change lands there too, because that is a fact about the config
127
+ rather than a rule that stopped firing.
107
128
 
108
129
  **Fork vs published**, before deleting a vendored copy — two configs identical but for `jsPlugins`:
109
130
 
@@ -0,0 +1,59 @@
1
+ {
2
+ "plugin": "biological-architecture",
3
+ "rules": [
4
+ "biological-architecture/atom-no-deps",
5
+ "biological-architecture/cell-must-be-stateful",
6
+ "biological-architecture/cell-must-not-compose-cell",
7
+ "biological-architecture/cell-no-tissues",
8
+ "biological-architecture/cells-folder-index-is-barrel",
9
+ "biological-architecture/compound-must-be-stateless",
10
+ "biological-architecture/compound-no-stores",
11
+ "biological-architecture/constants-in-constants-file",
12
+ "biological-architecture/dialect-through-the-seam",
13
+ "biological-architecture/document-sagas-are-generic",
14
+ "biological-architecture/documents-share-one-table",
15
+ "biological-architecture/effect-hook-naming",
16
+ "biological-architecture/max-comment-density",
17
+ "biological-architecture/molecule-atoms-only",
18
+ "biological-architecture/molecule-must-compose",
19
+ "biological-architecture/next-route-segment-is-thin-delegate",
20
+ "biological-architecture/no-brand-names",
21
+ "biological-architecture/no-card-shaped-div",
22
+ "biological-architecture/no-cross-feature-stores",
23
+ "biological-architecture/no-d1-transaction",
24
+ "biological-architecture/no-duplicate-jsx-patterns",
25
+ "biological-architecture/no-hook-in-component-disguise",
26
+ "biological-architecture/no-inert-hidden-jsx",
27
+ "biological-architecture/no-inline-data-in-jsx",
28
+ "biological-architecture/no-invalid-feature-folders",
29
+ "biological-architecture/no-logic-in-component-files",
30
+ "biological-architecture/no-orm-outside-db",
31
+ "biological-architecture/no-raw-html-atoms",
32
+ "biological-architecture/no-raw-sql-outside-allowed",
33
+ "biological-architecture/no-react-namespace",
34
+ "biological-architecture/no-renamed-html-props",
35
+ "biological-architecture/no-render-prop-reader",
36
+ "biological-architecture/no-trivial-wrapper-component",
37
+ "biological-architecture/no-ts-in-bio-folders",
38
+ "biological-architecture/no-type-definitions-in-components",
39
+ "biological-architecture/no-void-port",
40
+ "biological-architecture/organelle-dependency",
41
+ "biological-architecture/organelle-single-source",
42
+ "biological-architecture/queries-require-org-scope",
43
+ "biological-architecture/queue-loop-is-the-library",
44
+ "biological-architecture/ssot-no-inline-facts",
45
+ "biological-architecture/ssot-no-process-env",
46
+ "biological-architecture/step-opens-its-own-cell",
47
+ "biological-architecture/store-route-scopes-tenant-data",
48
+ "biological-architecture/tables-declare-their-plane",
49
+ "biological-architecture/tenant-tables-carry-org-id",
50
+ "biological-architecture/time-through-the-door",
51
+ "biological-architecture/tissue-must-compose",
52
+ "biological-architecture/tissue-no-data-props",
53
+ "biological-architecture/tissue-no-hooks",
54
+ "biological-architecture/tissue-no-organelles",
55
+ "biological-architecture/tissue-no-stores",
56
+ "biological-architecture/worker-handles-are-scoped",
57
+ "biological-architecture/zustand-v5-best-practices"
58
+ ]
59
+ }
@@ -0,0 +1,6 @@
1
+ // src/plugin-name.ts
2
+ var PLUGIN_NAME = "biological-architecture";
3
+
4
+ export {
5
+ PLUGIN_NAME
6
+ };
@@ -0,0 +1,339 @@
1
+ // src/parity.ts
2
+ import { spawnSync } from "child_process";
3
+ import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
4
+ import { tmpdir } from "os";
5
+ import { join } from "path";
6
+ var ANSI = /\[[0-9;]*m/g;
7
+ var SUMMARY_COUNT = /^(\d+) problems?$/m;
8
+ var RULE_IN_LINE = /\[(?:Error|Warning)\/([^\]]+)\]$/;
9
+ var UNIX_FINDING = /^\S[^\n]*:\d+:\d+: .*\[(?:Error|Warning)\/[^\]\n]+\]$/;
10
+ var FINDING_PLACE = /^(\S[^\n]*?:\d+:\d+): .*\[(?:Error|Warning)\/([^\]\n]+)\]$/;
11
+ var ParityError = class extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = "ParityError";
15
+ }
16
+ };
17
+ var normaliseFindings = (output, cwd) => output.replaceAll(ANSI, "").split("\n").map((line) => line.trim().replace(`${cwd}/`, "").replace(/^\.\//, "")).filter((line) => UNIX_FINDING.test(line)).toSorted();
18
+ var readRun = ({ code, output }, cwd, side) => {
19
+ const findings = normaliseFindings(output, cwd);
20
+ const summary = SUMMARY_COUNT.exec(output);
21
+ const refuse = (why) => {
22
+ throw new ParityError(`config ${side}: ${why}
23
+ ${output.trim()}`);
24
+ };
25
+ if (summary?.[1] !== void 0 && Number(summary[1]) !== findings.length) {
26
+ return refuse(`oxlint says ${summary[1]} problems, ${findings.length} lines parsed as findings`);
27
+ }
28
+ if (findings.length === 0 && code !== 0) {
29
+ return refuse(`oxlint exited ${code} without reporting a single finding`);
30
+ }
31
+ return findings;
32
+ };
33
+ var UNATTRIBUTED = "(unattributed)";
34
+ var ruleOf = (line) => RULE_IN_LINE.exec(line)?.[1] ?? UNATTRIBUTED;
35
+ var asFile = (lines) => lines.length === 0 ? "" : `${lines.join("\n")}
36
+ `;
37
+ var changedLines = (changes) => changes.flatMap((one) => [`- A: ${one.a}`, `+ B: ${one.b}`]);
38
+ var countByRule = (lines) => {
39
+ const counts = /* @__PURE__ */ new Map();
40
+ for (const line of lines) counts.set(ruleOf(line), (counts.get(ruleOf(line)) ?? 0) + 1);
41
+ return counts;
42
+ };
43
+ var missingFrom = (lines, other) => {
44
+ const remaining = /* @__PURE__ */ new Map();
45
+ for (const line of other) remaining.set(line, (remaining.get(line) ?? 0) + 1);
46
+ return lines.filter((line) => {
47
+ const left = remaining.get(line) ?? 0;
48
+ if (left === 0) return true;
49
+ remaining.set(line, left - 1);
50
+ return false;
51
+ });
52
+ };
53
+ var identityOf = (line) => {
54
+ const found = FINDING_PLACE.exec(line);
55
+ return found === null ? line : `${found[1]} [${found[2]}]`;
56
+ };
57
+ var byIdentity = (lines) => {
58
+ const grouped = /* @__PURE__ */ new Map();
59
+ for (const line of lines) {
60
+ const identity = identityOf(line);
61
+ grouped.set(identity, [...grouped.get(identity) ?? [], line]);
62
+ }
63
+ return grouped;
64
+ };
65
+ var compareFindings = ({ a, b }) => {
66
+ const inA = countByRule(a);
67
+ const inB = countByRule(b);
68
+ const groupedA = byIdentity(a);
69
+ const groupedB = byIdentity(b);
70
+ const onlyInA = [];
71
+ const onlyInB = [];
72
+ const messageChanged = [];
73
+ for (const identity of [.../* @__PURE__ */ new Set([...groupedA.keys(), ...groupedB.keys()])].toSorted()) {
74
+ const here = groupedA.get(identity) ?? [];
75
+ const there = groupedB.get(identity) ?? [];
76
+ const leftA = missingFrom(here, there);
77
+ const leftB = missingFrom(there, here);
78
+ for (const [index, line] of leftA.entries()) {
79
+ const other = leftB[index];
80
+ if (other === void 0) onlyInA.push(line);
81
+ else messageChanged.push({ a: line, b: other });
82
+ }
83
+ onlyInB.push(...leftB.slice(leftA.length));
84
+ }
85
+ return {
86
+ a,
87
+ b,
88
+ lost: onlyInA.length > 0,
89
+ messageChanged,
90
+ onlyInA,
91
+ onlyInB,
92
+ tally: [.../* @__PURE__ */ new Set([...inA.keys(), ...inB.keys()])].toSorted().map((rule) => ({
93
+ a: inA.get(rule) ?? 0,
94
+ b: inB.get(rule) ?? 0,
95
+ rule
96
+ }))
97
+ };
98
+ };
99
+ var formatSummary = (parity) => {
100
+ const rows = parity.tally.map(
101
+ (one) => `| \`${one.rule}\` | ${one.a} | ${one.b} | ${one.b - one.a >= 0 ? "+" : ""}${one.b - one.a} |`
102
+ );
103
+ const lost = parity.onlyInA.length === 0 ? "None. Every finding A reported, B reported too.\n" : `${parity.onlyInA.map((line) => `- \`${line}\``).join("\n")}
104
+ `;
105
+ const gained = parity.onlyInB.length === 0 ? "None.\n" : `${parity.onlyInB.map((line) => `- \`${line}\``).join("\n")}
106
+ `;
107
+ const changed = parity.messageChanged.length === 0 ? "None. Every finding both runs made is worded and graded the same.\n" : `${changedLines(parity.messageChanged).map((line) => `- \`${line}\``).join("\n")}
108
+ `;
109
+ return [
110
+ "# findings parity",
111
+ "",
112
+ `- findings A: **${parity.a.length}**`,
113
+ `- findings B: **${parity.b.length}**`,
114
+ `- only in A (lost): **${parity.onlyInA.length}**`,
115
+ `- only in B (gained): **${parity.onlyInB.length}**`,
116
+ `- same finding, changed message or severity: **${parity.messageChanged.length}**`,
117
+ "",
118
+ `**${parity.lost ? "FAIL" : "PASS"}** \u2014 ${parity.lost ? "B lost findings A reported. A rule that fired under A and fires nowhere under B stops the adoption." : "B reported everything A did."}`,
119
+ "",
120
+ "## per rule",
121
+ "",
122
+ "| rule | A | B | delta |",
123
+ "|---|---:|---:|---:|",
124
+ ...rows,
125
+ "",
126
+ "## only in A (lost)",
127
+ "",
128
+ lost,
129
+ "## only in B (gained)",
130
+ "",
131
+ gained,
132
+ "## changed (same place, same rule, different message or severity)",
133
+ "",
134
+ "Reported, never counted against B: a reworded message is the same finding, and a rule graded",
135
+ "differently is a fact about the config. Neither reaches the exit code.",
136
+ "",
137
+ changed
138
+ ].join("\n");
139
+ };
140
+ var rulesNamedBy = (config) => {
141
+ const parsed = JSON.parse(readFileSync(config, "utf8"));
142
+ return Object.keys(parsed.rules ?? {});
143
+ };
144
+ var ruleIdOf = (line) => {
145
+ const token = RULE_IN_LINE.exec(line)?.[1];
146
+ if (token === void 0) return UNATTRIBUTED;
147
+ const at = token.indexOf("(");
148
+ return at === -1 ? token : `${token.slice(0, at)}/${token.slice(at + 1, -1)}`;
149
+ };
150
+ var MANIFEST_FILE = "manifest.json";
151
+ var manifestOf = (plugin) => ({
152
+ plugin: plugin.meta.name,
153
+ rules: Object.keys(plugin.rules).map((rule) => `${plugin.meta.name}/${rule}`).toSorted()
154
+ });
155
+ var readManifest = (corpus) => {
156
+ const path = join(corpus, MANIFEST_FILE);
157
+ let parsed;
158
+ try {
159
+ parsed = JSON.parse(readFileSync(path, "utf8"));
160
+ } catch (error) {
161
+ throw new ParityError(`could not read ${path}: ${error.message}`);
162
+ }
163
+ const manifest = parsed;
164
+ if (typeof manifest.plugin !== "string" || !Array.isArray(manifest.rules)) {
165
+ throw new ParityError(`${path} names no plugin and no rules \u2014 it cannot scope a reach run`);
166
+ }
167
+ return { plugin: manifest.plugin, rules: manifest.rules };
168
+ };
169
+ var corpusOf = ({
170
+ configA,
171
+ configB,
172
+ corpus,
173
+ oxlint
174
+ }) => {
175
+ const manifest = readManifest(corpus);
176
+ const exercised = new Set(manifest.rules);
177
+ const root = mkdtempSync(join(tmpdir(), "geonosis-corpus-"));
178
+ const here = join(root, "corpus");
179
+ const firedIn = (config, side) => new Set(
180
+ readRun(
181
+ lint({
182
+ config,
183
+ cwd: root,
184
+ extra: ["--no-ignore", "--disable-nested-config"],
185
+ oxlint,
186
+ paths: [here]
187
+ }),
188
+ root,
189
+ side
190
+ ).map(ruleIdOf)
191
+ );
192
+ let inA = /* @__PURE__ */ new Set();
193
+ let inB = /* @__PURE__ */ new Set();
194
+ try {
195
+ cpSync(corpus, here, { recursive: true });
196
+ inA = firedIn(configA, "A");
197
+ inB = firedIn(configB, "B");
198
+ } finally {
199
+ rmSync(root, { force: true, recursive: true });
200
+ }
201
+ 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 }));
203
+ return {
204
+ neither: reach.filter((one) => !one.firedInA && !one.firedInB).map((one) => one.rule),
205
+ outside: configured.filter((rule) => !exercised.has(rule)),
206
+ plugin: manifest.plugin,
207
+ reach
208
+ };
209
+ };
210
+ var formatCorpus = (report) => {
211
+ const rows = report.reach.map(
212
+ (one) => `| \`${one.rule}\` | ${one.firedInA ? "yes" : "NO"} | ${one.firedInB ? "yes" : "NO"} |`
213
+ );
214
+ 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
+ `;
216
+ return [
217
+ "# corpus reach",
218
+ "",
219
+ `- rules in scope of this corpus: **${report.reach.length}**`,
220
+ `- fired under neither config: **${report.neither.length}**`,
221
+ `- outside this corpus: **${report.outside.length}**`,
222
+ "",
223
+ 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
+ ${report.neither.map((rule) => `- \`${rule}\``).join("\n")}`,
225
+ "",
226
+ "## per rule",
227
+ "",
228
+ "| rule | fired under A | fired under B |",
229
+ "|---|---|---|",
230
+ ...rows,
231
+ "",
232
+ "## outside this corpus",
233
+ "",
234
+ `Configured rules this corpus never claimed to exercise. It speaks for \`${report.plugin}\` only,`,
235
+ "so it is no evidence either way about these and passes no verdict on them.",
236
+ "",
237
+ outside
238
+ ].join("\n");
239
+ };
240
+ var FLAGS = { "--a": "configA", "--b": "configB", "--out": "out", "--oxlint": "oxlint" };
241
+ var CORPUS_FLAG = "--corpus";
242
+ var parseParityArgs = (argv) => {
243
+ const at = argv.indexOf("--");
244
+ const flags = at === -1 ? argv : argv.slice(0, at);
245
+ const paths = at === -1 ? [] : argv.slice(at + 1);
246
+ const read = {};
247
+ let corpus = false;
248
+ let corpusDir;
249
+ for (let index = 0; index < flags.length; index += 1) {
250
+ const flag = flags[index];
251
+ const value = flags[index + 1];
252
+ if (flag === CORPUS_FLAG) {
253
+ corpus = true;
254
+ if (value !== void 0 && !value.startsWith("--")) {
255
+ corpusDir = value;
256
+ index += 1;
257
+ }
258
+ continue;
259
+ }
260
+ const name = FLAGS[flag];
261
+ if (name === void 0) throw new Error(`geonosis-lint-parity: unknown argument "${flag}"`);
262
+ if (value === void 0 || value.startsWith("--")) {
263
+ throw new Error(`geonosis-lint-parity: ${flag} needs a value`);
264
+ }
265
+ read[name] = value;
266
+ index += 1;
267
+ }
268
+ if (read.configA === void 0 || read.configB === void 0) {
269
+ throw new Error(
270
+ "geonosis-lint-parity: both --a and --b are required \u2014 one config compares to nothing"
271
+ );
272
+ }
273
+ return {
274
+ configA: read.configA,
275
+ configB: read.configB,
276
+ corpus,
277
+ ...corpusDir === void 0 ? {} : { corpusDir },
278
+ ...read.out === void 0 ? {} : { out: read.out },
279
+ ...read.oxlint === void 0 ? {} : { oxlint: read.oxlint },
280
+ paths: paths.length === 0 ? ["."] : paths
281
+ };
282
+ };
283
+ var lint = ({
284
+ config,
285
+ cwd,
286
+ extra = [],
287
+ oxlint,
288
+ paths
289
+ }) => {
290
+ const run = spawnSync(oxlint, ["--format=unix", ...extra, "--config", config, ...paths], {
291
+ cwd,
292
+ encoding: "utf8",
293
+ env: { ...process.env, NO_COLOR: "1" },
294
+ maxBuffer: 64 * 1024 * 1024
295
+ });
296
+ if (run.error !== void 0)
297
+ throw new ParityError(`could not run ${oxlint}: ${run.error.message}`);
298
+ return { code: run.status ?? -1, output: `${run.stdout ?? ""}${run.stderr ?? ""}` };
299
+ };
300
+ var parityOf = ({
301
+ configA,
302
+ configB,
303
+ cwd,
304
+ out,
305
+ oxlint,
306
+ paths
307
+ }) => {
308
+ const parity = compareFindings({
309
+ a: readRun(lint({ config: configA, cwd, oxlint, paths }), cwd, "A"),
310
+ b: readRun(lint({ config: configB, cwd, oxlint, paths }), cwd, "B")
311
+ });
312
+ if (out !== void 0) {
313
+ mkdirSync(out, { recursive: true });
314
+ writeFileSync(join(out, "a.txt"), asFile(parity.a));
315
+ writeFileSync(join(out, "b.txt"), asFile(parity.b));
316
+ writeFileSync(join(out, "only-in-a.txt"), asFile(parity.onlyInA));
317
+ writeFileSync(join(out, "only-in-b.txt"), asFile(parity.onlyInB));
318
+ writeFileSync(join(out, "changed.txt"), asFile(changedLines(parity.messageChanged)));
319
+ writeFileSync(join(out, "SUMMARY.md"), formatSummary(parity));
320
+ }
321
+ return parity;
322
+ };
323
+
324
+ export {
325
+ ParityError,
326
+ normaliseFindings,
327
+ readRun,
328
+ compareFindings,
329
+ formatSummary,
330
+ rulesNamedBy,
331
+ ruleIdOf,
332
+ MANIFEST_FILE,
333
+ manifestOf,
334
+ readManifest,
335
+ corpusOf,
336
+ formatCorpus,
337
+ parseParityArgs,
338
+ parityOf
339
+ };
@@ -0,0 +1,268 @@
1
+ import {
2
+ PLUGIN_NAME
3
+ } from "./chunk-BFFWNFJM.js";
4
+
5
+ // src/parity.ts
6
+ import { spawnSync } from "child_process";
7
+ import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
8
+ import { tmpdir } from "os";
9
+ import { join } from "path";
10
+ var ANSI = /\[[0-9;]*m/g;
11
+ var SUMMARY_COUNT = /^(\d+) problems?$/m;
12
+ var RULE_IN_LINE = /\[(?:Error|Warning)\/([^\]]+)\]$/;
13
+ var UNIX_FINDING = /^\S[^\n]*:\d+:\d+: .*\[(?:Error|Warning)\/[^\]\n]+\]$/;
14
+ var ParityError = class extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "ParityError";
18
+ }
19
+ };
20
+ var normaliseFindings = (output, cwd) => output.replaceAll(ANSI, "").split("\n").map((line) => line.trim().replace(`${cwd}/`, "").replace(/^\.\//, "")).filter((line) => UNIX_FINDING.test(line)).toSorted();
21
+ var readRun = ({ code, output }, cwd, side) => {
22
+ const findings = normaliseFindings(output, cwd);
23
+ const summary = SUMMARY_COUNT.exec(output);
24
+ const refuse = (why) => {
25
+ throw new ParityError(`config ${side}: ${why}
26
+ ${output.trim()}`);
27
+ };
28
+ if (summary?.[1] !== void 0 && Number(summary[1]) !== findings.length) {
29
+ return refuse(`oxlint says ${summary[1]} problems, ${findings.length} lines parsed as findings`);
30
+ }
31
+ if (findings.length === 0 && code !== 0) {
32
+ return refuse(`oxlint exited ${code} without reporting a single finding`);
33
+ }
34
+ return findings;
35
+ };
36
+ var UNATTRIBUTED = "(unattributed)";
37
+ var ruleOf = (line) => RULE_IN_LINE.exec(line)?.[1] ?? UNATTRIBUTED;
38
+ var asFile = (lines) => lines.length === 0 ? "" : `${lines.join("\n")}
39
+ `;
40
+ var countByRule = (lines) => {
41
+ const counts = /* @__PURE__ */ new Map();
42
+ for (const line of lines) counts.set(ruleOf(line), (counts.get(ruleOf(line)) ?? 0) + 1);
43
+ return counts;
44
+ };
45
+ var missingFrom = (lines, other) => {
46
+ const remaining = /* @__PURE__ */ new Map();
47
+ for (const line of other) remaining.set(line, (remaining.get(line) ?? 0) + 1);
48
+ return lines.filter((line) => {
49
+ const left = remaining.get(line) ?? 0;
50
+ if (left === 0) return true;
51
+ remaining.set(line, left - 1);
52
+ return false;
53
+ });
54
+ };
55
+ var compareFindings = ({ a, b }) => {
56
+ const inA = countByRule(a);
57
+ const inB = countByRule(b);
58
+ const onlyInA = missingFrom(a, b);
59
+ return {
60
+ a,
61
+ b,
62
+ lost: onlyInA.length > 0,
63
+ onlyInA,
64
+ onlyInB: missingFrom(b, a),
65
+ tally: [.../* @__PURE__ */ new Set([...inA.keys(), ...inB.keys()])].toSorted().map((rule) => ({
66
+ a: inA.get(rule) ?? 0,
67
+ b: inB.get(rule) ?? 0,
68
+ rule
69
+ }))
70
+ };
71
+ };
72
+ var formatSummary = (parity) => {
73
+ const rows = parity.tally.map(
74
+ (one) => `| \`${one.rule}\` | ${one.a} | ${one.b} | ${one.b - one.a >= 0 ? "+" : ""}${one.b - one.a} |`
75
+ );
76
+ const lost = parity.onlyInA.length === 0 ? "None. Every finding A reported, B reported too.\n" : `${parity.onlyInA.map((line) => `- \`${line}\``).join("\n")}
77
+ `;
78
+ const gained = parity.onlyInB.length === 0 ? "None.\n" : `${parity.onlyInB.map((line) => `- \`${line}\``).join("\n")}
79
+ `;
80
+ return [
81
+ "# findings parity",
82
+ "",
83
+ `- findings A: **${parity.a.length}**`,
84
+ `- findings B: **${parity.b.length}**`,
85
+ `- only in A (lost): **${parity.onlyInA.length}**`,
86
+ `- only in B (gained): **${parity.onlyInB.length}**`,
87
+ "",
88
+ `**${parity.lost ? "FAIL" : "PASS"}** \u2014 ${parity.lost ? "B lost findings A reported. A rule that fired under A and fires nowhere under B stops the adoption." : "B reported everything A did."}`,
89
+ "",
90
+ "## per rule",
91
+ "",
92
+ "| rule | A | B | delta |",
93
+ "|---|---:|---:|---:|",
94
+ ...rows,
95
+ "",
96
+ "## only in A (lost)",
97
+ "",
98
+ lost,
99
+ "## only in B (gained)",
100
+ "",
101
+ gained
102
+ ].join("\n");
103
+ };
104
+ var rulesNamedBy = (config) => {
105
+ const parsed = JSON.parse(readFileSync(config, "utf8"));
106
+ return Object.keys(parsed.rules ?? {});
107
+ };
108
+ var ruleIdOf = (line) => {
109
+ const token = RULE_IN_LINE.exec(line)?.[1];
110
+ if (token === void 0) return UNATTRIBUTED;
111
+ const at = token.indexOf("(");
112
+ return at === -1 ? token : `${token.slice(0, at)}/${token.slice(at + 1, -1)}`;
113
+ };
114
+ var CORPUS_PLUGIN = `${PLUGIN_NAME}/`;
115
+ var corpusOf = ({
116
+ configA,
117
+ configB,
118
+ corpus,
119
+ oxlint
120
+ }) => {
121
+ const root = mkdtempSync(join(tmpdir(), "geonosis-corpus-"));
122
+ const here = join(root, "corpus");
123
+ const firedIn = (config, side) => new Set(
124
+ readRun(
125
+ lint({
126
+ config,
127
+ cwd: root,
128
+ extra: ["--no-ignore", "--disable-nested-config"],
129
+ oxlint,
130
+ paths: [here]
131
+ }),
132
+ root,
133
+ side
134
+ ).map(ruleIdOf)
135
+ );
136
+ let inA = /* @__PURE__ */ new Set();
137
+ let inB = /* @__PURE__ */ new Set();
138
+ try {
139
+ cpSync(corpus, here, { recursive: true });
140
+ inA = firedIn(configA, "A");
141
+ inB = firedIn(configB, "B");
142
+ } finally {
143
+ rmSync(root, { force: true, recursive: true });
144
+ }
145
+ const configured = [.../* @__PURE__ */ new Set([...rulesNamedBy(configA), ...rulesNamedBy(configB)])].toSorted();
146
+ const reach = configured.filter((rule) => rule.startsWith(CORPUS_PLUGIN)).map((rule) => ({ firedInA: inA.has(rule), firedInB: inB.has(rule), rule }));
147
+ return {
148
+ neither: reach.filter((one) => !one.firedInA && !one.firedInB).map((one) => one.rule),
149
+ outside: configured.filter((rule) => !rule.startsWith(CORPUS_PLUGIN)),
150
+ reach
151
+ };
152
+ };
153
+ var formatCorpus = (report) => {
154
+ const rows = report.reach.map(
155
+ (one) => `| \`${one.rule}\` | ${one.firedInA ? "yes" : "NO"} | ${one.firedInB ? "yes" : "NO"} |`
156
+ );
157
+ const outside = report.outside.length === 0 ? "None \u2014 every configured rule belongs to this plugin.\n" : `${report.outside.map((rule) => `- \`${rule}\``).join("\n")}
158
+ `;
159
+ return [
160
+ "# corpus reach",
161
+ "",
162
+ `- rules in scope of this corpus: **${report.reach.length}**`,
163
+ `- fired under neither config: **${report.neither.length}**`,
164
+ `- outside this corpus: **${report.outside.length}**`,
165
+ "",
166
+ 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:
167
+ ${report.neither.map((rule) => `- \`${rule}\``).join("\n")}`,
168
+ "",
169
+ "## per rule",
170
+ "",
171
+ "| rule | fired under A | fired under B |",
172
+ "|---|---|---|",
173
+ ...rows,
174
+ "",
175
+ `## outside this corpus`,
176
+ "",
177
+ `Configured rules from other plugins and the built-ins. This corpus exercises \`${CORPUS_PLUGIN}\``,
178
+ "only, so it is no evidence either way about these and passes no verdict on them.",
179
+ "",
180
+ outside
181
+ ].join("\n");
182
+ };
183
+ var FLAGS = { "--a": "configA", "--b": "configB", "--out": "out", "--oxlint": "oxlint" };
184
+ var CORPUS_FLAG = "--corpus";
185
+ var parseParityArgs = (argv) => {
186
+ const at = argv.indexOf("--");
187
+ const flags = (at === -1 ? argv : argv.slice(0, at)).filter((one) => one !== CORPUS_FLAG);
188
+ const paths = at === -1 ? [] : argv.slice(at + 1);
189
+ const corpus = (at === -1 ? argv : argv.slice(0, at)).includes(CORPUS_FLAG);
190
+ const read = {};
191
+ for (let index = 0; index < flags.length; index += 1) {
192
+ const flag = flags[index];
193
+ const name = FLAGS[flag];
194
+ if (name === void 0) throw new Error(`geonosis-lint-parity: unknown argument "${flag}"`);
195
+ const value = flags[index + 1];
196
+ if (value === void 0 || value.startsWith("--")) {
197
+ throw new Error(`geonosis-lint-parity: ${flag} needs a value`);
198
+ }
199
+ read[name] = value;
200
+ index += 1;
201
+ }
202
+ if (read.configA === void 0 || read.configB === void 0) {
203
+ throw new Error(
204
+ "geonosis-lint-parity: both --a and --b are required \u2014 one config compares to nothing"
205
+ );
206
+ }
207
+ return {
208
+ configA: read.configA,
209
+ configB: read.configB,
210
+ corpus,
211
+ ...read.out === void 0 ? {} : { out: read.out },
212
+ ...read.oxlint === void 0 ? {} : { oxlint: read.oxlint },
213
+ paths: paths.length === 0 ? ["."] : paths
214
+ };
215
+ };
216
+ var lint = ({
217
+ config,
218
+ cwd,
219
+ extra = [],
220
+ oxlint,
221
+ paths
222
+ }) => {
223
+ const run = spawnSync(oxlint, ["--format=unix", ...extra, "--config", config, ...paths], {
224
+ cwd,
225
+ encoding: "utf8",
226
+ env: { ...process.env, NO_COLOR: "1" },
227
+ maxBuffer: 64 * 1024 * 1024
228
+ });
229
+ if (run.error !== void 0)
230
+ throw new ParityError(`could not run ${oxlint}: ${run.error.message}`);
231
+ return { code: run.status ?? -1, output: `${run.stdout ?? ""}${run.stderr ?? ""}` };
232
+ };
233
+ var parityOf = ({
234
+ configA,
235
+ configB,
236
+ cwd,
237
+ out,
238
+ oxlint,
239
+ paths
240
+ }) => {
241
+ const parity = compareFindings({
242
+ a: readRun(lint({ config: configA, cwd, oxlint, paths }), cwd, "A"),
243
+ b: readRun(lint({ config: configB, cwd, oxlint, paths }), cwd, "B")
244
+ });
245
+ if (out !== void 0) {
246
+ mkdirSync(out, { recursive: true });
247
+ writeFileSync(join(out, "a.txt"), asFile(parity.a));
248
+ writeFileSync(join(out, "b.txt"), asFile(parity.b));
249
+ writeFileSync(join(out, "only-in-a.txt"), asFile(parity.onlyInA));
250
+ writeFileSync(join(out, "only-in-b.txt"), asFile(parity.onlyInB));
251
+ writeFileSync(join(out, "SUMMARY.md"), formatSummary(parity));
252
+ }
253
+ return parity;
254
+ };
255
+
256
+ export {
257
+ ParityError,
258
+ normaliseFindings,
259
+ readRun,
260
+ compareFindings,
261
+ formatSummary,
262
+ rulesNamedBy,
263
+ ruleIdOf,
264
+ corpusOf,
265
+ formatCorpus,
266
+ parseParityArgs,
267
+ parityOf
268
+ };
@@ -0,0 +1,298 @@
1
+ // src/parity.ts
2
+ import { spawnSync } from "child_process";
3
+ import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
4
+ import { tmpdir } from "os";
5
+ import { join } from "path";
6
+ var ANSI = /\[[0-9;]*m/g;
7
+ var SUMMARY_COUNT = /^(\d+) problems?$/m;
8
+ var RULE_IN_LINE = /\[(?:Error|Warning)\/([^\]]+)\]$/;
9
+ var UNIX_FINDING = /^\S[^\n]*:\d+:\d+: .*\[(?:Error|Warning)\/[^\]\n]+\]$/;
10
+ var ParityError = class extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = "ParityError";
14
+ }
15
+ };
16
+ var normaliseFindings = (output, cwd) => output.replaceAll(ANSI, "").split("\n").map((line) => line.trim().replace(`${cwd}/`, "").replace(/^\.\//, "")).filter((line) => UNIX_FINDING.test(line)).toSorted();
17
+ var readRun = ({ code, output }, cwd, side) => {
18
+ const findings = normaliseFindings(output, cwd);
19
+ const summary = SUMMARY_COUNT.exec(output);
20
+ const refuse = (why) => {
21
+ throw new ParityError(`config ${side}: ${why}
22
+ ${output.trim()}`);
23
+ };
24
+ if (summary?.[1] !== void 0 && Number(summary[1]) !== findings.length) {
25
+ return refuse(`oxlint says ${summary[1]} problems, ${findings.length} lines parsed as findings`);
26
+ }
27
+ if (findings.length === 0 && code !== 0) {
28
+ return refuse(`oxlint exited ${code} without reporting a single finding`);
29
+ }
30
+ return findings;
31
+ };
32
+ var UNATTRIBUTED = "(unattributed)";
33
+ var ruleOf = (line) => RULE_IN_LINE.exec(line)?.[1] ?? UNATTRIBUTED;
34
+ var asFile = (lines) => lines.length === 0 ? "" : `${lines.join("\n")}
35
+ `;
36
+ var countByRule = (lines) => {
37
+ const counts = /* @__PURE__ */ new Map();
38
+ for (const line of lines) counts.set(ruleOf(line), (counts.get(ruleOf(line)) ?? 0) + 1);
39
+ return counts;
40
+ };
41
+ var missingFrom = (lines, other) => {
42
+ const remaining = /* @__PURE__ */ new Map();
43
+ for (const line of other) remaining.set(line, (remaining.get(line) ?? 0) + 1);
44
+ return lines.filter((line) => {
45
+ const left = remaining.get(line) ?? 0;
46
+ if (left === 0) return true;
47
+ remaining.set(line, left - 1);
48
+ return false;
49
+ });
50
+ };
51
+ var compareFindings = ({ a, b }) => {
52
+ const inA = countByRule(a);
53
+ const inB = countByRule(b);
54
+ const onlyInA = missingFrom(a, b);
55
+ return {
56
+ a,
57
+ b,
58
+ lost: onlyInA.length > 0,
59
+ onlyInA,
60
+ onlyInB: missingFrom(b, a),
61
+ tally: [.../* @__PURE__ */ new Set([...inA.keys(), ...inB.keys()])].toSorted().map((rule) => ({
62
+ a: inA.get(rule) ?? 0,
63
+ b: inB.get(rule) ?? 0,
64
+ rule
65
+ }))
66
+ };
67
+ };
68
+ var formatSummary = (parity) => {
69
+ const rows = parity.tally.map(
70
+ (one) => `| \`${one.rule}\` | ${one.a} | ${one.b} | ${one.b - one.a >= 0 ? "+" : ""}${one.b - one.a} |`
71
+ );
72
+ const lost = parity.onlyInA.length === 0 ? "None. Every finding A reported, B reported too.\n" : `${parity.onlyInA.map((line) => `- \`${line}\``).join("\n")}
73
+ `;
74
+ const gained = parity.onlyInB.length === 0 ? "None.\n" : `${parity.onlyInB.map((line) => `- \`${line}\``).join("\n")}
75
+ `;
76
+ return [
77
+ "# findings parity",
78
+ "",
79
+ `- findings A: **${parity.a.length}**`,
80
+ `- findings B: **${parity.b.length}**`,
81
+ `- only in A (lost): **${parity.onlyInA.length}**`,
82
+ `- only in B (gained): **${parity.onlyInB.length}**`,
83
+ "",
84
+ `**${parity.lost ? "FAIL" : "PASS"}** \u2014 ${parity.lost ? "B lost findings A reported. A rule that fired under A and fires nowhere under B stops the adoption." : "B reported everything A did."}`,
85
+ "",
86
+ "## per rule",
87
+ "",
88
+ "| rule | A | B | delta |",
89
+ "|---|---:|---:|---:|",
90
+ ...rows,
91
+ "",
92
+ "## only in A (lost)",
93
+ "",
94
+ lost,
95
+ "## only in B (gained)",
96
+ "",
97
+ gained
98
+ ].join("\n");
99
+ };
100
+ var rulesNamedBy = (config) => {
101
+ const parsed = JSON.parse(readFileSync(config, "utf8"));
102
+ return Object.keys(parsed.rules ?? {});
103
+ };
104
+ var ruleIdOf = (line) => {
105
+ const token = RULE_IN_LINE.exec(line)?.[1];
106
+ if (token === void 0) return UNATTRIBUTED;
107
+ const at = token.indexOf("(");
108
+ return at === -1 ? token : `${token.slice(0, at)}/${token.slice(at + 1, -1)}`;
109
+ };
110
+ var MANIFEST_FILE = "manifest.json";
111
+ var manifestOf = (plugin) => ({
112
+ plugin: plugin.meta.name,
113
+ rules: Object.keys(plugin.rules).map((rule) => `${plugin.meta.name}/${rule}`).toSorted()
114
+ });
115
+ var readManifest = (corpus) => {
116
+ const path = join(corpus, MANIFEST_FILE);
117
+ let parsed;
118
+ try {
119
+ parsed = JSON.parse(readFileSync(path, "utf8"));
120
+ } catch (error) {
121
+ throw new ParityError(`could not read ${path}: ${error.message}`);
122
+ }
123
+ const manifest = parsed;
124
+ if (typeof manifest.plugin !== "string" || !Array.isArray(manifest.rules)) {
125
+ throw new ParityError(`${path} names no plugin and no rules \u2014 it cannot scope a reach run`);
126
+ }
127
+ return { plugin: manifest.plugin, rules: manifest.rules };
128
+ };
129
+ var corpusOf = ({
130
+ configA,
131
+ configB,
132
+ corpus,
133
+ oxlint
134
+ }) => {
135
+ const manifest = readManifest(corpus);
136
+ const exercised = new Set(manifest.rules);
137
+ const root = mkdtempSync(join(tmpdir(), "geonosis-corpus-"));
138
+ const here = join(root, "corpus");
139
+ const firedIn = (config, side) => new Set(
140
+ readRun(
141
+ lint({
142
+ config,
143
+ cwd: root,
144
+ extra: ["--no-ignore", "--disable-nested-config"],
145
+ oxlint,
146
+ paths: [here]
147
+ }),
148
+ root,
149
+ side
150
+ ).map(ruleIdOf)
151
+ );
152
+ let inA = /* @__PURE__ */ new Set();
153
+ let inB = /* @__PURE__ */ new Set();
154
+ try {
155
+ cpSync(corpus, here, { recursive: true });
156
+ inA = firedIn(configA, "A");
157
+ inB = firedIn(configB, "B");
158
+ } finally {
159
+ rmSync(root, { force: true, recursive: true });
160
+ }
161
+ const configured = [.../* @__PURE__ */ new Set([...rulesNamedBy(configA), ...rulesNamedBy(configB)])].toSorted();
162
+ const reach = configured.filter((rule) => exercised.has(rule)).map((rule) => ({ firedInA: inA.has(rule), firedInB: inB.has(rule), rule }));
163
+ return {
164
+ neither: reach.filter((one) => !one.firedInA && !one.firedInB).map((one) => one.rule),
165
+ outside: configured.filter((rule) => !exercised.has(rule)),
166
+ plugin: manifest.plugin,
167
+ reach
168
+ };
169
+ };
170
+ var formatCorpus = (report) => {
171
+ const rows = report.reach.map(
172
+ (one) => `| \`${one.rule}\` | ${one.firedInA ? "yes" : "NO"} | ${one.firedInB ? "yes" : "NO"} |`
173
+ );
174
+ 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")}
175
+ `;
176
+ return [
177
+ "# corpus reach",
178
+ "",
179
+ `- rules in scope of this corpus: **${report.reach.length}**`,
180
+ `- fired under neither config: **${report.neither.length}**`,
181
+ `- outside this corpus: **${report.outside.length}**`,
182
+ "",
183
+ 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:
184
+ ${report.neither.map((rule) => `- \`${rule}\``).join("\n")}`,
185
+ "",
186
+ "## per rule",
187
+ "",
188
+ "| rule | fired under A | fired under B |",
189
+ "|---|---|---|",
190
+ ...rows,
191
+ "",
192
+ "## outside this corpus",
193
+ "",
194
+ `Configured rules this corpus never claimed to exercise. It speaks for \`${report.plugin}\` only,`,
195
+ "so it is no evidence either way about these and passes no verdict on them.",
196
+ "",
197
+ outside
198
+ ].join("\n");
199
+ };
200
+ var FLAGS = { "--a": "configA", "--b": "configB", "--out": "out", "--oxlint": "oxlint" };
201
+ var CORPUS_FLAG = "--corpus";
202
+ var parseParityArgs = (argv) => {
203
+ const at = argv.indexOf("--");
204
+ const flags = at === -1 ? argv : argv.slice(0, at);
205
+ const paths = at === -1 ? [] : argv.slice(at + 1);
206
+ const read = {};
207
+ let corpus = false;
208
+ let corpusDir;
209
+ for (let index = 0; index < flags.length; index += 1) {
210
+ const flag = flags[index];
211
+ const value = flags[index + 1];
212
+ if (flag === CORPUS_FLAG) {
213
+ corpus = true;
214
+ if (value !== void 0 && !value.startsWith("--")) {
215
+ corpusDir = value;
216
+ index += 1;
217
+ }
218
+ continue;
219
+ }
220
+ const name = FLAGS[flag];
221
+ if (name === void 0) throw new Error(`geonosis-lint-parity: unknown argument "${flag}"`);
222
+ if (value === void 0 || value.startsWith("--")) {
223
+ throw new Error(`geonosis-lint-parity: ${flag} needs a value`);
224
+ }
225
+ read[name] = value;
226
+ index += 1;
227
+ }
228
+ if (read.configA === void 0 || read.configB === void 0) {
229
+ throw new Error(
230
+ "geonosis-lint-parity: both --a and --b are required \u2014 one config compares to nothing"
231
+ );
232
+ }
233
+ return {
234
+ configA: read.configA,
235
+ configB: read.configB,
236
+ corpus,
237
+ ...corpusDir === void 0 ? {} : { corpusDir },
238
+ ...read.out === void 0 ? {} : { out: read.out },
239
+ ...read.oxlint === void 0 ? {} : { oxlint: read.oxlint },
240
+ paths: paths.length === 0 ? ["."] : paths
241
+ };
242
+ };
243
+ var lint = ({
244
+ config,
245
+ cwd,
246
+ extra = [],
247
+ oxlint,
248
+ paths
249
+ }) => {
250
+ const run = spawnSync(oxlint, ["--format=unix", ...extra, "--config", config, ...paths], {
251
+ cwd,
252
+ encoding: "utf8",
253
+ env: { ...process.env, NO_COLOR: "1" },
254
+ maxBuffer: 64 * 1024 * 1024
255
+ });
256
+ if (run.error !== void 0)
257
+ throw new ParityError(`could not run ${oxlint}: ${run.error.message}`);
258
+ return { code: run.status ?? -1, output: `${run.stdout ?? ""}${run.stderr ?? ""}` };
259
+ };
260
+ var parityOf = ({
261
+ configA,
262
+ configB,
263
+ cwd,
264
+ out,
265
+ oxlint,
266
+ paths
267
+ }) => {
268
+ const parity = compareFindings({
269
+ a: readRun(lint({ config: configA, cwd, oxlint, paths }), cwd, "A"),
270
+ b: readRun(lint({ config: configB, cwd, oxlint, paths }), cwd, "B")
271
+ });
272
+ if (out !== void 0) {
273
+ mkdirSync(out, { recursive: true });
274
+ writeFileSync(join(out, "a.txt"), asFile(parity.a));
275
+ writeFileSync(join(out, "b.txt"), asFile(parity.b));
276
+ writeFileSync(join(out, "only-in-a.txt"), asFile(parity.onlyInA));
277
+ writeFileSync(join(out, "only-in-b.txt"), asFile(parity.onlyInB));
278
+ writeFileSync(join(out, "SUMMARY.md"), formatSummary(parity));
279
+ }
280
+ return parity;
281
+ };
282
+
283
+ export {
284
+ ParityError,
285
+ normaliseFindings,
286
+ readRun,
287
+ compareFindings,
288
+ formatSummary,
289
+ rulesNamedBy,
290
+ ruleIdOf,
291
+ MANIFEST_FILE,
292
+ manifestOf,
293
+ readManifest,
294
+ corpusOf,
295
+ formatCorpus,
296
+ parseParityArgs,
297
+ parityOf
298
+ };
package/dist/index.js CHANGED
@@ -2,6 +2,9 @@ import {
2
2
  PRESETS
3
3
  } from "./chunk-74VWOEPL.js";
4
4
 
5
+ // src/index.ts
6
+ import { createRequire } from "module";
7
+
5
8
  // src/rules/atom-no-deps.ts
6
9
  var FORBIDDEN_PATTERNS = [
7
10
  /molecules\//,
@@ -3909,11 +3912,12 @@ var rules = {
3909
3912
  "worker-handles-are-scoped": worker_handles_are_scoped_default,
3910
3913
  "zustand-v5-best-practices": zustand_v5_best_practices_default
3911
3914
  };
3915
+ var { version } = createRequire(import.meta.url)("../package.json");
3912
3916
  var plugin = {
3913
3917
  configs: PRESETS,
3914
3918
  meta: {
3915
3919
  name: "biological-architecture",
3916
- version: "0.1.0"
3920
+ version
3917
3921
  },
3918
3922
  rules
3919
3923
  };
@@ -4,22 +4,29 @@ import {
4
4
  formatSummary,
5
5
  parityOf,
6
6
  parseParityArgs
7
- } from "./chunk-UM6UIUH3.js";
7
+ } from "./chunk-NZVMV3Y5.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
  import { fileURLToPath } from "url";
13
13
  var USAGE = `geonosis-lint-parity --a <config-a.json> --b <config-b.json> [--oxlint <path>] [--out <dir>] -- <paths\u2026>
14
- geonosis-lint-parity --corpus --a <config-a.json> --b <config-b.json> [--out <dir>]
14
+ geonosis-lint-parity --corpus [<dir>] --a <config-a.json> --b <config-b.json> [--out <dir>]
15
15
 
16
16
  Runs oxlint twice over the same paths under two configs and diffs the findings. Exits 1 when a
17
17
  finding config A reported is missing under config B \u2014 a rule that fired before and fires nowhere
18
18
  now is the adoption stopping, whatever the version numbers say.
19
19
 
20
- --corpus runs both configs over the reach corpus this package ships \u2014 one file per rule that the
21
- rule must fire on \u2014 and exits 1 when a configured rule fires under NEITHER. Run it first: a rule
22
- with no reach evidence reports parity with every other rule that also does nothing.`;
20
+ --corpus runs both configs over a reach corpus \u2014 one file per rule that the rule must fire on \u2014
21
+ and exits 1 when a rule the corpus claims to exercise fires under NEITHER. Run it first: a rule
22
+ with no reach evidence reports parity with every other rule that also does nothing. It defaults to
23
+ the corpus this package ships; give it a directory to use another plugin's. A corpus names the
24
+ rules it speaks for in its own manifest.json, and rules outside that list are reported unjudged.
25
+
26
+ oxlint resolves a jsPlugins specifier relative to the CONFIG FILE's directory, not the working
27
+ directory. Write both configs INSIDE the workspace being linted, or name the plugin by absolute
28
+ path \u2014 a config in a temp dir cannot find a plugin installed in the workspace, and the run is
29
+ refused rather than counted as zero findings.`;
23
30
  var CORPUS = resolve(dirname(fileURLToPath(import.meta.url)), "../corpus");
24
31
  var resolveOxlint = (from) => {
25
32
  let dir = from;
@@ -44,7 +51,8 @@ var main = () => {
44
51
  const configB = resolve(cwd, args.configB);
45
52
  const oxlint = args.oxlint ?? resolveOxlint(cwd);
46
53
  if (args.corpus) {
47
- const report = corpusOf({ configA, configB, corpus: CORPUS, oxlint });
54
+ const corpus = args.corpusDir === void 0 ? CORPUS : resolve(cwd, args.corpusDir);
55
+ const report = corpusOf({ configA, configB, corpus, oxlint });
48
56
  const text = formatCorpus(report);
49
57
  process.stdout.write(`${text}
50
58
  `);
package/dist/parity.js CHANGED
@@ -1,24 +1,32 @@
1
1
  import {
2
+ MANIFEST_FILE,
2
3
  ParityError,
3
4
  compareFindings,
4
5
  corpusOf,
5
6
  formatCorpus,
6
7
  formatSummary,
8
+ manifestOf,
7
9
  normaliseFindings,
8
10
  parityOf,
9
11
  parseParityArgs,
12
+ readManifest,
10
13
  readRun,
14
+ ruleIdOf,
11
15
  rulesNamedBy
12
- } from "./chunk-UM6UIUH3.js";
16
+ } from "./chunk-NZVMV3Y5.js";
13
17
  export {
18
+ MANIFEST_FILE,
14
19
  ParityError,
15
20
  compareFindings,
16
21
  corpusOf,
17
22
  formatCorpus,
18
23
  formatSummary,
24
+ manifestOf,
19
25
  normaliseFindings,
20
26
  parityOf,
21
27
  parseParityArgs,
28
+ readManifest,
22
29
  readRun,
30
+ ruleIdOf,
23
31
  rulesNamedBy
24
32
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geonosis/oxlint-plugin-biological-architecture",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Biological tier architecture as lint — the union of the dielime and during.day rule sets, shipped as presets.",
5
5
  "keywords": [
6
6
  "oxlint",