@n8n/eslint-plugin-community-nodes 0.14.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 (83) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/README.md +7 -0
  3. package/dist/plugin.d.ts +48 -0
  4. package/dist/plugin.d.ts.map +1 -1
  5. package/dist/plugin.js +16 -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 +11 -1
  16. package/dist/rules/index.d.ts.map +1 -1
  17. package/dist/rules/index.js +16 -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-runtime-dependencies.d.ts +2 -0
  30. package/dist/rules/no-runtime-dependencies.d.ts.map +1 -0
  31. package/dist/rules/no-runtime-dependencies.js +41 -0
  32. package/dist/rules/no-runtime-dependencies.js.map +1 -0
  33. package/dist/rules/no-template-placeholders.d.ts +2 -0
  34. package/dist/rules/no-template-placeholders.d.ts.map +1 -0
  35. package/dist/rules/no-template-placeholders.js +57 -0
  36. package/dist/rules/no-template-placeholders.js.map +1 -0
  37. package/dist/rules/node-operation-error-itemindex.d.ts +12 -0
  38. package/dist/rules/node-operation-error-itemindex.d.ts.map +1 -0
  39. package/dist/rules/node-operation-error-itemindex.js +184 -0
  40. package/dist/rules/node-operation-error-itemindex.js.map +1 -0
  41. package/dist/rules/valid-credential-references.d.ts +2 -0
  42. package/dist/rules/valid-credential-references.d.ts.map +1 -0
  43. package/dist/rules/valid-credential-references.js +77 -0
  44. package/dist/rules/valid-credential-references.js.map +1 -0
  45. package/dist/rules/webhook-lifecycle-complete.d.ts +1 -1
  46. package/dist/rules/webhook-lifecycle-complete.d.ts.map +1 -1
  47. package/dist/rules/webhook-lifecycle-complete.js +8 -0
  48. package/dist/rules/webhook-lifecycle-complete.js.map +1 -1
  49. package/dist/utils/ast-utils.d.ts.map +1 -1
  50. package/dist/utils/ast-utils.js +5 -1
  51. package/dist/utils/ast-utils.js.map +1 -1
  52. package/docs/rules/cred-class-name-suffix.md +46 -0
  53. package/docs/rules/cred-class-oauth2-naming.md +68 -0
  54. package/docs/rules/n8n-object-validation.md +93 -0
  55. package/docs/rules/no-overrides-field.md +5 -5
  56. package/docs/rules/no-runtime-dependencies.md +58 -0
  57. package/docs/rules/no-template-placeholders.md +51 -0
  58. package/docs/rules/node-operation-error-itemindex.md +81 -0
  59. package/docs/rules/valid-credential-references.md +78 -0
  60. package/package.json +3 -3
  61. package/src/plugin.ts +16 -0
  62. package/src/rules/cred-class-name-suffix.test.ts +74 -0
  63. package/src/rules/cred-class-name-suffix.ts +57 -0
  64. package/src/rules/cred-class-oauth2-naming.test.ts +197 -0
  65. package/src/rules/cred-class-oauth2-naming.ts +118 -0
  66. package/src/rules/index.ts +16 -0
  67. package/src/rules/n8n-object-validation.test.ts +202 -0
  68. package/src/rules/n8n-object-validation.ts +200 -0
  69. package/src/rules/no-builder-hint-leakage.test.ts +84 -0
  70. package/src/rules/no-builder-hint-leakage.ts +112 -0
  71. package/src/rules/no-overrides-field.ts +1 -1
  72. package/src/rules/no-runtime-dependencies.test.ts +50 -0
  73. package/src/rules/no-runtime-dependencies.ts +50 -0
  74. package/src/rules/no-template-placeholders.test.ts +135 -0
  75. package/src/rules/no-template-placeholders.ts +68 -0
  76. package/src/rules/node-operation-error-itemindex.test.ts +280 -0
  77. package/src/rules/node-operation-error-itemindex.ts +223 -0
  78. package/src/rules/valid-credential-references.test.ts +230 -0
  79. package/src/rules/valid-credential-references.ts +105 -0
  80. package/src/rules/webhook-lifecycle-complete.test.ts +5 -0
  81. package/src/rules/webhook-lifecycle-complete.ts +10 -0
  82. package/src/utils/ast-utils.ts +5 -1
  83. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,118 @@
