Skip to content

Commit ec834db

Browse files
authored
feat(plugin-eslint): add support for custom groups (#925)
1 parent b230a3d commit ec834db

File tree

17 files changed

+589
-41
lines changed

17 files changed

+589
-41
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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
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 { readRcByPath } from './read-rc-file.js';
55

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

7575
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/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,42 @@ Detected ESLint rules are mapped to Code PushUp audits. Audit reports are calcul
9393

9494
5. Run the CLI with `npx code-pushup collect` and view or upload report (refer to [CLI docs](../cli/README.md)).
9595

96+
### Custom groups
97+
98+
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.
99+
100+
```js
101+
import eslintPlugin from '@code-pushup/eslint-plugin';
102+
103+
export default {
104+
// ...
105+
plugins: [
106+
// ...
107+
await eslintPlugin(
108+
{ eslintrc: '.eslintrc.js', patterns: ['src/**/*.js'] },
109+
{
110+
groups: [
111+
{
112+
slug: 'modern-angular',
113+
title: 'Modern Angular',
114+
rules: {
115+
'@angular-eslint/template/prefer-control-flow': 3,
116+
'@angular-eslint/template/prefer-ngsrc': 2,
117+
'@angular-eslint/component-selector': 1,
118+
},
119+
},
120+
{
121+
slug: 'type-safety',
122+
title: 'Type safety',
123+
rules: ['@typescript-eslint/no-unsafe-*'],
124+
},
125+
],
126+
},
127+
),
128+
],
129+
};
130+
```
131+
96132
### Optionally set up categories
97133
98134
1. Reference audits (or groups) which you wish to include in custom categories (use `npx code-pushup print-config` to list audits and groups).

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,36 @@ export type ESLintPluginRunnerConfig = {
3333
targets: ESLintTarget[];
3434
slugs: string[];
3535
};
36+
37+
const customGroupRulesSchema = z.union(
38+
[
39+
z
40+
.array(z.string())
41+
.min(1, 'Custom group rules must contain at least 1 element'),
42+
z.record(z.string(), z.number()).refine(
43+
schema => Object.keys(schema).length > 0,
44+
() => ({
45+
code: 'too_small',
46+
message: 'Custom group rules must contain at least 1 element',
47+
}),
48+
),
49+
],
50+
{
51+
description:
52+
'Array of rule IDs with equal weights or object mapping rule IDs to specific weights',
53+
},
54+
);
55+
56+
const customGroupSchema = z.object({
57+
slug: z.string({ description: 'Unique group identifier' }),
58+
title: z.string({ description: 'Group display title' }),
59+
description: z.string({ description: 'Group metadata' }).optional(),
60+
docsUrl: z.string({ description: 'Group documentation site' }).optional(),
61+
rules: customGroupRulesSchema,
62+
});
63+
export type CustomGroup = z.infer<typeof customGroupSchema>;
64+
65+
export const eslintPluginOptionsSchema = z.object({
66+
groups: z.array(customGroupSchema).optional(),
67+
});
68+
export type ESLintPluginOptions = z.infer<typeof eslintPluginOptionsSchema>;

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

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,76 @@ describe('eslintPlugin', () => {
7171
);
7272
});
7373

74+
it('should initialize with plugin options for custom groups', async () => {
75+
cwdSpy.mockReturnValue(path.join(fixturesDir, 'nx-monorepo'));
76+
const plugin = await eslintPlugin(
77+
{
78+
eslintrc: './packages/nx-plugin/eslint.config.js',
79+
patterns: ['packages/nx-plugin/**/*.ts'],
80+
},
81+
{
82+
groups: [
83+
{
84+
slug: 'type-safety',
85+
title: 'Type safety',
86+
rules: [
87+
'@typescript-eslint/no-explicit-any',
88+
'@typescript-eslint/no-unsafe-*',
89+
],
90+
},
91+
],
92+
},
93+
);
94+
95+
expect(plugin.groups).toContainEqual({
96+
slug: 'type-safety',
97+
title: 'Type safety',
98+
refs: [
99+
{ slug: 'typescript-eslint-no-explicit-any', weight: 1 },
100+
{
101+
slug: 'typescript-eslint-no-unsafe-declaration-merging',
102+
weight: 1,
103+
},
104+
{ slug: 'typescript-eslint-no-unsafe-function-type', weight: 1 },
105+
],
106+
});
107+
expect(plugin.audits).toContainEqual(
108+
expect.objectContaining<Partial<Audit>>({
109+
slug: 'typescript-eslint-no-explicit-any',
110+
}),
111+
);
112+
});
113+
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(/Custom group rules must contain at least 1 element/);
126+
await expect(
127+
eslintPlugin(
128+
{
129+
eslintrc: './packages/nx-plugin/eslint.config.js',
130+
patterns: ['packages/nx-plugin/**/*.ts'],
131+
},
132+
{
133+
groups: [{ slug: 'type-safety', title: 'Type safety', rules: {} }],
134+
},
135+
),
136+
).rejects.toThrow(/Custom group rules must contain at least 1 element/);
137+
});
138+
74139
it('should throw when invalid parameters provided', async () => {
75140
await expect(
76141
// @ts-expect-error simulating invalid non-TS config
77142
eslintPlugin({ eslintrc: '.eslintrc.json' }),
78-
).rejects.toThrow('patterns');
143+
).rejects.toThrow(/Invalid input/);
79144
});
80145

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

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ 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 { type ESLintPluginConfig, eslintPluginConfigSchema } from './config.js';
5+
import { parseSchema } from '@code-pushup/utils';
6+
import {
7+
type ESLintPluginConfig,
8+
type ESLintPluginOptions,
9+
eslintPluginConfigSchema,
10+
eslintPluginOptionsSchema,
11+
} from './config.js';
612
import { listAuditsAndGroups } from './meta/index.js';
713
import { createRunnerConfig } from './runner/index.js';
814

