@n8n/eslint-plugin-community-nodes 0.7.0 → 0.9.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 (52) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/README.md +15 -13
  3. package/dist/plugin.d.ts +12 -0
  4. package/dist/plugin.d.ts.map +1 -1
  5. package/dist/plugin.js +4 -0
  6. package/dist/plugin.js.map +1 -1
  7. package/dist/rules/ai-node-package-json.d.ts +2 -0
  8. package/dist/rules/ai-node-package-json.d.ts.map +1 -0
  9. package/dist/rules/ai-node-package-json.js +80 -0
  10. package/dist/rules/ai-node-package-json.js.map +1 -0
  11. package/dist/rules/index.d.ts +2 -0
  12. package/dist/rules/index.d.ts.map +1 -1
  13. package/dist/rules/index.js +4 -0
  14. package/dist/rules/index.js.map +1 -1
  15. package/dist/rules/no-deprecated-workflow-functions.d.ts.map +1 -1
  16. package/dist/rules/no-deprecated-workflow-functions.js +1 -13
  17. package/dist/rules/no-deprecated-workflow-functions.js.map +1 -1
  18. package/dist/rules/no-http-request-with-manual-auth.d.ts +19 -0
  19. package/dist/rules/no-http-request-with-manual-auth.d.ts.map +1 -0
  20. package/dist/rules/no-http-request-with-manual-auth.js +65 -0
  21. package/dist/rules/no-http-request-with-manual-auth.js.map +1 -0
  22. package/dist/rules/no-restricted-imports.d.ts.map +1 -1
  23. package/dist/rules/no-restricted-imports.js +2 -0
  24. package/dist/rules/no-restricted-imports.js.map +1 -1
  25. package/dist/rules/node-usable-as-tool.d.ts.map +1 -1
  26. package/dist/rules/node-usable-as-tool.js +18 -0
  27. package/dist/rules/node-usable-as-tool.js.map +1 -1
  28. package/dist/rules/package-name-convention.d.ts.map +1 -1
  29. package/dist/rules/package-name-convention.js +3 -5
  30. package/dist/rules/package-name-convention.js.map +1 -1
  31. package/dist/utils/ast-utils.d.ts +7 -0
  32. package/dist/utils/ast-utils.d.ts.map +1 -1
  33. package/dist/utils/ast-utils.js +27 -0
  34. package/dist/utils/ast-utils.js.map +1 -1
  35. package/docs/rules/ai-node-package-json.md +62 -0
  36. package/docs/rules/credential-documentation-url.md +5 -3
  37. package/docs/rules/no-http-request-with-manual-auth.md +57 -0
  38. package/package.json +2 -2
  39. package/src/plugin.ts +4 -0
  40. package/src/rules/ai-node-package-json.test.ts +111 -0
  41. package/src/rules/ai-node-package-json.ts +100 -0
  42. package/src/rules/index.ts +4 -0
  43. package/src/rules/no-deprecated-workflow-functions.ts +1 -17
  44. package/src/rules/no-http-request-with-manual-auth.test.ts +104 -0
  45. package/src/rules/no-http-request-with-manual-auth.ts +78 -0
  46. package/src/rules/no-restricted-imports.test.ts +2 -1
  47. package/src/rules/no-restricted-imports.ts +2 -0
  48. package/src/rules/node-usable-as-tool.test.ts +66 -0
  49. package/src/rules/node-usable-as-tool.ts +22 -0
  50. package/src/rules/package-name-convention.ts +3 -8
  51. package/src/utils/ast-utils.ts +43 -0
  52. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,104 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+
3
+ import { NoHttpRequestWithManualAuthRule } from './no-http-request-with-manual-auth.js';
4
+
5
+ const ruleTester = new RuleTester();
6
+
7
+ ruleTester.run('no-http-request-with-manual-auth', NoHttpRequestWithManualAuthRule, {
8
+ valid: [
9
+ {
10
+ name: 'httpRequest and getCredentials at module top level (no function scope)',
11
+ code: `
12
+ const credentials = await this.getCredentials('myApi');
13
+ const result = await this.helpers.httpRequest({ url: 'https://api.example.com' });`,
14
+ },
15
+ {
16
+ name: 'httpRequest without getCredentials (unauthenticated call)',
17
+ code: `
18
+ async function makeRequest() {
19
+ return this.helpers.httpRequest({ url: 'https://public.api.com' });
20
+ }`,
21
+ },
22
+ {
23
+ name: 'httpRequestWithAuthentication with getCredentials (correct pattern)',
24
+ code: `
25
+ async function makeRequest() {
26
+ const credentials = await this.getCredentials('myApi');
27
+ return this.helpers.httpRequestWithAuthentication.call(this, 'myApi', {
28
+ url: 'https://api.example.com',
29
+ });
30
+ }`,
31
+ },
32
+ {
33
+ name: 'getCredentials called but no httpRequest in same function',
34
+ code: `
35
+ async function loadConfig() {
36
+ const credentials = await this.getCredentials('myApi');
37
+ return credentials.apiKey;
38
+ }`,
39
+ },
40
+ {
41
+ name: 'getCredentials in outer function, httpRequest in nested function (separate scopes)',
42
+ code: `
43
+ async function execute() {
44
+ const credentials = await this.getCredentials('myApi');
45
+ const makeRequest = async () => {
46
+ return this.helpers.httpRequest({ url: 'https://api.example.com' });
47
+ };
48
+ return makeRequest();
49
+ }`,
50
+ },
51
+ {
52
+ name: 'other object with helpers.httpRequest is not flagged',
53
+ code: `
54
+ async function test() {
55
+ const credentials = await this.getCredentials('myApi');
56
+ const otherObject = { helpers: { httpRequest: async () => {} } };
57
+ return otherObject.helpers.httpRequest({ url: 'https://example.com' });
58
+ }`,
59
+ },
60
+ ],
61
+ invalid: [
62
+ {
63
+ name: 'function calls both getCredentials and httpRequest',
64
+ code: `
65
+ async function makeRequest() {
66
+ const credentials = await this.getCredentials('myApi');
67
+ const options = {
68
+ headers: { Authorization: \`Bearer \${credentials.apiKey}\` },
69
+ url: 'https://api.example.com',
70
+ };
71
+ return this.helpers.httpRequest(options);
72
+ }`,
73
+ errors: [{ messageId: 'useHttpRequestWithAuthentication' }],
74
+ },
75
+ {
76
+ name: 'two httpRequest calls in a function that also calls getCredentials — both flagged',
77
+ code: `
78
+ async function makeRequests() {
79
+ const credentials = await this.getCredentials('myApi');
80
+ const r1 = await this.helpers.httpRequest({ url: 'https://api.example.com/a' });
81
+ const r2 = await this.helpers.httpRequest({ url: 'https://api.example.com/b' });
82
+ return [r1, r2];
83
+ }`,
84
+ errors: [
85
+ { messageId: 'useHttpRequestWithAuthentication' },
86
+ { messageId: 'useHttpRequestWithAuthentication' },
87
+ ],
88
+ },
89
+ {
90
+ name: 'class method pattern',
91
+ code: `
92
+ class MyNode {
93
+ async execute() {
94
+ const credentials = await this.getCredentials('myApi');
95
+ return this.helpers.httpRequest({
96
+ headers: { Authorization: credentials.apiKey },
97
+ url: 'https://api.example.com',
98
+ });
99
+ }
100
+ }`,
101
+ errors: [{ messageId: 'useHttpRequestWithAuthentication' }],
102
+ },
103
+ ],
104
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Flags `this.helpers.httpRequest()` in functions that also call `this.getCredentials()`.
3
+ * Those functions should use `this.helpers.httpRequestWithAuthentication()` instead.
4
+ *
5
+ * Uses a function-scope stack: if both `getCredentials` and `httpRequest` appear in
6
+ * the same function body, every `httpRequest` call is reported. Nested functions are
7
+ * checked independently.
8
+ *
9
+ * Alternatives considered:
10
+ * - Checking for credential variables in `httpRequest` arguments — misses the common
11
+ * pattern where options are built in a separate variable first.
12
+ * - Matching auth header names (`Authorization`, etc.) — brittle and requires deep
13
+ * AST traversal with no guarantee of coverage.
14
+ *
15
+ * Known false positive: a function that fetches credentials for a non-HTTP purpose
16
+ * and also makes an unauthenticated request. Use eslint-disable to suppress.
17
+ */
18
+
19
+ import type { TSESTree } from '@typescript-eslint/utils';
20
+
21
+ import { createRule, isThisHelpersMethodCall, isThisMethodCall } from '../utils/index.js';
22
+
23
+ type FunctionScope = {
24
+ getCredentialsCall: TSESTree.CallExpression | null;
25
+ httpRequestCalls: TSESTree.CallExpression[];
26
+ };
27
+
28
+ export const NoHttpRequestWithManualAuthRule = createRule({
29
+ name: 'no-http-request-with-manual-auth',
30
+ meta: {
31
+ type: 'suggestion',
32
+ docs: {
33
+ description:
34
+ 'Disallow this.helpers.httpRequest() in functions that call this.getCredentials(). Use this.helpers.httpRequestWithAuthentication() instead.',
35
+ },
36
+ messages: {
37
+ useHttpRequestWithAuthentication:
38
+ "Avoid calling 'this.helpers.httpRequest()' in a function that retrieves credentials via 'this.getCredentials()'. Use 'this.helpers.httpRequestWithAuthentication()' instead — it handles authentication internally and benefits from future n8n improvements like token refresh and audit logging.",
39
+ },
40
+ schema: [],
41
+ hasSuggestions: false,
42
+ },
43
+ defaultOptions: [],
44
+ create(context) {
45
+ const scopeStack: FunctionScope[] = [];
46
+
47
+ const pushScope = () => scopeStack.push({ getCredentialsCall: null, httpRequestCalls: [] });
48
+
49
+ const popScope = () => {
50
+ const scope = scopeStack.pop();
51
+ if (scope?.getCredentialsCall && scope.httpRequestCalls.length > 0) {
52
+ for (const call of scope.httpRequestCalls) {
53
+ context.report({ node: call, messageId: 'useHttpRequestWithAuthentication' });
54
+ }
55
+ }
56
+ };
57
+
58
+ return {
59
+ FunctionDeclaration: pushScope,
60
+ FunctionExpression: pushScope,
61
+ ArrowFunctionExpression: pushScope,
62
+ 'FunctionDeclaration:exit': popScope,
63
+ 'FunctionExpression:exit': popScope,
64
+ 'ArrowFunctionExpression:exit': popScope,
65
+
66
+ CallExpression(node: TSESTree.CallExpression) {
67
+ const scope = scopeStack[scopeStack.length - 1];
68
+ if (!scope) return;
69
+ if (isThisMethodCall(node, 'getCredentials')) {
70
+ scope.getCredentialsCall = node;
71
+ }
72
+ if (isThisHelpersMethodCall(node, 'httpRequest')) {
73
+ scope.httpRequestCalls.push(node);
74
+ }
75
+ },
76
+ };
77
+ },
78
+ });
@@ -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,
@@ -184,6 +197,36 @@ export function extractCredentialNameFromArray(
184
197
  return info ? { name: info.name, node: info.node } : null;
185
198
  }
186
199
 
200
+ /** Matches the `this.helpers` MemberExpression (the object part of `this.helpers.foo`). */
201
+ export function isThisHelpersAccess(node: TSESTree.MemberExpression): boolean {
202
+ return (
203
+ node.object?.type === AST_NODE_TYPES.MemberExpression &&
204
+ node.object.object?.type === AST_NODE_TYPES.ThisExpression &&
205
+ node.object.property?.type === AST_NODE_TYPES.Identifier &&
206
+ node.object.property.name === 'helpers'
207
+ );
208
+ }
209
+
210
+ /** Matches a call expression of the form `this.methodName(...)`. */
211
+ export function isThisMethodCall(node: TSESTree.CallExpression, method: string): boolean {
212
+ return (
213
+ node.callee.type === AST_NODE_TYPES.MemberExpression &&
214
+ node.callee.object.type === AST_NODE_TYPES.ThisExpression &&
215
+ node.callee.property.type === AST_NODE_TYPES.Identifier &&
216
+ node.callee.property.name === method
217
+ );
218
+ }
219
+
220
+ /** Matches a call expression of the form `this.helpers.methodName(...)`. */
221
+ export function isThisHelpersMethodCall(node: TSESTree.CallExpression, method: string): boolean {
222
+ return (
223
+ node.callee.type === AST_NODE_TYPES.MemberExpression &&
224
+ node.callee.property.type === AST_NODE_TYPES.Identifier &&
225
+ node.callee.property.name === method &&
226
+ isThisHelpersAccess(node.callee)
227
+ );
228
+ }
229
+
187
230
  export function findSimilarStrings(
188
231
  target: string,
189
232
  candidates: Set<string>,