Skip to content

Commit 7fb2fdc

Browse files
committed
feat(plugin-eslint): add support for custom groups
1 parent d9e43ee commit 7fb2fdc

File tree

11 files changed

+483
-7
lines changed

11 files changed

+483
-7
lines changed

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.
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-explicit-any', '@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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,25 @@ export type ESLintPluginRunnerConfig = {
3333
targets: ESLintTarget[];
3434
slugs: string[];
3535
};
36+
37+
const customGroupRulesSchema = z.union(
38+
[z.array(z.string()).min(1), z.record(z.string(), z.number())],
39+
{
40+
description:
41+
'Array of rule IDs with equal weights or object mapping rule IDs to specific weights',
42+
},
43+
);
44+
45+
const customGroupSchema = z.object({
46+
slug: z.string({ description: 'Unique group identifier' }),
47+
title: z.string({ description: 'Group display title' }),
48+
description: z.string({ description: 'Group metadata' }).optional(),
49+
docsUrl: z.string({ description: 'Group documentation site' }).optional(),
50+
rules: customGroupRulesSchema,
51+
});
52+
export type CustomGroup = z.infer<typeof customGroupSchema>;
53+
54+
export const eslintPluginOptionsSchema = z.object({
55+
groups: z.array(customGroupSchema).optional(),
56+
});
57+
export type ESLintPluginOptions = z.infer<typeof eslintPluginOptionsSchema>;

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,49 @@ describe('eslintPlugin', () => {
7171
);
7272
});
7373

74+
it('should initialize with plugin options for custom rules', 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: [
80+
'packages/nx-plugin/**/*.ts',
81+
'packages/nx-plugin/**/*.json',
82+
],
83+
},
84+
{
85+
groups: [
86+
{
87+
slug: 'type-safety',
88+
title: 'Type safety',
89+
rules: [
90+
'@typescript-eslint/no-explicit-any',
91+
'@typescript-eslint/no-unsafe-*',
92+
],
93+
},
94+
],
95+
},
96+
);
97+
98+
expect(plugin.groups).toContainEqual({
99+
slug: 'type-safety',
100+
title: 'Type safety',
101+
refs: [
102+
{ slug: 'typescript-eslint-no-explicit-any', weight: 1 },
103+
{
104+
slug: 'typescript-eslint-no-unsafe-declaration-merging',
105+
weight: 1,
106+
},
107+
{ slug: 'typescript-eslint-no-unsafe-function-type', weight: 1 },
108+
],
109+
});
110+
expect(plugin.audits).toContainEqual(
111+
expect.objectContaining<Partial<Audit>>({
112+
slug: 'typescript-eslint-no-explicit-any',
113+
}),
114+
);
115+
});
116+
74117
it('should throw when invalid parameters provided', async () => {
75118
await expect(
76119
// @ts-expect-error simulating invalid non-TS config

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ 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 {
6+
type ESLintPluginConfig,
7+
type ESLintPluginOptions,
8+
eslintPluginConfigSchema,
9+
eslintPluginOptionsSchema,
10+
} from './config.js';
611
import { listAuditsAndGroups } from './meta/index.js';
712
import { createRunnerConfig } from './runner/index.js';
813

@@ -24,14 +29,20 @@ import { createRunnerConfig } from './runner/index.js';
2429
* }
2530
*
2631
* @param config Configuration options.
32+
* @param options Optional settings for customizing the plugin behavior.
2733
* @returns Plugin configuration as a promise.
2834
*/
2935
export async function eslintPlugin(
3036
config: ESLintPluginConfig,
37+
options?: ESLintPluginOptions,
3138
): Promise<PluginConfig> {
3239
const targets = eslintPluginConfigSchema.parse(config);
3340

34-
const { audits, groups } = await listAuditsAndGroups(targets);
41+
const customGroups = options
42+
? eslintPluginOptionsSchema.parse(options).groups
43+
: undefined;
44+
45+
const { audits, groups } = await listAuditsAndGroups(targets, customGroups);
3546

3647
const runnerScriptPath = path.join(
3748
fileURLToPath(path.dirname(import.meta.url)),

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

Lines changed: 82 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,82 @@ 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+
const uniqueRuleIds = [...new Set(Object.keys(rulesMap))];
140+
141+
return Object.entries(groupRules).reduce<{
142+
refs: Group['refs'];
143+
invalidRules: string[];
144+
}>(
145+
(acc, [rule, weight]) => {
146+
const matchedRuleIds = rule.endsWith('*')
147+
? expandWildcardRules(rule, uniqueRuleIds)
148+
: [rule];
149+
150+
const matchedRefs = matchedRuleIds.flatMap(ruleId => {
151+
const matchingRules = rulesMap[ruleId] || [];
152+
const weightPerRule = weight / matchingRules.length;
153+
154+
return matchingRules.map(ruleData => ({
155+
slug: ruleToSlug(ruleData),
156+
weight: weightPerRule,
157+
}));
158+
});
159+
160+
return {
161+
refs: [...acc.refs, ...matchedRefs],
162+
invalidRules:
163+
matchedRefs.length > 0
164+
? acc.invalidRules
165+
: [...acc.invalidRules, rule],
166+
};
167+
},
168+
{ refs: [], invalidRules: [] },
169+
);
170+
}

0 commit comments

Comments
 (0)