Skip to content

Commit 676cbb7

Browse files
committed
feat(core): enhance config validation
1 parent 9099514 commit 676cbb7

File tree

11 files changed

+133
-52
lines changed

11 files changed

+133
-52
lines changed

package-lock.json

Lines changed: 6 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@
4343
"vscode-material-icons": "^0.1.1",
4444
"yaml": "^2.5.1",
4545
"yargs": "^17.7.2",
46-
"zod": "^3.23.8"
46+
"zod": "^3.23.8",
47+
"zod-validation-error": "^3.4.0"
4748
},
4849
"devDependencies": {
4950
"@beaussan/nx-knip": "^0.0.5-15",

packages/core/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
"dependencies": {
4242
"@code-pushup/models": "0.56.0",
4343
"@code-pushup/utils": "0.56.0",
44-
"ansis": "^3.3.0"
44+
"ansis": "^3.3.0",
45+
"zod-validation-error": "^3.4.0"
4546
},
4647
"peerDependencies": {
4748
"@code-pushup/portal-client": "^0.9.0"

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { dirname, join } from 'node:path';
22
import { fileURLToPath } from 'node:url';
33
import { describe, expect } from 'vitest';
4-
import { readRcByPath } from './read-rc-file.js';
4+
import { ConfigValidationError, readRcByPath } from './read-rc-file.js';
55

66
describe('readRcByPath', () => {
77
const configDirPath = join(
@@ -69,7 +69,7 @@ describe('readRcByPath', () => {
6969
it('should throw if the configuration is empty', async () => {
7070
await expect(
7171
readRcByPath(join(configDirPath, 'code-pushup.empty.config.js')),
72-
).rejects.toThrow(`"code": "invalid_type",`);
72+
).rejects.toThrow(expect.any(ConfigValidationError));
7373
});
7474

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

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

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { join } from 'node:path';
1+
import { bold, red } from 'ansis';
2+
import path, { join } from 'node:path';
3+
import {
4+
type MessageBuilder,
5+
fromError,
6+
isZodErrorLike,
7+
} from 'zod-validation-error';
28
import {
39
CONFIG_FILE_NAME,
410
type CoreConfig,
@@ -7,12 +13,42 @@ import {
713
} from '@code-pushup/models';
814
import { fileExists, importModule } from '@code-pushup/utils';
915

16+
function formatErrorPath(errorPath: (string | number)[]): string {
17+
return errorPath
18+
.map((key, index) => {
19+
if (typeof key === 'number') {
20+
return `[${key}]`;
21+
}
22+
return index > 0 ? `.${key}` : key;
23+
})
24+
.join('');
25+
}
26+
27+
const coreConfigMessageBuilder: MessageBuilder = issues =>
28+
issues
29+
.map(issue => {
30+
const formattedMessage = red(`${bold(issue.code)}: ${issue.message}`);
31+
const formattedPath = formatErrorPath(issue.path);
32+
if (formattedPath) {
33+
return `Validation error at ${bold(formattedPath)}\n${formattedMessage}\n`;
34+
}
35+
return `${formattedMessage}\n`;
36+
})
37+
.join('\n');
38+
1039
export class ConfigPathError extends Error {
1140
constructor(configPath: string) {
1241
super(`Provided path '${configPath}' is not valid.`);
1342
}
1443
}
1544

45+
export class ConfigValidationError extends Error {
46+
constructor(configPath: string, message: string) {
47+
const relativePath = path.relative(process.cwd(), configPath);
48+
super(`Failed parsing core config in ${bold(relativePath)}.\n\n${message}`);
49+
}
50+
}
51+
1652
export async function readRcByPath(
1753
filepath: string,
1854
tsconfig?: string,
@@ -27,7 +63,16 @@ export async function readRcByPath(
2763

2864
const cfg = await importModule({ filepath, tsconfig, format: 'esm' });
2965

30-
return coreConfigSchema.parse(cfg);
66+
try {
67+
return coreConfigSchema.parse(cfg);
68+
} catch (error) {
69+
const validationError = fromError(error, {
70+
messageBuilder: coreConfigMessageBuilder,
71+
});
72+
throw isZodErrorLike(error)
73+
? new ConfigValidationError(filepath, validationError.message)
74+
: error;
75+
}
3176
}
3277

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

packages/models/src/lib/category-config.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,22 @@ export const categoryRefSchema = weightedRefSchema(
2323
);
2424
export type CategoryRef = z.infer<typeof categoryRefSchema>;
2525

26-
export const categoryConfigSchema = scorableSchema(
27-
'Category with a score calculated from audits and groups from various plugins',
28-
categoryRefSchema,
29-
getDuplicateRefsInCategoryMetrics,
30-
duplicateRefsInCategoryMetricsErrorMsg,
31-
)
32-
.merge(
26+
export const categoryConfigSchema = z
27+
.intersection(
28+
scorableSchema(
29+
'Category with a score calculated from audits and groups from various plugins',
30+
categoryRefSchema,
31+
getDuplicateRefsInCategoryMetrics,
32+
duplicateRefsInCategoryMetricsErrorMsg,
33+
),
3334
metaSchema({
3435
titleDescription: 'Category Title',
3536
docsUrlDescription: 'Category docs URL',
3637
descriptionDescription: 'Category description',
3738
description: 'Meta info for category',
3839
}),
3940
)
40-
.merge(
41+
.and(
4142
z.object({
4243
isBinary: z
4344
.boolean({

packages/models/src/lib/category-config.unit.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ describe('categoryConfigSchema', () => {
129129
title: 'This category is empty for now',
130130
refs: [],
131131
} satisfies CategoryConfig),
132-
).toThrow('In a category there has to be at least one ref');
132+
).toThrow('In category in-progress, there has to be at least one ref');
133133
});
134134

135135
it('should throw for duplicate category references', () => {
@@ -175,7 +175,9 @@ describe('categoryConfigSchema', () => {
175175
},
176176
],
177177
} satisfies CategoryConfig),
178-
).toThrow('In a category there has to be at least one ref with weight > 0');
178+
).toThrow(
179+
'In category informational, there has to be at least one ref with weight > 0. Affected refs: functional/immutable-data, lighthouse-experimental',
180+
);
179181
});
180182
});
181183

packages/models/src/lib/group.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,16 @@ export const groupMetaSchema = metaSchema({
2525
});
2626
export type GroupMeta = z.infer<typeof groupMetaSchema>;
2727

28-
export const groupSchema = scorableSchema(
29-
'A group aggregates a set of audits into a single score which can be referenced from a category. ' +
30-
'E.g. the group slug "performance" groups audits and can be referenced in a category',
31-
groupRefSchema,
32-
getDuplicateRefsInGroups,
33-
duplicateRefsInGroupsErrorMsg,
34-
).merge(groupMetaSchema);
28+
export const groupSchema = z.intersection(
29+
scorableSchema(
30+
'A group aggregates a set of audits into a single score which can be referenced from a category. ' +
31+
'E.g. the group slug "performance" groups audits and can be referenced in a category',
32+
groupRefSchema,
33+
getDuplicateRefsInGroups,
34+
duplicateRefsInGroupsErrorMsg,
35+
),
36+
groupMetaSchema,
37+
);
3538
export type Group = z.infer<typeof groupSchema>;
3639

3740
export const groupsSchema = z

packages/models/src/lib/group.unit.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe('groupSchema', () => {
6969
title: 'Empty group',
7070
refs: [],
7171
} satisfies Group),
72-
).toThrow('In a category there has to be at least one ref');
72+
).toThrow('In category empty-group, there has to be at least one ref');
7373
});
7474

7575
it('should throw for duplicate group references', () => {

packages/models/src/lib/implementation/schemas.ts

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const slugSchema = z
3838
'The slug has to follow the pattern [0-9a-z] followed by multiple optional groups of -[0-9a-z]. e.g. my-slug',
3939
})
4040
.max(MAX_SLUG_LENGTH, {
41-
message: `slug can be max ${MAX_SLUG_LENGTH} characters long`,
41+
message: `The slug can be max ${MAX_SLUG_LENGTH} characters long`,
4242
});
4343

4444
/** Schema for a general description property */
@@ -105,7 +105,7 @@ export function metaSchema(options?: {
105105
export const filePathSchema = z
106106
.string()
107107
.trim()
108-
.min(1, { message: 'path is invalid' });
108+
.min(1, { message: 'The path is invalid' });
109109

110110
/** Schema for a fileNameSchema */
111111
export const fileNameSchema = z
@@ -114,7 +114,7 @@ export const fileNameSchema = z
114114
.regex(filenameRegex, {
115115
message: `The filename has to be valid`,
116116
})
117-
.min(1, { message: 'file name is invalid' });
117+
.min(1, { message: 'The file name is invalid' });
118118

119119
/** Schema for a positiveInt */
120120
export const positiveIntSchema = z.number().int().positive();
@@ -167,26 +167,43 @@ export function scorableSchema<T extends ReturnType<typeof weightedRefSchema>>(
167167
duplicateCheckFn: (metrics: z.infer<T>[]) => false | string[],
168168
duplicateMessageFn: (metrics: z.infer<T>[]) => string,
169169
) {
170-
return z.object(
171-
{
172-
slug: slugSchema.describe('Human-readable unique ID, e.g. "performance"'),
173-
refs: z
174-
.array(refSchema)
175-
.min(1)
176-
// refs are unique
177-
.refine(
178-
refs => !duplicateCheckFn(refs),
179-
refs => ({
180-
message: duplicateMessageFn(refs),
181-
}),
182-
)
183-
// categories weights are correct
184-
.refine(hasNonZeroWeightedRef, () => ({
185-
message:
186-
'In a category there has to be at least one ref with weight > 0',
187-
})),
188-
},
189-
{ description },
170+
return (
171+
z
172+
.object(
173+
{
174+
slug: slugSchema.describe(
175+
'Human-readable unique ID, e.g. "performance"',
176+
),
177+
refs: z
178+
.array(refSchema)
179+
.min(1)
180+
// refs are unique
181+
.refine(
182+
refs => !duplicateCheckFn(refs),
183+
refs => ({
184+
message: duplicateMessageFn(refs),
185+
}),
186+
),
187+
},
188+
{ description },
189+
)
190+
// category weights are correct
191+
.superRefine(({ slug, refs }, ctx) => {
192+
if (refs.length === 0) {
193+
ctx.addIssue({
194+
code: z.ZodIssueCode.custom,
195+
message: `In category ${slug}, there has to be at least one ref`,
196+
path: ['refs'],
197+
});
198+
} else if (!hasNonZeroWeightedRef(refs)) {
199+
const affectedRefs = refs.map(ref => ref.slug).join(', ');
200+
ctx.addIssue({
201+
code: z.ZodIssueCode.custom,
202+
message: `In category ${slug}, there has to be at least one ref with weight > 0. Affected refs: ${affectedRefs}`,
203+
path: ['refs'],
204+
});
205+
}
206+
})
190207
);
191208
}
192209

0 commit comments

Comments
 (0)