@n8n/eslint-plugin-community-nodes 0.33.0 → 0.35.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/README.md CHANGED
@@ -88,6 +88,7 @@ export default [
88
88
  | [valid-author](docs/rules/valid-author.md) | Require a non-empty author name and email in package.json | ✅ ☑️ | | | | |
89
89
  | [valid-credential-references](docs/rules/valid-credential-references.md) | Ensure credentials referenced in node descriptions exist as credential classes in the package | ✅ ☑️ | | | 💡 | |
90
90
  | [valid-description](docs/rules/valid-description.md) | Require a non-empty "description" field in community node package.json | ✅ ☑️ | | | | |
91
+ | [valid-node-categories](docs/rules/valid-node-categories.md) | Require supported community node categories in .node.json codex files | ✅ ☑️ | | | | |
91
92
  | [valid-peer-dependencies](docs/rules/valid-peer-dependencies.md) | Require community node package.json peerDependencies to contain only "n8n-workflow": "*" (and optionally "@n8n/ai-node-sdk") | ✅ ☑️ | | 🔧 | | |
92
93
  | [webhook-lifecycle-complete](docs/rules/webhook-lifecycle-complete.md) | Require webhook trigger nodes to implement the complete webhookMethods lifecycle (checkExists, create, delete) | ✅ ☑️ | | | | |
93
94
 
package/dist/plugin.d.ts CHANGED
@@ -55,6 +55,7 @@ declare const configs: {
55
55
  '@n8n/community-nodes/valid-author': "error";
56
56
  '@n8n/community-nodes/valid-credential-references': "error";
57
57
  '@n8n/community-nodes/valid-description': "error";
58
+ '@n8n/community-nodes/valid-node-categories': "error";
58
59
  '@n8n/community-nodes/valid-peer-dependencies': "error";
59
60
  '@n8n/community-nodes/webhook-lifecycle-complete': "error";
60
61
  };
@@ -111,6 +112,7 @@ declare const configs: {
111
112
  '@n8n/community-nodes/valid-author': "error";
112
113
  '@n8n/community-nodes/valid-credential-references': "error";
113
114
  '@n8n/community-nodes/valid-description': "error";
115
+ '@n8n/community-nodes/valid-node-categories': "error";
114
116
  '@n8n/community-nodes/valid-peer-dependencies': "error";
115
117
  '@n8n/community-nodes/webhook-lifecycle-complete': "error";
116
118
  };
@@ -178,6 +180,7 @@ declare const pluginWithConfigs: {
178
180
  '@n8n/community-nodes/valid-author': "error";
179
181
  '@n8n/community-nodes/valid-credential-references': "error";
180
182
  '@n8n/community-nodes/valid-description': "error";
183
+ '@n8n/community-nodes/valid-node-categories': "error";
181
184
  '@n8n/community-nodes/valid-peer-dependencies': "error";
182
185
  '@n8n/community-nodes/webhook-lifecycle-complete': "error";
183
186
  };
@@ -234,6 +237,7 @@ declare const pluginWithConfigs: {
234
237
  '@n8n/community-nodes/valid-author': "error";
235
238
  '@n8n/community-nodes/valid-credential-references': "error";
236
239
  '@n8n/community-nodes/valid-description': "error";
240
+ '@n8n/community-nodes/valid-node-categories': "error";
237
241
  '@n8n/community-nodes/valid-peer-dependencies': "error";
238
242
  '@n8n/community-nodes/webhook-lifecycle-complete': "error";
239
243
  };
@@ -302,6 +306,7 @@ declare const n8nCommunityNodesPlugin: {
302
306
  '@n8n/community-nodes/valid-author': "error";
303
307
  '@n8n/community-nodes/valid-credential-references': "error";
304
308
  '@n8n/community-nodes/valid-description': "error";
309
+ '@n8n/community-nodes/valid-node-categories': "error";
305
310
  '@n8n/community-nodes/valid-peer-dependencies': "error";
306
311
  '@n8n/community-nodes/webhook-lifecycle-complete': "error";
307
312
  };
@@ -358,6 +363,7 @@ declare const n8nCommunityNodesPlugin: {
358
363
  '@n8n/community-nodes/valid-author': "error";
359
364
  '@n8n/community-nodes/valid-credential-references': "error";
360
365
  '@n8n/community-nodes/valid-description': "error";
366
+ '@n8n/community-nodes/valid-node-categories': "error";
361
367
  '@n8n/community-nodes/valid-peer-dependencies': "error";
362
368
  '@n8n/community-nodes/webhook-lifecycle-complete': "error";
363
369
  };
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAU,MAAM,QAAQ,CAAC;AAG7C,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAYzC,QAAA,MAAM,OAAO;;;;YAIV,sBAAsB;;oBAZvB,IAAI;oBACJ,OAAO;oBACP,SAAS;;uBAGM,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YA0DpC,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8CgB,CAAC;AAE1C,QAAA,MAAM,iBAAiB;;QA/GrB,IAAI;QACJ,OAAO;QACP,SAAS;;WAGM,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC;;;;;gBAOpC,sBAAsB;;wBAZvB,IAAI;wBACJ,OAAO;wBACP,SAAS;;2BAGM,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBA0DpC,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgD+C,CAAC;AAEzE,QAAA,MAAM,uBAAuB;;QAjH3B,IAAI;QACJ,OAAO;QACP,SAAS;;WAGM,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC;;;;;gBAOpC,sBAAsB;;wBAZvB,IAAI;wBACJ,OAAO;wBACP,SAAS;;2BAGM,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBA0DpC,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDwB,CAAC;eACnC,iBAAiB;AAChC,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,uBAAuB,EAAE,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAU,MAAM,QAAQ,CAAC;AAG7C,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAYzC,QAAA,MAAM,OAAO;;;;YAIV,sBAAsB;;oBAZvB,IAAI;oBACJ,OAAO;oBACP,SAAS;;uBAGM,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YA2DpC,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+CgB,CAAC;AAE1C,QAAA,MAAM,iBAAiB;;QAjHrB,IAAI;QACJ,OAAO;QACP,SAAS;;WAGM,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC;;;;;gBAOpC,sBAAsB;;wBAZvB,IAAI;wBACJ,OAAO;wBACP,SAAS;;2BAGM,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBA2DpC,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiD+C,CAAC;AAEzE,QAAA,MAAM,uBAAuB;;QAnH3B,IAAI;QACJ,OAAO;QACP,SAAS;;WAGM,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC;;;;;gBAOpC,sBAAsB;;wBAZvB,IAAI;wBACJ,OAAO;wBACP,SAAS;;2BAGM,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBA2DpC,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmDwB,CAAC;eACnC,iBAAiB;AAChC,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,uBAAuB,EAAE,CAAC"}
package/dist/plugin.js CHANGED
@@ -57,6 +57,7 @@ const configs = {
57
57
  '@n8n/community-nodes/valid-author': 'error',
58
58
  '@n8n/community-nodes/valid-credential-references': 'error',
59
59
  '@n8n/community-nodes/valid-description': 'error',
60
+ '@n8n/community-nodes/valid-node-categories': 'error',
60
61
  '@n8n/community-nodes/valid-peer-dependencies': 'error',
61
62
  '@n8n/community-nodes/webhook-lifecycle-complete': 'error',
62
63
  },
@@ -106,6 +107,7 @@ const configs = {
106
107
  '@n8n/community-nodes/valid-author': 'error',
107
108
  '@n8n/community-nodes/valid-credential-references': 'error',
108
109
  '@n8n/community-nodes/valid-description': 'error',
110
+ '@n8n/community-nodes/valid-node-categories': 'error',
109
111
  '@n8n/community-nodes/valid-peer-dependencies': 'error',
110
112
  '@n8n/community-nodes/webhook-lifecycle-complete': 'error',
111
113
  },
@@ -130,6 +130,9 @@ export declare const rules: {
130
130
  'valid-description': import("@typescript-eslint/utils/ts-eslint").RuleModule<"emptyDescription" | "missingDescription", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
131
131
  name: string;
132
132
  };
133
+ 'valid-node-categories': import("@typescript-eslint/utils/ts-eslint").RuleModule<"invalidCategories" | "invalidCategory" | "invalidCategoryType" | "marketingCategory", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
134
+ name: string;
135
+ };
133
136
  'valid-peer-dependencies': import("@typescript-eslint/utils/ts-eslint").RuleModule<"forbiddenPeerDependency" | "invalidPeerDependenciesType" | "missingN8nWorkflow" | "missingPeerDependencies" | "pinnedN8nWorkflow", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
