@n8n/eslint-plugin-community-nodes 0.3.0 → 0.5.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 (114) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/README.md +60 -0
  3. package/dist/plugin.d.ts +144 -28
  4. package/dist/plugin.d.ts.map +1 -1
  5. package/dist/plugin.js +28 -25
  6. package/dist/plugin.js.map +1 -1
  7. package/dist/rules/credential-documentation-url.d.ts +7 -0
  8. package/dist/rules/credential-documentation-url.d.ts.map +1 -0
  9. package/dist/rules/credential-documentation-url.js +100 -0
  10. package/dist/rules/credential-documentation-url.js.map +1 -0
  11. package/dist/rules/credential-password-field.d.ts +1 -2
  12. package/dist/rules/credential-password-field.d.ts.map +1 -1
  13. package/dist/rules/credential-password-field.js +25 -14
  14. package/dist/rules/credential-password-field.js.map +1 -1
  15. package/dist/rules/credential-test-required.d.ts +1 -2
  16. package/dist/rules/credential-test-required.d.ts.map +1 -1
  17. package/dist/rules/credential-test-required.js +43 -5
  18. package/dist/rules/credential-test-required.js.map +1 -1
  19. package/dist/rules/icon-validation.d.ts +1 -2
  20. package/dist/rules/icon-validation.d.ts.map +1 -1
  21. package/dist/rules/icon-validation.js +81 -15
  22. package/dist/rules/icon-validation.js.map +1 -1
  23. package/dist/rules/index.d.ts +9 -5
  24. package/dist/rules/index.d.ts.map +1 -1
  25. package/dist/rules/index.js +7 -5
  26. package/dist/rules/index.js.map +1 -1
  27. package/dist/rules/no-credential-reuse.d.ts +1 -2
  28. package/dist/rules/no-credential-reuse.d.ts.map +1 -1
  29. package/dist/rules/no-credential-reuse.js +33 -4
  30. package/dist/rules/no-credential-reuse.js.map +1 -1
  31. package/dist/rules/no-deprecated-workflow-functions.d.ts +1 -2
  32. package/dist/rules/no-deprecated-workflow-functions.d.ts.map +1 -1
  33. package/dist/rules/no-deprecated-workflow-functions.js +38 -10
  34. package/dist/rules/no-deprecated-workflow-functions.js.map +1 -1
  35. package/dist/rules/no-restricted-globals.d.ts +2 -2
  36. package/dist/rules/no-restricted-globals.d.ts.map +1 -1
  37. package/dist/rules/no-restricted-globals.js +5 -3
  38. package/dist/rules/no-restricted-globals.js.map +1 -1
  39. package/dist/rules/no-restricted-imports.d.ts +1 -2
  40. package/dist/rules/no-restricted-imports.d.ts.map +1 -1
  41. package/dist/rules/no-restricted-imports.js +3 -3
  42. package/dist/rules/no-restricted-imports.js.map +1 -1
  43. package/dist/rules/node-usable-as-tool.d.ts +1 -2
  44. package/dist/rules/node-usable-as-tool.d.ts.map +1 -1
  45. package/dist/rules/node-usable-as-tool.js +6 -5
  46. package/dist/rules/node-usable-as-tool.js.map +1 -1
  47. package/dist/rules/package-name-convention.d.ts +1 -2
  48. package/dist/rules/package-name-convention.d.ts.map +1 -1
  49. package/dist/rules/package-name-convention.js +38 -2
  50. package/dist/rules/package-name-convention.js.map +1 -1
  51. package/dist/rules/resource-operation-pattern.d.ts +1 -2
  52. package/dist/rules/resource-operation-pattern.d.ts.map +1 -1
  53. package/dist/rules/resource-operation-pattern.js +9 -7
  54. package/dist/rules/resource-operation-pattern.js.map +1 -1
  55. package/dist/utils/ast-utils.d.ts +2 -1
  56. package/dist/utils/ast-utils.d.ts.map +1 -1
  57. package/dist/utils/ast-utils.js +37 -19
  58. package/dist/utils/ast-utils.js.map +1 -1
  59. package/dist/utils/file-utils.d.ts +14 -0
  60. package/dist/utils/file-utils.d.ts.map +1 -1
  61. package/dist/utils/file-utils.js +85 -18
  62. package/dist/utils/file-utils.js.map +1 -1
  63. package/dist/utils/index.d.ts +1 -0
  64. package/dist/utils/index.d.ts.map +1 -1
  65. package/dist/utils/index.js +1 -0
  66. package/dist/utils/index.js.map +1 -1
  67. package/dist/utils/rule-creator.d.ts +3 -0
  68. package/dist/utils/rule-creator.d.ts.map +1 -0
  69. package/dist/utils/rule-creator.js +5 -0
  70. package/dist/utils/rule-creator.js.map +1 -0
  71. package/docs/rules/credential-documentation-url.md +94 -0
  72. package/docs/rules/credential-password-field.md +45 -0
  73. package/docs/rules/credential-test-required.md +58 -0
  74. package/docs/rules/icon-validation.md +67 -0
  75. package/docs/rules/no-credential-reuse.md +82 -0
  76. package/docs/rules/no-deprecated-workflow-functions.md +61 -0
  77. package/docs/rules/no-restricted-globals.md +44 -0
  78. package/docs/rules/no-restricted-imports.md +47 -0
  79. package/docs/rules/node-usable-as-tool.md +43 -0
  80. package/docs/rules/package-name-convention.md +52 -0
  81. package/docs/rules/resource-operation-pattern.md +84 -0
  82. package/eslint.config.mjs +27 -0
  83. package/package.json +25 -4
  84. package/src/plugin.ts +30 -26
  85. package/src/rules/credential-documentation-url.test.ts +306 -0
  86. package/src/rules/credential-documentation-url.ts +129 -0
  87. package/src/rules/credential-password-field.test.ts +1 -0
  88. package/src/rules/credential-password-field.ts +34 -16
  89. package/src/rules/credential-test-required.test.ts +84 -57
  90. package/src/rules/credential-test-required.ts +51 -5
  91. package/src/rules/icon-validation.test.ts +97 -14
  92. package/src/rules/icon-validation.ts +95 -14
  93. package/src/rules/index.ts +8 -5
  94. package/src/rules/no-credential-reuse.test.ts +306 -58
  95. package/src/rules/no-credential-reuse.ts +43 -3
  96. package/src/rules/no-deprecated-workflow-functions.test.ts +70 -0
  97. package/src/rules/no-deprecated-workflow-functions.ts +44 -10
  98. package/src/rules/no-restricted-globals.test.ts +1 -0
  99. package/src/rules/no-restricted-globals.ts +6 -3
  100. package/src/rules/no-restricted-imports.test.ts +1 -0
  101. package/src/rules/no-restricted-imports.ts +8 -3
  102. package/src/rules/node-usable-as-tool.test.ts +1 -0
  103. package/src/rules/node-usable-as-tool.ts +8 -6
  104. package/src/rules/package-name-convention.test.ts +82 -5
  105. package/src/rules/package-name-convention.ts +46 -2
  106. package/src/rules/resource-operation-pattern.test.ts +1 -0
  107. package/src/rules/resource-operation-pattern.ts +13 -6
  108. package/src/utils/ast-utils.ts +47 -19
  109. package/src/utils/file-utils.ts +108 -18
  110. package/src/utils/index.ts +1 -0
  111. package/src/utils/rule-creator.ts +6 -0
  112. package/tsconfig.build.json +4 -0
  113. package/tsconfig.eslint.json +5 -0
  114. package/tsconfig.json +1 -2
