@n8n/eslint-plugin-community-nodes 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/README.md +5 -0
  3. package/dist/plugin.d.ts +36 -0
  4. package/dist/plugin.d.ts.map +1 -1
  5. package/dist/plugin.js +12 -0
  6. package/dist/plugin.js.map +1 -1
  7. package/dist/rules/cred-class-name-suffix.d.ts +2 -0
  8. package/dist/rules/cred-class-name-suffix.d.ts.map +1 -0
  9. package/dist/rules/cred-class-name-suffix.js +53 -0
  10. package/dist/rules/cred-class-name-suffix.js.map +1 -0
  11. package/dist/rules/cred-class-oauth2-naming.d.ts +2 -0
  12. package/dist/rules/cred-class-oauth2-naming.d.ts.map +1 -0
  13. package/dist/rules/cred-class-oauth2-naming.js +96 -0
  14. package/dist/rules/cred-class-oauth2-naming.js.map +1 -0
  15. package/dist/rules/index.d.ts +8 -0
  16. package/dist/rules/index.d.ts.map +1 -1
  17. package/dist/rules/index.js +12 -0
  18. package/dist/rules/index.js.map +1 -1
  19. package/dist/rules/n8n-object-validation.d.ts +5 -0
  20. package/dist/rules/n8n-object-validation.d.ts.map +1 -0
  21. package/dist/rules/n8n-object-validation.js +148 -0
  22. package/dist/rules/n8n-object-validation.js.map +1 -0
  23. package/dist/rules/no-builder-hint-leakage.d.ts +7 -0
  24. package/dist/rules/no-builder-hint-leakage.d.ts.map +1 -0
  25. package/dist/rules/no-builder-hint-leakage.js +99 -0
  26. package/dist/rules/no-builder-hint-leakage.js.map +1 -0
  27. package/dist/rules/no-overrides-field.js +1 -1
  28. package/dist/rules/no-overrides-field.js.map +1 -1
  29. package/dist/rules/no-template-placeholders.d.ts +2 -0
  30. package/dist/rules/no-template-placeholders.d.ts.map +1 -0
  31. package/dist/rules/no-template-placeholders.js +57 -0
  32. package/dist/rules/no-template-placeholders.js.map +1 -0
  33. package/dist/rules/node-operation-error-itemindex.d.ts +12 -0
  34. package/dist/rules/node-operation-error-itemindex.d.ts.map +1 -0
  35. package/dist/rules/node-operation-error-itemindex.js +184 -0
  36. package/dist/rules/node-operation-error-itemindex.js.map +1 -0
  37. package/dist/utils/ast-utils.d.ts.map +1 -1
  38. package/dist/utils/ast-utils.js +5 -1
  39. package/dist/utils/ast-utils.js.map +1 -1
  40. package/docs/rules/cred-class-name-suffix.md +46 -0
  41. package/docs/rules/cred-class-oauth2-naming.md +68 -0
  42. package/docs/rules/n8n-object-validation.md +93 -0
  43. package/docs/rules/no-overrides-field.md +5 -5
  44. package/docs/rules/no-template-placeholders.md +51 -0
  45. package/docs/rules/node-operation-error-itemindex.md +81 -0
  46. package/package.json +2 -2
  47. package/src/plugin.ts +12 -0
  48. package/src/rules/cred-class-name-suffix.test.ts +74 -0
  49. package/src/rules/cred-class-name-suffix.ts +57 -0
  50. package/src/rules/cred-class-oauth2-naming.test.ts +197 -0
  51. package/src/rules/cred-class-oauth2-naming.ts +118 -0
  52. package/src/rules/index.ts +12 -0
  53. package/src/rules/n8n-object-validation.test.ts +202 -0
  54. package/src/rules/n8n-object-validation.ts +200 -0
  55. package/src/rules/no-builder-hint-leakage.test.ts +84 -0
  56. package/src/rules/no-builder-hint-leakage.ts +112 -0
  57. package/src/rules/no-overrides-field.ts +1 -1
  58. package/src/rules/no-template-placeholders.test.ts +135 -0
  59. package/src/rules/no-template-placeholders.ts +68 -0
  60. package/src/rules/node-operation-error-itemindex.test.ts +280 -0
  61. package/src/rules/node-operation-error-itemindex.ts +223 -0
  62. package/src/utils/ast-utils.ts +5 -1
  63. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,200 @@
