diff --git a/packages/@n8n/eslint-plugin-community-nodes/README.md b/packages/@n8n/eslint-plugin-community-nodes/README.md index b96c562e242..8ec86f34e34 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/README.md +++ b/packages/@n8n/eslint-plugin-community-nodes/README.md @@ -70,6 +70,7 @@ export default [ | [require-node-api-error](docs/rules/require-node-api-error.md) | Require NodeApiError or NodeOperationError for error wrapping in catch blocks. Raw errors lose HTTP context in the n8n UI. | ✅ ☑️ | | | | | | [require-node-description-fields](docs/rules/require-node-description-fields.md) | Node class description must define all required fields: icon, subtitle | ✅ ☑️ | | | | | | [resource-operation-pattern](docs/rules/resource-operation-pattern.md) | Enforce proper resource/operation pattern for better UX in n8n nodes | | ✅ ☑️ | | | | +| [valid-peer-dependencies](docs/rules/valid-peer-dependencies.md) | Require community node package.json peerDependencies to contain only "n8n-workflow": "*" (and optionally "ai-node-sdk") | ✅ ☑️ | | 🔧 | | | | [webhook-lifecycle-complete](docs/rules/webhook-lifecycle-complete.md) | Require webhook trigger nodes to implement the complete webhookMethods lifecycle (checkExists, create, delete) | ✅ ☑️ | | | | | diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/valid-peer-dependencies.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/valid-peer-dependencies.md new file mode 100644 index 00000000000..228ef0897a6 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/valid-peer-dependencies.md @@ -0,0 +1,72 @@ +# Require community node package.json peerDependencies to contain only "n8n-workflow": "*" (and optionally "ai-node-sdk") (`@n8n/community-nodes/valid-peer-dependencies`) + +💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +## Rule Details + +Community node packages must declare their n8n integration via `peerDependencies` so that they resolve against the host n8n installation rather than bundling their own copy. The only permitted entries are: + +- `n8n-workflow` — required, must be exactly `"*"` (no pinned or ranged versions) +- `ai-node-sdk` — optional, present only for AI nodes (its shape is validated by [`ai-node-package-json`](ai-node-package-json.md)) + +Any other entry (notably `n8n-core`) is flagged because it causes duplicate or incompatible copies of n8n internals to be loaded at runtime. + +The rule checks: + +- `peerDependencies` is present in `package.json` +- `n8n-workflow` is listed with value `"*"` +- No other packages (besides `ai-node-sdk`) appear in `peerDependencies` + +## Examples + +### ❌ Incorrect + +```json +{ + "name": "n8n-nodes-example" +} +``` + +```json +{ + "name": "n8n-nodes-example", + "peerDependencies": { + "n8n-workflow": "^1.0.0" + } +} +``` + +```json +{ + "name": "n8n-nodes-example", + "peerDependencies": { + "n8n-workflow": "*", + "n8n-core": "*" + } +} +``` + +### ✅ Correct + +```json +{ + "name": "n8n-nodes-example", + "peerDependencies": { + "n8n-workflow": "*" + } +} +``` + +```json +{ + "name": "n8n-nodes-my-ai-node", + "peerDependencies": { + "n8n-workflow": "*", + "ai-node-sdk": "*" + } +} +``` diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts b/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts index b7210d8e19f..14b8774c8b2 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts @@ -43,6 +43,7 @@ const configs = { '@n8n/community-nodes/require-continue-on-fail': 'error', '@n8n/community-nodes/require-node-api-error': 'error', '@n8n/community-nodes/require-node-description-fields': 'error', + '@n8n/community-nodes/valid-peer-dependencies': 'error', '@n8n/community-nodes/webhook-lifecycle-complete': 'error', }, }, @@ -73,6 +74,7 @@ const configs = { '@n8n/community-nodes/require-continue-on-fail': 'error', '@n8n/community-nodes/require-node-api-error': 'error', '@n8n/community-nodes/require-node-description-fields': 'error', + '@n8n/community-nodes/valid-peer-dependencies': 'error', '@n8n/community-nodes/webhook-lifecycle-complete': 'error', }, }, diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts index 4e9e320af19..7291988cf87 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts @@ -24,6 +24,7 @@ import { RequireContinueOnFailRule } from './require-continue-on-fail.js'; import { RequireNodeApiErrorRule } from './require-node-api-error.js'; import { RequireNodeDescriptionFieldsRule } from './require-node-description-fields.js'; import { ResourceOperationPatternRule } from './resource-operation-pattern.js'; +import { ValidPeerDependenciesRule } from './valid-peer-dependencies.js'; import { WebhookLifecycleCompleteRule } from './webhook-lifecycle-complete.js'; export const rules = { @@ -51,5 +52,6 @@ export const rules = { 'require-continue-on-fail': RequireContinueOnFailRule, 'require-node-api-error': RequireNodeApiErrorRule, 'require-node-description-fields': RequireNodeDescriptionFieldsRule, + 'valid-peer-dependencies': ValidPeerDependenciesRule, 'webhook-lifecycle-complete': WebhookLifecycleCompleteRule, } satisfies Record; diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/valid-peer-dependencies.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/valid-peer-dependencies.test.ts new file mode 100644 index 00000000000..74f6e184b2a --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/valid-peer-dependencies.test.ts @@ -0,0 +1,130 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import { ValidPeerDependenciesRule } from './valid-peer-dependencies.js'; + +const ruleTester = new RuleTester(); + +ruleTester.run('valid-peer-dependencies', ValidPeerDependenciesRule, { + valid: [ + { + name: 'only n8n-workflow with "*"', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*" } }', + }, + { + name: 'n8n-workflow and ai-node-sdk', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*", "ai-node-sdk": "*" } }', + }, + { + name: 'n8n-workflow and ai-node-sdk with a version range (ai-node-sdk shape is checked by ai-node-package-json rule)', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*", "ai-node-sdk": "^1.0.0" } }', + }, + { + name: 'non-package.json file is ignored', + filename: 'some-config.json', + code: '{ "peerDependencies": { "n8n-core": "*" } }', + }, + { + name: 'nested objects are not checked', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*" }, "config": { "peerDependencies": { "n8n-core": "*" } } }', + }, + ], + invalid: [ + { + name: 'missing peerDependencies section entirely', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "version": "1.0.0" }', + output: + '{ "name": "n8n-nodes-example", "version": "1.0.0", "peerDependencies": { "n8n-workflow": "*" } }', + errors: [{ messageId: 'missingPeerDependencies' }], + }, + { + name: 'empty peerDependencies section', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "peerDependencies": {} }', + output: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*" } }', + errors: [{ messageId: 'missingN8nWorkflow' }], + }, + { + name: 'peerDependencies missing n8n-workflow but has ai-node-sdk', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "peerDependencies": { "ai-node-sdk": "*" } }', + output: + '{ "name": "n8n-nodes-example", "peerDependencies": { "ai-node-sdk": "*", "n8n-workflow": "*" } }', + errors: [{ messageId: 'missingN8nWorkflow' }], + }, + { + name: 'n8n-workflow pinned to a specific version', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "^1.0.0" } }', + output: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*" } }', + errors: [{ messageId: 'pinnedN8nWorkflow', data: { value: '"^1.0.0"' } }], + }, + { + name: 'forbidden n8n-core peer dependency (CNOC-404 Sinch)', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*", "n8n-core": "*" } }', + errors: [{ messageId: 'forbiddenPeerDependency', data: { name: 'n8n-core' } }], + }, + { + name: 'forbidden arbitrary peer dependency', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*", "lodash": "^4.0.0" } }', + errors: [{ messageId: 'forbiddenPeerDependency', data: { name: 'lodash' } }], + }, + { + name: 'multiple forbidden peer dependencies reported separately', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*", "n8n-core": "*", "axios": "^1.0.0" } }', + errors: [ + { messageId: 'forbiddenPeerDependency', data: { name: 'n8n-core' } }, + { messageId: 'forbiddenPeerDependency', data: { name: 'axios' } }, + ], + }, + { + name: 'completely empty package.json gets peerDependencies inserted', + filename: 'package.json', + code: '{}', + output: '{ "peerDependencies": { "n8n-workflow": "*" } }', + errors: [{ messageId: 'missingPeerDependencies' }], + }, + { + name: 'n8n-workflow value is a non-literal (object) — not auto-fixable', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": { "version": "*" } } }', + errors: [{ messageId: 'pinnedN8nWorkflow', data: { value: 'non-literal' } }], + }, + { + name: 'peerDependencies is a string instead of an object', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "peerDependencies": "n8n-workflow" }', + errors: [{ messageId: 'invalidPeerDependenciesType' }], + }, + { + name: 'peerDependencies is an array instead of an object', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "peerDependencies": ["n8n-workflow"] }', + errors: [{ messageId: 'invalidPeerDependenciesType' }], + }, + { + name: 'peerDependencies is null', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "peerDependencies": null }', + errors: [{ messageId: 'invalidPeerDependenciesType' }], + }, + { + name: 'pinned n8n-workflow combined with forbidden entry', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "1.0.0", "n8n-core": "*" } }', + output: + '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*", "n8n-core": "*" } }', + errors: [ + { messageId: 'pinnedN8nWorkflow', data: { value: '"1.0.0"' } }, + { messageId: 'forbiddenPeerDependency', data: { name: 'n8n-core' } }, + ], + }, + ], +}); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/valid-peer-dependencies.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/valid-peer-dependencies.ts new file mode 100644 index 00000000000..dc2d8f804d0 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/valid-peer-dependencies.ts @@ -0,0 +1,116 @@ +import type { TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; + +import { createRule, findJsonProperty } from '../utils/index.js'; + +const REQUIRED_DEP = 'n8n-workflow'; +const REQUIRED_VERSION = '*'; +const ALLOWED_DEPS = new Set([REQUIRED_DEP, 'ai-node-sdk']); + +export const ValidPeerDependenciesRule = createRule({ + name: 'valid-peer-dependencies', + meta: { + type: 'problem', + docs: { + description: + 'Require community node package.json peerDependencies to contain only "n8n-workflow": "*" (and optionally "ai-node-sdk")', + }, + fixable: 'code', + messages: { + missingPeerDependencies: `The package.json must have a "peerDependencies" section containing "${REQUIRED_DEP}": "${REQUIRED_VERSION}".`, + invalidPeerDependenciesType: `"peerDependencies" must be an object mapping package names to version ranges (containing "${REQUIRED_DEP}": "${REQUIRED_VERSION}").`, + missingN8nWorkflow: `"peerDependencies" must include "${REQUIRED_DEP}": "${REQUIRED_VERSION}".`, + pinnedN8nWorkflow: `"peerDependencies.${REQUIRED_DEP}" must be "${REQUIRED_VERSION}", got {{ value }}.`, + forbiddenPeerDependency: + '"{{ name }}" is not allowed in "peerDependencies". Only "n8n-workflow" and "ai-node-sdk" are permitted.', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + if (!context.filename.endsWith('package.json')) { + return {}; + } + + return { + ObjectExpression(node: TSESTree.ObjectExpression) { + if (node.parent?.type !== AST_NODE_TYPES.ExpressionStatement) { + return; + } + + const peerDepsProp = findJsonProperty(node, 'peerDependencies'); + + if (!peerDepsProp) { + context.report({ + node, + messageId: 'missingPeerDependencies', + fix(fixer) { + const insertion = `"peerDependencies": { "${REQUIRED_DEP}": "${REQUIRED_VERSION}" }`; + const lastProp = node.properties[node.properties.length - 1]; + if (!lastProp) { + return fixer.replaceText(node, `{ ${insertion} }`); + } + return fixer.insertTextAfter(lastProp, `, ${insertion}`); + }, + }); + return; + } + + if (peerDepsProp.value.type !== AST_NODE_TYPES.ObjectExpression) { + context.report({ + node: peerDepsProp, + messageId: 'invalidPeerDependenciesType', + }); + return; + } + + const peerDepsObject = peerDepsProp.value; + const workflowEntry = findJsonProperty(peerDepsObject, REQUIRED_DEP); + + if (!workflowEntry) { + context.report({ + node: peerDepsProp, + messageId: 'missingN8nWorkflow', + fix(fixer) { + const insertion = `"${REQUIRED_DEP}": "${REQUIRED_VERSION}"`; + const lastProp = peerDepsObject.properties[peerDepsObject.properties.length - 1]; + if (!lastProp) { + return fixer.replaceText(peerDepsObject, `{ ${insertion} }`); + } + return fixer.insertTextAfter(lastProp, `, ${insertion}`); + }, + }); + } else if ( + workflowEntry.value.type !== AST_NODE_TYPES.Literal || + workflowEntry.value.value !== REQUIRED_VERSION + ) { + const valueNode = workflowEntry.value; + const rawValue = + valueNode.type === AST_NODE_TYPES.Literal ? String(valueNode.raw) : 'non-literal'; + context.report({ + node: workflowEntry, + messageId: 'pinnedN8nWorkflow', + data: { value: rawValue }, + fix(fixer) { + if (valueNode.type !== AST_NODE_TYPES.Literal) return null; + return fixer.replaceText(valueNode, `"${REQUIRED_VERSION}"`); + }, + }); + } + + for (const prop of peerDepsObject.properties) { + if (prop.type !== AST_NODE_TYPES.Property) continue; + if (prop.key.type !== AST_NODE_TYPES.Literal) continue; + const name = prop.key.value; + if (typeof name !== 'string') continue; + if (ALLOWED_DEPS.has(name)) continue; + context.report({ + node: prop, + messageId: 'forbiddenPeerDependency', + data: { name }, + }); + } + }, + }; + }, +});