diff --git a/nx.json b/nx.json index 2b481c774..cb109d498 100644 --- a/nx.json +++ b/nx.json @@ -40,9 +40,7 @@ } } }, - "e2e": { - "dependsOn": ["^build"] - }, + "e2e": { "dependsOn": ["^build"] }, "lint": { "inputs": ["default", "{workspaceRoot}/eslint.config.?(c)js"], "executor": "@nx/linter:eslint", diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 0a0d2c71d..7e64d5403 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -34,6 +34,7 @@ export { commitSchema, type Commit } from './lib/commit.js'; export { artifactGenerationCommandSchema, pluginArtifactOptionsSchema, + type PluginArtifactOptions, } from './lib/configuration.js'; export { coreConfigSchema, type CoreConfig } from './lib/core-config.js'; export { @@ -62,6 +63,7 @@ export { export { fileNameSchema, filePathSchema, + globPathSchema, materialIconSchema, scoreSchema, slugSchema, diff --git a/packages/models/src/lib/configuration.ts b/packages/models/src/lib/configuration.ts index e3512d2f5..7151e2ceb 100644 --- a/packages/models/src/lib/configuration.ts +++ b/packages/models/src/lib/configuration.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { globPathSchema } from './implementation/schemas.js'; /** * Generic schema for a tool command configuration, reusable across plugins. @@ -13,7 +14,9 @@ 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([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 735952511..522de6074 100644 --- a/packages/models/src/lib/implementation/schemas.ts +++ b/packages/models/src/lib/implementation/schemas.ts @@ -138,6 +138,25 @@ 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 = /^!?[^<>"|]+$/; + +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( + 'Schema for a glob pattern (supports wildcards like *, **, {}, !, etc.)', + ); + /** Schema for a fileNameSchema */ export const fileNameSchema = z .string() diff --git a/packages/models/src/lib/implementation/schemas.unit.test.ts b/packages/models/src/lib/implementation/schemas.unit.test.ts index a72640350..818797d82 100644 --- a/packages/models/src/lib/implementation/schemas.unit.test.ts +++ b/packages/models/src/lib/implementation/schemas.unit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { type TableCellValue, docsUrlSchema, + globPathSchema, tableCellValueSchema, weightSchema, } from './schemas.js'; @@ -66,3 +67,23 @@ describe('docsUrlSchema', () => { ); }); }); + +describe('globPathSchema', () => { + it.each([ + '**/*.ts', + 'src/components/*.jsx', + '{src,lib,test}/**/*.js', + '!node_modules/**', + ])('should accept a valid glob pattern: %s', pattern => { + expect(() => globPathSchema.parse(pattern)).not.toThrow(); + }); + + it.each(['pathfile.js', 'path"file.js', 'path|file.js'])( + 'should throw for invalid path with forbidden character: %s', + pattern => { + expect(() => globPathSchema.parse(pattern)).toThrow( + 'valid file path or glob pattern', + ); + }, + ); +}); diff --git a/packages/plugin-eslint/package.json b/packages/plugin-eslint/package.json index 9862687cf..3992516e2 100644 --- a/packages/plugin-eslint/package.json +++ b/packages/plugin-eslint/package.json @@ -38,6 +38,7 @@ }, "type": "module", "dependencies": { + "glob": "^11.0.0", "@code-pushup/utils": "0.76.0", "@code-pushup/models": "0.76.0", "yargs": "^17.7.2", diff --git a/packages/plugin-eslint/src/lib/runner/index.ts b/packages/plugin-eslint/src/lib/runner/index.ts index 7e5e8ee9d..0ae6afd3b 100644 --- a/packages/plugin-eslint/src/lib/runner/index.ts +++ b/packages/plugin-eslint/src/lib/runner/index.ts @@ -3,6 +3,8 @@ import path from 'node:path'; import type { Audit, AuditOutput, + AuditOutputs, + PluginArtifactOptions, RunnerConfig, RunnerFilesPaths, } from '@code-pushup/models'; @@ -18,6 +20,7 @@ import { 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, @@ -71,3 +74,34 @@ export async function createRunnerConfig( outputFile: runnerOutputPath, }; } + +export async function generateAuditOutputs(options: { + audits: Audit[]; + targets: ESLintTarget[]; + artifacts?: PluginArtifactOptions; +}): Promise { + const { audits, targets, artifacts } = options; + const config: ESLintPluginRunnerConfig = { + targets, + slugs: audits.map(audit => audit.slug), + }; + + ui().logger.log(`ESLint plugin executing ${targets.length} lint targets`); + + const linterOutputs = artifacts + ? await loadArtifacts(artifacts) + : await asyncSequential(targets, lint); + const lintResults = mergeLinterOutputs(linterOutputs); + const failedAudits = lintResultsToAudits(lintResults); + + return config.slugs.map( + (slug): AuditOutput => + failedAudits.find(audit => audit.slug === slug) ?? { + slug, + score: 1, + value: 0, + displayValue: 'passed', + details: { issues: [] }, + }, + ); +} 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..07e12f5c1 --- /dev/null +++ b/packages/plugin-eslint/src/lib/runner/index.unit.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { z } from 'zod'; +import type { + Audit, + AuditOutput, + pluginArtifactOptionsSchema, +} from '@code-pushup/models'; +import { ui } from '@code-pushup/utils'; +import type { ESLintTarget } from '../config.js'; +import { generateAuditOutputs } from './index.js'; +import * as lintModule from './lint.js'; +import type { LinterOutput } from './types.js'; +import * as utilsFileModule from './utils.js'; + +describe('generateAuditOutputs', () => { + const loadArtifactsSpy = vi.spyOn(utilsFileModule, 'loadArtifacts'); + const lintSpy = vi.spyOn(lintModule, 'lint'); + + const mockAudits: Audit[] = [ + { slug: 'max-lines', title: 'Max lines', description: 'Test' }, + { slug: 'no-unused-vars', title: 'No unused vars', description: 'Test' }, + ]; + const mockTargetPatterns = { patterns: ['src/**/*.ts'] }; + const mockTargetPatternsAndConfigs = { + patterns: ['lib/**/*.js'], + eslintrc: '.eslintrc.js', + }; + const mockTargets: ESLintTarget[] = [ + mockTargetPatterns, + mockTargetPatternsAndConfigs, + ]; + + const mockLinterOutputs: LinterOutput[] = [ + { + results: [ + { + filePath: 'test.ts', + messages: [ + { + ruleId: 'max-lines', + severity: 1, + message: 'File has too many lines', + line: 1, + column: 1, + }, + ], + } as any, + ], + ruleOptionsPerFile: { 'test.ts': { 'max-lines': [] } }, + }, + { + results: [ + { + filePath: 'test.ts', + messages: [ + { + ruleId: 'max-lines', + severity: 1, + message: 'File has too many lines', + line: 1, + column: 1, + }, + ], + } as any, + ], + ruleOptionsPerFile: { 'test.ts': { 'max-lines': [] } }, + }, + ]; + + const mockedAuditOutputs: AuditOutput[] = [ + { + slug: 'max-lines', + score: 0, + value: 2, + displayValue: '2 warnings', + details: { + issues: [ + { + message: 'File has too many lines', + severity: 'warning', + source: { + file: 'test.ts', + position: { + startLine: 1, + startColumn: 1, + }, + }, + }, + { + message: 'File has too many lines', + severity: 'warning', + source: { + file: 'test.ts', + position: { + startLine: 1, + startColumn: 1, + }, + }, + }, + ], + }, + }, + { + slug: 'no-unused-vars', + score: 1, + value: 0, + displayValue: 'passed', + details: { issues: [] }, + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should use loadArtifacts when artifacts are provided', async () => { + const artifacts: z.infer = { + artifactsPaths: ['path/to/artifacts.json'], + }; + loadArtifactsSpy.mockResolvedValue(mockLinterOutputs); + + await expect( + generateAuditOutputs({ + audits: mockAudits, + targets: mockTargets, + artifacts, + }), + ).resolves.toStrictEqual(mockedAuditOutputs); + + expect(loadArtifactsSpy).toHaveBeenCalledWith(artifacts); + expect(lintSpy).not.toHaveBeenCalled(); + expect(ui()).toHaveLogged('log', 'ESLint plugin executing 2 lint targets'); + }); + + it('should use internal linting logic when artifacts are not provided', async () => { + lintSpy.mockResolvedValueOnce(mockLinterOutputs.at(0)!); + lintSpy.mockResolvedValueOnce(mockLinterOutputs.at(0)!); + + await expect( + generateAuditOutputs({ + audits: mockAudits, + targets: mockTargets, + outputDir: 'custom-output', + }), + ).resolves.toStrictEqual(mockedAuditOutputs); + + expect(loadArtifactsSpy).not.toHaveBeenCalled(); + expect(lintSpy).toHaveBeenCalledTimes(2); + }); +}); 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..2e23354af --- /dev/null +++ b/packages/plugin-eslint/src/lib/runner/utils.ts @@ -0,0 +1,39 @@ +import type { ESLint } from 'eslint'; +import { glob } from 'glob'; +import type { PluginArtifactOptions } from '@code-pushup/models'; +import { executeProcess, readJsonFile } 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, + }); + } + + const initialArtifactPaths = Array.isArray(artifacts.artifactsPaths) + ? artifacts.artifactsPaths + : [artifacts.artifactsPaths]; + + const artifactPaths = await glob(initialArtifactPaths); + + 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 + }; + }), + ); +} 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..04b868a16 --- /dev/null +++ b/packages/plugin-eslint/src/lib/runner/utils.unit.test.ts @@ -0,0 +1,148 @@ +import type { ESLint } from 'eslint'; +import * as globModule from 'glob'; +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 globSpy = vi.spyOn(globModule, 'glob'); + 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 () => { + globSpy.mockResolvedValue([artifactsPaths.at(0)!]); + 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 () => { + globSpy.mockResolvedValue(artifactsPaths); + readJsonFileSpy + .mockResolvedValueOnce(mockRawResults1) + .mockResolvedValueOnce(mockRawResults2); + + await expect(loadArtifacts({ artifactsPaths })).resolves.toStrictEqual([ + expectedLinterOutput1, + expectedLinterOutput2, + ]); + + expect(globSpy).toHaveBeenCalledTimes(1); + expect(globSpy).toHaveBeenCalledWith(artifactsPaths); + expect(readJsonFileSpy).toHaveBeenCalledTimes(2); + expect(readJsonFileSpy).toHaveBeenNthCalledWith(1, artifactsPaths.at(0)); + expect(readJsonFileSpy).toHaveBeenNthCalledWith(2, artifactsPaths.at(1)); + expect(executeProcessSpy).not.toHaveBeenCalled(); + }); + + it('should load artifacts with generateArtifactsCommand as string', async () => { + globSpy.mockResolvedValue(artifactsPaths); + 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(globSpy).toHaveBeenCalledTimes(1); + expect(globSpy).toHaveBeenCalledWith(artifactsPaths); + }); + + it('should load artifacts with generateArtifactsCommand as object', async () => { + globSpy.mockResolvedValue(artifactsPaths); + 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(globSpy).toHaveBeenCalledTimes(1); + expect(globSpy).toHaveBeenCalledWith(artifactsPaths); + }); +});