mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
261 lines
9.4 KiB
JavaScript
261 lines
9.4 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Proves an oxlint package enforces the same rules its ESLint layer did.
|
|
*
|
|
* Run `snapshot.mjs` on a tree where the package still uses ESLint, then this
|
|
* against the oxlint config:
|
|
* node scripts/lint-parity/snapshot.mjs --out /tmp/before.json --only packages/@n8n/errors
|
|
* node scripts/lint-parity/oxlint-parity.mjs --pkg packages/@n8n/errors --eslint /tmp/before.json
|
|
*
|
|
* Exits non-zero on a rule oxlint enforces that ESLint did not, and on a rule
|
|
* ESLint enforced that is missing from oxlint and from `oxlint-gap.json`. A gap
|
|
* entry is how a drop gets reviewed instead of discovered later.
|
|
*
|
|
* Two oxlint limits shape this:
|
|
*
|
|
* - `--print-config` lists native rules only, never jsPlugin ones, so the 14
|
|
* local rules would read as missing. Those are read from the config objects
|
|
* instead, which is weaker: it describes what we declared, not what oxlint
|
|
* resolved. A fixture probe is the only way to prove they fire.
|
|
* - `--print-config` does not apply overrides to its top-level rule table. Both
|
|
* sides are therefore compared as a union across the package's sample files.
|
|
*/
|
|
import { spawnSync } from 'node:child_process';
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, join, matchesGlob, resolve } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const root = join(here, '..', '..');
|
|
|
|
function arg(name) {
|
|
const i = process.argv.indexOf(name);
|
|
return i === -1 ? undefined : process.argv[i + 1];
|
|
}
|
|
|
|
const pkgDir = arg('--pkg');
|
|
const eslintFile = arg('--eslint');
|
|
if (!pkgDir || !eslintFile) {
|
|
console.error(
|
|
'usage: oxlint-parity.mjs --pkg <pkgDir> --eslint <snapshot.json> [--gap <gap.json>]',
|
|
);
|
|
process.exit(2);
|
|
}
|
|
const gapFile = arg('--gap') ?? join(here, 'oxlint-gap.json');
|
|
|
|
/**
|
|
* oxlint keeps a rule under the plugin that owns it, which is not always the
|
|
* namespace ESLint used. Everything else maps by namespace alone.
|
|
*/
|
|
const SCOPE_BY_NAMESPACE = {
|
|
'@typescript-eslint': 'typescript',
|
|
'import-x': 'import',
|
|
import: 'import',
|
|
unicorn: 'unicorn',
|
|
promise: 'promise',
|
|
jsdoc: 'jsdoc',
|
|
vue: 'vue',
|
|
};
|
|
|
|
/** Namespaces oxlint can only reach through a jsPlugin, so ids stay as ESLint wrote them. */
|
|
const JS_PLUGIN_NAMESPACES = new Set([
|
|
'@stylistic',
|
|
'import-x-alias',
|
|
'lodash',
|
|
'unused-imports',
|
|
'n8n-local-rules',
|
|
'playwright',
|
|
'@n8n/community-nodes',
|
|
]);
|
|
|
|
function splitId(id) {
|
|
const i = id.lastIndexOf('/');
|
|
return i === -1 ? ['', id] : [id.slice(0, i), id.slice(i + 1)];
|
|
}
|
|
|
|
function oxlintRuleIds() {
|
|
const out = spawnSync('pnpm', ['exec', 'oxlint', '--rules', '--format=json'], {
|
|
cwd: join(root, pkgDir),
|
|
encoding: 'utf8',
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
});
|
|
const arrayLine = out.stdout.lastIndexOf('\n[');
|
|
const start = arrayLine === -1 ? out.stdout.indexOf('[') : arrayLine + 1;
|
|
if (start === -1) throw new Error(`oxlint --rules failed: ${(out.stderr || out.stdout).trim()}`);
|
|
return new Set(JSON.parse(out.stdout.slice(start)).map((r) => `${r.scope}/${r.value}`));
|
|
}
|
|
|
|
/** ESLint id -> the oxlint id we would write in a config, or null if oxlint has no such rule. */
|
|
function toOxlintId(eslintId, native) {
|
|
if (eslintId === 'import-x/no-extraneous-dependencies') {
|
|
return 'import-x-alias/no-extraneous-dependencies';
|
|
}
|
|
const [ns, name] = splitId(eslintId);
|
|
if (JS_PLUGIN_NAMESPACES.has(ns)) return eslintId;
|
|
if (ns === '') return native.has(`eslint/${name}`) ? name : null;
|
|
const scope = SCOPE_BY_NAMESPACE[ns];
|
|
if (!scope) return null;
|
|
if (native.has(`${scope}/${name}`)) return `${scope}/${name}`;
|
|
// A handful of typescript-eslint extension rules live in oxlint's eslint scope.
|
|
if (native.has(`eslint/${name}`)) return name;
|
|
return null;
|
|
}
|
|
|
|
/** A rule id as it appears in an oxlint config -> its canonical `scope/value` form. */
|
|
function canonical(configId, native) {
|
|
const [ns, name] = splitId(configId);
|
|
if (ns === '') return native.has(`eslint/${name}`) ? `eslint/${name}` : configId;
|
|
return configId;
|
|
}
|
|
|
|
function isEnabled(severity) {
|
|
// oxlint prints `deny`/`warn`/`allow`; a config states error/warn/off. Every
|
|
// lint script runs with --quiet, so only the deny set is enforced.
|
|
const value = Array.isArray(severity) ? severity[0] : severity;
|
|
return value === 'deny' || value === 'error' || value === 2;
|
|
}
|
|
|
|
const matchesPattern = (file, pattern) => matchesGlob(file, pattern.replace(/^\.\//, ''));
|
|
|
|
function overrideMatchesSample({ files = [], excludeFiles = [], ignores = [] }) {
|
|
return sampledFiles.some(
|
|
(file) =>
|
|
(files.length === 0 || files.some((pattern) => matchesPattern(file, pattern))) &&
|
|
![...excludeFiles, ...ignores].some((pattern) => matchesPattern(file, pattern)),
|
|
);
|
|
}
|
|
|
|
/** Native rules oxlint actually resolved for this package. */
|
|
function resolvedNativeRules() {
|
|
const out = spawnSync('pnpm', ['exec', 'oxlint', '--print-config'], {
|
|
cwd: join(root, pkgDir),
|
|
encoding: 'utf8',
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
});
|
|
const start = out.stdout.indexOf('{');
|
|
if (start === -1) {
|
|
throw new Error(`oxlint --print-config failed: ${(out.stderr || out.stdout).trim()}`);
|
|
}
|
|
const parsed = JSON.parse(out.stdout.slice(start));
|
|
return [
|
|
...Object.entries(parsed.rules ?? {}),
|
|
...(parsed.overrides ?? [])
|
|
.filter(overrideMatchesSample)
|
|
.flatMap(({ rules }) => Object.entries(rules ?? {})),
|
|
]
|
|
.filter(([, severity]) => isEnabled(severity))
|
|
.map(([id]) => id);
|
|
}
|
|
|
|
/**
|
|
* jsPlugin rules, read from the config objects because `--print-config` omits
|
|
* them. `extends` is depth-first and later wins, matching how oxlint merges.
|
|
*/
|
|
async function declaredJsPluginRules() {
|
|
const mod = await import(pathToFileURL(join(root, pkgDir, 'oxlint.config.mts')).href);
|
|
const declared = new Map();
|
|
const overrideOnly = new Map();
|
|
const plugins = new Set();
|
|
const addPlugin = (plugin) => plugins.add(typeof plugin === 'string' ? plugin : plugin.name);
|
|
|
|
const visit = (config) => {
|
|
for (const parent of config.extends ?? []) visit(parent);
|
|
for (const plugin of config.jsPlugins ?? []) addPlugin(plugin);
|
|
for (const [id, severity] of Object.entries(config.rules ?? {})) declared.set(id, severity);
|
|
for (const override of config.overrides ?? []) {
|
|
for (const plugin of override.jsPlugins ?? []) addPlugin(plugin);
|
|
for (const [id, severity] of Object.entries(override.rules ?? {})) {
|
|
// An override that turns a rule off still leaves it enforced elsewhere
|
|
// in the package, and this comparison is a union.
|
|
if (!isEnabled(severity)) continue;
|
|
if (!isEnabled(declared.get(id)) || overrideOnly.has(id)) {
|
|
const overrides = overrideOnly.get(id) ?? [];
|
|
overrides.push(override);
|
|
overrideOnly.set(id, overrides);
|
|
}
|
|
declared.set(id, severity);
|
|
}
|
|
}
|
|
};
|
|
visit(mod.default);
|
|
|
|
const ids = [...declared]
|
|
.filter(([id, severity]) => isEnabled(severity) && JS_PLUGIN_NAMESPACES.has(splitId(id)[0]))
|
|
.map(([id]) => id);
|
|
return { ids, overrideOnly, plugins: [...plugins] };
|
|
}
|
|
|
|
const native = oxlintRuleIds();
|
|
const gap = JSON.parse(readFileSync(gapFile, 'utf8'));
|
|
|
|
// ESLint's enforced set, unioned across the package's sample files.
|
|
const snapshot = JSON.parse(readFileSync(resolve(eslintFile), 'utf8'));
|
|
const eslintRules = new Set();
|
|
let sampled = 0;
|
|
const sampledFiles = [];
|
|
for (const [key, entry] of Object.entries(snapshot)) {
|
|
if (!key.startsWith(`${pkgDir}|`) || !entry.rules) continue;
|
|
sampled++;
|
|
sampledFiles.push(key.split('|')[1]);
|
|
for (const id of Object.keys(entry.rules)) eslintRules.add(id);
|
|
}
|
|
if (sampled === 0) {
|
|
console.error(`no ESLint snapshot entries for ${pkgDir} in ${eslintFile}`);
|
|
process.exit(2);
|
|
}
|
|
|
|
const { ids: jsPluginIds, overrideOnly, plugins } = await declaredJsPluginRules();
|
|
|
|
const comparable = (id) => {
|
|
const overrides = overrideOnly.get(id);
|
|
return !overrides || overrides.some(overrideMatchesSample);
|
|
};
|
|
|
|
const oxlintRules = new Set(
|
|
[...resolvedNativeRules(), ...jsPluginIds].filter(comparable).map((id) => canonical(id, native)),
|
|
);
|
|
|
|
const matched = [];
|
|
const missing = [];
|
|
for (const id of [...eslintRules].sort()) {
|
|
const target = toOxlintId(id, native);
|
|
if (target && oxlintRules.has(canonical(target, native))) {
|
|
matched.push(id);
|
|
continue;
|
|
}
|
|
missing.push({ id, native: target });
|
|
}
|
|
|
|
// Anything oxlint enforces that no ESLint rule maps onto. A rule we did not ask
|
|
// for is as much a parity break as a rule we lost.
|
|
const expected = new Set();
|
|
for (const id of eslintRules) {
|
|
const target = toOxlintId(id, native);
|
|
if (target) expected.add(canonical(target, native));
|
|
}
|
|
const extra = [...oxlintRules].filter((id) => !expected.has(id)).sort();
|
|
|
|
const hasApplicableGap = (id) => {
|
|
const entry = gap[id];
|
|
return entry !== undefined && (!entry.packages || entry.packages.includes(pkgDir));
|
|
};
|
|
const isDocumented = ({ id, native: target }) =>
|
|
hasApplicableGap(id) || (target !== null && hasApplicableGap(target));
|
|
const undocumented = missing.filter((entry) => !isDocumented(entry));
|
|
|
|
console.log(`${pkgDir}: ${sampled} sample files, ${eslintRules.size} ESLint rules at error`);
|
|
console.log(` jsPlugins declared: ${plugins.join(', ') || 'none'}`);
|
|
console.log(` matched: ${matched.length}`);
|
|
console.log(
|
|
` missing in oxlint: ${missing.length} (${missing.length - undocumented.length} documented)`,
|
|
);
|
|
console.log(` extra in oxlint: ${extra.length}`);
|
|
|
|
for (const id of extra) console.log(` EXTRA ${id}`);
|
|
for (const { id, native: target } of undocumented) {
|
|
console.log(` MISSING ${id}${target ? ` (oxlint has ${target})` : ''} — not in ${gapFile}`);
|
|
}
|
|
|
|
if (extra.length > 0 || undocumented.length > 0) process.exit(1);
|
|
console.log(' parity OK');
|