diff --git a/packages/plugin-eslint/mocks/fixtures/todos-app/.eslintrc.js b/packages/plugin-eslint/mocks/fixtures/todos-app/.eslintrc.js index 15b1fa5f6..c0278d702 100644 --- a/packages/plugin-eslint/mocks/fixtures/todos-app/.eslintrc.js +++ b/packages/plugin-eslint/mocks/fixtures/todos-app/.eslintrc.js @@ -1,5 +1,6 @@ /** @type {import('eslint').ESLint.ConfigData} */ module.exports = { + root: true, env: { browser: true, es2021: true, diff --git a/packages/plugin-eslint/src/lib/config.ts b/packages/plugin-eslint/src/lib/config.ts index c5cb0d424..d2942662d 100644 --- a/packages/plugin-eslint/src/lib/config.ts +++ b/packages/plugin-eslint/src/lib/config.ts @@ -1,7 +1,8 @@ import type { ESLint } from 'eslint'; import { type ZodType, z } from 'zod'; +import { toArray } from '@code-pushup/utils'; -export const eslintPluginConfigSchema = z.object({ +export const eslintTargetSchema = z.object({ eslintrc: z.union( [ z.string({ description: 'Path to ESLint config file' }), @@ -16,11 +17,14 @@ export const eslintPluginConfigSchema = z.object({ 'Lint target files. May contain file paths, directory paths or glob patterns', }), }); +export type ESLintTarget = z.infer; -export type ESLintPluginConfig = z.infer; +export const eslintPluginConfigSchema = z + .union([eslintTargetSchema, z.array(eslintTargetSchema).min(1)]) + .transform(toArray); +export type ESLintPluginConfig = z.input; export type ESLintPluginRunnerConfig = { - eslintrc: string; + targets: ESLintTarget[]; slugs: string[]; - patterns: string[]; }; diff --git a/packages/plugin-eslint/src/lib/eslint-plugin.ts b/packages/plugin-eslint/src/lib/eslint-plugin.ts index e09ad7528..f555ad042 100644 --- a/packages/plugin-eslint/src/lib/eslint-plugin.ts +++ b/packages/plugin-eslint/src/lib/eslint-plugin.ts @@ -1,12 +1,10 @@ -import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { PluginConfig } from '@code-pushup/models'; import { name, version } from '../../package.json'; import { ESLintPluginConfig, eslintPluginConfigSchema } from './config'; import { listAuditsAndGroups } from './meta'; -import { ESLINTRC_PATH, createRunnerConfig } from './runner'; -import { setupESLint } from './setup'; +import { createRunnerConfig } from './runner'; /** * Instantiates Code PushUp ESLint plugin for use in core config. @@ -31,18 +29,9 @@ import { setupESLint } from './setup'; export async function eslintPlugin( config: ESLintPluginConfig, ): Promise { - const { eslintrc, patterns } = eslintPluginConfigSchema.parse(config); + const targets = eslintPluginConfigSchema.parse(config); - const eslint = setupESLint(eslintrc); - - const { audits, groups } = await listAuditsAndGroups(eslint, patterns); - - // save inline config to file so runner can access it later - if (typeof eslintrc !== 'string') { - await mkdir(dirname(ESLINTRC_PATH), { recursive: true }); - await writeFile(ESLINTRC_PATH, JSON.stringify(eslintrc)); - } - const eslintrcPath = typeof eslintrc === 'string' ? eslintrc : ESLINTRC_PATH; + const { audits, groups } = await listAuditsAndGroups(targets); const runnerScriptPath = join( fileURLToPath(dirname(import.meta.url)), @@ -61,11 +50,6 @@ export async function eslintPlugin( audits, groups, - runner: await createRunnerConfig( - runnerScriptPath, - audits, - eslintrcPath, - patterns, - ), + runner: await createRunnerConfig(runnerScriptPath, audits, targets), }; } diff --git a/packages/plugin-eslint/src/lib/meta/index.ts b/packages/plugin-eslint/src/lib/meta/index.ts index b90a2ce58..1252c8778 100644 --- a/packages/plugin-eslint/src/lib/meta/index.ts +++ b/packages/plugin-eslint/src/lib/meta/index.ts @@ -1,14 +1,13 @@ -import type { ESLint } from 'eslint'; import type { Audit, Group } from '@code-pushup/models'; +import type { ESLintTarget } from '../config'; import { groupsFromRuleCategories, groupsFromRuleTypes } from './groups'; import { listRules } from './rules'; import { ruleToAudit } from './transform'; export async function listAuditsAndGroups( - eslint: ESLint, - patterns: string | string[], + targets: ESLintTarget[], ): Promise<{ audits: Audit[]; groups: Group[] }> { - const rules = await listRules(eslint, patterns); + const rules = await listRules(targets); const audits = rules.map(ruleToAudit); diff --git a/packages/plugin-eslint/src/lib/meta/rules.ts b/packages/plugin-eslint/src/lib/meta/rules.ts index 2a5d0d131..a5dd1ff70 100644 --- a/packages/plugin-eslint/src/lib/meta/rules.ts +++ b/packages/plugin-eslint/src/lib/meta/rules.ts @@ -1,5 +1,7 @@ import type { ESLint, Linter, Rule } from 'eslint'; import { distinct, toArray, ui } from '@code-pushup/utils'; +import type { ESLintTarget } from '../config'; +import { setupESLint } from '../setup'; import { jsonHash } from './hash'; export type RuleData = { @@ -8,10 +10,23 @@ export type RuleData = { options: unknown[] | undefined; }; -export async function listRules( +type RulesMap = Record>; + +export async function listRules(targets: ESLintTarget[]): Promise { + const rulesMap = await targets.reduce(async (acc, { eslintrc, patterns }) => { + const eslint = setupESLint(eslintrc); + const prev = await acc; + const curr = await loadRulesMap(eslint, patterns); + return mergeRulesMaps(prev, curr); + }, Promise.resolve({})); + + return Object.values(rulesMap).flatMap(Object.values); +} + +async function loadRulesMap( eslint: ESLint, patterns: string | string[], -): Promise { +): Promise { const configs = await toArray(patterns).reduce( async (acc, pattern) => [ ...(await acc), @@ -31,35 +46,43 @@ export async function listRules( } as ESLint.LintResult, ]); - const rulesMap = configs + return configs .flatMap(config => Object.entries(config.rules ?? {})) .filter(([, ruleEntry]) => ruleEntry != null && !isRuleOff(ruleEntry)) - .reduce>>( - (acc, [ruleId, ruleEntry]) => { - const meta = rulesMeta[ruleId]; - if (!meta) { - ui().logger.warning(`Metadata not found for ESLint rule ${ruleId}`); - return acc; - } - const options = toArray(ruleEntry).slice(1); - const optionsHash = jsonHash(options); - const ruleData: RuleData = { - ruleId, - meta, - options, - }; - return { - ...acc, - [ruleId]: { - ...acc[ruleId], - [optionsHash]: ruleData, - }, - }; - }, - {}, - ); + .reduce((acc, [ruleId, ruleEntry]) => { + const meta = rulesMeta[ruleId]; + if (!meta) { + ui().logger.warning(`Metadata not found for ESLint rule ${ruleId}`); + return acc; + } + const options = toArray(ruleEntry).slice(1); + const optionsHash = jsonHash(options); + const ruleData: RuleData = { + ruleId, + meta, + options, + }; + return { + ...acc, + [ruleId]: { + ...acc[ruleId], + [optionsHash]: ruleData, + }, + }; + }, {}); +} - return Object.values(rulesMap).flatMap(Object.values); +function mergeRulesMaps(prev: RulesMap, curr: RulesMap): RulesMap { + return Object.entries(curr).reduce( + (acc, [ruleId, ruleVariants]) => ({ + ...acc, + [ruleId]: { + ...acc[ruleId], + ...ruleVariants, + }, + }), + prev, + ); } function isRuleOff(entry: Linter.RuleEntry): boolean { diff --git a/packages/plugin-eslint/src/lib/meta/rules.unit.test.ts b/packages/plugin-eslint/src/lib/meta/rules.unit.test.ts index c81397685..53e81f69f 100644 --- a/packages/plugin-eslint/src/lib/meta/rules.unit.test.ts +++ b/packages/plugin-eslint/src/lib/meta/rules.unit.test.ts @@ -1,7 +1,7 @@ -import { ESLint } from 'eslint'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { MockInstance } from 'vitest'; +import type { ESLintTarget } from '../config'; import { RuleData, listRules, parseRuleId } from './rules'; describe('listRules', () => { @@ -28,22 +28,19 @@ describe('listRules', () => { const appRootDir = join(fixturesDir, 'todos-app'); const eslintrc = join(appRootDir, '.eslintrc.js'); - const eslint = new ESLint({ - useEslintrc: false, - baseConfig: { extends: eslintrc }, - }); const patterns = ['src/**/*.js', 'src/**/*.jsx']; + const targets: ESLintTarget[] = [{ eslintrc, patterns }]; beforeAll(() => { cwdSpy.mockReturnValue(appRootDir); }); it('should list expected number of rules', async () => { - await expect(listRules(eslint, patterns)).resolves.toHaveLength(47); + await expect(listRules(targets)).resolves.toHaveLength(47); }); it('should include explicitly set built-in rule', async () => { - await expect(listRules(eslint, patterns)).resolves.toContainEqual({ + await expect(listRules(targets)).resolves.toContainEqual({ ruleId: 'no-const-assign', meta: { docs: { @@ -62,7 +59,7 @@ describe('listRules', () => { }); it('should include explicitly set plugin rule', async () => { - await expect(listRules(eslint, patterns)).resolves.toContainEqual({ + await expect(listRules(targets)).resolves.toContainEqual({ ruleId: 'react/jsx-key', meta: { docs: { @@ -94,24 +91,21 @@ describe('listRules', () => { const nxRootDir = join(fixturesDir, 'nx-monorepo'); const eslintrc = join(nxRootDir, 'packages/utils/.eslintrc.json'); - const eslint = new ESLint({ - useEslintrc: false, - baseConfig: { extends: eslintrc }, - }); const patterns = ['packages/utils/**/*.ts', 'packages/utils/**/*.json']; + const targets: ESLintTarget[] = [{ eslintrc, patterns }]; beforeAll(() => { cwdSpy.mockReturnValue(nxRootDir); }); it('should list expected number of rules', async () => { - const rules = await listRules(eslint, patterns); + const rules = await listRules(targets); expect(rules.length).toBeGreaterThanOrEqual(50); }); it('should include explicitly set plugin rule with custom options', async () => { // set in root .eslintrc.json - await expect(listRules(eslint, patterns)).resolves.toContainEqual({ + await expect(listRules(targets)).resolves.toContainEqual({ ruleId: '@nx/enforce-module-boundaries', meta: expect.any(Object), options: [ @@ -131,7 +125,7 @@ describe('listRules', () => { it('should include built-in rule set implicitly by extending recommended config', async () => { // extended via @nx/typescript -> @typescript-eslint/eslint-recommended - await expect(listRules(eslint, patterns)).resolves.toContainEqual({ + await expect(listRules(targets)).resolves.toContainEqual({ ruleId: 'no-var', meta: expect.any(Object), options: [], @@ -140,7 +134,7 @@ describe('listRules', () => { it('should include plugin rule set implicitly by extending recommended config', async () => { // extended via @nx/typescript -> @typescript-eslint/recommended - await expect(listRules(eslint, patterns)).resolves.toContainEqual({ + await expect(listRules(targets)).resolves.toContainEqual({ ruleId: '@typescript-eslint/no-unused-vars', meta: expect.any(Object), options: [], @@ -149,7 +143,7 @@ describe('listRules', () => { it('should not include rule which was turned off in extended config', async () => { // extended TypeScript config sets "no-unused-semi": "off" - await expect(listRules(eslint, patterns)).resolves.not.toContainEqual( + await expect(listRules(targets)).resolves.not.toContainEqual( expect.objectContaining({ ruleId: 'no-unused-vars', } satisfies Partial), @@ -158,7 +152,7 @@ describe('listRules', () => { it('should include rule added to root config by project config', async () => { // set only in packages/utils/.eslintrc.json - await expect(listRules(eslint, patterns)).resolves.toContainEqual({ + await expect(listRules(targets)).resolves.toContainEqual({ ruleId: '@nx/dependency-checks', meta: expect.any(Object), options: [], diff --git a/packages/plugin-eslint/src/lib/nx.integration.test.ts b/packages/plugin-eslint/src/lib/nx.integration.test.ts index 4ead6f26a..6dd49a7cd 100644 --- a/packages/plugin-eslint/src/lib/nx.integration.test.ts +++ b/packages/plugin-eslint/src/lib/nx.integration.test.ts @@ -2,7 +2,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { setWorkspaceRoot, workspaceRoot } from 'nx/src/utils/workspace-root'; import type { MockInstance } from 'vitest'; -import { ESLintPluginConfig } from './config'; +import { type ESLintTarget } from './config'; import { eslintConfigFromNxProject, eslintConfigFromNxProjects } from './nx'; describe('Nx helpers', () => { @@ -33,63 +33,53 @@ describe('Nx helpers', () => { describe('create config from all Nx projects', () => { it('should include eslintrc and patterns of each project', async () => { - await expect(eslintConfigFromNxProjects()).resolves.toEqual({ - eslintrc: { - root: true, - overrides: [ - { - files: ['packages/cli/**/*.ts', 'packages/cli/package.json'], - extends: './packages/cli/.eslintrc.json', - }, - { - files: ['packages/core/**/*.ts', 'packages/core/package.json'], - extends: './packages/core/.eslintrc.json', - }, - { - files: [ - 'packages/nx-plugin/**/*.ts', - 'packages/nx-plugin/package.json', - 'packages/nx-plugin/generators.json', - ], - extends: './packages/nx-plugin/.eslintrc.json', - }, - { - files: ['packages/utils/**/*.ts', 'packages/utils/package.json'], - extends: './packages/utils/.eslintrc.json', - }, + await expect(eslintConfigFromNxProjects()).resolves.toEqual([ + { + eslintrc: './packages/cli/.eslintrc.json', + patterns: [ + 'packages/cli/**/*.ts', + 'packages/cli/package.json', + 'packages/cli/src/*.spec.ts', + 'packages/cli/src/*.cy.ts', + 'packages/cli/src/*.stories.ts', + 'packages/cli/src/.storybook/main.ts', ], }, - patterns: [ - 'packages/cli/**/*.ts', - 'packages/cli/package.json', - 'packages/cli/src/*.spec.ts', - 'packages/cli/src/*.cy.ts', - 'packages/cli/src/*.stories.ts', - 'packages/cli/src/.storybook/main.ts', - - 'packages/core/**/*.ts', - 'packages/core/package.json', - 'packages/core/src/*.spec.ts', - 'packages/core/src/*.cy.ts', - 'packages/core/src/*.stories.ts', - 'packages/core/src/.storybook/main.ts', - - 'packages/nx-plugin/**/*.ts', - 'packages/nx-plugin/package.json', - 'packages/nx-plugin/generators.json', - 'packages/nx-plugin/src/*.spec.ts', - 'packages/nx-plugin/src/*.cy.ts', - 'packages/nx-plugin/src/*.stories.ts', - 'packages/nx-plugin/src/.storybook/main.ts', - - 'packages/utils/**/*.ts', - 'packages/utils/package.json', - 'packages/utils/src/*.spec.ts', - 'packages/utils/src/*.cy.ts', - 'packages/utils/src/*.stories.ts', - 'packages/utils/src/.storybook/main.ts', - ], - } satisfies ESLintPluginConfig); + { + eslintrc: './packages/core/.eslintrc.json', + patterns: [ + 'packages/core/**/*.ts', + 'packages/core/package.json', + 'packages/core/src/*.spec.ts', + 'packages/core/src/*.cy.ts', + 'packages/core/src/*.stories.ts', + 'packages/core/src/.storybook/main.ts', + ], + }, + { + eslintrc: './packages/nx-plugin/.eslintrc.json', + patterns: [ + 'packages/nx-plugin/**/*.ts', + 'packages/nx-plugin/package.json', + 'packages/nx-plugin/generators.json', + 'packages/nx-plugin/src/*.spec.ts', + 'packages/nx-plugin/src/*.cy.ts', + 'packages/nx-plugin/src/*.stories.ts', + 'packages/nx-plugin/src/.storybook/main.ts', + ], + }, + { + eslintrc: './packages/utils/.eslintrc.json', + patterns: [ + 'packages/utils/**/*.ts', + 'packages/utils/package.json', + 'packages/utils/src/*.spec.ts', + 'packages/utils/src/*.cy.ts', + 'packages/utils/src/*.stories.ts', + 'packages/utils/src/.storybook/main.ts', + ], + }, + ] satisfies ESLintTarget[]); }); }); @@ -119,28 +109,14 @@ describe('Nx helpers', () => { ])( 'project %j - expected configurations for projects %j', async (project, expectedProjects) => { - const otherProjects = ALL_PROJECTS.filter( - p => !expectedProjects.includes(p), - ); - - const config = await eslintConfigFromNxProject(project); + const targets = await eslintConfigFromNxProject(project); - expect(config.eslintrc).toEqual({ - root: true, - overrides: expectedProjects.map(p => ({ - files: expect.arrayContaining([`packages/${p}/**/*.ts`]), - extends: `./packages/${p}/.eslintrc.json`, - })), - }); - - expect(config.patterns).toEqual( - expect.arrayContaining( - expectedProjects.map(p => `packages/${p}/**/*.ts`), - ), - ); - expect(config.patterns).toEqual( - expect.not.arrayContaining( - otherProjects.map(p => `packages/${p}/**/*.ts`), + expect(targets).toEqual( + expectedProjects.map( + (p): ESLintTarget => ({ + eslintrc: `./packages/${p}/.eslintrc.json`, + patterns: expect.arrayContaining([`packages/${p}/**/*.ts`]), + }), ), ); }, diff --git a/packages/plugin-eslint/src/lib/nx/find-all-projects.ts b/packages/plugin-eslint/src/lib/nx/find-all-projects.ts index 916839689..219f37b5f 100644 --- a/packages/plugin-eslint/src/lib/nx/find-all-projects.ts +++ b/packages/plugin-eslint/src/lib/nx/find-all-projects.ts @@ -1,4 +1,4 @@ -import type { ESLintPluginConfig } from '../config'; +import type { ESLintTarget } from '../config'; import { nxProjectsToConfig } from './projects-to-config'; /** @@ -22,7 +22,7 @@ import { nxProjectsToConfig } from './projects-to-config'; * * @returns ESLint config and patterns, intended to be passed to {@link eslintPlugin} */ -export async function eslintConfigFromNxProjects(): Promise { +export async function eslintConfigFromNxProjects(): Promise { const { createProjectGraphAsync } = await import('@nx/devkit'); const projectGraph = await createProjectGraphAsync({ exitOnError: false }); return nxProjectsToConfig(projectGraph); diff --git a/packages/plugin-eslint/src/lib/nx/find-project-with-deps.ts b/packages/plugin-eslint/src/lib/nx/find-project-with-deps.ts index 818ba1e42..99f501ff1 100644 --- a/packages/plugin-eslint/src/lib/nx/find-project-with-deps.ts +++ b/packages/plugin-eslint/src/lib/nx/find-project-with-deps.ts @@ -1,4 +1,4 @@ -import type { ESLintPluginConfig } from '../config'; +import type { ESLintTarget } from '../config'; import { nxProjectsToConfig } from './projects-to-config'; import { findAllDependencies } from './traverse-graph'; @@ -28,7 +28,7 @@ import { findAllDependencies } from './traverse-graph'; */ export async function eslintConfigFromNxProject( projectName: string, -): Promise { +): Promise { const { createProjectGraphAsync } = await import('@nx/devkit'); const projectGraph = await createProjectGraphAsync({ exitOnError: false }); diff --git a/packages/plugin-eslint/src/lib/nx/projects-to-config.ts b/packages/plugin-eslint/src/lib/nx/projects-to-config.ts index 8e2536238..02bd699bd 100644 --- a/packages/plugin-eslint/src/lib/nx/projects-to-config.ts +++ b/packages/plugin-eslint/src/lib/nx/projects-to-config.ts @@ -1,6 +1,5 @@ import type { ProjectConfiguration, ProjectGraph } from '@nx/devkit'; -import type { ESLint } from 'eslint'; -import type { ESLintPluginConfig } from '../config'; +import type { ESLintTarget } from '../config'; import { findCodePushupEslintrc, getEslintConfig, @@ -10,7 +9,7 @@ import { export async function nxProjectsToConfig( projectGraph: ProjectGraph, predicate: (project: ProjectConfiguration) => boolean = () => true, -): Promise { +): Promise { // find Nx projects with lint target const { readProjectsConfigurationFromProjectGraph } = await import( '@nx/devkit' @@ -22,32 +21,22 @@ export async function nxProjectsToConfig( .filter(predicate) // apply predicate .sort((a, b) => a.root.localeCompare(b.root)); - // create single ESLint config with project-specific overrides - const eslintConfig: ESLint.ConfigData = { - root: true, - overrides: await Promise.all( - projects.map(async project => ({ - files: getLintFilePatterns(project), - extends: + return Promise.all( + projects.map( + async (project): Promise => ({ + eslintrc: (await findCodePushupEslintrc(project)) ?? getEslintConfig(project), - })), + patterns: [ + ...getLintFilePatterns(project), + // HACK: ESLint.calculateConfigForFile won't find rules included only for subsets of *.ts when globs used + // so we explicitly provide additional patterns used by @code-pushup/eslint-config to ensure those rules are included + // this workaround won't be necessary once flat configs are stable (much easier to find all rules) + `${project.sourceRoot}/*.spec.ts`, // jest/* and vitest/* rules + `${project.sourceRoot}/*.cy.ts`, // cypress/* rules + `${project.sourceRoot}/*.stories.ts`, // storybook/* rules + `${project.sourceRoot}/.storybook/main.ts`, // storybook/no-uninstalled-addons rule + ], + }), ), - }; - - // include patterns from each project - const patterns = projects.flatMap(project => [ - ...getLintFilePatterns(project), - // HACK: ESLint.calculateConfigForFile won't find rules included only for subsets of *.ts when globs used - // so we explicitly provide additional patterns used by @code-pushup/eslint-config to ensure those rules are included - // this workaround won't be necessary once flat configs are stable (much easier to find all rules) - `${project.sourceRoot}/*.spec.ts`, // jest/* and vitest/* rules - `${project.sourceRoot}/*.cy.ts`, // cypress/* rules - `${project.sourceRoot}/*.stories.ts`, // storybook/* rules - `${project.sourceRoot}/.storybook/main.ts`, // storybook/no-uninstalled-addons rule - ]); - - return { - eslintrc: eslintConfig, - patterns, - }; + ); } diff --git a/packages/plugin-eslint/src/lib/nx/projects-to-config.unit.test.ts b/packages/plugin-eslint/src/lib/nx/projects-to-config.unit.test.ts index ae850a883..b00bca913 100644 --- a/packages/plugin-eslint/src/lib/nx/projects-to-config.unit.test.ts +++ b/packages/plugin-eslint/src/lib/nx/projects-to-config.unit.test.ts @@ -3,11 +3,10 @@ import type { ProjectGraphDependency, ProjectGraphProjectNode, } from '@nx/devkit'; -import type { ESLint } from 'eslint'; import { vol } from 'memfs'; import type { MockInstance } from 'vitest'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; -import type { ESLintPluginConfig } from '../config'; +import type { ESLintPluginConfig, ESLintTarget } from '../config'; import { nxProjectsToConfig } from './projects-to-config'; describe('nxProjectsToConfig', () => { @@ -28,6 +27,7 @@ describe('nxProjectsToConfig', () => { }, }, }, + sourceRoot: `${node.data.root}/src`, ...node.data, }, }, @@ -65,20 +65,19 @@ describe('nxProjectsToConfig', () => { ]); const config = await nxProjectsToConfig(projectGraph); - const { overrides } = config.eslintrc as ESLint.ConfigData; - expect(overrides).toEqual([ + expect(config).toEqual([ { - files: ['apps/client/**/*.ts'], - extends: './apps/client/.eslintrc.json', + eslintrc: './apps/client/.eslintrc.json', + patterns: expect.arrayContaining(['apps/client/**/*.ts']), }, { - files: ['apps/server/**/*.ts'], - extends: './apps/server/.eslintrc.json', + eslintrc: './apps/server/.eslintrc.json', + patterns: expect.arrayContaining(['apps/server/**/*.ts']), }, { - files: ['libs/models/**/*.ts'], - extends: './libs/models/.eslintrc.json', + eslintrc: './libs/models/.eslintrc.json', + patterns: expect.arrayContaining(['libs/models/**/*.ts']), }, ]); }); @@ -107,11 +106,10 @@ describe('nxProjectsToConfig', () => { project => project.projectType === 'library', ); - const { overrides } = config.eslintrc as ESLint.ConfigData; - expect(overrides).toEqual([ + expect(config).toEqual([ { - files: ['libs/models/**/*.ts'], - extends: './libs/models/.eslintrc.json', + eslintrc: './libs/models/.eslintrc.json', + patterns: expect.arrayContaining(['libs/models/**/*.ts']), }, ]); }); @@ -150,15 +148,14 @@ describe('nxProjectsToConfig', () => { const config = await nxProjectsToConfig(projectGraph); - const { overrides } = config.eslintrc as ESLint.ConfigData; - expect(overrides).toEqual([ + expect(config).toEqual([ { - files: ['apps/client/**/*.ts'], - extends: './apps/client/.eslintrc.json', + eslintrc: './apps/client/.eslintrc.json', + patterns: expect.arrayContaining(['apps/client/**/*.ts']), }, { - files: ['apps/server/**/*.ts'], - extends: './apps/server/.eslintrc.json', + eslintrc: './apps/server/.eslintrc.json', + patterns: expect.arrayContaining(['apps/server/**/*.ts']), }, ]); }); @@ -167,7 +164,7 @@ describe('nxProjectsToConfig', () => { vol.fromJSON( { 'apps/client/code-pushup.eslintrc.json': - '{ "extends": "@code-pushup" }', + '{ "eslintrc": "@code-pushup" }', }, MEMFS_VOLUME, ); @@ -177,10 +174,9 @@ describe('nxProjectsToConfig', () => { const config = await nxProjectsToConfig(projectGraph); - const { overrides } = config.eslintrc as ESLint.ConfigData; - expect(overrides).toEqual([ - expect.objectContaining({ - extends: './apps/client/code-pushup.eslintrc.json', + expect(config).toEqual([ + expect.objectContaining>({ + eslintrc: './apps/client/code-pushup.eslintrc.json', }), ]); }); @@ -220,25 +216,18 @@ describe('nxProjectsToConfig', () => { }, ]); - await expect(nxProjectsToConfig(projectGraph)).resolves.toEqual({ - eslintrc: { - root: true, - overrides: [ - { - files: ['apps/client/**/*.ts', 'apps/client/**/*.html'], - extends: './apps/client/.eslintrc.json', - }, - { - files: ['apps/server/**/*.ts'], - extends: './apps/server/.eslintrc.json', - }, - ], + await expect(nxProjectsToConfig(projectGraph)).resolves.toEqual([ + { + patterns: expect.arrayContaining([ + 'apps/client/**/*.ts', + 'apps/client/**/*.html', + ]), + eslintrc: './apps/client/.eslintrc.json', }, - patterns: expect.arrayContaining([ - 'apps/client/**/*.ts', - 'apps/client/**/*.html', - 'apps/server/**/*.ts', - ]), - } satisfies ESLintPluginConfig); + { + patterns: expect.arrayContaining(['apps/server/**/*.ts']), + eslintrc: './apps/server/.eslintrc.json', + }, + ] satisfies ESLintPluginConfig); }); }); diff --git a/packages/plugin-eslint/src/lib/runner.integration.test.ts b/packages/plugin-eslint/src/lib/runner.integration.test.ts index ba01076b1..d831537b9 100644 --- a/packages/plugin-eslint/src/lib/runner.integration.test.ts +++ b/packages/plugin-eslint/src/lib/runner.integration.test.ts @@ -1,31 +1,27 @@ -import { ESLint } from 'eslint'; -import { rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { MockInstance, describe, expect, it } from 'vitest'; import type { AuditOutput, AuditOutputs, Issue } from '@code-pushup/models'; import { osAgnosticAuditOutputs } from '@code-pushup/test-utils'; -import { ensureDirectoryExists, readJsonFile } from '@code-pushup/utils'; +import { readJsonFile } from '@code-pushup/utils'; +import type { ESLintTarget } from './config'; import { listAuditsAndGroups } from './meta'; import { - ESLINTRC_PATH, - PLUGIN_CONFIG_PATH, RUNNER_OUTPUT_PATH, createRunnerConfig, executeRunner, } from './runner'; -import { setupESLint } from './setup'; describe('executeRunner', () => { let cwdSpy: MockInstance<[], string>; let platformSpy: MockInstance<[], NodeJS.Platform>; - const createPluginConfig = async (eslintrc: string) => { + const createPluginConfig = async (eslintrc: ESLintTarget['eslintrc']) => { const patterns = ['src/**/*.js', 'src/**/*.jsx']; - const eslint = setupESLint(eslintrc); - const { audits } = await listAuditsAndGroups(eslint, patterns); - await createRunnerConfig('bin.js', audits, eslintrc, patterns); + const targets: ESLintTarget[] = [{ eslintrc, patterns }]; + const { audits } = await listAuditsAndGroups(targets); + await createRunnerConfig('bin.js', audits, targets); }; const appDir = join( @@ -37,24 +33,15 @@ describe('executeRunner', () => { 'todos-app', ); - beforeAll(async () => { + beforeAll(() => { cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(appDir); // Windows does not require additional quotation marks for globs platformSpy = vi.spyOn(os, 'platform').mockReturnValue('win32'); - - const config: ESLint.ConfigData = { - extends: '@code-pushup', - }; - await ensureDirectoryExists(dirname(ESLINTRC_PATH)); - await writeFile(ESLINTRC_PATH, JSON.stringify(config)); }); - afterAll(async () => { + afterAll(() => { cwdSpy.mockRestore(); platformSpy.mockRestore(); - - await rm(ESLINTRC_PATH, { force: true }); - await rm(PLUGIN_CONFIG_PATH, { force: true }); }); it('should execute ESLint and create audit results for React application', async () => { @@ -66,7 +53,7 @@ describe('executeRunner', () => { }); it('should execute runner with inline config using @code-pushup/eslint-config', async () => { - await createPluginConfig(ESLINTRC_PATH); + await createPluginConfig({ extends: '@code-pushup' }); await executeRunner(); const json = await readJsonFile(RUNNER_OUTPUT_PATH); @@ -74,7 +61,7 @@ describe('executeRunner', () => { expect(json).toContainEqual( expect.objectContaining>({ slug: 'unicorn-filename-case', - displayValue: '5 warnings', + displayValue: expect.stringMatching(/^\d+ warnings?$/), details: { issues: expect.arrayContaining([ { diff --git a/packages/plugin-eslint/src/lib/runner/index.ts b/packages/plugin-eslint/src/lib/runner/index.ts index 84998a136..4de4275a1 100644 --- a/packages/plugin-eslint/src/lib/runner/index.ts +++ b/packages/plugin-eslint/src/lib/runner/index.ts @@ -5,15 +5,14 @@ import { ensureDirectoryExists, pluginWorkDir, readJsonFile, - toArray, } from '@code-pushup/utils'; -import { ESLintPluginRunnerConfig } from '../config'; +import { ESLintPluginRunnerConfig, type ESLintTarget } from '../config'; import { lint } from './lint'; -import { lintResultsToAudits } from './transform'; +import { lintResultsToAudits, mergeLinterOutputs } from './transform'; +import type { LinterOutput } from './types'; export const WORKDIR = pluginWorkDir('eslint'); export const RUNNER_OUTPUT_PATH = join(WORKDIR, 'runner-output.json'); -export const ESLINTRC_PATH = join(process.cwd(), WORKDIR, '.eslintrc.json'); export const PLUGIN_CONFIG_PATH = join( process.cwd(), WORKDIR, @@ -21,15 +20,15 @@ export const PLUGIN_CONFIG_PATH = join( ); export async function executeRunner(): Promise { - const { slugs, eslintrc, patterns } = - await readJsonFile(PLUGIN_CONFIG_PATH); + const { slugs, targets } = await readJsonFile( + PLUGIN_CONFIG_PATH, + ); - const lintResults = await lint({ - // if file created from inline object, provide inline to preserve relative links - eslintrc: - eslintrc === ESLINTRC_PATH ? await readJsonFile(eslintrc) : eslintrc, - patterns, - }); + const linterOutputs = await targets.reduce( + async (acc, target) => [...(await acc), await lint(target)], + Promise.resolve([]), + ); + const lintResults = mergeLinterOutputs(linterOutputs); const failedAudits = lintResultsToAudits(lintResults); const audits = slugs.map( @@ -50,13 +49,11 @@ export async function executeRunner(): Promise { export async function createRunnerConfig( scriptPath: string, audits: Audit[], - eslintrc: string, - patterns: string | string[], + targets: ESLintTarget[], ): Promise { const config: ESLintPluginRunnerConfig = { - eslintrc, + targets, slugs: audits.map(audit => audit.slug), - patterns: toArray(patterns), }; await ensureDirectoryExists(dirname(PLUGIN_CONFIG_PATH)); await writeFile(PLUGIN_CONFIG_PATH, JSON.stringify(config)); diff --git a/packages/plugin-eslint/src/lib/runner/lint.ts b/packages/plugin-eslint/src/lib/runner/lint.ts index 560d7b5ab..09c6e9740 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.ts @@ -1,44 +1,101 @@ -import type { Linter } from 'eslint'; -import { distinct, toArray } from '@code-pushup/utils'; -import { ESLintPluginConfig } from '../config'; +import type { ESLint, Linter } from 'eslint'; +import { rm, writeFile } from 'node:fs/promises'; +import { platform } from 'node:os'; +import { join } from 'node:path'; +import { distinct, executeProcess, toArray } from '@code-pushup/utils'; +import type { ESLintTarget } from '../config'; import { setupESLint } from '../setup'; import type { LinterOutput, RuleOptionsPerFile } from './types'; export async function lint({ eslintrc, patterns, -}: ESLintPluginConfig): Promise { +}: ESLintTarget): Promise { + const results = await executeLint({ eslintrc, patterns }); + const ruleOptionsPerFile = await loadRuleOptionsPerFile(eslintrc, results); + return { results, ruleOptionsPerFile }; +} + +function executeLint({ + eslintrc, + patterns, +}: ESLintTarget): Promise { + return withConfig(eslintrc, async configPath => { + // running as CLI because ESLint#lintFiles() runs out of memory + const { stdout } = await executeProcess({ + command: 'npx', + args: [ + 'eslint', + `--config=${configPath}`, + '--no-eslintrc', + '--no-error-on-unmatched-pattern', + '--format=json', + ...toArray(patterns).map(pattern => + // globs need to be escaped on Unix + platform() === 'win32' ? pattern : `'${pattern}'`, + ), + ], + ignoreExitCode: true, + cwd: process.cwd(), + }); + + return JSON.parse(stdout) as ESLint.LintResult[]; + }); +} + +function loadRuleOptionsPerFile( + eslintrc: ESLintTarget['eslintrc'], + results: ESLint.LintResult[], +): Promise { const eslint = setupESLint(eslintrc); - const results = await eslint.lintFiles(patterns); - - const ruleOptionsPerFile = await results.reduce( - async (acc, { filePath, messages }) => { - const filesMap = await acc; - const config = (await eslint.calculateConfigForFile( - filePath, - )) as Linter.Config; - const ruleIds = distinct( - messages - .map(({ ruleId }) => ruleId) - .filter((ruleId): ruleId is string => ruleId != null), - ); - const rulesMap = Object.fromEntries( - ruleIds.map(ruleId => [ - ruleId, - toArray(config.rules?.[ruleId] ?? []).slice(1), - ]), - ); - return { - ...filesMap, - [filePath]: { - ...filesMap[filePath], - ...rulesMap, - }, - }; - }, - Promise.resolve({}), - ); + return results.reduce(async (acc, { filePath, messages }) => { + const filesMap = await acc; + const config = (await eslint.calculateConfigForFile( + filePath, + )) as Linter.Config; + const ruleIds = distinct( + messages + .map(({ ruleId }) => ruleId) + .filter((ruleId): ruleId is string => ruleId != null), + ); + const rulesMap = Object.fromEntries( + ruleIds.map(ruleId => [ + ruleId, + toArray(config.rules?.[ruleId] ?? []).slice(1), + ]), + ); + return { + ...filesMap, + [filePath]: { + ...filesMap[filePath], + ...rulesMap, + }, + }; + }, Promise.resolve({})); +} - return { results, ruleOptionsPerFile }; +async function withConfig( + eslintrc: ESLintTarget['eslintrc'], + fn: (configPath: string) => Promise, +): Promise { + if (typeof eslintrc === 'string') { + return fn(eslintrc); + } + + const configPath = generateTempConfigPath(); + await writeFile(configPath, JSON.stringify(eslintrc)); + + try { + return await fn(configPath); + } finally { + await rm(configPath); + } +} + +function generateTempConfigPath(): string { + return join( + process.cwd(), + `.eslintrc.${Math.random().toString().slice(2)}.json`, + ); } diff --git a/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts b/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts index 0c61272bb..a15407dcc 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts @@ -1,30 +1,10 @@ import { ESLint, Linter } from 'eslint'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { executeProcess } from '@code-pushup/utils'; import { ESLintPluginConfig } from '../config'; import { lint } from './lint'; class MockESLint { - lintFiles = vi.fn().mockResolvedValue([ - { - filePath: `${process.cwd()}/src/app/app.component.ts`, - messages: [ - { ruleId: 'max-lines' }, - { ruleId: '@typescript-eslint/no-explicit-any' }, - { ruleId: '@typescript-eslint/no-explicit-any' }, - ], - }, - { - filePath: `${process.cwd()}/src/app/app.component.spec.ts`, - messages: [ - { ruleId: 'max-lines' }, - { ruleId: '@typescript-eslint/no-explicit-any' }, - ], - }, - { - filePath: `${process.cwd()}/src/app/pages/settings.component.ts`, - messages: [{ ruleId: 'max-lines' }], - }, - ] as ESLint.LintResult[]); - calculateConfigForFile = vi.fn().mockImplementation( (path: string) => ({ @@ -50,6 +30,41 @@ vi.mock('eslint', () => ({ }), })); +vi.mock('@code-pushup/utils', async () => { + const utils = await vi.importActual('@code-pushup/utils'); + // eslint-disable-next-line @typescript-eslint/naming-convention + const testUtils: { MEMFS_VOLUME: string } = await vi.importActual( + '@code-pushup/test-utils', + ); + const cwd = testUtils.MEMFS_VOLUME; + return { + ...utils, + executeProcess: vi.fn().mockResolvedValue({ + stdout: JSON.stringify([ + { + filePath: `${cwd}/src/app/app.component.ts`, + messages: [ + { ruleId: 'max-lines' }, + { ruleId: '@typescript-eslint/no-explicit-any' }, + { ruleId: '@typescript-eslint/no-explicit-any' }, + ], + }, + { + filePath: `${cwd}/src/app/app.component.spec.ts`, + messages: [ + { ruleId: 'max-lines' }, + { ruleId: '@typescript-eslint/no-explicit-any' }, + ], + }, + { + filePath: `${cwd}/src/app/pages/settings.component.ts`, + messages: [{ ruleId: 'max-lines' }], + }, + ] as ESLint.LintResult[]), + }), + }; +}); + describe('lint', () => { const config: ESLintPluginConfig = { eslintrc: '.eslintrc.js', @@ -77,15 +92,31 @@ describe('lint', () => { }); }); - it('should correctly use ESLint Node API', async () => { + it('should correctly use ESLint CLI and Node API', async () => { await lint(config); expect(ESLint).toHaveBeenCalledWith>({ overrideConfigFile: '.eslintrc.js', useEslintrc: false, errorOnUnmatchedPattern: false, }); - expect(eslint.lintFiles).toHaveBeenCalledTimes(1); - expect(eslint.lintFiles).toHaveBeenCalledWith(['**/*.js']); + + expect(executeProcess).toHaveBeenCalledTimes(1); + expect(executeProcess).toHaveBeenCalledWith< + Parameters + >({ + command: 'npx', + args: [ + 'eslint', + '--config=.eslintrc.js', + '--no-eslintrc', + '--no-error-on-unmatched-pattern', + '--format=json', + expect.stringContaining('**/*.js'), // wraps in quotes on Unix + ], + ignoreExitCode: true, + cwd: MEMFS_VOLUME, + }); + expect(eslint.calculateConfigForFile).toHaveBeenCalledTimes(3); expect(eslint.calculateConfigForFile).toHaveBeenCalledWith( `${process.cwd()}/src/app/app.component.ts`, diff --git a/packages/plugin-eslint/src/lib/runner/transform.ts b/packages/plugin-eslint/src/lib/runner/transform.ts index 4185df935..ecbdfb49c 100644 --- a/packages/plugin-eslint/src/lib/runner/transform.ts +++ b/packages/plugin-eslint/src/lib/runner/transform.ts @@ -15,6 +15,16 @@ type LintIssue = Linter.LintMessage & { filePath: string; }; +export function mergeLinterOutputs(outputs: LinterOutput[]): LinterOutput { + return outputs.reduce( + (acc, { results, ruleOptionsPerFile }) => ({ + results: [...acc.results, ...results], + ruleOptionsPerFile: { ...acc.ruleOptionsPerFile, ...ruleOptionsPerFile }, + }), + { results: [], ruleOptionsPerFile: {} }, + ); +} + export function lintResultsToAudits({ results, ruleOptionsPerFile, @@ -26,7 +36,9 @@ export function lintResultsToAudits({ .reduce>((acc, issue) => { const { ruleId, message, filePath } = issue; if (!ruleId) { - ui().logger.warning(`ESLint core error - ${message}`); + ui().logger.warning( + `ESLint core error - ${message} (file: ${filePath})`, + ); return acc; } const options = ruleOptionsPerFile[filePath]?.[ruleId] ?? []; diff --git a/packages/plugin-eslint/src/lib/setup.ts b/packages/plugin-eslint/src/lib/setup.ts index d554ce15f..bfd6dbd1e 100644 --- a/packages/plugin-eslint/src/lib/setup.ts +++ b/packages/plugin-eslint/src/lib/setup.ts @@ -1,7 +1,7 @@ import { ESLint } from 'eslint'; -import { ESLintPluginConfig } from './config'; +import type { ESLintTarget } from './config'; -export function setupESLint(eslintrc: ESLintPluginConfig['eslintrc']) { +export function setupESLint(eslintrc: ESLintTarget['eslintrc']) { return new ESLint({ ...(typeof eslintrc === 'string' ? { overrideConfigFile: eslintrc }