mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
build: Initialize n8n-oxlint config (#38565)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
68d567c063
commit
4cda10ebc6
|
|
@ -1,4 +0,0 @@
|
|||
import { defineConfig } from 'eslint/config';
|
||||
import { baseConfig } from '@n8n/eslint-config/base';
|
||||
|
||||
export default defineConfig(baseConfig);
|
||||
7
packages/@n8n/crdt/oxlint.config.mts
Normal file
7
packages/@n8n/crdt/oxlint.config.mts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { baseConfig } from '@n8n/oxlint-config/base';
|
||||
import { defineConfig } from 'oxlint';
|
||||
|
||||
export default defineConfig({
|
||||
extends: [baseConfig],
|
||||
options: { typeAware: true },
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
import { defineConfig } from 'eslint/config';
|
||||
import { baseConfig } from '@n8n/eslint-config/base';
|
||||
|
||||
export default defineConfig(baseConfig);
|
||||
7
packages/@n8n/di/oxlint.config.mts
Normal file
7
packages/@n8n/di/oxlint.config.mts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { baseConfig } from '@n8n/oxlint-config/base';
|
||||
import { defineConfig } from 'oxlint';
|
||||
|
||||
export default defineConfig({
|
||||
extends: [baseConfig],
|
||||
options: { typeAware: true },
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
import { defineConfig } from 'eslint/config';
|
||||
import { baseConfig } from '@n8n/eslint-config/base';
|
||||
|
||||
export default defineConfig(baseConfig);
|
||||
7
packages/@n8n/errors/oxlint.config.mts
Normal file
7
packages/@n8n/errors/oxlint.config.mts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { baseConfig } from '@n8n/oxlint-config/base';
|
||||
import { defineConfig } from 'oxlint';
|
||||
|
||||
export default defineConfig({
|
||||
extends: [baseConfig],
|
||||
options: { typeAware: true },
|
||||
});
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -37,3 +37,6 @@ export const localRulesPlugin = {
|
|||
},
|
||||
},
|
||||
} satisfies ESLint.Plugin;
|
||||
|
||||
// oxlint loads a jsPlugin from the module default export.
|
||||
export default localRulesPlugin;
|
||||
|
|
|
|||
37
packages/@n8n/oxlint-config/package.json
Normal file
37
packages/@n8n/oxlint-config/package.json
Normal 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"
|
||||
}
|
||||
224
packages/@n8n/oxlint-config/src/configs/base.ts
Normal file
224
packages/@n8n/oxlint-config/src/configs/base.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
14
packages/@n8n/oxlint-config/tsconfig.json
Normal file
14
packages/@n8n/oxlint-config/tsconfig.json
Normal 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": []
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
52
scripts/lint-parity/oxlint-gap.json
Normal file
52
scripts/lint-parity/oxlint-gap.json
Normal 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."
|
||||
}
|
||||
}
|
||||
232
scripts/lint-parity/oxlint-parity.mjs
Normal file
232
scripts/lint-parity/oxlint-parity.mjs
Normal 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');
|
||||
11
turbo.json
11
turbo.json
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user