@n8n/eslint-plugin-community-nodes 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/README.md +32 -24
  3. package/dist/plugin.d.ts +36 -0
  4. package/dist/plugin.d.ts.map +1 -1
  5. package/dist/plugin.js +12 -0
  6. package/dist/plugin.js.map +1 -1
  7. package/dist/rules/index.d.ts +6 -0
  8. package/dist/rules/index.d.ts.map +1 -1
  9. package/dist/rules/index.js +12 -0
  10. package/dist/rules/index.js.map +1 -1
  11. package/dist/rules/no-overrides-field.d.ts +2 -0
  12. package/dist/rules/no-overrides-field.d.ts.map +1 -0
  13. package/dist/rules/no-overrides-field.js +37 -0
  14. package/dist/rules/no-overrides-field.js.map +1 -0
  15. package/dist/rules/no-runtime-dependencies.d.ts +2 -0
  16. package/dist/rules/no-runtime-dependencies.d.ts.map +1 -0
  17. package/dist/rules/no-runtime-dependencies.js +41 -0
  18. package/dist/rules/no-runtime-dependencies.js.map +1 -0
  19. package/dist/rules/require-node-api-error.d.ts +2 -0
  20. package/dist/rules/require-node-api-error.d.ts.map +1 -0
  21. package/dist/rules/require-node-api-error.js +77 -0
  22. package/dist/rules/require-node-api-error.js.map +1 -0
  23. package/dist/rules/valid-credential-references.d.ts +2 -0
  24. package/dist/rules/valid-credential-references.d.ts.map +1 -0
  25. package/dist/rules/valid-credential-references.js +77 -0
  26. package/dist/rules/valid-credential-references.js.map +1 -0
  27. package/dist/rules/valid-peer-dependencies.d.ts +2 -0
  28. package/dist/rules/valid-peer-dependencies.d.ts.map +1 -0
  29. package/dist/rules/valid-peer-dependencies.js +107 -0
  30. package/dist/rules/valid-peer-dependencies.js.map +1 -0
  31. package/dist/rules/webhook-lifecycle-complete.d.ts +2 -0
  32. package/dist/rules/webhook-lifecycle-complete.d.ts.map +1 -0
  33. package/dist/rules/webhook-lifecycle-complete.js +106 -0
  34. package/dist/rules/webhook-lifecycle-complete.js.map +1 -0
  35. package/docs/rules/no-overrides-field.md +50 -0
  36. package/docs/rules/no-runtime-dependencies.md +58 -0
  37. package/docs/rules/node-class-description-icon-missing.md +4 -2
  38. package/docs/rules/require-community-node-keyword.md +3 -3
  39. package/docs/rules/require-node-api-error.md +62 -0
  40. package/docs/rules/require-node-description-fields.md +1 -1
  41. package/docs/rules/valid-credential-references.md +78 -0
  42. package/docs/rules/valid-peer-dependencies.md +72 -0
  43. package/docs/rules/webhook-lifecycle-complete.md +88 -0
  44. package/package.json +5 -5
  45. package/src/plugin.ts +12 -0
  46. package/src/rules/index.ts +12 -0
  47. package/src/rules/no-overrides-field.test.ts +50 -0
  48. package/src/rules/no-overrides-field.ts +43 -0
  49. package/src/rules/no-runtime-dependencies.test.ts +50 -0
  50. package/src/rules/no-runtime-dependencies.ts +50 -0
  51. package/src/rules/require-node-api-error.test.ts +199 -0
  52. package/src/rules/require-node-api-error.ts +90 -0
  53. package/src/rules/valid-credential-references.test.ts +230 -0
  54. package/src/rules/valid-credential-references.ts +105 -0
  55. package/src/rules/valid-peer-dependencies.test.ts +130 -0
  56. package/src/rules/valid-peer-dependencies.ts +116 -0
  57. package/src/rules/webhook-lifecycle-complete.test.ts +217 -0
  58. package/src/rules/webhook-lifecycle-complete.ts +130 -0
  59. package/tsconfig.build.tsbuildinfo +1 -1
  60. package/tsconfig.json +0 -1
