@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.
Files changed (38) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/plugin.d.ts +6 -0
  3. package/dist/plugin.d.ts.map +1 -1
  4. package/dist/plugin.js +2 -0
  5. package/dist/plugin.js.map +1 -1
  6. package/dist/rules/ai-node-package-json.d.ts +2 -0
  7. package/dist/rules/ai-node-package-json.d.ts.map +1 -0
  8. package/dist/rules/ai-node-package-json.js +80 -0
  9. package/dist/rules/ai-node-package-json.js.map +1 -0
  10. package/dist/rules/index.d.ts +1 -0
  11. package/dist/rules/index.d.ts.map +1 -1
  12. package/dist/rules/index.js +2 -0
  13. package/dist/rules/index.js.map +1 -1
  14. package/dist/rules/no-restricted-imports.d.ts.map +1 -1
  15. package/dist/rules/no-restricted-imports.js +2 -0
  16. package/dist/rules/no-restricted-imports.js.map +1 -1
  17. package/dist/rules/node-usable-as-tool.d.ts.map +1 -1
  18. package/dist/rules/node-usable-as-tool.js +18 -0
  19. package/dist/rules/node-usable-as-tool.js.map +1 -1
  20. package/dist/rules/package-name-convention.d.ts.map +1 -1
  21. package/dist/rules/package-name-convention.js +3 -5
  22. package/dist/rules/package-name-convention.js.map +1 -1
  23. package/dist/utils/ast-utils.d.ts +1 -0
  24. package/dist/utils/ast-utils.d.ts.map +1 -1
  25. package/dist/utils/ast-utils.js +6 -0
  26. package/dist/utils/ast-utils.js.map +1 -1
  27. package/package.json +3 -2
  28. package/src/plugin.ts +2 -0
  29. package/src/rules/ai-node-package-json.test.ts +111 -0
  30. package/src/rules/ai-node-package-json.ts +100 -0
  31. package/src/rules/index.ts +2 -0
  32. package/src/rules/no-restricted-imports.test.ts +2 -1
  33. package/src/rules/no-restricted-imports.ts +2 -0
  34. package/src/rules/node-usable-as-tool.test.ts +66 -0
  35. package/src/rules/node-usable-as-tool.ts +22 -0
  36. package/src/rules/package-name-convention.ts +3 -8
  37. package/src/utils/ast-utils.ts +13 -0
  38. 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
+ }
@@ -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.properties.find(
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 || nameProperty.type !== AST_NODE_TYPES.Property) {
36
+ if (!nameProperty) {
42
37
  return;
43
38
  }
44
39
 
@@ -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,