From 31f577a39fc16e8895eafc6dd8eeed6029bcc825 Mon Sep 17 00:00:00 2001 From: Garrit Franke <32395585+garritfra@users.noreply.github.com> Date: Wed, 6 May 2026 18:00:49 +0200 Subject: [PATCH] feat: Add cred-class-name-suffix lint rule (no-changelog) (#29801) --- .../eslint-plugin-community-nodes/README.md | 1 + .../docs/rules/cred-class-name-suffix.md | 46 ++++++++++++ .../src/plugin.ts | 2 + .../src/rules/cred-class-name-suffix.test.ts | 74 +++++++++++++++++++ .../src/rules/cred-class-name-suffix.ts | 57 ++++++++++++++ .../src/rules/index.ts | 2 + 6 files changed, 182 insertions(+) create mode 100644 packages/@n8n/eslint-plugin-community-nodes/docs/rules/cred-class-name-suffix.md create mode 100644 packages/@n8n/eslint-plugin-community-nodes/src/rules/cred-class-name-suffix.test.ts create mode 100644 packages/@n8n/eslint-plugin-community-nodes/src/rules/cred-class-name-suffix.ts diff --git a/packages/@n8n/eslint-plugin-community-nodes/README.md b/packages/@n8n/eslint-plugin-community-nodes/README.md index b5dbf546f94..e1ba068e4d6 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/README.md +++ b/packages/@n8n/eslint-plugin-community-nodes/README.md @@ -48,6 +48,7 @@ export default [ | :--------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ | :--- | :--- | :- | :- | :- | | [ai-node-package-json](docs/rules/ai-node-package-json.md) | Enforce consistency between n8n.aiNodeSdkVersion and ai-node-sdk peer dependency in community node packages | ✅ ☑️ | | | | | | [cred-class-field-icon-missing](docs/rules/cred-class-field-icon-missing.md) | Credential class must have an `icon` property defined | ✅ ☑️ | | | 💡 | | +| [cred-class-name-suffix](docs/rules/cred-class-name-suffix.md) | Credential class names must be suffixed with `Api` | ✅ ☑️ | | 🔧 | | | | [cred-class-oauth2-naming](docs/rules/cred-class-oauth2-naming.md) | OAuth2 credentials must include `OAuth2` in the class name, `name`, and `displayName` | ✅ ☑️ | | 🔧 | | | | [credential-documentation-url](docs/rules/credential-documentation-url.md) | Enforce valid credential documentationUrl format (URL or lowercase alphanumeric slug) | ✅ ☑️ | | 🔧 | | | | [credential-password-field](docs/rules/credential-password-field.md) | Ensure credential fields with sensitive names have typeOptions.password = true | ✅ ☑️ | | 🔧 | | | diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/cred-class-name-suffix.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/cred-class-name-suffix.md new file mode 100644 index 00000000000..a6a6848f367 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/cred-class-name-suffix.md @@ -0,0 +1,46 @@ +# Credential class names must be suffixed with `Api` (`@n8n/community-nodes/cred-class-name-suffix`) + +💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +## Rule Details + +Credential classes (those implementing `ICredentialType` in `*.credentials.ts` files) must have a class name ending in `Api`. This is the n8n convention so credentials are easily recognisable in code, in import statements, and across the credential registry. + +For OAuth2 credentials extending the OAuth2 base, see the sibling rule `cred-class-oauth2-naming` which enforces the more specific `OAuth2Api` suffix. + +## Opt-out + +For legitimate exceptions (for example credentials representing custom auth headers where the `Api` suffix would be misleading), disable the rule for the specific class with a standard ESLint comment: + +```typescript +// eslint-disable-next-line @n8n/community-nodes/cred-class-name-suffix +export class CustomAuthHeader implements ICredentialType { + // ... +} +``` + +## Examples + +### ❌ Incorrect + +```typescript +export class MyService implements ICredentialType { + name = 'myServiceApi'; + displayName = 'My Service API'; + properties: INodeProperties[] = []; +} +``` + +### ✅ Correct + +```typescript +export class MyServiceApi implements ICredentialType { + name = 'myServiceApi'; + displayName = 'My Service API'; + properties: INodeProperties[] = []; +} +``` diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts b/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts index 2360f8e5ffc..0f732b193a6 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts @@ -40,6 +40,7 @@ const configs = { '@n8n/community-nodes/resource-operation-pattern': 'warn', '@n8n/community-nodes/credential-documentation-url': 'error', '@n8n/community-nodes/cred-class-field-icon-missing': 'error', + '@n8n/community-nodes/cred-class-name-suffix': 'error', '@n8n/community-nodes/cred-class-oauth2-naming': 'error', '@n8n/community-nodes/node-connection-type-literal': 'error', '@n8n/community-nodes/missing-paired-item': 'error', @@ -77,6 +78,7 @@ const configs = { '@n8n/community-nodes/credential-documentation-url': 'error', '@n8n/community-nodes/resource-operation-pattern': 'warn', '@n8n/community-nodes/cred-class-field-icon-missing': 'error', + '@n8n/community-nodes/cred-class-name-suffix': 'error', '@n8n/community-nodes/cred-class-oauth2-naming': 'error', '@n8n/community-nodes/node-connection-type-literal': 'error', '@n8n/community-nodes/missing-paired-item': 'error', diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/cred-class-name-suffix.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/cred-class-name-suffix.test.ts new file mode 100644 index 00000000000..5dd175f5e67 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/cred-class-name-suffix.test.ts @@ -0,0 +1,74 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import { CredClassNameSuffixRule } from './cred-class-name-suffix.js'; + +const ruleTester = new RuleTester(); + +const credFilePath = '/tmp/TestCredential.credentials.ts'; +const nonCredFilePath = '/tmp/SomeHelper.ts'; + +function createCredentialCode(className: string): string { + return ` +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class ${className} implements ICredentialType { + name = 'testApi'; + displayName = 'Test API'; + properties: INodeProperties[] = []; +}`; +} + +function createRegularClass(className: string): string { + return ` +export class ${className} { + name = 'test'; +}`; +} + +ruleTester.run('cred-class-name-suffix', CredClassNameSuffixRule, { + valid: [ + { + name: 'credential class with Api suffix', + filename: credFilePath, + code: createCredentialCode('TestApi'), + }, + { + name: 'credential class with OAuth2Api suffix', + filename: credFilePath, + code: createCredentialCode('TestOAuth2Api'), + }, + { + name: 'class not implementing ICredentialType is ignored', + filename: credFilePath, + code: createRegularClass('SomeHelper'), + }, + { + name: 'non-.credentials.ts file is ignored', + filename: nonCredFilePath, + code: createCredentialCode('TestCredential'), + }, + ], + invalid: [ + { + name: 'credential class missing Api suffix', + filename: credFilePath, + code: createCredentialCode('TestCredential'), + errors: [{ messageId: 'missingSuffix', data: { name: 'TestCredential' } }], + output: createCredentialCode('TestCredentialApi'), + }, + { + name: 'credential class name ending in Ap', + filename: credFilePath, + code: createCredentialCode('TestAp'), + errors: [{ messageId: 'missingSuffix', data: { name: 'TestAp' } }], + output: createCredentialCode('TestApi'), + }, + { + name: 'credential class name ending in A', + filename: credFilePath, + code: createCredentialCode('TestA'), + errors: [{ messageId: 'missingSuffix', data: { name: 'TestA' } }], + output: createCredentialCode('TestApi'), + }, + ], +}); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/cred-class-name-suffix.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/cred-class-name-suffix.ts new file mode 100644 index 00000000000..23e533571ff --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/cred-class-name-suffix.ts @@ -0,0 +1,57 @@ +import { isCredentialTypeClass, isFileType, createRule } from '../utils/index.js'; + +function addApiSuffix(name: string): string { + if (name.endsWith('Ap')) return `${name}i`; + if (name.endsWith('A')) return `${name}pi`; + return `${name}Api`; +} + +export const CredClassNameSuffixRule = createRule({ + name: 'cred-class-name-suffix', + meta: { + type: 'problem', + docs: { + description: 'Credential class names must be suffixed with `Api`', + }, + messages: { + missingSuffix: "Credential class name '{{name}}' must end with 'Api'", + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + if (!isFileType(context.filename, '.credentials.ts')) { + return {}; + } + + return { + ClassDeclaration(node) { + if (!isCredentialTypeClass(node)) { + return; + } + + const classNameNode = node.id; + if (!classNameNode) { + return; + } + + const className = classNameNode.name; + if (className.endsWith('Api')) { + return; + } + + const fixedName = addApiSuffix(className); + + context.report({ + node: classNameNode, + messageId: 'missingSuffix', + data: { name: className }, + fix(fixer) { + return fixer.replaceText(classNameNode, fixedName); + }, + }); + }, + }; + }, +}); 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 19370a08c58..20e19eb47c4 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts @@ -2,6 +2,7 @@ import type { AnyRuleModule } from '@typescript-eslint/utils/ts-eslint'; import { AiNodePackageJsonRule } from './ai-node-package-json.js'; import { CredClassFieldIconMissingRule } from './cred-class-field-icon-missing.js'; +import { CredClassNameSuffixRule } from './cred-class-name-suffix.js'; import { CredClassOAuth2NamingRule } from './cred-class-oauth2-naming.js'; import { CredentialDocumentationUrlRule } from './credential-documentation-url.js'; import { CredentialPasswordFieldRule } from './credential-password-field.js'; @@ -54,6 +55,7 @@ export const rules = { 'credential-documentation-url': CredentialDocumentationUrlRule, 'node-class-description-icon-missing': NodeClassDescriptionIconMissingRule, 'cred-class-field-icon-missing': CredClassFieldIconMissingRule, + 'cred-class-name-suffix': CredClassNameSuffixRule, 'cred-class-oauth2-naming': CredClassOAuth2NamingRule, 'node-connection-type-literal': NodeConnectionTypeLiteralRule, 'node-operation-error-itemindex': NodeOperationErrorItemIndexRule,