diff --git a/packages/plugin-eslint/package.json b/packages/plugin-eslint/package.json index 03288291d..506b21136 100644 --- a/packages/plugin-eslint/package.json +++ b/packages/plugin-eslint/package.json @@ -41,7 +41,6 @@ "glob": "^11.0.0", "@code-pushup/utils": "0.77.0", "@code-pushup/models": "0.77.0", - "yargs": "^17.7.2", "zod": "^4.0.5" }, "peerDependencies": { diff --git a/packages/plugin-eslint/src/bin.ts b/packages/plugin-eslint/src/bin.ts deleted file mode 100644 index fc625dd73..000000000 --- a/packages/plugin-eslint/src/bin.ts +++ /dev/null @@ -1,7 +0,0 @@ -import process from 'node:process'; -import { Parser } from 'yargs/helpers'; -import { executeRunner } from './lib/runner/index.js'; - -const { runnerConfigPath, runnerOutputPath } = Parser(process.argv); - -await executeRunner({ runnerConfigPath, runnerOutputPath }); diff --git a/packages/plugin-eslint/src/lib/__snapshots__/eslint-plugin.int.test.ts.snap b/packages/plugin-eslint/src/lib/__snapshots__/eslint-plugin.int.test.ts.snap index 388229cba..957e81e7b 100644 --- a/packages/plugin-eslint/src/lib/__snapshots__/eslint-plugin.int.test.ts.snap +++ b/packages/plugin-eslint/src/lib/__snapshots__/eslint-plugin.int.test.ts.snap @@ -346,16 +346,7 @@ exports[`eslintPlugin > should initialize ESLint plugin for React application 1` ], "icon": "eslint", "packageName": "@code-pushup/eslint-plugin", - "runner": { - "args": [ - ""/bin.js"", - "--runnerConfigPath="node_modules/.code-pushup/eslint//plugin-config.json"", - "--runnerOutputPath="node_modules/.code-pushup/eslint//runner-output.json"", - ], - "command": "node", - "configFile": "node_modules/.code-pushup/eslint//plugin-config.json", - "outputFile": "node_modules/.code-pushup/eslint//runner-output.json", - }, + "runner": [Function], "slug": "eslint", "title": "ESLint", "version": Any, diff --git a/packages/plugin-eslint/src/lib/config.ts b/packages/plugin-eslint/src/lib/config.ts index 5f7cabeee..6a539b502 100644 --- a/packages/plugin-eslint/src/lib/config.ts +++ b/packages/plugin-eslint/src/lib/config.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { pluginArtifactOptionsSchema } from '@code-pushup/models'; import { toArray } from '@code-pushup/utils'; const patternsSchema = z @@ -61,5 +62,6 @@ export type CustomGroup = z.infer; export const eslintPluginOptionsSchema = z.object({ groups: z.array(customGroupSchema).optional(), + artifacts: pluginArtifactOptionsSchema.optional(), }); export type ESLintPluginOptions = z.infer; diff --git a/packages/plugin-eslint/src/lib/eslint-plugin.int.test.ts b/packages/plugin-eslint/src/lib/eslint-plugin.int.test.ts index 7fee0ef8e..eef2cf872 100644 --- a/packages/plugin-eslint/src/lib/eslint-plugin.int.test.ts +++ b/packages/plugin-eslint/src/lib/eslint-plugin.int.test.ts @@ -3,8 +3,7 @@ import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; import type { MockInstance } from 'vitest'; -import type { Audit, PluginConfig, RunnerConfig } from '@code-pushup/models'; -import { toUnixPath } from '@code-pushup/utils'; +import type { Audit } from '@code-pushup/models'; import { eslintPlugin } from './eslint-plugin.js'; describe('eslintPlugin', () => { @@ -15,27 +14,6 @@ describe('eslintPlugin', () => { let cwdSpy: MockInstance<[], string>; let platformSpy: MockInstance<[], NodeJS.Platform>; - const replaceAbsolutePath = (plugin: PluginConfig): PluginConfig => ({ - ...plugin, - runner: { - ...(plugin.runner as RunnerConfig), - args: (plugin.runner as RunnerConfig).args?.map(arg => - toUnixPath(arg.replace(path.dirname(thisDir), '')).replace( - /\/eslint\/\d+\//, - '/eslint//', - ), - ), - ...((plugin.runner as RunnerConfig).configFile && { - configFile: toUnixPath( - (plugin.runner as RunnerConfig).configFile!, - ).replace(/\/eslint\/\d+\//, '/eslint//'), - }), - outputFile: toUnixPath( - (plugin.runner as RunnerConfig).outputFile, - ).replace(/\/eslint\/\d+\//, '/eslint//'), - }, - }); - beforeAll(() => { cwdSpy = vi.spyOn(process, 'cwd'); // Linux produces extra quotation marks for globs @@ -55,7 +33,7 @@ describe('eslintPlugin', () => { patterns: ['src/**/*.js', 'src/**/*.jsx'], }); - expect(replaceAbsolutePath(plugin)).toMatchSnapshot({ + expect(plugin).toMatchSnapshot({ version: expect.any(String), }); }); @@ -68,18 +46,17 @@ describe('eslintPlugin', () => { }); // expect rule from extended base eslint.config.js - expect(plugin.audits).toContainEqual( - expect.objectContaining({ - slug: expect.stringMatching(/^nx-enforce-module-boundaries/), - title: expect.any(String), - description: expect.stringContaining('sourceTag'), - }), - ); - // expect rule from nx-plugin project's eslint.config.js - expect(plugin.audits).toContainEqual( - expect.objectContaining>({ - slug: 'nx-nx-plugin-checks', - }), + expect(plugin.audits).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + slug: expect.stringMatching(/^nx-enforce-module-boundaries/), + title: expect.any(String), + description: expect.stringContaining('sourceTag'), + }), + expect.objectContaining>({ + slug: 'nx-nx-plugin-checks', + }), + ]), ); }); @@ -152,7 +129,7 @@ describe('eslintPlugin', () => { await expect( // @ts-expect-error simulating invalid non-TS config eslintPlugin({ eslintrc: '.eslintrc.json' }), - ).rejects.toThrow('Invalid input'); + ).rejects.toThrow('Failed parsing ESLint plugin config'); }); it("should throw if eslintrc file doesn't exist", async () => { @@ -160,4 +137,22 @@ describe('eslintPlugin', () => { eslintPlugin({ eslintrc: '.eslintrc.yml', patterns: '**/*.js' }), ).rejects.toThrow(/Failed to load url .*\.eslintrc.yml/); }); + + it('should initialize with artifact options', async () => { + cwdSpy.mockReturnValue(path.join(fixturesDir, 'todos-app')); + const plugin = await eslintPlugin( + { + eslintrc: 'eslint.config.js', + patterns: ['src/**/*.js'], + }, + { + artifacts: { + artifactsPaths: './artifacts/eslint-output.json', + generateArtifactsCommand: 'echo "Generating artifacts"', + }, + }, + ); + + expect(plugin.runner).toBeTypeOf('function'); + }); }); diff --git a/packages/plugin-eslint/src/lib/eslint-plugin.ts b/packages/plugin-eslint/src/lib/eslint-plugin.ts index 2b8b36246..49f6ea228 100644 --- a/packages/plugin-eslint/src/lib/eslint-plugin.ts +++ b/packages/plugin-eslint/src/lib/eslint-plugin.ts @@ -1,6 +1,4 @@ import { createRequire } from 'node:module'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import type { PluginConfig } from '@code-pushup/models'; import { parseSchema } from '@code-pushup/utils'; import { @@ -11,7 +9,7 @@ import { } from './config.js'; import { ESLINT_PLUGIN_SLUG } from './constants.js'; import { listAuditsAndGroups } from './meta/index.js'; -import { createRunnerConfig } from './runner/index.js'; +import { createRunnerFunction } from './runner/index.js'; /** * Instantiates Code PushUp ESLint plugin for use in core config. @@ -42,20 +40,14 @@ export async function eslintPlugin( schemaType: 'ESLint plugin config', }); - const customGroups = options + const { groups: customGroups, artifacts } = options ? parseSchema(eslintPluginOptionsSchema, options, { schemaType: 'ESLint plugin options', - }).groups - : undefined; + }) + : {}; const { audits, groups } = await listAuditsAndGroups(targets, customGroups); - const runnerScriptPath = path.join( - fileURLToPath(path.dirname(import.meta.url)), - '..', - 'bin.js', - ); - const packageJson = createRequire(import.meta.url)( '../../package.json', ) as typeof import('../../package.json'); @@ -72,6 +64,10 @@ export async function eslintPlugin( audits, groups, - runner: await createRunnerConfig(runnerScriptPath, audits, targets), + runner: await createRunnerFunction({ + audits, + targets, + ...(artifacts ? { artifacts } : {}), + }), }; } diff --git a/packages/plugin-eslint/src/lib/runner.int.test.ts b/packages/plugin-eslint/src/lib/runner.int.test.ts index cd97a78bd..05f8bb004 100644 --- a/packages/plugin-eslint/src/lib/runner.int.test.ts +++ b/packages/plugin-eslint/src/lib/runner.int.test.ts @@ -3,37 +3,29 @@ import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { type MockInstance, describe, expect, it } from 'vitest'; -import type { - AuditOutput, - AuditOutputs, - Issue, - RunnerFilesPaths, +import { + type Audit, + type AuditOutput, + type AuditOutputs, + DEFAULT_PERSIST_OUTPUT_DIR, + type Issue, } from '@code-pushup/models'; import { osAgnosticAuditOutputs } from '@code-pushup/test-utils'; -import { readJsonFile } from '@code-pushup/utils'; import type { ESLintTarget } from './config.js'; import { listAuditsAndGroups } from './meta/index.js'; -import { createRunnerConfig, executeRunner } from './runner/index.js'; +import { createRunnerFunction } from './runner/index.js'; describe('executeRunner', () => { let cwdSpy: MockInstance<[], string>; let platformSpy: MockInstance<[], NodeJS.Platform>; - const createPluginConfig = async ( + const prepareRunnerArgs = async ( eslintrc: ESLintTarget['eslintrc'], - ): Promise => { + ): Promise<{ audits: Audit[]; targets: ESLintTarget[] }> => { const patterns = ['src/**/*.js', 'src/**/*.jsx']; const targets: ESLintTarget[] = [{ eslintrc, patterns }]; const { audits } = await listAuditsAndGroups(targets); - const { outputFile, configFile } = await createRunnerConfig( - 'bin.js', - audits, - targets, - ); - return { - runnerOutputPath: outputFile, - runnerConfigPath: configFile!, - }; + return { audits, targets }; }; const appDir = path.join( @@ -57,24 +49,23 @@ describe('executeRunner', () => { }); it('should execute ESLint and create audit results for React application', async () => { - const runnerPaths = await createPluginConfig('eslint.config.js'); - await executeRunner(runnerPaths); - - const json = await readJsonFile(runnerPaths.runnerOutputPath); - expect(osAgnosticAuditOutputs(json)).toMatchSnapshot(); + const args = await prepareRunnerArgs('eslint.config.js'); + const runnerFn = await createRunnerFunction(args); + const res = (await runnerFn({ + outputDir: DEFAULT_PERSIST_OUTPUT_DIR, + })) as AuditOutputs; + expect(osAgnosticAuditOutputs(res)).toMatchSnapshot(); }); it.skipIf(process.platform === 'win32')( 'should execute runner with custom config using @code-pushup/eslint-config', async () => { - const runnerPaths = await createPluginConfig( - 'code-pushup.eslint.config.mjs', - ); - await executeRunner(runnerPaths); + const eslintTarget = 'code-pushup.eslint.config.mjs'; + const runnerFn = await createRunnerFunction({ + ...(await prepareRunnerArgs(eslintTarget)), + }); - const json = await readJsonFile( - runnerPaths.runnerOutputPath, - ); + const json = await runnerFn({ outputDir: DEFAULT_PERSIST_OUTPUT_DIR }); // expect warnings from unicorn/filename-case rule from default config expect(json).toContainEqual( expect.objectContaining>({ diff --git a/packages/plugin-eslint/src/lib/runner/index.ts b/packages/plugin-eslint/src/lib/runner/index.ts index 0ae6afd3b..79a4abdfc 100644 --- a/packages/plugin-eslint/src/lib/runner/index.ts +++ b/packages/plugin-eslint/src/lib/runner/index.ts @@ -1,107 +1,49 @@ -import { writeFile } from 'node:fs/promises'; -import path from 'node:path'; import type { Audit, AuditOutput, AuditOutputs, + PersistConfig, PluginArtifactOptions, - RunnerConfig, - RunnerFilesPaths, + RunnerFunction, } from '@code-pushup/models'; -import { - asyncSequential, - createRunnerFiles, - ensureDirectoryExists, - filePathToCliArg, - objectToCliArgs, - readJsonFile, - ui, -} from '@code-pushup/utils'; +import { asyncSequential, ui } from '@code-pushup/utils'; import type { ESLintPluginRunnerConfig, ESLintTarget } from '../config.js'; import { lint } from './lint.js'; import { lintResultsToAudits, mergeLinterOutputs } from './transform.js'; import { loadArtifacts } from './utils.js'; -export async function executeRunner({ - runnerConfigPath, - runnerOutputPath, -}: RunnerFilesPaths): Promise { - const { slugs, targets } = - await readJsonFile(runnerConfigPath); - - ui().logger.log(`ESLint plugin executing ${targets.length} lint targets`); - - const linterOutputs = await asyncSequential(targets, lint); - const lintResults = mergeLinterOutputs(linterOutputs); - const failedAudits = lintResultsToAudits(lintResults); - - const audits = slugs.map( - (slug): AuditOutput => - failedAudits.find(audit => audit.slug === slug) ?? { - slug, - score: 1, - value: 0, - displayValue: 'passed', - details: { issues: [] }, - }, - ); - - await ensureDirectoryExists(path.dirname(runnerOutputPath)); - await writeFile(runnerOutputPath, JSON.stringify(audits)); -} - -export async function createRunnerConfig( - scriptPath: string, - audits: Audit[], - targets: ESLintTarget[], -): Promise { - const config: ESLintPluginRunnerConfig = { - targets, - slugs: audits.map(audit => audit.slug), - }; - const { runnerConfigPath, runnerOutputPath } = await createRunnerFiles( - 'eslint', - JSON.stringify(config), - ); - - return { - command: 'node', - args: [ - filePathToCliArg(scriptPath), - ...objectToCliArgs({ runnerConfigPath, runnerOutputPath }), - ], - configFile: runnerConfigPath, - outputFile: runnerOutputPath, - }; -} - -export async function generateAuditOutputs(options: { +export function createRunnerFunction(options: { audits: Audit[]; targets: ESLintTarget[]; artifacts?: PluginArtifactOptions; -}): Promise { +}): RunnerFunction { const { audits, targets, artifacts } = options; const config: ESLintPluginRunnerConfig = { targets, slugs: audits.map(audit => audit.slug), }; - ui().logger.log(`ESLint plugin executing ${targets.length} lint targets`); - - const linterOutputs = artifacts - ? await loadArtifacts(artifacts) - : await asyncSequential(targets, lint); - const lintResults = mergeLinterOutputs(linterOutputs); - const failedAudits = lintResultsToAudits(lintResults); - - return config.slugs.map( - (slug): AuditOutput => - failedAudits.find(audit => audit.slug === slug) ?? { - slug, - score: 1, - value: 0, - displayValue: 'passed', - details: { issues: [] }, - }, - ); + return async ({ outputDir }: PersistConfig): Promise => { + ui().logger.log(`ESLint plugin executing ${targets.length} lint targets`); + + const linterOutputs = artifacts + ? await loadArtifacts(artifacts) + : await asyncSequential( + targets.map(target => ({ ...target, outputDir })), + lint, + ); + const lintResults = mergeLinterOutputs(linterOutputs); + const failedAudits = lintResultsToAudits(lintResults); + + return config.slugs.map( + (slug): AuditOutput => + failedAudits.find(audit => audit.slug === slug) ?? { + slug, + score: 1, + value: 0, + displayValue: 'passed', + details: { issues: [] }, + }, + ); + }; } diff --git a/packages/plugin-eslint/src/lib/runner/index.unit.test.ts b/packages/plugin-eslint/src/lib/runner/index.unit.test.ts index 07e12f5c1..bc9413d56 100644 --- a/packages/plugin-eslint/src/lib/runner/index.unit.test.ts +++ b/packages/plugin-eslint/src/lib/runner/index.unit.test.ts @@ -7,12 +7,12 @@ import type { } from '@code-pushup/models'; import { ui } from '@code-pushup/utils'; import type { ESLintTarget } from '../config.js'; -import { generateAuditOutputs } from './index.js'; +import { createRunnerFunction } from './index.js'; import * as lintModule from './lint.js'; import type { LinterOutput } from './types.js'; import * as utilsFileModule from './utils.js'; -describe('generateAuditOutputs', () => { +describe('createRunnerFunction', () => { const loadArtifactsSpy = vi.spyOn(utilsFileModule, 'loadArtifacts'); const lintSpy = vi.spyOn(lintModule, 'lint'); @@ -120,11 +120,11 @@ describe('generateAuditOutputs', () => { loadArtifactsSpy.mockResolvedValue(mockLinterOutputs); await expect( - generateAuditOutputs({ + createRunnerFunction({ audits: mockAudits, targets: mockTargets, artifacts, - }), + })({}), ).resolves.toStrictEqual(mockedAuditOutputs); expect(loadArtifactsSpy).toHaveBeenCalledWith(artifacts); @@ -137,9 +137,10 @@ describe('generateAuditOutputs', () => { lintSpy.mockResolvedValueOnce(mockLinterOutputs.at(0)!); await expect( - generateAuditOutputs({ + createRunnerFunction({ audits: mockAudits, targets: mockTargets, + })({ outputDir: 'custom-output', }), ).resolves.toStrictEqual(mockedAuditOutputs);