@n8n/eslint-plugin-community-nodes 0.6.0 → 0.8.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/dist/plugin.d.ts +6 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +2 -0
- package/dist/plugin.js.map +1 -1
- package/dist/rules/ai-node-package-json.d.ts +2 -0
- package/dist/rules/ai-node-package-json.d.ts.map +1 -0
- package/dist/rules/ai-node-package-json.js +80 -0
- package/dist/rules/ai-node-package-json.js.map +1 -0
- package/dist/rules/index.d.ts +1 -0
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +2 -0
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/no-restricted-imports.d.ts.map +1 -1
- package/dist/rules/no-restricted-imports.js +2 -0
- package/dist/rules/no-restricted-imports.js.map +1 -1
- package/dist/rules/node-usable-as-tool.d.ts.map +1 -1
- package/dist/rules/node-usable-as-tool.js +18 -0
- package/dist/rules/node-usable-as-tool.js.map +1 -1
- package/dist/rules/package-name-convention.d.ts.map +1 -1
- package/dist/rules/package-name-convention.js +3 -5
- package/dist/rules/package-name-convention.js.map +1 -1
- package/dist/utils/ast-utils.d.ts +1 -0
- package/dist/utils/ast-utils.d.ts.map +1 -1
- package/dist/utils/ast-utils.js +6 -0
- package/dist/utils/ast-utils.js.map +1 -1
- package/package.json +3 -2
- package/src/plugin.ts +2 -0
- package/src/rules/ai-node-package-json.test.ts +111 -0
- package/src/rules/ai-node-package-json.ts +100 -0
- package/src/rules/index.ts +2 -0
- package/src/rules/no-restricted-imports.test.ts +2 -1
- package/src/rules/no-restricted-imports.ts +2 -0
- package/src/rules/node-usable-as-tool.test.ts +66 -0
- package/src/rules/node-usable-as-tool.ts +22 -0
- package/src/rules/package-name-convention.ts +3 -8
- package/src/utils/ast-utils.ts +13 -0
- package/tsconfig.build.tsbuildinfo +0 -1
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
export const AiNodePackageJsonRule = createRule({
|
|
7
|
+
name: 'ai-node-package-json',
|
|
8
|
+
meta: {
|
|
9
|
+
type: 'problem',
|
|
10
|
+
docs: {
|
|
11
|
+
description:
|
|
12
|
+
'Enforce consistency between n8n.aiNodeSdkVersion and ai-node-sdk peer dependency in community node packages',
|
|
13
|
+
},
|
|
14
|
+
messages: {
|
|
15
|
+
missingPeerDep:
|
|
16
|
+
'Package declares "n8n.aiNodeSdkVersion" but is missing "ai-node-sdk" in peerDependencies. Add "ai-node-sdk": "*" to peerDependencies.',
|
|
17
|
+
missingSdkVersion:
|
|
18
|
+
'Package has "ai-node-sdk" in peerDependencies but is missing "aiNodeSdkVersion" in the "n8n" section of package.json.',
|
|
19
|
+
invalidSdkVersion: '"n8n.aiNodeSdkVersion" must be a positive integer, got {{ value }}.',
|
|
20
|
+
wrongLocation:
|
|
21
|
+
'"aiNodeSdkVersion" must be inside the "n8n" section, not at the root level of package.json.',
|
|
22
|
+
},
|
|
23
|
+
schema: [],
|
|
24
|
+
},
|
|
25
|
+
defaultOptions: [],
|
|
26
|
+
create(context) {
|
|
27
|
+
if (!context.filename.endsWith('package.json')) {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
ObjectExpression(node: TSESTree.ObjectExpression) {
|
|
33
|
+
// Only process the root object, not nested ones
|
|
34
|
+
if (node.parent?.type === AST_NODE_TYPES.Property) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const n8nProp = findJsonProperty(node, 'n8n');
|
|
39
|
+
const n8nObject =
|
|
40
|
+
n8nProp?.value.type === AST_NODE_TYPES.ObjectExpression ? n8nProp.value : null;
|
|
41
|
+
|
|
42
|
+
const aiNodeSdkVersionProp = n8nObject
|
|
43
|
+
? findJsonProperty(n8nObject, 'aiNodeSdkVersion')
|
|
44
|
+
: null;
|
|
45
|
+
|
|
46
|
+
const rootAiNodeSdkVersionProp = findJsonProperty(node, 'aiNodeSdkVersion');
|
|
47
|
+
const peerDependenciesProp = findJsonProperty(node, 'peerDependencies');
|
|
48
|
+
|
|
49
|
+
const hasAiNodeSdkVersion = aiNodeSdkVersionProp !== null;
|
|
50
|
+
const hasAiNodeSdkPeerDep =
|
|
51
|
+
peerDependenciesProp?.value.type === AST_NODE_TYPES.ObjectExpression &&
|
|
52
|
+
findJsonProperty(peerDependenciesProp.value, 'ai-node-sdk') !== null;
|
|
53
|
+
|
|
54
|
+
// Catch aiNodeSdkVersion placed at root level instead of inside n8n
|
|
55
|
+
if (rootAiNodeSdkVersionProp) {
|
|
56
|
+
context.report({
|
|
57
|
+
node: rootAiNodeSdkVersionProp,
|
|
58
|
+
messageId: 'wrongLocation',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Validate aiNodeSdkVersion is a positive integer when present
|
|
63
|
+
if (hasAiNodeSdkVersion) {
|
|
64
|
+
const valueNode = aiNodeSdkVersionProp.value;
|
|
65
|
+
if (valueNode.type !== AST_NODE_TYPES.Literal || !isPositiveInteger(valueNode.value)) {
|
|
66
|
+
context.report({
|
|
67
|
+
node: aiNodeSdkVersionProp,
|
|
68
|
+
messageId: 'invalidSdkVersion',
|
|
69
|
+
data: {
|
|
70
|
+
value: String(
|
|
71
|
+
valueNode.type === AST_NODE_TYPES.Literal ? valueNode.value : 'non-literal',
|
|
72
|
+
),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// If aiNodeSdkVersion is declared, ai-node-sdk must be in peerDependencies
|
|
79
|
+
if (hasAiNodeSdkVersion && !hasAiNodeSdkPeerDep) {
|
|
80
|
+
context.report({
|
|
81
|
+
node: aiNodeSdkVersionProp,
|
|
82
|
+
messageId: 'missingPeerDep',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// If ai-node-sdk is in peerDependencies, aiNodeSdkVersion must be declared
|
|
87
|
+
if (hasAiNodeSdkPeerDep && !hasAiNodeSdkVersion) {
|
|
88
|
+
context.report({
|
|
89
|
+
node: peerDependenciesProp,
|
|
90
|
+
messageId: 'missingSdkVersion',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function isPositiveInteger(value: unknown): boolean {
|
|
99
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0;
|
|
100
|
+
}
|
package/src/rules/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AnyRuleModule } from '@typescript-eslint/utils/ts-eslint';
|
|
2
2
|
|
|
3
|
+
import { AiNodePackageJsonRule } from './ai-node-package-json.js';
|
|
3
4
|
import { CredentialDocumentationUrlRule } from './credential-documentation-url.js';
|
|
4
5
|
import { CredentialPasswordFieldRule } from './credential-password-field.js';
|
|
5
6
|
import { CredentialTestRequiredRule } from './credential-test-required.js';
|
|
@@ -13,6 +14,7 @@ import { PackageNameConventionRule } from './package-name-convention.js';
|
|
|
13
14
|
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
|
|
14
15
|
|
|
15
16
|
export const rules = {
|
|
17
|
+
'ai-node-package-json': AiNodePackageJsonRule,
|
|
16
18
|
'no-restricted-globals': NoRestrictedGlobalsRule,
|
|
17
19
|
'no-restricted-imports': NoRestrictedImportsRule,
|
|
18
20
|
'credential-password-field': CredentialPasswordFieldRule,
|
|
@@ -128,7 +128,8 @@ ruleTester.run('no-restricted-imports', NoRestrictedImportsRule, {
|
|
|
128
128
|
code: `
|
|
129
129
|
import fs from "fs";
|
|
130
130
|
import path from "path";
|
|
131
|
-
import { WorkflowExecuteMode } from "n8n-workflow"
|
|
131
|
+
import { WorkflowExecuteMode } from "n8n-workflow";
|
|
132
|
+
import { supplyModel } from "@n8n/ai-node-sdk";`,
|
|
132
133
|
errors: [
|
|
133
134
|
{ messageId: 'restrictedImport', data: { modulePath: 'fs' } },
|
|
134
135
|
{ messageId: 'restrictedImport', data: { modulePath: 'path' } },
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
|
|
8
8
|
const allowedModules = [
|
|
9
9
|
'n8n-workflow',
|
|
10
|
+
'ai-node-sdk',
|
|
10
11
|
'lodash',
|
|
11
12
|
'moment',
|
|
12
13
|
'p-limit',
|
|
@@ -14,6 +15,7 @@ const allowedModules = [
|
|
|
14
15
|
'zod',
|
|
15
16
|
'crypto',
|
|
16
17
|
'node:crypto',
|
|
18
|
+
'@n8n/ai-node-sdk',
|
|
17
19
|
];
|
|
18
20
|
|
|
19
21
|
const isModuleAllowed = (modulePath: string): boolean => {
|
|
@@ -51,6 +51,33 @@ export class RegularClass {
|
|
|
51
51
|
}`;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function createNodeCodeWithOutputsInputs(
|
|
55
|
+
outputs: string,
|
|
56
|
+
inputs: string,
|
|
57
|
+
includeUsableAsTool = false,
|
|
58
|
+
): string {
|
|
59
|
+
const usableAsToolLine = includeUsableAsTool ? '\n\t\tusableAsTool: true,' : '';
|
|
60
|
+
return `
|
|
61
|
+
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
|
62
|
+
import { NodeConnectionTypes } from 'n8n-workflow';
|
|
63
|
+
|
|
64
|
+
export class TestNode implements INodeType {
|
|
65
|
+
description: INodeTypeDescription = {
|
|
66
|
+
displayName: 'Test Node',
|
|
67
|
+
name: 'testNode',
|
|
68
|
+
group: ['input'],
|
|
69
|
+
version: 1,
|
|
70
|
+
description: 'A test node',
|
|
71
|
+
defaults: {
|
|
72
|
+
name: 'Test Node',
|
|
73
|
+
},
|
|
74
|
+
inputs: ${inputs},
|
|
75
|
+
outputs: ${outputs},
|
|
76
|
+
properties: [],${usableAsToolLine}
|
|
77
|
+
};
|
|
78
|
+
}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
54
81
|
ruleTester.run('node-usable-as-tool', NodeUsableAsToolRule, {
|
|
55
82
|
valid: [
|
|
56
83
|
{
|
|
@@ -69,6 +96,21 @@ ruleTester.run('node-usable-as-tool', NodeUsableAsToolRule, {
|
|
|
69
96
|
name: 'node without description property',
|
|
70
97
|
code: createNodeCode(undefined, false),
|
|
71
98
|
},
|
|
99
|
+
{
|
|
100
|
+
name: 'AI-only node: NodeConnectionTypes non-Main output and empty inputs skips usableAsTool check',
|
|
101
|
+
code: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.AiAgent]', '[]'),
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'AI-only node: multiple non-Main NodeConnectionTypes outputs and empty inputs skips usableAsTool check',
|
|
105
|
+
code: createNodeCodeWithOutputsInputs(
|
|
106
|
+
'[NodeConnectionTypes.AiAgent, NodeConnectionTypes.AiTool]',
|
|
107
|
+
'[]',
|
|
108
|
+
),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'AI-only node: non-main string literal output and empty inputs skips usableAsTool check',
|
|
112
|
+
code: createNodeCodeWithOutputsInputs("['ai_agent']", '[]'),
|
|
113
|
+
},
|
|
72
114
|
],
|
|
73
115
|
invalid: [
|
|
74
116
|
{
|
|
@@ -77,5 +119,29 @@ ruleTester.run('node-usable-as-tool', NodeUsableAsToolRule, {
|
|
|
77
119
|
errors: [{ messageId: 'missingUsableAsTool' }],
|
|
78
120
|
output: createNodeCode(true),
|
|
79
121
|
},
|
|
122
|
+
{
|
|
123
|
+
name: 'NodeConnectionTypes.Main output with empty inputs does not skip check',
|
|
124
|
+
code: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.Main]', '[]'),
|
|
125
|
+
errors: [{ messageId: 'missingUsableAsTool' }],
|
|
126
|
+
output: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.Main]', '[]', true),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'main string literal output with empty inputs does not skip check',
|
|
130
|
+
code: createNodeCodeWithOutputsInputs("['main']", '[]'),
|
|
131
|
+
errors: [{ messageId: 'missingUsableAsTool' }],
|
|
132
|
+
output: createNodeCodeWithOutputsInputs("['main']", '[]', true),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'non-Main output with non-empty inputs does not skip check',
|
|
136
|
+
code: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.AiAgent]', "['main']"),
|
|
137
|
+
errors: [{ messageId: 'missingUsableAsTool' }],
|
|
138
|
+
output: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.AiAgent]', "['main']", true),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'non-main string literal output with non-empty inputs does not skip check',
|
|
142
|
+
code: createNodeCodeWithOutputsInputs("['ai_agent']", "['main']"),
|
|
143
|
+
errors: [{ messageId: 'missingUsableAsTool' }],
|
|
144
|
+
output: createNodeCodeWithOutputsInputs("['ai_agent']", "['main']", true),
|
|
145
|
+
},
|
|
80
146
|
],
|
|
81
147
|
});
|
|
@@ -40,6 +40,28 @@ export const NodeUsableAsToolRule = createRule({
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const usableAsToolProperty = findObjectProperty(descriptionValue, 'usableAsTool');
|
|
43
|
+
const outputsProperty = findObjectProperty(descriptionValue, 'outputs');
|
|
44
|
+
const inputsProperty = findObjectProperty(descriptionValue, 'inputs');
|
|
45
|
+
if (
|
|
46
|
+
outputsProperty?.value?.type === TSESTree.AST_NODE_TYPES.ArrayExpression &&
|
|
47
|
+
inputsProperty?.value?.type === TSESTree.AST_NODE_TYPES.ArrayExpression
|
|
48
|
+
) {
|
|
49
|
+
const isAiOutput = outputsProperty?.value?.elements?.some((element) => {
|
|
50
|
+
const isAiOutputEnum =
|
|
51
|
+
element?.type === TSESTree.AST_NODE_TYPES.MemberExpression &&
|
|
52
|
+
element?.object?.type === TSESTree.AST_NODE_TYPES.Identifier &&
|
|
53
|
+
element?.object?.name === 'NodeConnectionTypes' &&
|
|
54
|
+
element?.property?.type === TSESTree.AST_NODE_TYPES.Identifier &&
|
|
55
|
+
element?.property?.name !== 'Main';
|
|
56
|
+
const isAiOutputLiteral =
|
|
57
|
+
element?.type === TSESTree.AST_NODE_TYPES.Literal && element?.value !== 'main';
|
|
58
|
+
return isAiOutputEnum || isAiOutputLiteral;
|
|
59
|
+
});
|
|
60
|
+
const isEmptyInputs = inputsProperty?.value?.elements?.length === 0;
|
|
61
|
+
if (isAiOutput && isEmptyInputs) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
43
65
|
|
|
44
66
|
if (!usableAsToolProperty) {
|
|
45
67
|
context.report({
|
|
@@ -2,7 +2,7 @@ import type { TSESTree } from '@typescript-eslint/utils';
|
|
|
2
2
|
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
|
3
3
|
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
|
|
4
4
|
|
|
5
|
-
import { createRule } from '../utils/index.js';
|
|
5
|
+
import { createRule, findJsonProperty } from '../utils/index.js';
|
|
6
6
|
|
|
7
7
|
export const PackageNameConventionRule = createRule({
|
|
8
8
|
name: 'package-name-convention',
|
|
@@ -31,14 +31,9 @@ export const PackageNameConventionRule = createRule({
|
|
|
31
31
|
return;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const nameProperty = node
|
|
35
|
-
(property) =>
|
|
36
|
-
property.type === AST_NODE_TYPES.Property &&
|
|
37
|
-
property.key.type === AST_NODE_TYPES.Literal &&
|
|
38
|
-
property.key.value === 'name',
|
|
39
|
-
);
|
|
34
|
+
const nameProperty = findJsonProperty(node, 'name');
|
|
40
35
|
|
|
41
|
-
if (!nameProperty
|
|
36
|
+
if (!nameProperty) {
|
|
42
37
|
return;
|
|
43
38
|
}
|
|
44
39
|
|
package/src/utils/ast-utils.ts
CHANGED
|
@@ -89,6 +89,19 @@ export function getBooleanLiteralValue(node: TSESTree.Node | null): boolean | nu
|
|
|
89
89
|
return typeof value === 'boolean' ? value : null;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
export function findJsonProperty(
|
|
93
|
+
obj: TSESTree.ObjectExpression,
|
|
94
|
+
propertyName: string,
|
|
95
|
+
): TSESTree.Property | null {
|
|
96
|
+
const property = obj.properties.find(
|
|
97
|
+
(prop) =>
|
|
98
|
+
prop.type === AST_NODE_TYPES.Property &&
|
|
99
|
+
prop.key.type === AST_NODE_TYPES.Literal &&
|
|
100
|
+
prop.key.value === propertyName,
|
|
101
|
+
);
|
|
102
|
+
return property?.type === AST_NODE_TYPES.Property ? property : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
92
105
|
export function findArrayLiteralProperty(
|
|
93
106
|
obj: TSESTree.ObjectExpression,
|
|
94
107
|
propertyName: string,
|