feat(core): Add valid-peer-dependencies ESLint rule for community nodes (no-changelog) (#28983)

Co-authored-by: Garrit Franke <garritfra@users.noreply.github.com>
This commit is contained in:
Garrit Franke
2026-04-28 06:59:21 +00:00
committed by GitHub
co-authored by Garrit Franke
parent a7d7352c9b
commit fadd81c519
6 changed files with 323 additions and 0 deletions
@@ -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) | ✅ ☑️ | | | | |
<!-- end auto-generated rules list -->
@@ -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).
<!-- end auto-generated rule header -->
## 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": "*"
}
}
```
@@ -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',
},
},
@@ -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<string, AnyRuleModule>;
@@ -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' } },
],
},
],
});
@@ -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 },
});
}
},
};
},
});