feat(core): Add lint rule to flag string literals in node inputs/outputs (#27890)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garrit Franke 2026-04-02 14:08:57 +02:00 committed by GitHub
parent 70be3f5990
commit 1140c83565
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 300 additions and 18 deletions

View file

@ -43,22 +43,23 @@ export default [
🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\
💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
| Name                             | Description | 💼 | ⚠️ | 🔧 | 💡 |
| :--------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ | :--- | :--- | :- | :- |
| [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 | ✅ ☑️ | | | 💡 |
| [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 | ✅ ☑️ | | 🔧 | |
| [credential-test-required](docs/rules/credential-test-required.md) | Ensure credentials have a credential test | ✅ ☑️ | | | 💡 |
| [icon-validation](docs/rules/icon-validation.md) | Validate node and credential icon files exist, are SVG format, and light/dark icons are different | ✅ ☑️ | | | 💡 |
| [no-credential-reuse](docs/rules/no-credential-reuse.md) | Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package | ✅ ☑️ | | | 💡 |
| [no-deprecated-workflow-functions](docs/rules/no-deprecated-workflow-functions.md) | Disallow usage of deprecated functions and types from n8n-workflow package | ✅ ☑️ | | | 💡 |
| [no-http-request-with-manual-auth](docs/rules/no-http-request-with-manual-auth.md) | Disallow this.helpers.httpRequest() in functions that call this.getCredentials(). Use this.helpers.httpRequestWithAuthentication() instead. | ✅ ☑️ | | | |
| [no-restricted-globals](docs/rules/no-restricted-globals.md) | Disallow usage of restricted global variables in community nodes. | ✅ | | | |
| [no-restricted-imports](docs/rules/no-restricted-imports.md) | Disallow usage of restricted imports in community nodes. | ✅ | | | |
| [node-class-description-icon-missing](docs/rules/node-class-description-icon-missing.md) | Node class description must have an `icon` property defined | ✅ ☑️ | | | 💡 |
| [node-usable-as-tool](docs/rules/node-usable-as-tool.md) | Ensure node classes have usableAsTool property | ✅ ☑️ | | 🔧 | |
| [package-name-convention](docs/rules/package-name-convention.md) | Enforce correct package naming convention for n8n community nodes | ✅ ☑️ | | | 💡 |
| [resource-operation-pattern](docs/rules/resource-operation-pattern.md) | Enforce proper resource/operation pattern for better UX in n8n nodes | | ✅ ☑️ | | |
| Name                                | Description | 💼 | ⚠️ | 🔧 | 💡 |
| :--------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ | :--- | :--- | :- | :- |
| [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 | ✅ ☑️ | | | 💡 |
| [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 | ✅ ☑️ | | 🔧 | |
| [credential-test-required](docs/rules/credential-test-required.md) | Ensure credentials have a credential test | ✅ ☑️ | | | 💡 |
| [icon-validation](docs/rules/icon-validation.md) | Validate node and credential icon files exist, are SVG format, and light/dark icons are different | ✅ ☑️ | | | 💡 |
| [no-credential-reuse](docs/rules/no-credential-reuse.md) | Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package | ✅ ☑️ | | | 💡 |
| [no-deprecated-workflow-functions](docs/rules/no-deprecated-workflow-functions.md) | Disallow usage of deprecated functions and types from n8n-workflow package | ✅ ☑️ | | | 💡 |
| [no-http-request-with-manual-auth](docs/rules/no-http-request-with-manual-auth.md) | Disallow this.helpers.httpRequest() in functions that call this.getCredentials(). Use this.helpers.httpRequestWithAuthentication() instead. | ✅ ☑️ | | | |
| [no-restricted-globals](docs/rules/no-restricted-globals.md) | Disallow usage of restricted global variables in community nodes. | ✅ | | | |
| [no-restricted-imports](docs/rules/no-restricted-imports.md) | Disallow usage of restricted imports in community nodes. | ✅ | | | |
| [node-class-description-icon-missing](docs/rules/node-class-description-icon-missing.md) | Node class description must have an `icon` property defined | ✅ ☑️ | | | 💡 |
| [node-connection-type-literal](docs/rules/node-connection-type-literal.md) | Disallow string literals in node description `inputs`/`outputs` — use `NodeConnectionTypes` enum instead | ✅ ☑️ | | 🔧 | |
| [node-usable-as-tool](docs/rules/node-usable-as-tool.md) | Ensure node classes have usableAsTool property | ✅ ☑️ | | 🔧 | |
| [package-name-convention](docs/rules/package-name-convention.md) | Enforce correct package naming convention for n8n community nodes | ✅ ☑️ | | | 💡 |
| [resource-operation-pattern](docs/rules/resource-operation-pattern.md) | Enforce proper resource/operation pattern for better UX in n8n nodes | | ✅ ☑️ | | |
<!-- end auto-generated rules list -->

View file

@ -0,0 +1,46 @@
# Disallow string literals in node description `inputs`/`outputs` — use `NodeConnectionTypes` enum instead (`@n8n/community-nodes/node-connection-type-literal`)
💼 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).
<!-- end auto-generated rule header -->
## Rule Details
Using raw string literals like `'main'` in `inputs` and `outputs` is fragile: the values are not type-checked, and typos or renamed connection types will go undetected. The `NodeConnectionTypes` object from `n8n-workflow` is the single source of truth and should always be used instead.
## Examples
### ❌ Incorrect
```typescript
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class MyNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'My Node',
name: 'myNode',
inputs: ['main'],
outputs: ['main'],
properties: [],
};
}
```
### ✅ Correct
```typescript
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
export class MyNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'My Node',
name: 'myNode',
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
properties: [],
};
}
```

View file

@ -34,6 +34,7 @@
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@types/node": "catalog:",
"n8n-workflow": "workspace:*",
"@typescript-eslint/rule-tester": "^8.35.0",
"eslint-doc-generator": "^2.2.2",
"eslint-plugin-eslint-plugin": "^7.0.0",
@ -42,7 +43,8 @@
"vitest": "catalog:"
},
"peerDependencies": {
"eslint": ">= 9"
"eslint": ">= 9",
"n8n-workflow": ">=2"
},
"eslint-doc-generator": {
"configEmoji": [

View file

@ -35,6 +35,7 @@ const configs = {
'@n8n/community-nodes/credential-documentation-url': 'error',
'@n8n/community-nodes/node-class-description-icon-missing': 'error',
'@n8n/community-nodes/cred-class-field-icon-missing': 'error',
'@n8n/community-nodes/node-connection-type-literal': 'error',
},
},
recommendedWithoutN8nCloudSupport: {
@ -56,6 +57,7 @@ const configs = {
'@n8n/community-nodes/resource-operation-pattern': 'warn',
'@n8n/community-nodes/node-class-description-icon-missing': 'error',
'@n8n/community-nodes/cred-class-field-icon-missing': 'error',
'@n8n/community-nodes/node-connection-type-literal': 'error',
},
},
} satisfies Record<string, Linter.Config>;

View file

@ -12,6 +12,7 @@ import { NoHttpRequestWithManualAuthRule } from './no-http-request-with-manual-a
import { NoRestrictedGlobalsRule } from './no-restricted-globals.js';
import { NoRestrictedImportsRule } from './no-restricted-imports.js';
import { NodeClassDescriptionIconMissingRule } from './node-class-description-icon-missing.js';
import { NodeConnectionTypeLiteralRule } from './node-connection-type-literal.js';
import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
import { PackageNameConventionRule } from './package-name-convention.js';
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
@ -32,4 +33,5 @@ export const rules = {
'credential-documentation-url': CredentialDocumentationUrlRule,
'node-class-description-icon-missing': NodeClassDescriptionIconMissingRule,
'cred-class-field-icon-missing': CredClassFieldIconMissingRule,
'node-connection-type-literal': NodeConnectionTypeLiteralRule,
} satisfies Record<string, AnyRuleModule>;

View file

@ -0,0 +1,128 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NodeConnectionTypeLiteralRule } from './node-connection-type-literal.js';
const ruleTester = new RuleTester();
function createNodeCode(inputs: string, outputs: string): string {
return `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['input'],
version: 1,
description: 'A test node',
defaults: { name: 'Test Node' },
inputs: ${inputs},
outputs: ${outputs},
properties: [],
};
}`;
}
function createNodeCodeNoImport(inputs: string, outputs: string): string {
return `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['input'],
version: 1,
description: 'A test node',
defaults: { name: 'Test Node' },
inputs: ${inputs},
outputs: ${outputs},
properties: [],
};
}`;
}
function createNonNodeClass(): string {
return `
export class RegularClass {
someProperty = 'value';
}`;
}
ruleTester.run('node-connection-type-literal', NodeConnectionTypeLiteralRule, {
valid: [
{
name: 'class that does not implement INodeType',
code: createNonNodeClass(),
},
{
name: 'node with enum in inputs and outputs',
code: createNodeCode('[NodeConnectionTypes.Main]', '[NodeConnectionTypes.Main]'),
},
{
name: 'node with empty inputs and outputs',
code: createNodeCode('[]', '[]'),
},
{
name: 'node with AI enum in inputs',
code: createNodeCode('[NodeConnectionTypes.AiAgent]', '[NodeConnectionTypes.Main]'),
},
{
name: 'node with multiple enum values',
code: createNodeCode(
'[NodeConnectionTypes.Main]',
'[NodeConnectionTypes.AiAgent, NodeConnectionTypes.AiTool]',
),
},
],
invalid: [
{
name: 'string literal "main" in inputs',
code: createNodeCodeNoImport("['main']", '[NodeConnectionTypes.Main]'),
errors: [{ messageId: 'stringLiteralInInputs', data: { value: 'main', enumKey: 'Main' } }],
output: createNodeCodeNoImport('[NodeConnectionTypes.Main]', '[NodeConnectionTypes.Main]'),
},
{
name: 'string literal "main" in outputs',
code: createNodeCodeNoImport('[NodeConnectionTypes.Main]', "['main']"),
errors: [{ messageId: 'stringLiteralInOutputs', data: { value: 'main', enumKey: 'Main' } }],
output: createNodeCodeNoImport('[NodeConnectionTypes.Main]', '[NodeConnectionTypes.Main]'),
},
{
name: 'string literals in both inputs and outputs',
code: createNodeCodeNoImport("['main']", "['main']"),
errors: [
{ messageId: 'stringLiteralInInputs', data: { value: 'main', enumKey: 'Main' } },
{ messageId: 'stringLiteralInOutputs', data: { value: 'main', enumKey: 'Main' } },
],
output: createNodeCodeNoImport('[NodeConnectionTypes.Main]', '[NodeConnectionTypes.Main]'),
},
{
name: 'string literal "ai_agent" in inputs',
code: createNodeCodeNoImport("['ai_agent']", '[]'),
errors: [
{ messageId: 'stringLiteralInInputs', data: { value: 'ai_agent', enumKey: 'AiAgent' } },
],
output: createNodeCodeNoImport('[NodeConnectionTypes.AiAgent]', '[]'),
},
{
name: 'unknown string literal in inputs — no autofix',
code: createNodeCodeNoImport("['unknown_type']", '[]'),
errors: [{ messageId: 'unknownStringLiteralInInputs', data: { value: 'unknown_type' } }],
output: null,
},
{
name: 'unknown string literal in outputs — no autofix',
code: createNodeCodeNoImport('[]', "['unknown_type']"),
errors: [{ messageId: 'unknownStringLiteralInOutputs', data: { value: 'unknown_type' } }],
output: null,
},
{
name: 'string literal in node that already imports NodeConnectionTypes',
code: createNodeCode("['main']", '[NodeConnectionTypes.Main]'),
errors: [{ messageId: 'stringLiteralInInputs', data: { value: 'main', enumKey: 'Main' } }],
output: createNodeCode('[NodeConnectionTypes.Main]', '[NodeConnectionTypes.Main]'),
},
],
});

View file

@ -0,0 +1,98 @@
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import type { TSESTree } from '@typescript-eslint/utils';
import { createRequire } from 'node:module';
import {
isNodeTypeClass,
findClassProperty,
findObjectProperty,
createRule,
} from '../utils/index.js';
// n8n-workflow's ESM dist uses bare module specifiers that Node's native ESM
// loader cannot resolve. Loading via CJS (createRequire) sidesteps this.
const { NodeConnectionTypes } = createRequire(import.meta.url)('n8n-workflow') as {
NodeConnectionTypes: Record<string, string>;
};
// Reverse map: string value (e.g. 'main') → enum key name (e.g. 'Main').
// Derived directly from NodeConnectionTypes so it stays in sync automatically.
const VALUE_TO_ENUM_KEY: Record<string, string> = Object.fromEntries(
Object.entries(NodeConnectionTypes).map(([key, value]) => [value, key]),
);
export const NodeConnectionTypeLiteralRule = createRule({
name: 'node-connection-type-literal',
meta: {
type: 'problem',
docs: {
description:
'Disallow string literals in node description `inputs`/`outputs` — use `NodeConnectionTypes` enum instead',
},
messages: {
stringLiteralInInputs:
'Use NodeConnectionTypes.{{enumKey}} from "n8n-workflow" instead of the string literal "{{value}}" in "inputs".',
stringLiteralInOutputs:
'Use NodeConnectionTypes.{{enumKey}} from "n8n-workflow" instead of the string literal "{{value}}" in "outputs".',
unknownStringLiteralInInputs:
'Use the NodeConnectionTypes enum from "n8n-workflow" instead of the string literal "{{value}}" in "inputs".',
unknownStringLiteralInOutputs:
'Use the NodeConnectionTypes enum from "n8n-workflow" instead of the string literal "{{value}}" in "outputs".',
},
fixable: 'code',
schema: [],
},
defaultOptions: [],
create(context) {
function checkArrayElements(
elements: TSESTree.ArrayExpression['elements'],
property: 'inputs' | 'outputs',
) {
for (const element of elements) {
if (element?.type !== AST_NODE_TYPES.Literal) continue;
if (typeof element.value !== 'string') continue;
const value = element.value;
const enumKey = VALUE_TO_ENUM_KEY[value];
if (enumKey) {
context.report({
node: element,
messageId: property === 'inputs' ? 'stringLiteralInInputs' : 'stringLiteralInOutputs',
data: { value, enumKey },
fix(fixer) {
return fixer.replaceText(element, `NodeConnectionTypes.${enumKey}`);
},
});
} else {
context.report({
node: element,
messageId:
property === 'inputs'
? 'unknownStringLiteralInInputs'
: 'unknownStringLiteralInOutputs',
data: { value },
});
}
}
}
return {
ClassDeclaration(node) {
if (!isNodeTypeClass(node)) return;
const descriptionProperty = findClassProperty(node, 'description');
if (!descriptionProperty) return;
const descriptionValue = descriptionProperty.value;
if (descriptionValue?.type !== AST_NODE_TYPES.ObjectExpression) return;
for (const prop of ['inputs', 'outputs'] as const) {
const property = findObjectProperty(descriptionValue, prop);
if (property?.value.type !== AST_NODE_TYPES.ArrayExpression) continue;
checkArrayElements(property.value.elements, prop);
}
},
};
},
});

View file

@ -1355,6 +1355,9 @@ importers:
eslint-plugin-eslint-plugin:
specifier: ^7.0.0
version: 7.0.0(eslint@9.29.0(jiti@2.6.1))
n8n-workflow:
specifier: workspace:*
version: link:../../workflow
rimraf:
specifier: 'catalog:'
version: 6.0.1