mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-18 14:56:50 +02:00
fix: validate inline codex categories and AI subcategories
This commit is contained in:
parent
1a4a91256d
commit
0ffc193634
|
|
@ -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) | ✅ ☑️ | | | | |
|
||||
|
||||
|
|
|
|||
|
|
@ -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'] } },
|
||||
};
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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)'],
|
||||
|
|
|
|||
|
|
@ -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)'],
|
||||
|
|
|
|||
|
|
@ -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)'],
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user