Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
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,7 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect } from 'vitest';
import { ConfigValidationError, readRcByPath } from './read-rc-file.js';
import { readRcByPath } from './read-rc-file.js';

describe('readRcByPath', () => {
const configDirPath = path.join(
Expand Down Expand Up @@ -69,7 +69,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(/invalid_type/);
});

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
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. Additionally, you can use wildcard patterns (`*`) to include multiple rules with similar prefixes.

```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-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
33 changes: 33 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,36 @@
targets: ESLintTarget[];
slugs: string[];
};

const customGroupRulesSchema = z.union(
[
z
.array(z.string())
.min(1, 'Custom group rules must contain at least 1 element'),
z.record(z.string(), z.number()).refine(
schema => Object.keys(schema).length > 0,
() => ({
code: 'too_small',
message: 'Custom group rules must contain at least 1 element',
}),
),
],
{
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>;

Check warning on line 63 in packages/plugin-eslint/src/lib/config.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> JSDoc coverage | Types coverage

Missing types documentation for CustomGroup

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,11 +71,76 @@ describe('eslintPlugin', () => {
);
});

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'],
},
{
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 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(/Custom group rules must contain at least 1 element/);
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(/Custom group rules must contain at least 1 element/);
});

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(/Invalid input/);
});

it("should throw if eslintrc file doesn't exist", async () => {
Expand Down
22 changes: 19 additions & 3 deletions packages/plugin-eslint/src/lib/eslint-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ 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 { parseSchema } from '@code-pushup/utils';
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 +30,24 @@ 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 targets = parseSchema(eslintPluginConfigSchema, config, {
schemaType: 'ESLint plugin config',
});

const { audits, groups } = await listAuditsAndGroups(targets);
const customGroups = options
? parseSchema(eslintPluginOptionsSchema, options, {
schemaType: 'ESLint plugin options',
}).groups
: undefined;

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

const runnerScriptPath = path.join(
fileURLToPath(path.dirname(import.meta.url)),
Expand Down
81 changes: 80 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,80 @@

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

export function groupsFromCustomConfig(

Check warning on line 93 in packages/plugin-eslint/src/lib/meta/groups.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> JSDoc coverage | Functions coverage

Missing functions documentation for 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]))

Check failure on line 101 in packages/plugin-eslint/src/lib/meta/groups.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> Code coverage | Branch coverage

1st branch is not taken in any test case.
: group.rules;

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

if (invalidRules.length > 0 && Object.entries(groupRules).length > 0) {

Check failure on line 106 in packages/plugin-eslint/src/lib/meta/groups.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> Code coverage | Branch coverage

1st branch is not taken in any test case.
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[]> {

Check warning on line 125 in packages/plugin-eslint/src/lib/meta/groups.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> JSDoc coverage | Functions coverage

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

export function resolveGroupRefs(

Check warning on line 135 in packages/plugin-eslint/src/lib/meta/groups.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> JSDoc coverage | Functions coverage

Missing functions documentation for resolveGroupRefs
groupRules: Record<string, number>,
rulesMap: Record<string, RuleData[]>,
): { refs: Group['refs']; invalidRules: string[] } {
return Object.entries(groupRules).reduce<{
refs: Group['refs'];
invalidRules: string[];
}>(
(acc, [rule, weight]) => {
const matchedRuleIds = rule.endsWith('*')
? expandWildcardRules(rule, Object.keys(rulesMap))
: [rule];

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

Check failure on line 149 in packages/plugin-eslint/src/lib/meta/groups.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> Code coverage | Branch coverage

1st branch is not taken in any test case.
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

Check failure on line 162 in packages/plugin-eslint/src/lib/meta/groups.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> Code coverage | Branch coverage

1st branch is not taken in any test case.
: [...acc.invalidRules, rule],
};
},
{ refs: [], invalidRules: [] },
);
}
Loading
Loading