diff --git a/code-pushup.preset.ts b/code-pushup.preset.ts index 5d9200904..34fb85c10 100644 --- a/code-pushup.preset.ts +++ b/code-pushup.preset.ts @@ -177,7 +177,13 @@ export const eslintCoreConfigNx = async ( eslintrc: `packages/${projectName}/eslint.config.js`, patterns: ['.'], }) - : await eslintPlugin(await eslintConfigFromAllNxProjects()), + : await eslintPlugin(await eslintConfigFromAllNxProjects(), { + artifacts: { + // We leverage Nx dependsOn to only run all lint targets before we run code-pushup + // generateArtifactsCommand: 'npx nx run-many -t lint', + artifactsPaths: ['packages/**/.eslint/eslint-report.json'], + }, + }), ], categories: eslintCategories, }); diff --git a/nx.json b/nx.json index 854a8c736..e86940814 100644 --- a/nx.json +++ b/nx.json @@ -49,12 +49,40 @@ } ], "sharedGlobals": [ - { "runtime": "node -e \"console.log(require('os').platform())\"" }, - { "runtime": "node -v" }, - { "runtime": "npm -v" } + { + "runtime": "node -e \"console.log(require('os').platform())\"" + }, + { + "runtime": "node -v" + }, + { + "runtime": "npm -v" + } ] }, "targetDefaults": { + "lint": { + "dependsOn": ["eslint-formatter-multi:build"], + "inputs": ["lint-eslint-inputs"], + "outputs": ["{projectRoot}/.eslint/**/*"], + "cache": true, + "executor": "nx:run-commands", + "options": { + "command": "eslint", + "args": [ + "{projectRoot}/**/*.ts", + "{projectRoot}/package.json", + "--config={projectRoot}/eslint.config.js", + "--max-warnings=0", + "--no-warn-ignored", + "--error-on-unmatched-pattern=false", + "--format=./tools/eslint-formatter-multi/dist/src/index.js" + ], + "env": { + "ESLINT_FORMATTER_CONFIG": "{\"outputDir\":\"{projectRoot}/.eslint\"}" + } + } + }, "build": { "dependsOn": ["^build"], "inputs": ["production", "^production"], @@ -97,36 +125,6 @@ "inputs": ["default"], "cache": true }, - "lint": { - "inputs": ["lint-eslint-inputs"], - "executor": "@nx/eslint:lint", - "outputs": ["{options.outputFile}"], - "cache": true, - "options": { - "errorOnUnmatchedPattern": false, - "maxWarnings": 0, - "lintFilePatterns": [ - "{projectRoot}/**/*.ts", - "{projectRoot}/package.json" - ] - } - }, - "lint-report": { - "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" - ] - } - }, "nxv-pkg-install": { "parallelism": false }, diff --git a/packages/plugin-eslint/src/lib/runner/lint.ts b/packages/plugin-eslint/src/lib/runner/lint.ts index 80300ded3..b014738c1 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.ts @@ -25,7 +25,7 @@ async function executeLint({ patterns, }: ESLintTarget): Promise { // running as CLI because ESLint#lintFiles() runs out of memory - const { stdout } = await executeProcess({ + const { stdout, stderr, code } = await executeProcess({ command: 'npx', args: [ 'eslint', @@ -42,6 +42,12 @@ async function executeLint({ cwd: process.cwd(), }); + if (!stdout.trim()) { + throw new Error( + `ESLint produced empty output. Exit code: ${code}, STDERR: ${stderr}`, + ); + } + return JSON.parse(stdout) as ESLint.LintResult[]; } diff --git a/project.json b/project.json index fc7dfea0d..c53ed1a80 100644 --- a/project.json +++ b/project.json @@ -16,7 +16,14 @@ } ] }, - "code-pushup-eslint": {}, + "code-pushup-eslint": { + "dependsOn": [ + { + "target": "lint", + "projects": "*" + } + ] + }, "code-pushup-jsdocs": {}, "code-pushup-typescript": {}, "code-pushup": { diff --git a/tools/eslint-formatter-multi/README.md b/tools/eslint-formatter-multi/README.md new file mode 100644 index 000000000..2a4032a8d --- /dev/null +++ b/tools/eslint-formatter-multi/README.md @@ -0,0 +1,77 @@ +# ESLint Multi 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 + +```jsonc +{ + "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: 'stylish') + "verbose": true, // Optional: Enable verbose logging (default: false) +} +``` + +### Supported Formats + +The following ESLint formatters are supported: + +- `stylish` (default terminal output) +- `json` (default file output) +- Custom formatters (fallback to stylish formatting) + +## 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 JSON file +ESLINT_FORMATTER_CONFIG='{"formats":["json"],"terminal":"stylish"}' npx eslint . +# Creates: .eslint/eslint-report.json + terminal output + +# Custom directory with JSON format +ESLINT_FORMATTER_CONFIG='{"outputDir":"./reports","filename":"eslint-results","formats":["json"]}' npx eslint . +# Creates: reports/eslint-results.json +``` + +### Terminal Output Only + +```bash +# Only show terminal output, no files +ESLINT_FORMATTER_CONFIG='{"formats":[],"terminal":"stylish"}' npx eslint . + +# Different terminal format +ESLINT_FORMATTER_CONFIG='{"formats":[],"terminal":"stylish"}' 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-formatter-multi/eslint.config.js b/tools/eslint-formatter-multi/eslint.config.js new file mode 100644 index 000000000..29bda515b --- /dev/null +++ b/tools/eslint-formatter-multi/eslint.config.js @@ -0,0 +1,21 @@ +import tseslint from 'typescript-eslint'; +import baseConfig from '../../eslint.config.js'; + +export default tseslint.config( + ...baseConfig, + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': ['error'], + }, + }, +); diff --git a/tools/eslint-formatter-multi/package.json b/tools/eslint-formatter-multi/package.json new file mode 100644 index 000000000..6aaddcec0 --- /dev/null +++ b/tools/eslint-formatter-multi/package.json @@ -0,0 +1,41 @@ +{ + "name": "@code-pushup/eslint-formatter-multi", + "version": "0.0.1", + "private": false, + "type": "module", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "engines": { + "node": ">=17.0.0" + }, + "description": "ESLint formatter that supports multiple output formats and destinations simultaneously", + "author": "Michael Hladky", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/code-pushup/cli.git", + "directory": "tools/eslint-formatter-multi" + }, + "keywords": [ + "eslint", + "eslint-formatter", + "eslintformatter", + "multi-format", + "code-quality", + "linting" + ], + "files": [ + "src/*", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "dependencies": { + "ansis": "^3.3.0", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "eslint": "^8.0.0 || ^9.0.0" + } +} diff --git a/tools/eslint-formatter-multi/project.json b/tools/eslint-formatter-multi/project.json new file mode 100644 index 000000000..34fb74339 --- /dev/null +++ b/tools/eslint-formatter-multi/project.json @@ -0,0 +1,12 @@ +{ + "name": "eslint-formatter-multi", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "tools/eslint-formatter-multi/src", + "projectType": "library", + "tags": ["scope:tooling", "type:util"], + "targets": { + "lint": {}, + "unit-test": {}, + "build": {} + } +} diff --git a/tools/eslint-formatter-multi/src/index.ts b/tools/eslint-formatter-multi/src/index.ts new file mode 100644 index 000000000..6e372fa3e --- /dev/null +++ b/tools/eslint-formatter-multi/src/index.ts @@ -0,0 +1,3 @@ +export { default } from './lib/multiple-formats.js'; +export * from './lib/multiple-formats.js'; +export * from './lib/utils.js'; diff --git a/tools/eslint-formatter-multi/src/lib/multiple-formats.ts b/tools/eslint-formatter-multi/src/lib/multiple-formats.ts new file mode 100644 index 000000000..874a915a5 --- /dev/null +++ b/tools/eslint-formatter-multi/src/lib/multiple-formats.ts @@ -0,0 +1,83 @@ +import type { ESLint } from 'eslint'; +import * as process from 'node:process'; +import type { FormatterConfig } from './types.js'; +import { + type EslintFormat, + formatTerminalOutput, + findConfigFromEnv as getConfigFromEnv, + persistEslintReports, +} from './utils.js'; + +export const DEFAULT_OUTPUT_DIR = '.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< + Pick< + FormatterConfig, + 'outputDir' | 'filename' | 'formats' | 'terminal' | 'verbose' + > +> = { + 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 for terminal display + * + * @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: 'stylish') + * } + */ +export default async function multipleFormats( + results: ESLint.LintResult[], + _args?: unknown, +): Promise { + const config = { + ...DEFAULT_CONFIG, + ...getConfigFromEnv(process.env), + } satisfies FormatterConfig; + + const { + outputDir = DEFAULT_OUTPUT_DIR, + filename, + formats, + terminal, + verbose = false, + } = config; + + try { + await persistEslintReports(formats, results, { + outputDir, + filename, + verbose, + }); + } catch (error) { + if (verbose) { + console.error('Error writing ESLint reports:', error); + } + } + + return formatTerminalOutput(terminal, results); +} diff --git a/tools/eslint-formatter-multi/src/lib/stylish.ts b/tools/eslint-formatter-multi/src/lib/stylish.ts new file mode 100644 index 000000000..43834d3d0 --- /dev/null +++ b/tools/eslint-formatter-multi/src/lib/stylish.ts @@ -0,0 +1,129 @@ +/* COPY OF https://github.com/eslint/eslint/blob/a355a0e5b2e6a47cda099b31dc7d112cfb5c4315/lib/cli-engine/formatters/stylish.js */ + +/** + * @fileoverview Stylish reporter + * @author Sindre Sorhus + */ +import { bold, dim, red, reset, underline, yellow } from 'ansis'; +import type { ESLint } from 'eslint'; +import { stripVTControlCharacters } from 'node:util'; +import { textTable } from './text-table.js'; + +//------------------------------------------------------------------------------ +// Helpers +//------------------------------------------------------------------------------ + +/** + * Given a word and a count, append an s if count is not one. + * @param {string} word A word in its singular form. + * @param {number} count A number controlling whether word should be pluralized. + * @returns {string} The original word with an s on the end if count is not one. + */ +function pluralize(word: string, count: number): string { + return count === 1 ? word : `${word}s`; +} + +//------------------------------------------------------------------------------ +// Public Interface +//------------------------------------------------------------------------------ + +// eslint-disable-next-line max-lines-per-function +export function stylishFormatter(results: ESLint.LintResult[]): string { + // eslint-disable-next-line functional/no-let + let output = '\n', + errorCount = 0, + warningCount = 0, + fixableErrorCount = 0, + fixableWarningCount = 0, + summaryColor = 'yellow'; + + results.forEach(result => { + const messages = result.messages; + + if (messages.length === 0) { + return; + } + + errorCount += result.errorCount; + warningCount += result.warningCount; + fixableErrorCount += result.fixableErrorCount; + fixableWarningCount += result.fixableWarningCount; + + output += `${underline(result.filePath)}\n`; + + output += `${textTable( + messages.map(message => { + // eslint-disable-next-line functional/no-let + let messageType; + + if (message.fatal || message.severity === 2) { + messageType = red('error'); + summaryColor = 'red'; + } else { + messageType = yellow('warning'); + } + + return [ + '', + String(message.line || 0), + String(message.column || 0), + messageType, + message.message.replace(/([^ ])\.$/u, '$1'), + dim(message.ruleId || ''), + ]; + }), + { + align: ['', 'r', 'l'], + stringLength(str: string) { + return stripVTControlCharacters(str).length; + }, + }, + ) + .split('\n') + .map(el => + el.replace(/(\d+)\s+(\d+)/u, (m, p1, p2) => dim(`${p1}:${p2}`)), + ) + .join('\n')}\n\n`; + }); + + const total = errorCount + warningCount; + + if (total > 0) { + const colorFn = summaryColor === 'red' ? red : yellow; + output += bold( + colorFn( + [ + '\u2716 ', + total, + pluralize(' problem', total), + ' (', + errorCount, + pluralize(' error', errorCount), + ', ', + warningCount, + pluralize(' warning', warningCount), + ')\n', + ].join(''), + ), + ); + + if (fixableErrorCount > 0 || fixableWarningCount > 0) { + output += bold( + colorFn( + [ + ' ', + fixableErrorCount, + pluralize(' error', fixableErrorCount), + ' and ', + fixableWarningCount, + pluralize(' warning', fixableWarningCount), + ' potentially fixable with the `--fix` option.\n', + ].join(''), + ), + ); + } + } + + // Resets output color, for prevent change on top level + return total > 0 ? reset(output) : ''; +} diff --git a/tools/eslint-formatter-multi/src/lib/text-table.ts b/tools/eslint-formatter-multi/src/lib/text-table.ts new file mode 100644 index 000000000..58f0d65da --- /dev/null +++ b/tools/eslint-formatter-multi/src/lib/text-table.ts @@ -0,0 +1,78 @@ +/* COPY OF https://github.com/eslint/eslint/blob/a355a0e5b2e6a47cda099b31dc7d112cfb5c4315/lib/shared/text-table.js */ + +/** + * @fileoverview Optimized version of the `text-table` npm module to improve performance by replacing inefficient regex-based + * whitespace trimming with a modern built-in method. + * + * This modification addresses a performance issue reported in https://github.com/eslint/eslint/issues/18709 + * + * The `text-table` module is published under the MIT License. For the original source, refer to: + * https://www.npmjs.com/package/text-table. + */ + +/* + * + * This software is released under the MIT license: + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export type TextTableOptions = { + align?: (string | undefined)[]; + stringLength?: (str: string) => number; +}; + +export function textTable( + rows_: (string | number)[][], + opts: TextTableOptions = {}, +): string { + const hsep = ' '; + const align = opts.align ?? []; + const stringLength = opts.stringLength ?? ((str: string) => str.length); + + const sizes = rows_.reduce((acc: number[], row: (string | number)[]) => { + row.forEach((c: string | number, ix: number) => { + const n = stringLength(String(c)); + + if (!acc[ix] || n > acc[ix]) { + // eslint-disable-next-line functional/immutable-data,no-param-reassign + acc[ix] = n; + } + }); + return acc; + }, []); + + return rows_ + .map((row: (string | number)[]) => + row + .map((c: string | number, ix: number) => { + const cellStr = String(c); + const n = (sizes[ix] ?? 0) - stringLength(cellStr) || 0; + const s = Array.from({ length: Math.max(n + 1, 1) }).join(' '); + + if (align[ix] === 'r') { + return s + cellStr; + } + + return cellStr + s; + }) + .join(hsep) + .trimEnd(), + ) + .join('\n'); +} diff --git a/tools/eslint-formatter-multi/src/lib/types.ts b/tools/eslint-formatter-multi/src/lib/types.ts new file mode 100644 index 000000000..d878163f6 --- /dev/null +++ b/tools/eslint-formatter-multi/src/lib/types.ts @@ -0,0 +1,8 @@ +import type { EslintFormat, PersistConfig } from './utils.js'; + +export type FormatterConfig = Omit & { + projectsDir?: string; // e.g. 'apps' or 'packages' to make paths relative to these folders + projectName?: string; // e.g. 'utils' or 'models' also auto-derived for Nx environment variables + formats?: EslintFormat[]; + terminal?: EslintFormat; +}; diff --git a/tools/eslint-formatter-multi/src/lib/utils.ts b/tools/eslint-formatter-multi/src/lib/utils.ts new file mode 100644 index 000000000..ffe64e603 --- /dev/null +++ b/tools/eslint-formatter-multi/src/lib/utils.ts @@ -0,0 +1,129 @@ +import type { ESLint } from 'eslint'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { stylishFormatter } from './stylish.js'; +import type { FormatterConfig } from './types.js'; + +export function stringifyError(error: unknown): string { + if (error instanceof Error) { + if (error.name === 'Error' || error.message.startsWith(error.name)) { + return error.message; + } + return `${error.name}: ${error.message}`; + } + if (typeof error === 'string') { + return error; + } + return JSON.stringify(error); +} + +export function getExtensionForFormat(format: EslintFormat): string { + const extensionMap: Record = { + json: 'json', + stylish: 'txt', + }; + + return extensionMap[format] || 'txt'; +} + +export function findConfigFromEnv( + env: NodeJS.ProcessEnv, +): FormatterConfig | null { + const configString = env['ESLINT_FORMATTER_CONFIG']; + + if (!configString || configString.trim() === '') { + return null; + } + + try { + return JSON.parse(configString ?? '{}') as FormatterConfig; + } catch (error) { + console.error( + 'Error parsing ESLINT_FORMATTER_CONFIG environment variable:', + stringifyError(error), + ); + return null; + } +} + +function formatJson(results: ESLint.LintResult[]): string { + return JSON.stringify(results, null, 2); +} + +export function formatContent( + results: ESLint.LintResult[], + format: EslintFormat, +): string { + switch (format) { + case 'json': + return formatJson(results); + case 'stylish': + return stylishFormatter(results); + default: + return stylishFormatter(results); + } +} + +export function formatTerminalOutput( + format: EslintFormat | undefined, + results: ESLint.LintResult[], +): string { + if (!format) { + return ''; + } + return formatContent(results, format); +} + +export type EslintFormat = 'stylish' | 'json' | string; + +export type PersistConfig = { + outputDir: string; // e.g. './.eslint' to make paths relative to this folder + filename: string; + format: EslintFormat; + verbose: boolean; +}; + +export async function persistEslintReport( + results: ESLint.LintResult[], + options: PersistConfig, +): Promise { + const { outputDir, filename, format, verbose = false } = options; + try { + await mkdir(outputDir, { recursive: true }); + await writeFile( + path.join(outputDir, `${filename}.${getExtensionForFormat(format)}`), + formatContent(results, format), + ); + if (verbose) { + console.info(`ESLint report (${format}) written to: ${outputDir}`); + } + return true; + } catch (error) { + if (verbose) { + console.error( + 'There was a problem writing the output file:\n%s', + stringifyError(error), + ); + } + return false; + } +} + +export async function persistEslintReports( + formats: EslintFormat[], + results: ESLint.LintResult[], + options: Omit, +): Promise { + const { outputDir, filename, verbose } = options; + + await Promise.all( + formats.map(format => + persistEslintReport(results, { + outputDir, + filename, + format, + verbose, + }), + ), + ); +} diff --git a/tools/eslint-formatter-multi/src/lib/utils.unit.test.ts b/tools/eslint-formatter-multi/src/lib/utils.unit.test.ts new file mode 100644 index 000000000..effd6d8d4 --- /dev/null +++ b/tools/eslint-formatter-multi/src/lib/utils.unit.test.ts @@ -0,0 +1,150 @@ +import type { ESLint } from 'eslint'; +import { describe, expect, it, vi } from 'vitest'; +import { removeColorCodes } from '@code-pushup/test-utils'; +import { + formatContent, + formatTerminalOutput, + findConfigFromEnv as getConfigFromEnv, + getExtensionForFormat, +} from './utils.js'; + +describe('getExtensionForFormat', () => { + it.each([ + ['json', 'json'], + ['stylish', 'txt'], + ['unknown', 'txt'], + ['', 'txt'], + ])('should return json extension for json format', (format, ext) => { + expect(getExtensionForFormat(format)).toBe(ext); + }); +}); + +describe('getConfigFromEnv', () => { + it('should return null when ESLINT_FORMATTER_CONFIG is not set', () => { + const env = {}; + expect(getConfigFromEnv(env)).toBeNull(); + }); + + it('should return null when ESLINT_FORMATTER_CONFIG is empty string', () => { + const env = { ESLINT_FORMATTER_CONFIG: '' }; + expect(getConfigFromEnv(env)).toBeNull(); + }); + + it('should return null when ESLINT_FORMATTER_CONFIG is whitespace only', () => { + const env = { ESLINT_FORMATTER_CONFIG: ' ' }; + expect(getConfigFromEnv(env)).toBeNull(); + }); + + it('should parse valid JSON configuration', () => { + const config = { + outputDir: './reports', + filename: 'lint-results', + formats: ['json', 'stylish'], + terminal: 'stylish', + }; + const env = { ESLINT_FORMATTER_CONFIG: JSON.stringify(config) }; + + expect(getConfigFromEnv(env)).toEqual(config); + }); + + it('should return null and log error for invalid JSON', () => { + const env = { ESLINT_FORMATTER_CONFIG: '{ invalid json }' }; + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(getConfigFromEnv(env)).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Error parsing ESLINT_FORMATTER_CONFIG environment variable:', + expect.any(String), + ); + }); +}); + +describe('formatContent', () => { + const mockResults: ESLint.LintResult[] = [ + { + filePath: '/test/file.js', + messages: [ + { + line: 1, + column: 1, + message: 'Test error', + severity: 2, + ruleId: 'test-rule', + }, + ], + errorCount: 1, + warningCount: 0, + fixableErrorCount: 0, + fixableWarningCount: 0, + fatalErrorCount: 0, + suppressedMessages: [], + usedDeprecatedRules: [], + }, + ]; + + it('should format results as JSON when format is json', () => { + const result = formatContent(mockResults, 'json'); + expect(result).toMatchInlineSnapshot(` + "[ + { + "filePath": "/test/file.js", + "messages": [ + { + "line": 1, + "column": 1, + "message": "Test error", + "severity": 2, + "ruleId": "test-rule" + } + ], + "errorCount": 1, + "warningCount": 0, + "fixableErrorCount": 0, + "fixableWarningCount": 0, + "fatalErrorCount": 0, + "suppressedMessages": [], + "usedDeprecatedRules": [] + } + ]" + `); + }); + + it('should use stylish formatter when format is stylish', () => { + const result = formatContent(mockResults, 'stylish'); + + expect(removeColorCodes(result)).toMatchInlineSnapshot(` + " + /test/file.js + 1:1 error Test error test-rule + + ✖ 1 problem (1 error, 0 warnings) + " + `); + }); + + it('should default to stylish formatter for unknown formats', () => { + const result = formatContent(mockResults, 'unknown' as any); + + expect(removeColorCodes(result)).toMatchInlineSnapshot(` + " + /test/file.js + 1:1 error Test error test-rule + + ✖ 1 problem (1 error, 0 warnings) + " + `); + }); +}); + +describe('formatTerminalOutput', () => { + const mockResults: ESLint.LintResult[] = []; + + it('should return empty string when format is undefined', () => { + expect(formatTerminalOutput(undefined, mockResults)).toBe(''); + }); + + it('should call formatContent when format is provided', () => { + const result = formatTerminalOutput('json', mockResults); + expect(result).toMatchInlineSnapshot(`"[]"`); + }); +}); diff --git a/tools/eslint-formatter-multi/tsconfig.json b/tools/eslint-formatter-multi/tsconfig.json new file mode 100644 index 000000000..6914db489 --- /dev/null +++ b/tools/eslint-formatter-multi/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/tools/eslint-formatter-multi/tsconfig.lib.json b/tools/eslint-formatter-multi/tsconfig.lib.json new file mode 100644 index 000000000..ea5a20dd3 --- /dev/null +++ b/tools/eslint-formatter-multi/tsconfig.lib.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node", "vite/client"] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx" + ] +} diff --git a/tools/eslint-formatter-multi/tsconfig.spec.json b/tools/eslint-formatter-multi/tsconfig.spec.json new file mode 100644 index 000000000..827403667 --- /dev/null +++ b/tools/eslint-formatter-multi/tsconfig.spec.json @@ -0,0 +1,29 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "vitest.unit.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/tools/eslint-formatter-multi/vitest.unit.config.ts b/tools/eslint-formatter-multi/vitest.unit.config.ts new file mode 100644 index 000000000..d6e409500 --- /dev/null +++ b/tools/eslint-formatter-multi/vitest.unit.config.ts @@ -0,0 +1,32 @@ +/// +import { defineConfig } from 'vite'; +// eslint-disable-next-line import/no-useless-path-segments +import { tsconfigPathAliases } from '../../tools/vitest-tsconfig-path-aliases.js'; + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/plugin-eslint', + test: { + reporters: ['basic'], + globals: true, + cache: { + dir: '../../node_modules/.vitest', + }, + alias: tsconfigPathAliases(), + pool: 'threads', + poolOptions: { threads: { singleThread: true } }, + coverage: { + reporter: ['text', 'lcov'], + reportsDirectory: '../../coverage/plugin-eslint/unit-tests', + exclude: ['mocks/**', '**/types.ts'], + }, + environment: 'node', + include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + exclude: ['src/index.ts'], + globalSetup: ['../../global-setup.ts'], + setupFiles: [ + '../../testing/test-setup/src/lib/console.mock.ts', + '../../testing/test-setup/src/lib/fs.mock.ts', + '../../testing/test-setup/src/lib/reset.mocks.ts', + ], + }, +}); diff --git a/tools/tsconfig.tools.json b/tools/tsconfig.tools.json index 75c41a56a..387fcedf7 100644 --- a/tools/tsconfig.tools.json +++ b/tools/tsconfig.tools.json @@ -8,5 +8,6 @@ "types": ["node"], "importHelpers": false }, - "include": ["**/*.ts"] + "include": ["**/*.ts"], + "exclude": ["eslint-formatter-multi/**/*"] } diff --git a/tsconfig.base.json b/tsconfig.base.json index b183db5a3..7f3b4d21f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -24,6 +24,9 @@ "@code-pushup/cli": ["packages/cli/src/index.ts"], "@code-pushup/core": ["packages/core/src/index.ts"], "@code-pushup/coverage-plugin": ["packages/plugin-coverage/src/index.ts"], + "@code-pushup/eslint-formatter-multi": [ + "tools/eslint-formatter-multi/src/index.ts" + ], "@code-pushup/eslint-plugin": ["packages/plugin-eslint/src/index.ts"], "@code-pushup/js-packages-plugin": [ "packages/plugin-js-packages/src/index.ts"