@n8n/eslint-plugin-community-nodes 0.13.0 → 0.14.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.
- package/.turbo/turbo-build.log +1 -1
- package/README.md +30 -24
- package/dist/plugin.d.ts +24 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +8 -0
- package/dist/plugin.js.map +1 -1
- package/dist/rules/index.d.ts +4 -0
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +8 -0
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/no-overrides-field.d.ts +2 -0
- package/dist/rules/no-overrides-field.d.ts.map +1 -0
- package/dist/rules/no-overrides-field.js +37 -0
- package/dist/rules/no-overrides-field.js.map +1 -0
- package/dist/rules/require-node-api-error.d.ts +2 -0
- package/dist/rules/require-node-api-error.d.ts.map +1 -0
- package/dist/rules/require-node-api-error.js +77 -0
- package/dist/rules/require-node-api-error.js.map +1 -0
- package/dist/rules/valid-peer-dependencies.d.ts +2 -0
- package/dist/rules/valid-peer-dependencies.d.ts.map +1 -0
- package/dist/rules/valid-peer-dependencies.js +107 -0
- package/dist/rules/valid-peer-dependencies.js.map +1 -0
- package/dist/rules/webhook-lifecycle-complete.d.ts +2 -0
- package/dist/rules/webhook-lifecycle-complete.d.ts.map +1 -0
- package/dist/rules/webhook-lifecycle-complete.js +98 -0
- package/dist/rules/webhook-lifecycle-complete.js.map +1 -0
- package/docs/rules/no-overrides-field.md +50 -0
- package/docs/rules/node-class-description-icon-missing.md +4 -2
- package/docs/rules/require-community-node-keyword.md +3 -3
- package/docs/rules/require-node-api-error.md +62 -0
- package/docs/rules/require-node-description-fields.md +1 -1
- package/docs/rules/valid-peer-dependencies.md +72 -0
- package/docs/rules/webhook-lifecycle-complete.md +88 -0
- package/package.json +5 -5
- package/src/plugin.ts +8 -0
- package/src/rules/index.ts +8 -0
- package/src/rules/no-overrides-field.test.ts +50 -0
- package/src/rules/no-overrides-field.ts +43 -0
- package/src/rules/require-node-api-error.test.ts +199 -0
- package/src/rules/require-node-api-error.ts +90 -0
- package/src/rules/valid-peer-dependencies.test.ts +130 -0
- package/src/rules/valid-peer-dependencies.ts +116 -0
- package/src/rules/webhook-lifecycle-complete.test.ts +212 -0
- package/src/rules/webhook-lifecycle-complete.ts +120 -0
- package/tsconfig.build.tsbuildinfo +1 -1
- package/tsconfig.json +0 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
|
|
3
|
+
import { RequireNodeApiErrorRule } from './require-node-api-error.js';
|
|
4
|
+
|
|
5
|
+
const ruleTester = new RuleTester();
|
|
6
|
+
|
|
7
|
+
ruleTester.run('require-node-api-error', RequireNodeApiErrorRule, {
|
|
8
|
+
valid: [
|
|
9
|
+
{
|
|
10
|
+
name: 'throw NodeApiError in catch block',
|
|
11
|
+
code: `
|
|
12
|
+
try {
|
|
13
|
+
await apiRequest();
|
|
14
|
+
} catch (error) {
|
|
15
|
+
throw new NodeApiError(this.getNode(), error as JsonObject);
|
|
16
|
+
}`,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'throw NodeOperationError in catch block',
|
|
20
|
+
code: `
|
|
21
|
+
try {
|
|
22
|
+
await apiRequest();
|
|
23
|
+
} catch (error) {
|
|
24
|
+
throw new NodeOperationError(this.getNode(), 'Operation failed', { itemIndex: i });
|
|
25
|
+
}`,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'throw outside catch block (not in scope)',
|
|
29
|
+
code: `
|
|
30
|
+
function validate(input: string) {
|
|
31
|
+
if (!input) {
|
|
32
|
+
throw new Error('Input required');
|
|
33
|
+
}
|
|
34
|
+
}`,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'throw new Error outside catch block (not in scope)',
|
|
38
|
+
code: `
|
|
39
|
+
throw new Error('Something went wrong');`,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'continueOnFail pattern with NodeApiError',
|
|
43
|
+
code: `
|
|
44
|
+
try {
|
|
45
|
+
responseData = await apiRequest.call(this, 'POST', '/tasks', body);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (this.continueOnFail()) {
|
|
48
|
+
returnData.push({ json: { error: error.message } });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
throw new NodeApiError(this.getNode(), error as JsonObject);
|
|
52
|
+
}`,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'conditional handling then NodeApiError in else',
|
|
56
|
+
code: `
|
|
57
|
+
try {
|
|
58
|
+
await ftp.put(data, path);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error.code === 553) {
|
|
61
|
+
await ftp.mkdir(dirPath, true);
|
|
62
|
+
await ftp.put(data, path);
|
|
63
|
+
} else {
|
|
64
|
+
throw new NodeApiError(this.getNode(), error as JsonObject);
|
|
65
|
+
}
|
|
66
|
+
}`,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'throw wrapped error stored in variable',
|
|
70
|
+
code: `
|
|
71
|
+
try {
|
|
72
|
+
await apiRequest();
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const wrapped = new NodeApiError(this.getNode(), error as JsonObject);
|
|
75
|
+
throw wrapped;
|
|
76
|
+
}`,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'shadowed variable with same name as catch param',
|
|
80
|
+
code: `
|
|
81
|
+
try {
|
|
82
|
+
await apiRequest();
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const fn = (error: Error) => {
|
|
85
|
+
throw error;
|
|
86
|
+
};
|
|
87
|
+
}`,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'no throw in catch block',
|
|
91
|
+
code: `
|
|
92
|
+
try {
|
|
93
|
+
await apiRequest();
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error(error);
|
|
96
|
+
}`,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'bare re-throw in credential file (skipped)',
|
|
100
|
+
filename: '/path/to/MyCredential.credentials.ts',
|
|
101
|
+
code: `
|
|
102
|
+
try {
|
|
103
|
+
await apiRequest();
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw error;
|
|
106
|
+
}`,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'bare re-throw in .js file (skipped)',
|
|
110
|
+
filename: '/path/to/helper.js',
|
|
111
|
+
code: `
|
|
112
|
+
try {
|
|
113
|
+
apiRequest();
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw error;
|
|
116
|
+
}`,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
invalid: [
|
|
120
|
+
{
|
|
121
|
+
name: 'bare re-throw of caught error',
|
|
122
|
+
code: `
|
|
123
|
+
try {
|
|
124
|
+
await apiRequest();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw error;
|
|
127
|
+
}`,
|
|
128
|
+
errors: [{ messageId: 'useNodeApiError' }],
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'throw new Error in catch block',
|
|
132
|
+
code: `
|
|
133
|
+
try {
|
|
134
|
+
await apiRequest();
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw new Error('Request failed');
|
|
137
|
+
}`,
|
|
138
|
+
errors: [
|
|
139
|
+
{
|
|
140
|
+
messageId: 'useNodeApiErrorInsteadOfGeneric',
|
|
141
|
+
data: { errorClass: 'Error' },
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'bare re-throw after continueOnFail',
|
|
147
|
+
code: `
|
|
148
|
+
try {
|
|
149
|
+
responseData = await apiRequest.call(this, 'POST', '/tasks', body);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (this.continueOnFail()) {
|
|
152
|
+
returnData.push({ json: { error: error.message } });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
throw error;
|
|
156
|
+
}`,
|
|
157
|
+
errors: [{ messageId: 'useNodeApiError' }],
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'throw new TypeError in catch block',
|
|
161
|
+
code: `
|
|
162
|
+
try {
|
|
163
|
+
JSON.parse(data);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
throw new TypeError('Invalid JSON');
|
|
166
|
+
}`,
|
|
167
|
+
errors: [
|
|
168
|
+
{
|
|
169
|
+
messageId: 'useNodeApiErrorInsteadOfGeneric',
|
|
170
|
+
data: { errorClass: 'TypeError' },
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'bare re-throw in nested catch',
|
|
176
|
+
code: `
|
|
177
|
+
try {
|
|
178
|
+
try {
|
|
179
|
+
await apiRequest();
|
|
180
|
+
} catch (innerError) {
|
|
181
|
+
throw innerError;
|
|
182
|
+
}
|
|
183
|
+
} catch (outerError) {
|
|
184
|
+
throw new NodeApiError(this.getNode(), outerError as JsonObject);
|
|
185
|
+
}`,
|
|
186
|
+
errors: [{ messageId: 'useNodeApiError' }],
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'throw named variable in catch',
|
|
190
|
+
code: `
|
|
191
|
+
try {
|
|
192
|
+
await apiRequest();
|
|
193
|
+
} catch (e) {
|
|
194
|
+
throw e;
|
|
195
|
+
}`,
|
|
196
|
+
errors: [{ messageId: 'useNodeApiError' }],
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
});
|
|
@@ -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,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
|
+
});
|