@@ -24,14 +30,24 @@ import { createRunnerConfig } from './runner/index.js';
2430
* }
2531
*
2632
* @param config Configuration options.
33+
* @param options Optional settings for customizing the plugin behavior.
2734
* @returns Plugin configuration as a promise.
2835
*/
2936
export async function eslintPlugin(
3037
config: ESLintPluginConfig,
38+
options?: ESLintPluginOptions,
3139
): Promise<PluginConfig> {
32-
const targets = eslintPluginConfigSchema.parse(config);
40+
const targets = parseSchema(eslintPluginConfigSchema, config, {
41+
schemaType: 'ESLint plugin config',
42+
});
3343

34-
const { audits, groups } = await listAuditsAndGroups(targets);
44+
const customGroups = options
45+
? parseSchema(eslintPluginOptionsSchema, options, {
46+
schemaType: 'ESLint plugin options',
47+
}).groups
48+
: undefined;
49+
50+
const { audits, groups } = await listAuditsAndGroups(targets, customGroups);
3551

3652
const runnerScriptPath = path.join(
3753
fileURLToPath(path.dirname(import.meta.url)),

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

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { Rule } from 'eslint';
22
import type { Group, GroupRef } from '@code-pushup/models';
3-
import { objectToKeys, slugify } from '@code-pushup/utils';
3+
import { objectToKeys, slugify, ui } from '@code-pushup/utils';
4+
import type { CustomGroup } from '../config.js';
45
import { ruleToSlug } from './hash.js';
56
import { type RuleData, parseRuleId } from './parse.js';
7+
import { expandWildcardRules } from './rules.js';
68

79
type RuleType = NonNullable<Rule.RuleMetaData['type']>;
810

@@ -87,3 +89,80 @@ export function groupsFromRuleCategories(rules: RuleData[]): Group[] {
8789

8890
return groups.toSorted((a, b) => a.slug.localeCompare(b.slug));
8991
}
92+
93+
export function groupsFromCustomConfig(
94+
rules: RuleData[],
95+
groups: CustomGroup[],
96+
): Group[] {
97+
const rulesMap = createRulesMap(rules);
98+
99+
return groups.map(group => {
100+
const groupRules = Array.isArray(group.rules)
101+
? Object.fromEntries(group.rules.map(rule => [rule, 1]))
102+
: group.rules;
103+
104+
const { refs, invalidRules } = resolveGroupRefs(groupRules, rulesMap);
105+
106+
if (invalidRules.length > 0 && Object.entries(groupRules).length > 0) {
107+
if (refs.length === 0) {
108+
throw new Error(
109+
`Invalid rule configuration in group ${group.slug}. All rules are invalid.`,
110+
);
111+
}
112+
ui().logger.warning(
113+
`Some rules in group ${group.slug} are invalid: ${invalidRules.join(', ')}`,
114+
);
115+
}
116+
117+
return {
118+
slug: group.slug,
119+
title: group.title,
120+
refs,
121+
};
122+
});
123+
}
124+
125+
export function createRulesMap(rules: RuleData[]): Record<string, RuleData[]> {
126+
return rules.reduce<Record<string, RuleData[]>>(
127+
(acc, rule) => ({
128+
...acc,
129+
[rule.id]: [...(acc[rule.id] || []), rule],
130+
}),
131+
{},
132+
);
133+
}
134+
135+
export function resolveGroupRefs(
136+
groupRules: Record<string, number>,
137+
rulesMap: Record<string, RuleData[]>,
138+
): { refs: Group['refs']; invalidRules: string[] } {
139+
return Object.entries(groupRules).reduce<{
140+
refs: Group['refs'];
141+
invalidRules: string[];
142+
}>(
143+
(acc, [rule, weight]) => {
144+
const matchedRuleIds = rule.endsWith('*')
145+
? expandWildcardRules(rule, Object.keys(rulesMap))
146+
: [rule];
147+
148+
const matchedRefs = matchedRuleIds.flatMap(ruleId => {
149+
const matchingRules = rulesMap[ruleId] || [];
150+
const weightPerRule = weight / matchingRules.length;
151+
152+
return matchingRules.map(ruleData => ({
153+
slug: ruleToSlug(ruleData),
154+
weight: weightPerRule,
155+
}));
156+
});
157+
158+
return {
159+
refs: [...acc.refs, ...matchedRefs],
160+
invalidRules:
161+
matchedRefs.length > 0
162+
? acc.invalidRules
163+
: [...acc.invalidRules, rule],
164+
};
165+
},
166+
{ refs: [], invalidRules: [] },
167+
);
168+
}

0 commit comments

Comments
 (0)