fix: validate inline codex categories and AI subcategories

This commit is contained in:
n8n-cat-bot[bot] 2026-09-25 07:58:28 +00:00
parent 1a4a91256d
commit 0ffc193634
8 changed files with 222 additions and 17 deletions

View File

@ -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) | ✅ ☑️ | | | | |

View File

@ -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'] } },
};
```

View File

@ -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,
},
]),
],
});

View File

@ -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);
},
};
},

View File

@ -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'],

View File

@ -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)'],

View File

@ -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)'],

View File

@ -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)'],