1
+ import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
2
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
3
+
4
+ import { createRule, findJsonProperty, getTopLevelObjectInJson } from '../utils/index.js';
5
+
6
+ type MessageIds =
7
+ | 'missingN8nObject'
8
+ | 'wrongLocationApiVersion'
9
+ | 'missingNodesApiVersion'
10
+ | 'invalidNodesApiVersion'
11
+ | 'missingN8nNodes'
12
+ | 'n8nNodesNotArray'
13
+ | 'emptyN8nNodes'
14
+ | 'n8nCredentialsNotArray'
15
+ | 'nodePathNotString'
16
+ | 'nodePathNotInDist'
17
+ | 'credentialPathNotString'
18
+ | 'credentialPathNotInDist'
19
+ | 'invalidStrict';
20
+
21
+ type Context = TSESLint.RuleContext<MessageIds, []>;
22
+
23
+ export const N8nObjectValidationRule = createRule<[], MessageIds>({
24
+ name: 'n8n-object-validation',
25
+ meta: {
26
+ type: 'problem',
27
+ docs: {
28
+ description:
29
+ 'Validate the structure of the "n8n" object in community node package.json (required keys, types, and dist/ paths)',
30
+ },
31
+ messages: {
32
+ missingN8nObject:
33
+ 'Community node package.json must contain an "n8n" object describing the package.',
34
+ wrongLocationApiVersion:
35
+ '"n8nNodesApiVersion" must be inside the "n8n" section, not at the root level of package.json.',
36
+ missingNodesApiVersion:
37
+ 'The "n8n" object must declare "n8nNodesApiVersion" (a positive integer).',
38
+ invalidNodesApiVersion:
39
+ '"n8n.n8nNodesApiVersion" must be a positive integer, got {{ value }}.',
40
+ missingN8nNodes: 'The "n8n" object must declare "nodes" as an array of "dist/" paths.',
41
+ n8nNodesNotArray: '"n8n.nodes" must be an array of "dist/" paths.',
42
+ emptyN8nNodes: '"n8n.nodes" must contain at least one path.',
43
+ n8nCredentialsNotArray: '"n8n.credentials" must be an array of "dist/" paths.',
44
+ nodePathNotString: 'Each entry in "n8n.nodes" must be a string starting with "dist/".',
45
+ nodePathNotInDist:
46
+ 'Path "{{ path }}" in "n8n.nodes" must start with "dist/" (compiled output).',
47
+ credentialPathNotString:
48
+ 'Each entry in "n8n.credentials" must be a string starting with "dist/".',
49
+ credentialPathNotInDist:
50
+ 'Path "{{ path }}" in "n8n.credentials" must start with "dist/" (compiled output).',
51
+ invalidStrict: '"n8n.strict" must be a boolean, got {{ value }}.',
52
+ },
53
+ schema: [],
54
+ },
55
+ defaultOptions: [],
56
+ create(context) {
57
+ if (!context.filename.endsWith('package.json')) {
58
+ return {};
59
+ }
60
+
61
+ return {
62
+ ObjectExpression(node: TSESTree.ObjectExpression) {
63
+ const root = getTopLevelObjectInJson(node);
64
+ if (!root) return;
65
+
66
+ // Catch n8nNodesApiVersion accidentally placed at root level.
67
+ const rootApiVersionProp = findJsonProperty(root, 'n8nNodesApiVersion');
68
+ if (rootApiVersionProp) {
69
+ context.report({
70
+ node: rootApiVersionProp,
71
+ messageId: 'wrongLocationApiVersion',
72
+ });
73
+ }
74
+
75
+ const n8nProp = findJsonProperty(root, 'n8n');
76
+ if (!n8nProp) {
77
+ context.report({
78
+ node: root,
79
+ messageId: 'missingN8nObject',
80
+ });
81
+ return;
82
+ }
83
+
84
+ if (n8nProp.value.type !== AST_NODE_TYPES.ObjectExpression) {
85
+ context.report({
86
+ node: n8nProp,
87
+ messageId: 'missingN8nObject',
88
+ });
89
+ return;
90
+ }
91
+
92
+ const n8nObject = n8nProp.value;
93
+
94
+ validateApiVersion(context, n8nObject);
95
+ validateNodes(context, n8nObject);
96
+ validateCredentials(context, n8nObject);
97
+ validateStrict(context, n8nObject);
98
+ },
99
+ };
100
+ },
101
+ });
102
+
103
+ function validateApiVersion(context: Context, n8nObject: TSESTree.ObjectExpression): void {
104
+ const apiVersionProp = findJsonProperty(n8nObject, 'n8nNodesApiVersion');
105
+ if (!apiVersionProp) {
106
+ context.report({ node: n8nObject, messageId: 'missingNodesApiVersion' });
107
+ return;
108
+ }
109
+
110
+ const valueNode = apiVersionProp.value;
111
+ if (valueNode.type !== AST_NODE_TYPES.Literal || !isPositiveInteger(valueNode.value)) {
112
+ context.report({
113
+ node: apiVersionProp,
114
+ messageId: 'invalidNodesApiVersion',
115
+ data: {
116
+ value: String(valueNode.type === AST_NODE_TYPES.Literal ? valueNode.value : 'non-literal'),
117
+ },
118
+ });
119
+ }
120
+ }
121
+
122
+ function validateStrict(context: Context, n8nObject: TSESTree.ObjectExpression): void {
123
+ const strictProp = findJsonProperty(n8nObject, 'strict');
124
+ if (!strictProp) return; // optional
125
+
126
+ const valueNode = strictProp.value;
127
+ if (valueNode.type !== AST_NODE_TYPES.Literal || typeof valueNode.value !== 'boolean') {
128
+ context.report({
129
+ node: strictProp,
130
+ messageId: 'invalidStrict',
131
+ data: {
132
+ value: String(valueNode.type === AST_NODE_TYPES.Literal ? valueNode.value : 'non-literal'),
133
+ },
134
+ });
135
+ }
136
+ }
137
+
138
+ function validateNodes(context: Context, n8nObject: TSESTree.ObjectExpression): void {
139
+ const nodesProp = findJsonProperty(n8nObject, 'nodes');
140
+ if (!nodesProp) {
141
+ context.report({ node: n8nObject, messageId: 'missingN8nNodes' });
142
+ return;
143
+ }
144
+
145
+ if (nodesProp.value.type !== AST_NODE_TYPES.ArrayExpression) {
146
+ context.report({ node: nodesProp, messageId: 'n8nNodesNotArray' });
147
+ return;
148
+ }
149
+
150
+ const elements = nodesProp.value.elements;
151
+ if (elements.length === 0) {
152
+ context.report({ node: nodesProp, messageId: 'emptyN8nNodes' });
153
+ return;
154
+ }
155
+
156
+ validatePathArray(context, elements, 'nodePathNotString', 'nodePathNotInDist');
157
+ }
158
+
159
+ function validateCredentials(context: Context, n8nObject: TSESTree.ObjectExpression): void {
160
+ const credentialsProp = findJsonProperty(n8nObject, 'credentials');
161
+ if (!credentialsProp) return; // optional
162
+
163
+ if (credentialsProp.value.type !== AST_NODE_TYPES.ArrayExpression) {
164
+ context.report({ node: credentialsProp, messageId: 'n8nCredentialsNotArray' });
165
+ return;
166
+ }
167
+
168
+ validatePathArray(
169
+ context,
170
+ credentialsProp.value.elements,
171
+ 'credentialPathNotString',
172
+ 'credentialPathNotInDist',
173
+ );
174
+ }
175
+
176
+ function validatePathArray(
177
+ context: Context,
178
+ elements: TSESTree.ArrayExpression['elements'],
179
+ notStringMessageId: 'nodePathNotString' | 'credentialPathNotString',
180
+ notInDistMessageId: 'nodePathNotInDist' | 'credentialPathNotInDist',
181
+ ): void {
182
+ for (const element of elements) {
183
+ if (!element) continue;
184
+ if (element.type !== AST_NODE_TYPES.Literal || typeof element.value !== 'string') {
185
+ context.report({ node: element, messageId: notStringMessageId });
186
+ continue;
187
+ }
188
+ if (!element.value.startsWith('dist/')) {
189
+ context.report({
190
+ node: element,
191
+ messageId: notInDistMessageId,
192
+ data: { path: element.value },
193
+ });
194
+ }
195
+ }
196
+ }
197
+
198
+ function isPositiveInteger(value: unknown): boolean {
199
+ return typeof value === 'number' && Number.isInteger(value) && value > 0;
200
+ }
@@ -0,0 +1,84 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+
3
+ import { NoBuilderHintLeakageRule } from './no-builder-hint-leakage.js';
4
+
5
+ const ruleTester = new RuleTester();
6
+
7
+ ruleTester.run('no-builder-hint-leakage', NoBuilderHintLeakageRule, {
8
+ valid: [
9
+ {
10
+ name: 'builderHint with no forbidden patterns',
11
+ code: 'const x = { builderHint: { propertyHint: "use expr() to embed expressions" } };',
12
+ },
13
+ {
14
+ name: 'builderHint using expr() form in example',
15
+ code: 'const x = { builderHint: { propertyHint: "e.g. expr(\'{{ $json.field }}\')" } };',
16
+ },
17
+ {
18
+ name: 'connection-type names as keys are allowed (wire-format structural data)',
19
+ code: 'const x = { builderHint: { inputs: { ai_languageModel: { required: true } } } };',
20
+ },
21
+ {
22
+ name: 'wire-format ={{ ... }} outside builderHint is fine in default scope',
23
+ code: "const inputs = '={{ $parameter.foo }}';",
24
+ },
25
+ {
26
+ name: 'connection-type literal outside builderHint is fine in default scope',
27
+ code: "const requires = ['ai_languageModel'];",
28
+ },
29
+ {
30
+ name: 'with scope=all, connection-type as key is still allowed',
31
+ code: "const inputs = { ai_languageModel: 'required' };",
32
+ options: [{ scope: 'all' }],
33
+ },
34
+ {
35
+ name: 'with scope=all, exact connection-type as value is allowed (structured data)',
36
+ code: "const config = { connectionType: 'ai_languageModel' };",
37
+ options: [{ scope: 'all' }],
38
+ },
39
+ {
40
+ name: 'with scope=all, connection-type in array of literals is allowed',
41
+ code: "const required = ['ai_tool', 'ai_memory'];",
42
+ options: [{ scope: 'all' }],
43
+ },
44
+ ],
45
+ invalid: [
46
+ {
47
+ name: 'wire-format ={{ ... }} inside builderHint message',
48
+ code: 'const x = { builderHint: { propertyHint: "e.g. ={{ $json.field }}" } };',
49
+ errors: [{ messageId: 'wireExpression' }],
50
+ },
51
+ {
52
+ name: 'wire-format ={{ ... }} inside template literal in builderHint',
53
+ code: 'const x = { builderHint: { propertyHint: `e.g. ={{ $json.field }}` } };',
54
+ errors: [{ messageId: 'wireExpression' }],
55
+ },
56
+ {
57
+ name: 'connection-type literal inside builderHint message',
58
+ code: 'const x = { builderHint: { searchHint: "requires ai_languageModel" } };',
59
+ errors: [{ messageId: 'connectionTypeLiteral' }],
60
+ },
61
+ {
62
+ name: 'multiple connection-type literals in one string',
63
+ code: 'const x = { builderHint: { propertyHint: "needs ai_languageModel and ai_tool" } };',
64
+ errors: [{ messageId: 'connectionTypeLiteral' }, { messageId: 'connectionTypeLiteral' }],
65
+ },
66
+ {
67
+ name: 'wire-format inside nested builderHint structure',
68
+ code: 'const x = { description: { builderHint: { tip: { message: "={{ $json.x }}" } } } };',
69
+ errors: [{ messageId: 'wireExpression' }],
70
+ },
71
+ {
72
+ name: 'with scope=all, wire-format anywhere is flagged',
73
+ code: 'const prompt = "use ={{ $json.foo }} pattern";',
74
+ options: [{ scope: 'all' }],
75
+ errors: [{ messageId: 'wireExpression' }],
76
+ },
77
+ {
78
+ name: 'with scope=all, connection-type literal in any string is flagged',
79
+ code: 'const prompt = "Connect via ai_tool to AI Agent";',
80
+ options: [{ scope: 'all' }],
81
+ errors: [{ messageId: 'connectionTypeLiteral' }],
82
+ },
83
+ ],
84
+ });
@@ -0,0 +1,112 @@
1
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2
+ import type { TSESTree } from '@typescript-eslint/utils';
3
+
4
+ import { createRule } from '../utils/index.js';
5
+
6
+ const WIRE_EXPRESSION_RE = /={{/;
7
+ const CONNECTION_NAME_GROUP =
8
+ 'agent|chain|document|embedding|languageModel|memory|outputParser|retriever|reranker|textSplitter|tool|vectorStore';
9
+ const CONNECTION_NAME_RE = new RegExp(`\\bai_(${CONNECTION_NAME_GROUP})\\b`, 'g');
10
+ // Strings that are *exactly* a connection name are structured data values,
11
+ // not prose, so they're allowed (e.g. `connectionType: 'ai_languageModel'`).
12
+ const EXACT_CONNECTION_NAME_RE = new RegExp(`^ai_(${CONNECTION_NAME_GROUP})$`);
13
+
14
+ type Options = [{ scope?: 'builderHint' | 'all' }];
15
+ type MessageIds = 'wireExpression' | 'connectionTypeLiteral';
16
+
17
+ function isPropertyKey(node: TSESTree.Node): boolean {
18
+ const parent = node.parent;
19
+ return parent?.type === AST_NODE_TYPES.Property && parent.key === node;
20
+ }
21
+
22
+ function isInsideBuilderHintValue(node: TSESTree.Node): boolean {
23
+ let current: TSESTree.Node | undefined = node;
24
+ let parent = current.parent;
25
+ while (parent) {
26
+ if (
27
+ parent.type === AST_NODE_TYPES.Property &&
28
+ parent.value === current &&
29
+ ((parent.key.type === AST_NODE_TYPES.Identifier && parent.key.name === 'builderHint') ||
30
+ (parent.key.type === AST_NODE_TYPES.Literal && parent.key.value === 'builderHint'))
31
+ ) {
32
+ return true;
33
+ }
34
+ current = parent;
35
+ parent = current.parent;
36
+ }
37
+ return false;
38
+ }
39
+
40
+ export const NoBuilderHintLeakageRule = createRule<Options, MessageIds>({
41
+ name: 'no-builder-hint-leakage',
42
+ meta: {
43
+ type: 'problem',
44
+ docs: {
45
+ description:
46
+ 'Disallow wire-format expression syntax (={{...}}) and NodeConnectionType string literals in builderHint texts and AI-builder prompts. Use expr() and SDK-canonical references instead.',
47
+ },
48
+ messages: {
49
+ wireExpression:
50
+ 'Wire-format expression syntax leaks into {{ ctx }}. Use the expr() SDK helper instead.',
51
+ connectionTypeLiteral:
52
+ 'NodeConnectionType literal "{{ value }}" leaks wire format into {{ ctx }}. Refer to the SDK helper instead (e.g. languageModel(), tool(), memory()).',
53
+ },
54
+ schema: [
55
+ {
56
+ type: 'object',
57
+ description:
58
+ 'Configures where the rule scans for forbidden patterns. Default `builderHint` only checks string values inside builderHint property values; `all` checks every string in the file (used for AI-builder prompts).',
59
+ properties: {
60
+ scope: {
61
+ type: 'string',
62
+ enum: ['builderHint', 'all'],
63
+ description:
64
+ '`builderHint` (default): only flag strings inside builderHint property values. `all`: flag every string literal/template in the file.',
65
+ },
66
+ },
67
+ additionalProperties: false,
68
+ },
69
+ ],
70
+ },
71
+ defaultOptions: [{}],
72
+ create(context, [options]) {
73
+ const scope = options.scope ?? 'builderHint';
74
+ const ctx = scope === 'all' ? 'AI-builder prompt' : 'builderHint';
75
+
76
+ function reportFor(node: TSESTree.Node, str: string): void {
77
+ if (WIRE_EXPRESSION_RE.test(str)) {
78
+ context.report({ node, messageId: 'wireExpression', data: { ctx } });
79
+ }
80
+ if (EXACT_CONNECTION_NAME_RE.test(str)) return;
81
+ CONNECTION_NAME_RE.lastIndex = 0;
82
+ let match: RegExpExecArray | null;
83
+ while ((match = CONNECTION_NAME_RE.exec(str)) !== null) {
84
+ context.report({
85
+ node,
86
+ messageId: 'connectionTypeLiteral',
87
+ data: { value: match[0], ctx },
88
+ });
89
+ }
90
+ }
91
+
92
+ function shouldCheck(node: TSESTree.Node): boolean {
93
+ if (isPropertyKey(node)) return false;
94
+ if (scope === 'all') return true;
95
+ return isInsideBuilderHintValue(node);
96
+ }
97
+
98
+ return {
99
+ Literal(node) {
100
+ if (typeof node.value !== 'string') return;
101
+ if (!shouldCheck(node)) return;
102
+ reportFor(node, node.value);
103
+ },
104
+ TemplateLiteral(node) {
105
+ if (!shouldCheck(node)) return;
106
+ for (const quasi of node.quasis) {
107
+ reportFor(quasi, quasi.value.cooked ?? quasi.value.raw);
108
+ }
109
+ },
110
+ };
111
+ },
112
+ });
@@ -12,7 +12,7 @@ export const NoOverridesFieldRule = createRule({
12
12
  },
13
13
  messages: {
14
14
  overridesForbidden:
15
- 'The "overrides" field is not allowed in community node packages. Dependency overrides can introduce incompatible versions of shared libraries into the n8n runtime and cause conflicts with other nodes.',
15
+ 'The "overrides" field is not allowed in community node packages. Each community package installs into an isolated dependency tree, so overrides do not affect other nodes or n8n core in practice they are copy-pasted boilerplate with no useful effect. Use the helpers on the execute context (this.helpers.httpRequest, etc.) instead; most community nodes do not need third-party runtime libraries.',
16
16
  },
17
17
  schema: [],
18
18
  },
@@ -0,0 +1,135 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+
3
+ import { NoTemplatePlaceholdersRule } from './no-template-placeholders.js';
4
+
5
+ const ruleTester = new RuleTester();
6
+
7
+ ruleTester.run('no-template-placeholders', NoTemplatePlaceholdersRule, {
8
+ valid: [
9
+ {
10
+ name: 'package.json with no placeholders',
11
+ filename: 'package.json',
12
+ code: `{
13
+ "name": "n8n-nodes-example",
14
+ "version": "1.0.0",
15
+ "description": "An example community node",
16
+ "homepage": "https://example.com",
17
+ "repository": { "type": "git", "url": "git+https://github.com/acme/n8n-nodes-example.git" }
18
+ }`,
19
+ },
20
+ {
21
+ name: 'angle brackets that do not look like placeholders are ignored',
22
+ filename: 'package.json',
23
+ code: '{ "description": "Compares a < b values" }',
24
+ },
25
+ {
26
+ name: 'single curly braces are ignored',
27
+ filename: 'package.json',
28
+ code: '{ "description": "Use { key: value } syntax" }',
29
+ },
30
+ {
31
+ name: 'non-package.json file is ignored even if it has placeholders',
32
+ filename: 'tsconfig.json',
33
+ code: '{ "name": "<PACKAGE_NAME>" }',
34
+ },
35
+ {
36
+ name: 'numeric and boolean values are not flagged',
37
+ filename: 'package.json',
38
+ code: '{ "name": "n8n-nodes-example", "private": false, "engines": { "node": ">=18" } }',
39
+ },
40
+ ],
41
+ invalid: [
42
+ {
43
+ name: 'angle bracket placeholder in name',
44
+ filename: 'package.json',
45
+ code: '{ "name": "n8n-nodes-<PACKAGE_NAME>" }',
46
+ errors: [
47
+ {
48
+ messageId: 'unresolvedPlaceholder',
49
+ data: { pattern: '<PACKAGE_NAME>' },
50
+ },
51
+ ],
52
+ },
53
+ {
54
+ name: 'angle bracket placeholder in description',
55
+ filename: 'package.json',
56
+ code: '{ "description": "An n8n community node for <SERVICE>" }',
57
+ errors: [
58
+ {
59
+ messageId: 'unresolvedPlaceholder',
60
+ data: { pattern: '<SERVICE>' },
61
+ },
62
+ ],
63
+ },
64
+ {
65
+ name: 'angle bracket placeholder in repository url',
66
+ filename: 'package.json',
67
+ code: '{ "repository": { "type": "git", "url": "git+https://github.com/<USERNAME>/n8n-nodes-example.git" } }',
68
+ errors: [
69
+ {
70
+ messageId: 'unresolvedPlaceholder',
71
+ data: { pattern: '<USERNAME>' },
72
+ },
73
+ ],
74
+ },
75
+ {
76
+ name: 'angle bracket placeholder in homepage',
77
+ filename: 'package.json',
78
+ code: '{ "homepage": "https://github.com/<USERNAME>/n8n-nodes-example#readme" }',
79
+ errors: [
80
+ {
81
+ messageId: 'unresolvedPlaceholder',
82
+ data: { pattern: '<USERNAME>' },
83
+ },
84
+ ],
85
+ },
86
+ {
87
+ name: 'mustache placeholder in author',
88
+ filename: 'package.json',
89
+ code: '{ "author": "{{ authorName }}" }',
90
+ errors: [
91
+ {
92
+ messageId: 'unresolvedPlaceholder',
93
+ data: { pattern: '{{ authorName }}' },
94
+ },
95
+ ],
96
+ },
97
+ {
98
+ name: 'mustache placeholder inside larger string',
99
+ filename: 'package.json',
100
+ code: '{ "description": "Node by {{author}} for service" }',
101
+ errors: [
102
+ {
103
+ messageId: 'unresolvedPlaceholder',
104
+ data: { pattern: '{{author}}' },
105
+ },
106
+ ],
107
+ },
108
+ {
109
+ name: 'placeholder in custom field',
110
+ filename: 'package.json',
111
+ code: '{ "n8n": { "n8nNodesApiVersion": 1, "credentials": ["<CREDENTIAL>"] } }',
112
+ errors: [
113
+ {
114
+ messageId: 'unresolvedPlaceholder',
115
+ data: { pattern: '<CREDENTIAL>' },
116
+ },
117
+ ],
118
+ },
119
+ {
120
+ name: 'multiple placeholders in different fields are all reported',
121
+ filename: 'package.json',
122
+ code: '{ "name": "<NAME>", "description": "{{description}}" }',
123
+ errors: [
124
+ {
125
+ messageId: 'unresolvedPlaceholder',
126
+ data: { pattern: '<NAME>' },
127
+ },
128
+ {
129
+ messageId: 'unresolvedPlaceholder',
130
+ data: { pattern: '{{description}}' },
131
+ },
132
+ ],
133
+ },
134
+ ],
135
+ });
@@ -0,0 +1,68 @@
1
+ import type { TSESTree } from '@typescript-eslint/utils';
2
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
3
+
4
+ import { createRule } from '../utils/index.js';
5
+
6
+ const ANGLE_PLACEHOLDER = /<[^<>\n]+?>/;
7
+ const MUSTACHE_PLACEHOLDER = /\{\{[^{}\n]+?\}\}/;
8
+
9
+ function findPlaceholder(value: string): { pattern: string; type: 'angle' | 'mustache' } | null {
10
+ const angleMatch = ANGLE_PLACEHOLDER.exec(value);
11
+ if (angleMatch) {
12
+ return { pattern: angleMatch[0], type: 'angle' };
13
+ }
14
+ const mustacheMatch = MUSTACHE_PLACEHOLDER.exec(value);
15
+ if (mustacheMatch) {
16
+ return { pattern: mustacheMatch[0], type: 'mustache' };
17
+ }
18
+ return null;
19
+ }
20
+
21
+ export const NoTemplatePlaceholdersRule = createRule({
22
+ name: 'no-template-placeholders',
23
+ meta: {
24
+ type: 'problem',
25
+ docs: {
26
+ description: 'Disallow unresolved template placeholders in package.json',
27
+ },
28
+ messages: {
29
+ unresolvedPlaceholder:
30
+ 'String value contains an unresolved template placeholder "{{ pattern }}". Replace it with a real value before publishing.',
31
+ },
32
+ schema: [],
33
+ },
34
+ defaultOptions: [],
35
+ create(context) {
36
+ if (!context.filename.endsWith('package.json')) {
37
+ return {};
38
+ }
39
+
40
+ return {
41
+ Literal(node: TSESTree.Literal) {
42
+ if (typeof node.value !== 'string') {
43
+ return;
44
+ }
45
+
46
+ // Skip property keys — only flag values.
47
+ if (
48
+ node.parent?.type === AST_NODE_TYPES.Property &&
49
+ node.parent.key === node &&
50
+ !node.parent.computed
51
+ ) {
52
+ return;
53
+ }
54
+
55
+ const placeholder = findPlaceholder(node.value);
56
+ if (!placeholder) {
57
+ return;
58
+ }
59
+
60
+ context.report({
61
+ node,
62
+ messageId: 'unresolvedPlaceholder',
63
+ data: { pattern: placeholder.pattern },
64
+ });
65
+ },
66
+ };
67
+ },
68
+ });