@geonosis/lint-parity
v3.4.1
Published
Findings parity and rule reach over any two oxlint configs — the diff a fork is deleted on.
Maintainers
Readme
@geonosis/lint-parity
Through the front door: geonosis lint-parity — the metapackage pins this and every other kit tool
at ONE version, and passes the exit code through unchanged.
A repo does not delete its vendored copy of a lint plugin because the version numbers match. It
deletes it when the findings match: same tree, same rule ids, two configs, and a diff.
geonosis-lint-parity is that diff — and, before it, the answer to whether either config's rules
reach anything at all.
It knows no rule names and no plugin. Two oxlint configs and a tree are the whole input; a corpus,
when there is one, arrives as a directory whose own manifest.json says which rules it can speak
for. So it checks any plugin's adoption, including plugins it was never shipped beside.
Install
pnpm add -D @geonosis/lint-parity oxlintgeonosis-lint-parity --a <config-a.json> --b <config-b.json> [--oxlint <path>] [--out <dir>] [--json] -- <paths…>
geonosis-lint-parity --corpus <dir> --a <config-a.json> --b <config-b.json> [--out <dir>] [--json]
[--expect-changed <rule,rule,…>]Write both configs inside the workspace being linted — <ws>/.oxlintrc.parity-a.json, deleted
afterwards — or name the plugin by absolute path in jsPlugins. oxlint resolves a jsPlugins
specifier relative to the config file's directory, not the working directory, so a config
written to a temp dir cannot find a plugin installed in the workspace: it fails with
Cannot find module '@…/plugin', and the tool correctly refuses with
config A: oxlint exited 1 without reporting a single finding.
--oxlint overrides the binary; by default it walks up from the working directory for
node_modules/.bin/oxlint, so the comparison runs under the consumer's own linter rather than one
npx might fetch.
Step 0 — reach, before parity
Parity between two configs that both do nothing is perfect parity. In one repo 34 of 51 configured
rules fired nowhere in any corpus it had: 34 rules a consumer would inherit as claims nothing could
check. --corpus answers that directly.
geonosis-lint-parity \
--corpus node_modules/@geonosis/oxlint-plugin-biological-architecture/corpus \
--a oxlintrc.fork.json --b oxlintrc.published.json --out proofs/reachBoth configs run over the corpus — copied to a temp root first, so neither a .gitignore nor
oxlint's node_modules skip can quietly turn "every rule fired nowhere" into the answer. It prints
a fired-under-A / fired-under-B row per in-scope rule and exits 1 when one fires under neither.
--out writes CORPUS.md.
A rule enabled without an option it requires reports that as a finding — <rule> requires option
\`.` — and judges no file. That finding is never reach: it is listed under enabled
but unconfigured, apart from the rules that fire nowhere, and fails the run like them. Read as
reach it made a rule that looked at nothing count as covered.
The directory is required. This package ships no corpus, because a corpus is one plugin's
evidence: one deliberately broken file per rule, and a manifest.json naming the rules it therefore
speaks for. A rule the manifest does not name — another plugin's, one of oxlint's built-ins — is
listed under outside this corpus and gets no verdict. Judging those would fail every real config
for saying nothing.
A plugin generates its manifest from its own rules export, so a rule cannot be added and forgotten in the list of what the corpus is evidence about:
import { manifestOf, MANIFEST_FILE } from '@geonosis/lint-parity'
writeFileSync(join(corpus, MANIFEST_FILE), `${JSON.stringify(manifestOf(plugin), null, 2)}\n`)And a release's claims, checked
A changelog line that names a rule is a claim. --expect-changed makes it data the corpus judges:
geonosis-lint-parity --corpus node_modules/<plugin>/corpus \
--a oxlintrc.old.json --b oxlintrc.new.json \
--expect-changed some-rule,another-ruleIt exits 1 unless the corpus shows exactly those rules behaving differently under the two configs — findings in different places, which covers a rule that started or stopped firing, gained a case, or moved to other files with the same count:
| verdict | means |
| --- | --- |
| HELD | claimed, and the corpus shows it changing |
| UNPROVEN | claimed, and the corpus shows nothing — including a rule the corpus does not exercise |
| UNCLAIMED | the corpus shows it changing and the release never said so |
The report prints the claims table beside the reach table. Without the flag no claim is made and none is judged, which is what a corpus run has always done.
Why it exists: a release once announced a behaviour widening for ten rules while its corpus carried evidence for two, and the parity run offered as proof went over a consumer tree that had no such violation in it — so every number matched. The claim is the thing to check, and the corpus is what checks it.
Then parity
It runs oxlint twice with --format=unix over the same paths, strips the working-directory prefix,
sorts, and prints the two counts, only in A, only in B and a per-rule tally. It exits 1 when
only in A is non-empty — a finding the old config caught and the new one does not. New findings
are what an upgrade is for; lost ones stop the adoption. --out writes a.txt, b.txt,
only-in-a.txt, only-in-b.txt, changed.txt and SUMMARY.md for the proof file.
A finding is identified by where it is and which rule found it — path:line:col plus
plugin(rule). The message is not part of it, and neither is the severity. A release that only
rewords a rule's message would otherwise show as one finding lost and one gained, and stop an
adoption over a sentence. Those pair up instead under changed, reported in full and never
reaching the exit code; a severity change lands there too, because that is a fact about the config
rather than a rule that stopped firing.
A run it cannot read is an error, never a number. oxlint exits 1 both when it reported findings and when it never linted a file at all — a rule name it does not know, options a rule has no schema for, a plugin it could not load. All three print a paragraph and no findings, and "0 findings" on one side reads as agreement with the other. A summary counting findings no line accounts for, or a non-zero exit with nothing readable on it, stops the run.
Fork vs published, before deleting a vendored copy — two configs identical but for jsPlugins:
geonosis-lint-parity \
--a oxlintrc.fork.json --b oxlintrc.published.json \
--out proofs/adoption-parity \
-- apps packagesVersion vs version, as a release check — the same config, pointing first at the installed version and then at a local build, by absolute path:
geonosis-lint-parity \
--a oxlintrc.old.json --b oxlintrc.new.json \
--out proofs/parity-old-vs-new \
-- apps packagesMeasure one config over a consumer's tree
geonosis-lint-parity --measure narrowed.json --root ../their-repo [--json] [-- <paths…>]oxlint resolves an override's files and a config's ignorePatterns against the directory of the
CONFIG FILE. A narrowed config written beside a tree rather than in it measures nothing its
overrides say and ignores nothing its ignorePatterns say, and the count comes back a confident,
wrong number: three planted breaches read 0 beside the tree and 3 inside it. --measure runs the
config in a root of its own, every top-level entry of the tree linked into it and the config written
in as .oxlintrc.json; the tree itself is never touched. It prints the findings per rule and how
many files the config opened, and refuses a config that opened none — a measurement over nothing is
not zero findings.
Programmatic use
import { compareFindings, corpusOf, normaliseFindings, parityOf, readRun } from '@geonosis/lint-parity'
const parity = parityOf({ configA, configB, cwd, oxlint, paths: ['apps', 'packages'] })
process.exit(parity.lost ? 1 : 0)parityOf and corpusOf spawn the binary; normaliseFindings, readRun and compareFindings are
pure and take output a run already produced.
Apache-2.0.
