From 4cda10ebc605133898cc1fb2ab794ac8cc5232c8 Mon Sep 17 00:00:00 2001 From: Matsu Date: Mon, 14 Sep 2026 07:55:26 +0000 Subject: [PATCH] build: Initialize n8n-oxlint config (#38565) Co-authored-by: Claude Opus 5 --- packages/@n8n/crdt/eslint.config.mjs | 4 - packages/@n8n/crdt/oxlint.config.mts | 7 + packages/@n8n/crdt/package.json | 7 +- packages/@n8n/di/eslint.config.mjs | 4 - packages/@n8n/di/oxlint.config.mts | 7 + packages/@n8n/di/package.json | 11 +- packages/@n8n/errors/eslint.config.mjs | 4 - packages/@n8n/errors/oxlint.config.mts | 7 + packages/@n8n/errors/package.json | 7 +- packages/@n8n/eslint-config/package.json | 4 + packages/@n8n/eslint-config/src/plugin.ts | 3 + packages/@n8n/oxlint-config/package.json | 37 +++ .../@n8n/oxlint-config/src/configs/base.ts | 224 +++++++++++++++++ packages/@n8n/oxlint-config/tsconfig.json | 14 ++ .../rules/encryption-boundary.rule.test.ts | 36 ++- .../src/rules/encryption-boundary.rule.ts | 12 +- .../rules/lint-config-layering.rule.test.ts | 78 +++++- .../src/rules/lint-config-layering.rule.ts | 70 +++++- pnpm-lock.yaml | 55 +++++ scripts/lint-parity/oxlint-gap.json | 52 ++++ scripts/lint-parity/oxlint-parity.mjs | 232 ++++++++++++++++++ turbo.json | 11 + 22 files changed, 847 insertions(+), 39 deletions(-) delete mode 100644 packages/@n8n/crdt/eslint.config.mjs create mode 100644 packages/@n8n/crdt/oxlint.config.mts delete mode 100644 packages/@n8n/di/eslint.config.mjs create mode 100644 packages/@n8n/di/oxlint.config.mts delete mode 100644 packages/@n8n/errors/eslint.config.mjs create mode 100644 packages/@n8n/errors/oxlint.config.mts create mode 100644 packages/@n8n/oxlint-config/package.json create mode 100644 packages/@n8n/oxlint-config/src/configs/base.ts create mode 100644 packages/@n8n/oxlint-config/tsconfig.json create mode 100644 scripts/lint-parity/oxlint-gap.json create mode 100644 scripts/lint-parity/oxlint-parity.mjs diff --git a/packages/@n8n/crdt/eslint.config.mjs b/packages/@n8n/crdt/eslint.config.mjs deleted file mode 100644 index beff10615e5..00000000000 --- a/packages/@n8n/crdt/eslint.config.mjs +++ /dev/null @@ -1,4 +0,0 @@ -import { defineConfig } from 'eslint/config'; -import { baseConfig } from '@n8n/eslint-config/base'; - -export default defineConfig(baseConfig); diff --git a/packages/@n8n/crdt/oxlint.config.mts b/packages/@n8n/crdt/oxlint.config.mts new file mode 100644 index 00000000000..294250a5f2c --- /dev/null +++ b/packages/@n8n/crdt/oxlint.config.mts @@ -0,0 +1,7 @@ +import { baseConfig } from '@n8n/oxlint-config/base'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [baseConfig], + options: { typeAware: true }, +}); diff --git a/packages/@n8n/crdt/package.json b/packages/@n8n/crdt/package.json index fec8ffa648b..8631c2d4099 100644 --- a/packages/@n8n/crdt/package.json +++ b/packages/@n8n/crdt/package.json @@ -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" diff --git a/packages/@n8n/di/eslint.config.mjs b/packages/@n8n/di/eslint.config.mjs deleted file mode 100644 index beff10615e5..00000000000 --- a/packages/@n8n/di/eslint.config.mjs +++ /dev/null @@ -1,4 +0,0 @@ -import { defineConfig } from 'eslint/config'; -import { baseConfig } from '@n8n/eslint-config/base'; - -export default defineConfig(baseConfig); diff --git a/packages/@n8n/di/oxlint.config.mts b/packages/@n8n/di/oxlint.config.mts new file mode 100644 index 00000000000..294250a5f2c --- /dev/null +++ b/packages/@n8n/di/oxlint.config.mts @@ -0,0 +1,7 @@ +import { baseConfig } from '@n8n/oxlint-config/base'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [baseConfig], + options: { typeAware: true }, +}); diff --git a/packages/@n8n/di/package.json b/packages/@n8n/di/package.json index 7374042487d..1d0dbc7cb8f 100644 --- a/packages/@n8n/di/package.json +++ b/packages/@n8n/di/package.json @@ -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" } diff --git a/packages/@n8n/errors/eslint.config.mjs b/packages/@n8n/errors/eslint.config.mjs deleted file mode 100644 index beff10615e5..00000000000 --- a/packages/@n8n/errors/eslint.config.mjs +++ /dev/null @@ -1,4 +0,0 @@ -import { defineConfig } from 'eslint/config'; -import { baseConfig } from '@n8n/eslint-config/base'; - -export default defineConfig(baseConfig); diff --git a/packages/@n8n/errors/oxlint.config.mts b/packages/@n8n/errors/oxlint.config.mts new file mode 100644 index 00000000000..294250a5f2c --- /dev/null +++ b/packages/@n8n/errors/oxlint.config.mts @@ -0,0 +1,7 @@ +import { baseConfig } from '@n8n/oxlint-config/base'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [baseConfig], + options: { typeAware: true }, +}); diff --git a/packages/@n8n/errors/package.json b/packages/@n8n/errors/package.json index 451b32a8008..664a3c2e1e2 100644 --- a/packages/@n8n/errors/package.json +++ b/packages/@n8n/errors/package.json @@ -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": { diff --git a/packages/@n8n/eslint-config/package.json b/packages/@n8n/eslint-config/package.json index f13084d21ad..6b4902bc407 100644 --- a/packages/@n8n/eslint-config/package.json +++ b/packages/@n8n/eslint-config/package.json @@ -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": { diff --git a/packages/@n8n/eslint-config/src/plugin.ts b/packages/@n8n/eslint-config/src/plugin.ts index 0ae9e68b089..33a7d243807 100644 --- a/packages/@n8n/eslint-config/src/plugin.ts +++ b/packages/@n8n/eslint-config/src/plugin.ts @@ -37,3 +37,6 @@ export const localRulesPlugin = { }, }, } satisfies ESLint.Plugin; + +// oxlint loads a jsPlugin from the module default export. +export default localRulesPlugin; diff --git a/packages/@n8n/oxlint-config/package.json b/packages/@n8n/oxlint-config/package.json new file mode 100644 index 00000000000..cef5b74f783 --- /dev/null +++ b/packages/@n8n/oxlint-config/package.json @@ -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" +} diff --git a/packages/@n8n/oxlint-config/src/configs/base.ts b/packages/@n8n/oxlint-config/src/configs/base.ts new file mode 100644 index 00000000000..1c95a0a1dad --- /dev/null +++ b/packages/@n8n/oxlint-config/src/configs/base.ts @@ -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', + }, + }, + ], +}); diff --git a/packages/@n8n/oxlint-config/tsconfig.json b/packages/@n8n/oxlint-config/tsconfig.json new file mode 100644 index 00000000000..0dbcc37c4e0 --- /dev/null +++ b/packages/@n8n/oxlint-config/tsconfig.json @@ -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": [] +} diff --git a/packages/testing/code-health/src/rules/encryption-boundary.rule.test.ts b/packages/testing/code-health/src/rules/encryption-boundary.rule.test.ts index 273af66137f..9287356b7c0 100644 --- a/packages/testing/code-health/src/rules/encryption-boundary.rule.test.ts +++ b/packages/testing/code-health/src/rules/encryption-boundary.rule.test.ts @@ -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 () => { diff --git a/packages/testing/code-health/src/rules/encryption-boundary.rule.ts b/packages/testing/code-health/src/rules/encryption-boundary.rule.ts index b292645265b..a7c9251039b 100644 --- a/packages/testing/code-health/src/rules/encryption-boundary.rule.ts +++ b/packages/testing/code-health/src/rules/encryption-boundary.rule.ts @@ -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 = - /(? typeof entry === 'string') @@ -211,7 +215,7 @@ export class EncryptionBoundaryRule extends BaseRule { 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 { 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.`, ); } diff --git a/packages/testing/code-health/src/rules/lint-config-layering.rule.test.ts b/packages/testing/code-health/src/rules/lint-config-layering.rule.test.ts index 941bbc62923..1ef54a38c85 100644 --- a/packages/testing/code-health/src/rules/lint-config-layering.rule.test.ts +++ b/packages/testing/code-health/src/rules/lint-config-layering.rule.test.ts @@ -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 = {}): Promise { @@ -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', diff --git a/packages/testing/code-health/src/rules/lint-config-layering.rule.ts b/packages/testing/code-health/src/rules/lint-config-layering.rule.ts index 27dc41b98b8..54e495cc8ad 100644 --- a/packages/testing/code-health/src/rules/lint-config-layering.rule.ts +++ b/packages/testing/code-health/src/rules/lint-config-layering.rule.ts @@ -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 { 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 { 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 { 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) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24acf7ee741..de2a17544c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/scripts/lint-parity/oxlint-gap.json b/scripts/lint-parity/oxlint-gap.json new file mode 100644 index 00000000000..e68d075caea --- /dev/null +++ b/scripts/lint-parity/oxlint-gap.json @@ -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." + } +} diff --git a/scripts/lint-parity/oxlint-parity.mjs b/scripts/lint-parity/oxlint-parity.mjs new file mode 100644 index 00000000000..03f948c1820 --- /dev/null +++ b/scripts/lint-parity/oxlint-parity.mjs @@ -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 --eslint [--gap ]', + ); + 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'); diff --git a/turbo.json b/turbo.json index 005a50df9af..6c3b36311f5 100644 --- a/turbo.json +++ b/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": {