@@ -0,0 +1,90 @@
1
+ import { DefinitionType } from '@typescript-eslint/scope-manager';
2
+ import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
3
+
4
+ import { isFileType } from '../utils/index.js';
5
+ import { createRule } from '../utils/rule-creator.js';
6
+
7
+ const ALLOWED_ERROR_CLASSES = new Set(['NodeApiError', 'NodeOperationError']);
8
+
9
+ function getThrowCalleeName(argument: TSESTree.Expression): string | null {
10
+ if (argument.type === AST_NODE_TYPES.NewExpression) {
11
+ if (argument.callee.type === AST_NODE_TYPES.Identifier) {
12
+ return argument.callee.name;
13
+ }
14
+ }
15
+ return null;
16
+ }
17
+
18
+ function isInsideCatchClause(node: TSESTree.Node): boolean {
19
+ let current: TSESTree.Node | undefined = node.parent;
20
+ while (current) {
21
+ if (current.type === AST_NODE_TYPES.CatchClause) {
22
+ return true;
23
+ }
24
+ current = current.parent;
25
+ }
26
+ return false;
27
+ }
28
+
29
+ export const RequireNodeApiErrorRule = createRule({
30
+ name: 'require-node-api-error',
31
+ meta: {
32
+ type: 'problem',
33
+ docs: {
34
+ description:
35
+ 'Require NodeApiError or NodeOperationError for error wrapping in catch blocks. ' +
36
+ 'Raw errors lose HTTP context in the n8n UI.',
37
+ },
38
+ messages: {
39
+ useNodeApiError:
40
+ 'Use `NodeApiError` or `NodeOperationError` instead of re-throwing raw errors. ' +
41
+ 'Example: `throw new NodeApiError(this.getNode(), error as JsonObject)`',
42
+ useNodeApiErrorInsteadOfGeneric:
43
+ 'Use `NodeApiError` or `NodeOperationError` instead of `{{ errorClass }}`. ' +
44
+ 'Example: `throw new NodeApiError(this.getNode(), error as JsonObject)`',
45
+ },
46
+ schema: [],
47
+ },
48
+ defaultOptions: [],
49
+ create(context) {
50
+ const isNodeFile = isFileType(context.filename, '.node.ts');
51
+ const isHelperFile =
52
+ context.filename.endsWith('.ts') &&
53
+ !isNodeFile &&
54
+ !isFileType(context.filename, '.credentials.ts');
55
+
56
+ if (!isNodeFile && !isHelperFile) {
57
+ return {};
58
+ }
59
+
60
+ return {
61
+ ThrowStatement(node) {
62
+ if (!isInsideCatchClause(node)) return;
63
+ if (!node.argument) return;
64
+
65
+ const { argument } = node;
66
+
67
+ if (argument.type === AST_NODE_TYPES.Identifier) {
68
+ const scope = context.sourceCode.getScope(node);
69
+ const ref = scope.references.find((r) => r.identifier === argument);
70
+ const isCatchParam =
71
+ ref?.resolved?.defs.some((def) => def.type === DefinitionType.CatchClause) ?? false;
72
+
73
+ if (isCatchParam) {
74
+ context.report({ node, messageId: 'useNodeApiError' });
75
+ }
76
+ return;
77
+ }
78
+
79
+ const calleeName = getThrowCalleeName(argument);
80
+ if (calleeName !== null && !ALLOWED_ERROR_CLASSES.has(calleeName)) {
81
+ context.report({
82
+ node,
83
+ messageId: 'useNodeApiErrorInsteadOfGeneric',
84
+ data: { errorClass: calleeName },
85
+ });
86
+ }
87
+ },
88
+ };
89
+ },
90
+ });
@@ -0,0 +1,230 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+ import { afterEach, beforeEach, describe, vi } from 'vitest';
3
+
4
+ import { ValidCredentialReferencesRule } from './valid-credential-references.js';
5
+ import * as fileUtils from '../utils/file-utils.js';
6
+
7
+ vi.mock('../utils/file-utils.js', async () => {
8
+ const actual = await vi.importActual('../utils/file-utils.js');
9
+ return {
10
+ ...actual,
11
+ readPackageJsonCredentials: vi.fn(),
12
+ findPackageJson: vi.fn(),
13
+ };
14
+ });
15
+
16
+ const mockReadPackageJsonCredentials = vi.mocked(fileUtils.readPackageJsonCredentials);
17
+ const mockFindPackageJson = vi.mocked(fileUtils.findPackageJson);
18
+
19
+ const ruleTester = new RuleTester();
20
+
21
+ const nodeFilePath = '/tmp/TestNode.node.ts';
22
+
23
+ function createNodeCode(
24
+ credentials: Array<string | { name: string; required?: boolean }> = [],
25
+ ): string {
26
+ const credentialsArray =
27
+ credentials.length > 0
28
+ ? credentials
29
+ .map((cred) => {
30
+ if (typeof cred === 'string') {
31
+ return `'${cred}'`;
32
+ } else {
33
+ const required =
34
+ cred.required !== undefined ? `,\n\t\t\t\trequired: ${cred.required}` : '';
35
+ return `{\n\t\t\t\tname: '${cred.name}'${required},\n\t\t\t}`;
36
+ }
37
+ })
38
+ .join(',\n\t\t\t')
39
+ : '';
40
+
41
+ const credentialsProperty =
42
+ credentials.length > 0 ? `credentials: [\n\t\t\t${credentialsArray}\n\t\t],` : '';
43
+
44
+ return `
45
+ import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
46
+
47
+ export class TestNode implements INodeType {
48
+ description: INodeTypeDescription = {
49
+ displayName: 'Test Node',
50
+ name: 'testNode',
51
+ group: ['output'],
52
+ version: 1,
53
+ inputs: ['main'],
54
+ outputs: ['main'],
55
+ ${credentialsProperty}
56
+ properties: [],
57
+ };
58
+ }`;
59
+ }
60
+
61
+ /** Same as createNodeCode but uses double quotes for the credential name — matches fixer output */
62
+ function createExpectedNodeCode(
63
+ credentials: Array<string | { name: string; required?: boolean }> = [],
64
+ ): string {
65
+ const credentialsArray =
66
+ credentials.length > 0
67
+ ? credentials
68
+ .map((cred) => {
69
+ if (typeof cred === 'string') {
70
+ return `"${cred}"`;
71
+ } else {
72
+ const required =
73
+ cred.required !== undefined ? `,\n\t\t\t\trequired: ${cred.required}` : '';
74
+ return `{\n\t\t\t\tname: "${cred.name}"${required},\n\t\t\t}`;
75
+ }
76
+ })
77
+ .join(',\n\t\t\t')
78
+ : '';
79
+
80
+ const credentialsProperty =
81
+ credentials.length > 0 ? `credentials: [\n\t\t\t${credentialsArray}\n\t\t],` : '';
82
+
83
+ return `
84
+ import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
85
+
86
+ export class TestNode implements INodeType {
87
+ description: INodeTypeDescription = {
88
+ displayName: 'Test Node',
89
+ name: 'testNode',
90
+ group: ['output'],
91
+ version: 1,
92
+ inputs: ['main'],
93
+ outputs: ['main'],
94
+ ${credentialsProperty}
95
+ properties: [],
96
+ };
97
+ }`;
98
+ }
99
+
100
+ function createNonNodeClass(): string {
101
+ return `
102
+ export class RegularClass {
103
+ credentials = [
104
+ { name: 'ExternalApi', required: true }
105
+ ];
106
+ }`;
107
+ }
108
+
109
+ function createNonINodeTypeClass(): string {
110
+ return `
111
+ export class NotANode {
112
+ description = {
113
+ displayName: 'Not A Node',
114
+ credentials: [
115
+ { name: 'ExternalApi', required: true }
116
+ ]
117
+ };
118
+ }`;
119
+ }
120
+
121
+ mockFindPackageJson.mockReturnValue('/tmp/package.json');
122
+ mockReadPackageJsonCredentials.mockReturnValue(new Set(['myApiCredential', 'oauthApi']));
123
+
124
+ ruleTester.run('valid-credential-references', ValidCredentialReferencesRule, {
125
+ valid: [
126
+ {
127
+ name: 'node referencing a credential that exists (object form)',
128
+ filename: nodeFilePath,
129
+ code: createNodeCode([{ name: 'myApiCredential', required: true }]),
130
+ },
131
+ {
132
+ name: 'node referencing a credential that exists (string form)',
133
+ filename: nodeFilePath,
134
+ code: createNodeCode(['myApiCredential']),
135
+ },
136
+ {
137
+ name: 'node referencing multiple credentials that all exist',
138
+ filename: nodeFilePath,
139
+ code: createNodeCode(['myApiCredential', { name: 'oauthApi', required: false }]),
140
+ },
141
+ {
142
+ name: 'node without credentials array',
143
+ filename: nodeFilePath,
144
+ code: createNodeCode(),
145
+ },
146
+ {
147
+ name: 'non-node file ignored',
148
+ filename: '/tmp/regular-file.ts',
149
+ code: createNonNodeClass(),
150
+ },
151
+ {
152
+ name: 'non-INodeType class ignored',
153
+ filename: nodeFilePath,
154
+ code: createNonINodeTypeClass(),
155
+ },
156
+ ],
157
+ invalid: [
158
+ {
159
+ name: 'credential name does not exist in package (object form)',
160
+ filename: nodeFilePath,
161
+ code: createNodeCode([{ name: 'brokenReference', required: true }]),
162
+ errors: [
163
+ {
164
+ messageId: 'credentialNotFound',
165
+ data: { credentialName: 'brokenReference' },
166
+ },
167
+ ],
168
+ },
169
+ {
170
+ name: 'credential name does not exist in package (string form)',
171
+ filename: nodeFilePath,
172
+ code: createNodeCode(['unknownCredential']),
173
+ errors: [
174
+ {
175
+ messageId: 'credentialNotFound',
176
+ data: { credentialName: 'unknownCredential' },
177
+ },
178
+ ],
179
+ },
180
+ {
181
+ name: 'credential name is a typo close to an existing credential — suggestion provided',
182
+ filename: nodeFilePath,
183
+ code: createNodeCode([{ name: 'myApiCredentail', required: true }]),
184
+ errors: [
185
+ {
186
+ messageId: 'credentialNotFound',
187
+ data: { credentialName: 'myApiCredentail' },
188
+ suggestions: [
189
+ {
190
+ messageId: 'didYouMean',
191
+ data: { suggestedName: 'myApiCredential' },
192
+ output: createExpectedNodeCode([{ name: 'myApiCredential', required: true }]),
193
+ },
194
+ ],
195
+ },
196
+ ],
197
+ },
198
+ {
199
+ name: 'mix of valid and invalid credentials — only invalid reported',
200
+ filename: nodeFilePath,
201
+ code: createNodeCode(['myApiCredential', { name: 'brokenRef', required: true }]),
202
+ errors: [
203
+ {
204
+ messageId: 'credentialNotFound',
205
+ data: { credentialName: 'brokenRef' },
206
+ },
207
+ ],
208
+ },
209
+ ],
210
+ });
211
+
212
+ describe('valid-credential-references — no package.json found', () => {
213
+ beforeEach(() => {
214
+ mockFindPackageJson.mockReturnValue(null);
215
+ });
216
+ afterEach(() => {
217
+ mockFindPackageJson.mockReturnValue('/tmp/package.json');
218
+ });
219
+
220
+ ruleTester.run('valid-credential-references (no package.json)', ValidCredentialReferencesRule, {
221
+ valid: [
222
+ {
223
+ name: 'check is skipped when package.json cannot be found',
224
+ filename: nodeFilePath,
225
+ code: createNodeCode([{ name: 'anyCredential', required: true }]),
226
+ },
227
+ ],
228
+ invalid: [],
229
+ });
230
+ });
@@ -0,0 +1,105 @@
1
+ import { TSESTree } from '@typescript-eslint/types';
2
+ import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
3
+
4
+ import {
5
+ isNodeTypeClass,
6
+ findClassProperty,
7
+ findArrayLiteralProperty,
8
+ extractCredentialNameFromArray,
9
+ findPackageJson,
10
+ readPackageJsonCredentials,
11
+ isFileType,
12
+ findSimilarStrings,
13
+ createRule,
14
+ } from '../utils/index.js';
15
+
16
+ export const ValidCredentialReferencesRule = createRule({
17
+ name: 'valid-credential-references',
18
+ meta: {
19
+ type: 'problem',
20
+ docs: {
21
+ description:
22
+ 'Ensure credentials referenced in node descriptions exist as credential classes in the package',
23
+ },
24
+ messages: {
25
+ credentialNotFound:
26
+ 'Credential "{{ credentialName }}" does not exist in this package. Check for typos or ensure the credential class is declared and listed in package.json.',
27
+ didYouMean: "Did you mean '{{ suggestedName }}'?",
28
+ },
29
+ schema: [],
30
+ hasSuggestions: true,
31
+ },
32
+ defaultOptions: [],
33
+ create(context) {
34
+ if (!isFileType(context.filename, '.node.ts')) {
35
+ return {};
36
+ }
37
+
38
+ let packageCredentials: Set<string> | null = null;
39
+
40
+ const loadPackageCredentials = (): Set<string> => {
41
+ if (packageCredentials !== null) {
42
+ return packageCredentials;
43
+ }
44
+
45
+ const packageJsonPath = findPackageJson(context.filename);
46
+ if (!packageJsonPath) {
47
+ packageCredentials = new Set();
48
+ return packageCredentials;
49
+ }
50
+
51
+ packageCredentials = readPackageJsonCredentials(packageJsonPath);
52
+ return packageCredentials;
53
+ };
54
+
55
+ return {
56
+ ClassDeclaration(node) {
57
+ if (!isNodeTypeClass(node)) {
58
+ return;
59
+ }
60
+
61
+ const descriptionProperty = findClassProperty(node, 'description');
62
+ if (
63
+ !descriptionProperty?.value ||
64
+ descriptionProperty.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression
65
+ ) {
66
+ return;
67
+ }
68
+
69
+ const credentialsArray = findArrayLiteralProperty(descriptionProperty.value, 'credentials');
70
+ if (!credentialsArray) {
71
+ return;
72
+ }
73
+
74
+ const knownCredentials = loadPackageCredentials();
75
+ if (knownCredentials.size === 0) {
76
+ return;
77
+ }
78
+
79
+ credentialsArray.elements.forEach((element) => {
80
+ const credentialInfo = extractCredentialNameFromArray(element);
81
+ if (!credentialInfo || knownCredentials.has(credentialInfo.name)) {
82
+ return;
83
+ }
84
+
85
+ const similar = findSimilarStrings(credentialInfo.name, knownCredentials);
86
+ const suggestions: ReportSuggestionArray<'credentialNotFound' | 'didYouMean'> =
87
+ similar.map((suggestedName) => ({
88
+ messageId: 'didYouMean' as const,
89
+ data: { suggestedName },
90
+ fix(fixer) {
91
+ return fixer.replaceText(credentialInfo.node, `"${suggestedName}"`);
92
+ },
93
+ }));
94
+
95
+ context.report({
96
+ node: credentialInfo.node,
97
+ messageId: 'credentialNotFound',
98
+ data: { credentialName: credentialInfo.name },
99
+ suggest: suggestions,
100
+ });
101
+ });
102
+ },
103
+ };
104
+ },
105
+ });
@@ -0,0 +1,130 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+
3
+ import { ValidPeerDependenciesRule } from './valid-peer-dependencies.js';
4
+
5
+ const ruleTester = new RuleTester();
6
+
7
+ ruleTester.run('valid-peer-dependencies', ValidPeerDependenciesRule, {
8
+ valid: [
9
+ {
10
+ name: 'only n8n-workflow with "*"',
11
+ filename: 'package.json',
12
+ code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*" } }',
13
+ },
14
+ {
15
+ name: 'n8n-workflow and ai-node-sdk',
16
+ filename: 'package.json',
17
+ code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*", "ai-node-sdk": "*" } }',
18
+ },
19
+ {
20
+ name: 'n8n-workflow and ai-node-sdk with a version range (ai-node-sdk shape is checked by ai-node-package-json rule)',
21
+ filename: 'package.json',
22
+ code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*", "ai-node-sdk": "^1.0.0" } }',
23
+ },
24
+ {
25
+ name: 'non-package.json file is ignored',
26
+ filename: 'some-config.json',
27
+ code: '{ "peerDependencies": { "n8n-core": "*" } }',
28
+ },
29
+ {
30
+ name: 'nested objects are not checked',
31
+ filename: 'package.json',
32
+ code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*" }, "config": { "peerDependencies": { "n8n-core": "*" } } }',
33
+ },
34
+ ],
35
+ invalid: [
36
+ {
37
+ name: 'missing peerDependencies section entirely',
38
+ filename: 'package.json',
39
+ code: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
40
+ output:
41
+ '{ "name": "n8n-nodes-example", "version": "1.0.0", "peerDependencies": { "n8n-workflow": "*" } }',
42
+ errors: [{ messageId: 'missingPeerDependencies' }],
43
+ },
44
+ {
45
+ name: 'empty peerDependencies section',
46
+ filename: 'package.json',
47
+ code: '{ "name": "n8n-nodes-example", "peerDependencies": {} }',
48
+ output: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*" } }',
49
+ errors: [{ messageId: 'missingN8nWorkflow' }],
50
+ },
51
+ {
52
+ name: 'peerDependencies missing n8n-workflow but has ai-node-sdk',
53
+ filename: 'package.json',
54
+ code: '{ "name": "n8n-nodes-example", "peerDependencies": { "ai-node-sdk": "*" } }',
55
+ output:
56
+ '{ "name": "n8n-nodes-example", "peerDependencies": { "ai-node-sdk": "*", "n8n-workflow": "*" } }',
57
+ errors: [{ messageId: 'missingN8nWorkflow' }],
58
+ },
59
+ {
60
+ name: 'n8n-workflow pinned to a specific version',
61
+ filename: 'package.json',
62
+ code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "^1.0.0" } }',
63
+ output: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*" } }',
64
+ errors: [{ messageId: 'pinnedN8nWorkflow', data: { value: '"^1.0.0"' } }],
65
+ },
66
+ {
67
+ name: 'forbidden n8n-core peer dependency (CNOC-404 Sinch)',
68
+ filename: 'package.json',
69
+ code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*", "n8n-core": "*" } }',
70
+ errors: [{ messageId: 'forbiddenPeerDependency', data: { name: 'n8n-core' } }],
71
+ },
72
+ {
73
+ name: 'forbidden arbitrary peer dependency',
74
+ filename: 'package.json',
75
+ code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*", "lodash": "^4.0.0" } }',
76
+ errors: [{ messageId: 'forbiddenPeerDependency', data: { name: 'lodash' } }],
77
+ },
78
+ {
79
+ name: 'multiple forbidden peer dependencies reported separately',
80
+ filename: 'package.json',
81
+ code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*", "n8n-core": "*", "axios": "^1.0.0" } }',
82
+ errors: [
83
+ { messageId: 'forbiddenPeerDependency', data: { name: 'n8n-core' } },
84
+ { messageId: 'forbiddenPeerDependency', data: { name: 'axios' } },
85
+ ],
86
+ },
87
+ {
88
+ name: 'completely empty package.json gets peerDependencies inserted',
89
+ filename: 'package.json',
90
+ code: '{}',
91
+ output: '{ "peerDependencies": { "n8n-workflow": "*" } }',
92
+ errors: [{ messageId: 'missingPeerDependencies' }],
93
+ },
94
+ {
95
+ name: 'n8n-workflow value is a non-literal (object) — not auto-fixable',
96
+ filename: 'package.json',
97
+ code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": { "version": "*" } } }',
98
+ errors: [{ messageId: 'pinnedN8nWorkflow', data: { value: 'non-literal' } }],
99
+ },
100
+ {
101
+ name: 'peerDependencies is a string instead of an object',
102
+ filename: 'package.json',
103
+ code: '{ "name": "n8n-nodes-example", "peerDependencies": "n8n-workflow" }',
104
+ errors: [{ messageId: 'invalidPeerDependenciesType' }],
105
+ },
106
+ {
107
+ name: 'peerDependencies is an array instead of an object',
108
+ filename: 'package.json',
109
+ code: '{ "name": "n8n-nodes-example", "peerDependencies": ["n8n-workflow"] }',
110
+ errors: [{ messageId: 'invalidPeerDependenciesType' }],
111
+ },
112
+ {
113
+ name: 'peerDependencies is null',
114
+ filename: 'package.json',
115
+ code: '{ "name": "n8n-nodes-example", "peerDependencies": null }',
116
+ errors: [{ messageId: 'invalidPeerDependenciesType' }],
117
+ },
118
+ {
119
+ name: 'pinned n8n-workflow combined with forbidden entry',
120
+ filename: 'package.json',
121
+ code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "1.0.0", "n8n-core": "*" } }',
122
+ output:
123
+ '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*", "n8n-core": "*" } }',
124
+ errors: [
125
+ { messageId: 'pinnedN8nWorkflow', data: { value: '"1.0.0"' } },
126
+ { messageId: 'forbiddenPeerDependency', data: { name: 'n8n-core' } },
127
+ ],
128
+ },
129
+ ],
130
+ });
@@ -0,0 +1,116 @@
1
+ import type { TSESTree } from '@typescript-eslint/utils';
2
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
3
+
4
+ import { createRule, findJsonProperty } from '../utils/index.js';
5
+
6
+ const REQUIRED_DEP = 'n8n-workflow';
7
+ const REQUIRED_VERSION = '*';
8
+ const ALLOWED_DEPS = new Set([REQUIRED_DEP, 'ai-node-sdk']);
9
+
10
+ export const ValidPeerDependenciesRule = createRule({
11
+ name: 'valid-peer-dependencies',
12
+ meta: {
13
+ type: 'problem',
14
+ docs: {
15
+ description:
16
+ 'Require community node package.json peerDependencies to contain only "n8n-workflow": "*" (and optionally "ai-node-sdk")',
17
+ },
18
+ fixable: 'code',
19
+ messages: {
20
+ missingPeerDependencies: `The package.json must have a "peerDependencies" section containing "${REQUIRED_DEP}": "${REQUIRED_VERSION}".`,
21
+ invalidPeerDependenciesType: `"peerDependencies" must be an object mapping package names to version ranges (containing "${REQUIRED_DEP}": "${REQUIRED_VERSION}").`,
22
+ missingN8nWorkflow: `"peerDependencies" must include "${REQUIRED_DEP}": "${REQUIRED_VERSION}".`,
23
+ pinnedN8nWorkflow: `"peerDependencies.${REQUIRED_DEP}" must be "${REQUIRED_VERSION}", got {{ value }}.`,
24
+ forbiddenPeerDependency:
25
+ '"{{ name }}" is not allowed in "peerDependencies". Only "n8n-workflow" and "ai-node-sdk" are permitted.',
26
+ },
27
+ schema: [],
28
+ },
29
+ defaultOptions: [],
30
+ create(context) {
31
+ if (!context.filename.endsWith('package.json')) {
32
+ return {};
33
+ }
34
+
35
+ return {
36
+ ObjectExpression(node: TSESTree.ObjectExpression) {
37
+ if (node.parent?.type !== AST_NODE_TYPES.ExpressionStatement) {
38
+ return;
39
+ }
40
+
41
+ const peerDepsProp = findJsonProperty(node, 'peerDependencies');
42
+
43
+ if (!peerDepsProp) {
44
+ context.report({
45
+ node,
46
+ messageId: 'missingPeerDependencies',
47
+ fix(fixer) {
48
+ const insertion = `"peerDependencies": { "${REQUIRED_DEP}": "${REQUIRED_VERSION}" }`;
49
+ const lastProp = node.properties[node.properties.length - 1];
50
+ if (!lastProp) {
51
+ return fixer.replaceText(node, `{ ${insertion} }`);
52
+ }
53
+ return fixer.insertTextAfter(lastProp, `, ${insertion}`);
54
+ },
55
+ });
56
+ return;
57
+ }
58
+
59
+ if (peerDepsProp.value.type !== AST_NODE_TYPES.ObjectExpression) {
60
+ context.report({
61
+ node: peerDepsProp,
62
+ messageId: 'invalidPeerDependenciesType',
63
+ });
64
+ return;
65
+ }
66
+
67
+ const peerDepsObject = peerDepsProp.value;
68
+ const workflowEntry = findJsonProperty(peerDepsObject, REQUIRED_DEP);
69
+
70
+ if (!workflowEntry) {
71
+ context.report({
72
+ node: peerDepsProp,
73
+ messageId: 'missingN8nWorkflow',
74
+ fix(fixer) {
75
+ const insertion = `"${REQUIRED_DEP}": "${REQUIRED_VERSION}"`;
76
+ const lastProp = peerDepsObject.properties[peerDepsObject.properties.length - 1];
77
+ if (!lastProp) {
78
+ return fixer.replaceText(peerDepsObject, `{ ${insertion} }`);
79
+ }
80
+ return fixer.insertTextAfter(lastProp, `, ${insertion}`);
81
+ },
82
+ });
83
+ } else if (
84
+ workflowEntry.value.type !== AST_NODE_TYPES.Literal ||
85
+ workflowEntry.value.value !== REQUIRED_VERSION
86
+ ) {
87
+ const valueNode = workflowEntry.value;
88
+ const rawValue =
89
+ valueNode.type === AST_NODE_TYPES.Literal ? String(valueNode.raw) : 'non-literal';
90
+ context.report({
91
+ node: workflowEntry,
92
+ messageId: 'pinnedN8nWorkflow',
93
+ data: { value: rawValue },
94
+ fix(fixer) {
95
+ if (valueNode.type !== AST_NODE_TYPES.Literal) return null;
96
+ return fixer.replaceText(valueNode, `"${REQUIRED_VERSION}"`);
97
+ },
98
+ });
99
+ }
100
+
101
+ for (const prop of peerDepsObject.properties) {
102
+ if (prop.type !== AST_NODE_TYPES.Property) continue;
103
+ if (prop.key.type !== AST_NODE_TYPES.Literal) continue;
104
+ const name = prop.key.value;
105
+ if (typeof name !== 'string') continue;
106
+ if (ALLOWED_DEPS.has(name)) continue;
107
+ context.report({
108
+ node: prop,
109
+ messageId: 'forbiddenPeerDependency',
110
+ data: { name },
111
+ });
112
+ }
113
+ },
114
+ };
115
+ },
116
+ });