mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
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:
co-authored by
Garrit Franke
parent
a7d7352c9b
commit
fadd81c519
@@ -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>;
|
||||
|
||||
+130
@@ -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 },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user