From fb2bc1ca5f63a68f10fbdeac9f145c1bebd28407 Mon Sep 17 00:00:00 2001 From: Garrit Franke <32395585+garritfra@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:26:11 +0200 Subject: [PATCH] feat: Add require-community-node-keyword ESLint rule (no-changelog) (#28395) --- .../rules/require-community-node-keyword.md | 45 ++++++++++ .../src/plugin.ts | 2 + .../src/rules/index.ts | 2 + .../require-community-node-keyword.test.ts | 82 +++++++++++++++++++ .../rules/require-community-node-keyword.ts | 78 ++++++++++++++++++ 5 files changed, 209 insertions(+) create mode 100644 packages/@n8n/eslint-plugin-community-nodes/docs/rules/require-community-node-keyword.md create mode 100644 packages/@n8n/eslint-plugin-community-nodes/src/rules/require-community-node-keyword.test.ts create mode 100644 packages/@n8n/eslint-plugin-community-nodes/src/rules/require-community-node-keyword.ts diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/require-community-node-keyword.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/require-community-node-keyword.md new file mode 100644 index 00000000000..09f30327aec --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/require-community-node-keyword.md @@ -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). + + + +## 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"] +} +``` diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts b/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts index 8665ae138bd..7ca7cea4bbb 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts @@ -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', }, diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts index 362d645432b..4b77332d7f3 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts @@ -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; diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/require-community-node-keyword.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/require-community-node-keyword.test.ts new file mode 100644 index 00000000000..e1ab0f28c5d --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/require-community-node-keyword.test.ts @@ -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' }], + }, + ], +}); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/require-community-node-keyword.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/require-community-node-keyword.ts new file mode 100644 index 00000000000..5076469b799 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/require-community-node-keyword.ts @@ -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}"`); + }, + }); + } + }, + }; + }, +});