@@ -1,4 +1,5 @@
1
1
  import { RuleTester } from '@typescript-eslint/rule-tester';
2
+
2
3
  import { CredentialTestRequiredRule } from './credential-test-required.js';
3
4
 
4
5
  const ruleTester = new RuleTester();
@@ -20,13 +21,13 @@ function createCredentialCode(options: {
20
21
  } = options;
21
22
 
22
23
  const imports = hasTest
23
- ? `import type { ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow';`
24
- : `import type { ICredentialType, INodeProperties } from 'n8n-workflow';`;
24
+ ? "import type { ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow';"
25
+ : "import type { ICredentialType, INodeProperties } from 'n8n-workflow';";
25
26
 
26
27
  const extendsStr = extendsArray ? `\n\textends = ${JSON.stringify(extendsArray)};` : '';
27
28
 
28
29
  const testProperty = hasTest
29
- ? `\n\n\ttest: ICredentialTestRequest = {\n\t\trequest: {\n\t\t\tbaseURL: 'https://api.example.com',\n\t\t\turl: '/test',\n\t\t},\n\t};`
30
+ ? "\n\n\ttest: ICredentialTestRequest = {\n\t\trequest: {\n\t\t\tbaseURL: 'https://api.example.com',\n\t\t\turl: '/test',\n\t\t},\n\t};"
30
31
  : '';
31
32
 
32
33
  return `
@@ -46,57 +47,6 @@ export class ${className} {
46
47
  }`;
47
48
  }
