diff --git a/packages/@n8n/eslint-plugin-community-nodes/README.md b/packages/@n8n/eslint-plugin-community-nodes/README.md index e9f96201f83..006dcaa70e0 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/README.md +++ b/packages/@n8n/eslint-plugin-community-nodes/README.md @@ -88,7 +88,7 @@ export default [ | [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 | ✅ ☑️ | | | | | -| [valid-node-categories](docs/rules/valid-node-categories.md) | Require supported community node categories in .node.json codex files | ✅ ☑️ | | | | | +| [valid-node-categories](docs/rules/valid-node-categories.md) | Require supported community node codex categories and AI subcategories | ✅ ☑️ | | | | | | [valid-peer-dependencies](docs/rules/valid-peer-dependencies.md) | Require community node package.json peerDependencies to contain only "n8n-workflow": "*" (and optionally "@n8n/ai-node-sdk") | ✅ ☑️ | | 🔧 | | | | [webhook-lifecycle-complete](docs/rules/webhook-lifecycle-complete.md) | Require webhook trigger nodes to implement the complete webhookMethods lifecycle (checkExists, create, delete) | ✅ ☑️ | | | | | diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/valid-node-categories.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/valid-node-categories.md index 892752432d0..a96475d6426 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/valid-node-categories.md +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/valid-node-categories.md @@ -1,4 +1,4 @@ -# Require supported community node categories in .node.json codex files (`@n8n/community-nodes/valid-node-categories`) +# Require supported community node codex categories and AI subcategories (`@n8n/community-nodes/valid-node-categories`) 💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. @@ -6,7 +6,9 @@ ## Rule Details -This rule checks `categories` in community node `.node.json` codex files. Each category must match a value in the [node category list](https://docs.n8n.io/connect/create-nodes/build-your-node/reference/codex-files/#node-categories). The rule also rejects empty arrays and values that are not strings. The `categories` field is optional. +This rule checks `categories` in community node `.node.json` files and inline `description.codex` objects in `.node.ts` files. Each category must match a value in the [node category list](https://docs.n8n.io/connect/create-nodes/build-your-node/reference/codex-files/#node-categories) or the `AI` category. The rule also rejects empty arrays and values that are not strings. The `categories` field is optional. + +The `AI` category requires `subcategories.AI` with at least one supported AI subcategory. Use the AI subcategory names from the node creator, such as `Language Models`, `Tools`, or `Root Nodes`. A node must not declare `subcategories.AI` without the `AI` category. Non-AI community nodes must not declare `subcategories`. ## Examples @@ -18,6 +20,12 @@ This rule checks `categories` in community node `.node.json` codex files. Each c } ``` +```typescript +description: INodeTypeDescription = { + codex: { categories: ['AI'], subcategories: { AI: ['Agents & Tools'] } }, +}; +``` + ### ✅ Correct ```json @@ -25,3 +33,9 @@ This rule checks `categories` in community node `.node.json` codex files. Each c "categories": ["Marketing & Content", "Development"] } ``` + +```typescript +description: INodeTypeDescription = { + codex: { categories: ['AI'], subcategories: { AI: ['Language Models', 'Root Nodes'] } }, +}; +``` diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/valid-node-categories.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/valid-node-categories.test.ts index e725985ba92..c5775b6d7fa 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/valid-node-categories.test.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/valid-node-categories.test.ts @@ -9,6 +9,52 @@ function codex(categories: unknown): string { return JSON.stringify({ node: 'n8n-nodes-example', categories }); } +function inlineCodex(value: string): string { + return `class Example implements INodeType { + description: INodeTypeDescription = { codex: ${value} }; + }`; +} + +const aiCodex = JSON.stringify({ categories: ['AI'], subcategories: { AI: ['Language Models'] } }); + +const invalidCodexCases = [ + { + name: 'unknown top-level category', + code: '{ "categories": ["Bananas"] }', + errors: [{ messageId: 'invalidCategory', data: { category: 'Bananas' } }], + }, + { + name: 'unsupported AI subcategory', + code: '{ "categories": ["AI"], "subcategories": { "AI": ["Agents & Tools"] } }', + errors: [{ messageId: 'invalidAiSubcategory', data: { subcategory: 'Agents & Tools' } }], + }, + { + name: 'AI category without AI subcategories', + code: '{ "categories": ["AI"] }', + errors: [{ messageId: 'missingAiSubcategories' }], + }, + { + name: 'AI subcategories without AI category', + code: '{ "categories": ["Development"], "subcategories": { "AI": ["Tools"] } }', + errors: [{ messageId: 'missingAiCategory' }], + }, + { + name: 'empty AI subcategories', + code: '{ "categories": ["AI"], "subcategories": { "AI": [] } }', + errors: [{ messageId: 'missingAiSubcategories' }], + }, + { + name: 'non-string AI subcategory', + code: '{ "categories": ["AI"], "subcategories": { "AI": [123] } }', + errors: [{ messageId: 'invalidAiSubcategoryType' }], + }, + { + name: 'subcategories without an AI category', + code: '{ "categories": ["Sales"], "subcategories": { "Core Nodes": ["Helpers"] } }', + errors: [{ messageId: 'invalidSubcategories' }], + }, +] as const; + ruleTester.run('valid-node-categories', ValidNodeCategoriesRule, { valid: [ { @@ -28,6 +74,39 @@ ruleTester.run('valid-node-categories', ValidNodeCategoriesRule, { ]), }, { name: 'codex without categories', filename, code: '{ "node": "n8n-nodes-example" }' }, + { name: 'AI category with a supported subcategory in JSON', filename, code: aiCodex }, + { + name: 'AI category with a supported subcategory inline', + filename: 'nodes/Example/Example.node.ts', + code: inlineCodex(aiCodex), + }, + { + name: 'AI node with a secondary subcategory', + filename: 'nodes/Example/Example.node.ts', + code: inlineCodex( + '{ categories: ["AI"], subcategories: { AI: ["Language Models", "Root Nodes"], "Language Models": ["Chat Models (Recommended)"] } }', + ), + }, + { + name: 'regular category in an inline codex', + filename: 'nodes/Example/Example.node.ts', + code: inlineCodex('{ categories: ["Marketing & Content"] }'), + }, + { + name: 'a class without INodeType is ignored', + filename: 'nodes/Example/Example.node.ts', + code: 'class Example { description = { codex: { categories: ["Bananas"] } }; }', + }, + { + name: 'a nested codex outside the node description is ignored', + filename: 'nodes/Example/Example.node.ts', + code: inlineCodex('{ categories: ["Development"], resources: { categories: ["Bananas"] } }'), + }, + { + name: 'codex in a constructor-assigned description', + filename: 'nodes/Example/Example.node.ts', + code: 'class Example implements INodeType { constructor() { this.description = { codex: { categories: ["Sales"] } }; } }', + }, { name: 'unrelated JSON file', filename: 'package.json', @@ -75,5 +154,20 @@ ruleTester.run('valid-node-categories', ValidNodeCategoriesRule, { code: codex([]), errors: [{ messageId: 'invalidCategories' }], }, + { + name: 'unknown category in a constructor-assigned description', + filename: 'nodes/Example/Example.node.ts', + code: 'class Example implements INodeType { constructor() { this.description = { codex: { categories: ["Bananas"] } }; } }', + errors: [{ messageId: 'invalidCategory', data: { category: 'Bananas' } }], + }, + ...invalidCodexCases.flatMap(({ name, code, errors }) => [ + { name: `${name} in JSON`, filename, code, errors }, + { + name: `${name} inline`, + filename: 'nodes/Example/Example.node.ts', + code: inlineCodex(code), + errors, + }, + ]), ], }); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/valid-node-categories.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/valid-node-categories.ts index b3bd5f0f846..85297bc37cf 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/valid-node-categories.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/valid-node-categories.ts @@ -1,7 +1,13 @@ import type { TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; -import { createRule, findJsonProperty, getTopLevelObjectInJson } from '../utils/index.js'; +import { + createRule, + findNodeDescriptionObject, + findObjectProperty, + getTopLevelObjectInJson, + isNodeTypeClass, +} from '../utils/index.js'; const validCategories = new Set([ 'Data & Storage', @@ -14,6 +20,29 @@ const validCategories = new Set([ 'Analytics', 'Communication', 'Utility', + 'AI', +]); + +// Keep these values in sync with the AI_CATEGORY_* constants in editor-ui/src/app/constants/nodeCreator.ts. +const validAiSubcategories = new Set([ + 'Agents', + 'Chains', + 'Language Models', + 'Memory', + 'Output Parsers', + 'Tools', + 'Vector Stores', + 'Retrievers', + 'Embeddings', + 'Document Loaders', + 'Text Splitters', + 'Other Tools', + 'Root Nodes', + 'Model Context Protocol', + 'Human in the Loop', + 'Miscellaneous', + 'Rerankers', + 'Evaluation', ]); export const ValidNodeCategoriesRule = createRule({ @@ -21,7 +50,7 @@ export const ValidNodeCategoriesRule = createRule({ meta: { type: 'problem', docs: { - description: 'Require supported community node categories in .node.json codex files', + description: 'Require supported community node codex categories and AI subcategories', }, messages: { invalidCategories: '"categories" must be a non-empty array of community node categories.', @@ -29,21 +58,27 @@ export const ValidNodeCategoriesRule = createRule({ invalidCategory: '"{{ category }}" is not a supported community node category. See https://docs.n8n.io/connect/create-nodes/build-your-node/reference/codex-files/#node-categories for the allowed values.', marketingCategory: '"Marketing" is not a supported category. Use "Marketing & Content".', + missingAiSubcategories: 'The "AI" category requires a non-empty subcategories.AI array.', + missingAiCategory: 'subcategories.AI requires "AI" in categories.', + invalidSubcategories: 'subcategories must be an object with an AI category.', + invalidAiSubcategoryType: 'Each AI subcategory must be a supported string value.', + invalidAiSubcategory: '"{{ subcategory }}" is not a supported AI subcategory.', }, schema: [], }, defaultOptions: [], create(context) { - if (!context.filename.endsWith('.node.json')) return {}; + if (!context.filename.endsWith('.node.json') && !context.filename.endsWith('.node.ts')) { + return {}; + } - return { - ObjectExpression(node: TSESTree.ObjectExpression) { - const root = getTopLevelObjectInJson(node); - if (!root) return; - - const categories = findJsonProperty(root, 'categories'); - if (!categories) return; + function checkCodex(codex: TSESTree.ObjectExpression) { + const categories = findObjectProperty(codex, 'categories'); + const subcategories = findObjectProperty(codex, 'subcategories'); + if (!categories && !subcategories) return; + let hasAiCategory = false; + if (categories) { if ( categories.value.type !== AST_NODE_TYPES.ArrayExpression || categories.value.elements.length === 0 @@ -58,6 +93,8 @@ export const ValidNodeCategoriesRule = createRule({ context.report({ node: category, messageId: 'invalidCategoryType' }); } else if (category.value === 'Marketing') { context.report({ node: category, messageId: 'marketingCategory' }); + } else if (category.value === 'AI') { + hasAiCategory = true; } else if (!validCategories.has(category.value)) { context.report({ node: category, @@ -66,6 +103,66 @@ export const ValidNodeCategoriesRule = createRule({ }); } } + } + + if (subcategories && subcategories.value.type !== AST_NODE_TYPES.ObjectExpression) { + context.report({ node: subcategories.value, messageId: 'invalidSubcategories' }); + return; + } + + const aiSubcategories = + subcategories?.value.type === AST_NODE_TYPES.ObjectExpression + ? findObjectProperty(subcategories.value, 'AI') + : null; + if (hasAiCategory && !aiSubcategories) { + context.report({ node: categories ?? codex, messageId: 'missingAiSubcategories' }); + } + if (aiSubcategories && !hasAiCategory) { + context.report({ node: aiSubcategories, messageId: 'missingAiCategory' }); + } + if (subcategories && !hasAiCategory && !aiSubcategories) { + context.report({ node: subcategories, messageId: 'invalidSubcategories' }); + } + if (!aiSubcategories) return; + + if ( + aiSubcategories.value.type !== AST_NODE_TYPES.ArrayExpression || + aiSubcategories.value.elements.length === 0 + ) { + context.report({ node: aiSubcategories.value, messageId: 'missingAiSubcategories' }); + return; + } + + for (const subcategory of aiSubcategories.value.elements) { + if (!subcategory) continue; + if (subcategory.type !== AST_NODE_TYPES.Literal || typeof subcategory.value !== 'string') { + context.report({ node: subcategory, messageId: 'invalidAiSubcategoryType' }); + } else if (!validAiSubcategories.has(subcategory.value)) { + context.report({ + node: subcategory, + messageId: 'invalidAiSubcategory', + data: { subcategory: subcategory.value }, + }); + } + } + } + + if (context.filename.endsWith('.node.json')) { + return { + ObjectExpression(node: TSESTree.ObjectExpression) { + const root = getTopLevelObjectInJson(node); + if (root) checkCodex(root); + }, + }; + } + + return { + ClassDeclaration(node) { + if (!isNodeTypeClass(node)) return; + const description = findNodeDescriptionObject(node); + if (!description) return; + const codex = findObjectProperty(description, 'codex'); + if (codex?.value.type === AST_NODE_TYPES.ObjectExpression) checkCodex(codex.value); }, }; }, diff --git a/packages/@n8n/node-cli/src/template/templates/programmatic/ai/memory-custom/template/nodes/ExampleChatMemory/ExampleChatMemory.node.ts b/packages/@n8n/node-cli/src/template/templates/programmatic/ai/memory-custom/template/nodes/ExampleChatMemory/ExampleChatMemory.node.ts index dbf80f7f210..33638b23537 100644 --- a/packages/@n8n/node-cli/src/template/templates/programmatic/ai/memory-custom/template/nodes/ExampleChatMemory/ExampleChatMemory.node.ts +++ b/packages/@n8n/node-cli/src/template/templates/programmatic/ai/memory-custom/template/nodes/ExampleChatMemory/ExampleChatMemory.node.ts @@ -19,7 +19,7 @@ export class ExampleChatMemory implements INodeType { name: 'Example Memory', }, codex: { - categories: ['assistant'], + categories: ['AI'], subcategories: { AI: ['Memory', 'Root Nodes'], Memory: ['Other memories'], diff --git a/packages/@n8n/node-cli/src/template/templates/programmatic/ai/model-ai-custom-example/template/nodes/ExampleChatModel/ExampleChatModel.node.ts b/packages/@n8n/node-cli/src/template/templates/programmatic/ai/model-ai-custom-example/template/nodes/ExampleChatModel/ExampleChatModel.node.ts index ab3a77c4707..0e5935867d7 100644 --- a/packages/@n8n/node-cli/src/template/templates/programmatic/ai/model-ai-custom-example/template/nodes/ExampleChatModel/ExampleChatModel.node.ts +++ b/packages/@n8n/node-cli/src/template/templates/programmatic/ai/model-ai-custom-example/template/nodes/ExampleChatModel/ExampleChatModel.node.ts @@ -26,7 +26,7 @@ export class ExampleChatModel implements INodeType { name: 'Example Chat Model', }, codex: { - categories: ['assistant'], + categories: ['AI'], subcategories: { AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Chat Models (Recommended)'], diff --git a/packages/@n8n/node-cli/src/template/templates/programmatic/ai/model-ai-custom/template/nodes/ExampleChatModel/ExampleChatModel.node.ts b/packages/@n8n/node-cli/src/template/templates/programmatic/ai/model-ai-custom/template/nodes/ExampleChatModel/ExampleChatModel.node.ts index ab6faa330ba..30540092599 100644 --- a/packages/@n8n/node-cli/src/template/templates/programmatic/ai/model-ai-custom/template/nodes/ExampleChatModel/ExampleChatModel.node.ts +++ b/packages/@n8n/node-cli/src/template/templates/programmatic/ai/model-ai-custom/template/nodes/ExampleChatModel/ExampleChatModel.node.ts @@ -19,7 +19,7 @@ export class ExampleChatModel implements INodeType { name: 'Example Chat Model', }, codex: { - categories: ['assistant'], + categories: ['AI'], subcategories: { AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Chat Models (Recommended)'], diff --git a/packages/@n8n/node-cli/src/template/templates/programmatic/ai/model-openai-compatible/template/nodes/ExampleChatModel/ExampleChatModel.node.ts b/packages/@n8n/node-cli/src/template/templates/programmatic/ai/model-openai-compatible/template/nodes/ExampleChatModel/ExampleChatModel.node.ts index 00aaf60628d..c98a14d8e6e 100644 --- a/packages/@n8n/node-cli/src/template/templates/programmatic/ai/model-openai-compatible/template/nodes/ExampleChatModel/ExampleChatModel.node.ts +++ b/packages/@n8n/node-cli/src/template/templates/programmatic/ai/model-openai-compatible/template/nodes/ExampleChatModel/ExampleChatModel.node.ts @@ -18,7 +18,7 @@ export class ExampleChatModel implements INodeType { name: 'Example Chat Model', }, codex: { - categories: ['assistant'], + categories: ['AI'], subcategories: { AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Chat Models (Recommended)'],