1
+ import type { TSESTree } from '@typescript-eslint/utils';
2
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
3
+
4
+ import {
5
+ createRule,
6
+ findClassProperty,
7
+ getStringLiteralValue,
8
+ isCredentialTypeClass,
9
+ isFileType,
10
+ } from '../utils/index.js';
11
+
12
+ const OAUTH2_PATTERN = /oauth2/i;
13
+
14
+ function containsOAuth2(value: string): boolean {
15
+ return OAUTH2_PATTERN.test(value);
16
+ }
17
+
18
+ function getExtendsArrayValues(node: TSESTree.PropertyDefinition): string[] {
19
+ if (node.value?.type !== AST_NODE_TYPES.ArrayExpression) return [];
20
+ const values: string[] = [];
21
+ for (const element of node.value.elements) {
22
+ const value = element ? getStringLiteralValue(element) : null;
23
+ if (value !== null) values.push(value);
24
+ }
25
+ return values;
26
+ }
27
+
28
+ function suggestOAuth2ApiName(className: string): string {
29
+ if (className.endsWith('Api')) {
30
+ return `${className.slice(0, -'Api'.length)}OAuth2Api`;
31
+ }
32
+ return `${className}OAuth2Api`;
33
+ }
34
+
35
+ export const CredClassOAuth2NamingRule = createRule({
36
+ name: 'cred-class-oauth2-naming',
37
+ meta: {
38
+ type: 'problem',
39
+ docs: {
40
+ description:
41
+ 'OAuth2 credentials must include `OAuth2` in the class name, `name`, and `displayName`',
42
+ },
43
+ messages: {
44
+ classNameMissingOAuth2: "OAuth2 credential class name '{{name}}' must end with 'OAuth2Api'",
45
+ nameMissingOAuth2: "OAuth2 credential `name` field '{{value}}' must contain 'OAuth2'",
46
+ displayNameMissingOAuth2:
47
+ "OAuth2 credential `displayName` field '{{value}}' must contain 'OAuth2'",
48
+ },
49
+ schema: [],
50
+ fixable: 'code',
51
+ },
52
+ defaultOptions: [],
53
+ create(context) {
54
+ if (!isFileType(context.filename, '.credentials.ts')) {
55
+ return {};
56
+ }
57
+
58
+ return {
59
+ ClassDeclaration(node) {
60
+ if (!isCredentialTypeClass(node)) return;
61
+ if (!node.id) return;
62
+
63
+ const className = node.id.name;
64
+ const superClassName =
65
+ node.superClass?.type === AST_NODE_TYPES.Identifier ? node.superClass.name : null;
66
+
67
+ const nameProperty = findClassProperty(node, 'name');
68
+ const nameValue = nameProperty?.value ? getStringLiteralValue(nameProperty.value) : null;
69
+
70
+ const displayNameProperty = findClassProperty(node, 'displayName');
71
+ const displayNameValue = displayNameProperty?.value
72
+ ? getStringLiteralValue(displayNameProperty.value)
73
+ : null;
74
+
75
+ const extendsProperty = findClassProperty(node, 'extends');
76
+ const extendsValues = extendsProperty ? getExtendsArrayValues(extendsProperty) : [];
77
+
78
+ const isOAuth2Credential =
79
+ containsOAuth2(className) ||
80
+ (superClassName !== null && containsOAuth2(superClassName)) ||
81
+ extendsValues.some(containsOAuth2) ||
82
+ (nameValue !== null && containsOAuth2(nameValue)) ||
83
+ (displayNameValue !== null && containsOAuth2(displayNameValue));
84
+
85
+ if (!isOAuth2Credential) return;
86
+
87
+ if (!className.endsWith('OAuth2Api')) {
88
+ const fixedClassName = suggestOAuth2ApiName(className);
89
+
90
+ context.report({
91
+ node: node.id,
92
+ messageId: 'classNameMissingOAuth2',
93
+ data: { name: className },
94
+ fix(fixer) {
95
+ return fixer.replaceText(node.id!, fixedClassName);
96
+ },
97
+ });
98
+ }
99
+
100
+ if (nameValue !== null && !containsOAuth2(nameValue)) {
101
+ context.report({
102
+ node: nameProperty!.value!,
103
+ messageId: 'nameMissingOAuth2',
104
+ data: { value: nameValue },
105
+ });
106
+ }
107
+
108
+ if (displayNameValue !== null && !containsOAuth2(displayNameValue)) {
109
+ context.report({
110
+ node: displayNameProperty!.value!,
111
+ messageId: 'displayNameMissingOAuth2',
112
+ data: { value: displayNameValue },
113
+ });
114
+ }
115
+ },
116
+ };
117
+ },
118
+ });
@@ -2,11 +2,15 @@ import type { AnyRuleModule } from '@typescript-eslint/utils/ts-eslint';
2
2
 
