mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 03:24:59 +02:00
feat(core): Add trigger-node-conventions ESLint rule for community nodes (#32929)
This commit is contained in:
parent
0a97fe8743
commit
09f070bfca
|
|
@ -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 | ✅ ☑️ | | | | |
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user