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..b903ab41e 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,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect } from 'vitest'; -import { ConfigValidationError, readRcByPath } from './read-rc-file.js'; +import { readRcByPath } from './read-rc-file.js'; describe('readRcByPath', () => { const configDirPath = path.join( @@ -69,7 +69,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(/invalid_type/); }); 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/README.md b/packages/plugin-eslint/README.md index 83e9252de..75a45dc63 100644 --- a/packages/plugin-eslint/README.md +++ b/packages/plugin-eslint/README.md @@ -93,6 +93,42 @@ Detected ESLint rules are mapped to Code PushUp audits. Audit reports are calcul 5. Run the CLI with `npx code-pushup collect` and view or upload report (refer to [CLI docs](../cli/README.md)). +### Custom groups + +You can extend the plugin configuration with custom groups to categorize ESLint rules according to your project's specific needs. Custom groups allow you to assign weights to individual rules, influencing their impact on the report. Rules can be defined as an object with explicit weights or as an array where each rule defaults to a weight of 1. Additionally, you can use wildcard patterns (`*`) to include multiple rules with similar prefixes. + +```js +import eslintPlugin from '@code-pushup/eslint-plugin'; + +export default { + // ... + plugins: [ + // ... + await eslintPlugin( + { eslintrc: '.eslintrc.js', patterns: ['src/**/*.js'] }, + { + groups: [ + { + slug: 'modern-angular', + title: 'Modern Angular', + rules: { + '@angular-eslint/template/prefer-control-flow': 3, + '@angular-eslint/template/prefer-ngsrc': 2, + '@angular-eslint/component-selector': 1, + }, + }, + { + slug: 'type-safety', + title: 'Type safety', + rules: ['@typescript-eslint/no-unsafe-*'], + }, + ], + }, + ), + ], +}; +``` + ### 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/src/lib/config.ts b/packages/plugin-eslint/src/lib/config.ts index 2eb182437..4a3133d2c 100644 --- a/packages/plugin-eslint/src/lib/config.ts +++ b/packages/plugin-eslint/src/lib/config.ts @@ -33,3 +33,36 @@ export type ESLintPluginRunnerConfig = { targets: ESLintTarget[]; slugs: string[]; }; + +const customGroupRulesSchema = z.union( + [ + z + .array(z.string()) + .min(1, 'Custom group rules must contain at least 1 element'), + z.record(z.string(), z.number()).refine( + schema => Object.keys(schema).length > 0, + () => ({ + code: 'too_small', + message: 'Custom group rules must contain at least 1 element', + }), + ), + ], + { + description: + 'Array of rule IDs with equal weights or object mapping rule IDs to specific weights', + }, +); + +const customGroupSchema = z.object({ + slug: z.string({ description: 'Unique group identifier' }), + title: z.string({ description: 'Group display title' }), + description: z.string({ description: 'Group metadata' }).optional(), + docsUrl: z.string({ description: 'Group documentation site' }).optional(), + rules: customGroupRulesSchema, +}); +export type CustomGroup = z.infer; + +export const eslintPluginOptionsSchema = z.object({ + groups: z.array(customGroupSchema).optional(), +}); +export type ESLintPluginOptions = z.infer; 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 afb82191a..34acd8ed3 100644 --- a/packages/plugin-eslint/src/lib/eslint-plugin.integration.test.ts +++ b/packages/plugin-eslint/src/lib/eslint-plugin.integration.test.ts @@ -71,11 +71,76 @@ describe('eslintPlugin', () => { ); }); + 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'], + }, + { + groups: [ + { + slug: 'type-safety', + title: 'Type safety', + rules: [ + '@typescript-eslint/no-explicit-any', + '@typescript-eslint/no-unsafe-*', + ], + }, + ], + }, + ); + + expect(plugin.groups).toContainEqual({ + slug: 'type-safety', + title: 'Type safety', + refs: [ + { slug: 'typescript-eslint-no-explicit-any', weight: 1 }, + { + slug: 'typescript-eslint-no-unsafe-declaration-merging', + weight: 1, + }, + { slug: 'typescript-eslint-no-unsafe-function-type', weight: 1 }, + ], + }); + expect(plugin.audits).toContainEqual( + expect.objectContaining>({ + slug: 'typescript-eslint-no-explicit-any', + }), + ); + }); + + 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(/Custom group rules must contain at least 1 element/); + 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(/Custom group rules must contain at least 1 element/); + }); + 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(/Invalid input/); }); 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 1393bad09..285a39e50 100644 --- a/packages/plugin-eslint/src/lib/eslint-plugin.ts +++ b/packages/plugin-eslint/src/lib/eslint-plugin.ts @@ -2,7 +2,13 @@ import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { PluginConfig } from '@code-pushup/models'; -import { type ESLintPluginConfig, eslintPluginConfigSchema } from './config.js'; +import { parseSchema } from '@code-pushup/utils'; +import { + type ESLintPluginConfig, + type ESLintPluginOptions, + eslintPluginConfigSchema, + eslintPluginOptionsSchema, +} from './config.js'; import { listAuditsAndGroups } from './meta/index.js'; import { createRunnerConfig } from './runner/index.js'; @@ -24,14 +30,24 @@ import { createRunnerConfig } from './runner/index.js'; * } * * @param config Configuration options. + * @param options Optional settings for customizing the plugin behavior. * @returns Plugin configuration as a promise. */ export async function eslintPlugin( config: ESLintPluginConfig, + options?: ESLintPluginOptions, ): Promise { - const targets = eslintPluginConfigSchema.parse(config); + const targets = parseSchema(eslintPluginConfigSchema, config, { + schemaType: 'ESLint plugin config', + }); - const { audits, groups } = await listAuditsAndGroups(targets); + const customGroups = options + ? parseSchema(eslintPluginOptionsSchema, options, { + schemaType: 'ESLint plugin options', + }).groups + : undefined; + + const { audits, groups } = await listAuditsAndGroups(targets, customGroups); const runnerScriptPath = path.join( fileURLToPath(path.dirname(import.meta.url)), diff --git a/packages/plugin-eslint/src/lib/meta/groups.ts b/packages/plugin-eslint/src/lib/meta/groups.ts index b7250aca2..d0d82a36d 100644 --- a/packages/plugin-eslint/src/lib/meta/groups.ts +++ b/packages/plugin-eslint/src/lib/meta/groups.ts @@ -1,8 +1,10 @@ import type { Rule } from 'eslint'; import type { Group, GroupRef } from '@code-pushup/models'; -import { objectToKeys, slugify } from '@code-pushup/utils'; +import { objectToKeys, slugify, ui } from '@code-pushup/utils'; +import type { CustomGroup } from '../config.js'; import { ruleToSlug } from './hash.js'; import { type RuleData, parseRuleId } from './parse.js'; +import { expandWildcardRules } from './rules.js'; type RuleType = NonNullable; @@ -87,3 +89,80 @@ export function groupsFromRuleCategories(rules: RuleData[]): Group[] { return groups.toSorted((a, b) => a.slug.localeCompare(b.slug)); } + +export function groupsFromCustomConfig( + rules: RuleData[], + groups: CustomGroup[], +): Group[] { + const rulesMap = createRulesMap(rules); + + return groups.map(group => { + const groupRules = Array.isArray(group.rules) + ? Object.fromEntries(group.rules.map(rule => [rule, 1])) + : group.rules; + + const { refs, invalidRules } = resolveGroupRefs(groupRules, rulesMap); + + if (invalidRules.length > 0 && Object.entries(groupRules).length > 0) { + if (refs.length === 0) { + throw new Error( + `Invalid rule configuration in group ${group.slug}. All rules are invalid.`, + ); + } + ui().logger.warning( + `Some rules in group ${group.slug} are invalid: ${invalidRules.join(', ')}`, + ); + } + + return { + slug: group.slug, + title: group.title, + refs, + }; + }); +} + +export function createRulesMap(rules: RuleData[]): Record { + return rules.reduce>( + (acc, rule) => ({ + ...acc, + [rule.id]: [...(acc[rule.id] || []), rule], + }), + {}, + ); +} + +export function resolveGroupRefs( + groupRules: Record, + rulesMap: Record, +): { refs: Group['refs']; invalidRules: string[] } { + return Object.entries(groupRules).reduce<{ + refs: Group['refs']; + invalidRules: string[]; + }>( + (acc, [rule, weight]) => { + const matchedRuleIds = rule.endsWith('*') + ? expandWildcardRules(rule, Object.keys(rulesMap)) + : [rule]; + + const matchedRefs = matchedRuleIds.flatMap(ruleId => { + const matchingRules = rulesMap[ruleId] || []; + const weightPerRule = weight / matchingRules.length; + + return matchingRules.map(ruleData => ({ + slug: ruleToSlug(ruleData), + weight: weightPerRule, + })); + }); + + return { + refs: [...acc.refs, ...matchedRefs], + invalidRules: + matchedRefs.length > 0 + ? acc.invalidRules + : [...acc.invalidRules, rule], + }; + }, + { refs: [], invalidRules: [] }, + ); +} diff --git a/packages/plugin-eslint/src/lib/meta/groups.unit.test.ts b/packages/plugin-eslint/src/lib/meta/groups.unit.test.ts index 1b9070ee8..4c1fb9179 100644 --- a/packages/plugin-eslint/src/lib/meta/groups.unit.test.ts +++ b/packages/plugin-eslint/src/lib/meta/groups.unit.test.ts @@ -1,5 +1,12 @@ import type { Group } from '@code-pushup/models'; -import { groupsFromRuleCategories, groupsFromRuleTypes } from './groups.js'; +import { ui } from '@code-pushup/utils'; +import { + createRulesMap, + groupsFromCustomConfig, + groupsFromRuleCategories, + groupsFromRuleTypes, + resolveGroupRefs, +} from './groups.js'; import type { RuleData } from './parse.js'; const eslintRules: RuleData[] = [ @@ -165,3 +172,221 @@ describe('groupsFromRuleCategories', () => { ]); }); }); + +describe('groupsFromCustomConfig', () => { + it('should create a group with refs for wildcard rules', () => { + expect( + groupsFromCustomConfig(eslintRules, [ + { + slug: 'react', + title: 'React', + rules: ['react/*'], + }, + ]), + ).toEqual([ + { + slug: 'react', + title: 'React', + refs: [ + { slug: 'react-jsx-key', weight: 1 }, + { slug: 'react-react-in-jsx-scope', weight: 1 }, + { slug: 'react-no-deprecated', weight: 1 }, + ], + }, + ]); + }); + + it('should create a group with custom weights for specific rules', () => { + expect( + groupsFromCustomConfig(eslintRules, [ + { + slug: 'react', + title: 'React', + rules: { + 'react/jsx-key': 3, + 'react/react-in-jsx-scope': 2, + 'react/no-deprecated': 1, + }, + }, + ]), + ).toEqual([ + { + slug: 'react', + title: 'React', + refs: [ + { slug: 'react-jsx-key', weight: 3 }, + { slug: 'react-react-in-jsx-scope', weight: 2 }, + { slug: 'react-no-deprecated', weight: 1 }, + ], + }, + ]); + }); + + it('should handle multiple instances of the same rule', () => { + const rule: RuleData = { + id: 'promise/always-return', + options: [{ ignoreLastCallback: true }], + meta: { + type: 'problem', + docs: { + description: + 'Require returning inside each `then()` to create readable and reusable Promise chains.', + url: 'https://github.com/eslint-community/eslint-plugin-promise/blob/main/docs/rules/always-return.md', + }, + schema: [], + messages: { + thenShouldReturnOrThrow: 'Each then() should return a value or throw', + }, + }, + }; + expect( + groupsFromCustomConfig( + [rule, { ...rule, options: [] }], + [{ slug: 'custom-group', title: 'Custom Group', rules: ['promise/*'] }], + ), + ).toEqual([ + { + slug: 'custom-group', + title: 'Custom Group', + refs: [ + { slug: 'promise-always-return-ae56718b964cc0c7', weight: 0.5 }, + { slug: 'promise-always-return', weight: 0.5 }, + ], + }, + ]); + }); + + it('should throw when rules are empty', () => { + expect(() => + groupsFromCustomConfig( + [], + [ + { + slug: 'custom-group', + title: 'Custom Group', + rules: ['react/*'], + }, + ], + ), + ).toThrow( + 'Invalid rule configuration in group custom-group. All rules are invalid.', + ); + }); + + it('should throw when all custom group rules are invalid', () => { + expect(() => + groupsFromCustomConfig(eslintRules, [ + { + slug: 'custom-group', + title: 'Custom Group', + rules: ['non-existent/*'], + }, + ]), + ).toThrow( + 'Invalid rule configuration in group custom-group. All rules are invalid.', + ); + }); + + it('should log a warning when some of custom group rules are invalid', () => { + expect( + groupsFromCustomConfig(eslintRules, [ + { + slug: 'custom-group', + title: 'Custom Group', + rules: { + 'react/jsx-key': 3, + 'invalid-rule': 3, + }, + }, + ]), + ).toEqual([ + { + slug: 'custom-group', + title: 'Custom Group', + refs: [{ slug: 'react-jsx-key', weight: 3 }], + }, + ]); + expect(ui()).toHaveLogged( + 'warn', + 'Some rules in group custom-group are invalid: invalid-rule', + ); + }); +}); + +describe('createRulesMap', () => { + it('should map rule IDs to arrays of RuleData objects', () => { + expect( + createRulesMap([ + { id: 'rule1', meta: {}, options: [] }, + { id: 'rule2', meta: {}, options: [] }, + { id: 'rule1', meta: {}, options: ['option1'] }, + ]), + ).toEqual({ + rule1: [ + { id: 'rule1', meta: {}, options: [] }, + { id: 'rule1', meta: {}, options: ['option1'] }, + ], + rule2: [{ id: 'rule2', meta: {}, options: [] }], + }); + }); + + it('should return an empty object for an empty rules array', () => { + expect(createRulesMap([])).toEqual({}); + }); +}); + +describe('resolveGroupRefs', () => { + const rulesMap = { + rule1: [{ id: 'rule1', meta: {}, options: [] }], + rule2: [{ id: 'rule2', meta: {}, options: [] }], + rule3: [ + { id: 'rule3', meta: {}, options: [] }, + { id: 'rule3', meta: {}, options: ['option1'] }, + ], + }; + + it('should resolve refs for exact matches', () => { + expect(resolveGroupRefs({ rule1: 1, rule2: 2 }, rulesMap)).toEqual({ + refs: [ + { slug: 'rule1', weight: 1 }, + { slug: 'rule2', weight: 2 }, + ], + invalidRules: [], + }); + }); + + it('should resolve refs for wildcard matches', () => { + expect(resolveGroupRefs({ 'rule*': 1 }, rulesMap)).toEqual({ + refs: [ + { slug: 'rule1', weight: 1 }, + { slug: 'rule2', weight: 1 }, + { slug: 'rule3', weight: 0.5 }, + { slug: 'rule3-9a11e3400eca832a', weight: 0.5 }, + ], + invalidRules: [], + }); + }); + + it('should return invalid rules when no matches are found', () => { + expect(resolveGroupRefs({ 'non-existent': 1 }, rulesMap)).toEqual({ + refs: [], + invalidRules: ['non-existent'], + }); + }); + + it('should handle mixed valid and invalid rules', () => { + expect(resolveGroupRefs({ rule1: 1, 'non-existent': 2 }, rulesMap)).toEqual( + { + refs: [{ slug: 'rule1', weight: 1 }], + invalidRules: ['non-existent'], + }, + ); + }); + + it('should return empty refs and invalid for empty groupRules', () => { + expect(resolveGroupRefs({}, rulesMap)).toEqual({ + refs: [], + invalidRules: [], + }); + }); +}); diff --git a/packages/plugin-eslint/src/lib/meta/index.ts b/packages/plugin-eslint/src/lib/meta/index.ts index 2b4f61728..58f341908 100644 --- a/packages/plugin-eslint/src/lib/meta/index.ts +++ b/packages/plugin-eslint/src/lib/meta/index.ts @@ -1,6 +1,10 @@ import type { Audit, Group } from '@code-pushup/models'; -import type { ESLintTarget } from '../config.js'; -import { groupsFromRuleCategories, groupsFromRuleTypes } from './groups.js'; +import type { CustomGroup, ESLintTarget } from '../config.js'; +import { + groupsFromCustomConfig, + groupsFromRuleCategories, + groupsFromRuleTypes, +} from './groups.js'; import { listRules } from './rules.js'; import { ruleToAudit } from './transform.js'; @@ -9,14 +13,20 @@ export { detectConfigVersion, type ConfigFormat } from './versions/index.js'; export async function listAuditsAndGroups( targets: ESLintTarget[], + customGroups: CustomGroup[] | undefined, ): Promise<{ audits: Audit[]; groups: Group[] }> { const rules = await listRules(targets); + const resolvedCustomGroups = customGroups + ? groupsFromCustomConfig(rules, customGroups) + : []; + const audits = rules.map(ruleToAudit); const groups = [ ...groupsFromRuleTypes(rules), ...groupsFromRuleCategories(rules), + ...resolvedCustomGroups, ]; return { audits, groups }; diff --git a/packages/plugin-eslint/src/lib/meta/rules.ts b/packages/plugin-eslint/src/lib/meta/rules.ts index 2750a7b8a..a64429425 100644 --- a/packages/plugin-eslint/src/lib/meta/rules.ts +++ b/packages/plugin-eslint/src/lib/meta/rules.ts @@ -27,3 +27,11 @@ function mergeRuleIntoMap(map: RulesMap, rule: RuleData): RulesMap { }, }; } + +export function expandWildcardRules( + wildcard: string, + rules: string[], +): string[] { + const prefix = wildcard.slice(0, -1); + return rules.filter(rule => rule.startsWith(prefix)); +} diff --git a/packages/plugin-eslint/src/lib/meta/rules.unit.test.ts b/packages/plugin-eslint/src/lib/meta/rules.unit.test.ts new file mode 100644 index 000000000..80dd9a588 --- /dev/null +++ b/packages/plugin-eslint/src/lib/meta/rules.unit.test.ts @@ -0,0 +1,45 @@ +import { expandWildcardRules } from './rules.js'; + +describe('expandWildcardRules', () => { + const rules = [ + 'no-var', + 'no-const-assign', + 'no-debugger', + 'react/jsx-key', + 'react/react-in-jsx-scope', + 'react/no-deprecated', + '@typescript-eslint/no-array-constructor', + ]; + + it('should expand wildcard rules correctly', () => { + expect(expandWildcardRules('react/*', rules)).toEqual([ + 'react/jsx-key', + 'react/react-in-jsx-scope', + 'react/no-deprecated', + ]); + }); + + it('should return an empty array when no rules match the wildcard', () => { + expect(expandWildcardRules('non-existent/*', rules)).toEqual([]); + }); + + it('should handle wildcards matching a single rule', () => { + expect(expandWildcardRules('no-var*', rules)).toEqual(['no-var']); + }); + + it('should return an empty array when the rules are empty', () => { + expect(expandWildcardRules('react/*', [])).toEqual([]); + }); + + it('should return all rules when a wildcard has no valid prefix', () => { + expect(expandWildcardRules('*', rules)).toEqual(rules); + }); + + it('should handle a wildcard with a single-character prefix', () => { + expect(expandWildcardRules('n*', rules)).toEqual([ + 'no-var', + 'no-const-assign', + 'no-debugger', + ]); + }); +}); diff --git a/packages/plugin-eslint/tsconfig.test.json b/packages/plugin-eslint/tsconfig.test.json index bb1ab5e0c..4de6650fc 100644 --- a/packages/plugin-eslint/tsconfig.test.json +++ b/packages/plugin-eslint/tsconfig.test.json @@ -12,6 +12,7 @@ "src/**/*.test.tsx", "src/**/*.test.js", "src/**/*.test.jsx", - "src/**/*.d.ts" + "src/**/*.d.ts", + "../../testing/test-setup/src/vitest.d.ts" ] } diff --git a/packages/plugin-eslint/vite.config.unit.ts b/packages/plugin-eslint/vite.config.unit.ts index da6bc190f..65695242a 100644 --- a/packages/plugin-eslint/vite.config.unit.ts +++ b/packages/plugin-eslint/vite.config.unit.ts @@ -25,6 +25,8 @@ export default defineConfig({ '../../testing/test-setup/src/lib/console.mock.ts', '../../testing/test-setup/src/lib/fs.mock.ts', '../../testing/test-setup/src/lib/reset.mocks.ts', + '../../testing/test-setup/src/lib/cliui.mock.ts', + '../../testing/test-setup/src/lib/extend/ui-logger.matcher.ts', ], }, }); 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..57a15c638 100644 --- a/packages/utils/src/lib/zod-validation.ts +++ b/packages/utils/src/lib/zod-validation.ts @@ -1,5 +1,33 @@ 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( + { schemaType, sourcePath }: SchemaValidationContext, + error: Error, + ) { + const validationError = fromError(error, { + messageBuilder: zodErrorMessageBuilder, + }); + const pathDetails = sourcePath + ? ` in ${bold(path.relative(process.cwd(), sourcePath))}` + : ''; + super( + `Failed parsing ${schemaType}${pathDetails}.\n\n${validationError.message}`, + ); + } +} export function formatErrorPath(errorPath: (string | number)[]): string { return errorPath @@ -12,7 +40,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 +51,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; + } +}