fix(core): Lint package.json in community node tooling (no-changelog) (#29864)
Some checks are pending
Build: Benchmark Image / build (push) Waiting to run
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.14.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (25.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Util: Sync API Docs / sync-public-api (push) Waiting to run

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garrit Franke 2026-05-06 12:56:31 +02:00 committed by GitHub
parent d63e1ae84e
commit 8b54333739
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 235 additions and 12 deletions

View File

@ -0,0 +1,78 @@
import { ESLint } from 'eslint';
import fs from 'node:fs/promises';
import path from 'node:path';
import { describe, expect } from 'vitest';
import { config, configWithoutCloudSupport } from './eslint';
import { tmpdirTest } from '../test-utils/temp-fs';
/**
* These tests guard against regressions in the flat-config layout where
* `@n8n/eslint-plugin-community-nodes` rules that target `package.json`
* (e.g. `no-overrides-field`) get loaded but never actually fire because
* they are wrapped inside a `**\/*.ts`-scoped `extends:` block. See CE-1023
* for the analogous bug in `@n8n/scan-community-package`.
*/
async function lintFile(filePath: string, eslintConfig: typeof config) {
const eslint = new ESLint({
cwd: path.dirname(filePath),
overrideConfigFile: true,
// `tseslint.config()` returns the typescript-eslint `ConfigArray`, which
// is structurally a `Linter.Config[]` but nominally a different type due
// to duplicated `Parser` definitions across packages.
overrideConfig: eslintConfig as unknown as ESLint.Options['overrideConfig'],
});
const [result] = await eslint.lintFiles([filePath]);
return result;
}
const packageJsonWithOverrides = `{
"name": "n8n-nodes-fixture",
"version": "1.0.0",
"keywords": ["n8n-community-node-package"],
"peerDependencies": { "n8n-workflow": "*" },
"overrides": { "change-case": "4.1.2" }
}`;
const packageJsonWithLifecycleScript = `{
"name": "n8n-nodes-fixture",
"version": "1.0.0",
"keywords": ["n8n-community-node-package"],
"peerDependencies": { "n8n-workflow": "*" },
"scripts": { "postinstall": "node ./malicious.js" }
}`;
describe('@n8n/node-cli eslint config', () => {
tmpdirTest(
'flags an `overrides` field in package.json (regression for CE-1023)',
async ({ tmpdir }) => {
const pkgPath = path.join(tmpdir, 'package.json');
await fs.writeFile(pkgPath, packageJsonWithOverrides);
const result = await lintFile(pkgPath, config);
const ruleIds = result.messages.map((m) => m.ruleId);
expect(ruleIds).toContain('@n8n/community-nodes/no-overrides-field');
},
);
tmpdirTest('flags forbidden lifecycle scripts in package.json', async ({ tmpdir }) => {
const pkgPath = path.join(tmpdir, 'package.json');
await fs.writeFile(pkgPath, packageJsonWithLifecycleScript);
const result = await lintFile(pkgPath, config);
const ruleIds = result.messages.map((m) => m.ruleId);
expect(ruleIds).toContain('@n8n/community-nodes/no-forbidden-lifecycle-scripts');
});
tmpdirTest('configWithoutCloudSupport also lints package.json rules', async ({ tmpdir }) => {
const pkgPath = path.join(tmpdir, 'package.json');
await fs.writeFile(pkgPath, packageJsonWithOverrides);
const result = await lintFile(pkgPath, configWithoutCloudSupport);
const ruleIds = result.messages.map((m) => m.ruleId);
expect(ruleIds).toContain('@n8n/community-nodes/no-overrides-field');
});
});

View File

@ -7,6 +7,10 @@ import n8nNodesPlugin from 'eslint-plugin-n8n-nodes-base';
import tseslint, { type ConfigArray } from 'typescript-eslint';
function createConfig(supportCloud = true): ConfigArray {
const communityNodesRecommended = supportCloud
? n8nCommunityNodesPlugin.configs.recommended
: n8nCommunityNodesPlugin.configs.recommendedWithoutN8nCloudSupport;
return tseslint.config(
globalIgnores(['dist']),
{
@ -14,9 +18,7 @@ function createConfig(supportCloud = true): ConfigArray {
extends: [
eslint.configs.recommended,
tseslint.configs.recommended,
supportCloud
? n8nCommunityNodesPlugin.configs.recommended
: n8nCommunityNodesPlugin.configs.recommendedWithoutN8nCloudSupport,
communityNodesRecommended,
importPlugin.configs['flat/recommended'],
],
rules: {
@ -32,6 +34,13 @@ function createConfig(supportCloud = true): ConfigArray {
},
{
files: ['package.json'],
// Apply the community-nodes recommended config here as well so that
// rules gating on `package.json` (e.g. no-overrides-field,
// valid-peer-dependencies, no-forbidden-lifecycle-scripts) actually
// fire. The `**/*.ts` block above scopes its `extends:` to TypeScript
// only, which means ESLint never lints package.json under that block —
// see CE-1023 for the analogous issue in @n8n/scan-community-package.
extends: [communityNodesRecommended],
rules: {
...n8nNodesPlugin.configs.community.rules,
},

View File

@ -10,12 +10,21 @@
"files": [
"scanner"
],
"scripts": {
"test": "vitest run",
"test:dev": "vitest"
},
"dependencies": {
"eslint": "catalog:",
"fast-glob": "catalog:",
"axios": "catalog:",
"@n8n/eslint-plugin-community-nodes": "workspace:*",
"@typescript-eslint/parser": "^8.35.0",
"semver": "^7.5.4",
"tmp": "0.2.4"
},
"devDependencies": {
"@n8n/vitest-config": "workspace:*",
"vitest": "catalog:"
}
}

View File

@ -117,29 +117,47 @@ const downloadAndExtractPackage = async (packageName, version) => {
}
};
const analyzePackage = async (packageDir) => {
export const analyzePackage = async (packageDir) => {
const { n8nCommunityNodesPlugin } = await import('@n8n/eslint-plugin-community-nodes');
const tsParser = await import('@typescript-eslint/parser');
const eslint = new ESLint({
cwd: packageDir,
allowInlineConfig: false,
overrideConfigFile: true,
overrideConfig: defineConfig(n8nCommunityNodesPlugin.configs.recommended, {
overrideConfig: defineConfig(
n8nCommunityNodesPlugin.configs.recommended,
{
rules: { 'no-console': 'error' },
}),
},
// JSON files (notably `package.json`) are not parseable by ESLint's
// default JS parser, so register the TypeScript parser for them. The
// community-nodes rules that gate on `package.json` walk a TSESTree
// `ObjectExpression` AST, which `@typescript-eslint/parser` produces
// when given a top-level JSON object literal.
{
files: ['**/*.json'],
languageOptions: { parser: tsParser.default ?? tsParser },
},
),
});
try {
const jsFiles = glob.sync('**/*.js', {
// Lint both JS and JSON files. JSON inclusion is required because rules
// such as `no-overrides-field`, `valid-peer-dependencies`, and
// `package-name-convention` only run against `package.json`. Without
// it the scanner silently skips every package.json-based rule.
const filesToLint = glob.sync(['**/*.js', '**/*.json'], {
cwd: packageDir,
absolute: true,
ignore: ['node_modules/**'],
ignore: ['node_modules/**', '**/package-lock.json'],
});
if (jsFiles.length === 0) {
return { passed: true, message: 'No JavaScript files found to analyze' };
if (filesToLint.length === 0) {
return { passed: true, message: 'No files found to analyze' };
}
const results = await eslint.lintFiles(jsFiles);
const results = await eslint.lintFiles(filesToLint);
const violations = results.filter((result) => result.errorCount > 0);
if (violations.length > 0) {

View File

@ -0,0 +1,98 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { analyzePackage } from './scanner.mjs';
/**
* Build a temporary package directory on disk so we can hand it to
* `analyzePackage` exactly the way the scanner would after extracting a
* tarball from npm.
*/
function makeFixturePackage(files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'scan-fixture-'));
for (const [relativePath, contents] of Object.entries(files)) {
const fullPath = path.join(dir, relativePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(
fullPath,
typeof contents === 'string' ? contents : JSON.stringify(contents, null, 2),
);
}
return dir;
}
describe('analyzePackage', () => {
let fixtureDir;
afterEach(() => {
if (fixtureDir) {
fs.rmSync(fixtureDir, { recursive: true, force: true });
fixtureDir = undefined;
}
});
it('lints package.json and flags forbidden `overrides` field (regression for CE-1023)', async () => {
fixtureDir = makeFixturePackage({
'package.json': {
name: 'n8n-nodes-fixture',
version: '1.0.0',
keywords: ['n8n-community-node-package'],
peerDependencies: { 'n8n-workflow': '*' },
overrides: { 'change-case': '4.1.2' },
},
'index.js': "module.exports = {};\n",
});
const result = await analyzePackage(fixtureDir);
expect(result.passed).toBe(false);
expect(result.details).toContain('no-overrides-field');
});
it('passes a clean package that does not violate any error-level rules', async () => {
fixtureDir = makeFixturePackage({
'package.json': {
name: 'n8n-nodes-fixture',
version: '1.0.0',
keywords: ['n8n-community-node-package'],
peerDependencies: { 'n8n-workflow': '*' },
},
'index.js': "module.exports = {};\n",
});
const result = await analyzePackage(fixtureDir);
expect(result.passed).toBe(true);
});
it('flags forbidden lifecycle scripts in package.json', async () => {
fixtureDir = makeFixturePackage({
'package.json': {
name: 'n8n-nodes-fixture',
version: '1.0.0',
keywords: ['n8n-community-node-package'],
peerDependencies: { 'n8n-workflow': '*' },
scripts: { postinstall: 'node ./malicious.js' },
},
'index.js': "module.exports = {};\n",
});
const result = await analyzePackage(fixtureDir);
expect(result.passed).toBe(false);
expect(result.details).toContain('no-forbidden-lifecycle-scripts');
});
it('returns passed when the package contains no lintable files', async () => {
fixtureDir = makeFixturePackage({
'README.md': '# empty package\n',
});
const result = await analyzePackage(fixtureDir);
expect(result.passed).toBe(true);
expect(result.message).toBe('No files found to analyze');
});
});

View File

@ -0,0 +1 @@
export { vitestConfig as default } from '@n8n/vitest-config/node';

View File

@ -2246,6 +2246,9 @@ importers:
'@n8n/eslint-plugin-community-nodes':
specifier: workspace:*
version: link:../eslint-plugin-community-nodes
'@typescript-eslint/parser':
specifier: ^8.35.0
version: 8.35.0(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.2)
axios:
specifier: 1.15.0
version: 1.15.0
@ -2261,6 +2264,13 @@ importers:
tmp:
specifier: 0.2.4
version: 0.2.4
devDependencies:
'@n8n/vitest-config':
specifier: workspace:*
version: link:../vitest-config
vitest:
specifier: 'catalog:'
version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(@vitest/browser-playwright@4.0.16)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))
packages/@n8n/stylelint-config:
dependencies: