mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
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:
parent
70be3f5990
commit
1140c83565
8 changed files with 300 additions and 18 deletions
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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]'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue