feat: Add require-community-node-keyword ESLint rule (no-changelog) (#28395)

This commit is contained in:
Garrit Franke 2026-04-16 19:26:11 +02:00 committed by GitHub
parent 04860d5cd7
commit fb2bc1ca5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 209 additions and 0 deletions

View file

@ -0,0 +1,45 @@
# Require the "n8n-community-node-package" keyword in package.json (`@n8n/community-nodes/require-community-node-keyword`)
⚠️ This rule is set to `warn` in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/use/command-line-interface#--fix).
<!-- end auto-generated rule header -->
## Rule Details
Validates that the `package.json` of a community node package includes `"n8n-community-node-package"` in its `keywords` array. This keyword is required for n8n to discover and list community node packages.
## Examples
### ❌ Incorrect
```json
{
"name": "n8n-nodes-my-service",
"version": "1.0.0"
}
```
```json
{
"name": "n8n-nodes-my-service",
"keywords": ["n8n", "automation"]
}
```
### ✅ Correct
```json
{
"name": "n8n-nodes-my-service",
"keywords": ["n8n-community-node-package"]
}
```
```json
{
"name": "n8n-nodes-my-service",
"keywords": ["n8n", "automation", "n8n-community-node-package"]
}
```

View file

@ -38,6 +38,7 @@ const configs = {
'@n8n/community-nodes/cred-class-field-icon-missing': 'error',
'@n8n/community-nodes/node-connection-type-literal': 'error',
'@n8n/community-nodes/missing-paired-item': 'error',
'@n8n/community-nodes/require-community-node-keyword': 'warn',
'@n8n/community-nodes/require-continue-on-fail': 'error',
'@n8n/community-nodes/require-node-description-fields': 'error',
},
@ -64,6 +65,7 @@ const configs = {
'@n8n/community-nodes/cred-class-field-icon-missing': 'error',
'@n8n/community-nodes/node-connection-type-literal': 'error',
'@n8n/community-nodes/missing-paired-item': 'error',
'@n8n/community-nodes/require-community-node-keyword': 'warn',
'@n8n/community-nodes/require-continue-on-fail': 'error',
'@n8n/community-nodes/require-node-description-fields': 'error',
},

View file

@ -18,6 +18,7 @@ import { NodeConnectionTypeLiteralRule } from './node-connection-type-literal.js
import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
import { OptionsSortedAlphabeticallyRule } from './options-sorted-alphabetically.js';
import { PackageNameConventionRule } from './package-name-convention.js';
import { RequireCommunityNodeKeywordRule } from './require-community-node-keyword.js';
import { RequireContinueOnFailRule } from './require-continue-on-fail.js';
import { RequireNodeDescriptionFieldsRule } from './require-node-description-fields.js';
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
@ -42,6 +43,7 @@ export const rules = {
'cred-class-field-icon-missing': CredClassFieldIconMissingRule,
'node-connection-type-literal': NodeConnectionTypeLiteralRule,
'missing-paired-item': MissingPairedItemRule,
'require-community-node-keyword': RequireCommunityNodeKeywordRule,
'require-continue-on-fail': RequireContinueOnFailRule,
'require-node-description-fields': RequireNodeDescriptionFieldsRule,
} satisfies Record<string, AnyRuleModule>;

View file

@ -0,0 +1,82 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { RequireCommunityNodeKeywordRule } from './require-community-node-keyword.js';
const ruleTester = new RuleTester();
ruleTester.run('require-community-node-keyword', RequireCommunityNodeKeywordRule, {
valid: [
{
name: 'keywords array contains required keyword',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "keywords": ["n8n-community-node-package"] }',
},
{
name: 'keywords array contains required keyword among others',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "keywords": ["n8n", "automation", "n8n-community-node-package", "workflow"] }',
},
{
name: 'non-package.json file is ignored',
filename: 'some-config.json',
code: '{ "name": "n8n-nodes-example" }',
},
{
name: 'nested objects are not checked',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "keywords": ["n8n-community-node-package"], "config": { "nested": "value" } }',
},
{
name: 'objects inside arrays (e.g. contributors) are not flagged',
filename: 'package.json',
code: `{
"name": "n8n-nodes-example",
"keywords": ["n8n-community-node-package"],
"contributors": [
{ "name": "Alice", "email": "alice@example.com" },
{ "name": "Bob", "email": "bob@example.com" }
]
}`,
},
],
invalid: [
{
name: 'missing keywords array entirely',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
output:
'{ "name": "n8n-nodes-example", "version": "1.0.0", "keywords": ["n8n-community-node-package"] }',
errors: [{ messageId: 'missingKeywordsArray' }],
},
{
name: 'empty keywords array',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "keywords": [] }',
output: '{ "name": "n8n-nodes-example", "keywords": ["n8n-community-node-package"] }',
errors: [{ messageId: 'missingKeyword' }],
},
{
name: 'keywords array without the required keyword',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "keywords": ["n8n", "automation"] }',
output:
'{ "name": "n8n-nodes-example", "keywords": ["n8n", "automation", "n8n-community-node-package"] }',
errors: [{ messageId: 'missingKeyword' }],
},
{
name: 'keywords array with similar but incorrect keyword',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "keywords": ["n8n-community-node"] }',
output:
'{ "name": "n8n-nodes-example", "keywords": ["n8n-community-node", "n8n-community-node-package"] }',
errors: [{ messageId: 'missingKeyword' }],
},
{
name: 'empty package.json object',
filename: 'package.json',
code: '{}',
output: '{ "keywords": ["n8n-community-node-package"] }',
errors: [{ messageId: 'missingKeywordsArray' }],
},
],
});

View file

@ -0,0 +1,78 @@
import type { TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import { createRule, findJsonProperty } from '../utils/index.js';
const REQUIRED_KEYWORD = 'n8n-community-node-package';
export const RequireCommunityNodeKeywordRule = createRule({
name: 'require-community-node-keyword',
meta: {
type: 'problem',
docs: {
description:
'Require the "n8n-community-node-package" keyword in community node package.json',
},
fixable: 'code',
messages: {
missingKeyword: `The "keywords" array must include "${REQUIRED_KEYWORD}". This keyword is required for n8n to discover community node packages.`,
missingKeywordsArray: `The package.json must have a "keywords" array containing "${REQUIRED_KEYWORD}".`,
},
schema: [],
},
defaultOptions: [],
create(context) {
if (!context.filename.endsWith('package.json')) {
return {};
}
return {
ObjectExpression(node: TSESTree.ObjectExpression) {
if (node.parent?.type !== AST_NODE_TYPES.ExpressionStatement) {
return;
}
const keywordsProp = findJsonProperty(node, 'keywords');
if (!keywordsProp) {
context.report({
node,
messageId: 'missingKeywordsArray',
fix(fixer) {
const lastProp = node.properties[node.properties.length - 1];
if (!lastProp) {
return fixer.replaceText(node, `{ "keywords": ["${REQUIRED_KEYWORD}"] }`);
}
return fixer.insertTextAfter(lastProp, `, "keywords": ["${REQUIRED_KEYWORD}"]`);
},
});
return;
}
if (keywordsProp.value.type !== AST_NODE_TYPES.ArrayExpression) {
return;
}
const keywordsArray = keywordsProp.value;
const hasRequiredKeyword = keywordsArray.elements.some(
(element) =>
element?.type === AST_NODE_TYPES.Literal && element.value === REQUIRED_KEYWORD,
);
if (!hasRequiredKeyword) {
context.report({
node: keywordsProp,
messageId: 'missingKeyword',
fix(fixer) {
const lastElement = keywordsArray.elements[keywordsArray.elements.length - 1];
if (!lastElement) {
return fixer.replaceText(keywordsArray, `["${REQUIRED_KEYWORD}"]`);
}
return fixer.insertTextAfter(lastElement, `, "${REQUIRED_KEYWORD}"`);
},
});
}
},
};
},
});