134
137
  name: string;
135
138
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/rules/index.ts"],"names":[],"mappings":"AA+CA,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6CuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/rules/index.ts"],"names":[],"mappings":"AAgDA,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8CuB,CAAC"}
@@ -40,6 +40,7 @@ import { TriggerNodeConventionsRule } from './trigger-node-conventions.js';
40
40
  import { ValidAuthorRule } from './valid-author.js';
41
41
  import { ValidCredentialReferencesRule } from './valid-credential-references.js';
42
42
  import { ValidDescriptionRule } from './valid-description.js';
43
+ import { ValidNodeCategoriesRule } from './valid-node-categories.js';
43
44
  import { ValidPeerDependenciesRule } from './valid-peer-dependencies.js';
44
45
  import { WebhookLifecycleCompleteRule } from './webhook-lifecycle-complete.js';
45
46
  export const rules = {
@@ -85,6 +86,7 @@ export const rules = {
85
86
  'valid-author': ValidAuthorRule,
86
87
  'valid-credential-references': ValidCredentialReferencesRule,
87
88
  'valid-description': ValidDescriptionRule,
89
+ 'valid-node-categories': ValidNodeCategoriesRule,
88
90
  'valid-peer-dependencies': ValidPeerDependenciesRule,
89
91
  'webhook-lifecycle-complete': WebhookLifecycleCompleteRule,
90
92
  };
@@ -1 +1 @@
1
- {"version":3,"file":"no-silent-error-swallowing.d.ts","sourceRoot":"","sources":["../../src/rules/no-silent-error-swallowing.ts"],"names":[],"mappings":"AA6CA,eAAO,MAAM,2BAA2B;;CAiFtC,CAAC"}
1
+ {"version":3,"file":"no-silent-error-swallowing.d.ts","sourceRoot":"","sources":["../../src/rules/no-silent-error-swallowing.ts"],"names":[],"mappings":"AAsCA,eAAO,MAAM,2BAA2B;;CAiFtC,CAAC"}
@@ -1,16 +1,6 @@
1
1
  import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2
- import { createRule, findClassProperty, isNodeTypeClass, WEBHOOK_LIFECYCLE_METHODS, } from '../utils/index.js';
2
+ import { createRule, findClassProperty, getPropertyKeyName, isNodeTypeClass, WEBHOOK_LIFECYCLE_METHODS, } from '../utils/index.js';
3
3
  const LIFECYCLE_METHODS = WEBHOOK_LIFECYCLE_METHODS;
4
- /** Returns the static name of a non-computed object property key, or null. */
5
- function getPropertyName(property) {
6
- if (property.computed)
7
- return null;
8
- if (property.key.type === AST_NODE_TYPES.Identifier)
9
- return property.key.name;
10
- if (property.key.type === AST_NODE_TYPES.Literal)
11
- return String(property.key.value);
12
- return null;
13
- }
14
4
  function isFunctionNode(node) {
15
5
  return (node.type === AST_NODE_TYPES.FunctionDeclaration ||
16
6
  node.type === AST_NODE_TYPES.FunctionExpression ||
@@ -70,7 +60,7 @@ export const NoSilentErrorSwallowingRule = createRule({
70
60
  for (const methodProperty of groupProperty.value.properties) {
71
61
  if (methodProperty.type !== AST_NODE_TYPES.Property)
72
62
  continue;
73
- const methodName = getPropertyName(methodProperty);
63
+ const methodName = getPropertyKeyName(methodProperty);
74
64
  if (methodName === null || !LIFECYCLE_METHODS.includes(methodName))
75
65
  continue;
76
66
  const fn = methodProperty.value;
@@ -0,0 +1,4 @@
1
+ export declare const ValidNodeCategoriesRule: import("@typescript-eslint/utils/ts-eslint").RuleModule<"invalidCategories" | "invalidCategory" | "invalidCategoryType" | "marketingCategory", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
+ name: string;
3
+ };
4
+ //# sourceMappingURL=valid-node-categories.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"valid-node-categories.d.ts","sourceRoot":"","sources":["../../src/rules/valid-node-categories.ts"],"names":[],"mappings":"AAkBA,eAAO,MAAM,uBAAuB;;CAqDlC,CAAC"}
@@ -0,0 +1,68 @@
1
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2
+ import { createRule, findJsonProperty, getTopLevelObjectInJson } from '../utils/index.js';
3
+ const validCategories = new Set([
4
+ 'Data & Storage',
5
+ 'Finance & Accounting',
6
+ 'Marketing & Content',
7
+ 'Productivity',
8
+ 'Miscellaneous',
9
+ 'Sales',
10
+ 'Development',
11
+ 'Analytics',
12
+ 'Communication',
13
+ 'Utility',
14
+ ]);
15
+ export const ValidNodeCategoriesRule = createRule({
16
+ name: 'valid-node-categories',
17
+ meta: {
18
+ type: 'problem',
19
+ docs: {
20
+ description: 'Require supported community node categories in .node.json codex files',
21
+ },
22
+ messages: {
23
+ invalidCategories: '"categories" must be a non-empty array of community node categories.',
24
+ invalidCategoryType: 'Each category must be a string from the community node category list.',
25
+ invalidCategory: '"{{ category }}" is not a supported community node category. See https://docs.n8n.io/connect/create-nodes/build-your-node/reference/codex-files/#node-categories for the allowed values.',
26
+ marketingCategory: '"Marketing" is not a supported category. Use "Marketing & Content".',
27
+ },
28
+ schema: [],
29
+ },
30
+ defaultOptions: [],
31
+ create(context) {
32
+ if (!context.filename.endsWith('.node.json'))
33
+ return {};
34
+ return {
35
+ ObjectExpression(node) {
36
+ const root = getTopLevelObjectInJson(node);
37
+ if (!root)
38
+ return;
39
+ const categories = findJsonProperty(root, 'categories');
40
+ if (!categories)
41
+ return;
42
+ if (categories.value.type !== AST_NODE_TYPES.ArrayExpression ||
43
+ categories.value.elements.length === 0) {
44
+ context.report({ node: categories.value, messageId: 'invalidCategories' });
45
+ return;
46
+ }
47
+ for (const category of categories.value.elements) {
48
+ if (!category)
49
+ continue;
50
+ if (category.type !== AST_NODE_TYPES.Literal || typeof category.value !== 'string') {
51
+ context.report({ node: category, messageId: 'invalidCategoryType' });
52
+ }
53
+ else if (category.value === 'Marketing') {
54
+ context.report({ node: category, messageId: 'marketingCategory' });
55
+ }
56
+ else if (!validCategories.has(category.value)) {
57
+ context.report({
58
+ node: category,
59
+ messageId: 'invalidCategory',
60
+ data: { category: category.value },
61
+ });
62
+ }
63
+ }
64
+ },
65
+ };
66
+ },
67
+ });
68
+ //# sourceMappingURL=valid-node-categories.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"webhook-lifecycle-complete.d.ts","sourceRoot":"","sources":["../../src/rules/webhook-lifecycle-complete.ts"],"names":[],"mappings":"AA8CA,eAAO,MAAM,4BAA4B;;CAkFvC,CAAC"}
1
+ {"version":3,"file":"webhook-lifecycle-complete.d.ts","sourceRoot":"","sources":["../../src/rules/webhook-lifecycle-complete.ts"],"names":[],"mappings":"AA0CA,eAAO,MAAM,4BAA4B;;CA6EvC,CAAC"}
@@ -1,5 +1,5 @@
1
1
  import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2
- import { createRule, findClassProperty, findObjectProperty, isNodeTypeClass, WEBHOOK_LIFECYCLE_METHODS, } from '../utils/index.js';
2
+ import { createRule, findClassProperty, findObjectProperty, getPropertyKeyName, isNodeTypeClass, WEBHOOK_LIFECYCLE_METHODS, } from '../utils/index.js';
3
3
  /**
4
4
  * Returns true if the description declares webhook endpoints, indicating the
5
5
  * node is a webhook-based trigger that needs a complete lifecycle.
@@ -17,11 +17,7 @@ function hasWebhooksDeclared(descriptionValue) {
17
17
  function isMethodProperty(property, name) {
18
18
  if (property.type !== AST_NODE_TYPES.Property)
19
19
  return false;
20
- if (property.computed)
21
- return false;
22
- const keyMatches = (property.key.type === AST_NODE_TYPES.Identifier && property.key.name === name) ||
23
- (property.key.type === AST_NODE_TYPES.Literal && property.key.value === name);
24
- if (!keyMatches)
20
+ if (getPropertyKeyName(property) !== name)
25
21
  return false;
26
22
  return (property.value.type === AST_NODE_TYPES.FunctionExpression ||
27
23
  property.value.type === AST_NODE_TYPES.ArrowFunctionExpression);
@@ -81,11 +77,7 @@ export const WebhookLifecycleCompleteRule = createRule({
81
77
  continue;
82
78
  if (groupProperty.value.type !== AST_NODE_TYPES.ObjectExpression)
83
79
  continue;
84
- const groupName = groupProperty.key.type === AST_NODE_TYPES.Identifier
85
- ? groupProperty.key.name
86
- : groupProperty.key.type === AST_NODE_TYPES.Literal
87
- ? String(groupProperty.key.value)
88
- : 'default';
80
+ const groupName = getPropertyKeyName(groupProperty) ?? 'default';
89
81
  const missing = findMissingMethods(groupProperty.value);
90
82
  if (missing.length === 0)
91
83
  continue;
@@ -9,6 +9,12 @@ export declare function isTriggerNodeClass(node: TSESTree.ClassDeclaration): boo
9
9
  * `group` values.
10
10
  */
11
11
  export declare function isTriggerNode(node: TSESTree.ClassDeclaration, descriptionValue: TSESTree.ObjectExpression): boolean;
12
+ /**
13
+ * Returns the name a property key stands for, or null when it is only known at
14
+ * runtime. `delete`, `'delete'` and `['delete']` all name the same property, so
15
+ * how the key is written must not decide whether a rule sees it.
16
+ */
17
+ export declare function getPropertyKeyName(property: TSESTree.Property | TSESTree.PropertyDefinition | TSESTree.MethodDefinition): string | null;
12
18
  export declare function findClassProperty(node: TSESTree.ClassDeclaration, propertyName: string): TSESTree.PropertyDefinition | null;
13
19
  /**
14
20
  * Returns the `description` object literal of a node class. Versioned nodes
@@ -1 +1 @@
1
- {"version":3,"file":"ast-utils.d.ts","sourceRoot":"","sources":["../../src/utils/ast-utils.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ast-utils.d.ts","sourceRoot":"","sources":["../../src/utils/ast-utils.ts"],"names":[],"mappings":"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"}
@@ -33,10 +33,29 @@ function hasTriggerGroup(descriptionValue) {
33
33
  export function isTriggerNode(node, descriptionValue) {
34
34
  return hasTriggerGroup(descriptionValue) || isTriggerNodeClass(node);
35
35
  }
36
+ /**
37
+ * Returns the name a property key stands for, or null when it is only known at
38
+ * runtime. `delete`, `'delete'` and `['delete']` all name the same property, so
39
+ * how the key is written must not decide whether a rule sees it.
40
+ */
41
+ export function getPropertyKeyName(property) {
42
+ const { key } = property;
43
+ if (key.type === AST_NODE_TYPES.Literal) {
44
+ return key.value === null ? null : String(key.value);
45
+ }
46
+ // A computed key still names one property when it is a template without
47
+ // substitutions; written plainly, an identifier is the name itself.
48
+ if (key.type === AST_NODE_TYPES.TemplateLiteral) {
49
+ return key.expressions.length === 0 ? (key.quasis[0]?.value.cooked ?? null) : null;
50
+ }
51
+ if (key.type === AST_NODE_TYPES.Identifier) {
52
+ return property.computed ? null : key.name;
53
+ }
54
+ return null;
55
+ }
36
56
  export function findClassProperty(node, propertyName) {
37
57
  const property = node.body.body.find((member) => member.type === AST_NODE_TYPES.PropertyDefinition &&
38
- member.key?.type === AST_NODE_TYPES.Identifier &&
39
- member.key.name === propertyName);
58
+ getPropertyKeyName(member) === propertyName);
40
59
  return property?.type === AST_NODE_TYPES.PropertyDefinition ? property : null;
41
60
  }
42
61
  /**
@@ -90,9 +109,7 @@ export function findNodeDescriptionObject(node) {
90
109
  return findConstructorAssignedDescription(node);
91
110
  }
92
111
  export function findObjectProperty(obj, propertyName) {
93
- const property = obj.properties.find((prop) => prop.type === AST_NODE_TYPES.Property &&
94
- prop.key.type === AST_NODE_TYPES.Identifier &&
95
- prop.key.name === propertyName);
112
+ const property = obj.properties.find((prop) => prop.type === AST_NODE_TYPES.Property && getPropertyKeyName(prop) === propertyName);
96
113
  return property?.type === AST_NODE_TYPES.Property ? property : null;
97
114
  }
98
115
  export function getLiteralValue(node) {
@@ -0,0 +1,27 @@
1
+ # Require supported community node categories in .node.json codex files (`@n8n/community-nodes/valid-node-categories`)
2
+
3
+ 💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
4
+
5
+ <!-- end auto-generated rule header -->
6
+
7
+ ## Rule Details
8
+
9
+ This rule checks `categories` in community node `.node.json` codex files. Each category must match a value in the [node category list](https://docs.n8n.io/connect/create-nodes/build-your-node/reference/codex-files/#node-categories). The rule also rejects empty arrays and values that are not strings. The `categories` field is optional.
10
+
11
+ ## Examples
12
+
13
+ ### ❌ Incorrect
14
+
15
+ ```json
16
+ {
17
+ "categories": ["Marketing", "Bananas"]
18
+ }
19
+ ```
20
+
21
+ ### ✅ Correct
22
+
23
+ ```json
24
+ {
25
+ "categories": ["Marketing & Content", "Development"]
26
+ }
27
+ ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@n8n/eslint-plugin-community-nodes",
3
3
  "type": "module",
4
- "version": "0.33.0",
4
+ "version": "0.35.0",
5
5
  "main": "./dist/plugin.js",
6
6
  "types": "./dist/plugin.d.ts",
7
7
  "exports": {
@@ -17,9 +17,9 @@
17
17
  },
18
18
  "devDependencies": {
19
19
  "@n8n/typescript-config": "1.12.0",
20
- "@n8n/vitest-config": "1.22.0",
20
+ "@n8n/vitest-config": "1.23.0",
21
21
  "@types/node": "24.10.1",
22
- "n8n-workflow": "2.40.0",
22
+ "n8n-workflow": "2.42.0",
23
23
  "@typescript-eslint/rule-tester": "8.70.0",
24
24
  "eslint-doc-generator": "^2.2.2",
25
25
  "eslint-plugin-eslint-plugin": "^7.0.0",
@@ -27,7 +27,7 @@
27
27
  "@typescript/native": "npm:typescript@7.0.2",
28
28
  "typescript": "npm:@typescript/typescript6@6.0.2",
29
29
  "vite": "^8.0.2",
30
- "vitest": "^4.1.9"
30
+ "vitest": "5.0.1"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "eslint": "9.29.0",
package/src/plugin.ts CHANGED
@@ -61,6 +61,7 @@ const configs = {
61
61
  '@n8n/community-nodes/valid-author': 'error',
62
62
  '@n8n/community-nodes/valid-credential-references': 'error',
63
63
  '@n8n/community-nodes/valid-description': 'error',
64
+ '@n8n/community-nodes/valid-node-categories': 'error',
64
65
  '@n8n/community-nodes/valid-peer-dependencies': 'error',
65
66
  '@n8n/community-nodes/webhook-lifecycle-complete': 'error',
66
67
  },
@@ -110,6 +111,7 @@ const configs = {
110
111
  '@n8n/community-nodes/valid-author': 'error',
111
112
  '@n8n/community-nodes/valid-credential-references': 'error',
112
113
  '@n8n/community-nodes/valid-description': 'error',
114
+ '@n8n/community-nodes/valid-node-categories': 'error',
113
115
  '@n8n/community-nodes/valid-peer-dependencies': 'error',
114
116
  '@n8n/community-nodes/webhook-lifecycle-complete': 'error',
115
117
  },
@@ -42,6 +42,7 @@ import { TriggerNodeConventionsRule } from './trigger-node-conventions.js';
42
42
  import { ValidAuthorRule } from './valid-author.js';
43
43
  import { ValidCredentialReferencesRule } from './valid-credential-references.js';
44
44
  import { ValidDescriptionRule } from './valid-description.js';
45
+ import { ValidNodeCategoriesRule } from './valid-node-categories.js';
45
46
  import { ValidPeerDependenciesRule } from './valid-peer-dependencies.js';
46
47
  import { WebhookLifecycleCompleteRule } from './webhook-lifecycle-complete.js';
47
48
 
@@ -88,6 +89,7 @@ export const rules = {
88
89
  'valid-author': ValidAuthorRule,
89
90
  'valid-credential-references': ValidCredentialReferencesRule,
90
91
  'valid-description': ValidDescriptionRule,
92
+ 'valid-node-categories': ValidNodeCategoriesRule,
91
93
  'valid-peer-dependencies': ValidPeerDependenciesRule,
92
94
  'webhook-lifecycle-complete': WebhookLifecycleCompleteRule,
93
95
  } satisfies Record<string, AnyRuleModule>;
@@ -174,6 +174,32 @@ export class RegularClass {
174
174
  }`),
175
175
  errors: [{ messageId: 'silentReturn', data: { method: 'delete' } }],
176
176
  },
177
+ {
178
+ name: 'lifecycle method written with a computed literal key',
179
+ code: createTriggerNode(`{
180
+ default: {
181
+ ['delete']: async function (this: IHookFunctions): Promise<boolean> {
182
+ try {
183
+ return await this.helpers.httpRequest({ url: 'https://example.com' });
184
+ } catch (error) {}
185
+ },
186
+ },
187
+ }`),
188
+ errors: [{ messageId: 'emptyCatch', data: { method: 'delete' } }],
189
+ },
190
+ {
191
+ name: 'lifecycle group written with a quoted key',
192
+ code: createTriggerNode(`{
193
+ 'default': {
194
+ async delete(this: IHookFunctions): Promise<boolean> {
195
+ try {
196
+ return await this.helpers.httpRequest({ url: 'https://example.com' });
197
+ } catch (error) {}
198
+ },
199
+ },
200
+ }`),
201
+ errors: [{ messageId: 'emptyCatch', data: { method: 'delete' } }],
202
+ },
177
203
  {
178
204
  name: 'catch block with a bare return',
179
205
  code: createTriggerNode(`{
@@ -3,20 +3,13 @@ import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
3
3
  import {
4
4
  createRule,
5
5
  findClassProperty,
6
+ getPropertyKeyName,
6
7
  isNodeTypeClass,
7
8
  WEBHOOK_LIFECYCLE_METHODS,
8
9
  } from '../utils/index.js';
9
10
 
10
11
  const LIFECYCLE_METHODS: readonly string[] = WEBHOOK_LIFECYCLE_METHODS;
11
12
 
12
- /** Returns the static name of a non-computed object property key, or null. */
13
- function getPropertyName(property: TSESTree.Property): string | null {
14
- if (property.computed) return null;
15
- if (property.key.type === AST_NODE_TYPES.Identifier) return property.key.name;
16
- if (property.key.type === AST_NODE_TYPES.Literal) return String(property.key.value);
17
- return null;
18
- }
19
-
20
13
  function isFunctionNode(node: TSESTree.Node): boolean {
21
14
  return (
22
15
  node.type === AST_NODE_TYPES.FunctionDeclaration ||
@@ -81,7 +74,7 @@ export const NoSilentErrorSwallowingRule = createRule({
81
74
  for (const methodProperty of groupProperty.value.properties) {
82
75
  if (methodProperty.type !== AST_NODE_TYPES.Property) continue;
83
76
 
84
- const methodName = getPropertyName(methodProperty);
77
+ const methodName = getPropertyKeyName(methodProperty);
85
78
  if (methodName === null || !LIFECYCLE_METHODS.includes(methodName)) continue;
86
79
 
87
80
  const fn = methodProperty.value;
@@ -0,0 +1,79 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+
3
+ import { ValidNodeCategoriesRule } from './valid-node-categories.js';
4
+
5
+ const ruleTester = new RuleTester();
6
+ const filename = 'nodes/Example/Example.node.json';
7
+
8
+ function codex(categories: unknown): string {
9
+ return JSON.stringify({ node: 'n8n-nodes-example', categories });
10
+ }
11
+
12
+ ruleTester.run('valid-node-categories', ValidNodeCategoriesRule, {
13
+ valid: [
14
+ {
15
+ name: 'all supported community categories',
16
+ filename,
17
+ code: codex([
18
+ 'Data & Storage',
19
+ 'Finance & Accounting',
20
+ 'Marketing & Content',
21
+ 'Productivity',
22
+ 'Miscellaneous',
23
+ 'Sales',
24
+ 'Development',
25
+ 'Analytics',
26
+ 'Communication',
27
+ 'Utility',
28
+ ]),
29
+ },
30
+ { name: 'codex without categories', filename, code: '{ "node": "n8n-nodes-example" }' },
31
+ {
32
+ name: 'unrelated JSON file',
33
+ filename: 'package.json',
34
+ code: codex(['Bananas']),
35
+ },
36
+ {
37
+ name: 'non-node class',
38
+ filename: 'nodes/Example/Example.node.ts',
39
+ code: 'class Example { categories = ["Bananas"]; }',
40
+ },
41
+ {
42
+ name: 'nested categories do not count as codex categories',
43
+ filename,
44
+ code: '{ "resources": { "categories": ["Bananas"] } }',
45
+ },
46
+ ],
47
+ invalid: [
48
+ {
49
+ name: 'unknown category',
50
+ filename,
51
+ code: codex(['Development', 'Bananas']),
52
+ errors: [{ messageId: 'invalidCategory', data: { category: 'Bananas' } }],
53
+ },
54
+ {
55
+ name: 'old marketing category',
56
+ filename,
57
+ code: codex(['Marketing']),
58
+ errors: [{ messageId: 'marketingCategory' }],
59
+ },
60
+ {
61
+ name: 'invalid category type',
62
+ filename,
63
+ code: codex(['Sales', 12]),
64
+ errors: [{ messageId: 'invalidCategoryType' }],
65
+ },
66
+ {
67
+ name: 'categories must be an array',
68
+ filename,
69
+ code: codex('Sales'),
70
+ errors: [{ messageId: 'invalidCategories' }],
71
+ },
72
+ {
73
+ name: 'empty categories array',
74
+ filename,
75
+ code: codex([]),
76
+ errors: [{ messageId: 'invalidCategories' }],
77
+ },
78
+ ],
79
+ });
@@ -0,0 +1,72 @@
1
+ import type { TSESTree } from '@typescript-eslint/utils';
2
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
3
+
4
+ import { createRule, findJsonProperty, getTopLevelObjectInJson } from '../utils/index.js';
5
+
6
+ const validCategories = new Set([
7
+ 'Data & Storage',
8
+ 'Finance & Accounting',
9
+ 'Marketing & Content',
10
+ 'Productivity',
11
+ 'Miscellaneous',
12
+ 'Sales',
13
+ 'Development',
14
+ 'Analytics',
15
+ 'Communication',
16
+ 'Utility',
17
+ ]);
18
+
19
+ export const ValidNodeCategoriesRule = createRule({
20
+ name: 'valid-node-categories',
21
+ meta: {
22
+ type: 'problem',
23
+ docs: {
24
+ description: 'Require supported community node categories in .node.json codex files',
25
+ },
26
+ messages: {
27
+ invalidCategories: '"categories" must be a non-empty array of community node categories.',
28
+ invalidCategoryType: 'Each category must be a string from the community node category list.',
29
+ invalidCategory:
30
+ '"{{ category }}" is not a supported community node category. See https://docs.n8n.io/connect/create-nodes/build-your-node/reference/codex-files/#node-categories for the allowed values.',
31
+ marketingCategory: '"Marketing" is not a supported category. Use "Marketing & Content".',
32
+ },
33
+ schema: [],
34
+ },
35
+ defaultOptions: [],
36
+ create(context) {
37
+ if (!context.filename.endsWith('.node.json')) return {};
38
+
39
+ return {
40
+ ObjectExpression(node: TSESTree.ObjectExpression) {
41
+ const root = getTopLevelObjectInJson(node);
42
+ if (!root) return;
43
+
44
+ const categories = findJsonProperty(root, 'categories');
45
+ if (!categories) return;
46
+
47
+ if (
48
+ categories.value.type !== AST_NODE_TYPES.ArrayExpression ||
49
+ categories.value.elements.length === 0
50
+ ) {
51
+ context.report({ node: categories.value, messageId: 'invalidCategories' });
52
+ return;
53
+ }
54
+
55
+ for (const category of categories.value.elements) {
56
+ if (!category) continue;
57
+ if (category.type !== AST_NODE_TYPES.Literal || typeof category.value !== 'string') {
58
+ context.report({ node: category, messageId: 'invalidCategoryType' });
59
+ } else if (category.value === 'Marketing') {
60
+ context.report({ node: category, messageId: 'marketingCategory' });
61
+ } else if (!validCategories.has(category.value)) {
62
+ context.report({
63
+ node: category,
64
+ messageId: 'invalidCategory',
65
+ data: { category: category.value },
66
+ });
67
+ }
68
+ }
69
+ },
70
+ };
71
+ },
72
+ });
@@ -88,6 +88,30 @@ export class RegularClass {
88
88
  }`,
89
89
  }),
90
90
  },
91
+ {
92
+ name: 'lifecycle methods written with computed literal keys',
93
+ code: createTriggerNode({
94
+ webhookMethods: `{
95
+ default: {
96
+ ['checkExists']: async () => true,
97
+ ['create']: async () => true,
98
+ [\`delete\`]: async () => true,
99
+ },
100
+ }`,
101
+ }),
102
+ },
103
+ {
104
+ name: 'lifecycle group and methods written with quoted keys',
105
+ code: createTriggerNode({
106
+ webhookMethods: `{
107
+ 'default': {
108
+ 'checkExists': async () => true,
109
+ 'create': async () => true,
110
+ 'delete': async () => true,
111
+ },
112
+ }`,
113
+ }),
114
+ },
91
115
  {
92
116
  name: 'webhook lifecycle defined via arrow functions',
93
117
  code: createTriggerNode({
@@ -189,6 +213,24 @@ export class RegularClass {
189
213
  }),
190
214
  errors: [{ messageId: 'missingWebhookMethods' }],
191
215
  },
216
+ {
217
+ name: 'lifecycle method behind a key only known at runtime',
218
+ code: createTriggerNode({
219
+ webhookMethods: `{
220
+ default: {
221
+ async checkExists() { return true; },
222
+ async create() { return true; },
223
+ [methodName]: async () => true,
224
+ },
225
+ }`,
226
+ }),
227
+ errors: [
228
+ {
229
+ messageId: 'missingLifecycleMethod',
230
+ data: { group: 'default', missing: '`delete`' },
231
+ },
232
+ ],
233
+ },
192
234
  {
193
235
  name: 'multiple webhook groups each missing methods',
194
236
  code: createTriggerNode({
@@ -4,6 +4,7 @@ import {
4
4
  createRule,
5
5
  findClassProperty,
6
6
  findObjectProperty,
7
+ getPropertyKeyName,
7
8
  isNodeTypeClass,
8
9
  WEBHOOK_LIFECYCLE_METHODS,
9
10
  type WebhookLifecycleMethod,
@@ -25,12 +26,7 @@ function hasWebhooksDeclared(descriptionValue: TSESTree.ObjectExpression): boole
25
26
  /** Returns true when the property defines a (possibly async) method named `name`. */
26
27
  function isMethodProperty(property: TSESTree.ObjectLiteralElement, name: string): boolean {
27
28
  if (property.type !== AST_NODE_TYPES.Property) return false;
28
- if (property.computed) return false;
29
-
30
- const keyMatches =
31
- (property.key.type === AST_NODE_TYPES.Identifier && property.key.name === name) ||
32
- (property.key.type === AST_NODE_TYPES.Literal && property.key.value === name);
33
- if (!keyMatches) return false;
29
+ if (getPropertyKeyName(property) !== name) return false;
34
30
 
35
31
  return (
36
32
  property.value.type === AST_NODE_TYPES.FunctionExpression ||
@@ -104,12 +100,7 @@ export const WebhookLifecycleCompleteRule = createRule({
104
100
  if (groupProperty.type !== AST_NODE_TYPES.Property) continue;
105
101
  if (groupProperty.value.type !== AST_NODE_TYPES.ObjectExpression) continue;
106
102
 
107
- const groupName =
108
- groupProperty.key.type === AST_NODE_TYPES.Identifier
109
- ? groupProperty.key.name
110
- : groupProperty.key.type === AST_NODE_TYPES.Literal
111
- ? String(groupProperty.key.value)
112
- : 'default';
103
+ const groupName = getPropertyKeyName(groupProperty) ?? 'default';
113
104
 
114
105
  const missing = findMissingMethods(groupProperty.value);
115
106
  if (missing.length === 0) continue;
@@ -55,6 +55,33 @@ export function isTriggerNode(
55
55
  return hasTriggerGroup(descriptionValue) || isTriggerNodeClass(node);
56
56
  }
57
57
 
58
+ /**
59
+ * Returns the name a property key stands for, or null when it is only known at
60
+ * runtime. `delete`, `'delete'` and `['delete']` all name the same property, so
61
+ * how the key is written must not decide whether a rule sees it.
62
+ */
63
+ export function getPropertyKeyName(
64
+ property: TSESTree.Property | TSESTree.PropertyDefinition | TSESTree.MethodDefinition,
65
+ ): string | null {
66
+ const { key } = property;
67
+
68
+ if (key.type === AST_NODE_TYPES.Literal) {
69
+ return key.value === null ? null : String(key.value);
70
+ }
71
+
72
+ // A computed key still names one property when it is a template without
73
+ // substitutions; written plainly, an identifier is the name itself.
74
+ if (key.type === AST_NODE_TYPES.TemplateLiteral) {
75
+ return key.expressions.length === 0 ? (key.quasis[0]?.value.cooked ?? null) : null;
76
+ }
77
+
78
+ if (key.type === AST_NODE_TYPES.Identifier) {
79
+ return property.computed ? null : key.name;
80
+ }
81
+
82
+ return null;
83
+ }
84
+
58
85
  export function findClassProperty(
59
86
  node: TSESTree.ClassDeclaration,
60
87
  propertyName: string,
@@ -62,8 +89,7 @@ export function findClassProperty(
62
89
  const property = node.body.body.find(
63
90
  (member) =>
64
91
  member.type === AST_NODE_TYPES.PropertyDefinition &&
65
- member.key?.type === AST_NODE_TYPES.Identifier &&
66
- member.key.name === propertyName,
92
+ getPropertyKeyName(member) === propertyName,
67
93
  );
68
94
  return property?.type === AST_NODE_TYPES.PropertyDefinition ? property : null;
69
95
  }
@@ -140,10 +166,7 @@ export function findObjectProperty(
140
166
  propertyName: string,
141
167
  ): TSESTree.Property | null {
142
168
  const property = obj.properties.find(
143
- (prop) =>
144
- prop.type === AST_NODE_TYPES.Property &&
145
- prop.key.type === AST_NODE_TYPES.Identifier &&
146
- prop.key.name === propertyName,
169
+ (prop) => prop.type === AST_NODE_TYPES.Property && getPropertyKeyName(prop) === propertyName,
147
170
  );
148
171
  return property?.type === AST_NODE_TYPES.Property ? property : null;
149
172
  }