@n8n/eslint-plugin-community-nodes 0.19.0 → 0.21.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 (84) hide show
  1. package/.turbo/turbo-build$colon$unchecked.log +4 -0
  2. package/README.md +9 -1
  3. package/dist/plugin.d.ts +48 -0
  4. package/dist/plugin.d.ts.map +1 -1
  5. package/dist/plugin.js +16 -0
  6. package/dist/plugin.js.map +1 -1
  7. package/dist/rules/cred-filename-against-convention.d.ts +2 -0
  8. package/dist/rules/cred-filename-against-convention.d.ts.map +1 -0
  9. package/dist/rules/cred-filename-against-convention.js +42 -0
  10. package/dist/rules/cred-filename-against-convention.js.map +1 -0
  11. package/dist/rules/icon-prefer-themed-variants.d.ts +2 -0
  12. package/dist/rules/icon-prefer-themed-variants.d.ts.map +1 -0
  13. package/dist/rules/icon-prefer-themed-variants.js +53 -0
  14. package/dist/rules/icon-prefer-themed-variants.js.map +1 -0
  15. package/dist/rules/icon-validation.d.ts +1 -1
  16. package/dist/rules/icon-validation.d.ts.map +1 -1
  17. package/dist/rules/icon-validation.js +4 -25
  18. package/dist/rules/icon-validation.js.map +1 -1
  19. package/dist/rules/index.d.ts +9 -1
  20. package/dist/rules/index.d.ts.map +1 -1
  21. package/dist/rules/index.js +16 -0
  22. package/dist/rules/index.js.map +1 -1
  23. package/dist/rules/no-dangerous-functions.d.ts +2 -0
  24. package/dist/rules/no-dangerous-functions.d.ts.map +1 -0
  25. package/dist/rules/no-dangerous-functions.js +121 -0
  26. package/dist/rules/no-dangerous-functions.js.map +1 -0
  27. package/dist/rules/no-emoji-in-options.d.ts +2 -0
  28. package/dist/rules/no-emoji-in-options.d.ts.map +1 -0
  29. package/dist/rules/no-emoji-in-options.js +86 -0
  30. package/dist/rules/no-emoji-in-options.js.map +1 -0
  31. package/dist/rules/node-filename-against-convention.d.ts +2 -0
  32. package/dist/rules/node-filename-against-convention.d.ts.map +1 -0
  33. package/dist/rules/node-filename-against-convention.js +61 -0
  34. package/dist/rules/node-filename-against-convention.js.map +1 -0
  35. package/dist/rules/node-registration-complete.d.ts +2 -0
  36. package/dist/rules/node-registration-complete.d.ts.map +1 -0
  37. package/dist/rules/node-registration-complete.js +60 -0
  38. package/dist/rules/node-registration-complete.js.map +1 -0
  39. package/dist/rules/require-version.d.ts +2 -0
  40. package/dist/rules/require-version.d.ts.map +1 -0
  41. package/dist/rules/require-version.js +50 -0
  42. package/dist/rules/require-version.js.map +1 -0
  43. package/dist/rules/valid-author.d.ts +2 -0
  44. package/dist/rules/valid-author.d.ts.map +1 -0
  45. package/dist/rules/valid-author.js +89 -0
  46. package/dist/rules/valid-author.js.map +1 -0
  47. package/dist/typecheck.tsbuildinfo +1 -0
  48. package/dist/utils/file-utils.d.ts +7 -2
  49. package/dist/utils/file-utils.d.ts.map +1 -1
  50. package/dist/utils/file-utils.js +47 -10
  51. package/dist/utils/file-utils.js.map +1 -1
  52. package/docs/rules/cred-filename-against-convention.md +42 -0
  53. package/docs/rules/icon-prefer-themed-variants.md +71 -0
  54. package/docs/rules/icon-validation.md +5 -3
  55. package/docs/rules/no-dangerous-functions.md +41 -0
  56. package/docs/rules/no-emoji-in-options.md +60 -0
  57. package/docs/rules/node-filename-against-convention.md +50 -0
  58. package/docs/rules/node-registration-complete.md +46 -0
  59. package/docs/rules/require-version.md +51 -0
  60. package/docs/rules/valid-author.md +60 -0
  61. package/package.json +5 -4
  62. package/src/plugin.ts +16 -0
  63. package/src/rules/cred-filename-against-convention.test.ts +72 -0
  64. package/src/rules/cred-filename-against-convention.ts +48 -0
  65. package/src/rules/icon-prefer-themed-variants.test.ts +128 -0
  66. package/src/rules/icon-prefer-themed-variants.ts +70 -0
  67. package/src/rules/icon-validation.test.ts +10 -0
  68. package/src/rules/icon-validation.ts +4 -28
  69. package/src/rules/index.ts +16 -0
  70. package/src/rules/no-dangerous-functions.test.ts +83 -0
  71. package/src/rules/no-dangerous-functions.ts +155 -0
  72. package/src/rules/no-emoji-in-options.test.ts +157 -0
  73. package/src/rules/no-emoji-in-options.ts +105 -0
  74. package/src/rules/node-filename-against-convention.test.ts +115 -0
  75. package/src/rules/node-filename-against-convention.ts +76 -0
  76. package/src/rules/node-registration-complete.test.ts +87 -0
  77. package/src/rules/node-registration-complete.ts +79 -0
  78. package/src/rules/require-version.test.ts +90 -0
  79. package/src/rules/require-version.ts +62 -0
  80. package/src/rules/valid-author.test.ts +108 -0
  81. package/src/rules/valid-author.ts +100 -0
  82. package/src/utils/file-utils.ts +58 -11
  83. package/.turbo/turbo-build.log +0 -4
  84. package/tsconfig.build.tsbuildinfo +0 -1
