build: Initialize n8n-oxlint config (#38565)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Matsu 2026-09-14 07:55:26 +00:00 committed by GitHub
parent 68d567c063
commit 4cda10ebc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 847 additions and 39 deletions

View File

@ -1,4 +0,0 @@
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@n8n/eslint-config/base';
export default defineConfig(baseConfig);

View File

@ -0,0 +1,7 @@
import { baseConfig } from '@n8n/oxlint-config/base';
import { defineConfig } from 'oxlint';
export default defineConfig({
extends: [baseConfig],
options: { typeAware: true },
});

View File

@ -10,8 +10,8 @@
"build:unchecked": "tsc -p tsconfig.build.json --noCheck",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"lint": "oxlint --quiet",
"lint:fix": "oxlint --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "vitest run",
"test:unit": "vitest run",
@ -35,8 +35,11 @@
"yjs": "^13.6.20"
},
"devDependencies": {
"@n8n/oxlint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
"typescript": "catalog:typescript",
"vitest": "catalog:",
"vitest-websocket-mock": "^0.5.0"

View File

@ -1,4 +0,0 @@
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@n8n/eslint-config/base';
export default defineConfig(baseConfig);

View File

@ -0,0 +1,7 @@
import { baseConfig } from '@n8n/oxlint-config/base';
import { defineConfig } from 'oxlint';
export default defineConfig({
extends: [baseConfig],
options: { typeAware: true },
});

View File

@ -9,8 +9,8 @@
"build:unchecked": "tsc -p tsconfig.build.json --noCheck",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"lint": "oxlint --quiet",
"lint:fix": "oxlint --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "vitest run",
"test:unit": "vitest run",
@ -26,12 +26,15 @@
"reflect-metadata": "catalog:"
},
"devDependencies": {
"@n8n/oxlint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@vitest/coverage-v8": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
"typescript": "catalog:typescript",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:",
"@n8n/typescript-config": "workspace:*"
"vitest-mock-extended": "catalog:"
},
"license": "LicenseRef-n8n-sustainable-use"
}

View File

@ -1,4 +0,0 @@
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@n8n/eslint-config/base';
export default defineConfig(baseConfig);

View File

@ -0,0 +1,7 @@
import { baseConfig } from '@n8n/oxlint-config/base';
import { defineConfig } from 'oxlint';
export default defineConfig({
extends: [baseConfig],
options: { typeAware: true },
});

View File

@ -9,8 +9,8 @@
"build:unchecked": "tsc -p tsconfig.build.json --noCheck",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"lint": "oxlint --quiet",
"lint:fix": "oxlint --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "node -e \"process.exit(0)\"",
"test:dev": "echo \"WARNING: no test specified\" && exit 0"
@ -22,8 +22,11 @@
"dist/**/*"
],
"devDependencies": {
"@n8n/oxlint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@sentry/node": "catalog:sentry",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
"typescript": "catalog:typescript"
},
"dependencies": {

View File

@ -19,6 +19,10 @@
"./nodes": {
"types": "./dist/configs/nodes.d.js",
"default": "./dist/configs/nodes.js"
},
"./plugin": {
"types": "./dist/plugin.d.ts",
"default": "./dist/plugin.js"
}
},
"scripts": {

View File

@ -37,3 +37,6 @@ export const localRulesPlugin = {
},
},
} satisfies ESLint.Plugin;
// oxlint loads a jsPlugin from the module default export.
export default localRulesPlugin;

View File

@ -0,0 +1,37 @@
{
"private": true,
"name": "@n8n/oxlint-config",
"type": "module",
"version": "0.0.1",
"exports": {
"./base": {
"types": "./dist/configs/base.d.ts",
"default": "./dist/configs/base.js"
}
},
"scripts": {
"build": "tsc",
"build:unchecked": "tsc --noCheck",
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "node -e \"process.exit(0)\"",
"test": "node -e \"process.exit(0)\"",
"typecheck": "tsc --noEmit",
"watch": "tsc --watch"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@types/node": "catalog:",
"typescript": "catalog:typescript-tooling"
},
"dependencies": {
"@n8n/eslint-config": "workspace:*",
"@stylistic/eslint-plugin": "^5.0.0",
"eslint-plugin-lodash": "^8.0.0",
"eslint-plugin-unused-imports": "^4.1.4",
"oxlint": "catalog:"
},
"license": "LicenseRef-n8n-sustainable-use"
}

View File

