Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/plugin-eslint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,42 @@ Detected ESLint rules are mapped to Code PushUp audits. Audit reports are calcul

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

### Custom groups

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.

```js
import eslintPlugin from '@code-pushup/eslint-plugin';

export default {
// ...
plugins: [
// ...
await eslintPlugin(
{ eslintrc: '.eslintrc.js', patterns: ['src/**/*.js'] },
{
groups: [
{
slug: 'modern-angular',
title: 'Modern Angular',
rules: {
'@angular-eslint/template/prefer-control-flow': 3,
'@angular-eslint/template/prefer-ngsrc': 2,
'@angular-eslint/component-selector': 1,
},
},
{
slug: 'type-safety',
title: 'Type safety',
rules: ['@typescript-eslint/no-explicit-any', '@typescript-eslint/no-unsafe-*'],
},
],
},
),
],
};
```

### Optionally set up categories

1. Reference audits (or groups) which you wish to include in custom categories (use `npx code-pushup print-config` to list audits and groups).
Expand Down
22 changes: 22 additions & 0 deletions packages/plugin-eslint/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,25 @@ export type ESLintPluginRunnerConfig = {
targets: ESLintTarget[];
slugs: string[];
};

const customGroupRulesSchema = z.union(
[z.array(z.string()).min(1), z.record(z.string(), z.number())],
{
description:
'Array of rule IDs with equal weights or object mapping rule IDs to specific weights',
},
);

const customGroupSchema = z.object({
slug: z.string({ description: 'Unique group identifier' }),
title: z.string({ description: 'Group display title' }),
description: z.string({ description: 'Group metadata' }).optional(),
docsUrl: z.string({ description: 'Group documentation site' }).optional(),
rules: customGroupRulesSchema,
});
export type CustomGroup = z.infer<typeof customGroupSchema>;

export const eslintPluginOptionsSchema = z.object({
groups: z.array(customGroupSchema).optional(),
});
export type ESLintPluginOptions = z.infer<typeof eslintPluginOptionsSchema>;
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,49 @@ describe('eslintPlugin', () => {
);
});

it('should initialize with plugin options for custom rules', 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',
],
},
{
groups: [
{
slug: 'type-safety',
title: 'Type safety',
rules: [
'@typescript-eslint/no-explicit-any',
'@typescript-eslint/no-unsafe-*',
],
},
],
},
);

expect(plugin.groups).toContainEqual({
slug: 'type-safety',
title: 'Type safety',
refs: [
{ slug: 'typescript-eslint-no-explicit-any', weight: 1 },
{
slug: 'typescript-eslint-no-unsafe-declaration-merging',
weight: 1,
},
{ slug: 'typescript-eslint-no-unsafe-function-type', weight: 1 },
],
});
expect(plugin.audits).toContainEqual(
expect.objectContaining<Partial<Audit>>({
slug: 'typescript-eslint-no-explicit-any',
}),
);
});

it('should throw when invalid parameters provided', async () => {
await expect(
// @ts-expect-error simulating invalid non-TS config
Expand Down
15 changes: 13 additions & 2 deletions packages/plugin-eslint/src/lib/eslint-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { PluginConfig } from '@code-pushup/models';
import { type ESLintPluginConfig, eslintPluginConfigSchema } from './config.js';
import {
type ESLintPluginConfig,
type ESLintPluginOptions,
eslintPluginConfigSchema,
eslintPluginOptionsSchema,
} from './config.js';
import { listAuditsAndGroups } from './meta/index.js';
import { createRunnerConfig } from './runner/index.js';

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

const { audits, groups } = await listAuditsAndGroups(targets);
const customGroups = options
? eslintPluginOptionsSchema.parse(options).groups
: undefined;

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

const runnerScriptPath = path.join(
fileURLToPath(path.dirname(import.meta.url)),
Expand Down
83 changes: 82 additions & 1 deletion packages/plugin-eslint/src/lib/meta/groups.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Rule } from 'eslint';
import type { Group, GroupRef } from '@code-pushup/models';
import { objectToKeys, slugify } from '@code-pushup/utils';
import { objectToKeys, slugify, ui } from '@code-pushup/utils';
import type { CustomGroup } from '../config.js';
import { ruleToSlug } from './hash.js';
import { type RuleData, parseRuleId } from './parse.js';
import { expandWildcardRules } from './rules.js';

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

Expand Down Expand Up @@ -87,3 +89,82 @@ export function groupsFromRuleCategories(rules: RuleData[]): Group[] {

return groups.toSorted((a, b) => a.slug.localeCompare(b.slug));
}

export function groupsFromCustomConfig(
rules: RuleData[],
groups: CustomGroup[],
): Group[] {
const rulesMap = createRulesMap(rules);

return groups.map(group => {
const groupRules = Array.isArray(group.rules)
? Object.fromEntries(group.rules.map(rule => [rule, 1]))
: group.rules;

const { refs, invalidRules } = resolveGroupRefs(groupRules, rulesMap);

if (invalidRules.length > 0 && Object.entries(groupRules).length > 0) {
if (refs.length === 0) {
throw new Error(
`Invalid rule configuration in group ${group.slug}. All rules are invalid.`,
);
}
ui().logger.warning(
`Some rules in group ${group.slug} are invalid: ${invalidRules.join(', ')}`,
);
}

return {
slug: group.slug,
title: group.title,
refs,
};
});
}

export function createRulesMap(rules: RuleData[]): Record<string, RuleData[]> {
return rules.reduce<Record<string, RuleData[]>>(
(acc, rule) => ({
...acc,
[rule.id]: [...(acc[rule.id] || []), rule],
}),
{},
);
}

export function resolveGroupRefs(
groupRules: Record<string, number>,
rulesMap: Record<string, RuleData[]>,
): { refs: Group['refs']; invalidRules: string[] } {
const uniqueRuleIds = [...new Set(Object.keys(rulesMap))];

return Object.entries(groupRules).reduce<{
refs: Group['refs'];
invalidRules: string[];
}>(
(acc, [rule, weight]) => {
const matchedRuleIds = rule.endsWith('*')
? expandWildcardRules(rule, uniqueRuleIds)
: [rule];

const matchedRefs = matchedRuleIds.flatMap(ruleId => {
const matchingRules = rulesMap[ruleId] || [];
const weightPerRule = weight / matchingRules.length;

return matchingRules.map(ruleData => ({
slug: ruleToSlug(ruleData),
weight: weightPerRule,
}));
});

return {
refs: [...acc.refs, ...matchedRefs],
invalidRules:
matchedRefs.length > 0
? acc.invalidRules
: [...acc.invalidRules, rule],
};
},
{ refs: [], invalidRules: [] },
);
}
Loading
Loading