@@ -0,0 +1,155 @@
1
+ import { TSESTree } from '@typescript-eslint/utils';
2
+
3
+ import {
4
+ createRule,
5
+ getModulePath,
6
+ isDirectRequireCall,
7
+ isRequireMemberCall,
8
+ } from '../utils/index.js';
9
+
10
+ const { AST_NODE_TYPES } = TSESTree;
11
+
12
+ const CHILD_PROCESS_MODULES = new Set(['child_process', 'node:child_process']);
13
+
14
+ /**
15
+ * `child_process` functions that spawn OS processes and are therefore
16
+ * vulnerable to command injection when fed untrusted input.
17
+ */
18
+ const DANGEROUS_CHILD_PROCESS_FUNCTIONS = new Set([
19
+ 'exec',
20
+ 'execSync',
21
+ 'execFile',
22
+ 'execFileSync',
23
+ 'spawn',
24
+ 'spawnSync',
25
+ 'fork',
26
+ ]);
27
+
28
+ const isChildProcessModule = (node: TSESTree.Node | null): boolean => {
29
+ const modulePath = getModulePath(node);
30
+ return modulePath !== null && CHILD_PROCESS_MODULES.has(modulePath);
31
+ };
32
+
33
+ export const NoDangerousFunctionsRule = createRule({
34
+ name: 'no-dangerous-functions',
35
+ meta: {
36
+ type: 'problem',
37
+ docs: {
38
+ description:
39
+ 'Disallow `eval`, the `Function` constructor, and `child_process` process-spawning functions (`exec`, `spawn`, etc.) in community nodes.',
40
+ },
41
+ messages: {
42
+ noEval:
43
+ 'Use of `eval` is not allowed. It executes arbitrary code and is a common source of remote code execution vulnerabilities.',
44
+ noFunctionConstructor:
45
+ 'Use of the `Function` constructor is not allowed. Like `eval`, it executes arbitrary code from strings.',
46
+ noChildProcess:
47
+ 'Use of `{{ name }}` from `child_process` is not allowed. Spawning OS processes is not permitted in community nodes and can lead to command injection.',
48
+ },
49
+ schema: [],
50
+ },
51
+ defaultOptions: [],
52
+ create(context) {
53
+ // Local names bound to dangerous named imports, e.g. `import { exec as run }` -> `run`.
54
+ const dangerousLocalNames = new Map<string, string>();
55
+ // Local names bound to the whole module, e.g. `import * as cp` or `const cp = require(...)`.
56
+ const namespaceNames = new Set<string>();
57
+
58
+ const recordDestructuredModule = (pattern: TSESTree.ObjectPattern) => {
59
+ for (const property of pattern.properties) {
60
+ if (
61
+ property.type !== AST_NODE_TYPES.Property ||
62
+ property.key.type !== AST_NODE_TYPES.Identifier ||
63
+ !DANGEROUS_CHILD_PROCESS_FUNCTIONS.has(property.key.name)
64
+ ) {
65
+ continue;
66
+ }
67
+
68
+ if (property.value.type === AST_NODE_TYPES.Identifier) {
69
+ dangerousLocalNames.set(property.value.name, property.key.name);
70
+ }
71
+ }
72
+ };
73
+
74
+ return {
75
+ ImportDeclaration(node) {
76
+ if (!CHILD_PROCESS_MODULES.has(node.source.value)) return;
77
+
78
+ for (const specifier of node.specifiers) {
79
+ if (
80
+ specifier.type === AST_NODE_TYPES.ImportSpecifier &&
81
+ specifier.imported.type === AST_NODE_TYPES.Identifier &&
82
+ DANGEROUS_CHILD_PROCESS_FUNCTIONS.has(specifier.imported.name)
83
+ ) {
84
+ dangerousLocalNames.set(specifier.local.name, specifier.imported.name);
85
+ } else if (
86
+ specifier.type === AST_NODE_TYPES.ImportNamespaceSpecifier ||
87
+ specifier.type === AST_NODE_TYPES.ImportDefaultSpecifier
88
+ ) {
89
+ namespaceNames.add(specifier.local.name);
90
+ }
91
+ }
92
+ },
93
+
94
+ VariableDeclarator(node) {
95
+ if (
96
+ node.init?.type !== AST_NODE_TYPES.CallExpression ||
97
+ !(isDirectRequireCall(node.init) || isRequireMemberCall(node.init)) ||
98
+ !isChildProcessModule(node.init.arguments[0] ?? null)
99
+ ) {
100
+ return;
101
+ }
102
+
103
+ if (node.id.type === AST_NODE_TYPES.ObjectPattern) {
104
+ recordDestructuredModule(node.id);
105
+ } else if (node.id.type === AST_NODE_TYPES.Identifier) {
106
+ namespaceNames.add(node.id.name);
107
+ }
108
+ },
109
+
110
+ NewExpression(node) {
111
+ if (node.callee.type === AST_NODE_TYPES.Identifier && node.callee.name === 'Function') {
112
+ context.report({ node, messageId: 'noFunctionConstructor' });
113
+ }
114
+ },
115
+
116
+ CallExpression(node) {
117
+ const { callee } = node;
118
+
119
+ if (callee.type === AST_NODE_TYPES.Identifier) {
120
+ if (callee.name === 'eval') {
121
+ context.report({ node, messageId: 'noEval' });
122
+ return;
123
+ }
124
+
125
+ if (callee.name === 'Function') {
126
+ context.report({ node, messageId: 'noFunctionConstructor' });
127
+ return;
128
+ }
129
+
130
+ const originalName = dangerousLocalNames.get(callee.name);
131
+ if (originalName) {
132
+ context.report({ node, messageId: 'noChildProcess', data: { name: originalName } });
133
+ }
134
+
135
+ return;
136
+ }
137
+
138
+ if (
139
+ callee.type === AST_NODE_TYPES.MemberExpression &&
140
+ !callee.computed &&
141
+ callee.object.type === AST_NODE_TYPES.Identifier &&
142
+ namespaceNames.has(callee.object.name) &&
143
+ callee.property.type === AST_NODE_TYPES.Identifier &&
144
+ DANGEROUS_CHILD_PROCESS_FUNCTIONS.has(callee.property.name)
145
+ ) {
146
+ context.report({
147
+ node,
148
+ messageId: 'noChildProcess',
149
+ data: { name: callee.property.name },
150
+ });
151
+ }
152
+ },
153
+ };
154
+ },
155
+ });
@@ -0,0 +1,157 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+
3
+ import { NoEmojiInOptionsRule } from './no-emoji-in-options.js';
4
+
5
+ const ruleTester = new RuleTester();
6
+
7
+ function createNodeCode(body: string): string {
8
+ return `
9
+ import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
10
+
11
+ export class TestNode implements INodeType {
12
+ description: INodeTypeDescription = {
13
+ ${body}
14
+ };
15
+ }
16
+ `;
17
+ }
18
+
19
+ ruleTester.run('no-emoji-in-options', NoEmojiInOptionsRule, {
20
+ valid: [
21
+ {
22
+ name: 'class that does not implement INodeType',
23
+ filename: '/tmp/TestNode.node.ts',
24
+ code: `
25
+ export class NotANode {
26
+ description = { displayName: '🚀 Rocket' };
27
+ }
28
+ `,
29
+ },
30
+ {
31
+ name: 'non .node.ts file is ignored',
32
+ filename: '/tmp/helper.ts',
33
+ code: `
34
+ export class TestNode {
35
+ description = { displayName: '🚀 Rocket' };
36
+ }
37
+ `,
38
+ },
39
+ {
40
+ name: 'node and option labels without emoji',
41
+ filename: '/tmp/TestNode.node.ts',
42
+ code: createNodeCode(`
43
+ displayName: 'Test Node',
44
+ name: 'testNode',
45
+ defaults: { name: 'Test Node' },
46
+ properties: [
47
+ {
48
+ displayName: 'Operation',
49
+ name: 'operation',
50
+ type: 'options',
51
+ options: [
52
+ { name: 'Create', value: 'create' },
53
+ { name: 'Delete', value: 'delete' },
54
+ ],
55
+ default: 'create',
56
+ },
57
+ ],
58
+ `),
59
+ },
60
+ {
61
+ name: 'accented and non-latin characters are allowed',
62
+ filename: '/tmp/TestNode.node.ts',
63
+ code: createNodeCode(`
64
+ displayName: 'Créer un café',
65
+ name: 'testNode',
66
+ properties: [
67
+ {
68
+ displayName: '日本語',
69
+ name: 'field',
70
+ type: 'string',
71
+ default: '',
72
+ },
73
+ ],
74
+ `),
75
+ },
76
+ ],
77
+ invalid: [
78
+ {
79
+ name: 'emoji in node displayName',
80
+ filename: '/tmp/TestNode.node.ts',
81
+ code: createNodeCode(`
82
+ displayName: '🚀 Rocket Node',
83
+ name: 'testNode',
84
+ properties: [],
85
+ `),
86
+ errors: [{ messageId: 'emojiInOption', data: { key: 'displayName', emoji: '🚀' } }],
87
+ },
88
+ {
89
+ name: 'emoji in option name within options array',
90
+ filename: '/tmp/TestNode.node.ts',
91
+ code: createNodeCode(`
92
+ displayName: 'Test Node',
93
+ name: 'testNode',
94
+ properties: [
95
+ {
96
+ displayName: 'Operation',
97
+ name: 'operation',
98
+ type: 'options',
99
+ options: [
100
+ { name: '✅ Create', value: 'create' },
101
+ { name: 'Delete', value: 'delete' },
102
+ ],
103
+ default: 'create',
104
+ },
105
+ ],
106
+ `),
107
+ errors: [{ messageId: 'emojiInOption', data: { key: 'name', emoji: '✅' } }],
108
+ },
109
+ {
110
+ name: 'emoji in property displayName',
111
+ filename: '/tmp/TestNode.node.ts',
112
+ code: createNodeCode(`
113
+ displayName: 'Test Node',
114
+ name: 'testNode',
115
+ properties: [
116
+ {
117
+ displayName: 'First Name 🙂',
118
+ name: 'firstName',
119
+ type: 'string',
120
+ default: '',
121
+ },
122
+ ],
123
+ `),
124
+ errors: [{ messageId: 'emojiInOption', data: { key: 'displayName', emoji: '🙂' } }],
125
+ },
126
+ {
127
+ name: 'flag emoji built from regional indicators',
128
+ filename: '/tmp/TestNode.node.ts',
129
+ code: createNodeCode(`
130
+ displayName: 'Region 🇺🇸',
131
+ name: 'testNode',
132
+ properties: [],
133
+ `),
134
+ errors: [{ messageId: 'emojiInOption', data: { key: 'displayName', emoji: '🇺 🇸' } }],
135
+ },
136
+ {
137
+ name: 'multiple emoji values reported separately',
138
+ filename: '/tmp/TestNode.node.ts',
139
+ code: createNodeCode(`
140
+ displayName: '🚀 Node',
141
+ name: 'testNode',
142
+ properties: [
143
+ {
144
+ displayName: 'Field 🎉',
145
+ name: 'field',
146
+ type: 'string',
147
+ default: '',
148
+ },
149
+ ],
150
+ `),
151
+ errors: [
152
+ { messageId: 'emojiInOption', data: { key: 'displayName', emoji: '🚀' } },
153
+ { messageId: 'emojiInOption', data: { key: 'displayName', emoji: '🎉' } },
154
+ ],
155
+ },
156
+ ],
157
+ });
@@ -0,0 +1,105 @@
1
+ import type { TSESTree } from '@typescript-eslint/utils';
2
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
3
+
4
+ import {
5
+ isNodeTypeClass,
6
+ findClassProperty,
7
+ getStringLiteralValue,
8
+ isFileType,
9
+ createRule,
10
+ } from '../utils/index.js';
11
+
12
+ /**
13
+ * Matches emoji characters: pictographs (😀, 🚀, ✉️, etc.) and regional
14
+ * indicator symbols that compose flag emoji (🇺🇸).
15
+ */
16
+ const EMOJI_REGEX = /(\p{Extended_Pictographic}|\p{Regional_Indicator})/gu;
17
+
18
+ /** Keys whose string values are surfaced to users as labels. */
19
+ const LABEL_KEYS = new Set(['name', 'displayName']);
20
+
21
+ export const NoEmojiInOptionsRule = createRule({
22
+ name: 'no-emoji-in-options',
23
+ meta: {
24
+ type: 'problem',
25
+ docs: {
26
+ description: 'Disallow emoji characters in node option name and displayName values',
27
+ },
28
+ messages: {
29
+ emojiInOption: 'Emoji characters are not allowed in "{{ key }}" values. Found: {{ emoji }}.',
30
+ },
31
+ schema: [],
32
+ },
33
+ defaultOptions: [],
34
+ create(context) {
35
+ if (!isFileType(context.filename, '.node.ts')) {
36
+ return {};
37
+ }
38
+
39
+ const checkLabelValue = (key: string, valueNode: TSESTree.Node): void => {
40
+ const value = getStringLiteralValue(valueNode);
41
+ if (value === null) {
42
+ return;
43
+ }
44
+
45
+ const matches = value.match(EMOJI_REGEX);
46
+ if (!matches) {
47
+ return;
48
+ }
49
+
50
+ context.report({
51
+ node: valueNode,
52
+ messageId: 'emojiInOption',
53
+ data: {
54
+ key,
55
+ emoji: [...new Set(matches)].join(' '),
56
+ },
57
+ });
58
+ };
59
+
60
+ const traverse = (node: TSESTree.Node): void => {
61
+ if (node.type === AST_NODE_TYPES.ObjectExpression) {
62
+ for (const property of node.properties) {
63
+ if (
64
+ property.type === AST_NODE_TYPES.Property &&
65
+ property.key.type === AST_NODE_TYPES.Identifier &&
66
+ LABEL_KEYS.has(property.key.name)
67
+ ) {
68
+ checkLabelValue(property.key.name, property.value);
69
+ }
70
+ }
71
+ }
72
+
73
+ for (const key in node) {
74
+ if (key === 'parent') {
75
+ continue;
76
+ }
77
+ const child = node[key as keyof TSESTree.Node] as unknown;
78
+ if (Array.isArray(child)) {
79
+ for (const item of child) {
80
+ if (item && typeof item === 'object' && 'type' in item) {
81
+ traverse(item as TSESTree.Node);
82
+ }
83
+ }
84
+ } else if (child && typeof child === 'object' && 'type' in child) {
85
+ traverse(child as TSESTree.Node);
86
+ }
87
+ }
88
+ };
89
+
90
+ return {
91
+ ClassDeclaration(node) {
92
+ if (!isNodeTypeClass(node)) {
93
+ return;
94
+ }
95
+
96
+ const descriptionProperty = findClassProperty(node, 'description');
97
+ if (!descriptionProperty?.value) {
98
+ return;
99
+ }
100
+
101
+ traverse(descriptionProperty.value);
102
+ },
103
+ };
104
+ },
105
+ });
@@ -0,0 +1,115 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+
3
+ import { NodeFilenameAgainstConventionRule } from './node-filename-against-convention.js';
4
+
5
+ const ruleTester = new RuleTester();
6
+
7
+ function createNodeCode(name: string): string {
8
+ return `
9
+ import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
10
+
11
+ export class TestNode implements INodeType {
12
+ description: INodeTypeDescription = {
13
+ displayName: 'Test Node',
14
+ name: '${name}',
15
+ group: ['input'],
16
+ version: 1,
17
+ description: 'A test node',
18
+ defaults: { name: 'Test Node' },
19
+ inputs: [],
20
+ outputs: [],
21
+ properties: [],
22
+ };
23
+ }`;
24
+ }
25
+
26
+ function createNodeCodeWithoutName(): string {
27
+ return `
28
+ import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
29
+
30
+ export class TestNode implements INodeType {
31
+ description: INodeTypeDescription = {
32
+ displayName: 'Test Node',
33
+ group: ['input'],
34
+ version: 1,
35
+ description: 'A test node',
36
+ defaults: { name: 'Test Node' },
37
+ inputs: [],
38
+ outputs: [],
39
+ properties: [],
40
+ };
41
+ }`;
42
+ }
43
+
44
+ function createRegularClass(): string {
45
+ return `
46
+ export class SomeHelper {
47
+ name = 'github';
48
+ }`;
49
+ }
50
+
51
+ ruleTester.run('node-filename-against-convention', NodeFilenameAgainstConventionRule, {
52
+ valid: [
53
+ {
54
+ name: 'filename matches PascalCased description.name',
55
+ filename: '/tmp/Github.node.ts',
56
+ code: createNodeCode('github'),
57
+ },
58
+ {
59
+ name: 'filename with version suffix is accepted',
60
+ filename: '/tmp/GithubV2.node.ts',
61
+ code: createNodeCode('github'),
62
+ },
63
+ {
64
+ name: 'filename with multi-digit version suffix is accepted',
65
+ filename: '/tmp/GithubV10.node.ts',
66
+ code: createNodeCode('github'),
67
+ },
68
+ {
69
+ name: 'multi-word camelCase name maps to PascalCase filename',
70
+ filename: '/tmp/GoogleSheets.node.ts',
71
+ code: createNodeCode('googleSheets'),
72
+ },
73
+ {
74
+ name: 'class not implementing INodeType is ignored',
75
+ filename: '/tmp/Github.node.ts',
76
+ code: createRegularClass(),
77
+ },
78
+ {
79
+ name: 'non-.node.ts file is ignored',
80
+ filename: '/tmp/Github.ts',
81
+ code: createNodeCode('mismatch'),
82
+ },
83
+ {
84
+ name: 'missing description.name is ignored',
85
+ filename: '/tmp/Github.node.ts',
86
+ code: createNodeCodeWithoutName(),
87
+ },
88
+ ],
89
+ invalid: [
90
+ {
91
+ name: 'filename does not match description.name',
92
+ filename: '/tmp/Github.node.ts',
93
+ code: createNodeCode('gitlab'),
94
+ errors: [{ messageId: 'renameFile', data: { expected: 'Gitlab.node.ts' } }],
95
+ },
96
+ {
97
+ name: 'filename is lowercased',
98
+ filename: '/tmp/github.node.ts',
99
+ code: createNodeCode('github'),
100
+ errors: [{ messageId: 'renameFile', data: { expected: 'Github.node.ts' } }],
101
+ },
102
+ {
103
+ name: 'filename has wrong internal casing',
104
+ filename: '/tmp/GitHub.node.ts',
105
+ code: createNodeCode('github'),
106
+ errors: [{ messageId: 'renameFile', data: { expected: 'Github.node.ts' } }],
107
+ },
108
+ {
109
+ name: 'version suffix does not excuse a mismatched base name',
110
+ filename: '/tmp/GitlabV2.node.ts',
111
+ code: createNodeCode('github'),
112
+ errors: [{ messageId: 'renameFile', data: { expected: 'Github.node.ts' } }],
113
+ },
114
+ ],
115
+ });
@@ -0,0 +1,76 @@
1
+ import { TSESTree } from '@typescript-eslint/utils';
2
+ import * as path from 'node:path';
3
+
4
+ import {
5
+ isNodeTypeClass,
6
+ findClassProperty,
7
+ findObjectProperty,
8
+ getStringLiteralValue,
9
+ isFileType,
10
+ createRule,
11
+ } from '../utils/index.js';
12
+
13
+ /**
14
+ * Converts a `description.name` to the expected node file basename by
15
+ * upper-casing the first character. The source of truth is `description.name`,
16
+ * not the class name. Example: `github` -> `Github`.
17
+ */
18
+ function toExpectedBaseName(name: string): string {
19
+ return name.charAt(0).toUpperCase() + name.slice(1);
20
+ }
21
+
22
+ export const NodeFilenameAgainstConventionRule = createRule({
23
+ name: 'node-filename-against-convention',
24
+ meta: {
25
+ type: 'problem',
26
+ docs: {
27
+ description: 'Node filename must match the node `description.name`',
28
+ },
29
+ messages: {
30
+ renameFile: 'Node filename must match `description.name`. Rename file to "{{expected}}".',
31
+ },
32
+ schema: [],
33
+ },
34
+ defaultOptions: [],
35
+ create(context) {
36
+ if (!isFileType(context.filename, '.node.ts')) {
37
+ return {};
38
+ }
39
+
40
+ return {
41
+ ClassDeclaration(node) {
42
+ if (!isNodeTypeClass(node)) {
43
+ return;
44
+ }
45
+
46
+ const descriptionProperty = findClassProperty(node, 'description');
47
+ if (descriptionProperty?.value?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
48
+ return;
49
+ }
50
+
51
+ const nameProperty = findObjectProperty(descriptionProperty.value, 'name');
52
+ if (!nameProperty) {
53
+ return;
54
+ }
55
+
56
+ const name = getStringLiteralValue(nameProperty.value);
57
+ if (!name) {
58
+ return;
59
+ }
60
+
61
+ const expectedBaseName = toExpectedBaseName(name);
62
+ // Strip the `.node.ts` extension and any trailing `V<digits>` version
63
+ // suffix so versioned files (e.g. `GithubV2.node.ts`) are accepted.
64
+ const actualBaseName = path.basename(context.filename, '.node.ts').replace(/V\d+$/, '');
65
+
66
+ if (actualBaseName !== expectedBaseName) {
67
+ context.report({
68
+ node: nameProperty.value,
69
+ messageId: 'renameFile',
70
+ data: { expected: `${expectedBaseName}.node.ts` },
71
+ });
72
+ }
73
+ },
74
+ };
75
+ },
76
+ });
@@ -0,0 +1,87 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+ import { afterEach, vi } from 'vitest';
3
+
4
+ import { NodeRegistrationCompleteRule } from './node-registration-complete.js';
5
+ import * as fileUtils from '../utils/file-utils.js';
6
+
7
+ vi.mock('../utils/file-utils.js', async () => {
8
+ const actual = await vi.importActual('../utils/file-utils.js');
9
+ return {
10
+ ...actual,
11
+ findNodeSourceFilesOnDisk: vi.fn(),
12
+ readPackageJsonNodes: vi.fn(),
13
+ };
14
+ });
15
+
16
+ const mockFindNodeSourceFilesOnDisk = vi.mocked(fileUtils.findNodeSourceFilesOnDisk);
17
+ const mockReadPackageJsonNodes = vi.mocked(fileUtils.readPackageJsonNodes);
18
+
19
+ const packageJsonPath = '/tmp/package.json';
20
+ const fooNode = '/tmp/nodes/Foo/Foo.node.ts';
21
+ const barNode = '/tmp/nodes/Bar/Bar.node.ts';
22
+
23
+ const ruleTester = new RuleTester();
24
+
25
+ function setup(onDisk: string[], registered: string[]): void {
26
+ mockFindNodeSourceFilesOnDisk.mockReturnValue(onDisk);
27
+ mockReadPackageJsonNodes.mockReturnValue(registered);
28
+ }
29
+
30
+ afterEach(() => {
31
+ vi.clearAllMocks();
32
+ });
33
+
34
+ // Default: both node files exist on disk and both are registered.
35
+ setup([fooNode, barNode], [fooNode, barNode]);
36
+
37
+ ruleTester.run('node-registration-complete', NodeRegistrationCompleteRule, {
38
+ valid: [
39
+ {
40
+ name: 'all node files are registered',
41
+ filename: packageJsonPath,
42
+ code: '{ "name": "n8n-nodes-example", "n8n": { "nodes": ["dist/nodes/Foo/Foo.node.js", "dist/nodes/Bar/Bar.node.js"] } }',
43
+ },
44
+ {
45
+ name: 'non-package.json file is ignored',
46
+ filename: 'some-config.json',
47
+ code: '{ "name": "n8n-nodes-example" }',
48
+ },
49
+ ],
50
+ invalid: [
51
+ {
52
+ name: 'one node file is not registered',
53
+ filename: packageJsonPath,
54
+ code: '{ "name": "n8n-nodes-example", "n8n": { "nodes": ["dist/nodes/Foo/Foo.node.js"] } }',
55
+ before() {
56
+ setup([fooNode, barNode], [fooNode]);
57
+ },
58
+ errors: [
59
+ {
60
+ messageId: 'nodeNotRegistered',
61
+ data: { nodeFile: 'nodes/Bar/Bar.node.ts' },
62
+ },
63
+ ],
64
+ },
65
+ {
66
+ name: 'multiple node files are not registered',
67
+ filename: packageJsonPath,
68
+ code: '{ "name": "n8n-nodes-example", "n8n": { "nodes": [] } }',
69
+ before() {
70
+ setup([fooNode, barNode], []);
71
+ },
72
+ errors: [
73
+ { messageId: 'nodeNotRegistered', data: { nodeFile: 'nodes/Foo/Foo.node.ts' } },
74
+ { messageId: 'nodeNotRegistered', data: { nodeFile: 'nodes/Bar/Bar.node.ts' } },
75
+ ],
76
+ },
77
+ {
78
+ name: 'node files exist on disk but there is no n8n object',
79
+ filename: packageJsonPath,
80
+ code: '{ "name": "n8n-nodes-example" }',
81
+ before() {
82
+ setup([fooNode], []);
83
+ },
84
+ errors: [{ messageId: 'nodeNotRegistered', data: { nodeFile: 'nodes/Foo/Foo.node.ts' } }],
85
+ },
86
+ ],
87
+ });