3
3
  import { AiNodePackageJsonRule } from './ai-node-package-json.js';
4
4
  import { CredClassFieldIconMissingRule } from './cred-class-field-icon-missing.js';
5
+ import { CredClassNameSuffixRule } from './cred-class-name-suffix.js';
6
+ import { CredClassOAuth2NamingRule } from './cred-class-oauth2-naming.js';
5
7
  import { CredentialDocumentationUrlRule } from './credential-documentation-url.js';
6
8
  import { CredentialPasswordFieldRule } from './credential-password-field.js';
7
9
  import { CredentialTestRequiredRule } from './credential-test-required.js';
8
10
  import { IconValidationRule } from './icon-validation.js';
9
11
  import { MissingPairedItemRule } from './missing-paired-item.js';
12
+ import { N8nObjectValidationRule } from './n8n-object-validation.js';
13
+ import { NoBuilderHintLeakageRule } from './no-builder-hint-leakage.js';
10
14
  import { NoCredentialReuseRule } from './no-credential-reuse.js';
11
15
  import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js';
12
16
  import { NoForbiddenLifecycleScriptsRule } from './no-forbidden-lifecycle-scripts.js';
@@ -14,8 +18,11 @@ import { NoHttpRequestWithManualAuthRule } from './no-http-request-with-manual-a
14
18
  import { NoOverridesFieldRule } from './no-overrides-field.js';
15
19
  import { NoRestrictedGlobalsRule } from './no-restricted-globals.js';
16
20
  import { NoRestrictedImportsRule } from './no-restricted-imports.js';
21
+ import { NoRuntimeDependenciesRule } from './no-runtime-dependencies.js';
22
+ import { NoTemplatePlaceholdersRule } from './no-template-placeholders.js';
17
23
  import { NodeClassDescriptionIconMissingRule } from './node-class-description-icon-missing.js';
18
24
  import { NodeConnectionTypeLiteralRule } from './node-connection-type-literal.js';
25
+ import { NodeOperationErrorItemIndexRule } from './node-operation-error-itemindex.js';
19
26
  import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
20
27
  import { OptionsSortedAlphabeticallyRule } from './options-sorted-alphabetically.js';
21
28
  import { PackageNameConventionRule } from './package-name-convention.js';
@@ -24,6 +31,7 @@ import { RequireContinueOnFailRule } from './require-continue-on-fail.js';
24
31
  import { RequireNodeApiErrorRule } from './require-node-api-error.js';
25
32
  import { RequireNodeDescriptionFieldsRule } from './require-node-description-fields.js';
26
33
  import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
34
+ import { ValidCredentialReferencesRule } from './valid-credential-references.js';
27
35
  import { ValidPeerDependenciesRule } from './valid-peer-dependencies.js';
28
36
  import { WebhookLifecycleCompleteRule } from './webhook-lifecycle-complete.js';
29
37
 
@@ -41,17 +49,25 @@ export const rules = {
41
49
  'no-forbidden-lifecycle-scripts': NoForbiddenLifecycleScriptsRule,
42
50
  'no-http-request-with-manual-auth': NoHttpRequestWithManualAuthRule,
43
51
  'no-overrides-field': NoOverridesFieldRule,
52
+ 'no-runtime-dependencies': NoRuntimeDependenciesRule,
53
+ 'no-template-placeholders': NoTemplatePlaceholdersRule,
44
54
  'icon-validation': IconValidationRule,
45
55
  'resource-operation-pattern': ResourceOperationPatternRule,
46
56
  'credential-documentation-url': CredentialDocumentationUrlRule,
47
57
  'node-class-description-icon-missing': NodeClassDescriptionIconMissingRule,
48
58
  'cred-class-field-icon-missing': CredClassFieldIconMissingRule,
59
+ 'cred-class-name-suffix': CredClassNameSuffixRule,
60
+ 'cred-class-oauth2-naming': CredClassOAuth2NamingRule,
49
61
  'node-connection-type-literal': NodeConnectionTypeLiteralRule,
62
+ 'node-operation-error-itemindex': NodeOperationErrorItemIndexRule,
50
63
  'missing-paired-item': MissingPairedItemRule,
64
+ 'no-builder-hint-leakage': NoBuilderHintLeakageRule,
65
+ 'n8n-object-validation': N8nObjectValidationRule,
51
66
  'require-community-node-keyword': RequireCommunityNodeKeywordRule,
52
67
  'require-continue-on-fail': RequireContinueOnFailRule,
53
68
  'require-node-api-error': RequireNodeApiErrorRule,
54
69
  'require-node-description-fields': RequireNodeDescriptionFieldsRule,
70
+ 'valid-credential-references': ValidCredentialReferencesRule,
55
71
  'valid-peer-dependencies': ValidPeerDependenciesRule,
56
72
  'webhook-lifecycle-complete': WebhookLifecycleCompleteRule,
57
73
  } satisfies Record<string, AnyRuleModule>;
