diff --git a/packages/@n8n/agents/eslint.config.mjs b/packages/@n8n/agents/eslint.config.mjs index 64a1493506f..dcf00493659 100644 --- a/packages/@n8n/agents/eslint.config.mjs +++ b/packages/@n8n/agents/eslint.config.mjs @@ -14,28 +14,16 @@ export default defineConfig( ], }, backendConfig, - { - rules: { - '@typescript-eslint/naming-convention': [ - 'error', - { - selector: 'enumMember', - format: ['UPPER_CASE', 'PascalCase'], - }, - ], - }, - }, { files: ['src/**/*.ts'], ignores: ['src/**/__tests__/**/*.ts'], rules: { - '@typescript-eslint/no-restricted-imports': [ + 'n8n-local-rules/no-static-runtime-import': [ 'error', { paths: [ { name: 'ai', - allowTypeImports: true, message: AI_SDK_LAZY_IMPORT_MESSAGE, }, ], diff --git a/packages/@n8n/agents/oxlint.config.mts b/packages/@n8n/agents/oxlint.config.mts new file mode 100644 index 00000000000..022cdf347bd --- /dev/null +++ b/packages/@n8n/agents/oxlint.config.mts @@ -0,0 +1,45 @@ +import { backendConfig } from '@n8n/oxlint-config/backend'; +import { defineConfig } from 'oxlint'; + +const AI_SDK_LAZY_IMPORT_MESSAGE = + "Import runtime values from 'ai' through the lazy loader in src/runtime/lazy-ai.ts or a dynamic import at the call site, so @n8n/agents stays light at boot."; + +export default defineConfig({ + extends: [backendConfig], + options: { typeAware: true }, + ignorePatterns: [ + 'examples/**', + 'vitest.integration.config.*', + 'vitest.integration.setup.ts', + 'src/__tests__/fixtures/**', + ], + overrides: [ + { + files: ['src/**/*.ts'], + rules: { + 'n8n-local-rules/no-static-runtime-import': [ + 'error', + { + paths: [ + { + name: 'ai', + message: AI_SDK_LAZY_IMPORT_MESSAGE, + }, + ], + }, + ], + }, + }, + { + files: ['src/**/__tests__/**/*.ts'], + rules: { 'n8n-local-rules/no-static-runtime-import': 'off' }, + }, + { + files: ['src/__tests__/integration/**/*.ts'], + rules: { + 'typescript/require-await': 'off', + 'n8n-local-rules/no-uncaught-json-parse': 'off', + }, + }, + ], +}); diff --git a/packages/@n8n/agents/package.json b/packages/@n8n/agents/package.json index b10ec97b1d2..a3080844330 100644 --- a/packages/@n8n/agents/package.json +++ b/packages/@n8n/agents/package.json @@ -79,8 +79,8 @@ "build:unchecked": "rimraf dist && tsc -p tsconfig.build.json --noCheck", "format": "biome format --write src examples", "format:check": "biome ci src examples", - "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", @@ -159,6 +159,7 @@ } }, "devDependencies": { + "@n8n/oxlint-config": "workspace:*", "@n8n/typescript-config": "workspace:*", "@n8n/vitest-config": "workspace:*", "@opentelemetry/api": "catalog:", @@ -172,6 +173,8 @@ "dotenv": "catalog:", "cross-env": "catalog:", "nock": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", "pg": "catalog:", "tsx": "catalog:", "typescript": "catalog:typescript", diff --git a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts index 779fd1a0ea8..60d246ee4e6 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts @@ -1569,7 +1569,8 @@ describe('AgentRuntime — reasoning-only turn', () => { const error = chunks.find( (chunk): chunk is StreamChunk & { type: 'error' } => chunk.type === 'error', ); - expect(String((error?.error as Error).message)).toContain('no output'); + if (!error) throw new Error('Expected an error chunk'); + expect(String((error.error as Error).message)).toContain('no output'); }); it('fails a reasoning-only length turn without retrying or persisting it', async () => { diff --git a/packages/@n8n/agents/src/runtime/model/__tests__/prompt-cache.test.ts b/packages/@n8n/agents/src/runtime/model/__tests__/prompt-cache.test.ts index 2a4afe50a2c..38c59fc28b2 100644 --- a/packages/@n8n/agents/src/runtime/model/__tests__/prompt-cache.test.ts +++ b/packages/@n8n/agents/src/runtime/model/__tests__/prompt-cache.test.ts @@ -76,7 +76,8 @@ describe('buildCallPromptCacheOptions — OpenAI', () => { const result = buildCallPromptCacheOptions({ enabled: true }, 'openai/gpt-5.1', context); expect(result?.openai).toMatchObject({ promptCacheRetention: '24h' }); - expect(typeof (result?.openai as { promptCacheKey: string }).promptCacheKey).toBe('string'); + if (!result?.openai) throw new Error('Expected OpenAI prompt cache options'); + expect(typeof (result.openai as { promptCacheKey: string }).promptCacheKey).toBe('string'); }); it('passes through an explicit promptCacheKey and promptCacheRetention unchanged', () => { @@ -120,7 +121,8 @@ describe('createOpenAIPromptCacheKey (via buildCallPromptCacheOptions)', () => { ...context, ...overrides, }); - return (result?.openai as { promptCacheKey: string }).promptCacheKey; + if (!result?.openai) throw new Error('Expected OpenAI prompt cache options'); + return (result.openai as { promptCacheKey: string }).promptCacheKey; } it('is deterministic for the same agent, model, and instructions', () => { diff --git a/packages/@n8n/eslint-config/src/rules/index.ts b/packages/@n8n/eslint-config/src/rules/index.ts index 506d4894685..b45172b2894 100644 --- a/packages/@n8n/eslint-config/src/rules/index.ts +++ b/packages/@n8n/eslint-config/src/rules/index.ts @@ -33,6 +33,7 @@ import { RequireEscapedQueryValuesRule } from './require-escaped-query-values.js import { NoOnLeaderTakeoverRule } from './no-on-leader-takeover.js'; import { NoEncryptionGuardrailDisableRule } from './no-encryption-guardrail-disable.js'; import { NoRawEnumRule } from './no-raw-enum.js'; +import { NoStaticRuntimeImportRule } from './no-static-runtime-import.js'; export const rules = { 'no-uncaught-json-parse': NoUncaughtJsonParseRule, @@ -69,4 +70,5 @@ export const rules = { 'no-on-leader-takeover': NoOnLeaderTakeoverRule, 'no-encryption-guardrail-disable': NoEncryptionGuardrailDisableRule, 'no-raw-enum': NoRawEnumRule, + 'no-static-runtime-import': NoStaticRuntimeImportRule, } satisfies Record; diff --git a/packages/@n8n/eslint-config/src/rules/no-static-runtime-import.test.ts b/packages/@n8n/eslint-config/src/rules/no-static-runtime-import.test.ts new file mode 100644 index 00000000000..033ad940d68 --- /dev/null +++ b/packages/@n8n/eslint-config/src/rules/no-static-runtime-import.test.ts @@ -0,0 +1,46 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; +import { NoStaticRuntimeImportRule } from './no-static-runtime-import.js'; + +const ruleTester = new RuleTester(); +const options: [{ paths: Array<{ name: string; message: string }> }] = [ + { paths: [{ name: 'heavy-sdk', message: 'Load this module lazily.' }] }, +]; + +ruleTester.run('no-static-runtime-import', NoStaticRuntimeImportRule, { + valid: [ + { code: "import type { Client } from 'heavy-sdk';", options }, + { code: "import { type Client } from 'heavy-sdk';", options }, + { code: "const { Client } = await import('heavy-sdk');", options }, + { code: "import { Client } from 'other-sdk';", options }, + { code: "export type { Client } from 'heavy-sdk';", options }, + { code: "export { type Client } from 'heavy-sdk';", options }, + { code: "export type * from 'heavy-sdk';", options }, + ], + invalid: [ + { + code: "import { Client } from 'heavy-sdk';", + options, + errors: [{ messageId: 'restrictedImport' }], + }, + { + code: "import HeavySdk from 'heavy-sdk';", + options, + errors: [{ messageId: 'restrictedImport' }], + }, + { + code: "import 'heavy-sdk';", + options, + errors: [{ messageId: 'restrictedImport' }], + }, + { + code: "export { Client } from 'heavy-sdk';", + options, + errors: [{ messageId: 'restrictedImport' }], + }, + { + code: "export * from 'heavy-sdk';", + options, + errors: [{ messageId: 'restrictedImport' }], + }, + ], +}); diff --git a/packages/@n8n/eslint-config/src/rules/no-static-runtime-import.ts b/packages/@n8n/eslint-config/src/rules/no-static-runtime-import.ts new file mode 100644 index 00000000000..225089c8eb9 --- /dev/null +++ b/packages/@n8n/eslint-config/src/rules/no-static-runtime-import.ts @@ -0,0 +1,74 @@ +import { ESLintUtils, type TSESTree } from '@typescript-eslint/utils'; + +type Options = [{ paths: Array<{ name: string; message: string }> }]; +type MessageIds = 'restrictedImport'; + +export const NoStaticRuntimeImportRule = ESLintUtils.RuleCreator.withoutDocs({ + meta: { + type: 'problem', + docs: { + description: 'Disallow static runtime imports from configured modules.', + }, + messages: { + restrictedImport: '{{ message }}', + }, + schema: [ + { + type: 'object', + additionalProperties: false, + required: ['paths'], + properties: { + paths: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['name', 'message'], + properties: { + name: { type: 'string' }, + message: { type: 'string' }, + }, + }, + }, + }, + }, + ], + }, + defaultOptions: [{ paths: [] }], + create(context, [options]) { + const restrictedPaths = new Map(options.paths.map(({ name, message }) => [name, message])); + const reportRestrictedExport = ( + node: TSESTree.ExportAllDeclaration | TSESTree.ExportNamedDeclaration, + ) => { + if (!node.source || node.exportKind === 'type') return; + + const message = restrictedPaths.get(node.source.value); + if (!message) return; + + if (node.type === 'ExportNamedDeclaration') { + const hasRuntimeSpecifier = node.specifiers.some( + (specifier) => specifier.exportKind !== 'type', + ); + if (node.specifiers.length > 0 && !hasRuntimeSpecifier) return; + } + + context.report({ node: node.source, messageId: 'restrictedImport', data: { message } }); + }; + + return { + ImportDeclaration(node) { + const message = restrictedPaths.get(node.source.value); + if (!message || node.importKind === 'type') return; + + const hasRuntimeSpecifier = node.specifiers.some( + (specifier) => specifier.type !== 'ImportSpecifier' || specifier.importKind !== 'type', + ); + if (node.specifiers.length > 0 && !hasRuntimeSpecifier) return; + + context.report({ node: node.source, messageId: 'restrictedImport', data: { message } }); + }, + ExportAllDeclaration: reportRestrictedExport, + ExportNamedDeclaration: reportRestrictedExport, + }; + }, +}); diff --git a/packages/@n8n/instance-ai/eslint.config.mjs b/packages/@n8n/instance-ai/eslint.config.mjs index 040ab8267c3..88b7b775753 100644 --- a/packages/@n8n/instance-ai/eslint.config.mjs +++ b/packages/@n8n/instance-ai/eslint.config.mjs @@ -13,7 +13,6 @@ const restrictedLazyRuntimeImports = [ 'turndown', ].map((name) => ({ name, - allowTypeImports: true, message: LAZY_RUNTIME_IMPORT_MESSAGE, })); @@ -33,25 +32,11 @@ export default defineConfig( 'evaluations/cli/pairwise.ts', ], }, - { - rules: { - // Tool names may be kebab-case identifiers (e.g. 'list-workflows'), which - // require quotes in object literals. Skip naming checks for those. - '@typescript-eslint/naming-convention': [ - 'error', - { - selector: 'objectLiteralProperty', - modifiers: ['requiresQuotes'], - format: null, - }, - ], - }, - }, { files: ['src/**/*.ts'], ignores: ['src/**/__tests__/**/*.ts'], rules: { - '@typescript-eslint/no-restricted-imports': [ + 'n8n-local-rules/no-static-runtime-import': [ 'error', { paths: restrictedLazyRuntimeImports }, ], @@ -93,13 +78,6 @@ export default defineConfig( '@typescript-eslint/no-unsafe-call': 'off', }, }, - { - // Debt: the base layer enforces kebab-case filenames and this package has - // 6 files that predate it. Rename them, then delete this block. - rules: { - 'unicorn/filename-case': 'off', - }, - }, { files: ['evaluations/clients/n8n-client.ts'], // An evaluation harness that talks to a local instance it started diff --git a/packages/@n8n/instance-ai/oxlint.config.mts b/packages/@n8n/instance-ai/oxlint.config.mts new file mode 100644 index 00000000000..1ecdd77dfc2 --- /dev/null +++ b/packages/@n8n/instance-ai/oxlint.config.mts @@ -0,0 +1,82 @@ +import { backendConfig } from '@n8n/oxlint-config/backend'; +import { defineConfig } from 'oxlint'; + +const LAZY_RUNTIME_IMPORT_MESSAGE = + 'Use an existing lazy loader, or add one near first use. Static runtime imports of this dependency undo the idle-memory guardrail.'; + +const restrictedLazyRuntimeImports = [ + '@daytona/sdk', + 'csv-parse/sync', + 'linkedom', + 'pdf-parse', + 'psl', + 'turndown', +].map((name) => ({ + name, + message: LAZY_RUNTIME_IMPORT_MESSAGE, +})); + +export default defineConfig({ + extends: [backendConfig], + options: { typeAware: true }, + ignorePatterns: [ + 'scripts/**/*.cjs', + 'skills/**/*.mjs', + '.data/**', + 'evaluations/.data/**', + 'evaluations/.output/**', + '.output/**', + 'evaluations/cli/pairwise.ts', + ], + overrides: [ + { + files: ['src/**/*.ts'], + rules: { + 'n8n-local-rules/no-static-runtime-import': [ + 'error', + { paths: restrictedLazyRuntimeImports }, + ], + }, + }, + { + files: ['src/**/__tests__/**/*.ts'], + rules: { 'n8n-local-rules/no-static-runtime-import': 'off' }, + }, + { + files: ['src/tools/__tests__/**/*.test.ts'], + rules: { + 'typescript/no-unsafe-assignment': 'off', + 'typescript/no-unsafe-member-access': 'off', + 'typescript/no-unsafe-argument': 'off', + }, + }, + { + files: ['evaluations/**/*.ts'], + jsPlugins: ['@n8n/oxlint-config/import-x-alias'], + rules: { + 'import-x-alias/no-extraneous-dependencies': ['error', { devDependencies: true }], + }, + }, + { + files: ['evaluations/computer-use/report-html.ts'], + rules: { + 'typescript/no-unsafe-assignment': 'off', + 'typescript/no-unsafe-member-access': 'off', + 'typescript/no-unsafe-argument': 'off', + 'typescript/no-unsafe-call': 'off', + }, + }, + { + files: ['assets/workflow-diagnostics.mts'], + rules: { + 'import-x-alias/no-extraneous-dependencies': 'off', + }, + }, + { + files: ['evaluations/clients/n8n-client.ts'], + rules: { + 'n8n-local-rules/no-uncentralized-http': 'off', + }, + }, + ], +}); diff --git a/packages/@n8n/instance-ai/package.json b/packages/@n8n/instance-ai/package.json index add91a691bc..b846e314fa1 100644 --- a/packages/@n8n/instance-ai/package.json +++ b/packages/@n8n/instance-ai/package.json @@ -11,8 +11,8 @@ "test": "vitest run", "test:unit": "vitest run", "test:dev": "vitest --silent=false", - "lint": "eslint . --quiet", - "lint:fix": "eslint . --fix", + "lint": "oxlint --quiet", + "lint:fix": "oxlint --fix", "eval:instance-ai": "tsx evaluations/cli/index.ts", "eval:agents": "tsx evaluations/cli/index.ts --source langtracer --suite agents --tier agents", "eval:build-mcp-manifest": "tsx evaluations/cli/build-mcp-manifest.ts", @@ -100,6 +100,7 @@ "devDependencies": { "@langchain/anthropic": "catalog:", "@langchain/core": "catalog:", + "@n8n/oxlint-config": "workspace:*", "@n8n/typescript-config": "workspace:*", "@n8n/vitest-config": "workspace:*", "@types/luxon": "catalog:", @@ -109,6 +110,8 @@ "fast-glob": "catalog:", "p-limit": "catalog:p-limit-v7", "playwright-core": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", "tsx": "catalog:", "typescript": "catalog:typescript", "undici": "catalog:undici-v7", diff --git a/packages/@n8n/instance-ai/scripts/tsconfig.json b/packages/@n8n/instance-ai/scripts/tsconfig.json index 10281e80bae..b978c4b0ac9 100644 --- a/packages/@n8n/instance-ai/scripts/tsconfig.json +++ b/packages/@n8n/instance-ai/scripts/tsconfig.json @@ -6,8 +6,13 @@ "compilerOptions": { "target": "es2023", "lib": ["es2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", "emitDecoratorMetadata": true, "experimentalDecorators": true, + "paths": { + "@/*": ["../src/*"] + }, "types": ["node"] }, "include": ["**/*.ts"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ed719072b2..8d6198c40e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1255,6 +1255,9 @@ importers: specifier: 'catalog:' version: 3.23.3(zod@3.25.76) devDependencies: + '@n8n/oxlint-config': + specifier: workspace:* + version: link:../oxlint-config '@n8n/typescript-config': specifier: workspace:* version: link:../typescript-config @@ -1294,6 +1297,12 @@ importers: nock: specifier: 'catalog:' version: 14.0.14 + oxlint: + specifier: 'catalog:' + version: 1.78.0(oxlint-tsgolint@0.21.1) + oxlint-tsgolint: + specifier: 'catalog:' + version: 0.21.1 pg: specifier: 8.21.0 version: 8.21.0(pg-native@3.8.0) @@ -3150,6 +3159,9 @@ importers: '@langchain/core': specifier: 'catalog:' version: 1.2.8(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.221.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@6.46.0(@aws-sdk/credential-provider-node@3.972.80)(@smithy/signature-v4@5.7.2)(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@n8n/oxlint-config': + specifier: workspace:* + version: link:../oxlint-config '@n8n/typescript-config': specifier: workspace:* version: link:../typescript-config @@ -3171,6 +3183,12 @@ importers: fast-glob: specifier: 'catalog:' version: 3.3.3 + oxlint: + specifier: 'catalog:' + version: 1.78.0(oxlint-tsgolint@0.21.1) + oxlint-tsgolint: + specifier: 'catalog:' + version: 0.21.1 p-limit: specifier: catalog:p-limit-v7 version: 7.3.1 diff --git a/scripts/lint-parity/OXLINT_MIGRATION_BLOCKERS.md b/scripts/lint-parity/OXLINT_MIGRATION_BLOCKERS.md index 66f08651406..ae08d4f7d33 100644 --- a/scripts/lint-parity/OXLINT_MIGRATION_BLOCKERS.md +++ b/scripts/lint-parity/OXLINT_MIGRATION_BLOCKERS.md @@ -41,6 +41,13 @@ Possible approaches: Audit every package configuration before conversion. Do not copy the full generic naming rule into a new custom implementation. +Phase 3 decisions: + +- `@n8n/agents`: retire the enum-member casing selector. It enforces style only. +- `@n8n/instance-ai`: retire the quoted object-property selector. It only exempts names that require quotes and protects no API contract. +- `@n8n/instance-ai`: remove the stale package-wide filename exception. The package no longer contains files that need it. +- `@n8n/instance-ai`: override the removed Node 10 module resolution in the scripts tsconfig so tsgolint can keep linting `scripts/**/*.ts`. + ### Testing infrastructure decisions - `@n8n/rules-engine`, `@n8n/code-health`, and `@n8n/playwright-janitor`: retire the exemption-only rule-ID selectors. They enforce no positive naming contract. diff --git a/turbo.json b/turbo.json index 4c2e8b8a289..2af67666674 100644 --- a/turbo.json +++ b/turbo.json @@ -62,6 +62,9 @@ "@n8n/ai-node-sdk#lint": { "dependsOn": ["^build", "@n8n/oxlint-config#build"] }, + "@n8n/agents#lint": { + "dependsOn": ["^build", "@n8n/oxlint-config#build"] + }, "@n8n/ai-utilities#lint": { "dependsOn": ["^build", "@n8n/oxlint-config#build"] }, @@ -113,6 +116,9 @@ "@n8n/imap#lint": { "dependsOn": ["^build", "@n8n/oxlint-config#build"] }, + "@n8n/instance-ai#lint": { + "dependsOn": ["^build", "@n8n/oxlint-config#build"] + }, "@n8n/local-gateway#lint": { "dependsOn": ["^build", "@n8n/oxlint-config#build"] },