48
49
 
49
- function createNodeCode(options: {
50
- name?: string;
51
- displayName?: string;
52
- credentials?: Array<string | { name: string; testedBy?: string }>;
53
- extendsClass?: string;
54
- }): string {
55
- const { name = 'myNode', displayName = 'My Node', credentials = [], extendsClass } = options;
56
-
57
- const credentialsArray = credentials
58
- .map((cred) => {
59
- if (typeof cred === 'string') {
60
- return `'${cred}'`;
61
- }
62
- const testedByStr = cred.testedBy ? `, testedBy: '${cred.testedBy}'` : '';
63
- return `{ name: '${cred.name}'${testedByStr} }`;
64
- })
65
- .join(', ');
66
-
67
- const classDeclaration = extendsClass
68
- ? `export class ${name.charAt(0).toUpperCase() + name.slice(1)} extends ${extendsClass}`
69
- : `export class ${name.charAt(0).toUpperCase() + name.slice(1)} implements INodeType`;
70
-
71
- const descriptionProperty = extendsClass
72
- ? `description = {` // Extending classes might not need full INodeTypeDescription
73
- : `description: INodeTypeDescription = {`;
74
-
75
- return `
76
- import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
77
-
78
- ${classDeclaration} {
79
- ${descriptionProperty}
80
- displayName: '${displayName}',
81
- name: '${name}',
82
- group: ['transform'],
83
- version: 1,
84
- description: 'A test node',
85
- defaults: {
86
- name: '${displayName}',
87
- },
88
- inputs: ['main'],
89
- outputs: ['main'],
90
- credentials: [${credentialsArray}],
91
- properties: [],
92
- };
93
-
94
- execute() {
95
- return Promise.resolve([]);
96
- }
97
- }`;
98
- }
99
-
100
50
  ruleTester.run('credential-test-required', CredentialTestRequiredRule, {
101
51
  valid: [
102
52
  {
@@ -129,19 +79,96 @@ ruleTester.run('credential-test-required', CredentialTestRequiredRule, {
129
79
  name: 'credential class missing test property and no testedBy in package',
130
80
  filename: 'MyApi.credentials.ts',
131
81
  code: createCredentialCode({}),
132
- errors: [{ messageId: 'missingCredentialTest', data: { className: 'MyApi' } }],
82
+ errors: [
83
+ {
84
+ messageId: 'missingCredentialTest',
85
+ data: { className: 'MyApi' },
86
+ suggestions: [
87
+ {
88
+ messageId: 'addTemplate',
89
+ output: `
90
+ import type { ICredentialType, INodeProperties } from 'n8n-workflow';
91
+
92
+ export class MyApi implements ICredentialType {
93
+ name = 'myApi';
94
+ displayName = 'My API';
95
+ properties: INodeProperties[] = [];
96
+
97
+ test: ICredentialTestRequest = {
98
+ request: {
99
+ method: 'GET',
100
+ url: '={{$credentials.server}}/test', // Replace with actual endpoint
101
+ },
102
+ };
103
+ }`,
104
+ },
105
+ ],
106
+ },
107
+ ],
133
108
  },
134
109
  {
135
110
  name: 'credential class with extends but not oAuth2Api and no testedBy in package',
136
111
  filename: 'MyApi.credentials.ts',
137
112
  code: createCredentialCode({ extends: ['someOtherApi'] }),
138
- errors: [{ messageId: 'missingCredentialTest', data: { className: 'MyApi' } }],
113
+ errors: [
114
+ {
115
+ messageId: 'missingCredentialTest',
116
+ data: { className: 'MyApi' },
117
+ suggestions: [
118
+ {
119
+ messageId: 'addTemplate',
120
+ output: `
121
+ import type { ICredentialType, INodeProperties } from 'n8n-workflow';
122
+
123
+ export class MyApi implements ICredentialType {
124
+ name = 'myApi';
125
+ extends = ["someOtherApi"];
126
+ displayName = 'My API';
127
+ properties: INodeProperties[] = [];
128
+
129
+ test: ICredentialTestRequest = {
130
+ request: {
131
+ method: 'GET',
132
+ url: '={{$credentials.server}}/test', // Replace with actual endpoint
133
+ },
134
+ };
135
+ }`,
136
+ },
137
+ ],
138
+ },
139
+ ],
139
140
  },
140
141
  {
141
142
  name: 'credential class with empty extends array and no testedBy in package',
142
143
  filename: 'MyApi.credentials.ts',
143
144
  code: createCredentialCode({ extends: [] }),
144
- errors: [{ messageId: 'missingCredentialTest', data: { className: 'MyApi' } }],
145
+ errors: [
146
+ {
147
+ messageId: 'missingCredentialTest',
148
+ data: { className: 'MyApi' },
149
+ suggestions: [
150
+ {
151
+ messageId: 'addTemplate',
152
+ output: `
153
+ import type { ICredentialType, INodeProperties } from 'n8n-workflow';
154
+
155
+ export class MyApi implements ICredentialType {
156
+ name = 'myApi';
157
+ extends = [];
158
+ displayName = 'My API';
159
+ properties: INodeProperties[] = [];
160
+
161
+ test: ICredentialTestRequest = {
162
+ request: {
163
+ method: 'GET',
164
+ url: '={{$credentials.server}}/test', // Replace with actual endpoint
165
+ },
166
+ };
167
+ }`,
168
+ },
169
+ ],
170
+ },
171
+ ],
145
172
  },
146
173
  ],
147
174
  });
@@ -1,4 +1,6 @@
1
- import { ESLintUtils } from '@typescript-eslint/utils';
1
+ import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
2
+ import { dirname } from 'node:path';
3
+
2
4
  import {
3
5
  isCredentialTypeClass,
4
6
  findClassProperty,
@@ -7,20 +9,23 @@ import {
7
9
  getStringLiteralValue,
8
10
  findPackageJson,
9
11
  areAllCredentialUsagesTestedByNodes,
12
+ createRule,
10
13
  } from '../utils/index.js';
11
- import { dirname } from 'node:path';
12
14
 
13
- export const CredentialTestRequiredRule = ESLintUtils.RuleCreator.withoutDocs({
15
+ export const CredentialTestRequiredRule = createRule({
16
+ name: 'credential-test-required',
14
17
  meta: {
15
18
  type: 'problem',
16
19
  docs: {
17
20
  description: 'Ensure credentials have a credential test',
18
21
  },
19
22
  messages: {
23
+ addTemplate: 'Add basic credential test template',
20
24
  missingCredentialTest:
21
25
  'Credential class "{{ className }}" must have a test property or be tested by a node via testedBy',
22
26
  },
23
27
  schema: [],
28
+ hasSuggestions: true,
24
29
  },
25
30
  defaultOptions: [],
26
31
  create(context) {
@@ -73,27 +78,68 @@ export const CredentialTestRequiredRule = ESLintUtils.RuleCreator.withoutDocs({
73
78
 
74
79
  const pkgDir = getPackageDir();
75
80
  if (!pkgDir) {
81
+ const suggestions: ReportSuggestionArray<'addTemplate' | 'missingCredentialTest'> = [];
82
+
83
+ const testProperty = createCredentialTestTemplate();
84
+ suggestions.push({
85
+ messageId: 'addTemplate',
86
+ fix(fixer) {
87
+ const classBody = node.body.body;
88
+ const lastProperty = classBody[classBody.length - 1];
89
+ if (lastProperty) {
90
+ return fixer.insertTextAfter(lastProperty, `\n\n${testProperty}`);
91
+ }
92
+ return null;
93
+ },
94
+ });
95
+
76
96
  context.report({
77
97
  node,
78
98
  messageId: 'missingCredentialTest',
79
99
  data: {
80
- className: node.id?.name || 'Unknown',
100
+ className: node.id?.name ?? 'Unknown',
81
101
  },
102
+ suggest: suggestions,
82
103
  });
83
104
  return;
84
105
  }
85
106
 
86
107
  const allUsagesTestedByNodes = areAllCredentialUsagesTestedByNodes(credentialName, pkgDir);
87
108
  if (!allUsagesTestedByNodes) {
109
+ const suggestions: ReportSuggestionArray<'addTemplate' | 'missingCredentialTest'> = [];
110
+
111
+ const testProperty = createCredentialTestTemplate();
112
+ suggestions.push({
113
+ messageId: 'addTemplate',
114
+ fix(fixer) {
115
+ const classBody = node.body.body;
116
+ const lastProperty = classBody[classBody.length - 1];
117
+ if (lastProperty) {
118
+ return fixer.insertTextAfter(lastProperty, `\n\n${testProperty}`);
119
+ }
120
+ return null;
121
+ },
122
+ });
123
+
88
124
  context.report({
89
125
  node,
90
126
  messageId: 'missingCredentialTest',
91
127
  data: {
92
- className: node.id?.name || 'Unknown',
128
+ className: node.id?.name ?? 'Unknown',
93
129
  },
130
+ suggest: suggestions,
94
131
  });
95
132
  }
96
133
  },
97
134
  };
98
135
  },
99
136
  });
137
+
138
+ function createCredentialTestTemplate(): string {
139
+ return `\ttest: ICredentialTestRequest = {
140
+ \t\trequest: {
141
+ \t\t\tmethod: 'GET',
142
+ \t\t\turl: '={{$credentials.server}}/test', // Replace with actual endpoint
143
+ \t\t},
144
+ \t};`;
145
+ }
@@ -1,26 +1,51 @@
1
1
  import { RuleTester } from '@typescript-eslint/rule-tester';
2
- import { IconValidationRule } from './icon-validation.js';
3
- import { vi } from 'vitest';
4
2
  import * as fs from 'node:fs';
3
+ import { vi } from 'vitest';
4
+
5
+ import { IconValidationRule } from './icon-validation.js';
5
6
 
6
7
  const ruleTester = new RuleTester();
7
8
 
8
9
  vi.mock('node:fs', () => ({
9
10
  existsSync: vi.fn(),
11
+ readdirSync: vi.fn(),
10
12
  }));
11
13
 
12
14
  const mockExistsSync = vi.mocked(fs.existsSync);
15
+ const mockReaddirSync = vi.mocked(fs.readdirSync);
16
+
17
+ const mockSvgFiles = [
18
+ 'TestNode.svg',
19
+ 'ValidIcon.svg',
20
+ 'ValidIcon.dark.svg',
21
+ 'SameIcon.svg',
22
+ 'github.svg',
23
+ ];
13
24
 
14
25
  function setupMockFileSystem() {
15
26
  mockExistsSync.mockImplementation((path: fs.PathLike) => {
16
27
  const pathStr = path.toString();
17
- return (
18
- pathStr.includes('TestNode.svg') ||
19
- pathStr.includes('ValidIcon.svg') ||
20
- pathStr.includes('ValidIcon.dark.svg') ||
21
- pathStr.includes('SameIcon.svg') ||
22
- pathStr.includes('NotSvg.png')
23
- );
28
+
29
+ if (mockSvgFiles.some((file) => pathStr.includes(file)) || pathStr.includes('NotSvg.png')) {
30
+ return true;
31
+ }
32
+
33
+ if (pathStr.endsWith('/tmp/icons') || pathStr.endsWith('/tmp') || pathStr.endsWith('icons')) {
34
+ return true;
35
+ }
36
+
37
+ return false;
38
+ });
39
+
40
+ // @ts-expect-error Typescript does not select the correct overload
41
+ mockReaddirSync.mockImplementation((path: fs.PathLike): string[] => {
42
+ const pathStr = path.toString();
43
+
44
+ if (pathStr.includes('icons')) {
45
+ return [...mockSvgFiles, 'NotSvg.png'];
46
+ }
47
+
48
+ return [];
24
49
  });
25
50
  }
26
51
 
@@ -34,10 +59,10 @@ function createNodeCode(
34
59
  includeTypeImport: boolean = false,
35
60
  ): string {
36
61
  const typeImport = includeTypeImport
37
- ? `import type { INodeType, INodeTypeDescription } from 'n8n-workflow';`
38
- : `import type { INodeType } from 'n8n-workflow';`;
62
+ ? "import type { INodeType, INodeTypeDescription } from 'n8n-workflow';"
63
+ : "import type { INodeType } from 'n8n-workflow';";
39
64
 
40
- const typeAnnotation = includeTypeImport ? `: INodeTypeDescription` : '';
65
+ const typeAnnotation = includeTypeImport ? ': INodeTypeDescription' : '';
41
66
 
42
67
  let iconProperty = '';
43
68
  if (icon) {
@@ -151,7 +176,18 @@ ruleTester.run('icon-validation', IconValidationRule, {
151
176
  name: 'node missing icon property in description',
152
177
  filename: nodeFilePath,
153
178
  code: createNodeCode(undefined, true),
154
- errors: [{ messageId: 'missingIcon' }],
179
+ errors: [
180
+ {
181
+ messageId: 'missingIcon',
182
+ suggestions: [
183
+ {
184
+ messageId: 'addPlaceholder',
185
+ output:
186
+ "\nimport type { INodeType, INodeTypeDescription } from 'n8n-workflow';\n\nexport class TestNode implements INodeType {\n\tdescription: INodeTypeDescription = {\n\t\tdisplayName: 'Test Node',\n\t\tname: 'testNode',\n\t\t\n\t\tgroup: ['input'],\n\t\tversion: 1,\n\t\tdescription: 'A test node',\n\t\tdefaults: {\n\t\t\tname: 'Test Node',\n\t\t},\n\t\tinputs: ['main'],\n\t\toutputs: ['main'],\n\t\tproperties: [],\n\t\ticon: \"file:./icon.svg\",\n\t};\n}",
187
+ },
188
+ ],
189
+ },
190
+ ],
155
191
  },
156
192
  {
157
193
  name: 'icon file does not exist in description',
@@ -175,7 +211,18 @@ ruleTester.run('icon-validation', IconValidationRule, {
175
211
  name: 'credential missing icon property',
176
212
  filename: credentialFilePath,
177
213
  code: createCredentialCode(),
178
- errors: [{ messageId: 'missingIcon' }],
214
+ errors: [
215
+ {
216
+ messageId: 'missingIcon',
217
+ suggestions: [
218
+ {
219
+ messageId: 'addPlaceholder',
220
+ output:
221
+ "\nimport type { ICredentialType, INodeProperties } from 'n8n-workflow';\n\nexport class TestCredential implements ICredentialType {\n\tname = 'testApi';\n\tdisplayName = 'Test API';\n\t\n\tproperties: INodeProperties[] = [];\n\n\ticon = \"file:./icon.svg\";\n}",
222
+ },
223
+ ],
224
+ },
225
+ ],
179
226
  },
180
227
  {
181
228
  name: 'credential icon file does not exist',
@@ -192,5 +239,41 @@ ruleTester.run('icon-validation', IconValidationRule, {
192
239
  }),
193
240
  errors: [{ messageId: 'lightDarkSame', data: { iconPath: 'icons/SameIcon.svg' } }],
194
241
  },
242
+ {
243
+ name: 'node icon file does not exist but similar file exists - should suggest similar file',
244
+ filename: nodeFilePath,
245
+ code: createNodeCode('file:icons/github2.svg'),
246
+ errors: [
247
+ {
248
+ messageId: 'iconFileNotFound',
249
+ data: { iconPath: 'icons/github2.svg' },
250
+ suggestions: [
251
+ {
252
+ messageId: 'similarIcon',
253
+ data: { suggestedName: 'icons/github.svg' },
254
+ output: `
255
+ import type { INodeType } from 'n8n-workflow';
256
+
257
+ export class TestNode implements INodeType {
258
+ description = {
259
+ displayName: 'Test Node',
260
+ name: 'testNode',
261
+ icon: "file:icons/github.svg",
262
+ group: ['input'],
263
+ version: 1,
264
+ description: 'A test node',
265
+ defaults: {
266
+ name: 'Test Node',
267
+ },
268
+ inputs: ['main'],
269
+ outputs: ['main'],
270
+ properties: [],
271
+ };
272
+ }`,
273
+ },
274
+ ],
275
+ },
276
+ ],
277
+ },
195
278
  ],
196
279
  });
@@ -1,5 +1,7 @@
1
- import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
1
+ import { TSESTree } from '@typescript-eslint/utils';
2
+ import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
2
3
  import { dirname } from 'node:path';
4
+
3
5
  import {
4
6
  isNodeTypeClass,
5
7
  isCredentialTypeClass,
@@ -7,24 +9,34 @@ import {
7
9
  findObjectProperty,
8
10
  getStringLiteralValue,
9
11
  validateIconPath,
12
+ findSimilarSvgFiles,
10
13
  isFileType,
14
+ createRule,
11
15
  } from '../utils/index.js';
12
16
 
13
- export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({
17
+ const messages = {
18
+ iconFileNotFound: 'Icon file "{{ iconPath }}" does not exist',
19
+ iconNotSvg: 'Icon file "{{ iconPath }}" must be an SVG file (end with .svg)',
20
+ lightDarkSame: 'Light and dark icons cannot be the same file. Both point to "{{ iconPath }}"',
21
+ invalidIconPath: 'Icon path "{{ iconPath }}" must use file: protocol and be a string',
22
+ missingIcon: 'Node/Credential class must have an icon property defined',
23
+ addPlaceholder: 'Add icon property with placeholder',
24
+ addFileProtocol: "Add 'file:' protocol to icon path",
25
+ changeExtension: "Change icon extension to '.svg'",
26
+ similarIcon: "Use existing icon '{{ suggestedName }}'",
27
+ } as const;
28
+
29
+ export const IconValidationRule = createRule({
30
+ name: 'icon-validation',
14
31
  meta: {
15
32
  type: 'problem',
16
33
  docs: {
17
34
  description:
18
35
  'Validate node and credential icon files exist, are SVG format, and light/dark icons are different',
19
36
  },
20
- messages: {
21
- iconFileNotFound: 'Icon file "{{ iconPath }}" does not exist',
22
- iconNotSvg: 'Icon file "{{ iconPath }}" must be an SVG file (end with .svg)',
23
- lightDarkSame: 'Light and dark icons cannot be the same file. Both point to "{{ iconPath }}"',
24
- invalidIconPath: 'Icon path "{{ iconPath }}" must use file: protocol and be a string',
25
- missingIcon: 'Node/Credential class must have an icon property defined',
26
- },
37
+ messages,
27
38
  schema: [],
39
+ hasSuggestions: true,
28
40
  },
29
41
  defaultOptions: [],
30
42
  create(context) {
@@ -40,7 +52,7 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({
40
52
  context.report({
41
53
  node,
42
54
  messageId: 'invalidIconPath',
43
- data: { iconPath: iconPath || '' },
55
+ data: { iconPath: iconPath ?? '' },
44
56
  });
45
57
  return false;
46
58
  }
@@ -49,30 +61,68 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({
49
61
  const validation = validateIconPath(iconPath, currentDir);
50
62
 
51
63
  if (!validation.isFile) {
64
+ const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
65
+ if (!iconPath.startsWith('file:')) {
66
+ suggestions.push({
67
+ messageId: 'addFileProtocol',
68
+ fix(fixer) {
69
+ return fixer.replaceText(node, `"file:${iconPath}"`);
70
+ },
71
+ });
72
+ }
73
+
52
74
  context.report({
53
75
  node,
54
76
  messageId: 'invalidIconPath',
55
77
  data: { iconPath },
78
+ suggest: suggestions,
56
79
  });
57
80
  return false;
58
81
  }
59
82
 
60
83
  if (!validation.isSvg) {
61
84
  const relativePath = iconPath.replace(/^file:/, '');
85
+ const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
86
+
87
+ const pathWithoutExt = relativePath.replace(/\.[^/.]+$/, '');
88
+ const svgPath = `${pathWithoutExt}.svg`;
89
+ suggestions.push({
90
+ messageId: 'changeExtension',
91
+ fix(fixer) {
92
+ return fixer.replaceText(node, `"file:${svgPath}"`);
93
+ },
94
+ });
95
+
62
96
  context.report({
63
97
  node,
64
98
  messageId: 'iconNotSvg',
65
99
  data: { iconPath: relativePath },
100
+ suggest: suggestions,
66
101
  });
67
102
  return false;
68
103
  }
69
104
 
70
105
  if (!validation.exists) {
71
106
  const relativePath = iconPath.replace(/^file:/, '');
107
+ const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
108
+
109
+ // Find similar SVG files in the same directory
110
+ const similarFiles = findSimilarSvgFiles(relativePath, currentDir);
111
+ for (const similarFile of similarFiles) {
112
+ suggestions.push({
113
+ messageId: 'similarIcon',
114
+ data: { suggestedName: similarFile },
115
+ fix(fixer) {
116
+ return fixer.replaceText(node, `"file:${similarFile}"`);
117
+ },
118
+ });
119
+ }
120
+
72
121
  context.report({
73
122
  node,
74
123
  messageId: 'iconFileNotFound',
75
124
  data: { iconPath: relativePath },
125
+ suggest: suggestions,
76
126
  });
77
127
  return false;
78
128
  }
@@ -81,10 +131,10 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({
81
131
  };
82
132
 
83
133
  const validateIconValue = (iconValue: TSESTree.Node) => {
84
- if (iconValue.type === 'Literal') {
134
+ if (iconValue.type === TSESTree.AST_NODE_TYPES.Literal) {
85
135
  const iconPath = getStringLiteralValue(iconValue);
86
136
  validateIcon(iconPath, iconValue);
87
- } else if (iconValue.type === 'ObjectExpression') {
137
+ } else if (iconValue.type === TSESTree.AST_NODE_TYPES.ObjectExpression) {
88
138
  const lightProperty = findObjectProperty(iconValue, 'light');
89
139
  const darkProperty = findObjectProperty(iconValue, 'dark');
90
140
 
@@ -121,7 +171,7 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({
121
171
  const descriptionProperty = findClassProperty(node, 'description');
122
172
  if (
123
173
  !descriptionProperty?.value ||
124
- descriptionProperty.value.type !== 'ObjectExpression'
174
+ descriptionProperty.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression
125
175
  ) {
126
176
  context.report({
127
177
  node,
@@ -130,11 +180,27 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({
130
180
  return;
131
181
  }
132
182
 
133
- const iconProperty = findObjectProperty(descriptionProperty.value, 'icon');
183
+ const descriptionValue = descriptionProperty.value;
184
+ const iconProperty = findObjectProperty(descriptionValue, 'icon');
134
185
  if (!iconProperty) {
186
+ const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
187
+
188
+ suggestions.push({
189
+ messageId: 'addPlaceholder',
190
+ fix(fixer) {
191
+ const lastProperty =
192
+ descriptionValue.properties[descriptionValue.properties.length - 1];
193
+ if (lastProperty) {
194
+ return fixer.insertTextAfter(lastProperty, ',\n\t\ticon: "file:./icon.svg"');
195
+ }
196
+ return null;
197
+ },
198
+ });
199
+
135
200
  context.report({
136
201
  node,
137
202
  messageId: 'missingIcon',
203
+ suggest: suggestions,
138
204
  });
139
205
  return;
140
206
  }
@@ -143,9 +209,24 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({
143
209
  } else if (isCredentialClass) {
144
210
  const iconProperty = findClassProperty(node, 'icon');
145
211
  if (!iconProperty?.value) {
212
+ const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
213
+
214
+ suggestions.push({
215
+ messageId: 'addPlaceholder',
216
+ fix(fixer) {
217
+ const classBody = node.body.body;
218
+ const lastProperty = classBody[classBody.length - 1];
219
+ if (lastProperty) {
220
+ return fixer.insertTextAfter(lastProperty, '\n\n\ticon = "file:./icon.svg";');
221
+ }
222
+ return null;
223
+ },
224
+ });
225
+
146
226
  context.report({
147
227
  node,
148
228
  messageId: 'missingIcon',
229
+ suggest: suggestions,
149
230
  });
150
231
  return;
151
232
  }
@@ -1,13 +1,15 @@
1
1
  import type { AnyRuleModule } from '@typescript-eslint/utils/ts-eslint';
2
- import { NoRestrictedGlobalsRule } from './no-restricted-globals.js';
3
- import { NoRestrictedImportsRule } from './no-restricted-imports.js';
2
+
3
+ import { CredentialDocumentationUrlRule } from './credential-documentation-url.js';
4
4
  import { CredentialPasswordFieldRule } from './credential-password-field.js';
5
+ import { CredentialTestRequiredRule } from './credential-test-required.js';
6
+ import { IconValidationRule } from './icon-validation.js';
7
+ import { NoCredentialReuseRule } from './no-credential-reuse.js';
5
8
  import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js';
9
+ import { NoRestrictedGlobalsRule } from './no-restricted-globals.js';
10
+ import { NoRestrictedImportsRule } from './no-restricted-imports.js';
6
11
  import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
7
12
  import { PackageNameConventionRule } from './package-name-convention.js';
8
- import { CredentialTestRequiredRule } from './credential-test-required.js';
9
- import { NoCredentialReuseRule } from './no-credential-reuse.js';
10
- import { IconValidationRule } from './icon-validation.js';
11
13
  import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
12
14
 
13
15
  export const rules = {
@@ -21,4 +23,5 @@ export const rules = {
21
23
  'no-credential-reuse': NoCredentialReuseRule,
22
24
  'icon-validation': IconValidationRule,
23
25
  'resource-operation-pattern': ResourceOperationPatternRule,
26
+ 'credential-documentation-url': CredentialDocumentationUrlRule,
24
27
  } satisfies Record<string, AnyRuleModule>;