diff --git a/packages/@n8n/eslint-plugin-community-nodes/README.md b/packages/@n8n/eslint-plugin-community-nodes/README.md index b7ba5f3a57a..763bf046037 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/README.md +++ b/packages/@n8n/eslint-plugin-community-nodes/README.md @@ -88,6 +88,7 @@ export default [ | [require-param-default](docs/rules/require-param-default.md) | Require every node parameter to declare a default value. | ✅ ☑️ | | | | | | [require-version](docs/rules/require-version.md) | Require a valid "version" field in community node package.json | ✅ ☑️ | | | | | | [resource-operation-pattern](docs/rules/resource-operation-pattern.md) | Enforce proper resource/operation pattern for better UX in n8n nodes | | ✅ ☑️ | | | | +| [trigger-node-conventions](docs/rules/trigger-node-conventions.md) | Trigger nodes (class name ends with `Trigger`) must label themselves consistently as triggers | ✅ ☑️ | | | | | | [valid-author](docs/rules/valid-author.md) | Require a non-empty author name and email in package.json | ✅ ☑️ | | | | | | [valid-credential-references](docs/rules/valid-credential-references.md) | Ensure credentials referenced in node descriptions exist as credential classes in the package | ✅ ☑️ | | | 💡 | | | [valid-description](docs/rules/valid-description.md) | Require a non-empty "description" field in community node package.json | ✅ ☑️ | | | | | diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/trigger-node-conventions.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/trigger-node-conventions.md new file mode 100644 index 00000000000..ce0f3837f71 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/trigger-node-conventions.md @@ -0,0 +1,49 @@ +# Trigger nodes (class name ends with `Trigger`) must label themselves consistently as triggers (`@n8n/community-nodes/trigger-node-conventions`) + +💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. + + + +## Rule Details + +When a node class name ends with `Trigger`, the node must consistently present +itself as a trigger so users and the editor recognize it as one. This rule +requires all of: + +- `description.name` ends with `Trigger` +- `description.displayName` contains `Trigger` +- `description.inputs` is an empty array (`[]`) — trigger nodes start an + execution and take no main inputs + +This consolidates three checks that form a single conceptual requirement: if +it's a trigger node, label it consistently. + +## Examples + +### Incorrect + +```typescript +export class MyTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Node', + name: 'my', + inputs: ['main'], + outputs: ['main'], + // ... + }; +} +``` + +### Correct + +```typescript +export class MyTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Trigger', + name: 'myTrigger', + inputs: [], + outputs: ['main'], + // ... + }; +} +``` diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts b/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts index ebdc8956b4c..56dc8cf8ea1 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts @@ -41,6 +41,7 @@ const configs = { '@n8n/community-nodes/icon-prefer-themed-variants': 'warn', '@n8n/community-nodes/options-sorted-alphabetically': 'warn', '@n8n/community-nodes/resource-operation-pattern': 'warn', + '@n8n/community-nodes/trigger-node-conventions': 'error', '@n8n/community-nodes/credential-documentation-url': 'error', '@n8n/community-nodes/cred-class-field-icon-missing': 'error', '@n8n/community-nodes/cred-class-name-field-conventions': 'error', @@ -94,6 +95,7 @@ const configs = { '@n8n/community-nodes/options-sorted-alphabetically': 'warn', '@n8n/community-nodes/credential-documentation-url': 'error', '@n8n/community-nodes/resource-operation-pattern': 'warn', + '@n8n/community-nodes/trigger-node-conventions': 'error', '@n8n/community-nodes/cred-class-field-icon-missing': 'error', '@n8n/community-nodes/cred-class-name-field-conventions': 'error', '@n8n/community-nodes/cred-class-name-suffix': 'error', diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts index c2dbb16ae50..362f542b56f 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts @@ -42,6 +42,7 @@ import { RequireNodeDescriptionFieldsRule } from './require-node-description-fie import { RequireParamDefaultRule } from './require-param-default.js'; import { RequireVersionRule } from './require-version.js'; import { ResourceOperationPatternRule } from './resource-operation-pattern.js'; +import { TriggerNodeConventionsRule } from './trigger-node-conventions.js'; import { ValidAuthorRule } from './valid-author.js'; import { ValidCredentialReferencesRule } from './valid-credential-references.js'; import { ValidDescriptionRule } from './valid-description.js'; @@ -69,6 +70,7 @@ export const rules = { 'icon-validation': IconValidationRule, 'icon-prefer-themed-variants': IconPreferThemedVariantsRule, 'resource-operation-pattern': ResourceOperationPatternRule, + 'trigger-node-conventions': TriggerNodeConventionsRule, 'credential-documentation-url': CredentialDocumentationUrlRule, 'node-class-description-icon-missing': NodeClassDescriptionIconMissingRule, 'node-class-description-name-camelcase': NodeClassDescriptionNameCamelCaseRule, diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/trigger-node-conventions.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/trigger-node-conventions.test.ts new file mode 100644 index 00000000000..4e06e3a762f --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/trigger-node-conventions.test.ts @@ -0,0 +1,110 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import { TriggerNodeConventionsRule } from './trigger-node-conventions.js'; + +const ruleTester = new RuleTester(); + +function createNodeCode(options: { + className: string; + name: string; + displayName: string; + inputs: string; +}): string { + const { className, name, displayName, inputs } = options; + return ` +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; + +export class ${className} implements INodeType { + description: INodeTypeDescription = { + displayName: '${displayName}', + name: '${name}', + group: ['trigger'], + version: 1, + description: 'A test node', + defaults: { name: '${displayName}' }, + inputs: ${inputs}, + outputs: ['main'], + properties: [], + }; +}`; +} + +ruleTester.run('trigger-node-conventions', TriggerNodeConventionsRule, { + valid: [ + { + name: 'class that does not implement INodeType', + filename: 'MyTrigger.node.ts', + code: 'export class MyTrigger {}', + }, + { + name: 'non-trigger node class is ignored', + filename: 'My.node.ts', + code: createNodeCode({ + className: 'My', + name: 'my', + displayName: 'My Node', + inputs: "['main']", + }), + }, + { + name: 'compliant trigger node', + filename: 'MyTrigger.node.ts', + code: createNodeCode({ + className: 'MyTrigger', + name: 'myTrigger', + displayName: 'My Trigger', + inputs: '[]', + }), + }, + ], + invalid: [ + { + name: 'name missing Trigger suffix', + filename: 'MyTrigger.node.ts', + code: createNodeCode({ + className: 'MyTrigger', + name: 'my', + displayName: 'My Trigger', + inputs: '[]', + }), + errors: [{ messageId: 'nameMissingSuffix', data: { value: 'my' } }], + }, + { + name: 'displayName missing Trigger', + filename: 'MyTrigger.node.ts', + code: createNodeCode({ + className: 'MyTrigger', + name: 'myTrigger', + displayName: 'My Node', + inputs: '[]', + }), + errors: [{ messageId: 'displayNameMissingTrigger', data: { value: 'My Node' } }], + }, + { + name: 'inputs is not empty', + filename: 'MyTrigger.node.ts', + code: createNodeCode({ + className: 'MyTrigger', + name: 'myTrigger', + displayName: 'My Trigger', + inputs: "['main']", + }), + errors: [{ messageId: 'inputsNotEmpty' }], + }, + { + name: 'all three violations at once', + filename: 'MyTrigger.node.ts', + code: createNodeCode({ + className: 'MyTrigger', + name: 'my', + displayName: 'My Node', + inputs: "['main']", + }), + errors: [ + { messageId: 'displayNameMissingTrigger' }, + { messageId: 'nameMissingSuffix' }, + { messageId: 'inputsNotEmpty' }, + ], + }, + ], +}); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/trigger-node-conventions.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/trigger-node-conventions.ts new file mode 100644 index 00000000000..9c37cc44b9d --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/trigger-node-conventions.ts @@ -0,0 +1,92 @@ +import { TSESTree } from '@typescript-eslint/utils'; + +import { + isNodeTypeClass, + findClassProperty, + findObjectProperty, + getStringLiteralValue, + isFileType, + createRule, +} from '../utils/index.js'; + +export const TriggerNodeConventionsRule = createRule({ + name: 'trigger-node-conventions', + meta: { + type: 'problem', + docs: { + description: + 'Trigger nodes (class name ends with `Trigger`) must label themselves consistently as triggers', + }, + messages: { + nameMissingSuffix: "Trigger node `description.name` '{{value}}' must end with 'Trigger'", + displayNameMissingTrigger: + "Trigger node `description.displayName` '{{value}}' must contain 'Trigger'", + inputsNotEmpty: 'Trigger node `description.inputs` must be an empty array `[]`', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + if (!isFileType(context.filename, '.node.ts')) { + return {}; + } + + return { + ClassDeclaration(node) { + if (!isNodeTypeClass(node)) { + return; + } + + if (!node.id?.name.endsWith('Trigger')) { + return; + } + + const descriptionProperty = findClassProperty(node, 'description'); + if (descriptionProperty?.value?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) { + return; + } + const description = descriptionProperty.value; + + const displayNameProperty = findObjectProperty(description, 'displayName'); + const displayNameValue = displayNameProperty + ? getStringLiteralValue(displayNameProperty.value) + : null; + if ( + displayNameProperty && + displayNameValue !== null && + !displayNameValue.includes('Trigger') + ) { + context.report({ + node: displayNameProperty.value, + messageId: 'displayNameMissingTrigger', + data: { value: displayNameValue }, + }); + } + + const nameProperty = findObjectProperty(description, 'name'); + const nameValue = nameProperty ? getStringLiteralValue(nameProperty.value) : null; + if (nameProperty && nameValue !== null && !nameValue.endsWith('Trigger')) { + context.report({ + node: nameProperty.value, + messageId: 'nameMissingSuffix', + data: { value: nameValue }, + }); + } + + const inputsProperty = findObjectProperty(description, 'inputs'); + if ( + inputsProperty && + !( + inputsProperty.value.type === TSESTree.AST_NODE_TYPES.ArrayExpression && + inputsProperty.value.elements.length === 0 + ) + ) { + context.report({ + node: inputsProperty.value, + messageId: 'inputsNotEmpty', + }); + } + }, + }; + }, +});