@n8n/eslint-plugin-community-nodes 0.31.0 → 0.32.1

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 (38) hide show
  1. package/README.md +1 -0
  2. package/dist/plugin.d.ts +6 -0
  3. package/dist/plugin.d.ts.map +1 -1
  4. package/dist/plugin.js +2 -0
  5. package/dist/plugin.js.map +1 -1
  6. package/dist/rules/index.d.ts +1 -0
  7. package/dist/rules/index.d.ts.map +1 -1
  8. package/dist/rules/index.js +2 -0
  9. package/dist/rules/index.js.map +1 -1
  10. package/dist/rules/no-credential-reuse.d.ts.map +1 -1
  11. package/dist/rules/no-credential-reuse.js +4 -6
  12. package/dist/rules/no-credential-reuse.js.map +1 -1
  13. package/dist/rules/no-emoji-in-options.js +4 -4
  14. package/dist/rules/no-emoji-in-options.js.map +1 -1
  15. package/dist/rules/no-unsafe-connection-type-cast.d.ts +3 -0
  16. package/dist/rules/no-unsafe-connection-type-cast.d.ts.map +1 -0
  17. package/dist/rules/no-unsafe-connection-type-cast.js +104 -0
  18. package/dist/rules/no-unsafe-connection-type-cast.js.map +1 -0
  19. package/dist/rules/valid-credential-references.d.ts.map +1 -1
  20. package/dist/rules/valid-credential-references.js +4 -6
  21. package/dist/rules/valid-credential-references.js.map +1 -1
  22. package/dist/utils/ast-utils.d.ts +6 -0
  23. package/dist/utils/ast-utils.d.ts.map +1 -1
  24. package/dist/utils/ast-utils.js +50 -0
  25. package/dist/utils/ast-utils.js.map +1 -1
  26. package/docs/rules/no-unsafe-connection-type-cast.md +58 -0
  27. package/package.json +8 -8
  28. package/src/plugin.ts +2 -0
  29. package/src/rules/index.ts +2 -0
  30. package/src/rules/no-credential-reuse.test.ts +54 -0
  31. package/src/rules/no-credential-reuse.ts +4 -8
  32. package/src/rules/no-emoji-in-options.test.ts +77 -0
  33. package/src/rules/no-emoji-in-options.ts +4 -4
  34. package/src/rules/no-unsafe-connection-type-cast.test.ts +306 -0
  35. package/src/rules/no-unsafe-connection-type-cast.ts +125 -0
  36. package/src/rules/valid-credential-references.test.ts +75 -0
  37. package/src/rules/valid-credential-references.ts +4 -8
  38. package/src/utils/ast-utils.ts +67 -0
