From 2c352007630d90d04ea4ea9dbd2285836f164b3c Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 17 Aug 2025 00:57:58 +0200 Subject: [PATCH 01/30] chore: adjust .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4284058f4..de9074007 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,7 @@ testem.log Thumbs.db # generated Code PushUp reports -/.code-pushup +.code-pushup # Nx workspace cache .nx From 5006bf5924914302ab68dd8e3b7b3cd777ee01e9 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 17 Aug 2025 00:58:15 +0200 Subject: [PATCH 02/30] chore: adjust cp preset --- code-pushup.preset.ts | 74 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/code-pushup.preset.ts b/code-pushup.preset.ts index af7633ea9..9381bcd80 100644 --- a/code-pushup.preset.ts +++ b/code-pushup.preset.ts @@ -1,4 +1,5 @@ /* eslint-disable @nx/enforce-module-boundaries */ +import { ProjectConfiguration } from '@nx/devkit'; import type { CategoryConfig, CoreConfig, @@ -10,6 +11,8 @@ import eslintPlugin, { eslintConfigFromAllNxProjects, eslintConfigFromNxProject, } from './packages/plugin-eslint/src/index.js'; +import type { ESLintTarget } from './packages/plugin-eslint/src/lib/config.js'; +import { nxProjectsToConfig } from './packages/plugin-eslint/src/lib/nx/projects-to-config.js'; import jsPackagesPlugin from './packages/plugin-js-packages/src/index.js'; import jsDocsPlugin from './packages/plugin-jsdocs/src/index.js'; import type { JsDocsPluginTransformedConfig } from './packages/plugin-jsdocs/src/lib/config.js'; @@ -156,6 +159,57 @@ export const jsDocsCoreConfig = ( ), }); +export async function findNxProjectsWithTarget({ + targetNames, + exclude, + include, + tags, +}: { + targetNames: string[]; + exclude?: string[]; + include?: string[]; + tags?: string[]; +}): Promise { + const { createProjectGraphAsync } = await import('@nx/devkit'); + const projectGraph = await createProjectGraphAsync({ exitOnError: false }); + + const { readProjectsConfigurationFromProjectGraph } = await import( + '@nx/devkit' + ); + const projectsConfiguration = + readProjectsConfigurationFromProjectGraph(projectGraph); + const projects = Object.values(projectsConfiguration.projects).filter( + project => { + // Check if project has required target + const hasTarget = targetNames.some( + targetName => project.targets?.[targetName], + ); + + // Check include/exclude lists + const isIncluded = !include || include.includes(project?.name ?? ''); + const isNotExcluded = !exclude?.includes(project?.name ?? ''); + + // Check tags if specified + const hasRequiredTags = + !tags || tags.some(tag => project.tags?.includes(tag)); + + return (hasTarget || isIncluded) && isNotExcluded && hasRequiredTags; + }, + ); + return projects; +} + +export async function eslintConfigFromPublishableNxProjects(): Promise< + ESLintTarget[] +> { + const { createProjectGraphAsync } = await import('@nx/devkit'); + const projectGraph = await createProjectGraphAsync({ exitOnError: false }); + return nxProjectsToConfig( + projectGraph, + project => project.tags?.includes('publishable') ?? false, + ); +} + export const eslintCoreConfigNx = async ( projectName?: string, ): Promise => ({ @@ -164,6 +218,26 @@ export const eslintCoreConfigNx = async ( await (projectName ? eslintConfigFromNxProject(projectName) : eslintConfigFromAllNxProjects()), + { + artifacts: { + generateArtifactsCommand: { + command: 'npx', + args: [ + 'nx', + 'run-many', + '-t', + 'lint', + '--projects=tag:publishable', + ], + }, + artifactsPaths: ( + await findNxProjectsWithTarget({ + targetNames: ['lint'], + tags: ['publishable'], + }) + ).map(({ root }) => `${root}/.code-pushup/eslint/eslint-report.json`), + }, + }, ), ], categories: eslintCategories, From fd9b743a4ce1d5ef2a83002ddb4957bfcb8f0724 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 17 Aug 2025 00:58:35 +0200 Subject: [PATCH 03/30] chore: adjust nx.json default targets --- nx.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nx.json b/nx.json index 99e48af53..0bb68734f 100644 --- a/nx.json +++ b/nx.json @@ -49,6 +49,9 @@ "outputs": ["{options.outputFile}"], "cache": true, "options": { + "errorOnUnmatchedPattern": false, + "format": "json", + "outputFile": "{projectRoot}/.code-pushup/eslint/eslint-report.json", "maxWarnings": 0, "lintFilePatterns": [ "{projectRoot}/**/*.ts", From a253902c4e08ce64f16cd0de19147e2c766556e9 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 17 Aug 2025 00:59:16 +0200 Subject: [PATCH 04/30] feat: add artifact utils logic to eslint plugin --- .../plugin-eslint/src/lib/runner/utils.ts | 51 +++++++ .../src/lib/runner/utils.unit.test.ts | 135 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 packages/plugin-eslint/src/lib/runner/utils.ts create mode 100644 packages/plugin-eslint/src/lib/runner/utils.unit.test.ts diff --git a/packages/plugin-eslint/src/lib/runner/utils.ts b/packages/plugin-eslint/src/lib/runner/utils.ts new file mode 100644 index 000000000..9c147d7ff --- /dev/null +++ b/packages/plugin-eslint/src/lib/runner/utils.ts @@ -0,0 +1,51 @@ +import type { ESLint } from 'eslint'; +import type { PluginArtifactOptions } from '@code-pushup/models'; +import { + executeProcess, + pluralizeToken, + readJsonFile, + ui, +} from '@code-pushup/utils'; +import type { LinterOutput } from './types.js'; + +export async function loadArtifacts( + artifacts: PluginArtifactOptions, +): Promise { + if (artifacts.generateArtifactsCommand) { + const { command, args = [] } = + typeof artifacts.generateArtifactsCommand === 'string' + ? { command: artifacts.generateArtifactsCommand } + : artifacts.generateArtifactsCommand; + await executeProcess({ + command, + args, + ignoreExitCode: true, + }); + + // Log the actual command that was executed + const commandString = + typeof artifacts.generateArtifactsCommand === 'string' + ? artifacts.generateArtifactsCommand + : `${command} ${args.join(' ')}`; + await ui().logger.log(`$ ${commandString}`); + } + + const artifactPaths = Array.isArray(artifacts.artifactsPaths) + ? artifacts.artifactsPaths + : [artifacts.artifactsPaths]; + + ui().logger.log( + `ESLint plugin loading ${artifactPaths.length} eslint ${pluralizeToken('report', artifactPaths.length)}`, + ); + + return await Promise.all( + artifactPaths.map(async artifactPath => { + // ESLint CLI outputs raw ESLint.LintResult[], but we need LinterOutput format + const results = await readJsonFile(artifactPath); + return { + results, + ruleOptionsPerFile: {}, // TODO: This could be enhanced to load actual rule options if needed + }; + }), + ); +} diff --git a/packages/plugin-eslint/src/lib/runner/utils.unit.test.ts b/packages/plugin-eslint/src/lib/runner/utils.unit.test.ts new file mode 100644 index 000000000..a614fa592 --- /dev/null +++ b/packages/plugin-eslint/src/lib/runner/utils.unit.test.ts @@ -0,0 +1,135 @@ +import type { ESLint } from 'eslint'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as utilsModule from '@code-pushup/utils'; +import type { LinterOutput } from './types.js'; +import { loadArtifacts } from './utils.js'; + +describe('loadArtifacts', () => { + const readJsonFileSpy = vi.spyOn(utilsModule, 'readJsonFile'); + const executeProcessSpy = vi.spyOn(utilsModule, 'executeProcess'); + + // Mock data should be raw ESLint.LintResult[] as that's what ESLint CLI outputs + const mockRawResults1: ESLint.LintResult[] = [ + { + filePath: '/test/file1.js', + messages: [ + { + ruleId: 'no-unused-vars', + line: 1, + column: 7, + message: 'unused variable', + severity: 2, + }, + ], + suppressedMessages: [], + errorCount: 1, + fatalErrorCount: 0, + warningCount: 0, + fixableErrorCount: 0, + fixableWarningCount: 0, + source: 'const unused = 1;', + usedDeprecatedRules: [], + }, + ]; + + const mockRawResults2: ESLint.LintResult[] = [ + { + filePath: '/test/file2.js', + messages: [], + suppressedMessages: [], + errorCount: 0, + fatalErrorCount: 0, + warningCount: 0, + fixableErrorCount: 0, + fixableWarningCount: 0, + source: 'const valid = 1; console.log(valid);', + usedDeprecatedRules: [], + }, + ]; + + // Expected output after our function wraps raw results in LinterOutput format + const expectedLinterOutput1: LinterOutput = { + results: mockRawResults1, + ruleOptionsPerFile: {}, + }; + + const expectedLinterOutput2: LinterOutput = { + results: mockRawResults2, + ruleOptionsPerFile: {}, + }; + + const artifactsPaths = ['/path/to/artifact1.json', '/path/to/artifact2.json']; + + beforeEach(async () => { + vi.clearAllMocks(); + executeProcessSpy.mockResolvedValue({ + stdout: JSON.stringify(mockRawResults1), + stderr: '', + code: 0, + date: new Date().toISOString(), + duration: 0, + }); + }); + + it('should load single artifact without generateArtifactsCommand', async () => { + readJsonFileSpy.mockResolvedValue(mockRawResults1); + + await expect( + loadArtifacts({ artifactsPaths: artifactsPaths.at(0)! }), + ).resolves.toStrictEqual([expectedLinterOutput1]); + expect(executeProcessSpy).not.toHaveBeenCalled(); + expect(readJsonFileSpy).toHaveBeenCalledTimes(1); + expect(readJsonFileSpy).toHaveBeenNthCalledWith(1, artifactsPaths.at(0)); + }); + + it('should load multiple artifacts without generateArtifactsCommand', async () => { + readJsonFileSpy + .mockResolvedValueOnce(mockRawResults1) + .mockResolvedValueOnce(mockRawResults2); + + await expect(loadArtifacts({ artifactsPaths })).resolves.toStrictEqual([ + expectedLinterOutput1, + expectedLinterOutput2, + ]); + expect(executeProcessSpy).not.toHaveBeenCalled(); + expect(readJsonFileSpy).toHaveBeenCalledTimes(2); + expect(readJsonFileSpy).toHaveBeenNthCalledWith(1, artifactsPaths.at(0)); + expect(readJsonFileSpy).toHaveBeenNthCalledWith(2, artifactsPaths.at(1)); + }); + + it('should load artifacts with generateArtifactsCommand as string', async () => { + readJsonFileSpy.mockResolvedValue([]); + + const generateArtifactsCommand = `nx run-many -t lint --parallel --max-parallel=5`; + await expect( + loadArtifacts({ + artifactsPaths, + generateArtifactsCommand, + }), + ).resolves.not.toThrow(); + expect(executeProcessSpy).toHaveBeenCalledWith({ + args: [], + command: generateArtifactsCommand, + ignoreExitCode: true, + }); + }); + + it('should load artifacts with generateArtifactsCommand as object', async () => { + readJsonFileSpy.mockResolvedValue([]); + + const generateArtifactsCommand = { + command: 'nx', + args: ['run-many', '-t', 'lint', '--parallel', '--max-parallel=5'], + }; + await expect( + loadArtifacts({ + artifactsPaths, + generateArtifactsCommand, + }), + ).resolves.not.toThrow(); + expect(executeProcessSpy).toHaveBeenCalledWith({ + ...generateArtifactsCommand, + ignoreExitCode: true, + }); + }); +}); From f11b0931b361f527687805962efaaeb493c1f153 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 17 Aug 2025 01:01:26 +0200 Subject: [PATCH 05/30] feat(models): add runner options --- packages/core/src/lib/implementation/runner.ts | 13 +++++++++---- packages/models/src/lib/runner-config.ts | 2 ++ packages/plugin-eslint/src/lib/runner.int.test.ts | 10 ++++++++-- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/core/src/lib/implementation/runner.ts b/packages/core/src/lib/implementation/runner.ts index afdf17546..0bec5a3b4 100644 --- a/packages/core/src/lib/implementation/runner.ts +++ b/packages/core/src/lib/implementation/runner.ts @@ -3,6 +3,7 @@ import { writeFile } from 'node:fs/promises'; import path from 'node:path'; import { type AuditOutputs, + type PersistConfig, type PluginConfig, type RunnerConfig, type RunnerFunction, @@ -14,6 +15,7 @@ import { executeProcess, fileExists, isVerbose, + objectToCliArgs, readJsonFile, removeDirectoryIfExists, ui, @@ -32,12 +34,13 @@ export type ValidatedRunnerResult = Omit & { export async function executeRunnerConfig( cfg: RunnerConfig, + config: Required>, ): Promise { const { args, command, outputFile, outputTransform } = cfg; const { duration, date } = await executeProcess({ command, - args, + args: [...(args ?? []), ...objectToCliArgs(config)], observer: { onStdout: stdout => { if (isVerbose()) { @@ -66,12 +69,13 @@ export async function executeRunnerConfig( export async function executeRunnerFunction( runner: RunnerFunction, + config: PersistConfig, ): Promise { const date = new Date().toISOString(); const start = performance.now(); // execute plugin runner - const audits = await runner(); + const audits = await runner(config); // create runner result return { @@ -96,12 +100,13 @@ export class AuditOutputsMissingAuditError extends Error { export async function executePluginRunner( pluginConfig: Pick, + persist: Required>, ): Promise & { audits: AuditOutputs }> { const { audits: pluginConfigAudits, runner } = pluginConfig; const runnerResult: RunnerResult = typeof runner === 'object' - ? await executeRunnerConfig(runner) - : await executeRunnerFunction(runner); + ? await executeRunnerConfig(runner, persist) + : await executeRunnerFunction(runner, persist); const { audits: unvalidatedAuditOutputs, ...executionMeta } = runnerResult; const result = auditOutputsSchema.safeParse(unvalidatedAuditOutputs); diff --git a/packages/models/src/lib/runner-config.ts b/packages/models/src/lib/runner-config.ts index a40a0b983..422b39639 100644 --- a/packages/models/src/lib/runner-config.ts +++ b/packages/models/src/lib/runner-config.ts @@ -2,6 +2,7 @@ import { z } from 'zod/v4'; import { auditOutputsSchema } from './audit-output.js'; import { convertAsyncZodFunctionToSchema } from './implementation/function.js'; import { filePathSchema } from './implementation/schemas.js'; +import { persistConfigSchema } from './persist-config.js'; export const outputTransformSchema = convertAsyncZodFunctionToSchema( z.function({ @@ -25,6 +26,7 @@ export type RunnerConfig = z.infer; export const runnerFunctionSchema = convertAsyncZodFunctionToSchema( z.function({ + input: [persistConfigSchema], output: z.union([auditOutputsSchema, z.promise(auditOutputsSchema)]), }), ); diff --git a/packages/plugin-eslint/src/lib/runner.int.test.ts b/packages/plugin-eslint/src/lib/runner.int.test.ts index cd97a78bd..4eb40b00d 100644 --- a/packages/plugin-eslint/src/lib/runner.int.test.ts +++ b/packages/plugin-eslint/src/lib/runner.int.test.ts @@ -58,7 +58,10 @@ describe('executeRunner', () => { it('should execute ESLint and create audit results for React application', async () => { const runnerPaths = await createPluginConfig('eslint.config.js'); - await executeRunner(runnerPaths); + await executeRunner({ + ...runnerPaths, + persistOutputDir: os.tmpdir(), + }); const json = await readJsonFile(runnerPaths.runnerOutputPath); expect(osAgnosticAuditOutputs(json)).toMatchSnapshot(); @@ -70,7 +73,10 @@ describe('executeRunner', () => { const runnerPaths = await createPluginConfig( 'code-pushup.eslint.config.mjs', ); - await executeRunner(runnerPaths); + await executeRunner({ + ...runnerPaths, + persistOutputDir: os.tmpdir(), + }); const json = await readJsonFile( runnerPaths.runnerOutputPath, From 6175c5bceaa181a9e175814a2d7c79cf63f73a90 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 17 Aug 2025 01:11:55 +0200 Subject: [PATCH 06/30] feat(models): add runner options --- .../src/lib/implementation/execute-plugin.ts | 4 +-- .../execute-plugin.unit.test.ts | 9 ++--- packages/plugin-eslint/src/lib/runner/lint.ts | 25 +++++++++++--- .../src/lib/runner/lint.unit.test.ts | 33 +++++++++++++++++-- 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/packages/core/src/lib/implementation/execute-plugin.ts b/packages/core/src/lib/implementation/execute-plugin.ts index 8f0a07860..70bcf597c 100644 --- a/packages/core/src/lib/implementation/execute-plugin.ts +++ b/packages/core/src/lib/implementation/execute-plugin.ts @@ -66,8 +66,8 @@ export async function executePlugin( ? // IF not null, take the result from cache ((await readRunnerResults(pluginMeta.slug, outputDir)) ?? // ELSE execute the plugin runner - (await executePluginRunner(pluginConfig))) - : await executePluginRunner(pluginConfig); + (await executePluginRunner(pluginConfig, persist))) + : await executePluginRunner(pluginConfig, persist); if (cacheWrite) { await writeRunnerResults(pluginMeta.slug, outputDir, { diff --git a/packages/core/src/lib/implementation/execute-plugin.unit.test.ts b/packages/core/src/lib/implementation/execute-plugin.unit.test.ts index 8067f180c..d39e6269a 100644 --- a/packages/core/src/lib/implementation/execute-plugin.unit.test.ts +++ b/packages/core/src/lib/implementation/execute-plugin.unit.test.ts @@ -102,7 +102,7 @@ describe('executePlugin', () => { await expect( executePlugin(MINIMAL_PLUGIN_CONFIG_MOCK, { - persist: { outputDir: 'dummy-path-result-is-mocked' }, + persist: { outputDir: MEMFS_VOLUME }, cache: { read: true, write: false }, }), ).resolves.toStrictEqual({ @@ -122,6 +122,7 @@ describe('executePlugin', () => { expect(executePluginRunnerSpy).toHaveBeenCalledWith( MINIMAL_PLUGIN_CONFIG_MOCK, + { outputDir: MEMFS_VOLUME }, ); }); }); @@ -322,8 +323,8 @@ describe('executePlugins', () => { { ...MINIMAL_PLUGIN_CONFIG_MOCK, runner: { - command: 'node', - args: ['-v'], + command: 'echo', + args: ['16'], outputFile: 'output.json', outputTransform: (outputs: unknown): Promise => Promise.resolve([ @@ -337,7 +338,7 @@ describe('executePlugins', () => { }, }, ], - persist: { outputDir: '.code-pushup' }, + persist: { outputDir: MEMFS_VOLUME }, cache: { read: false, write: false }, }, { progress: false }, diff --git a/packages/plugin-eslint/src/lib/runner/lint.ts b/packages/plugin-eslint/src/lib/runner/lint.ts index 80300ded3..1fb2d66d1 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.ts @@ -1,31 +1,45 @@ import type { ESLint, Linter } from 'eslint'; import { platform } from 'node:os'; +import path from 'node:path'; +import { DEFAULT_PERSIST_OUTPUT_DIR } from '@code-pushup/models'; import { distinct, executeProcess, filePathToCliArg, + readJsonFile, toArray, } from '@code-pushup/utils'; import type { ESLintTarget } from '../config.js'; import { setupESLint } from '../setup.js'; import type { LinterOutput, RuleOptionsPerFile } from './types.js'; +import LintResult = ESLint.LintResult; + export async function lint({ eslintrc, patterns, -}: ESLintTarget): Promise { - const results = await executeLint({ eslintrc, patterns }); + outputDir, +}: ESLintTarget & { outputDir?: string }): Promise { + const results = await executeLint({ eslintrc, patterns, outputDir }); const eslint = await setupESLint(eslintrc); const ruleOptionsPerFile = await loadRuleOptionsPerFile(eslint, results); return { results, ruleOptionsPerFile }; } +// eslint-disable-next-line functional/no-let +let lintFilesRotation = 0; + async function executeLint({ eslintrc, patterns, -}: ESLintTarget): Promise { + outputDir, +}: ESLintTarget & { outputDir?: string }): Promise { + const reportOutputPath = path.join( + outputDir ?? DEFAULT_PERSIST_OUTPUT_DIR, + `eslint-report.${++lintFilesRotation}.json`, + ); // running as CLI because ESLint#lintFiles() runs out of memory - const { stdout } = await executeProcess({ + await executeProcess({ command: 'npx', args: [ 'eslint', @@ -33,6 +47,7 @@ async function executeLint({ ...(typeof eslintrc === 'object' ? ['--no-eslintrc'] : []), '--no-error-on-unmatched-pattern', '--format=json', + `--output-file=${reportOutputPath}`, ...toArray(patterns).map(pattern => // globs need to be escaped on Unix platform() === 'win32' ? pattern : `'${pattern}'`, @@ -42,7 +57,7 @@ async function executeLint({ cwd: process.cwd(), }); - return JSON.parse(stdout) as ESLint.LintResult[]; + return readJsonFile(reportOutputPath); } function loadRuleOptionsPerFile( 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 516e3501e..25f8ff227 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts @@ -1,4 +1,5 @@ import { ESLint, type Linter } from 'eslint'; +import { expect } from 'vitest'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import { executeProcess } from '@code-pushup/utils'; import type { ESLintPluginConfig } from '../config.js'; @@ -38,7 +39,32 @@ vi.mock('@code-pushup/utils', async () => { const cwd = testUtils.MEMFS_VOLUME; return { ...utils, - executeProcess: vi.fn().mockResolvedValue({ + readJsonFile: vi.fn( + (_: string) => + // Return the ESLint.LintResult[] array that readJsonFile should return + [ + { + 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[], + ), + executeProcess: vi.fn(async () => ({ stdout: JSON.stringify([ { filePath: `${cwd}/src/app/app.component.ts`, @@ -60,7 +86,7 @@ vi.mock('@code-pushup/utils', async () => { messages: [{ ruleId: 'max-lines' }], }, ] as ESLint.LintResult[]), - }), + })), }; }); @@ -76,7 +102,7 @@ describe('lint', () => { it('should get rule options for each file', async () => { const { ruleOptionsPerFile } = await lint(config); - expect(ruleOptionsPerFile).toEqual({ + expect(ruleOptionsPerFile).toStrictEqual({ [`${process.cwd()}/src/app/app.component.ts`]: { 'max-lines': [500], '@typescript-eslint/no-explicit-any': [], @@ -108,6 +134,7 @@ describe('lint', () => { '--config=".eslintrc.js"', '--no-error-on-unmatched-pattern', '--format=json', + expect.stringMatching(/--output-file=\/test\/eslint-report\.\d+\.json/), expect.stringContaining('**/*.js'), // wraps in quotes on Unix ], ignoreExitCode: true, From 1c1e20132f8c9193ce0ef5a71131cb3bb14a187c Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 17 Aug 2025 01:13:50 +0200 Subject: [PATCH 07/30] feat(plugin-eslint): add artifacts options --- packages/models/src/index.ts | 1 + packages/plugin-eslint/src/bin.ts | 10 +- packages/plugin-eslint/src/lib/config.ts | 2 + packages/plugin-eslint/src/lib/constants.ts | 1 + .../plugin-eslint/src/lib/eslint-plugin.ts | 19 ++- .../plugin-eslint/src/lib/runner/index.ts | 11 +- .../src/lib/runner/index.unit.test.ts | 110 ++++++++++++++++++ 7 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 packages/plugin-eslint/src/lib/constants.ts create mode 100644 packages/plugin-eslint/src/lib/runner/index.unit.test.ts diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index a305ea673..a76aa15fc 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -145,4 +145,5 @@ export { uploadConfigSchema, type UploadConfig } from './lib/upload-config.js'; export { artifactGenerationCommandSchema, pluginArtifactOptionsSchema, + type PluginArtifactOptions, } from './lib/configuration.js'; diff --git a/packages/plugin-eslint/src/bin.ts b/packages/plugin-eslint/src/bin.ts index fc625dd73..919fb3d16 100644 --- a/packages/plugin-eslint/src/bin.ts +++ b/packages/plugin-eslint/src/bin.ts @@ -1,7 +1,13 @@ +import path from 'node:path'; import process from 'node:process'; import { Parser } from 'yargs/helpers'; +import { ESLINT_PLUGIN_SLUG } from './lib/constants.js'; import { executeRunner } from './lib/runner/index.js'; -const { runnerConfigPath, runnerOutputPath } = Parser(process.argv); +const { runnerConfigPath, runnerOutputPath, outputDir } = Parser(process.argv); -await executeRunner({ runnerConfigPath, runnerOutputPath }); +await executeRunner({ + runnerConfigPath, + runnerOutputPath, + persistOutputDir: path.join(outputDir, ESLINT_PLUGIN_SLUG), +}); 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/constants.ts b/packages/plugin-eslint/src/lib/constants.ts new file mode 100644 index 000000000..8f4ec0403 --- /dev/null +++ b/packages/plugin-eslint/src/lib/constants.ts @@ -0,0 +1 @@ +export const ESLINT_PLUGIN_SLUG = 'eslint'; diff --git a/packages/plugin-eslint/src/lib/eslint-plugin.ts b/packages/plugin-eslint/src/lib/eslint-plugin.ts index 285a39e50..663890065 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 { @@ -9,8 +7,9 @@ import { eslintPluginConfigSchema, eslintPluginOptionsSchema, } 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. @@ -49,18 +48,12 @@ export async function eslintPlugin( 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'); return { - slug: 'eslint', + slug: ESLINT_PLUGIN_SLUG, title: 'ESLint', icon: 'eslint', description: 'Official Code PushUp ESLint plugin', @@ -71,6 +64,10 @@ export async function eslintPlugin( audits, groups, - runner: await createRunnerConfig(runnerScriptPath, audits, targets), + runner: await createRunnerFunction({ + audits, + targets, + artifacts: options?.artifacts, + }), }; } diff --git a/packages/plugin-eslint/src/lib/runner/index.ts b/packages/plugin-eslint/src/lib/runner/index.ts index 7e5e8ee9d..6a7375384 100644 --- a/packages/plugin-eslint/src/lib/runner/index.ts +++ b/packages/plugin-eslint/src/lib/runner/index.ts @@ -22,13 +22,20 @@ import { lintResultsToAudits, mergeLinterOutputs } from './transform.js'; export async function executeRunner({ runnerConfigPath, runnerOutputPath, -}: RunnerFilesPaths): Promise { + persistOutputDir, +}: RunnerFilesPaths & { persistOutputDir: string }): Promise { const { slugs, targets } = await readJsonFile(runnerConfigPath); ui().logger.log(`ESLint plugin executing ${targets.length} lint targets`); - const linterOutputs = await asyncSequential(targets, lint); + const linterOutputs = await asyncSequential( + targets.map(target => ({ + ...target, + outputDir: persistOutputDir, + })), + lint, + ); const lintResults = mergeLinterOutputs(linterOutputs); const failedAudits = lintResultsToAudits(lintResults); diff --git a/packages/plugin-eslint/src/lib/runner/index.unit.test.ts b/packages/plugin-eslint/src/lib/runner/index.unit.test.ts new file mode 100644 index 000000000..47e74db22 --- /dev/null +++ b/packages/plugin-eslint/src/lib/runner/index.unit.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PersistConfig } from '@code-pushup/models'; +import * as utilsModule from '@code-pushup/utils'; +import { createRunnerFunction } from './index.js'; +import * as lintModule from './lint.js'; +import * as transformModule from './transform.js'; +import type { LinterOutput } from './types.js'; +import * as utilsRunnerModule from './utils.js'; + +describe('createRunnerFunction', () => { + const asyncSequentialSpy = vi.spyOn(utilsModule, 'asyncSequential'); + const uiLoggerLogSpy = vi.fn(); + vi.spyOn(utilsModule, 'ui').mockReturnValue({ + logger: { + log: uiLoggerLogSpy, + flushLogs: vi.fn(), + }, + switchMode: vi.fn(), + } as any); + const lintSpy = vi.spyOn(lintModule, 'lint'); + const mergeLinterOutputsSpy = vi.spyOn(transformModule, 'mergeLinterOutputs'); + const lintResultsToAuditsSpy = vi.spyOn( + transformModule, + 'lintResultsToAudits', + ); + const loadArtifactsSpy = vi.spyOn(utilsRunnerModule, 'loadArtifacts'); + + const mockLinterOutputs: LinterOutput[] = [ + { + results: [], + ruleOptionsPerFile: {}, + }, + ]; + + const mockMergedOutput: LinterOutput = { + results: [], + ruleOptionsPerFile: {}, + }; + + const mockFailedAudits = [ + { + slug: 'no-unused-vars', + score: 0, + value: 1, + displayValue: '1 error', + details: { issues: [] }, + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + asyncSequentialSpy.mockResolvedValue(mockLinterOutputs); + mergeLinterOutputsSpy.mockReturnValue(mockMergedOutput); + lintResultsToAuditsSpy.mockReturnValue(mockFailedAudits); + loadArtifactsSpy.mockResolvedValue(mockLinterOutputs); + }); + + it('should create a runner function and call dependencies', async () => { + const runnerFunction = await createRunnerFunction({ + audits: [ + { + slug: 'no-unused-vars', + title: 'No unused vars', + description: 'Test rule', + }, + ], + targets: [{ eslintrc: '.eslintrc.js', patterns: ['src/**/*.js'] }], + }); + const persistConfig: PersistConfig = { outputDir: '/tmp/output' }; + await expect(runnerFunction(persistConfig)).resolves.not.toThrow(); + expect(uiLoggerLogSpy).toHaveBeenCalledWith( + 'ESLint plugin executing 1 lint targets', + ); + expect(asyncSequentialSpy).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + outputDir: '/tmp/output', + }), + ]), + lintSpy, + ); + expect(mergeLinterOutputsSpy).toHaveBeenCalledWith(mockLinterOutputs); + expect(lintResultsToAuditsSpy).toHaveBeenCalledWith(mockMergedOutput); + }); + + it('should call loadArtifacts when artifacts provided', async () => { + const runnerFunction = await createRunnerFunction({ + audits: [ + { + slug: 'no-unused-vars', + title: 'No unused vars', + description: 'Test rule', + }, + ], + targets: [{ eslintrc: '.eslintrc.js', patterns: ['src/**/*.js'] }], + artifacts: { + artifactsPaths: ['/path/to/artifact.json'], + }, + }); + const persistConfig: PersistConfig = { outputDir: '/tmp/output' }; + await expect(runnerFunction(persistConfig)).resolves.not.toThrow(); + + expect(loadArtifactsSpy).toHaveBeenCalledWith({ + artifactsPaths: ['/path/to/artifact.json'], + }); + expect(asyncSequentialSpy).not.toHaveBeenCalled(); + expect(mergeLinterOutputsSpy).toHaveBeenCalledWith(mockLinterOutputs); + expect(lintResultsToAuditsSpy).toHaveBeenCalledWith(mockMergedOutput); + }); +}); From e93232fafde03e2aaf89c9d472eed204635c23d5 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 17 Aug 2025 01:15:52 +0200 Subject: [PATCH 08/30] fix(plugin-eslint): artifacts options --- .../eslint-plugin.int.test.ts.snap | 11 +---- .../src/lib/eslint-plugin.int.test.ts | 24 +--------- .../plugin-eslint/src/lib/runner/index.ts | 44 +++++++++++++++++++ 3 files changed, 47 insertions(+), 32 deletions(-) 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/eslint-plugin.int.test.ts b/packages/plugin-eslint/src/lib/eslint-plugin.int.test.ts index 7fee0ef8e..2ea121474 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, PluginConfig } from '@code-pushup/models'; import { eslintPlugin } from './eslint-plugin.js'; describe('eslintPlugin', () => { @@ -15,26 +14,7 @@ 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//'), - }, - }); + const replaceAbsolutePath = (plugin: PluginConfig): PluginConfig => plugin; beforeAll(() => { cwdSpy = vi.spyOn(process, 'cwd'); diff --git a/packages/plugin-eslint/src/lib/runner/index.ts b/packages/plugin-eslint/src/lib/runner/index.ts index 6a7375384..2fa22f50a 100644 --- a/packages/plugin-eslint/src/lib/runner/index.ts +++ b/packages/plugin-eslint/src/lib/runner/index.ts @@ -3,8 +3,12 @@ import path from 'node:path'; import type { Audit, AuditOutput, + AuditOutputs, + PersistConfig, + PluginArtifactOptions, RunnerConfig, RunnerFilesPaths, + RunnerFunction, } from '@code-pushup/models'; import { asyncSequential, @@ -18,6 +22,7 @@ import { import type { ESLintPluginRunnerConfig, ESLintTarget } from '../config.js'; import { lint } from './lint.js'; import { lintResultsToAudits, mergeLinterOutputs } from './transform.js'; +import { loadArtifacts } from './utils'; export async function executeRunner({ runnerConfigPath, @@ -78,3 +83,42 @@ export async function createRunnerConfig( outputFile: runnerOutputPath, }; } + +export async function createRunnerFunction(options: { + audits: Audit[]; + targets: ESLintTarget[]; + artifacts?: PluginArtifactOptions; +}): Promise { + const { audits, targets, artifacts } = options; + const config: ESLintPluginRunnerConfig = { + targets, + slugs: audits.map(audit => audit.slug), + }; + + 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: [] }, + }, + ); + }; +} From dfeecab8581fa9177a49cbdc3fbafa9d71e75637 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 17 Aug 2025 01:32:22 +0200 Subject: [PATCH 09/30] fix(plugin-eslint): cleanup runner config removal --- packages/plugin-eslint/package.json | 1 - packages/plugin-eslint/src/bin.ts | 13 --- .../plugin-eslint/src/lib/runner.int.test.ts | 105 ------------------ .../plugin-eslint/src/lib/runner/index.ts | 76 +------------ 4 files changed, 2 insertions(+), 193 deletions(-) delete mode 100644 packages/plugin-eslint/src/bin.ts delete mode 100644 packages/plugin-eslint/src/lib/runner.int.test.ts diff --git a/packages/plugin-eslint/package.json b/packages/plugin-eslint/package.json index 0f4dae3eb..b3a9e102c 100644 --- a/packages/plugin-eslint/package.json +++ b/packages/plugin-eslint/package.json @@ -40,7 +40,6 @@ "dependencies": { "@code-pushup/utils": "0.74.0", "@code-pushup/models": "0.74.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 919fb3d16..000000000 --- a/packages/plugin-eslint/src/bin.ts +++ /dev/null @@ -1,13 +0,0 @@ -import path from 'node:path'; -import process from 'node:process'; -import { Parser } from 'yargs/helpers'; -import { ESLINT_PLUGIN_SLUG } from './lib/constants.js'; -import { executeRunner } from './lib/runner/index.js'; - -const { runnerConfigPath, runnerOutputPath, outputDir } = Parser(process.argv); - -await executeRunner({ - runnerConfigPath, - runnerOutputPath, - persistOutputDir: path.join(outputDir, ESLINT_PLUGIN_SLUG), -}); diff --git a/packages/plugin-eslint/src/lib/runner.int.test.ts b/packages/plugin-eslint/src/lib/runner.int.test.ts deleted file mode 100644 index 4eb40b00d..000000000 --- a/packages/plugin-eslint/src/lib/runner.int.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import os from 'node:os'; -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, -} 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'; - -describe('executeRunner', () => { - let cwdSpy: MockInstance<[], string>; - let platformSpy: MockInstance<[], NodeJS.Platform>; - - const createPluginConfig = async ( - eslintrc: ESLintTarget['eslintrc'], - ): Promise => { - 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!, - }; - }; - - const appDir = path.join( - fileURLToPath(path.dirname(import.meta.url)), - '..', - '..', - 'mocks', - 'fixtures', - 'todos-app', - ); - - beforeAll(() => { - cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(appDir); - // Windows does not require additional quotation marks for globs - platformSpy = vi.spyOn(os, 'platform').mockReturnValue('win32'); - }); - - afterAll(() => { - cwdSpy.mockRestore(); - platformSpy.mockRestore(); - }); - - it('should execute ESLint and create audit results for React application', async () => { - const runnerPaths = await createPluginConfig('eslint.config.js'); - await executeRunner({ - ...runnerPaths, - persistOutputDir: os.tmpdir(), - }); - - const json = await readJsonFile(runnerPaths.runnerOutputPath); - expect(osAgnosticAuditOutputs(json)).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, - persistOutputDir: os.tmpdir(), - }); - - const json = await readJsonFile( - runnerPaths.runnerOutputPath, - ); - // expect warnings from unicorn/filename-case rule from default config - expect(json).toContainEqual( - expect.objectContaining>({ - slug: 'unicorn-filename-case', - displayValue: expect.stringMatching(/^\d+ warnings?$/), - details: { - issues: expect.arrayContaining([ - { - severity: 'warning', - message: - 'Filename is not in kebab case. Rename it to `use-todos.js`.', - source: expect.objectContaining({ - file: path.join(appDir, 'src', 'hooks', 'useTodos.js'), - }), - }, - ]), - }, - }), - ); - }, - ); -}, 20_000); diff --git a/packages/plugin-eslint/src/lib/runner/index.ts b/packages/plugin-eslint/src/lib/runner/index.ts index 2fa22f50a..9f400670e 100644 --- a/packages/plugin-eslint/src/lib/runner/index.ts +++ b/packages/plugin-eslint/src/lib/runner/index.ts @@ -1,88 +1,16 @@ -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'; - -export async function executeRunner({ - runnerConfigPath, - runnerOutputPath, - persistOutputDir, -}: RunnerFilesPaths & { persistOutputDir: string }): Promise { - const { slugs, targets } = - await readJsonFile(runnerConfigPath); - - ui().logger.log(`ESLint plugin executing ${targets.length} lint targets`); - - const linterOutputs = await asyncSequential( - targets.map(target => ({ - ...target, - outputDir: persistOutputDir, - })), - 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, - }; -} +import { loadArtifacts } from './utils.js'; export async function createRunnerFunction(options: { audits: Audit[]; From 2877a8900b93a8562dbea20498313eecb6ed7b0a Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 17 Aug 2025 01:32:45 +0200 Subject: [PATCH 10/30] docs(plugin-eslint): add artifacts docs --- packages/plugin-eslint/README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/plugin-eslint/README.md b/packages/plugin-eslint/README.md index 75a45dc63..2ec7c3a4b 100644 --- a/packages/plugin-eslint/README.md +++ b/packages/plugin-eslint/README.md @@ -129,6 +129,38 @@ export default { }; ``` +### Using artifacts for better performance + +For better performance in CI environments or when dealing with large codebases, you can use pre-generated ESLint report files instead of running ESLint during Code PushUp execution. This is especially useful for separating linting from analysis, caching results, or integrating with existing CI pipelines. Use the `artifacts` option to specify paths to ESLint JSON report files: + +```js +// with artifacts +await eslintPlugin( + { eslintrc: '.eslintrc.js', patterns: ['src/**/*.js'] }, + { + artifacts: { + artifactsPaths: ['reports/eslint.json'], + }, + }, +); + +// with artifact generation +await eslintPlugin( + { eslintrc: '.eslintrc.js', patterns: ['src/**/*.js'] }, + { + artifacts: { + // Optional: generate reports first + generateArtifactsCommand: 'npm run lint:json', + // Single file or array of files + artifactsPaths: ['reports/eslint.json'], + }, + }, +); +``` + +If no artifacts are provided, the plugin will run ESLint during Code PushUp analysis. +It generates the required JSON reports, use ESLint's `--format=json --output-file=report.json` options. When using artifacts, the `eslintrc` (not `patterns`) configuration is still required for audit metadata, but ESLint won't be executed during Code PushUp analysis. + ### Optionally set up categories 1. Reference audits (or groups) which you wish to include in custom categories (use `npx code-pushup print-config` to list audits and groups). From 74b6bcf9bd9250c0c61ab54e42fa42ec26b24f43 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 17 Aug 2025 01:37:22 +0200 Subject: [PATCH 11/30] test(plugin-eslint): fix unit tests --- packages/plugin-eslint/src/lib/runner/lint.unit.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 25f8ff227..a96287c66 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts @@ -134,11 +134,11 @@ describe('lint', () => { '--config=".eslintrc.js"', '--no-error-on-unmatched-pattern', '--format=json', - expect.stringMatching(/--output-file=\/test\/eslint-report\.\d+\.json/), - expect.stringContaining('**/*.js'), // wraps in quotes on Unix + expect.stringMatching(/--output-file=.*eslint-report\.\d+\.json/), + "'**/*.js'", // wraps in quotes on Unix ], ignoreExitCode: true, - cwd: MEMFS_VOLUME, + cwd: '/test', }); expect(eslint.calculateConfigForFile).toHaveBeenCalledTimes(3); From b85199b8fb62c1cc118d3d6762a9e0adbf320028 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 17 Aug 2025 01:44:15 +0200 Subject: [PATCH 12/30] fix(plugin-eslint): fix build --- packages/plugin-eslint/src/lib/runner/lint.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-eslint/src/lib/runner/lint.ts b/packages/plugin-eslint/src/lib/runner/lint.ts index 1fb2d66d1..2315582ea 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.ts @@ -13,7 +13,7 @@ import type { ESLintTarget } from '../config.js'; import { setupESLint } from '../setup.js'; import type { LinterOutput, RuleOptionsPerFile } from './types.js'; -import LintResult = ESLint.LintResult; +type LintResult = ESLint.LintResult; export async function lint({ eslintrc, From e4fe8a90def8fa8be46c92c08d805c8fb572554b Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 17 Aug 2025 01:51:26 +0200 Subject: [PATCH 13/30] fix(plugin-eslint): fix unit-test --- packages/plugin-eslint/src/lib/runner/lint.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a96287c66..73c13f42c 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts @@ -135,7 +135,7 @@ describe('lint', () => { '--no-error-on-unmatched-pattern', '--format=json', expect.stringMatching(/--output-file=.*eslint-report\.\d+\.json/), - "'**/*.js'", // wraps in quotes on Unix + expect.stringMatching(/^'?\*\*\/\*\.js'?$/), ], ignoreExitCode: true, cwd: '/test', From 84742a76f1e2f885513150a5c9613730253bbe29 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 17 Aug 2025 01:52:12 +0200 Subject: [PATCH 14/30] docs(models): update models docs --- packages/models/docs/models-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/models/docs/models-reference.md b/packages/models/docs/models-reference.md index be7d688c1..c7b29dbe1 100644 --- a/packages/models/docs/models-reference.md +++ b/packages/models/docs/models-reference.md @@ -1418,7 +1418,7 @@ _Function._ _Parameters:_ -- _none_ +1. [PersistConfig](#persistconfig) _Returns:_ From 8de7b2b3587a082488a1c0248687c84b2a5badab Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Sun, 17 Aug 2025 01:54:37 +0200 Subject: [PATCH 15/30] fix: fix lint --- packages/plugin-eslint/src/lib/runner/lint.unit.test.ts | 1 - 1 file changed, 1 deletion(-) 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 73c13f42c..c317080b9 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts @@ -1,6 +1,5 @@ import { ESLint, type Linter } from 'eslint'; import { expect } from 'vitest'; -import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import { executeProcess } from '@code-pushup/utils'; import type { ESLintPluginConfig } from '../config.js'; import { lint } from './lint.js'; From eb8a7ef1d0af9ad71599222ac34ae1e828d20a9d Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Wed, 20 Aug 2025 14:37:33 +0200 Subject: [PATCH 16/30] feat: add custom formatter --- nx.json | 5 +- packages/plugin-eslint/src/lib/runner/lint.ts | 3 +- tools/eslint-config-examples.md | 107 ++++++++++++++ tools/eslint-programmatic-formatter.cjs | 134 ++++++++++++++++++ 4 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 tools/eslint-config-examples.md create mode 100644 tools/eslint-programmatic-formatter.cjs diff --git a/nx.json b/nx.json index 0bb68734f..a0aa03fb8 100644 --- a/nx.json +++ b/nx.json @@ -50,13 +50,16 @@ "cache": true, "options": { "errorOnUnmatchedPattern": false, - "format": "json", + "format": "./tools/eslint-programmatic-formatter.cjs", "outputFile": "{projectRoot}/.code-pushup/eslint/eslint-report.json", "maxWarnings": 0, "lintFilePatterns": [ "{projectRoot}/**/*.ts", "{projectRoot}/package.json" ] + }, + "env": { + "ESLINT_EXTRA_FORMATS": "{\"formatters\":[{\"name\":\"stylish\",\"output\":\"console\"},{\"name\":\"json\",\"output\":\"file\",\"path\":\"eslint-report.json\",\"options\":{\"pretty\":false}}],\"globalOptions\":{\"verbose\":false,\"timestamp\":false}}" } }, "nxv-pkg-install": { diff --git a/packages/plugin-eslint/src/lib/runner/lint.ts b/packages/plugin-eslint/src/lib/runner/lint.ts index 2315582ea..9ae549957 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.ts @@ -46,8 +46,7 @@ async function executeLint({ ...(eslintrc ? [`--config=${filePathToCliArg(eslintrc)}`] : []), ...(typeof eslintrc === 'object' ? ['--no-eslintrc'] : []), '--no-error-on-unmatched-pattern', - '--format=json', - `--output-file=${reportOutputPath}`, + '--format=../../../tools/eslint-programmatic-formatter.cjs', ...toArray(patterns).map(pattern => // globs need to be escaped on Unix platform() === 'win32' ? pattern : `'${pattern}'`, diff --git a/tools/eslint-config-examples.md b/tools/eslint-config-examples.md new file mode 100644 index 000000000..9f0883ffc --- /dev/null +++ b/tools/eslint-config-examples.md @@ -0,0 +1,107 @@ +# ESLint Formatter Configuration Examples + +## JSON Configuration Format + +The `ESLINT_EXTRA_FORMATS` environment variable now supports JSON configuration for advanced formatting options. + +### Basic JSON Configuration + +```json +{ + "formatters": [ + { + "name": "stylish", + "output": "console" + }, + { + "name": "json", + "output": "file", + "path": "custom-eslint-report.json", + "options": { + "pretty": true + } + } + ], + "globalOptions": { + "verbose": true, + "timestamp": true, + "showProgress": false + } +} +``` + +### Advanced Configuration + +```json +{ + "formatters": [ + { + "name": "stylish", + "output": "console" + }, + { + "name": "json", + "output": "file", + "path": "reports/eslint-detailed.json", + "options": { + "pretty": true + } + }, + { + "name": "json", + "output": "file", + "path": "reports/eslint-compact.json", + "options": { + "pretty": false + } + } + ], + "globalOptions": { + "verbose": true, + "timestamp": true + } +} +``` + +## Usage Examples + +### Using JSON Configuration + +```bash +# One-liner (escape quotes properly) +ESLINT_EXTRA_FORMATS='{"formatters":[{"name":"stylish","output":"console"},{"name":"json","output":"file","path":"custom-report.json","options":{"pretty":true}}],"globalOptions":{"verbose":true,"timestamp":true}}' npx nx run utils:lint + +# From file +ESLINT_EXTRA_FORMATS="$(cat eslint-config.json)" npx nx run utils:lint +``` + +### Backwards Compatibility (Comma-separated) + +```bash +# Still works for simple cases +ESLINT_EXTRA_FORMATS="stylish" npx nx run utils:lint +``` + +## Configuration Schema + +### Formatter Object + +- `name` (string): ESLint formatter name (e.g., "stylish", "json", "checkstyle") +- `output` (string): "console" or "file" +- `path` (string, optional): File path for "file" output +- `options` (object, optional): Formatter-specific options + - `pretty` (boolean): Pretty-print JSON output + +### Global Options + +- `verbose` (boolean): Show detailed logging +- `timestamp` (boolean): Show execution timestamp +- `showProgress` (boolean): Show progress information + +## Default Behavior + +Without `ESLINT_EXTRA_FORMATS`, the formatter will: + +1. Output stylish format to console +2. Generate JSON file for Nx at `eslint-report.json` +3. Return JSON data to Nx for the configured `outputFile` diff --git a/tools/eslint-programmatic-formatter.cjs b/tools/eslint-programmatic-formatter.cjs new file mode 100644 index 000000000..ce4d3d38b --- /dev/null +++ b/tools/eslint-programmatic-formatter.cjs @@ -0,0 +1,134 @@ +// Minimal ESLint multiple formatter for Nx +const { writeFileSync } = require('fs'); +const { dirname, resolve } = require('path'); +const { sync: mkdirp } = require('mkdirp'); +const { ESLint } = require('eslint'); + +const eslint = new ESLint(); + +module.exports = async function (results, context) { + // Default configuration + let config = { + formatters: [ + { + name: 'stylish', + output: 'console', + }, + ], + globalOptions: { + verbose: false, + timestamp: false, + showProgress: false, + }, + }; + + // Parse ESLINT_EXTRA_FORMATS - support both JSON and comma-separated formats + if (process.env.ESLINT_EXTRA_FORMATS) { + const extraFormatsValue = process.env.ESLINT_EXTRA_FORMATS.trim(); + + try { + // Try to parse as JSON first + const jsonConfig = JSON.parse(extraFormatsValue); + + // Merge with default config + if (jsonConfig.formatters) { + // Replace default formatters with JSON config formatters + config.formatters = [...jsonConfig.formatters]; + } + + if (jsonConfig.globalOptions) { + config.globalOptions = { + ...config.globalOptions, + ...jsonConfig.globalOptions, + }; + } + + // Handle additional JSON properties + if (jsonConfig.verbose !== undefined) + config.globalOptions.verbose = jsonConfig.verbose; + if (jsonConfig.timestamp !== undefined) + config.globalOptions.timestamp = jsonConfig.timestamp; + if (jsonConfig.showProgress !== undefined) + config.globalOptions.showProgress = jsonConfig.showProgress; + } catch (error) { + // Fallback to comma-separated format for backwards compatibility + const extraFormats = extraFormatsValue.split(','); + for (const format of extraFormats) { + const trimmedFormat = format.trim(); + if ( + trimmedFormat && + !config.formatters.some(f => f.name === trimmedFormat) + ) { + config.formatters.push({ + name: trimmedFormat, + output: 'console', + }); + } + } + } + } + + // Always ensure JSON formatter for Nx (unless already configured) + if (!config.formatters.some(f => f.name === 'json')) { + config.formatters.push({ + name: 'json', + output: 'file', + path: 'eslint-report.json', + }); + } + + // Apply global options + if (config.globalOptions.timestamp) { + console.log(`ESLint run at: ${new Date().toISOString()}`); + } + + let jsonResult = ''; + + for (const formatterConfig of config.formatters) { + if (config.globalOptions.verbose) { + console.log(`Using formatter: ${formatterConfig.name}`); + } + + const formatter = await eslint.loadFormatter(formatterConfig.name); + let formatterResult = formatter.format(results); + + // Apply formatter-specific options + if ( + formatterConfig.options && + formatterConfig.name === 'json' && + formatterConfig.options.pretty + ) { + try { + const parsed = JSON.parse(formatterResult); + formatterResult = JSON.stringify(parsed, null, 2); + } catch (e) { + // Keep original if parsing fails + } + } + + if (formatterConfig.output === 'console') { + console.log(formatterResult); + } else if (formatterConfig.output === 'file') { + const filePath = resolve(process.cwd(), formatterConfig.path); + try { + mkdirp(dirname(filePath)); + writeFileSync(filePath, formatterResult); + + if (config.globalOptions.verbose) { + console.log(`Written ${formatterConfig.name} output to: ${filePath}`); + } + } catch (ex) { + console.error('Error writing output file:', ex.message); + return false; + } + } + + // Store JSON result for Nx output file handling + if (formatterConfig.name === 'json') { + jsonResult = formatterResult; + } + } + + // Return JSON result for Nx to write to outputFile + return jsonResult || JSON.stringify(results); +}; From 3182a47da213a8b0d0f4fbe48a1c16d25ba011cd Mon Sep 17 00:00:00 2001 From: John Doe Date: Thu, 21 Aug 2025 02:07:37 +0200 Subject: [PATCH 17/30] chore: add lint-reporter targets --- packages/ci/project.json | 1 + packages/cli/project.json | 1 + packages/core/project.json | 1 + packages/create-cli/project.json | 1 + packages/models/project.json | 1 + packages/nx-plugin/project.json | 9 +++++ packages/plugin-coverage/project.json | 1 + packages/plugin-eslint/project.json | 1 + packages/plugin-eslint/src/lib/runner/lint.ts | 33 +++++++++++++------ packages/plugin-js-packages/project.json | 1 + packages/plugin-jsdocs/project.json | 1 + packages/plugin-lighthouse/project.json | 1 + packages/plugin-typescript/project.json | 1 + packages/utils/project.json | 1 + 14 files changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/ci/project.json b/packages/ci/project.json index 9215f87b3..2ba69b828 100644 --- a/packages/ci/project.json +++ b/packages/ci/project.json @@ -6,6 +6,7 @@ "targets": { "build": {}, "lint": {}, + "lint-reporter": {}, "unit-test": {}, "int-test": {} }, diff --git a/packages/cli/project.json b/packages/cli/project.json index bc3b384a7..283cf5b00 100644 --- a/packages/cli/project.json +++ b/packages/cli/project.json @@ -6,6 +6,7 @@ "targets": { "build": {}, "lint": {}, + "lint-reporter": {}, "unit-test": {}, "int-test": {}, "run-help": { diff --git a/packages/core/project.json b/packages/core/project.json index 4924717c5..97e5e1851 100644 --- a/packages/core/project.json +++ b/packages/core/project.json @@ -6,6 +6,7 @@ "targets": { "build": {}, "lint": {}, + "lint-reporter": {}, "unit-test": {}, "int-test": {} }, diff --git a/packages/create-cli/project.json b/packages/create-cli/project.json index c2bc6ef60..26c7c071a 100644 --- a/packages/create-cli/project.json +++ b/packages/create-cli/project.json @@ -6,6 +6,7 @@ "targets": { "build": {}, "lint": {}, + "lint-reporter": {}, "unit-test": {}, "exec-node": { "dependsOn": ["build"], diff --git a/packages/models/project.json b/packages/models/project.json index 15981781f..31d87756f 100644 --- a/packages/models/project.json +++ b/packages/models/project.json @@ -18,6 +18,7 @@ ] }, "lint": {}, + "lint-reporter": {}, "unit-test": {} }, "tags": ["scope:shared", "type:util", "publishable"] diff --git a/packages/nx-plugin/project.json b/packages/nx-plugin/project.json index 52c8f2290..895d6ba92 100644 --- a/packages/nx-plugin/project.json +++ b/packages/nx-plugin/project.json @@ -41,6 +41,15 @@ ] } }, + "lint-reporter": { + "options": { + "lintFilePatterns": [ + "packages/nx-plugin/**/*.ts", + "packages/nx-plugin/package.json", + "packages/nx-plugin/generators.json" + ] + } + }, "unit-test": {}, "int-test": {} }, diff --git a/packages/plugin-coverage/project.json b/packages/plugin-coverage/project.json index edb62346f..4e4b8f185 100644 --- a/packages/plugin-coverage/project.json +++ b/packages/plugin-coverage/project.json @@ -6,6 +6,7 @@ "targets": { "build": {}, "lint": {}, + "lint-reporter": {}, "unit-test": {}, "int-test": {} }, diff --git a/packages/plugin-eslint/project.json b/packages/plugin-eslint/project.json index 241101850..c45bf5642 100644 --- a/packages/plugin-eslint/project.json +++ b/packages/plugin-eslint/project.json @@ -6,6 +6,7 @@ "targets": { "build": {}, "lint": {}, + "lint-reporter": {}, "unit-test": {}, "int-test": {} }, diff --git a/packages/plugin-eslint/src/lib/runner/lint.ts b/packages/plugin-eslint/src/lib/runner/lint.ts index 9ae549957..183199a54 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.ts @@ -1,6 +1,7 @@ import type { ESLint, Linter } from 'eslint'; import { platform } from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { DEFAULT_PERSIST_OUTPUT_DIR } from '@code-pushup/models'; import { distinct, @@ -13,8 +14,6 @@ import type { ESLintTarget } from '../config.js'; import { setupESLint } from '../setup.js'; import type { LinterOutput, RuleOptionsPerFile } from './types.js'; -type LintResult = ESLint.LintResult; - export async function lint({ eslintrc, patterns, @@ -27,17 +26,17 @@ export async function lint({ } // eslint-disable-next-line functional/no-let -let lintFilesRotation = 0; +let rotation = 0; async function executeLint({ eslintrc, patterns, - outputDir, + outputDir: providedOutputDir, }: ESLintTarget & { outputDir?: string }): Promise { - const reportOutputPath = path.join( - outputDir ?? DEFAULT_PERSIST_OUTPUT_DIR, - `eslint-report.${++lintFilesRotation}.json`, - ); + const outputDir = + providedOutputDir ?? path.join(DEFAULT_PERSIST_OUTPUT_DIR, 'eslint'); + const filename = `eslint-report-${++rotation}`; + // running as CLI because ESLint#lintFiles() runs out of memory await executeProcess({ command: 'npx', @@ -46,7 +45,10 @@ async function executeLint({ ...(eslintrc ? [`--config=${filePathToCliArg(eslintrc)}`] : []), ...(typeof eslintrc === 'object' ? ['--no-eslintrc'] : []), '--no-error-on-unmatched-pattern', - '--format=../../../tools/eslint-programmatic-formatter.cjs', + `--format=${path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../formatter/multiple-formats.js', + )}`, ...toArray(patterns).map(pattern => // globs need to be escaped on Unix platform() === 'win32' ? pattern : `'${pattern}'`, @@ -54,9 +56,20 @@ async function executeLint({ ], ignoreExitCode: true, cwd: process.cwd(), + env: { + ...process.env, + ESLINT_FORMATTER_CONFIG: JSON.stringify({ + outputDir, + filename, + formats: ['json'], // Always write JSON to file for tracking + terminal: 'stylish', // Always show stylish terminal output for DX + }), + }, }); - return readJsonFile(reportOutputPath); + return readJsonFile( + path.join(outputDir, `${filename}.json`), + ); } function loadRuleOptionsPerFile( diff --git a/packages/plugin-js-packages/project.json b/packages/plugin-js-packages/project.json index 9f0dc7789..41bf8ab46 100644 --- a/packages/plugin-js-packages/project.json +++ b/packages/plugin-js-packages/project.json @@ -6,6 +6,7 @@ "targets": { "build": {}, "lint": {}, + "lint-reporter": {}, "unit-test": {}, "int-test": {} }, diff --git a/packages/plugin-jsdocs/project.json b/packages/plugin-jsdocs/project.json index 745540438..a2b1a9256 100644 --- a/packages/plugin-jsdocs/project.json +++ b/packages/plugin-jsdocs/project.json @@ -7,6 +7,7 @@ "targets": { "build": {}, "lint": {}, + "lint-reporter": {}, "unit-test": {}, "int-test": {} } diff --git a/packages/plugin-lighthouse/project.json b/packages/plugin-lighthouse/project.json index b93152ffb..c52222f6c 100644 --- a/packages/plugin-lighthouse/project.json +++ b/packages/plugin-lighthouse/project.json @@ -6,6 +6,7 @@ "targets": { "build": {}, "lint": {}, + "lint-reporter": {}, "unit-test": {} }, "tags": ["scope:plugin", "type:feature", "publishable"] diff --git a/packages/plugin-typescript/project.json b/packages/plugin-typescript/project.json index 645259554..f32d8cccb 100644 --- a/packages/plugin-typescript/project.json +++ b/packages/plugin-typescript/project.json @@ -6,6 +6,7 @@ "targets": { "build": {}, "lint": {}, + "lint-reporter": {}, "unit-test": {}, "int-test": {} }, diff --git a/packages/utils/project.json b/packages/utils/project.json index 021205deb..1c9992eb9 100644 --- a/packages/utils/project.json +++ b/packages/utils/project.json @@ -6,6 +6,7 @@ "targets": { "build": {}, "lint": {}, + "lint-reporter": {}, "perf": { "command": "npx tsx --tsconfig=../tsconfig.perf.json", "options": { From e4f6f374155ca4af068ceb7d9db2c3de46a1a106 Mon Sep 17 00:00:00 2001 From: John Doe Date: Thu, 21 Aug 2025 02:08:04 +0200 Subject: [PATCH 18/30] fix(plugin-eslint): refactor formatter to ts --- .../src/lib/formatter/multiple-formats.ts | 70 ++++++++++ .../plugin-eslint/src/lib/formatter/types.ts | 37 +++++ .../plugin-eslint/src/lib/formatter/utils.ts | 131 ++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 packages/plugin-eslint/src/lib/formatter/multiple-formats.ts create mode 100644 packages/plugin-eslint/src/lib/formatter/types.ts create mode 100644 packages/plugin-eslint/src/lib/formatter/utils.ts diff --git a/packages/plugin-eslint/src/lib/formatter/multiple-formats.ts b/packages/plugin-eslint/src/lib/formatter/multiple-formats.ts new file mode 100644 index 000000000..5bc72ee17 --- /dev/null +++ b/packages/plugin-eslint/src/lib/formatter/multiple-formats.ts @@ -0,0 +1,70 @@ +import { ESLint } from 'eslint'; +import path from 'node:path'; +import type { EslintFormat, FormatterConfig } from './types.js'; +import { + findConfigFromEnv as getConfigFromEnv, + handleTerminalOutput, + persistEslintReports, +} from './utils.js'; + +export const DEFAULT_OUTPUT_DIR = path.join(process.cwd(), '.eslint'); +export const DEFAULT_FILENAME = 'eslint-report'; +export const DEFAULT_FORMATS = ['json'] as EslintFormat[]; +export const DEFAULT_TERMINAL = 'stylish' as EslintFormat; + +export const DEFAULT_CONFIG: Required = { + outputDir: DEFAULT_OUTPUT_DIR, + filename: DEFAULT_FILENAME, + formats: DEFAULT_FORMATS, + terminal: DEFAULT_TERMINAL, + verbose: false, +}; + +/** + * Format ESLint results using multiple configurable formatters + * + * @param results - The ESLint results + * @param args - The arguments passed to the formatter + * @returns The formatted results or false if there was an error + * + * @example + * // Basic usage: + * ESLINT_FORMATTER_CONFIG='{"filename":"lint-results","formats":["json"],"terminal":"stylish"}' npx eslint . + * // Creates: .eslint/eslint-results.json + terminal output + * + * // With custom output directory: + * ESLINT_FORMATTER_CONFIG='{"outputDir":"./ci-reports","filename":"eslint-report","formats":["json","html"],"terminal":"stylish"}' nx lint utils + * // Creates: ci-reports/eslint-report.json, ci-reports/eslint-report.html + terminal output + * + * Configuration schema: + * { + * "outputDir": "./reports", // Optional: Output directory (default: cwd/.eslint) + * "filename": "eslint-report", // Optional: Base filename without extension (default: 'eslint-report') + * "formats": ["json"], // Optional: Array of format names for file output (default: ['json']) + * "terminal": "stylish" // Optional: Format for terminal output (default: no terminal output) + * } + */ +export default async function multipleFormats( + results: ESLint.LintResult[], + _args?: unknown, +): Promise { + const config = { ...DEFAULT_CONFIG, ...getConfigFromEnv(process.env) }; + + const { + outputDir = DEFAULT_OUTPUT_DIR, + filename = DEFAULT_FILENAME, + formats = DEFAULT_FORMATS, + terminal, + verbose = false, + } = config; + + await handleTerminalOutput(terminal, results); + + const success = await persistEslintReports(formats, results, { + outputDir, + filename, + verbose, + }); + + return success ? '' : false; +} diff --git a/packages/plugin-eslint/src/lib/formatter/types.ts b/packages/plugin-eslint/src/lib/formatter/types.ts new file mode 100644 index 000000000..5bf27fee7 --- /dev/null +++ b/packages/plugin-eslint/src/lib/formatter/types.ts @@ -0,0 +1,37 @@ +export type EslintFormat = + | 'stylish' + | 'json' + | 'json-with-metadata' + | 'html' + | 'xml' + | 'checkstyle' + | 'junit' + | 'tap' + | 'unix' + | 'compact' + | 'codeframe' + | 'table' + | string; + +export type FormatterConfig = { + outputDir?: string; + filename?: string; + formats?: EslintFormat[]; + terminal?: EslintFormat; + verbose?: boolean; +}; + +export type EnvironmentConfig = { + formatterConfig?: string; +}; + +export type ResolvedConfig = { + config: FormatterConfig; + source: string; + usingDefaults: boolean; +}; + +export type ProcessFileOutputsResult = { + success: boolean; + jsonResult: string; +}; diff --git a/packages/plugin-eslint/src/lib/formatter/utils.ts b/packages/plugin-eslint/src/lib/formatter/utils.ts new file mode 100644 index 000000000..358ebe7dc --- /dev/null +++ b/packages/plugin-eslint/src/lib/formatter/utils.ts @@ -0,0 +1,131 @@ +import { ESLint } from 'eslint'; +import { writeFileSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import type { EslintFormat, FormatterConfig } from './types.js'; + +const eslint = new ESLint(); + +export function getExtensionForFormat(format: EslintFormat): string { + const extensionMap: Record = { + json: '.json', + 'json-with-metadata': 'json-with-metadata.json', + html: 'html.html', + xml: 'xml.xml', + checkstyle: 'checkstyle.xml', + junit: 'junit.xml', + tap: '.tap', + unix: 'unix.txt', + stylish: 'stylish.txt', + compact: 'compact.txt', + codeframe: 'codeframe.txt', + table: 'table.txt', + }; + + return extensionMap[format] || '.txt'; +} + +export async function formatContent( + results: ESLint.LintResult[], + format: EslintFormat, +): Promise { + const formatter = await eslint.loadFormatter(format); + return formatter.format(results); +} + +export function findConfigFromEnv( + env: NodeJS.ProcessEnv, +): FormatterConfig | null { + const configString = env['ESLINT_FORMATTER_CONFIG']; + + if (!configString || configString.trim() === '') { + return null; + } + + try { + const parsed = JSON.parse(configString); + + // Validate that parsed result is an object + if (typeof parsed !== 'object' || parsed == null || Array.isArray(parsed)) { + console.error('ESLINT_FORMATTER_CONFIG must be a valid JSON object'); + return null; + } + + return parsed as FormatterConfig; + } catch (error_) { + console.error( + 'Error parsing ESLINT_FORMATTER_CONFIG environment variable:', + (error_ as Error).message, + ); + return null; + } +} + +export async function handleTerminalOutput( + format: EslintFormat | undefined, + results: ESLint.LintResult[], +): Promise { + if (!format) { + return; + } + const content = await formatContent(results, format); + // eslint-disable-next-line no-console + console.log(content); +} + +export type PersistConfig = { + outputDir: string; + filename: string; + format: EslintFormat; + verbose?: boolean; +}; + +export async function persistEslintReport( + content: string, + options: PersistConfig, +): Promise { + const { outputDir, filename, format, verbose = false } = options; + try { + await mkdir(path.dirname(outputDir), { recursive: true }); + // eslint-disable-next-line n/no-sync + writeFileSync( + path.join(outputDir, `${filename}.${getExtensionForFormat(format)}`), + content, + ); + if (verbose) { + // eslint-disable-next-line no-console + console.log(`ESLint report (${format}) written to: ${outputDir}`); + } + return true; + } catch (error_) { + if (verbose) { + console.error('There was a problem writing the output file:\n%s', error_); + } + return false; + } +} + +export async function persistEslintReports( + formats: EslintFormat[], + results: ESLint.LintResult[], + options: { outputDir: string; filename: string; verbose: boolean }, +): Promise { + const { outputDir, filename, verbose } = options; + + // eslint-disable-next-line functional/no-loop-statements + for (const format of formats) { + const content = await formatContent(results, format); + + const success = await persistEslintReport(content, { + outputDir, + filename, + format, + verbose, + }); + + if (!success) { + return false; + } + } + return true; +} From 3ab76741d63dd6edea15e0d6c8b9abb9f9024f8a Mon Sep 17 00:00:00 2001 From: John Doe Date: Thu, 21 Aug 2025 02:14:47 +0200 Subject: [PATCH 19/30] fix(plugin-eslint): add glob handling --- code-pushup.preset.ts | 50 +------------------ .../plugin-eslint/src/lib/runner/utils.ts | 17 ++++++- 2 files changed, 17 insertions(+), 50 deletions(-) diff --git a/code-pushup.preset.ts b/code-pushup.preset.ts index 9381bcd80..2a81d5d72 100644 --- a/code-pushup.preset.ts +++ b/code-pushup.preset.ts @@ -1,5 +1,4 @@ /* eslint-disable @nx/enforce-module-boundaries */ -import { ProjectConfiguration } from '@nx/devkit'; import type { CategoryConfig, CoreConfig, @@ -159,46 +158,6 @@ export const jsDocsCoreConfig = ( ), }); -export async function findNxProjectsWithTarget({ - targetNames, - exclude, - include, - tags, -}: { - targetNames: string[]; - exclude?: string[]; - include?: string[]; - tags?: string[]; -}): Promise { - const { createProjectGraphAsync } = await import('@nx/devkit'); - const projectGraph = await createProjectGraphAsync({ exitOnError: false }); - - const { readProjectsConfigurationFromProjectGraph } = await import( - '@nx/devkit' - ); - const projectsConfiguration = - readProjectsConfigurationFromProjectGraph(projectGraph); - const projects = Object.values(projectsConfiguration.projects).filter( - project => { - // Check if project has required target - const hasTarget = targetNames.some( - targetName => project.targets?.[targetName], - ); - - // Check include/exclude lists - const isIncluded = !include || include.includes(project?.name ?? ''); - const isNotExcluded = !exclude?.includes(project?.name ?? ''); - - // Check tags if specified - const hasRequiredTags = - !tags || tags.some(tag => project.tags?.includes(tag)); - - return (hasTarget || isIncluded) && isNotExcluded && hasRequiredTags; - }, - ); - return projects; -} - export async function eslintConfigFromPublishableNxProjects(): Promise< ESLintTarget[] > { @@ -226,16 +185,11 @@ export const eslintCoreConfigNx = async ( 'nx', 'run-many', '-t', - 'lint', + 'lint-reporter', '--projects=tag:publishable', ], }, - artifactsPaths: ( - await findNxProjectsWithTarget({ - targetNames: ['lint'], - tags: ['publishable'], - }) - ).map(({ root }) => `${root}/.code-pushup/eslint/eslint-report.json`), + artifactsPaths: 'packages/*/.code-pushup/eslint/eslint-report.json', }, }, ), diff --git a/packages/plugin-eslint/src/lib/runner/utils.ts b/packages/plugin-eslint/src/lib/runner/utils.ts index 9c147d7ff..00cbcf1ca 100644 --- a/packages/plugin-eslint/src/lib/runner/utils.ts +++ b/packages/plugin-eslint/src/lib/runner/utils.ts @@ -1,4 +1,5 @@ import type { ESLint } from 'eslint'; +import { glob } from 'glob'; import type { PluginArtifactOptions } from '@code-pushup/models'; import { executeProcess, @@ -8,6 +9,16 @@ import { } from '@code-pushup/utils'; import type { LinterOutput } from './types.js'; +async function resolveGlobPatterns(patterns: string[]): Promise { + const resolvedPathArrays = await Promise.all( + patterns.map(async pattern => glob(pattern)), + ); + + const resolvedPaths = resolvedPathArrays.flat(); + + return [...new Set(resolvedPaths)].sort(); +} + export async function loadArtifacts( artifacts: PluginArtifactOptions, ): Promise { @@ -30,12 +41,14 @@ export async function loadArtifacts( await ui().logger.log(`$ ${commandString}`); } - const artifactPaths = Array.isArray(artifacts.artifactsPaths) + const initialArtifactPaths = Array.isArray(artifacts.artifactsPaths) ? artifacts.artifactsPaths : [artifacts.artifactsPaths]; + const artifactPaths = await resolveGlobPatterns(initialArtifactPaths); + ui().logger.log( - `ESLint plugin loading ${artifactPaths.length} eslint ${pluralizeToken('report', artifactPaths.length)}`, + `ESLint plugin resolved ${initialArtifactPaths.length} ${pluralizeToken('pattern', initialArtifactPaths.length)} to ${artifactPaths.length} eslint ${pluralizeToken('report', artifactPaths.length)}`, ); return await Promise.all( From 42abfcfc9daf1059b2f7d5d04486c6284ddf0129 Mon Sep 17 00:00:00 2001 From: John Doe Date: Thu, 21 Aug 2025 02:15:09 +0200 Subject: [PATCH 20/30] fix(plugin-eslint): adjust default lint targets --- nx.json | 22 ++++++++++++++++------ packages/plugin-eslint/package.json | 3 ++- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/nx.json b/nx.json index a0aa03fb8..1dba7669b 100644 --- a/nx.json +++ b/nx.json @@ -46,20 +46,30 @@ "lint": { "inputs": ["default", "{workspaceRoot}/eslint.config.?(c)js"], "executor": "@nx/linter:eslint", - "outputs": ["{options.outputFile}"], "cache": true, "options": { "errorOnUnmatchedPattern": false, - "format": "./tools/eslint-programmatic-formatter.cjs", - "outputFile": "{projectRoot}/.code-pushup/eslint/eslint-report.json", "maxWarnings": 0, "lintFilePatterns": [ "{projectRoot}/**/*.ts", "{projectRoot}/package.json" ] - }, - "env": { - "ESLINT_EXTRA_FORMATS": "{\"formatters\":[{\"name\":\"stylish\",\"output\":\"console\"},{\"name\":\"json\",\"output\":\"file\",\"path\":\"eslint-report.json\",\"options\":{\"pretty\":false}}],\"globalOptions\":{\"verbose\":false,\"timestamp\":false}}" + } + }, + "lint-reporter": { + "inputs": ["default", "{workspaceRoot}/eslint.config.?(c)js"], + "outputs": ["{projectRoot}/.eslint/**/*.json"], + "cache": true, + "executor": "nx:run-commands", + "options": { + "command": "nx lint {projectName}", + "args": [ + "--format", + "./packages/plugin-eslint/dist/src/lib/formatter/multiple-formats.js" + ], + "env": { + "ESLINT_FORMATTER_CONFIG": "{\"outputDir\":\"{projectRoot}/.eslint\"}" + } } }, "nxv-pkg-install": { diff --git a/packages/plugin-eslint/package.json b/packages/plugin-eslint/package.json index b3a9e102c..d1639af06 100644 --- a/packages/plugin-eslint/package.json +++ b/packages/plugin-eslint/package.json @@ -44,7 +44,8 @@ }, "peerDependencies": { "@nx/devkit": ">=17.0.0", - "eslint": "^8.46.0 || ^9.0.0" + "eslint": "^8.46.0 || ^9.0.0", + "glob": "^11.0.1" }, "peerDependenciesMeta": { "@nx/devkit": { From 3c522c78164313af5c518ea8a50023c9f8cfbb4c Mon Sep 17 00:00:00 2001 From: John Doe Date: Thu, 21 Aug 2025 13:27:04 +0200 Subject: [PATCH 21/30] wip --- packages/plugin-eslint/package.json | 1 - packages/plugin-eslint/src/lib/runner/lint.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/plugin-eslint/package.json b/packages/plugin-eslint/package.json index dd2261264..6cb06c774 100644 --- a/packages/plugin-eslint/package.json +++ b/packages/plugin-eslint/package.json @@ -40,7 +40,6 @@ "dependencies": { "@code-pushup/utils": "0.74.1", "@code-pushup/models": "0.74.1", - "yargs": "^17.7.2", "zod": "^4.0.5" }, "peerDependencies": { diff --git a/packages/plugin-eslint/src/lib/runner/lint.ts b/packages/plugin-eslint/src/lib/runner/lint.ts index 183199a54..bb4768737 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.ts @@ -57,7 +57,6 @@ async function executeLint({ ignoreExitCode: true, cwd: process.cwd(), env: { - ...process.env, ESLINT_FORMATTER_CONFIG: JSON.stringify({ outputDir, filename, From 00e3fe757c713966fa9dafb32172e39a69d0670c Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Thu, 21 Aug 2025 15:28:41 +0200 Subject: [PATCH 22/30] docs(plugin-eslint): add formatter docs --- .../plugin-eslint/src/lib/formatter/README.md | 105 ++++++++++++++ tools/eslint-config-examples.md | 107 -------------- tools/eslint-programmatic-formatter.cjs | 134 ------------------ 3 files changed, 105 insertions(+), 241 deletions(-) create mode 100644 packages/plugin-eslint/src/lib/formatter/README.md delete mode 100644 tools/eslint-config-examples.md delete mode 100644 tools/eslint-programmatic-formatter.cjs diff --git a/packages/plugin-eslint/src/lib/formatter/README.md b/packages/plugin-eslint/src/lib/formatter/README.md new file mode 100644 index 000000000..773107cf6 --- /dev/null +++ b/packages/plugin-eslint/src/lib/formatter/README.md @@ -0,0 +1,105 @@ +# ESLint Multi-Format Formatter + +The ESLint plugin uses a custom formatter that supports multiple output formats and destinations simultaneously. + +## Configuration + +Use the `ESLINT_FORMATTER_CONFIG` environment variable to configure the formatter with JSON. + +### Configuration Schema + +```json +{ + "outputDir": "./reports", // Optional: Output directory (default: cwd/.eslint) + "filename": "eslint-report", // Optional: Base filename without extension (default: 'eslint-report') + "formats": ["json", "html"], // Optional: Array of format names for file output (default: ['json']) + "terminal": "stylish", // Optional: Format for terminal output (default: 'stylish') + "verbose": true // Optional: Enable verbose logging (default: false) +} +``` + +### Supported Formats + +All standard ESLint formatters are supported: + +- `stylish` (default terminal output) +- `json` (default file output) +- `json-with-metadata` +- `html` +- `xml` +- `checkstyle` +- `junit` +- `tap` +- `unix` +- `compact` +- `codeframe` +- `table` +- Custom formatters (any string) + +## Usage Examples + +### Basic Usage + +```bash +# Default behavior - JSON file output + stylish console output +npx eslint . + +# Custom output directory and filename +ESLINT_FORMATTER_CONFIG='{"outputDir":"./ci-reports","filename":"lint-results"}' npx eslint . +# Creates: ci-reports/lint-results.json + terminal output +``` + +### Multiple Output Formats + +```bash +# Generate multiple file formats +ESLINT_FORMATTER_CONFIG='{"formats":["json","html","xml"],"terminal":"stylish"}' npx eslint . +# Creates: .eslint/eslint-report.json, .eslint/eslint-report.html, .eslint/eslint-report.xml + terminal output + +# Custom directory with multiple formats +ESLINT_FORMATTER_CONFIG='{"outputDir":"./reports","filename":"eslint-results","formats":["json","html","checkstyle"]}' npx eslint . +# Creates: reports/eslint-results.json, reports/eslint-results.html, reports/eslint-results.xml +``` + +### Terminal Output Only + +```bash +# Only show terminal output, no files +ESLINT_FORMATTER_CONFIG='{"formats":[],"terminal":"compact"}' npx eslint . + +# Different terminal format +ESLINT_FORMATTER_CONFIG='{"formats":[],"terminal":"unix"}' npx eslint . +``` + +### Configuration from File + +```bash +# Create a configuration file +cat > eslint-config.json << 'EOF' +{ + "outputDir": "./ci-reports", + "filename": "eslint-report", + "formats": ["json", "junit"], + "terminal": "stylish", + "verbose": true +} +EOF + +# Use the configuration file +ESLINT_FORMATTER_CONFIG="$(cat eslint-config.json)" npx eslint . +``` + +## Default Behavior + +When no `ESLINT_FORMATTER_CONFIG` is provided, the formatter uses these defaults: + +- **outputDir**: `./.eslint` (relative to current working directory) +- **filename**: `eslint-report` +- **formats**: `["json"]` +- **terminal**: `stylish` +- **verbose**: `false` + +This means by default you get: + +- A JSON file at `./.eslint/eslint-report.json` +- Stylish terminal output diff --git a/tools/eslint-config-examples.md b/tools/eslint-config-examples.md deleted file mode 100644 index 9f0883ffc..000000000 --- a/tools/eslint-config-examples.md +++ /dev/null @@ -1,107 +0,0 @@ -# ESLint Formatter Configuration Examples - -## JSON Configuration Format - -The `ESLINT_EXTRA_FORMATS` environment variable now supports JSON configuration for advanced formatting options. - -### Basic JSON Configuration - -```json -{ - "formatters": [ - { - "name": "stylish", - "output": "console" - }, - { - "name": "json", - "output": "file", - "path": "custom-eslint-report.json", - "options": { - "pretty": true - } - } - ], - "globalOptions": { - "verbose": true, - "timestamp": true, - "showProgress": false - } -} -``` - -### Advanced Configuration - -```json -{ - "formatters": [ - { - "name": "stylish", - "output": "console" - }, - { - "name": "json", - "output": "file", - "path": "reports/eslint-detailed.json", - "options": { - "pretty": true - } - }, - { - "name": "json", - "output": "file", - "path": "reports/eslint-compact.json", - "options": { - "pretty": false - } - } - ], - "globalOptions": { - "verbose": true, - "timestamp": true - } -} -``` - -## Usage Examples - -### Using JSON Configuration - -```bash -# One-liner (escape quotes properly) -ESLINT_EXTRA_FORMATS='{"formatters":[{"name":"stylish","output":"console"},{"name":"json","output":"file","path":"custom-report.json","options":{"pretty":true}}],"globalOptions":{"verbose":true,"timestamp":true}}' npx nx run utils:lint - -# From file -ESLINT_EXTRA_FORMATS="$(cat eslint-config.json)" npx nx run utils:lint -``` - -### Backwards Compatibility (Comma-separated) - -```bash -# Still works for simple cases -ESLINT_EXTRA_FORMATS="stylish" npx nx run utils:lint -``` - -## Configuration Schema - -### Formatter Object - -- `name` (string): ESLint formatter name (e.g., "stylish", "json", "checkstyle") -- `output` (string): "console" or "file" -- `path` (string, optional): File path for "file" output -- `options` (object, optional): Formatter-specific options - - `pretty` (boolean): Pretty-print JSON output - -### Global Options - -- `verbose` (boolean): Show detailed logging -- `timestamp` (boolean): Show execution timestamp -- `showProgress` (boolean): Show progress information - -## Default Behavior - -Without `ESLINT_EXTRA_FORMATS`, the formatter will: - -1. Output stylish format to console -2. Generate JSON file for Nx at `eslint-report.json` -3. Return JSON data to Nx for the configured `outputFile` diff --git a/tools/eslint-programmatic-formatter.cjs b/tools/eslint-programmatic-formatter.cjs deleted file mode 100644 index ce4d3d38b..000000000 --- a/tools/eslint-programmatic-formatter.cjs +++ /dev/null @@ -1,134 +0,0 @@ -// Minimal ESLint multiple formatter for Nx -const { writeFileSync } = require('fs'); -const { dirname, resolve } = require('path'); -const { sync: mkdirp } = require('mkdirp'); -const { ESLint } = require('eslint'); - -const eslint = new ESLint(); - -module.exports = async function (results, context) { - // Default configuration - let config = { - formatters: [ - { - name: 'stylish', - output: 'console', - }, - ], - globalOptions: { - verbose: false, - timestamp: false, - showProgress: false, - }, - }; - - // Parse ESLINT_EXTRA_FORMATS - support both JSON and comma-separated formats - if (process.env.ESLINT_EXTRA_FORMATS) { - const extraFormatsValue = process.env.ESLINT_EXTRA_FORMATS.trim(); - - try { - // Try to parse as JSON first - const jsonConfig = JSON.parse(extraFormatsValue); - - // Merge with default config - if (jsonConfig.formatters) { - // Replace default formatters with JSON config formatters - config.formatters = [...jsonConfig.formatters]; - } - - if (jsonConfig.globalOptions) { - config.globalOptions = { - ...config.globalOptions, - ...jsonConfig.globalOptions, - }; - } - - // Handle additional JSON properties - if (jsonConfig.verbose !== undefined) - config.globalOptions.verbose = jsonConfig.verbose; - if (jsonConfig.timestamp !== undefined) - config.globalOptions.timestamp = jsonConfig.timestamp; - if (jsonConfig.showProgress !== undefined) - config.globalOptions.showProgress = jsonConfig.showProgress; - } catch (error) { - // Fallback to comma-separated format for backwards compatibility - const extraFormats = extraFormatsValue.split(','); - for (const format of extraFormats) { - const trimmedFormat = format.trim(); - if ( - trimmedFormat && - !config.formatters.some(f => f.name === trimmedFormat) - ) { - config.formatters.push({ - name: trimmedFormat, - output: 'console', - }); - } - } - } - } - - // Always ensure JSON formatter for Nx (unless already configured) - if (!config.formatters.some(f => f.name === 'json')) { - config.formatters.push({ - name: 'json', - output: 'file', - path: 'eslint-report.json', - }); - } - - // Apply global options - if (config.globalOptions.timestamp) { - console.log(`ESLint run at: ${new Date().toISOString()}`); - } - - let jsonResult = ''; - - for (const formatterConfig of config.formatters) { - if (config.globalOptions.verbose) { - console.log(`Using formatter: ${formatterConfig.name}`); - } - - const formatter = await eslint.loadFormatter(formatterConfig.name); - let formatterResult = formatter.format(results); - - // Apply formatter-specific options - if ( - formatterConfig.options && - formatterConfig.name === 'json' && - formatterConfig.options.pretty - ) { - try { - const parsed = JSON.parse(formatterResult); - formatterResult = JSON.stringify(parsed, null, 2); - } catch (e) { - // Keep original if parsing fails - } - } - - if (formatterConfig.output === 'console') { - console.log(formatterResult); - } else if (formatterConfig.output === 'file') { - const filePath = resolve(process.cwd(), formatterConfig.path); - try { - mkdirp(dirname(filePath)); - writeFileSync(filePath, formatterResult); - - if (config.globalOptions.verbose) { - console.log(`Written ${formatterConfig.name} output to: ${filePath}`); - } - } catch (ex) { - console.error('Error writing output file:', ex.message); - return false; - } - } - - // Store JSON result for Nx output file handling - if (formatterConfig.name === 'json') { - jsonResult = formatterResult; - } - } - - // Return JSON result for Nx to write to outputFile - return jsonResult || JSON.stringify(results); -}; From 5d40c597ee553c7fa7e212b20856433a66441021 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Thu, 21 Aug 2025 16:10:52 +0200 Subject: [PATCH 23/30] docs(plugin-eslint): fix code fro new loader --- packages/plugin-eslint/src/lib/runner/lint.ts | 20 +++----------- .../src/lib/runner/lint.unit.test.ts | 10 ++++++- .../plugin-eslint/src/lib/runner/utils.ts | 10 +++---- .../src/lib/runner/utils.unit.test.ts | 27 ++++++++++++++++--- 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/packages/plugin-eslint/src/lib/runner/lint.ts b/packages/plugin-eslint/src/lib/runner/lint.ts index bb4768737..2fce56eaa 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.ts @@ -36,7 +36,7 @@ async function executeLint({ const outputDir = providedOutputDir ?? path.join(DEFAULT_PERSIST_OUTPUT_DIR, 'eslint'); const filename = `eslint-report-${++rotation}`; - + const outputFile = path.join(outputDir, `${filename}.json`); // running as CLI because ESLint#lintFiles() runs out of memory await executeProcess({ command: 'npx', @@ -45,10 +45,8 @@ async function executeLint({ ...(eslintrc ? [`--config=${filePathToCliArg(eslintrc)}`] : []), ...(typeof eslintrc === 'object' ? ['--no-eslintrc'] : []), '--no-error-on-unmatched-pattern', - `--format=${path.join( - path.dirname(fileURLToPath(import.meta.url)), - '../formatter/multiple-formats.js', - )}`, + '--format=json', + `--output-file=${outputFile}`, ...toArray(patterns).map(pattern => // globs need to be escaped on Unix platform() === 'win32' ? pattern : `'${pattern}'`, @@ -56,19 +54,9 @@ async function executeLint({ ], ignoreExitCode: true, cwd: process.cwd(), - env: { - ESLINT_FORMATTER_CONFIG: JSON.stringify({ - outputDir, - filename, - formats: ['json'], // Always write JSON to file for tracking - terminal: 'stylish', // Always show stylish terminal output for DX - }), - }, }); - return readJsonFile( - path.join(outputDir, `${filename}.json`), - ); + return readJsonFile(outputFile); } function loadRuleOptionsPerFile( 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 c317080b9..64ff33cae 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts @@ -4,6 +4,14 @@ import { executeProcess } from '@code-pushup/utils'; import type { ESLintPluginConfig } from '../config.js'; import { lint } from './lint.js'; +/** + * Regex pattern to match ESLint report filename format. + * Matches: eslint-report.json or eslint-report-{number}.json + * - No number: eslint-report.json (no dash) + * - With number: eslint-report-123.json (with dash and digits) + */ +const ESLINT_REPORT_FILENAME_PATTERN = /eslint-report(?:-\d+)?\.json/; + class MockESLint { calculateConfigForFile = vi.fn().mockImplementation( (path: string) => @@ -133,7 +141,7 @@ describe('lint', () => { '--config=".eslintrc.js"', '--no-error-on-unmatched-pattern', '--format=json', - expect.stringMatching(/--output-file=.*eslint-report\.\d+\.json/), + expect.stringMatching(ESLINT_REPORT_FILENAME_PATTERN), expect.stringMatching(/^'?\*\*\/\*\.js'?$/), ], ignoreExitCode: true, diff --git a/packages/plugin-eslint/src/lib/runner/utils.ts b/packages/plugin-eslint/src/lib/runner/utils.ts index 00cbcf1ca..3148bb5f2 100644 --- a/packages/plugin-eslint/src/lib/runner/utils.ts +++ b/packages/plugin-eslint/src/lib/runner/utils.ts @@ -27,11 +27,6 @@ export async function loadArtifacts( typeof artifacts.generateArtifactsCommand === 'string' ? { command: artifacts.generateArtifactsCommand } : artifacts.generateArtifactsCommand; - await executeProcess({ - command, - args, - ignoreExitCode: true, - }); // Log the actual command that was executed const commandString = @@ -39,6 +34,11 @@ export async function loadArtifacts( ? artifacts.generateArtifactsCommand : `${command} ${args.join(' ')}`; await ui().logger.log(`$ ${commandString}`); + await executeProcess({ + command, + args, + ignoreExitCode: true, + }); } const initialArtifactPaths = Array.isArray(artifacts.artifactsPaths) diff --git a/packages/plugin-eslint/src/lib/runner/utils.unit.test.ts b/packages/plugin-eslint/src/lib/runner/utils.unit.test.ts index a614fa592..39ef5eeeb 100644 --- a/packages/plugin-eslint/src/lib/runner/utils.unit.test.ts +++ b/packages/plugin-eslint/src/lib/runner/utils.unit.test.ts @@ -1,14 +1,27 @@ import type { ESLint } from 'eslint'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ui } from '@code-pushup/utils'; import * as utilsModule from '@code-pushup/utils'; import type { LinterOutput } from './types.js'; import { loadArtifacts } from './utils.js'; +vi.mock('@code-pushup/utils', async () => { + const actual = await vi.importActual('@code-pushup/utils'); + return { + ...actual, + readJsonFile: vi.fn(), + executeProcess: vi.fn(), + }; +}); + +vi.mock('glob', () => ({ + glob: vi.fn((pattern: string) => Promise.resolve([pattern])), +})); + describe('loadArtifacts', () => { const readJsonFileSpy = vi.spyOn(utilsModule, 'readJsonFile'); const executeProcessSpy = vi.spyOn(utilsModule, 'executeProcess'); - // Mock data should be raw ESLint.LintResult[] as that's what ESLint CLI outputs const mockRawResults1: ESLint.LintResult[] = [ { filePath: '/test/file1.js', @@ -47,7 +60,6 @@ describe('loadArtifacts', () => { }, ]; - // Expected output after our function wraps raw results in LinterOutput format const expectedLinterOutput1: LinterOutput = { results: mockRawResults1, ruleOptionsPerFile: {}, @@ -60,7 +72,7 @@ describe('loadArtifacts', () => { const artifactsPaths = ['/path/to/artifact1.json', '/path/to/artifact2.json']; - beforeEach(async () => { + beforeEach(() => { vi.clearAllMocks(); executeProcessSpy.mockResolvedValue({ stdout: JSON.stringify(mockRawResults1), @@ -80,6 +92,8 @@ describe('loadArtifacts', () => { expect(executeProcessSpy).not.toHaveBeenCalled(); expect(readJsonFileSpy).toHaveBeenCalledTimes(1); expect(readJsonFileSpy).toHaveBeenNthCalledWith(1, artifactsPaths.at(0)); + + expect(ui()).not.toHaveLogged('log', expect.stringMatching(/^\$ /)); }); it('should load multiple artifacts without generateArtifactsCommand', async () => { @@ -95,6 +109,8 @@ describe('loadArtifacts', () => { expect(readJsonFileSpy).toHaveBeenCalledTimes(2); expect(readJsonFileSpy).toHaveBeenNthCalledWith(1, artifactsPaths.at(0)); expect(readJsonFileSpy).toHaveBeenNthCalledWith(2, artifactsPaths.at(1)); + + expect(ui()).not.toHaveLogged('log', expect.stringMatching(/^\$ /)); }); it('should load artifacts with generateArtifactsCommand as string', async () => { @@ -112,6 +128,7 @@ describe('loadArtifacts', () => { command: generateArtifactsCommand, ignoreExitCode: true, }); + expect(ui()).toHaveLogged('log', `$ ${generateArtifactsCommand}`); }); it('should load artifacts with generateArtifactsCommand as object', async () => { @@ -131,5 +148,9 @@ describe('loadArtifacts', () => { ...generateArtifactsCommand, ignoreExitCode: true, }); + expect(ui()).toHaveLogged( + 'log', + '$ nx run-many -t lint --parallel --max-parallel=5', + ); }); }); From 79170da7725b6e42dda52e9ad639b53d9878f452 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Thu, 21 Aug 2025 16:12:59 +0200 Subject: [PATCH 24/30] docs(plugin-eslint): fix code fro new loader 2 --- packages/plugin-eslint/src/lib/runner/lint.unit.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 64ff33cae..28d4085df 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts @@ -10,7 +10,8 @@ import { lint } from './lint.js'; * - No number: eslint-report.json (no dash) * - With number: eslint-report-123.json (with dash and digits) */ -const ESLINT_REPORT_FILENAME_PATTERN = /eslint-report(?:-\d+)?\.json/; +const ESLINT_REPORT_FILENAME_PATTERN = + /--output-file=\.code-pushup\/eslint\/eslint-report(?:-\d+)?\.json/; class MockESLint { calculateConfigForFile = vi.fn().mockImplementation( From 486e09981a63c21cfaeea8f0c1a02dc78645f74b Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Thu, 21 Aug 2025 16:27:49 +0200 Subject: [PATCH 25/30] chore: add lint target with formatter --- nx.json | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/nx.json b/nx.json index 1dba7669b..9f21661f8 100644 --- a/nx.json +++ b/nx.json @@ -58,7 +58,23 @@ }, "lint-reporter": { "inputs": ["default", "{workspaceRoot}/eslint.config.?(c)js"], - "outputs": ["{projectRoot}/.eslint/**/*.json"], + "outputs": ["{projectRoot}/.eslint/eslint-report*.json"], + "cache": true, + "executor": "@nx/linter:eslint", + "options": { + "errorOnUnmatchedPattern": false, + "maxWarnings": 0, + "format": "json", + "outputFile": "{projectRoot}/.eslint/eslint-report.json", + "lintFilePatterns": [ + "{projectRoot}/**/*.ts", + "{projectRoot}/package.json" + ] + } + }, + "lint-formatter": { + "inputs": ["default", "{workspaceRoot}/eslint.config.?(c)js"], + "outputs": ["{projectRoot}/.eslint/eslint-report*.json"], "cache": true, "executor": "nx:run-commands", "options": { From 146132ae607cc97fe3bd836982db2173a1ef2753 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Thu, 21 Aug 2025 16:30:54 +0200 Subject: [PATCH 26/30] fix(plugin-eslint): fix lint --- packages/plugin-eslint/src/lib/runner/lint.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/plugin-eslint/src/lib/runner/lint.ts b/packages/plugin-eslint/src/lib/runner/lint.ts index 2fce56eaa..ebf535faf 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.ts @@ -1,7 +1,6 @@ import type { ESLint, Linter } from 'eslint'; import { platform } from 'node:os'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { DEFAULT_PERSIST_OUTPUT_DIR } from '@code-pushup/models'; import { distinct, From d2de7bb1bd9c4961e0999dfea042cda6df7fa7c7 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Thu, 21 Aug 2025 16:35:06 +0200 Subject: [PATCH 27/30] test(plugin-eslint): fix unit tests --- packages/plugin-eslint/src/lib/runner/lint.ts | 2 +- packages/plugin-eslint/src/lib/runner/lint.unit.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin-eslint/src/lib/runner/lint.ts b/packages/plugin-eslint/src/lib/runner/lint.ts index ebf535faf..760af3033 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.ts @@ -45,7 +45,7 @@ async function executeLint({ ...(typeof eslintrc === 'object' ? ['--no-eslintrc'] : []), '--no-error-on-unmatched-pattern', '--format=json', - `--output-file=${outputFile}`, + `--output-file=${filePathToCliArg(outputFile)}`, ...toArray(patterns).map(pattern => // globs need to be escaped on Unix platform() === 'win32' ? pattern : `'${pattern}'`, 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 28d4085df..bcf5fbf66 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts @@ -11,7 +11,7 @@ import { lint } from './lint.js'; * - With number: eslint-report-123.json (with dash and digits) */ const ESLINT_REPORT_FILENAME_PATTERN = - /--output-file=\.code-pushup\/eslint\/eslint-report(?:-\d+)?\.json/; + /--output-file="\.code-pushup\/eslint\/eslint-report(?:-\d+)?\.json"/; class MockESLint { calculateConfigForFile = vi.fn().mockImplementation( From 690ba8e46b5c5dc76b888c42aa6abfd4a8404c62 Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Thu, 21 Aug 2025 22:39:08 +0200 Subject: [PATCH 28/30] test(models): add globPathSchema --- packages/models/src/index.ts | 1 + packages/models/src/lib/configuration.ts | 10 +++++++++- .../models/src/lib/implementation/schemas.ts | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index a76aa15fc..aa36975c9 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -57,6 +57,7 @@ export { export { fileNameSchema, filePathSchema, + globPathSchema, materialIconSchema, type MaterialIcon, } from './lib/implementation/schemas.js'; diff --git a/packages/models/src/lib/configuration.ts b/packages/models/src/lib/configuration.ts index e3512d2f5..dde017fc0 100644 --- a/packages/models/src/lib/configuration.ts +++ b/packages/models/src/lib/configuration.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { filePathSchema, globPathSchema } from './implementation/schemas.js'; /** * Generic schema for a tool command configuration, reusable across plugins. @@ -13,7 +14,14 @@ export const artifactGenerationCommandSchema = z.union([ export const pluginArtifactOptionsSchema = z.object({ generateArtifactsCommand: artifactGenerationCommandSchema.optional(), - artifactsPaths: z.union([z.string(), z.array(z.string()).min(1)]), + artifactsPaths: z + .union([ + filePathSchema, + z.array(filePathSchema).min(1), + globPathSchema, + z.array(globPathSchema).min(1), + ]) + .describe('File paths or glob patterns for artifact files'), }); export type PluginArtifactOptions = z.infer; diff --git a/packages/models/src/lib/implementation/schemas.ts b/packages/models/src/lib/implementation/schemas.ts index 720dec5c4..7bc128830 100644 --- a/packages/models/src/lib/implementation/schemas.ts +++ b/packages/models/src/lib/implementation/schemas.ts @@ -138,6 +138,24 @@ export const filePathSchema = z .trim() .min(1, { message: 'The path is invalid' }); +/** + * Regex for glob patterns - validates file paths and glob patterns + * Allows normal paths and paths with glob metacharacters: *, **, {}, [], !, ? + * Excludes invalid path characters: <>"| + */ +const globRegex = /^!?[^<>"|]+$/; + +/** Schema for a glob pattern (supports wildcards like *, **, {}, !, etc.) */ +export const globPathSchema = z + .string() + .trim() + .min(1, { message: 'The glob pattern is invalid' }) + .regex(globRegex, { + message: + 'The path must be a valid file path or glob pattern (supports *, **, {}, [], !, ?)', + }) + .describe('File path or glob pattern (supports *, **, {}, !, etc.)'); + /** Schema for a fileNameSchema */ export const fileNameSchema = z .string() From 3db86dc17ed4cd8e257a18bbb2753778dd9b577c Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Thu, 21 Aug 2025 22:39:28 +0200 Subject: [PATCH 29/30] fix: add regex for eslint report --- packages/plugin-eslint/src/lib/runner/lint.ts | 8 ++++++++ .../plugin-eslint/src/lib/runner/lint.unit.test.ts | 11 +---------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/plugin-eslint/src/lib/runner/lint.ts b/packages/plugin-eslint/src/lib/runner/lint.ts index 760af3033..dca1c43a2 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.ts @@ -13,6 +13,14 @@ import type { ESLintTarget } from '../config.js'; import { setupESLint } from '../setup.js'; import type { LinterOutput, RuleOptionsPerFile } from './types.js'; +/** + * Regex pattern to match ESLint report filename format with OS-agnostic path separators. + * Matches: eslint-report.json or eslint-report-{number}.json + * Handles both forward slashes (/) and backslashes (\) for cross-platform compatibility + */ +export const ESLINT_REPORT_FILENAME_PATTERN = + /--output-file="\.code-pushup[/\\]eslint[/\\]eslint-report(?:-\d+)?\.json"/; // eslint-disable-line unicorn/better-regex + export async function lint({ eslintrc, patterns, 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 bcf5fbf66..faed13b07 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts @@ -2,16 +2,7 @@ import { ESLint, type Linter } from 'eslint'; import { expect } from 'vitest'; import { executeProcess } from '@code-pushup/utils'; import type { ESLintPluginConfig } from '../config.js'; -import { lint } from './lint.js'; - -/** - * Regex pattern to match ESLint report filename format. - * Matches: eslint-report.json or eslint-report-{number}.json - * - No number: eslint-report.json (no dash) - * - With number: eslint-report-123.json (with dash and digits) - */ -const ESLINT_REPORT_FILENAME_PATTERN = - /--output-file="\.code-pushup\/eslint\/eslint-report(?:-\d+)?\.json"/; +import { ESLINT_REPORT_FILENAME_PATTERN, lint } from './lint.js'; class MockESLint { calculateConfigForFile = vi.fn().mockImplementation( From 330e480e63bf83096ee3446065e5089487e6e3bf Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Thu, 21 Aug 2025 22:41:43 +0200 Subject: [PATCH 30/30] fix: add regex for eslint report --- packages/plugin-eslint/src/lib/runner/lint.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/plugin-eslint/src/lib/runner/lint.ts b/packages/plugin-eslint/src/lib/runner/lint.ts index dca1c43a2..9375d0869 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.ts @@ -14,12 +14,10 @@ import { setupESLint } from '../setup.js'; import type { LinterOutput, RuleOptionsPerFile } from './types.js'; /** - * Regex pattern to match ESLint report filename format with OS-agnostic path separators. + * Regex pattern to match ESLint report filename format. * Matches: eslint-report.json or eslint-report-{number}.json - * Handles both forward slashes (/) and backslashes (\) for cross-platform compatibility */ -export const ESLINT_REPORT_FILENAME_PATTERN = - /--output-file="\.code-pushup[/\\]eslint[/\\]eslint-report(?:-\d+)?\.json"/; // eslint-disable-line unicorn/better-regex +export const ESLINT_REPORT_FILENAME_PATTERN = /eslint-report(?:-\d+)?\.json"/; export async function lint({ eslintrc,