@ -0,0 +1,224 @@
import { defineConfig } from 'oxlint';
/**
* oxlint translation of `@n8n/eslint-config/base`.
*
* Every rule below is at `error` in the ESLint layer. Rules the ESLint layer
* sets to `warn` or `off` are absent: every lint script runs with `--quiet`, so
* only the error set is enforced. Rules oxlint cannot run are listed in
* `scripts/lint-parity/oxlint-gap.json` with a reason.
*
* Options are the ones the ESLint layer states itself. Where it relies on a
* preset default, oxlint's own default applies.
*
* `categories` is off wholesale and every rule enumerated: oxlint's categories
* cut across the layer (rules we enable sit in `pedantic`, `restriction` and
* `style`), so a category both misses rules and adds unrequested ones.
*/
export const baseConfig = defineConfig({
plugins: ['typescript', 'import', 'unicorn', 'oxc'],
jsPlugins: [
'@n8n/eslint-config/plugin',
'@stylistic/eslint-plugin',
'eslint-plugin-unused-imports',
'eslint-plugin-lodash',
],
categories: { correctness: 'off' },
ignorePatterns: [
'node_modules/**',
'dist/**',
'eslint.config.mjs',
'oxlint.config.mts',
'tsup.config.ts',
'vite.config.ts',
'vitest.config.ts',
],
settings: {
'import-x/extensions': ['.ts', '.tsx', '.cts', '.mts', '.js', '.jsx', '.cjs', '.mjs'],
'import-x/external-module-folders': ['node_modules', 'node_modules/@types'],
'import-x/resolver-next': [{ interfaceVersion: 3, name: 'eslint-import-resolver-typescript' }],
},
rules: {
// ----------------------------------
// ESLint
// ----------------------------------
eqeqeq: 'error',
'for-direction': 'error',
'id-denylist': [
'error',
'err',
'cb',
'callback',
'any',
'Number',
'number',
'String',
'string',
'Boolean',
'boolean',
'Undefined',
'undefined',
],
'no-array-constructor': 'error',
'no-async-promise-executor': 'error',
'no-case-declarations': 'error',
'no-compare-neg-zero': 'error',
'no-cond-assign': 'error',
'no-constant-binary-expression': 'error',
'no-constant-condition': 'error',
'no-control-regex': 'error',
'no-debugger': 'error',
'no-delete-var': 'error',
'no-dupe-else-if': 'error',
'no-duplicate-case': 'error',
'no-empty-character-class': 'error',
'no-empty-pattern': 'error',
'no-empty-static-block': 'error',
'no-ex-assign': 'error',
'no-extra-boolean-cast': 'error',
'no-fallthrough': 'error',
'no-global-assign': 'error',
'no-invalid-regexp': 'error',
'no-irregular-whitespace': 'error',
'no-loss-of-precision': 'error',
'no-misleading-character-class': 'error',
'no-nonoctal-decimal-escape': 'error',
'no-prototype-builtins': 'error',
'no-regex-spaces': 'error',
'no-self-assign': 'error',
'no-shadow-restricted-names': 'error',
'no-sparse-arrays': 'error',
'no-unsafe-finally': 'error',
'no-unsafe-optional-chaining': 'error',
// oxlint keeps these two in the `eslint` scope, not `typescript`.
'no-unused-expressions': 'error',
'no-unused-labels': 'error',
'no-unused-private-class-members': 'error',
'no-useless-backreference': 'error',
'no-useless-catch': 'error',
'no-useless-escape': 'error',
'no-var': 'error',
'no-void': ['error', { allowAsStatement: true }],
'object-shorthand': 'error',
'prefer-const': 'error',
'prefer-rest-params': 'error',
'require-yield': 'error',
'use-isnan': 'error',
'valid-typeof': 'error',
// ----------------------------------
// typescript
// ----------------------------------
'typescript/array-type': ['error', { default: 'array-simple' }],
'typescript/await-thenable': 'error',
'typescript/ban-ts-comment': ['error', { 'ts-ignore': true }],
'typescript/consistent-type-assertions': 'error',
'typescript/consistent-type-exports': 'error',
'typescript/explicit-member-accessibility': ['error', { accessibility: 'no-public' }],
'typescript/no-array-delete': 'error',
'typescript/no-base-to-string': 'error',
'typescript/no-duplicate-enum-values': 'error',
'typescript/no-duplicate-type-constituents': 'error',
'typescript/no-explicit-any': 'error',
'typescript/no-extra-non-null-assertion': 'error',
'typescript/no-floating-promises': ['error', { ignoreVoid: true }],
'typescript/no-for-in-array': 'error',
'typescript/no-implied-eval': 'error',
'typescript/no-invalid-void-type': 'error',
'typescript/no-misused-new': 'error',
'typescript/no-misused-promises': ['error', { checksVoidReturn: false }],
'typescript/no-non-null-asserted-optional-chain': 'error',
'typescript/no-redundant-type-constituents': 'error',
'typescript/no-require-imports': 'error',
'typescript/no-restricted-types': [
'error',
{
types: {
Object: { message: 'Use object instead', fixWith: 'object' },
String: { message: 'Use string instead', fixWith: 'string' },
Boolean: { message: 'Use boolean instead', fixWith: 'boolean' },
Number: { message: 'Use number instead', fixWith: 'number' },
Symbol: { message: 'Use symbol instead', fixWith: 'symbol' },
Function: {
message: [
'The `Function` type accepts any function-like value.',
'It provides no type safety when calling the function, which can be a common source of bugs.',
'It also accepts things like class declarations, which will throw at runtime as they will not be called with `new`.',
'If you are expecting the function to accept certain arguments, you should explicitly define the function shape.',
].join('\n'),
},
},
},
],
'typescript/no-this-alias': 'error',
'typescript/no-unnecessary-boolean-literal-compare': 'error',
'typescript/no-unnecessary-qualifier': 'error',
'typescript/no-unnecessary-type-assertion': 'error',
'typescript/no-unnecessary-type-constraint': 'error',
'typescript/no-unsafe-declaration-merging': 'error',
'typescript/no-unsafe-enum-comparison': 'error',
'typescript/no-unsafe-unary-minus': 'error',
'typescript/no-wrapper-object-types': 'error',
'typescript/only-throw-error': 'error',
'typescript/prefer-as-const': 'error',
'typescript/prefer-namespace-keyword': 'error',
'typescript/prefer-promise-reject-errors': 'error',
'typescript/promise-function-async': 'error',
'typescript/restrict-plus-operands': 'error',
'typescript/restrict-template-expressions': 'error',
'typescript/return-await': ['error', 'always'],
// ----------------------------------
// import
// ----------------------------------
'import/no-cycle': ['error', { ignoreExternal: false, maxDepth: 3 }],
'import/no-duplicates': 'error',
// ----------------------------------
// unicorn
// ----------------------------------
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
'unicorn/no-unnecessary-await': 'error',
'unicorn/no-useless-promise-resolve-reject': 'error',
// ----------------------------------
// jsPlugins
// ----------------------------------
'@stylistic/member-delimiter-style': [
'error',
{
multiline: { delimiter: 'semi', requireLast: true },
singleline: { delimiter: 'semi', requireLast: false },
},
],
'lodash/import-scope': ['error', 'method'],
'lodash/path-style': ['error', 'as-needed'],
'unused-imports/no-unused-imports': 'error',
'n8n-local-rules/no-application-error': 'error',
'n8n-local-rules/no-aws-credential-discovery-imports': 'error',
'n8n-local-rules/no-internal-package-import': 'error',
'n8n-local-rules/no-interpolation-in-regular-string': 'error',
'n8n-local-rules/no-json-parse-json-stringify': 'error',
'n8n-local-rules/no-restricted-sleep-definition': 'error',
'n8n-local-rules/no-restricted-sleep-import': 'error',
'n8n-local-rules/no-type-only-import-in-di': 'error',
'n8n-local-rules/no-unneeded-backticks': 'error',
'n8n-local-rules/no-unused-param-in-catch-clause': 'error',
'n8n-local-rules/no-useless-catch-throw': 'error',
},
overrides: [
{
files: ['test/**/*.ts', '**/__tests__/*.ts', '**/*.test.ts'],
// An override that names a jsPlugin rule must re-declare the plugin.
jsPlugins: ['@n8n/eslint-config/plugin'],
rules: {
// Test code casts mocks into position; the rule's assignability check reads
// those casts as redundant and removing them breaks the build.
'typescript/no-unnecessary-type-assertion': 'off',
'n8n-local-rules/no-skipped-tests': 'error',
'n8n-local-rules/no-error-instance-in-to-throw': 'error',
},
},
],
});

View File

@ -0,0 +1,14 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.go.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node"],
"esModuleInterop": true,
"module": "NodeNext",
"moduleResolution": "nodenext",
"declaration": true
},
"include": ["src/**/*.ts"],
"files": []
}

View File

@ -181,6 +181,40 @@ export const extra = {
expect(violations[2]).toContain('`eslint-disable` directive silences every lint rule');
});
it('flags oxlint-spelled directives the same way', async () => {
write(
'packages/a/src/index.ts',
[
'// oxlint-disable-next-line n8n-local-rules/no-legacy-cipher-methods',
'export const a = 1; // oxlint-disable-line no-console, no-deployment-key-delete',
'/* oxlint-disable */',
'/* oxlint n8n-local-rules/no-misplaced-cipher-primitives: "off" */',
'',
].join('\n'),
);
const violations = await analyze();
expect(violations).toHaveLength(4);
expect(violations[0]).toContain('no-legacy-cipher-methods');
expect(violations[1]).toContain('no-deployment-key-delete');
expect(violations[2]).toContain('`oxlint-disable` directive silences every lint rule');
expect(violations[3]).toContain('no-misplaced-cipher-primitives');
});
it('accepts oxlint directives that name unrelated rules', async () => {
write(
'packages/a/src/index.ts',
[
'// oxlint-disable-next-line typescript/no-explicit-any',
'/* oxlint-disable unicorn/filename-case */',
'',
].join('\n'),
);
expect(await analyze()).toEqual([]);
});
it('flags directives that name a guarded rule, with or without the plugin prefix', async () => {
write(
'packages/a/src/index.ts',
@ -199,7 +233,7 @@ export const extra = {
expect(violations[0]).toContain('no-legacy-cipher-methods');
expect(violations[1]).toContain('no-deployment-key-delete');
expect(violations[2]).toContain('no-misplaced-cipher-primitives');
expect(violations[3]).toContain('inline ESLint configuration comment');
expect(violations[3]).toContain('inline `eslint` configuration comment');
});
it('ignores directive text inside string literals', async () => {

View File

@ -60,14 +60,18 @@ const SOURCE_IGNORE = [
];
/**
* An ESLint directive comment: a disable in any of its three forms, or an
* A linter directive comment: a disable in any of its three forms, or an
* inline configuration comment (`eslint rule: severity`). The captured form
* must end right after its name, so `eslint-enable` and `eslint-env` do not
* match. A quote right before the comment opener means directive text inside
* a string literal (e.g. a code generator), not a directive.
*
* oxlint honours its own `oxlint-disable` spelling as well as ESLint's, so a
* scanner that knows only the ESLint form leaves a silent second way to
* suppress a guardrail in any package that runs oxlint.
*/
const DIRECTIVE =
/(?<!['"`])(?:\/\/|\/\*)[ \t]*(eslint-disable(?:-next-line|-line)?|eslint)(?![\w-])(.*)$/;
/(?<!['"`])(?:\/\/|\/\*)[ \t]*((?:es|ox)lint-disable(?:-next-line|-line)?|(?:es|ox)lint)(?![\w-])(.*)$/;
function stringArrayOption(value: unknown, fallback: string[]): string[] {
return Array.isArray(value) && value.every((entry): entry is string => typeof entry === 'string')
@ -211,7 +215,7 @@ export class EncryptionBoundaryRule extends BaseRule<CodeHealthContext> {
const body = rest.split('*/')[0];
const column = match.index + 1;
if (form === 'eslint') {
if (form === 'eslint' || form === 'oxlint') {
const rule = guardedRules.find((name) =>
new RegExp(`(?:^|[\\s/'"])${name}(?![\\w-])`).test(body),
);
@ -220,7 +224,7 @@ export class EncryptionBoundaryRule extends BaseRule<CodeHealthContext> {
file,
lineNumber,
column,
`An inline ESLint configuration comment reconfigures the encryption guardrail \`${rule}\`.`,
`An inline \`${form}\` configuration comment reconfigures the encryption guardrail \`${rule}\`.`,
`Remove the comment. Widen the boundary in ${BOUNDARY_CONFIG_PATH} instead.`,
);
}

View File

@ -10,6 +10,10 @@ const HEAD = `import { defineConfig } from 'eslint/config';
import { backendConfig } from '@n8n/eslint-config/backend';
`;
const OXLINT_HEAD = `import { baseConfig } from '@n8n/oxlint-config/base';
import { defineConfig } from 'oxlint';
`;
describe('LintConfigLayeringRule', () => {
let rootDir: string;
let rule: LintConfigLayeringRule;
@ -23,11 +27,14 @@ describe('LintConfigLayeringRule', () => {
fs.rmSync(rootDir, { recursive: true, force: true });
});
function writePackage(dir: string, config: string | null): void {
function writePackage(dir: string, config: string | null, oxlintConfig?: string): void {
const full = path.join(rootDir, dir);
fs.mkdirSync(full, { recursive: true });
fs.writeFileSync(path.join(full, 'package.json'), JSON.stringify({ name: dir }));
if (config !== null) fs.writeFileSync(path.join(full, 'eslint.config.mjs'), config);
if (oxlintConfig !== undefined) {
fs.writeFileSync(path.join(full, 'oxlint.config.mts'), oxlintConfig);
}
}
async function analyze(options: Record<string, unknown> = {}): Promise<string[]> {
@ -103,12 +110,37 @@ export default defineConfig(backendConfig, {
expect(await analyze()).toEqual([]);
});
it('a package with no ESLint config', async () => {
it('a package with no lint config at all', async () => {
writePackage('packages/a', null);
expect(await analyze()).toEqual([]);
});
it('an oxlint config that only extends its layer', async () => {
writePackage(
'packages/a',
null,
`${OXLINT_HEAD}\nexport default defineConfig({ extends: [baseConfig], options: { typeAware: true } });\n`,
);
expect(await analyze()).toEqual([]);
});
it('an oxlint relaxation scoped to paths via overrides', async () => {
writePackage(
'packages/a',
null,
`${OXLINT_HEAD}
export default defineConfig({
extends: [baseConfig],
overrides: [{ files: ['**/*.test.ts'], rules: { 'typescript/no-explicit-any': 'off' } }],
});
`,
);
expect(await analyze()).toEqual([]);
});
it('an exempt package', async () => {
writePackage(
'packages/frontend/@n8n/eslint-plugin-design-system',
@ -122,6 +154,48 @@ export default defineConfig(backendConfig, {
});
describe('reports', () => {
it('an oxlint config that turns a rule down for the whole package', async () => {
writePackage(
'packages/a',
null,
`${OXLINT_HEAD}
export default defineConfig({
extends: [baseConfig],
rules: { 'typescript/no-explicit-any': 'off' },
});
`,
);
expect(await analyze()).toEqual([
"packages/a/oxlint.config.mts:6 packages/a turns 'typescript/no-explicit-any' down to 'off' for the whole package.",
]);
});
it('an oxlint config that extends no shared layer', async () => {
writePackage(
'packages/a',
null,
"import { defineConfig } from 'oxlint';\nexport default defineConfig({ rules: {} });\n",
);
expect(await analyze()).toEqual([
'packages/a/oxlint.config.mts:1 packages/a does not extend a shared oxlint layer.',
]);
});
it('both configs of a package mid-migration', async () => {
writePackage(
'packages/a',
`${HEAD}\nexport default defineConfig(backendConfig, { rules: { 'no-void': 'warn' } });\n`,
`${OXLINT_HEAD}\nexport default defineConfig({ extends: [baseConfig], rules: { 'no-void': 'warn' } });\n`,
);
expect(await analyze()).toEqual([
"packages/a/eslint.config.mjs:4 packages/a turns 'no-void' down to 'warn' for the whole package.",
"packages/a/oxlint.config.mts:4 packages/a turns 'no-void' down to 'warn' for the whole package.",
]);
});
it('a package-wide downgrade', async () => {
writePackage(
'packages/a',

View File

@ -10,12 +10,51 @@ import { findPackageJsonFiles, relativeDir } from '../utils/package-json-scanner
const CONFIG_FILENAMES = ['eslint.config.mjs', 'eslint.config.js', 'eslint.config.cjs'];
/**
* A package that has moved to oxlint deletes its ESLint config, so the layering
* policy has to follow it there. Only the layered `oxlint.config.{mts,ts}` form
* is checked: `.oxlintrc.json` is the editor-ui bridge, a standalone rule table
* whose policy still lives in its ESLint config.
*/
const OXLINT_CONFIG_FILENAMES = ['oxlint.config.mts', 'oxlint.config.ts'];
const LAYERS = ['base', 'backend', 'frontend', 'nodes'];
const LAYER_IMPORT = /^@n8n\/eslint-config\/([a-z-]+)$/;
const OXLINT_LAYER_IMPORT = /^@n8n\/oxlint-config\/([a-z-]+)$/;
/** Retired export paths, replaced by the four layers. */
const REMOVED_SUBPATHS = new Set(['node', 'encryption-boundary']);
/** What differs between an ESLint config and an oxlint one. */
interface Flavour {
/** `@n8n/eslint-config` or `@n8n/oxlint-config`, as written in an import. */
layerPackage: string;
layerImport: RegExp;
/** oxlint configs are TypeScript; ESLint ones are parsed as JavaScript. */
scriptKind: ScriptKind;
/** Kept verbatim per flavour: the baseline is keyed by message text. */
missingLayerMessage: (packageName: string) => string;
missingLayerSuggestion: string;
}
const ESLINT_FLAVOUR: Flavour = {
layerPackage: '@n8n/eslint-config',
layerImport: LAYER_IMPORT,
scriptKind: ScriptKind.JS,
missingLayerMessage: (packageName) => `${packageName} does not extend a shared ESLint layer.`,
missingLayerSuggestion:
'Import baseConfig, backendConfig, frontendConfig or nodesConfig from @n8n/eslint-config and pass it to defineConfig.',
};
const OXLINT_FLAVOUR: Flavour = {
layerPackage: '@n8n/oxlint-config',
layerImport: OXLINT_LAYER_IMPORT,
scriptKind: ScriptKind.TS,
missingLayerMessage: (packageName) => `${packageName} does not extend a shared oxlint layer.`,
missingLayerSuggestion:
'Import a layer from @n8n/oxlint-config and pass it to defineConfig as `extends: [layer]`.',
};
/**
* A `files` glob that covers effectively the whole package, so a block scoped to
* it is a package-wide decision wearing a scope.
@ -69,24 +108,31 @@ export class LintConfigLayeringRule extends BaseRule<CodeHealthContext> {
const rel = relativeDir(rootDir, packageJsonPath);
if (exempt.some((entry) => rel === entry || rel.startsWith(`${entry}/`))) continue;
const configPath = CONFIG_FILENAMES.map((name) => path.join(packageDir, name)).find((p) =>
fs.existsSync(p),
);
if (!configPath || seen.has(configPath)) continue;
seen.add(configPath);
// A package may hold both while a migration is in flight, and each
// config decides policy for the linter that reads it.
for (const [filenames, flavour] of [
[CONFIG_FILENAMES, ESLINT_FLAVOUR],
[OXLINT_CONFIG_FILENAMES, OXLINT_FLAVOUR],
] as const) {
const configPath = filenames
.map((name) => path.join(packageDir, name))
.find((p) => fs.existsSync(p));
if (!configPath || seen.has(configPath)) continue;
seen.add(configPath);
violations.push(...this.checkConfig(configPath, rel));
violations.push(...this.checkConfig(configPath, rel, flavour));
}
}
return violations;
}
private checkConfig(configPath: string, packageName: string): Violation[] {
private checkConfig(configPath: string, packageName: string, flavour: Flavour): Violation[] {
const text = fs.readFileSync(configPath, 'utf-8');
const source = new Project({
useInMemoryFileSystem: true,
compilerOptions: { allowJs: true },
}).createSourceFile(configPath, text, { scriptKind: ScriptKind.JS });
}).createSourceFile(configPath, text, { scriptKind: flavour.scriptKind });
const violations: Violation[] = [];
const at = (node: Node) => {
@ -98,10 +144,10 @@ export class LintConfigLayeringRule extends BaseRule<CodeHealthContext> {
const layerImports: string[] = [];
for (const declaration of source.getImportDeclarations()) {
const specifier = declaration.getModuleSpecifierValue();
const match = LAYER_IMPORT.exec(specifier);
const match = flavour.layerImport.exec(specifier);
if (!match) continue;
const subpath = match[1];
if (REMOVED_SUBPATHS.has(subpath)) {
if (flavour === ESLINT_FLAVOUR && REMOVED_SUBPATHS.has(subpath)) {
const { line, column } = at(declaration);
violations.push(
this.createViolation(
@ -123,8 +169,8 @@ export class LintConfigLayeringRule extends BaseRule<CodeHealthContext> {
configPath,
1,
1,
`${packageName} does not extend a shared ESLint layer.`,
'Import baseConfig, backendConfig, frontendConfig or nodesConfig from @n8n/eslint-config and pass it to defineConfig.',
flavour.missingLayerMessage(packageName),
flavour.missingLayerSuggestion,
),
);
} else if (layerImports.length > 1) {

View File

@ -2198,12 +2198,21 @@ importers:
specifier: ^13.6.20
version: 13.6.29
devDependencies:
'@n8n/oxlint-config':
specifier: workspace:*
version: link:../oxlint-config
'@n8n/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@n8n/vitest-config':
specifier: workspace:*
version: link:../vitest-config
oxlint:
specifier: 'catalog:'
version: 1.78.0(oxlint-tsgolint@0.21.1)
oxlint-tsgolint:
specifier: 'catalog:'
version: 0.21.1
typescript:
specifier: catalog:typescript
version: 7.0.2
@ -2382,6 +2391,9 @@ importers:
specifier: 'catalog:'
version: 0.2.2
devDependencies:
'@n8n/oxlint-config':
specifier: workspace:*
version: link:../oxlint-config
'@n8n/typescript-config':
specifier: workspace:*
version: link:../typescript-config
@ -2391,6 +2403,12 @@ importers:
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.1.9(@vitest/browser@4.1.10)(vitest@4.1.9)
oxlint:
specifier: 'catalog:'
version: 1.78.0(oxlint-tsgolint@0.21.1)
oxlint-tsgolint:
specifier: 'catalog:'
version: 0.21.1
typescript:
specifier: catalog:typescript
version: 7.0.2
@ -2477,12 +2495,21 @@ importers:
specifier: 'catalog:'
version: 3.1.0
devDependencies:
'@n8n/oxlint-config':
specifier: workspace:*
version: link:../oxlint-config
'@n8n/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@sentry/node':
specifier: catalog:sentry
version: 10.70.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.221.0(@opentelemetry/api@1.9.1))(supports-color@8.1.1)
oxlint:
specifier: 'catalog:'
version: 1.78.0(oxlint-tsgolint@0.21.1)
oxlint-tsgolint:
specifier: 'catalog:'
version: 0.21.1
typescript:
specifier: catalog:typescript
version: 7.0.2
@ -3674,6 +3701,34 @@ importers:
specifier: 'catalog:'
version: 3.1.0(@typescript/typescript6@6.0.2)(vitest@4.1.9)
packages/@n8n/oxlint-config:
dependencies:
'@n8n/eslint-config':
specifier: workspace:*
version: link:../eslint-config
'@stylistic/eslint-plugin':
specifier: ^5.0.0
version: 5.0.0(eslint@9.29.0(jiti@2.6.1)(supports-color@8.1.1))
eslint-plugin-lodash:
specifier: ^8.0.0
version: 8.0.0(eslint@9.29.0(jiti@2.6.1)(supports-color@8.1.1))
eslint-plugin-unused-imports:
specifier: ^4.1.4
version: 4.1.4(@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@9.29.0(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(eslint@9.29.0(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1))(eslint@9.29.0(jiti@2.6.1)(supports-color@8.1.1))
oxlint:
specifier: 'catalog:'
version: 1.78.0(oxlint-tsgolint@0.21.1)
devDependencies:
'@n8n/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@types/node':
specifier: ^20.17.50
version: 20.19.41
typescript:
specifier: catalog:typescript-tooling
version: '@typescript/typescript6@6.0.2'
packages/@n8n/permissions:
dependencies:
zod:

View File

@ -0,0 +1,52 @@
{
"$comment": [
"Rules the ESLint layers enforce at error that the oxlint layers do not.",
"`oxlint-parity.mjs` fails on any rule missing from oxlint that is absent here,",
"so every drop is a deliberate, reviewed entry. Measured against oxlint 1.78.0.",
"`layer` is the ESLint layer the rule is at error in. `native` names the oxlint",
"rule id when one exists but we still do not enable it."
],
"no-restricted-syntax": {
"layer": "base",
"native": null,
"reason": "oxlint 1.78 has no AST-selector rule and no equivalent. It is an ESLint core rule, so a jsPlugin cannot host it either: `Rule 'no-restricted-syntax' not found in plugin 'eslint'`. base uses it for the raw-enum ban; editor-ui uses it for store-migration ratchets, and editor-ui keeps ESLint.",
"impact": "The const-enum ban is unenforced in migrated packages."
},
"no-octal": {
"layer": "base",
"native": null,
"reason": "No oxlint rule. Largely covered incidentally: the oxc parser rejects legacy `0`-prefixed octal literals in strict-mode and module code, which is all of our source.",
"impact": "Near zero."
},
"import-x/no-extraneous-dependencies": {
"layer": "base",
"native": null,
"reason": "`import-x` is a reserved plugin name in oxlint, so `eslint-plugin-import-x` cannot load as a jsPlugin under its own name (`Plugin name 'import-x' is reserved`). An alias would permanently change the rule ids, and these rules are the slowest in ESLint and unvalidated through the alpha bridge.",
"impact": "A package can import a dependency it does not declare. Left out of the spike deliberately."
},
"typescript/prefer-optional-chain": {
"layer": "base",
"native": "typescript/prefer-optional-chain",
"reason": "Native but `nursery`, and it diverges. It reports `typeof crypto !== 'undefined' && crypto.randomUUID` in @n8n/crdt where ESLint does not, and its fix is wrong: `crypto?.randomUUID` still throws a ReferenceError when the global is undeclared, which is what the typeof guard prevents.",
"impact": "Re-evaluate when the rule leaves nursery."
},
"import/export": {
"layer": "base",
"native": "import/export",
"reason": "Native but `nursery`. Not enabling a pre-release rule silently; no divergence observed yet.",
"impact": "Re-evaluate when the rule leaves nursery."
},
"typescript/consistent-type-imports": {
"layer": "base",
"native": "typescript/consistent-type-imports",
"reason": "oxlint's rule has no `emitDecoratorMetadata` carve-out. typescript-eslint goes silent in any file containing a decorator when `experimentalDecorators` + `emitDecoratorMetadata` are set, because converting such an import to `import type` erases the `design:paramtypes` metadata that DI resolves. oxlint reads no compiler options in this rule and offers no option to declare them, so it reported 3488 false positives across packages/cli/src, all in decorated files. Its fixer also fights n8n-local-rules/no-type-only-import-in-di: one adds `type`, the other removes it, so `oxlint --fix` does not converge.",
"impact": "Low at runtime. The direction that breaks DI stays guarded by no-type-only-import-in-di, and these packages leave `verbatimModuleSyntax` off, so TypeScript elides a type-only-used import at emit. The cost is drift from a clean baseline.",
"upstream": "https://github.com/oxc-project/oxc/issues/13609"
},
"n8n-local-rules/no-unsealed-workflow-entity-write": {
"layer": "base",
"native": null,
"reason": "Needs type information. oxlint gives jsPlugin rules a frozen empty `parserServices`, so `ESLintUtils.getParserServices` throws and the rule reports `Error running JS plugin` on any file that calls a checked method. It is in `localRulesPlugin.configs.recommended`, so this is a base-layer gap, not a backend-only one.",
"impact": "The WorkflowEntity node-write seal is unenforced in migrated packages. None of the three spike packages depends on @n8n/db, but this blocks any package that does."
}
}

View File

@ -0,0 +1,232 @@
#!/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` resolves the config, not a file, so `overrides` do not
* appear. 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, 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',
'lodash',
'unused-imports',
'n8n-local-rules',
]);
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 start = out.stdout.indexOf('[');
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) {
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;
}
/** 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 ?? {})
.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 Set();
const plugins = new Set();
const visit = (config) => {
for (const parent of config.extends ?? []) visit(parent);
for (const name of config.jsPlugins ?? []) plugins.add(name);
for (const [id, severity] of Object.entries(config.rules ?? {})) declared.set(id, severity);
for (const override of config.overrides ?? []) {
for (const name of override.jsPlugins ?? []) plugins.add(name);
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 (!declared.has(id)) overrideOnly.add(id);
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] };
}
// The test-file override is only comparable when a test file was sampled:
// ESLint resolves its config per file, oxlint's --print-config does not resolve
// overrides at all, so without a test sample there is nothing to compare and an
// override-only rule would read as unrequested.
const isTestSample = (key) => /(\.test\.ts|\/__tests__\/|^test\/)/.test(key.split('|')[1] ?? '');
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;
let sampledTestFile = false;
for (const [key, entry] of Object.entries(snapshot)) {
if (!key.startsWith(`${pkgDir}|`) || !entry.rules) continue;
sampled++;
if (isTestSample(key)) sampledTestFile = true;
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) => !overrideOnly.has(id) || sampledTestFile;
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 isDocumented = ({ id, native: target }) => id in gap || (target !== null && target in gap);
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');

View File

@ -57,6 +57,17 @@
"lint": {
"dependsOn": ["^build", "@n8n/eslint-config#build"]
},
// oxlint spike packages (DEVP-584). They run oxlint instead of ESLint, so
// they need the oxlint layer built, not the ESLint one.
"@n8n/errors#lint": {
"dependsOn": ["^build", "@n8n/oxlint-config#build"]
},
"@n8n/di#lint": {
"dependsOn": ["^build", "@n8n/oxlint-config#build"]
},
"@n8n/crdt#lint": {
"dependsOn": ["^build", "@n8n/oxlint-config#build"]
},
// The n8n-nodes-base lint script also validates load-options methods
// against dist/methods/*.json, which only its own build emits.
"n8n-nodes-base#lint": {