@n8n/scan-community-package 0.25.0 → 0.26.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@n8n/scan-community-package",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Static code analyser for n8n community packages",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "bin": "scanner/cli.mjs",
@@ -14,13 +14,15 @@
14
14
  "fast-glob": "3.2.12",
15
15
  "axios": "1.18.0",
16
16
  "@typescript-eslint/parser": "^8.35.0",
17
+ "eslint-plugin-n8n-nodes-base": "^1.16.7",
17
18
  "semver": "7.7.3",
19
+ "typescript": "6.0.2",
18
20
  "tmp": "0.2.4",
19
- "@n8n/eslint-plugin-community-nodes": "0.23.0"
21
+ "@n8n/eslint-plugin-community-nodes": "0.24.0"
20
22
  },
21
23
  "devDependencies": {
22
24
  "vitest": "^4.1.9",
23
- "@n8n/vitest-config": "1.16.0"
25
+ "@n8n/vitest-config": "1.17.0"
24
26
  },
25
27
  "homepage": "https://n8n.io",
26
28
  "author": {
@@ -117,29 +117,88 @@ const downloadAndExtractPackage = async (packageName, version) => {
117
117
  }
118
118
  };
119
119
 
120
- export const analyzePackage = async (packageDir) => {
120
+ /**
121
+ * Builds the flat ESLint config the scanner lints packages with. Exported so
122
+ * tests can assert the external `eslint-plugin-n8n-nodes-base` plugin and its
123
+ * rulesets are wired in, independent of ESLint execution.
124
+ */
125
+ export const buildScanConfig = async () => {
121
126
  const { n8nCommunityNodesPlugin } = await import('@n8n/eslint-plugin-community-nodes');
122
127
  const tsParser = await import('@typescript-eslint/parser');
128
+ const n8nNodesPlugin = (await import('eslint-plugin-n8n-nodes-base')).default;
129
+
130
+ const parser = tsParser.default ?? tsParser;
131
+
132
+ return defineConfig(
133
+ n8nCommunityNodesPlugin.configs.recommended,
134
+ {
135
+ rules: { 'no-console': 'error' },
136
+ },
137
+ // Register the full `eslint-plugin-n8n-nodes-base` plugin and apply its
138
+ // three rulesets so the scan gate enforces the same rules as
139
+ // `n8n-node lint` (see node-cli/src/configs/eslint.ts). The off-overrides
140
+ // below are kept identical. Scoping differs on purpose: `n8n-node lint`
141
+ // runs at dev-time on `nodes/**` / `credentials/**` `.ts` sources, but
142
+ // published tarballs ship compiled output under `dist/` (e.g.
143
+ // `dist/nodes/Foo/Foo.node.js` + `.d.ts`). We match `nodes`/`credentials`
144
+ // dirs at any depth and target `.ts`/`.d.ts` only — the AST-walking rules
145
+ // resolve against the type-preserving `.d.ts`. Compiled `.js` is
146
+ // deliberately excluded: the description AST is buried in a constructor
147
+ // there so the rules no-op, and file-shape rules like
148
+ // node-filename-against-convention would false-positive on the `.js`
149
+ // extension (that check is meaningful only against `.ts` sources at
150
+ // dev-time). Without the `dist/`-aware glob these rules never run at the
151
+ // gate at all.
152
+ { plugins: { 'n8n-nodes-base': n8nNodesPlugin } },
153
+ {
154
+ files: ['package.json'],
155
+ rules: { ...n8nNodesPlugin.configs.community.rules },
156
+ },
157
+ {
158
+ files: ['**/credentials/**/*.ts'],
159
+ rules: {
160
+ ...n8nNodesPlugin.configs.credentials.rules,
161
+ // Not valid for community nodes
162
+ 'n8n-nodes-base/cred-class-field-documentation-url-miscased': 'off',
163
+ // @n8n/eslint-plugin-community-nodes credential-password-field rule is more accurate
164
+ 'n8n-nodes-base/cred-class-field-type-options-password-missing': 'off',
165
+ },
166
+ },
167
+ {
168
+ files: ['**/nodes/**/*.ts'],
169
+ rules: {
170
+ ...n8nNodesPlugin.configs.nodes.rules,
171
+ // Inputs and outputs can be enum instead of string "main"
172
+ 'n8n-nodes-base/node-class-description-inputs-wrong-regular-node': 'off',
173
+ 'n8n-nodes-base/node-class-description-outputs-wrong': 'off',
174
+ // Sometimes the 3rd party API does have a maximum limit, so maxValue is valid
175
+ 'n8n-nodes-base/node-param-type-options-max-value-present': 'off',
176
+ },
177
+ },
178
+ // JSON files (notably `package.json`) are not parseable by ESLint's
179
+ // default JS parser, so register the TypeScript parser for them. The
180
+ // community-nodes rules that gate on `package.json` walk a TSESTree
181
+ // `ObjectExpression` AST, which `@typescript-eslint/parser` produces
182
+ // when given a top-level JSON object literal.
183
+ {
184
+ files: ['**/*.json'],
185
+ languageOptions: { parser },
186
+ },
187
+ // The external `nodes`/`credentials` rulesets walk a TSESTree AST, so
188
+ // TS sources (when present in the tarball) need the TS parser too.
189
+ {
190
+ files: ['**/*.ts'],
191
+ languageOptions: { parser },
192
+ },
193
+ );
194
+ };
123
195
 
196
+ export const analyzePackage = async (packageDir) => {
124
197
  const eslint = new ESLint({
125
198
  cwd: packageDir,
126
199
  allowInlineConfig: false,
127
200
  overrideConfigFile: true,
128
- overrideConfig: defineConfig(
129
- n8nCommunityNodesPlugin.configs.recommended,
130
- {
131
- rules: { 'no-console': 'error' },
132
- },
133
- // JSON files (notably `package.json`) are not parseable by ESLint's
134
- // default JS parser, so register the TypeScript parser for them. The
135
- // community-nodes rules that gate on `package.json` walk a TSESTree
136
- // `ObjectExpression` AST, which `@typescript-eslint/parser` produces
137
- // when given a top-level JSON object literal.
138
- {
139
- files: ['**/*.json'],
140
- languageOptions: { parser: tsParser.default ?? tsParser },
141
- },
142
- ),
201
+ overrideConfig: await buildScanConfig(),
143
202
  });
144
203
 
145
204
  try {
@@ -147,7 +206,7 @@ export const analyzePackage = async (packageDir) => {
147
206
  // such as `no-overrides-field`, `valid-peer-dependencies`, and
148
207
  // `package-name-convention` only run against `package.json`. Without
149
208
  // it the scanner silently skips every package.json-based rule.
150
- const filesToLint = glob.sync(['**/*.js', '**/*.json'], {
209
+ const filesToLint = glob.sync(['**/*.js', '**/*.ts', '**/*.json'], {
151
210
  cwd: packageDir,
152
211
  absolute: true,
153
212
  ignore: ['node_modules/**', '**/package-lock.json'],
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
5
 
6
- import { analyzePackage } from './scanner.mjs';
6
+ import { analyzePackage, buildScanConfig } from './scanner.mjs';
7
7
 
8
8
  /**
9
9
  * Build a temporary package directory on disk so we can hand it to
@@ -23,6 +23,56 @@ function makeFixturePackage(files) {
23
23
  return dir;
24
24
  }
25
25
 
26
+ describe('buildScanConfig', () => {
27
+ // CE-1606: the scan gate must register the external eslint-plugin-n8n-nodes-base
28
+ // and apply all three rulesets, mirroring `n8n-node lint`. Assert on the
29
+ // resolved config rather than on rule execution: under vitest the external
30
+ // plugin resolves a different @typescript-eslint parser instance than the one
31
+ // it was compiled against, so its AST-walking rules silently no-op in-process
32
+ // (they run correctly when the scanner is invoked via `node`, as the CLI does).
33
+ it('registers the external n8n-nodes-base plugin', async () => {
34
+ const config = await buildScanConfig();
35
+ const pluginBlock = config.find((block) => block.plugins?.['n8n-nodes-base']);
36
+
37
+ expect(pluginBlock).toBeDefined();
38
+ expect(pluginBlock.plugins['n8n-nodes-base'].configs).toHaveProperty('community');
39
+ expect(pluginBlock.plugins['n8n-nodes-base'].configs).toHaveProperty('credentials');
40
+ expect(pluginBlock.plugins['n8n-nodes-base'].configs).toHaveProperty('nodes');
41
+ });
42
+
43
+ it('applies each external ruleset with the correct file scoping', async () => {
44
+ const config = await buildScanConfig();
45
+ const ruleBlockFor = (glob) =>
46
+ config.find(
47
+ (block) =>
48
+ block.files?.includes(glob) &&
49
+ Object.keys(block.rules ?? {}).some((r) => r.startsWith('n8n-nodes-base/')),
50
+ );
51
+
52
+ // community ruleset → package.json
53
+ expect(ruleBlockFor('package.json')).toBeDefined();
54
+ // credentials ruleset → credentials dir at any depth (incl. dist/)
55
+ expect(ruleBlockFor('**/credentials/**/*.ts')).toBeDefined();
56
+ // nodes ruleset → nodes dir at any depth (incl. dist/)
57
+ expect(ruleBlockFor('**/nodes/**/*.ts')).toBeDefined();
58
+ });
59
+
60
+ it('carries over the same off-overrides node-cli applies', async () => {
61
+ const config = await buildScanConfig();
62
+ const rules = Object.assign({}, ...config.map((block) => block.rules ?? {}));
63
+
64
+ for (const rule of [
65
+ 'n8n-nodes-base/cred-class-field-documentation-url-miscased',
66
+ 'n8n-nodes-base/cred-class-field-type-options-password-missing',
67
+ 'n8n-nodes-base/node-class-description-inputs-wrong-regular-node',
68
+ 'n8n-nodes-base/node-class-description-outputs-wrong',
69
+ 'n8n-nodes-base/node-param-type-options-max-value-present',
70
+ ]) {
71
+ expect(rules[rule]).toBe('off');
72
+ }
73
+ });
74
+ });
75
+
26
76
  describe('analyzePackage', () => {
27
77
  let fixtureDir;
28
78
 
@@ -56,8 +106,12 @@ describe('analyzePackage', () => {
56
106
  'package.json': {
57
107
  name: 'n8n-nodes-fixture',
58
108
  version: '1.0.0',
109
+ description: 'A fixture community node package',
110
+ license: 'MIT',
111
+ author: { name: 'Test Author', email: 'test@example.com' },
59
112
  keywords: ['n8n-community-node-package'],
60
113
  peerDependencies: { 'n8n-workflow': '*' },
114
+ n8n: { n8nNodesApiVersion: 1, nodes: ['dist/nodes/Foo/Foo.node.js'] },
61
115
  },
62
116
  'index.js': "module.exports = {};\n",
63
117
  });
@@ -85,6 +139,42 @@ describe('analyzePackage', () => {
85
139
  expect(result.details).toContain('no-forbidden-lifecycle-scripts');
86
140
  });
87
141
 
142
+ // A well-formed node package's compiled sources must not trip the external
143
+ // node rules — those rules can't see enough in `.d.ts`/`.js` output to fire,
144
+ // so scanning must not produce false positives on legitimate packages.
145
+ it('does not false-positive on a well-formed node package layout (CE-1606)', async () => {
146
+ fixtureDir = makeFixturePackage({
147
+ 'package.json': {
148
+ name: 'n8n-nodes-fixture',
149
+ version: '1.0.0',
150
+ description: 'A fixture community node package',
151
+ license: 'MIT',
152
+ author: { name: 'Test Author', email: 'test@example.com' },
153
+ keywords: ['n8n-community-node-package'],
154
+ peerDependencies: { 'n8n-workflow': '*' },
155
+ n8n: { n8nNodesApiVersion: 1, nodes: ['dist/nodes/Foo/Foo.node.js'] },
156
+ },
157
+ 'dist/nodes/Foo/Foo.node.d.ts': `export declare class Foo {
158
+ description: {
159
+ displayName: string;
160
+ name: string;
161
+ properties: {
162
+ displayName: string;
163
+ name: string;
164
+ type: string;
165
+ default: string;
166
+ }[];
167
+ };
168
+ }
169
+ `,
170
+ 'dist/nodes/Foo/Foo.node.js': "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.Foo = void 0;\nclass Foo {}\nexports.Foo = Foo;\n",
171
+ });
172
+
173
+ const result = await analyzePackage(fixtureDir);
174
+
175
+ expect(result.passed).toBe(true);
176
+ });
177
+
88
178
  it('returns passed when the package contains no lintable files', async () => {
89
179
  fixtureDir = makeFixturePackage({
90
180
  'README.md': '# empty package\n',