Skip to content

Commit de1a63e

Browse files
committed
feat: refine Zod schema validation
1 parent af5c632 commit de1a63e

File tree

8 files changed

+86
-46
lines changed

8 files changed

+86
-46
lines changed

packages/core/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,7 @@
4141
"dependencies": {
4242
"@code-pushup/models": "0.57.0",
4343
"@code-pushup/utils": "0.57.0",
44-
"ansis": "^3.3.0",
45-
"zod-validation-error": "^3.4.0"
44+
"ansis": "^3.3.0"
4645
},
4746
"peerDependencies": {
4847
"@code-pushup/portal-client": "^0.9.0"

packages/core/src/lib/implementation/read-rc-file.integration.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import path from 'node:path';
22
import { fileURLToPath } from 'node:url';
33
import { describe, expect } from 'vitest';
4-
import { ConfigValidationError, readRcByPath } from './read-rc-file.js';
4+
import { SchemaValidationError } from '@code-pushup/utils';
5+
import { readRcByPath } from './read-rc-file.js';
56

67
describe('readRcByPath', () => {
78
const configDirPath = path.join(
@@ -69,7 +70,7 @@ describe('readRcByPath', () => {
6970
it('should throw if the configuration is empty', async () => {
7071
await expect(
7172
readRcByPath(path.join(configDirPath, 'code-pushup.empty.config.js')),
72-
).rejects.toThrow(expect.any(ConfigValidationError));
73+
).rejects.toThrow(expect.any(SchemaValidationError));
7374
});
7475

7576
it('should throw if the configuration is invalid', async () => {

packages/core/src/lib/implementation/read-rc-file.ts

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,18 @@
1-
import { bold } from 'ansis';
21
import path from 'node:path';
3-
import { fromError, isZodErrorLike } from 'zod-validation-error';
42
import {
53
CONFIG_FILE_NAME,
64
type CoreConfig,
75
SUPPORTED_CONFIG_FILE_FORMATS,
86
coreConfigSchema,
97
} from '@code-pushup/models';
10-
import {
11-
fileExists,
12-
importModule,
13-
zodErrorMessageBuilder,
14-
} from '@code-pushup/utils';
8+
import { fileExists, importModule, parseSchema } from '@code-pushup/utils';
159

1610
export class ConfigPathError extends Error {
1711
constructor(configPath: string) {
1812
super(`Provided path '${configPath}' is not valid.`);
1913
}
2014
}
2115

22-
export class ConfigValidationError extends Error {
23-
constructor(configPath: string, message: string) {
24-
const relativePath = path.relative(process.cwd(), configPath);
25-
super(`Failed parsing core config in ${bold(relativePath)}.\n\n${message}`);
26-
}
27-
}
28-
2916
export async function readRcByPath(
3017
filepath: string,
3118
tsconfig?: string,
@@ -38,18 +25,16 @@ export async function readRcByPath(
3825
throw new ConfigPathError(filepath);
3926
}
4027

41-
const cfg = await importModule({ filepath, tsconfig, format: 'esm' });
28+
const cfg: CoreConfig = await importModule({
29+
filepath,
30+
tsconfig,
31+
format: 'esm',
32+
});
4233

43-
try {
44-
return coreConfigSchema.parse(cfg);
45-
} catch (error) {
46-
const validationError = fromError(error, {
47-
messageBuilder: zodErrorMessageBuilder,
48-
});
49-
throw isZodErrorLike(error)
50-
? new ConfigValidationError(filepath, validationError.message)
51-
: error;
52-
}
34+
return parseSchema(coreConfigSchema, cfg, {
35+
schemaType: 'core config',
36+
sourcePath: filepath,
37+
});
5338
}
5439

5540
export async function autoloadRc(tsconfig?: string): Promise<CoreConfig> {

packages/plugin-eslint/src/lib/eslint-plugin.integration.test.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import path from 'node:path';
33
import { fileURLToPath } from 'node:url';
44
import type { MockInstance } from 'vitest';
55
import type { Audit, PluginConfig, RunnerConfig } from '@code-pushup/models';
6-
import { toUnixPath } from '@code-pushup/utils';
6+
import { SchemaValidationError, toUnixPath } from '@code-pushup/utils';
77
import { eslintPlugin } from './eslint-plugin.js';
88

99
describe('eslintPlugin', () => {
@@ -71,15 +71,12 @@ describe('eslintPlugin', () => {
7171
);
7272
});
7373

74-
it('should initialize with plugin options for custom rules', async () => {
74+
it('should initialize with plugin options for custom groups', async () => {
7575
cwdSpy.mockReturnValue(path.join(fixturesDir, 'nx-monorepo'));
7676
const plugin = await eslintPlugin(
7777
{
7878
eslintrc: './packages/nx-plugin/eslint.config.js',
79-
patterns: [
80-
'packages/nx-plugin/**/*.ts',
81-
'packages/nx-plugin/**/*.json',
82-
],
79+
patterns: ['packages/nx-plugin/**/*.ts'],
8380
},
8481
{
8582
groups: [
@@ -114,11 +111,25 @@ describe('eslintPlugin', () => {
114111
);
115112
});
116113

114+
it('should throw when custom group rules are empty', async () => {
115+
await expect(
116+
eslintPlugin(
117+
{
118+
eslintrc: './packages/nx-plugin/eslint.config.js',
119+
patterns: ['packages/nx-plugin/**/*.ts'],
120+
},
121+
{
122+
groups: [{ slug: 'type-safety', title: 'Type safety', rules: [] }],
123+
},
124+
),
125+
).rejects.toThrow(expect.any(SchemaValidationError));
126+
});
127+
117128
it('should throw when invalid parameters provided', async () => {
118129
await expect(
119130
// @ts-expect-error simulating invalid non-TS config
120131
eslintPlugin({ eslintrc: '.eslintrc.json' }),
121-
).rejects.toThrow('patterns');
132+
).rejects.toThrow(expect.any(SchemaValidationError));
122133
});
123134

124135
it("should throw if eslintrc file doesn't exist", async () => {

packages/plugin-eslint/src/lib/eslint-plugin.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createRequire } from 'node:module';
22
import path from 'node:path';
33
import { fileURLToPath } from 'node:url';
44
import type { PluginConfig } from '@code-pushup/models';
5+
import { parseSchema } from '@code-pushup/utils';
56
import {
67
type ESLintPluginConfig,
78
type ESLintPluginOptions,
@@ -36,19 +37,23 @@ export async function eslintPlugin(
3637
config: ESLintPluginConfig,
3738
options?: ESLintPluginOptions,
3839
): Promise<PluginConfig> {
39-
const targets = eslintPluginConfigSchema.parse(config);
40+
const configPath = fileURLToPath(path.dirname(import.meta.url));
41+
42+
const targets = parseSchema(eslintPluginConfigSchema, config, {
43+
schemaType: 'ESLint plugin config',
44+
sourcePath: configPath,
45+
});
4046

4147
const customGroups = options
42-
? eslintPluginOptionsSchema.parse(options).groups
48+
? parseSchema(eslintPluginOptionsSchema, options, {
49+
schemaType: 'ESLint plugin options',
50+
sourcePath: configPath,
51+
}).groups
4352
: undefined;
4453

4554
const { audits, groups } = await listAuditsAndGroups(targets, customGroups);
4655

47-
const runnerScriptPath = path.join(
48-
fileURLToPath(path.dirname(import.meta.url)),
49-
'..',
50-
'bin.js',
51-
);
56+
const runnerScriptPath = path.join(configPath, '..', 'bin.js');
5257

5358
const packageJson = createRequire(import.meta.url)(
5459
'../../package.json',

packages/utils/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"multi-progress-bars": "^5.0.3",
3838
"semver": "^7.6.0",
3939
"simple-git": "^3.20.0",
40+
"zod": "^3.23.8",
4041
"zod-validation-error": "^3.4.0"
4142
}
4243
}

packages/utils/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,4 @@ export type {
123123
WithRequired,
124124
} from './lib/types.js';
125125
export { verboseUtils } from './lib/verbose-utils.js';
126-
export { zodErrorMessageBuilder } from './lib/zod-validation.js';
126+
export { parseSchema, SchemaValidationError } from './lib/zod-validation.js';

packages/utils/src/lib/zod-validation.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
import { bold, red } from 'ansis';
2-
import type { MessageBuilder } from 'zod-validation-error';
2+
import path from 'node:path';
3+
import type { z } from 'zod';
4+
import {
5+
type MessageBuilder,
6+
fromError,
7+
isZodErrorLike,
8+
} from 'zod-validation-error';
9+
10+
type SchemaValidationContext = {
11+
schemaType: string;
12+
sourcePath: string;
13+
};
14+
15+
export class SchemaValidationError extends Error {
16+
constructor(configType: string, configPath: string, error: Error) {
17+
const validationError = fromError(error, {
18+
messageBuilder: zodErrorMessageBuilder,
19+
});
20+
const relativePath = path.relative(process.cwd(), configPath);
21+
super(
22+
`Failed parsing ${configType} in ${bold(relativePath)}.\n\n${validationError.message}`,
23+
);
24+
}
25+
}
326

427
export function formatErrorPath(errorPath: (string | number)[]): string {
528
return errorPath
@@ -12,7 +35,7 @@ export function formatErrorPath(errorPath: (string | number)[]): string {
1235
.join('');
1336
}
1437

15-
export const zodErrorMessageBuilder: MessageBuilder = issues =>
38+
const zodErrorMessageBuilder: MessageBuilder = issues =>
1639
issues
1740
.map(issue => {
1841
const formattedMessage = red(`${bold(issue.code)}: ${issue.message}`);
@@ -23,3 +46,18 @@ export const zodErrorMessageBuilder: MessageBuilder = issues =>
2346
return `${formattedMessage}\n`;
2447
})
2548
.join('\n');
49+
50+
export function parseSchema<T extends z.ZodTypeAny>(
51+
schema: T,
52+
data: z.input<T>,
53+
{ schemaType, sourcePath }: SchemaValidationContext,
54+
): z.output<T> {
55+
try {
56+
return schema.parse(data);
57+
} catch (error) {
58+
if (isZodErrorLike(error)) {
59+
throw new SchemaValidationError(schemaType, sourcePath, error);
60+
}
61+
throw error;
62+
}
63+
}

0 commit comments

Comments
 (0)