diff --git a/packages/core/package.json b/packages/core/package.json index 13b13cd2c..f3abccc72 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,8 +41,7 @@ "dependencies": { "@code-pushup/models": "0.57.0", "@code-pushup/utils": "0.57.0", - "ansis": "^3.3.0", - "zod-validation-error": "^3.4.0" + "ansis": "^3.3.0" }, "peerDependencies": { "@code-pushup/portal-client": "^0.9.0" diff --git a/packages/core/src/lib/implementation/read-rc-file.integration.test.ts b/packages/core/src/lib/implementation/read-rc-file.integration.test.ts index 78cf90400..72702ae55 100644 --- a/packages/core/src/lib/implementation/read-rc-file.integration.test.ts +++ b/packages/core/src/lib/implementation/read-rc-file.integration.test.ts @@ -1,7 +1,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect } from 'vitest'; -import { ConfigValidationError, readRcByPath } from './read-rc-file.js'; +import { SchemaValidationError } from '@code-pushup/utils'; +import { readRcByPath } from './read-rc-file.js'; describe('readRcByPath', () => { const configDirPath = path.join( @@ -69,7 +70,7 @@ describe('readRcByPath', () => { it('should throw if the configuration is empty', async () => { await expect( readRcByPath(path.join(configDirPath, 'code-pushup.empty.config.js')), - ).rejects.toThrow(expect.any(ConfigValidationError)); + ).rejects.toThrow(expect.any(SchemaValidationError)); }); it('should throw if the configuration is invalid', async () => { diff --git a/packages/core/src/lib/implementation/read-rc-file.ts b/packages/core/src/lib/implementation/read-rc-file.ts index 3b1572c4c..5560efdc3 100644 --- a/packages/core/src/lib/implementation/read-rc-file.ts +++ b/packages/core/src/lib/implementation/read-rc-file.ts @@ -1,17 +1,11 @@ -import { bold } from 'ansis'; import path from 'node:path'; -import { fromError, isZodErrorLike } from 'zod-validation-error'; import { CONFIG_FILE_NAME, type CoreConfig, SUPPORTED_CONFIG_FILE_FORMATS, coreConfigSchema, } from '@code-pushup/models'; -import { - fileExists, - importModule, - zodErrorMessageBuilder, -} from '@code-pushup/utils'; +import { fileExists, importModule, parseSchema } from '@code-pushup/utils'; export class ConfigPathError extends Error { constructor(configPath: string) { @@ -19,13 +13,6 @@ export class ConfigPathError extends Error { } } -export class ConfigValidationError extends Error { - constructor(configPath: string, message: string) { - const relativePath = path.relative(process.cwd(), configPath); - super(`Failed parsing core config in ${bold(relativePath)}.\n\n${message}`); - } -} - export async function readRcByPath( filepath: string, tsconfig?: string, @@ -38,18 +25,16 @@ export async function readRcByPath( throw new ConfigPathError(filepath); } - const cfg = await importModule({ filepath, tsconfig, format: 'esm' }); + const cfg: CoreConfig = await importModule({ + filepath, + tsconfig, + format: 'esm', + }); - try { - return coreConfigSchema.parse(cfg); - } catch (error) { - const validationError = fromError(error, { - messageBuilder: zodErrorMessageBuilder, - }); - throw isZodErrorLike(error) - ? new ConfigValidationError(filepath, validationError.message) - : error; - } + return parseSchema(coreConfigSchema, cfg, { + schemaType: 'core config', + sourcePath: filepath, + }); } export async function autoloadRc(tsconfig?: string): Promise { diff --git a/packages/plugin-eslint/src/lib/eslint-plugin.integration.test.ts b/packages/plugin-eslint/src/lib/eslint-plugin.integration.test.ts index 9c53fcd7b..2367334e6 100644 --- a/packages/plugin-eslint/src/lib/eslint-plugin.integration.test.ts +++ b/packages/plugin-eslint/src/lib/eslint-plugin.integration.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; 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 { SchemaValidationError, toUnixPath } from '@code-pushup/utils'; import { eslintPlugin } from './eslint-plugin.js'; describe('eslintPlugin', () => { @@ -71,15 +71,12 @@ describe('eslintPlugin', () => { ); }); - it('should initialize with plugin options for custom rules', async () => { + it('should initialize with plugin options for custom groups', async () => { cwdSpy.mockReturnValue(path.join(fixturesDir, 'nx-monorepo')); const plugin = await eslintPlugin( { eslintrc: './packages/nx-plugin/eslint.config.js', - patterns: [ - 'packages/nx-plugin/**/*.ts', - 'packages/nx-plugin/**/*.json', - ], + patterns: ['packages/nx-plugin/**/*.ts'], }, { groups: [ @@ -114,11 +111,25 @@ describe('eslintPlugin', () => { ); }); + it('should throw when custom group rules are empty', async () => { + await expect(() => + eslintPlugin( + { + eslintrc: './packages/nx-plugin/eslint.config.js', + patterns: ['packages/nx-plugin/**/*.ts'], + }, + { + groups: [{ slug: 'type-safety', title: 'Type safety', rules: [] }], + }, + ), + ).rejects.toThrow(expect.any(SchemaValidationError)); + }); + it('should throw when invalid parameters provided', async () => { await expect( // @ts-expect-error simulating invalid non-TS config eslintPlugin({ eslintrc: '.eslintrc.json' }), - ).rejects.toThrow('patterns'); + ).rejects.toThrow(expect.any(SchemaValidationError)); }); it("should throw if eslintrc file doesn't exist", async () => { diff --git a/packages/plugin-eslint/src/lib/eslint-plugin.ts b/packages/plugin-eslint/src/lib/eslint-plugin.ts index f55514fdd..799e44886 100644 --- a/packages/plugin-eslint/src/lib/eslint-plugin.ts +++ b/packages/plugin-eslint/src/lib/eslint-plugin.ts @@ -2,6 +2,7 @@ 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 { type ESLintPluginConfig, type ESLintPluginOptions, @@ -36,19 +37,23 @@ export async function eslintPlugin( config: ESLintPluginConfig, options?: ESLintPluginOptions, ): Promise { - const targets = eslintPluginConfigSchema.parse(config); + const configPath = fileURLToPath(path.dirname(import.meta.url)); + + const targets = parseSchema(eslintPluginConfigSchema, config, { + schemaType: 'ESLint plugin config', + sourcePath: configPath, + }); const customGroups = options - ? eslintPluginOptionsSchema.parse(options).groups + ? parseSchema(eslintPluginOptionsSchema, options, { + schemaType: 'ESLint plugin options', + sourcePath: configPath, + }).groups : undefined; const { audits, groups } = await listAuditsAndGroups(targets, customGroups); - const runnerScriptPath = path.join( - fileURLToPath(path.dirname(import.meta.url)), - '..', - 'bin.js', - ); + const runnerScriptPath = path.join(configPath, '..', 'bin.js'); const packageJson = createRequire(import.meta.url)( '../../package.json', diff --git a/packages/utils/package.json b/packages/utils/package.json index c39dc1903..a7cedaa57 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -37,6 +37,7 @@ "multi-progress-bars": "^5.0.3", "semver": "^7.6.0", "simple-git": "^3.20.0", + "zod": "^3.23.8", "zod-validation-error": "^3.4.0" } } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 58dd0dbe6..7c8fd8556 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -123,4 +123,4 @@ export type { WithRequired, } from './lib/types.js'; export { verboseUtils } from './lib/verbose-utils.js'; -export { zodErrorMessageBuilder } from './lib/zod-validation.js'; +export { parseSchema, SchemaValidationError } from './lib/zod-validation.js'; diff --git a/packages/utils/src/lib/zod-validation.ts b/packages/utils/src/lib/zod-validation.ts index ae7813634..3db0a592a 100644 --- a/packages/utils/src/lib/zod-validation.ts +++ b/packages/utils/src/lib/zod-validation.ts @@ -1,5 +1,28 @@ import { bold, red } from 'ansis'; -import type { MessageBuilder } from 'zod-validation-error'; +import path from 'node:path'; +import type { z } from 'zod'; +import { + type MessageBuilder, + fromError, + isZodErrorLike, +} from 'zod-validation-error'; + +type SchemaValidationContext = { + schemaType: string; + sourcePath: string; +}; + +export class SchemaValidationError extends Error { + constructor(configType: string, configPath: string, error: Error) { + const validationError = fromError(error, { + messageBuilder: zodErrorMessageBuilder, + }); + const relativePath = path.relative(process.cwd(), configPath); + super( + `Failed parsing ${configType} in ${bold(relativePath)}.\n\n${validationError.message}`, + ); + } +} export function formatErrorPath(errorPath: (string | number)[]): string { return errorPath @@ -12,7 +35,7 @@ export function formatErrorPath(errorPath: (string | number)[]): string { .join(''); } -export const zodErrorMessageBuilder: MessageBuilder = issues => +const zodErrorMessageBuilder: MessageBuilder = issues => issues .map(issue => { const formattedMessage = red(`${bold(issue.code)}: ${issue.message}`); @@ -23,3 +46,18 @@ export const zodErrorMessageBuilder: MessageBuilder = issues => return `${formattedMessage}\n`; }) .join('\n'); + +export function parseSchema( + schema: T, + data: z.input, + { schemaType, sourcePath }: SchemaValidationContext, +): z.output { + try { + return schema.parse(data); + } catch (error) { + if (isZodErrorLike(error)) { + throw new SchemaValidationError(schemaType, sourcePath, error); + } + throw error; + } +}