@n8n/eslint-plugin-community-nodes 0.11.0 → 0.12.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 +3 -0
- package/dist/plugin.d.ts +18 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +6 -0
- package/dist/plugin.js.map +1 -1
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +6 -0
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/missing-paired-item.d.ts +16 -0
- package/dist/rules/missing-paired-item.d.ts.map +1 -0
- package/dist/rules/missing-paired-item.js +121 -0
- package/dist/rules/missing-paired-item.js.map +1 -0
- package/dist/rules/no-forbidden-lifecycle-scripts.d.ts +2 -0
- package/dist/rules/no-forbidden-lifecycle-scripts.d.ts.map +1 -0
- package/dist/rules/no-forbidden-lifecycle-scripts.js +59 -0
- package/dist/rules/no-forbidden-lifecycle-scripts.js.map +1 -0
- package/dist/rules/require-continue-on-fail.d.ts +2 -0
- package/dist/rules/require-continue-on-fail.d.ts.map +1 -0
- package/dist/rules/require-continue-on-fail.js +76 -0
- package/dist/rules/require-continue-on-fail.js.map +1 -0
- package/docs/rules/missing-paired-item.md +70 -0
- package/docs/rules/no-forbidden-lifecycle-scripts.md +46 -0
- package/docs/rules/require-continue-on-fail.md +56 -0
- package/package.json +2 -2
- package/src/plugin.ts +6 -0
- package/src/rules/index.ts +6 -0
- package/src/rules/missing-paired-item.test.ts +229 -0
- package/src/rules/missing-paired-item.ts +149 -0
- package/src/rules/no-forbidden-lifecycle-scripts.test.ts +103 -0
- package/src/rules/no-forbidden-lifecycle-scripts.ts +69 -0
- package/src/rules/require-continue-on-fail.test.ts +129 -0
- package/src/rules/require-continue-on-fail.ts +88 -0
- package/tsconfig.build.tsbuildinfo +1 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Require pairedItem on INodeExecutionData objects in execute() methods to preserve item linking (`@n8n/community-nodes/missing-paired-item`)
|
|
2
|
+
|
|
3
|
+
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
|
4
|
+
|
|
5
|
+
<!-- end auto-generated rule header -->
|
|
6
|
+
|
|
7
|
+
## Rule Details
|
|
8
|
+
|
|
9
|
+
Every `INodeExecutionData` object returned from `execute()` should include a `pairedItem` property. Without it, downstream nodes cannot trace data lineage and expressions like `$('NodeName').item` will silently fail.
|
|
10
|
+
|
|
11
|
+
The rule detects object literals with a `json` property but no `pairedItem` inside `execute()` methods of `INodeType` classes.
|
|
12
|
+
|
|
13
|
+
## Examples
|
|
14
|
+
|
|
15
|
+
### Incorrect
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
class MyNode implements INodeType {
|
|
19
|
+
async execute() {
|
|
20
|
+
const items = this.getInputData();
|
|
21
|
+
// Missing pairedItem
|
|
22
|
+
return [items.map((item) => ({ json: item.json }))];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
class MyNode implements INodeType {
|
|
29
|
+
async execute() {
|
|
30
|
+
const returnData: INodeExecutionData[] = [];
|
|
31
|
+
// Missing pairedItem
|
|
32
|
+
returnData.push({ json: { result: true } });
|
|
33
|
+
return [returnData];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Correct
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
class MyNode implements INodeType {
|
|
42
|
+
async execute() {
|
|
43
|
+
const items = this.getInputData();
|
|
44
|
+
return [items.map((item, index) => ({ json: item.json, pairedItem: { item: index } }))];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
class MyNode implements INodeType {
|
|
51
|
+
async execute() {
|
|
52
|
+
const returnData: INodeExecutionData[] = [];
|
|
53
|
+
returnData.push({ json: { result: true }, pairedItem: { item: 0 } });
|
|
54
|
+
return [returnData];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## When to Disable
|
|
60
|
+
|
|
61
|
+
If your node intentionally does not support item linking (e.g. it aggregates all input items into a single output), you can suppress this rule:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
// eslint-disable-next-line @n8n/community-nodes/missing-paired-item
|
|
65
|
+
returnData.push({ json: aggregatedResult });
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Further Reading
|
|
69
|
+
|
|
70
|
+
- [n8n Paired Items Documentation](https://docs.n8n.io/integrations/creating-nodes/build/reference/paired-items/)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Ban lifecycle scripts (prepare, preinstall, postinstall, etc.) in community node packages (`@n8n/community-nodes/no-forbidden-lifecycle-scripts`)
|
|
2
|
+
|
|
3
|
+
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
|
4
|
+
|
|
5
|
+
<!-- end auto-generated rule header -->
|
|
6
|
+
|
|
7
|
+
## Rule Details
|
|
8
|
+
|
|
9
|
+
npm lifecycle scripts (`prepare`, `preinstall`, `install`, `postinstall`, `prepublish`, `preprepare`, `postprepare`) run automatically — without user confirmation — during `npm install`. In the context of n8n community nodes, this means arbitrary code executes on the n8n instance the moment a community node is installed.
|
|
10
|
+
|
|
11
|
+
n8n community nodes are distributed as pre-built npm packages. Unlike regular npm libraries, there is no legitimate reason for a community node to hook into install-time lifecycle events — the package should already contain compiled code ready to use. A `prepare` or `postinstall` script in a community node is either a misconfiguration (the author forgot to remove a build step meant for development) or a supply-chain attack vector.
|
|
12
|
+
|
|
13
|
+
## Examples
|
|
14
|
+
|
|
15
|
+
### Incorrect
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"name": "n8n-nodes-example",
|
|
20
|
+
"scripts": {
|
|
21
|
+
"prepare": "npm run build"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"name": "n8n-nodes-example",
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc",
|
|
31
|
+
"postinstall": "node setup.js"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Correct
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"name": "n8n-nodes-example",
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc",
|
|
43
|
+
"test": "jest"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Require continueOnFail() handling in execute() methods of node classes (`@n8n/community-nodes/require-continue-on-fail`)
|
|
2
|
+
|
|
3
|
+
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
|
4
|
+
|
|
5
|
+
<!-- end auto-generated rule header -->
|
|
6
|
+
|
|
7
|
+
## Rule Details
|
|
8
|
+
|
|
9
|
+
Ensures that `execute()` methods in node classes include a `this.continueOnFail()` check. Without this, a single item error will abort the entire workflow instead of allowing execution to continue past the failing item.
|
|
10
|
+
|
|
11
|
+
## Examples
|
|
12
|
+
|
|
13
|
+
### ❌ Incorrect
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
export class MyNode implements INodeType {
|
|
17
|
+
description: INodeTypeDescription = { /* ... */ };
|
|
18
|
+
|
|
19
|
+
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
|
20
|
+
const items = this.getInputData();
|
|
21
|
+
const returnData: INodeExecutionData[] = [];
|
|
22
|
+
for (let i = 0; i < items.length; i++) {
|
|
23
|
+
// No error handling — one bad item kills the whole workflow
|
|
24
|
+
const result = await someApiCall(items[i]);
|
|
25
|
+
returnData.push({ json: result });
|
|
26
|
+
}
|
|
27
|
+
return [returnData];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### ✅ Correct
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
export class MyNode implements INodeType {
|
|
36
|
+
description: INodeTypeDescription = { /* ... */ };
|
|
37
|
+
|
|
38
|
+
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
|
39
|
+
const items = this.getInputData();
|
|
40
|
+
const returnData: INodeExecutionData[] = [];
|
|
41
|
+
for (let i = 0; i < items.length; i++) {
|
|
42
|
+
try {
|
|
43
|
+
const result = await someApiCall(items[i]);
|
|
44
|
+
returnData.push({ json: result });
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (this.continueOnFail()) {
|
|
47
|
+
returnData.push({ json: { error: error.message }, pairedItem: { item: i } });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return [returnData];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@n8n/eslint-plugin-community-nodes",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.12.0",
|
|
5
5
|
"main": "./dist/plugin.js",
|
|
6
6
|
"types": "./dist/plugin.d.ts",
|
|
7
7
|
"exports": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"vitest": "^4.1.1",
|
|
25
25
|
"@n8n/typescript-config": "1.4.0",
|
|
26
26
|
"@n8n/vitest-config": "1.9.0",
|
|
27
|
-
"n8n-workflow": "2.
|
|
27
|
+
"n8n-workflow": "2.17.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"eslint": ">= 9",
|
package/src/plugin.ts
CHANGED
|
@@ -29,6 +29,7 @@ const configs = {
|
|
|
29
29
|
'@n8n/community-nodes/package-name-convention': 'error',
|
|
30
30
|
'@n8n/community-nodes/credential-test-required': 'error',
|
|
31
31
|
'@n8n/community-nodes/no-credential-reuse': 'error',
|
|
32
|
+
'@n8n/community-nodes/no-forbidden-lifecycle-scripts': 'error',
|
|
32
33
|
'@n8n/community-nodes/no-http-request-with-manual-auth': 'error',
|
|
33
34
|
'@n8n/community-nodes/icon-validation': 'error',
|
|
34
35
|
'@n8n/community-nodes/options-sorted-alphabetically': 'warn',
|
|
@@ -37,6 +38,8 @@ const configs = {
|
|
|
37
38
|
'@n8n/community-nodes/node-class-description-icon-missing': 'error',
|
|
38
39
|
'@n8n/community-nodes/cred-class-field-icon-missing': 'error',
|
|
39
40
|
'@n8n/community-nodes/node-connection-type-literal': 'error',
|
|
41
|
+
'@n8n/community-nodes/missing-paired-item': 'error',
|
|
42
|
+
'@n8n/community-nodes/require-continue-on-fail': 'error',
|
|
40
43
|
},
|
|
41
44
|
},
|
|
42
45
|
recommendedWithoutN8nCloudSupport: {
|
|
@@ -52,6 +55,7 @@ const configs = {
|
|
|
52
55
|
'@n8n/community-nodes/package-name-convention': 'error',
|
|
53
56
|
'@n8n/community-nodes/credential-test-required': 'error',
|
|
54
57
|
'@n8n/community-nodes/no-credential-reuse': 'error',
|
|
58
|
+
'@n8n/community-nodes/no-forbidden-lifecycle-scripts': 'error',
|
|
55
59
|
'@n8n/community-nodes/no-http-request-with-manual-auth': 'error',
|
|
56
60
|
'@n8n/community-nodes/icon-validation': 'error',
|
|
57
61
|
'@n8n/community-nodes/options-sorted-alphabetically': 'warn',
|
|
@@ -60,6 +64,8 @@ const configs = {
|
|
|
60
64
|
'@n8n/community-nodes/node-class-description-icon-missing': 'error',
|
|
61
65
|
'@n8n/community-nodes/cred-class-field-icon-missing': 'error',
|
|
62
66
|
'@n8n/community-nodes/node-connection-type-literal': 'error',
|
|
67
|
+
'@n8n/community-nodes/missing-paired-item': 'error',
|
|
68
|
+
'@n8n/community-nodes/require-continue-on-fail': 'error',
|
|
63
69
|
},
|
|
64
70
|
},
|
|
65
71
|
} satisfies Record<string, Linter.Config>;
|
package/src/rules/index.ts
CHANGED
|
@@ -6,8 +6,10 @@ import { CredentialDocumentationUrlRule } from './credential-documentation-url.j
|
|
|
6
6
|
import { CredentialPasswordFieldRule } from './credential-password-field.js';
|
|
7
7
|
import { CredentialTestRequiredRule } from './credential-test-required.js';
|
|
8
8
|
import { IconValidationRule } from './icon-validation.js';
|
|
9
|
+
import { MissingPairedItemRule } from './missing-paired-item.js';
|
|
9
10
|
import { NoCredentialReuseRule } from './no-credential-reuse.js';
|
|
10
11
|
import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js';
|
|
12
|
+
import { NoForbiddenLifecycleScriptsRule } from './no-forbidden-lifecycle-scripts.js';
|
|
11
13
|
import { NoHttpRequestWithManualAuthRule } from './no-http-request-with-manual-auth.js';
|
|
12
14
|
import { NoRestrictedGlobalsRule } from './no-restricted-globals.js';
|
|
13
15
|
import { NoRestrictedImportsRule } from './no-restricted-imports.js';
|
|
@@ -16,6 +18,7 @@ import { NodeConnectionTypeLiteralRule } from './node-connection-type-literal.js
|
|
|
16
18
|
import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
|
|
17
19
|
import { OptionsSortedAlphabeticallyRule } from './options-sorted-alphabetically.js';
|
|
18
20
|
import { PackageNameConventionRule } from './package-name-convention.js';
|
|
21
|
+
import { RequireContinueOnFailRule } from './require-continue-on-fail.js';
|
|
19
22
|
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
|
|
20
23
|
|
|
21
24
|
export const rules = {
|
|
@@ -29,6 +32,7 @@ export const rules = {
|
|
|
29
32
|
'package-name-convention': PackageNameConventionRule,
|
|
30
33
|
'credential-test-required': CredentialTestRequiredRule,
|
|
31
34
|
'no-credential-reuse': NoCredentialReuseRule,
|
|
35
|
+
'no-forbidden-lifecycle-scripts': NoForbiddenLifecycleScriptsRule,
|
|
32
36
|
'no-http-request-with-manual-auth': NoHttpRequestWithManualAuthRule,
|
|
33
37
|
'icon-validation': IconValidationRule,
|
|
34
38
|
'resource-operation-pattern': ResourceOperationPatternRule,
|
|
@@ -36,4 +40,6 @@ export const rules = {
|
|
|
36
40
|
'node-class-description-icon-missing': NodeClassDescriptionIconMissingRule,
|
|
37
41
|
'cred-class-field-icon-missing': CredClassFieldIconMissingRule,
|
|
38
42
|
'node-connection-type-literal': NodeConnectionTypeLiteralRule,
|
|
43
|
+
'missing-paired-item': MissingPairedItemRule,
|
|
44
|
+
'require-continue-on-fail': RequireContinueOnFailRule,
|
|
39
45
|
} satisfies Record<string, AnyRuleModule>;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
|
|
3
|
+
import { MissingPairedItemRule } from './missing-paired-item.js';
|
|
4
|
+
|
|
5
|
+
const ruleTester = new RuleTester();
|
|
6
|
+
|
|
7
|
+
ruleTester.run('missing-paired-item', MissingPairedItemRule, {
|
|
8
|
+
valid: [
|
|
9
|
+
{
|
|
10
|
+
name: 'object with json and pairedItem in execute()',
|
|
11
|
+
filename: 'MyNode.node.ts',
|
|
12
|
+
code: `
|
|
13
|
+
class MyNode implements INodeType {
|
|
14
|
+
description = {};
|
|
15
|
+
async execute() {
|
|
16
|
+
return [[{ json: { id: 1 }, pairedItem: { item: 0 } }]];
|
|
17
|
+
}
|
|
18
|
+
}`,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'object with json and pairedItem as number shorthand',
|
|
22
|
+
filename: 'MyNode.node.ts',
|
|
23
|
+
code: `
|
|
24
|
+
class MyNode implements INodeType {
|
|
25
|
+
description = {};
|
|
26
|
+
async execute() {
|
|
27
|
+
return [[{ json: { id: 1 }, pairedItem: 0 }]];
|
|
28
|
+
}
|
|
29
|
+
}`,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'non-node-type class is ignored',
|
|
33
|
+
filename: 'MyNode.node.ts',
|
|
34
|
+
code: `
|
|
35
|
+
class Helper {
|
|
36
|
+
async execute() {
|
|
37
|
+
return [[{ json: { id: 1 } }]];
|
|
38
|
+
}
|
|
39
|
+
}`,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'non-.node.ts file is ignored',
|
|
43
|
+
filename: 'utils.ts',
|
|
44
|
+
code: `
|
|
45
|
+
class MyNode implements INodeType {
|
|
46
|
+
description = {};
|
|
47
|
+
async execute() {
|
|
48
|
+
return [[{ json: { id: 1 } }]];
|
|
49
|
+
}
|
|
50
|
+
}`,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'object outside execute() is ignored',
|
|
54
|
+
filename: 'MyNode.node.ts',
|
|
55
|
+
code: `
|
|
56
|
+
class MyNode implements INodeType {
|
|
57
|
+
description = {};
|
|
58
|
+
async someHelper() {
|
|
59
|
+
return { json: { id: 1 } };
|
|
60
|
+
}
|
|
61
|
+
}`,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'object without json property is ignored',
|
|
65
|
+
filename: 'MyNode.node.ts',
|
|
66
|
+
code: `
|
|
67
|
+
class MyNode implements INodeType {
|
|
68
|
+
description = {};
|
|
69
|
+
async execute() {
|
|
70
|
+
const options = { url: 'https://api.example.com', method: 'GET' };
|
|
71
|
+
return [[{ json: options, pairedItem: { item: 0 } }]];
|
|
72
|
+
}
|
|
73
|
+
}`,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'object with unrecognized keys is not INodeExecutionData',
|
|
77
|
+
filename: 'MyNode.node.ts',
|
|
78
|
+
code: `
|
|
79
|
+
class MyNode implements INodeType {
|
|
80
|
+
description = {};
|
|
81
|
+
async execute() {
|
|
82
|
+
const config = { json: true, indent: 2, spaces: 4 };
|
|
83
|
+
return this.process(config);
|
|
84
|
+
}
|
|
85
|
+
}`,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'class extending Node base class with pairedItem',
|
|
89
|
+
filename: 'MyNode.node.ts',
|
|
90
|
+
code: `
|
|
91
|
+
class MyNode extends Node {
|
|
92
|
+
description = {};
|
|
93
|
+
async execute() {
|
|
94
|
+
return [[{ json: { id: 1 }, pairedItem: { item: 0 } }]];
|
|
95
|
+
}
|
|
96
|
+
}`,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'spread element in object is allowed',
|
|
100
|
+
filename: 'MyNode.node.ts',
|
|
101
|
+
code: `
|
|
102
|
+
class MyNode implements INodeType {
|
|
103
|
+
description = {};
|
|
104
|
+
async execute() {
|
|
105
|
+
return [[{ ...baseItem, json: { id: 1 } }]];
|
|
106
|
+
}
|
|
107
|
+
}`,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'object inside constructExecutionMetaData is allowed',
|
|
111
|
+
filename: 'MyNode.node.ts',
|
|
112
|
+
code: `
|
|
113
|
+
class MyNode implements INodeType {
|
|
114
|
+
description = {};
|
|
115
|
+
async execute() {
|
|
116
|
+
const executionData = this.helpers.constructExecutionMetaData(
|
|
117
|
+
[{ json: { success: true } }],
|
|
118
|
+
{ itemData: { item: i } },
|
|
119
|
+
);
|
|
120
|
+
return [executionData];
|
|
121
|
+
}
|
|
122
|
+
}`,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'object with computed property key is not INodeExecutionData',
|
|
126
|
+
filename: 'MyNode.node.ts',
|
|
127
|
+
code: `
|
|
128
|
+
class MyNode implements INodeType {
|
|
129
|
+
description = {};
|
|
130
|
+
async execute() {
|
|
131
|
+
const obj = { json: data, [dynamicKey]: value };
|
|
132
|
+
return [[obj]];
|
|
133
|
+
}
|
|
134
|
+
}`,
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
invalid: [
|
|
138
|
+
{
|
|
139
|
+
name: 'missing pairedItem in return statement',
|
|
140
|
+
filename: 'MyNode.node.ts',
|
|
141
|
+
code: `
|
|
142
|
+
class MyNode implements INodeType {
|
|
143
|
+
description = {};
|
|
144
|
+
async execute() {
|
|
145
|
+
return [[{ json: { id: 1 } }]];
|
|
146
|
+
}
|
|
147
|
+
}`,
|
|
148
|
+
errors: [{ messageId: 'missingPairedItem' }],
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'missing pairedItem in .map() callback',
|
|
152
|
+
filename: 'MyNode.node.ts',
|
|
153
|
+
code: `
|
|
154
|
+
class MyNode implements INodeType {
|
|
155
|
+
description = {};
|
|
156
|
+
async execute() {
|
|
157
|
+
const items = this.getInputData();
|
|
158
|
+
return [items.map((item, index) => ({ json: item.json }))];
|
|
159
|
+
}
|
|
160
|
+
}`,
|
|
161
|
+
errors: [{ messageId: 'missingPairedItem' }],
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'missing pairedItem in .push() call',
|
|
165
|
+
filename: 'MyNode.node.ts',
|
|
166
|
+
code: `
|
|
167
|
+
class MyNode implements INodeType {
|
|
168
|
+
description = {};
|
|
169
|
+
async execute() {
|
|
170
|
+
const returnData = [];
|
|
171
|
+
returnData.push({ json: { result: true } });
|
|
172
|
+
return [returnData];
|
|
173
|
+
}
|
|
174
|
+
}`,
|
|
175
|
+
errors: [{ messageId: 'missingPairedItem' }],
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: 'multiple objects missing pairedItem',
|
|
179
|
+
filename: 'MyNode.node.ts',
|
|
180
|
+
code: `
|
|
181
|
+
class MyNode implements INodeType {
|
|
182
|
+
description = {};
|
|
183
|
+
async execute() {
|
|
184
|
+
const returnData = [];
|
|
185
|
+
returnData.push({ json: { a: 1 } });
|
|
186
|
+
returnData.push({ json: { b: 2 } });
|
|
187
|
+
return [returnData];
|
|
188
|
+
}
|
|
189
|
+
}`,
|
|
190
|
+
errors: [{ messageId: 'missingPairedItem' }, { messageId: 'missingPairedItem' }],
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: 'object with json and binary but no pairedItem',
|
|
194
|
+
filename: 'MyNode.node.ts',
|
|
195
|
+
code: `
|
|
196
|
+
class MyNode implements INodeType {
|
|
197
|
+
description = {};
|
|
198
|
+
async execute() {
|
|
199
|
+
return [[{ json: { id: 1 }, binary: { data: binaryData } }]];
|
|
200
|
+
}
|
|
201
|
+
}`,
|
|
202
|
+
errors: [{ messageId: 'missingPairedItem' }],
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: 'object with json and executionStatus but no pairedItem',
|
|
206
|
+
filename: 'MyNode.node.ts',
|
|
207
|
+
code: `
|
|
208
|
+
class MyNode implements INodeType {
|
|
209
|
+
description = {};
|
|
210
|
+
async execute() {
|
|
211
|
+
return [[{ json: { id: 1 }, executionStatus: 'success' }]];
|
|
212
|
+
}
|
|
213
|
+
}`,
|
|
214
|
+
errors: [{ messageId: 'missingPairedItem' }],
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: 'class extending Node base class missing pairedItem',
|
|
218
|
+
filename: 'MyNode.node.ts',
|
|
219
|
+
code: `
|
|
220
|
+
class MyNode extends Node {
|
|
221
|
+
description = {};
|
|
222
|
+
async execute() {
|
|
223
|
+
return [[{ json: { id: 1 } }]];
|
|
224
|
+
}
|
|
225
|
+
}`,
|
|
226
|
+
errors: [{ messageId: 'missingPairedItem' }],
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags object literals with a `json` property but no `pairedItem` property
|
|
3
|
+
* inside `execute()` methods of `INodeType` classes.
|
|
4
|
+
*
|
|
5
|
+
* Missing `pairedItem` breaks downstream item-referencing expressions like
|
|
6
|
+
* `$('NodeName').item`. This rule catches the three most common patterns:
|
|
7
|
+
*
|
|
8
|
+
* - Object literals in `.map()` callbacks
|
|
9
|
+
* - Object literals passed to `.push()` calls
|
|
10
|
+
* - Object literals in return statements (typically `return [[{ json }]]`)
|
|
11
|
+
*
|
|
12
|
+
* Only flags object literals directly — variable references are skipped since
|
|
13
|
+
* their shape cannot be determined without type resolution.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { TSESTree } from '@typescript-eslint/utils';
|
|
17
|
+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
createRule,
|
|
21
|
+
findObjectProperty,
|
|
22
|
+
isFileType,
|
|
23
|
+
isNodeTypeClass,
|
|
24
|
+
isThisHelpersMethodCall,
|
|
25
|
+
} from '../utils/index.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Checks whether the object is inside an array argument to
|
|
29
|
+
* `this.helpers.constructExecutionMetaData()`, which adds pairedItem
|
|
30
|
+
* via the second argument's `itemData` property.
|
|
31
|
+
*/
|
|
32
|
+
function isInsideConstructExecutionMetaData(node: TSESTree.ObjectExpression): boolean {
|
|
33
|
+
// Pattern: constructExecutionMetaData([{ json: ... }], { itemData: ... })
|
|
34
|
+
// Walk up: ObjectExpression -> ArrayExpression -> CallExpression
|
|
35
|
+
const parent = node.parent;
|
|
36
|
+
if (parent?.type !== AST_NODE_TYPES.ArrayExpression) return false;
|
|
37
|
+
|
|
38
|
+
const grandparent = parent.parent;
|
|
39
|
+
if (grandparent?.type !== AST_NODE_TYPES.CallExpression) return false;
|
|
40
|
+
|
|
41
|
+
// Check it's the first argument
|
|
42
|
+
if (grandparent.arguments[0] !== parent) return false;
|
|
43
|
+
|
|
44
|
+
return isThisHelpersMethodCall(grandparent, 'constructExecutionMetaData');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const MissingPairedItemRule = createRule({
|
|
48
|
+
name: 'missing-paired-item',
|
|
49
|
+
meta: {
|
|
50
|
+
type: 'problem',
|
|
51
|
+
docs: {
|
|
52
|
+
description:
|
|
53
|
+
'Require pairedItem on INodeExecutionData objects in execute() methods to preserve item linking.',
|
|
54
|
+
},
|
|
55
|
+
messages: {
|
|
56
|
+
missingPairedItem:
|
|
57
|
+
'Missing pairedItem on INodeExecutionData object. Add `pairedItem: { item: index }` to preserve item linking. See https://docs.n8n.io/integrations/creating-nodes/build/reference/paired-items/',
|
|
58
|
+
},
|
|
59
|
+
schema: [],
|
|
60
|
+
hasSuggestions: false,
|
|
61
|
+
},
|
|
62
|
+
defaultOptions: [],
|
|
63
|
+
create(context) {
|
|
64
|
+
if (!isFileType(context.filename, '.node.ts')) {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let inNodeTypeClass = false;
|
|
69
|
+
let inExecuteMethod = false;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
ClassDeclaration(node) {
|
|
73
|
+
if (isNodeTypeClass(node)) {
|
|
74
|
+
inNodeTypeClass = true;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
'ClassDeclaration:exit'() {
|
|
79
|
+
inNodeTypeClass = false;
|
|
80
|
+
inExecuteMethod = false;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
MethodDefinition(node: TSESTree.MethodDefinition) {
|
|
84
|
+
if (
|
|
85
|
+
inNodeTypeClass &&
|
|
86
|
+
node.key.type === AST_NODE_TYPES.Identifier &&
|
|
87
|
+
node.key.name === 'execute'
|
|
88
|
+
) {
|
|
89
|
+
inExecuteMethod = true;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
'MethodDefinition:exit'(node: TSESTree.MethodDefinition) {
|
|
94
|
+
if (
|
|
95
|
+
inExecuteMethod &&
|
|
96
|
+
node.key.type === AST_NODE_TYPES.Identifier &&
|
|
97
|
+
node.key.name === 'execute'
|
|
98
|
+
) {
|
|
99
|
+
inExecuteMethod = false;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
ObjectExpression(node: TSESTree.ObjectExpression) {
|
|
104
|
+
if (!inExecuteMethod) return;
|
|
105
|
+
|
|
106
|
+
const hasJson = findObjectProperty(node, 'json') !== null;
|
|
107
|
+
if (!hasJson) return;
|
|
108
|
+
|
|
109
|
+
const hasPairedItem = findObjectProperty(node, 'pairedItem') !== null;
|
|
110
|
+
if (hasPairedItem) return;
|
|
111
|
+
|
|
112
|
+
// Skip if inside constructExecutionMetaData() — it adds pairedItem via itemData
|
|
113
|
+
if (isInsideConstructExecutionMetaData(node)) return;
|
|
114
|
+
|
|
115
|
+
// Skip if the object contains spread elements — they may already provide pairedItem
|
|
116
|
+
const hasSpread = node.properties.some(
|
|
117
|
+
(prop) => prop.type === AST_NODE_TYPES.SpreadElement,
|
|
118
|
+
);
|
|
119
|
+
if (hasSpread) return;
|
|
120
|
+
|
|
121
|
+
// Only flag if this looks like an INodeExecutionData object literal —
|
|
122
|
+
// must have `json` and optionally `binary`/`error`, nothing unexpected.
|
|
123
|
+
// Objects with many unrelated keys are likely not INodeExecutionData.
|
|
124
|
+
const knownKeys = new Set([
|
|
125
|
+
'json',
|
|
126
|
+
'binary',
|
|
127
|
+
'error',
|
|
128
|
+
'pairedItem',
|
|
129
|
+
'executionStatus',
|
|
130
|
+
'metadata',
|
|
131
|
+
'evaluationData',
|
|
132
|
+
'redaction',
|
|
133
|
+
'sendMessage',
|
|
134
|
+
'index',
|
|
135
|
+
]);
|
|
136
|
+
const allPropertiesKnown = node.properties.every(
|
|
137
|
+
(prop) =>
|
|
138
|
+
prop.type === AST_NODE_TYPES.Property &&
|
|
139
|
+
prop.key.type === AST_NODE_TYPES.Identifier &&
|
|
140
|
+
knownKeys.has(prop.key.name),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (!allPropertiesKnown) return;
|
|
144
|
+
|
|
145
|
+
context.report({ node, messageId: 'missingPairedItem' });
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
});
|