@@ -0,0 +1,125 @@
1
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2
+ import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
3
+
4
+ import {
5
+ isNodeTypeClass,
6
+ findClassProperty,
7
+ findObjectProperty,
8
+ findJsonProperty,
9
+ createRule,
10
+ } from '../utils/index.js';
11
+
12
+ const ERASING_KEYWORDS: Partial<Record<AST_NODE_TYPES, string>> = {
13
+ [AST_NODE_TYPES.TSNeverKeyword]: 'never',
14
+ [AST_NODE_TYPES.TSAnyKeyword]: 'any',
15
+ [AST_NODE_TYPES.TSUnknownKeyword]: 'unknown',
16
+ };
17
+
18
+ type Cast = TSESTree.TSAsExpression | TSESTree.TSTypeAssertion;
19
+
20
+ function isCast(node: TSESTree.Node): node is Cast {
21
+ return (
22
+ node.type === AST_NODE_TYPES.TSAsExpression || node.type === AST_NODE_TYPES.TSTypeAssertion
23
+ );
24
+ }
25
+
26
+ type Wrapper = Cast | TSESTree.TSSatisfiesExpression;
27
+
28
+ function isWrapper(node: TSESTree.Node): node is Wrapper {
29
+ return isCast(node) || node.type === AST_NODE_TYPES.TSSatisfiesExpression;
30
+ }
31
+
32
+ /**
33
+ * Walks a wrapper chain such as `x as unknown as string[]` or
34
+ * `x as never satisfies string[]`, outermost first. `satisfies` is traversed
35
+ * but never collected: it checks the value instead of erasing its type, so it
36
+ * can hide a cast without being one.
37
+ */
38
+ function collectCasts(node: TSESTree.Node): Cast[] {
39
+ const casts: Cast[] = [];
40
+ let current = node;
41
+ while (isWrapper(current)) {
42
+ if (isCast(current)) casts.push(current);
43
+ current = current.expression;
44
+ }
45
+ return casts;
46
+ }
47
+
48
+ /**
49
+ * Removes the cast syntax itself and nothing else, so every other part of the
50
+ * chain survives: a `satisfies` at any depth, and the expression's own text.
51
+ */
52
+ function removeCastSyntax(fixer: TSESLint.RuleFixer, cast: Cast): TSESLint.RuleFix {
53
+ return cast.type === AST_NODE_TYPES.TSTypeAssertion
54
+ ? // `<never>expr`: drop the leading `<never>`
55
+ fixer.removeRange([cast.range[0], cast.expression.range[0]])
56
+ : // `expr as never`: drop the trailing ` as never`
57
+ fixer.removeRange([cast.expression.range[1], cast.range[1]]);
58
+ }
59
+
60
+ export const NoUnsafeConnectionTypeCastRule = createRule({
61
+ name: 'no-unsafe-connection-type-cast',
62
+ meta: {
63
+ type: 'problem',
64
+ docs: {
65
+ description:
66
+ 'Disallow type-erasing casts (`as never`, `as any`, `as unknown`) on node description `inputs`/`outputs`',
67
+ },
68
+ messages: {
69
+ erasingCast:
70
+ 'Remove the `as {{typeName}}` cast on "{{property}}". It disables type checking, so a malformed connection declaration passes both TypeScript and lint.',
71
+ removeCast: 'Remove the cast',
72
+ },
73
+ hasSuggestions: true,
74
+ schema: [],
75
+ },
76
+ defaultOptions: [],
77
+ create(context) {
78
+ function checkProperty(value: TSESTree.Node, property: 'inputs' | 'outputs') {
79
+ const casts = collectCasts(value);
80
+ const [outermost] = casts;
81
+ if (!outermost) return;
82
+
83
+ // A chain is unsafe if any link erases the type, e.g. the `unknown`
84
+ // in `as unknown as string[]` is what defeats the checker.
85
+ const erasing = casts.find((cast) => ERASING_KEYWORDS[cast.typeAnnotation.type]);
86
+ if (!erasing) return;
87
+
88
+ // Report the outermost cast rather than the whole property value, so a
89
+ // `satisfies` wrapped around it stays put when the suggestion is applied.
90
+ context.report({
91
+ node: outermost,
92
+ messageId: 'erasingCast',
93
+ data: {
94
+ typeName: ERASING_KEYWORDS[erasing.typeAnnotation.type],
95
+ property,
96
+ },
97
+ suggest: [
98
+ {
99
+ messageId: 'removeCast',
100
+ fix: (fixer) => casts.map((cast) => removeCastSyntax(fixer, cast)),
101
+ },
102
+ ],
103
+ });
104
+ }
105
+
106
+ return {
107
+ ClassDeclaration(node) {
108
+ if (!isNodeTypeClass(node)) return;
109
+
110
+ const descriptionProperty = findClassProperty(node, 'description');
111
+ if (!descriptionProperty) return;
112
+
113
+ const descriptionValue = descriptionProperty.value;
114
+ if (descriptionValue?.type !== AST_NODE_TYPES.ObjectExpression) return;
115
+
116
+ for (const prop of ['inputs', 'outputs'] as const) {
117
+ const property =
118
+ findObjectProperty(descriptionValue, prop) ?? findJsonProperty(descriptionValue, prop);
119
+ if (!property) continue;
120
+ checkProperty(property.value, prop);
121
+ }
122
+ },
123
+ };
124
+ },
125
+ });
@@ -19,6 +19,7 @@ const mockFindPackageJson = vi.mocked(fileUtils.findPackageJson);
19
19
  const ruleTester = new RuleTester();