@@ -0,0 +1,202 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+
3
+ import { N8nObjectValidationRule } from './n8n-object-validation.js';
4
+
5
+ const ruleTester = new RuleTester();
6
+
7
+ ruleTester.run('n8n-object-validation', N8nObjectValidationRule, {
8
+ valid: [
9
+ {
10
+ name: 'minimal valid n8n object with one node path',
11
+ filename: 'package.json',
12
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["dist/nodes/Foo/Foo.node.js"] } }',
13
+ },
14
+ {
15
+ name: 'valid n8n object with credentials',
16
+ filename: 'package.json',
17
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["dist/nodes/Foo/Foo.node.js"], "credentials": ["dist/credentials/Foo.credentials.js"] } }',
18
+ },
19
+ {
20
+ name: 'empty credentials array is allowed',
21
+ filename: 'package.json',
22
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["dist/x.js"], "credentials": [] } }',
23
+ },
24
+ {
25
+ name: 'higher integer api version is allowed',
26
+ filename: 'package.json',
27
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 2, "nodes": ["dist/x.js"] } }',
28
+ },
29
+ {
30
+ name: 'non-package.json file is ignored',
31
+ filename: 'some-config.json',
32
+ code: '{ "n8n": null }',
33
+ },
34
+ {
35
+ name: 'nested "n8n" key inside another field is allowed (only root is validated)',
36
+ filename: 'package.json',
37
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["dist/x.js"] }, "config": { "n8n": "ignored" } }',
38
+ },
39
+ {
40
+ name: 'objects nested inside arrays are not treated as the package root',
41
+ filename: 'package.json',
42
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["dist/x.js"] }, "contributors": [{ "name": "Jane" }, { "name": "John" }] }',
43
+ },
44
+ {
45
+ name: 'strict is true',
46
+ filename: 'package.json',
47
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["dist/x.js"], "strict": true } }',
48
+ },
49
+ {
50
+ name: 'strict is false',
51
+ filename: 'package.json',
52
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["dist/x.js"], "strict": false } }',
53
+ },
54
+ {
55
+ name: 'strict is omitted (optional field)',
56
+ filename: 'package.json',
57
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["dist/x.js"] } }',
58
+ },
59
+ ],
60
+ invalid: [
61
+ {
62
+ name: 'missing n8n object entirely',
63
+ filename: 'package.json',
64
+ code: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
65
+ errors: [{ messageId: 'missingN8nObject' }],
66
+ },
67
+ {
68
+ name: 'n8n value is not an object',
69
+ filename: 'package.json',
70
+ code: '{ "name": "n8n-nodes-example", "n8n": "not-an-object" }',
71
+ errors: [{ messageId: 'missingN8nObject' }],
72
+ },
73
+ {
74
+ name: 'missing n8nNodesApiVersion',
75
+ filename: 'package.json',
76
+ code: '{ "name": "n8n-nodes-example", "n8n": { "nodes": ["dist/x.js"] } }',
77
+ errors: [{ messageId: 'missingNodesApiVersion' }],
78
+ },
79
+ {
80
+ name: 'n8nNodesApiVersion is a string',
81
+ filename: 'package.json',
82
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "1", "nodes": ["dist/x.js"] } }',
83
+ errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '1' } }],
84
+ },
85
+ {
86
+ name: 'n8nNodesApiVersion is zero',
87
+ filename: 'package.json',
88
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 0, "nodes": ["dist/x.js"] } }',
89
+ errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '0' } }],
90
+ },
91
+ {
92
+ name: 'n8nNodesApiVersion is a float',
93
+ filename: 'package.json',
94
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1.5, "nodes": ["dist/x.js"] } }',
95
+ errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '1.5' } }],
96
+ },
97
+ {
98
+ name: 'n8nNodesApiVersion is negative',
99
+ filename: 'package.json',
100
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": -1, "nodes": ["dist/x.js"] } }',
101
+ errors: [{ messageId: 'invalidNodesApiVersion' }],
102
+ },
103
+ {
104
+ name: 'n8nNodesApiVersion at root level instead of inside n8n',
105
+ filename: 'package.json',
106
+ code: '{ "name": "n8n-nodes-example", "n8nNodesApiVersion": 1, "n8n": { "nodes": ["dist/x.js"] } }',
107
+ errors: [{ messageId: 'wrongLocationApiVersion' }, { messageId: 'missingNodesApiVersion' }],
108
+ },
109
+ {
110
+ name: 'missing nodes array',
111
+ filename: 'package.json',
112
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1 } }',
113
+ errors: [{ messageId: 'missingN8nNodes' }],
114
+ },
115
+ {
116
+ name: 'nodes is not an array',
117
+ filename: 'package.json',
118
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": "dist/x.js" } }',
119
+ errors: [{ messageId: 'n8nNodesNotArray' }],
120
+ },
121
+ {
122
+ name: 'nodes is empty array',
123
+ filename: 'package.json',
124
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": [] } }',
125
+ errors: [{ messageId: 'emptyN8nNodes' }],
126
+ },
127
+ {
128
+ name: 'node path does not start with dist/',
129
+ filename: 'package.json',
130
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["nodes/Foo/Foo.node.js"] } }',
131
+ errors: [{ messageId: 'nodePathNotInDist', data: { path: 'nodes/Foo/Foo.node.js' } }],
132
+ },
133
+ {
134
+ name: 'node path uses ./dist/ prefix instead of dist/',
135
+ filename: 'package.json',
136
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["./dist/x.js"] } }',
137
+ errors: [{ messageId: 'nodePathNotInDist', data: { path: './dist/x.js' } }],
138
+ },
139
+ {
140
+ name: 'node path with wrong casing',
141
+ filename: 'package.json',
142
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["DIST/x.js"] } }',
143
+ errors: [{ messageId: 'nodePathNotInDist', data: { path: 'DIST/x.js' } }],
144
+ },
145
+ {
146
+ name: 'node path is not a string',
147
+ filename: 'package.json',
148
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": [123] } }',
149
+ errors: [{ messageId: 'nodePathNotString' }],
150
+ },
151
+ {
152
+ name: 'multiple bad node paths each report',
153
+ filename: 'package.json',
154
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["dist/ok.js", "bad/one.js", "./dist/two.js"] } }',
155
+ errors: [
156
+ { messageId: 'nodePathNotInDist', data: { path: 'bad/one.js' } },
157
+ { messageId: 'nodePathNotInDist', data: { path: './dist/two.js' } },
158
+ ],
159
+ },
160
+ {
161
+ name: 'credentials is not an array',
162
+ filename: 'package.json',
163
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["dist/x.js"], "credentials": "dist/c.js" } }',
164
+ errors: [{ messageId: 'n8nCredentialsNotArray' }],
165
+ },
166
+ {
167
+ name: 'credential path does not start with dist/',
168
+ filename: 'package.json',
169
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["dist/x.js"], "credentials": ["credentials/Foo.credentials.js"] } }',
170
+ errors: [
171
+ {
172
+ messageId: 'credentialPathNotInDist',
173
+ data: { path: 'credentials/Foo.credentials.js' },
174
+ },
175
+ ],
176
+ },
177
+ {
178
+ name: 'credential path is not a string',
179
+ filename: 'package.json',
180
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["dist/x.js"], "credentials": [true] } }',
181
+ errors: [{ messageId: 'credentialPathNotString' }],
182
+ },
183
+ {
184
+ name: 'strict is a string',
185
+ filename: 'package.json',
186
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["dist/x.js"], "strict": "true" } }',
187
+ errors: [{ messageId: 'invalidStrict', data: { value: 'true' } }],
188
+ },
189
+ {
190
+ name: 'strict is a number',
191
+ filename: 'package.json',
192
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["dist/x.js"], "strict": 1 } }',
193
+ errors: [{ messageId: 'invalidStrict', data: { value: '1' } }],
194
+ },
195
+ {
196
+ name: 'strict is null',
197
+ filename: 'package.json',
198
+ code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 1, "nodes": ["dist/x.js"], "strict": null } }',
199
+ errors: [{ messageId: 'invalidStrict', data: { value: 'null' } }],
200
+ },
201
+ ],
202
+ });
@@ -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
+ });