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 diff --git a/code-pushup.preset.ts b/code-pushup.preset.ts index af7633ea9..2a81d5d72 100644 --- a/code-pushup.preset.ts +++ b/code-pushup.preset.ts @@ -10,6 +10,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 +158,17 @@ export const jsDocsCoreConfig = ( ), }); +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 +177,21 @@ export const eslintCoreConfigNx = async ( await (projectName ? eslintConfigFromNxProject(projectName) : eslintConfigFromAllNxProjects()), + { + artifacts: { + generateArtifactsCommand: { + command: 'npx', + args: [ + 'nx', + 'run-many', + '-t', + 'lint-reporter', + '--projects=tag:publishable', + ], + }, + artifactsPaths: 'packages/*/.code-pushup/eslint/eslint-report.json', + }, + }, ), ], categories: eslintCategories, diff --git a/nx.json b/nx.json index 99e48af53..9f21661f8 100644 --- a/nx.json +++ b/nx.json @@ -46,9 +46,9 @@ "lint": { "inputs": ["default", "{workspaceRoot}/eslint.config.?(c)js"], "executor": "@nx/linter:eslint", - "outputs": ["{options.outputFile}"], "cache": true, "options": { + "errorOnUnmatchedPattern": false, "maxWarnings": 0, "lintFilePatterns": [ "{projectRoot}/**/*.ts", @@ -56,6 +56,38 @@ ] } }, + "lint-reporter": { + "inputs": ["default", "{workspaceRoot}/eslint.config.?(c)js"], + "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": { + "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": { "parallelism": false }, 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/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/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/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/docs/models-reference.md b/packages/models/docs/models-reference.md index cad1b40d4..5be544931 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:_ 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/models/src/index.ts b/packages/models/src/index.ts index a305ea673..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'; @@ -145,4 +146,5 @@ export { uploadConfigSchema, type UploadConfig } from './lib/upload-config.js'; export { artifactGenerationCommandSchema, pluginArtifactOptionsSchema, + type PluginArtifactOptions, } from './lib/configuration.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() 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/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/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). diff --git a/packages/plugin-eslint/package.json b/packages/plugin-eslint/package.json index c06d1a1c1..6cb06c774 100644 --- a/packages/plugin-eslint/package.json +++ b/packages/plugin-eslint/package.json @@ -40,12 +40,12 @@ "dependencies": { "@code-pushup/utils": "0.74.1", "@code-pushup/models": "0.74.1", - "yargs": "^17.7.2", "zod": "^4.0.5" }, "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": { 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/bin.ts b/packages/plugin-eslint/src/bin.ts deleted file mode 100644 index fc625dd73..000000000 --- a/packages/plugin-eslint/src/bin.ts +++ /dev/null @@ -1,7 +0,0 @@ -import process from 'node:process'; -import { Parser } from 'yargs/helpers'; -import { executeRunner } from './lib/runner/index.js'; - -const { runnerConfigPath, runnerOutputPath } = Parser(process.argv); - -await executeRunner({ runnerConfigPath, runnerOutputPath }); diff --git a/packages/plugin-eslint/src/lib/__snapshots__/eslint-plugin.int.test.ts.snap b/packages/plugin-eslint/src/lib/__snapshots__/eslint-plugin.int.test.ts.snap index 388229cba..957e81e7b 100644 --- a/packages/plugin-eslint/src/lib/__snapshots__/eslint-plugin.int.test.ts.snap +++ b/packages/plugin-eslint/src/lib/__snapshots__/eslint-plugin.int.test.ts.snap @@ -346,16 +346,7 @@ exports[`eslintPlugin > should initialize ESLint plugin for React application 1` ], "icon": "eslint", "packageName": "@code-pushup/eslint-plugin", - "runner": { - "args": [ - ""/bin.js"", - "--runnerConfigPath="node_modules/.code-pushup/eslint//plugin-config.json"", - "--runnerOutputPath="node_modules/.code-pushup/eslint//runner-output.json"", - ], - "command": "node", - "configFile": "node_modules/.code-pushup/eslint//plugin-config.json", - "outputFile": "node_modules/.code-pushup/eslint//runner-output.json", - }, + "runner": [Function], "slug": "eslint", "title": "ESLint", "version": Any, diff --git a/packages/plugin-eslint/src/lib/config.ts b/packages/plugin-eslint/src/lib/config.ts index 5f7cabeee..6a539b502 100644 --- a/packages/plugin-eslint/src/lib/config.ts +++ b/packages/plugin-eslint/src/lib/config.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { pluginArtifactOptionsSchema } from '@code-pushup/models'; import { toArray } from '@code-pushup/utils'; const patternsSchema = z @@ -61,5 +62,6 @@ export type CustomGroup = z.infer; export const eslintPluginOptionsSchema = z.object({ groups: z.array(customGroupSchema).optional(), + artifacts: pluginArtifactOptionsSchema.optional(), }); export type ESLintPluginOptions = z.infer; diff --git a/packages/plugin-eslint/src/lib/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.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/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/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/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; +} 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 cd97a78bd..000000000 --- a/packages/plugin-eslint/src/lib/runner.int.test.ts +++ /dev/null @@ -1,99 +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); - - 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); - - 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 7e5e8ee9d..9f400670e 100644 --- a/packages/plugin-eslint/src/lib/runner/index.ts +++ b/packages/plugin-eslint/src/lib/runner/index.ts @@ -1,73 +1,52 @@ -import { writeFile } from 'node:fs/promises'; -import path from 'node:path'; import type { Audit, AuditOutput, - RunnerConfig, - RunnerFilesPaths, + AuditOutputs, + PersistConfig, + PluginArtifactOptions, + RunnerFunction, } from '@code-pushup/models'; -import { - asyncSequential, - createRunnerFiles, - ensureDirectoryExists, - filePathToCliArg, - objectToCliArgs, - readJsonFile, - ui, -} from '@code-pushup/utils'; +import { asyncSequential, ui } from '@code-pushup/utils'; import type { ESLintPluginRunnerConfig, ESLintTarget } from '../config.js'; import { lint } from './lint.js'; import { lintResultsToAudits, mergeLinterOutputs } from './transform.js'; +import { loadArtifacts } from './utils.js'; -export async function executeRunner({ - runnerConfigPath, - runnerOutputPath, -}: RunnerFilesPaths): Promise { - const { slugs, targets } = - await readJsonFile(runnerConfigPath); - - ui().logger.log(`ESLint plugin executing ${targets.length} lint targets`); - - const linterOutputs = await asyncSequential(targets, lint); - const lintResults = mergeLinterOutputs(linterOutputs); - const failedAudits = lintResultsToAudits(lintResults); - - const audits = slugs.map( - (slug): AuditOutput => - failedAudits.find(audit => audit.slug === slug) ?? { - slug, - score: 1, - value: 0, - displayValue: 'passed', - details: { issues: [] }, - }, - ); - - await ensureDirectoryExists(path.dirname(runnerOutputPath)); - await writeFile(runnerOutputPath, JSON.stringify(audits)); -} - -export async function createRunnerConfig( - scriptPath: string, - audits: Audit[], - targets: ESLintTarget[], -): Promise { +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), }; - const { runnerConfigPath, runnerOutputPath } = await createRunnerFiles( - 'eslint', - JSON.stringify(config), - ); - return { - command: 'node', - args: [ - filePathToCliArg(scriptPath), - ...objectToCliArgs({ runnerConfigPath, runnerOutputPath }), - ], - configFile: runnerConfigPath, - outputFile: runnerOutputPath, + return async ({ outputDir }: PersistConfig): Promise => { + ui().logger.log(`ESLint plugin executing ${targets.length} lint targets`); + + const linterOutputs = artifacts + ? await loadArtifacts(artifacts) + : await asyncSequential( + targets.map(target => ({ + ...target, + outputDir, + })), + lint, + ); + const lintResults = mergeLinterOutputs(linterOutputs); + const failedAudits = lintResultsToAudits(lintResults); + + return config.slugs.map( + (slug): AuditOutput => + failedAudits.find(audit => audit.slug === slug) ?? { + slug, + score: 1, + value: 0, + displayValue: 'passed', + details: { issues: [] }, + }, + ); }; } diff --git a/packages/plugin-eslint/src/lib/runner/index.unit.test.ts b/packages/plugin-eslint/src/lib/runner/index.unit.test.ts 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); + }); +}); diff --git a/packages/plugin-eslint/src/lib/runner/lint.ts b/packages/plugin-eslint/src/lib/runner/lint.ts index 80300ded3..9375d0869 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.ts @@ -1,31 +1,49 @@ 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'; +/** + * Regex pattern to match ESLint report filename format. + * Matches: eslint-report.json or eslint-report-{number}.json + */ +export const ESLINT_REPORT_FILENAME_PATTERN = /eslint-report(?:-\d+)?\.json"/; + 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 rotation = 0; + async function executeLint({ eslintrc, patterns, -}: ESLintTarget): Promise { + outputDir: providedOutputDir, +}: ESLintTarget & { outputDir?: string }): Promise { + 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 - const { stdout } = await executeProcess({ + await executeProcess({ command: 'npx', args: [ 'eslint', @@ -33,6 +51,7 @@ async function executeLint({ ...(typeof eslintrc === 'object' ? ['--no-eslintrc'] : []), '--no-error-on-unmatched-pattern', '--format=json', + `--output-file=${filePathToCliArg(outputFile)}`, ...toArray(patterns).map(pattern => // globs need to be escaped on Unix platform() === 'win32' ? pattern : `'${pattern}'`, @@ -42,7 +61,7 @@ async function executeLint({ cwd: process.cwd(), }); - return JSON.parse(stdout) as ESLint.LintResult[]; + 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 516e3501e..faed13b07 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts @@ -1,8 +1,8 @@ import { ESLint, type Linter } from 'eslint'; -import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { expect } from 'vitest'; import { executeProcess } from '@code-pushup/utils'; import type { ESLintPluginConfig } from '../config.js'; -import { lint } from './lint.js'; +import { ESLINT_REPORT_FILENAME_PATTERN, lint } from './lint.js'; class MockESLint { calculateConfigForFile = vi.fn().mockImplementation( @@ -38,7 +38,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 +85,7 @@ vi.mock('@code-pushup/utils', async () => { messages: [{ ruleId: 'max-lines' }], }, ] as ESLint.LintResult[]), - }), + })), }; }); @@ -76,7 +101,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,10 +133,11 @@ describe('lint', () => { '--config=".eslintrc.js"', '--no-error-on-unmatched-pattern', '--format=json', - expect.stringContaining('**/*.js'), // wraps in quotes on Unix + expect.stringMatching(ESLINT_REPORT_FILENAME_PATTERN), + expect.stringMatching(/^'?\*\*\/\*\.js'?$/), ], ignoreExitCode: true, - cwd: MEMFS_VOLUME, + cwd: '/test', }); expect(eslint.calculateConfigForFile).toHaveBeenCalledTimes(3); 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..3148bb5f2 --- /dev/null +++ b/packages/plugin-eslint/src/lib/runner/utils.ts @@ -0,0 +1,64 @@ +import type { ESLint } from 'eslint'; +import { glob } from 'glob'; +import type { PluginArtifactOptions } from '@code-pushup/models'; +import { + executeProcess, + pluralizeToken, + readJsonFile, + ui, +} 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 { + if (artifacts.generateArtifactsCommand) { + const { command, args = [] } = + typeof artifacts.generateArtifactsCommand === 'string' + ? { command: artifacts.generateArtifactsCommand } + : artifacts.generateArtifactsCommand; + + // Log the actual command that was executed + const commandString = + typeof artifacts.generateArtifactsCommand === 'string' + ? artifacts.generateArtifactsCommand + : `${command} ${args.join(' ')}`; + await ui().logger.log(`$ ${commandString}`); + await executeProcess({ + command, + args, + ignoreExitCode: true, + }); + } + + const initialArtifactPaths = Array.isArray(artifacts.artifactsPaths) + ? artifacts.artifactsPaths + : [artifacts.artifactsPaths]; + + const artifactPaths = await resolveGlobPatterns(initialArtifactPaths); + + ui().logger.log( + `ESLint plugin resolved ${initialArtifactPaths.length} ${pluralizeToken('pattern', initialArtifactPaths.length)} to ${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..39ef5eeeb --- /dev/null +++ b/packages/plugin-eslint/src/lib/runner/utils.unit.test.ts @@ -0,0 +1,156 @@ +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'); + + 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: [], + }, + ]; + + const expectedLinterOutput1: LinterOutput = { + results: mockRawResults1, + ruleOptionsPerFile: {}, + }; + + const expectedLinterOutput2: LinterOutput = { + results: mockRawResults2, + ruleOptionsPerFile: {}, + }; + + const artifactsPaths = ['/path/to/artifact1.json', '/path/to/artifact2.json']; + + beforeEach(() => { + 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)); + + expect(ui()).not.toHaveLogged('log', expect.stringMatching(/^\$ /)); + }); + + 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)); + + expect(ui()).not.toHaveLogged('log', expect.stringMatching(/^\$ /)); + }); + + 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, + }); + expect(ui()).toHaveLogged('log', `$ ${generateArtifactsCommand}`); + }); + + 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, + }); + expect(ui()).toHaveLogged( + 'log', + '$ nx run-many -t lint --parallel --max-parallel=5', + ); + }); +}); 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": {