Skip to content

Commit

Permalink
feat: refine Zod schema validation
Browse files Browse the repository at this point in the history
  • Loading branch information
hanna-skryl committed Jan 23, 2025
1 parent af5c632 commit 0395ac3
Show file tree
Hide file tree
Showing 8 changed files with 86 additions and 46 deletions.
3 changes: 1 addition & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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 () => {
Expand Down
35 changes: 10 additions & 25 deletions packages/core/src/lib/implementation/read-rc-file.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,18 @@
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) {
super(`Provided path '${configPath}' is not valid.`);
}
}

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,
Expand All @@ -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<CoreConfig> {
Expand Down
25 changes: 18 additions & 7 deletions packages/plugin-eslint/src/lib/eslint-plugin.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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 () => {
Expand Down
19 changes: 12 additions & 7 deletions packages/plugin-eslint/src/lib/eslint-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -36,19 +37,23 @@ export async function eslintPlugin(
config: ESLintPluginConfig,
options?: ESLintPluginOptions,
): Promise<PluginConfig> {
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',
Expand Down
1 change: 1 addition & 0 deletions packages/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
2 changes: 1 addition & 1 deletion packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
42 changes: 40 additions & 2 deletions packages/utils/src/lib/zod-validation.ts
Original file line number Diff line number Diff line change
@@ -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 {

Check warning on line 15 in packages/utils/src/lib/zod-validation.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> JSDoc coverage | Classes coverage

Missing classes documentation for SchemaValidationError
constructor(configType: string, configPath: string, error: Error) {

Check failure on line 16 in packages/utils/src/lib/zod-validation.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> Code coverage | Function coverage

Function SchemaValidationError is not called in any test case.
const validationError = fromError(error, {
messageBuilder: zodErrorMessageBuilder,
});
const relativePath = path.relative(process.cwd(), configPath);
super(
`Failed parsing ${configType} in ${bold(relativePath)}.\n\n${validationError.message}`,
);
}

Check warning on line 24 in packages/utils/src/lib/zod-validation.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> Code coverage | Line coverage

Lines 17-24 are not covered in any test case.
}

export function formatErrorPath(errorPath: (string | number)[]): string {
return errorPath
Expand All @@ -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}`);
Expand All @@ -23,3 +46,18 @@ export const zodErrorMessageBuilder: MessageBuilder = issues =>
return `${formattedMessage}\n`;
})
.join('\n');

export function parseSchema<T extends z.ZodTypeAny>(

Check failure on line 50 in packages/utils/src/lib/zod-validation.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> Code coverage | Function coverage

Function parseSchema is not called in any test case.

Check warning on line 50 in packages/utils/src/lib/zod-validation.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> JSDoc coverage | Functions coverage

Missing functions documentation for parseSchema
schema: T,
data: z.input<T>,
{ schemaType, sourcePath }: SchemaValidationContext,
): z.output<T> {
try {
return schema.parse(data);
} catch (error) {
if (isZodErrorLike(error)) {
throw new SchemaValidationError(schemaType, sourcePath, error);
}
throw error;
}
}

Check warning on line 63 in packages/utils/src/lib/zod-validation.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> Code coverage | Line coverage

Lines 51-63 are not covered in any test case.

0 comments on commit 0395ac3

Please sign in to comment.