@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 +28 -0
- package/dist/{chunk-OLCJQ6JC.js → chunk-YPSGSLHS.js} +86 -11
- package/dist/index.d.ts +35 -2
- package/dist/index.js +1 -1
- package/dist/parity-cli.js +16 -3
- package/package.json +1 -1
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
|
|
180
|
-
|
|
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
|
-
)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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 =
|
|
197
|
-
inB =
|
|
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) =>
|
|
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
package/dist/parity-cli.js
CHANGED
|
@@ -4,13 +4,14 @@ import {
|
|
|
4
4
|
formatSummary,
|
|
5
5
|
parityOf,
|
|
6
6
|
parseParityArgs
|
|
7
|
-
} from "./chunk-
|
|
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({
|
|
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,
|