20
20
 
21
21
  const nodeFilePath = '/tmp/TestNode.node.ts';
22
+ const versionedNodeFilePath = '/tmp/v1/TestNodeV1.node.ts';
22
23
 
23
24
  function createNodeCode(
24
25
  credentials: Array<string | { name: string; required?: boolean }> = [],
@@ -97,6 +98,36 @@ export class TestNode implements INodeType {
97
98
  }`;
98
99
  }
99
100
 
101
+ /**
102
+ * Versioned node layout from the node-building docs: each version class
103
+ * implements `INodeType` and assigns `description` in its constructor, merging
104
+ * the `baseDescription` that the `VersionedNodeType` entry file passes in.
105
+ */
106
+ function createVersionedNodeCode(credentialNameLiteral: string): string {
107
+ return `
108
+ import type { INodeType, INodeTypeBaseDescription, INodeTypeDescription } from 'n8n-workflow';
109
+
110
+ export class TestNodeV1 implements INodeType {
111
+ description: INodeTypeDescription;
112
+
113
+ constructor(baseDescription: INodeTypeBaseDescription) {
114
+ this.description = {
115
+ ...baseDescription,
116
+ version: 1,
117
+ inputs: ['main'],
118
+ outputs: ['main'],
119
+ credentials: [
120
+ {
121
+ name: ${credentialNameLiteral},
122
+ required: true,
123
+ },
124
+ ],
125
+ properties: [],
126
+ };
127
+ }
128
+ }`;
129
+ }
130
+
100
131
  function createNonNodeClass(): string {
101
132
  return `
102
133
  export class RegularClass {
@@ -153,8 +184,52 @@ ruleTester.run('valid-credential-references', ValidCredentialReferencesRule, {
153
184
  filename: nodeFilePath,
154
185
  code: createNonINodeTypeClass(),
155
186
  },
187
+ {
188
+ name: 'versioned node assigning description in its constructor references a credential that exists',
189
+ filename: versionedNodeFilePath,
190
+ code: createVersionedNodeCode("'myApiCredential'"),
191
+ },
156
192
  ],
157
193
  invalid: [
194
+ {
195
+ name: 'versioned node assigning description in its constructor references a credential that does not exist',
196
+ filename: versionedNodeFilePath,
197
+ code: createVersionedNodeCode("'myApiCredentail'"),
198
+ errors: [
199
+ {
200
+ messageId: 'credentialNotFound',
201
+ data: { credentialName: 'myApiCredentail' },
202
+ suggestions: [
203
+ {
204
+ messageId: 'didYouMean',
205
+ data: { suggestedName: 'myApiCredential' },
206
+ output: createVersionedNodeCode('"myApiCredential"'),
207
+ },
208
+ ],
209
+ },
210
+ ],
211
+ },
212
+ {
213
+ name: 'credential name in a description cast with `as` does not exist in package',
214
+ filename: nodeFilePath,
215
+ code: `
216
+ import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
217
+
218
+ export class TestNode implements INodeType {
219
+ description = {
220
+ displayName: 'Test Node',
221
+ name: 'testNode',
222
+ credentials: [{ name: 'brokenReference', required: true }],
223
+ properties: [],
224
+ } as INodeTypeDescription;
225
+ }`,
226
+ errors: [
227
+ {
228
+ messageId: 'credentialNotFound',
229
+ data: { credentialName: 'brokenReference' },
230
+ },
231
+ ],
232
+ },
158
233
  {
159
234
  name: 'credential name does not exist in package (object form)',
160
235
  filename: nodeFilePath,
@@ -1,9 +1,8 @@
1
- import { TSESTree } from '@typescript-eslint/utils';
2
1
  import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
3
2
 
4
3
  import {
5
4
  isNodeTypeClass,
6
- findClassProperty,
5
+ findNodeDescriptionObject,
7
6
  findArrayLiteralProperty,
8
7
  extractCredentialNameFromArray,
9
8
  findPackageJson,
@@ -58,15 +57,12 @@ export const ValidCredentialReferencesRule = createRule({
58
57
  return;
59
58
  }
60
59
 
61
- const descriptionProperty = findClassProperty(node, 'description');
62
- if (
63
- !descriptionProperty?.value ||
64
- descriptionProperty.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression
65
- ) {
60
+ const description = findNodeDescriptionObject(node);
61
+ if (!description) {
66
62
  return;
67
63
  }
68
64
 
69
- const credentialsArray = findArrayLiteralProperty(descriptionProperty.value, 'credentials');
65
+ const credentialsArray = findArrayLiteralProperty(description, 'credentials');
70
66
  if (!credentialsArray) {
71
67
  return;
72
68
  }
@@ -68,6 +68,73 @@ export function findClassProperty(
68
68
  return property?.type === AST_NODE_TYPES.PropertyDefinition ? property : null;
69
69
  }
70
70
 
71
+ /**
72
+ * Returns the object literal behind an expression, and looks through the type
73
+ * assertions node authors write, e.g. `{ … } as INodeTypeDescription`.
74
+ */
75
+ function asObjectExpression(node: TSESTree.Node | null): TSESTree.ObjectExpression | null {
76
+ let current = node;
77
+ while (
78
+ current?.type === AST_NODE_TYPES.TSAsExpression ||
79
+ current?.type === AST_NODE_TYPES.TSSatisfiesExpression ||
80
+ current?.type === AST_NODE_TYPES.TSTypeAssertion ||
81
+ current?.type === AST_NODE_TYPES.TSNonNullExpression
82
+ ) {
83
+ current = current.expression;
84
+ }
85
+
86
+ return current?.type === AST_NODE_TYPES.ObjectExpression ? current : null;
87
+ }
88
+
89
+ function findConstructorAssignedDescription(
90
+ node: TSESTree.ClassDeclaration,
91
+ ): TSESTree.ObjectExpression | null {
92
+ const constructor = node.body.body.find(
93
+ (member) => member.type === AST_NODE_TYPES.MethodDefinition && member.kind === 'constructor',
94
+ );
95
+ if (constructor?.type !== AST_NODE_TYPES.MethodDefinition) {
96
+ return null;
97
+ }
98
+
99
+ for (const statement of constructor.value.body?.body ?? []) {
100
+ if (statement.type !== AST_NODE_TYPES.ExpressionStatement) continue;
101
+
102
+ const { expression } = statement;
103
+ if (
104
+ expression.type === AST_NODE_TYPES.AssignmentExpression &&
105
+ expression.operator === '=' &&
106
+ expression.left.type === AST_NODE_TYPES.MemberExpression &&
107
+ expression.left.object.type === AST_NODE_TYPES.ThisExpression &&
108
+ expression.left.property.type === AST_NODE_TYPES.Identifier &&
109
+ expression.left.property.name === 'description'
110
+ ) {
111
+ const description = asObjectExpression(expression.right);
112
+ if (description) {
113
+ return description;
114
+ }
115
+ }
116
+ }
117
+
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Returns the `description` object literal of a node class. Versioned nodes
123
+ * follow the layout in the node-building docs and assign `this.description` in
124
+ * their constructor, so look there when the class property has no initializer.
125
+ */
126
+ export function findNodeDescriptionObject(
127
+ node: TSESTree.ClassDeclaration,
128
+ ): TSESTree.ObjectExpression | null {
129
+ const property = findClassProperty(node, 'description');
130
+ const propertyValue = asObjectExpression(property?.value ?? null);
131
+ if (propertyValue) {
132
+ return propertyValue;
133
+ }
134
+
135
+ return findConstructorAssignedDescription(node);
136
+ }
137
+
71
138
  export function findObjectProperty(
72
139
  obj: TSESTree.ObjectExpression,
73
140
  propertyName: string,