feat(core): Add trigger-node-conventions ESLint rule for community nodes (#32929)

This commit is contained in:
Garrit Franke 2026-06-26 12:35:45 +02:00 committed by GitHub
parent 0a97fe8743
commit 09f070bfca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 256 additions and 0 deletions

View File

@ -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 | ✅ ☑️ | | | | |

View File

@ -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`.
<!-- end auto-generated rule header -->
## 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'],
// ...
};
}
```

View File

@ -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',

View File

@ -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,

View File

@ -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' },
],
},
],
});

View File

@ -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',
});
}
},
};
},
});