Skip to content

Commit

Permalink
feat(plugin-eslint): support array of config and patterns to lint sep…
Browse files Browse the repository at this point in the history
…arately
  • Loading branch information
matejchalk committed Apr 29, 2024
1 parent f11f8a7 commit 7b1e458
Show file tree
Hide file tree
Showing 10 changed files with 109 additions and 97 deletions.
12 changes: 8 additions & 4 deletions packages/plugin-eslint/src/lib/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { ESLint } from 'eslint';
import { type ZodType, z } from 'zod';
import { toArray } from '@code-pushup/utils';

export const eslintPluginConfigSchema = z.object({
export const eslintTargetSchema = z.object({
eslintrc: z.union(
[
z.string({ description: 'Path to ESLint config file' }),
Expand All @@ -16,11 +17,14 @@ export const eslintPluginConfigSchema = z.object({
'Lint target files. May contain file paths, directory paths or glob patterns',
}),
});
export type ESLintTarget = z.infer<typeof eslintTargetSchema>;

export type ESLintPluginConfig = z.infer<typeof eslintPluginConfigSchema>;
export const eslintPluginConfigSchema = z
.union([eslintTargetSchema, z.array(eslintTargetSchema).min(1)])
.transform(toArray);
export type ESLintPluginConfig = z.input<typeof eslintPluginConfigSchema>;

export type ESLintPluginRunnerConfig = {
eslintrc: string;
targets: ESLintTarget[];
slugs: string[];
patterns: string[];
};
24 changes: 4 additions & 20 deletions packages/plugin-eslint/src/lib/eslint-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PluginConfig } from '@code-pushup/models';
import { name, version } from '../../package.json';
import { ESLintPluginConfig, eslintPluginConfigSchema } from './config';
import { listAuditsAndGroups } from './meta';
import { ESLINTRC_PATH, createRunnerConfig } from './runner';
import { setupESLint } from './setup';
import { createRunnerConfig } from './runner';

/**
* Instantiates Code PushUp ESLint plugin for use in core config.
Expand All @@ -31,18 +29,9 @@ import { setupESLint } from './setup';
export async function eslintPlugin(
config: ESLintPluginConfig,
): Promise<PluginConfig> {
const { eslintrc, patterns } = eslintPluginConfigSchema.parse(config);
const targets = eslintPluginConfigSchema.parse(config);

const eslint = setupESLint(eslintrc);

const { audits, groups } = await listAuditsAndGroups(eslint, patterns);

// save inline config to file so runner can access it later
if (typeof eslintrc !== 'string') {
await mkdir(dirname(ESLINTRC_PATH), { recursive: true });
await writeFile(ESLINTRC_PATH, JSON.stringify(eslintrc));
}
const eslintrcPath = typeof eslintrc === 'string' ? eslintrc : ESLINTRC_PATH;
const { audits, groups } = await listAuditsAndGroups(targets);

const runnerScriptPath = join(
fileURLToPath(dirname(import.meta.url)),
Expand All @@ -61,11 +50,6 @@ export async function eslintPlugin(
audits,
groups,

runner: await createRunnerConfig(
runnerScriptPath,
audits,
eslintrcPath,
patterns,
),
runner: await createRunnerConfig(runnerScriptPath, audits, targets),
};
}
7 changes: 3 additions & 4 deletions packages/plugin-eslint/src/lib/meta/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import type { ESLint } from 'eslint';
import type { Audit, Group } from '@code-pushup/models';
import type { ESLintTarget } from '../config';
import { groupsFromRuleCategories, groupsFromRuleTypes } from './groups';
import { listRules } from './rules';
import { ruleToAudit } from './transform';

export async function listAuditsAndGroups(
eslint: ESLint,
patterns: string | string[],
targets: ESLintTarget[],
): Promise<{ audits: Audit[]; groups: Group[] }> {
const rules = await listRules(eslint, patterns);
const rules = await listRules(targets);

const audits = rules.map(ruleToAudit);

Expand Down
79 changes: 51 additions & 28 deletions packages/plugin-eslint/src/lib/meta/rules.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ESLint, Linter, Rule } from 'eslint';
import { distinct, toArray, ui } from '@code-pushup/utils';
import type { ESLintTarget } from '../config';
import { setupESLint } from '../setup';
import { jsonHash } from './hash';

export type RuleData = {
Expand All @@ -8,10 +10,23 @@ export type RuleData = {
options: unknown[] | undefined;
};

export async function listRules(
type RulesMap = Record<string, Record<string, RuleData>>;

export async function listRules(targets: ESLintTarget[]): Promise<RuleData[]> {
const rulesMap = await targets.reduce(async (acc, { eslintrc, patterns }) => {
const eslint = setupESLint(eslintrc);
const prev = await acc;
const curr = await loadRulesMap(eslint, patterns);
return mergeRulesMaps(prev, curr);
}, Promise.resolve<RulesMap>({}));

return Object.values(rulesMap).flatMap<RuleData>(Object.values);
}

async function loadRulesMap(
eslint: ESLint,
patterns: string | string[],
): Promise<RuleData[]> {
): Promise<RulesMap> {
const configs = await toArray(patterns).reduce(
async (acc, pattern) => [
...(await acc),
Expand All @@ -31,35 +46,43 @@ export async function listRules(
} as ESLint.LintResult,
]);

const rulesMap = configs
return configs
.flatMap(config => Object.entries(config.rules ?? {}))
.filter(([, ruleEntry]) => ruleEntry != null && !isRuleOff(ruleEntry))
.reduce<Record<string, Record<string, RuleData>>>(
(acc, [ruleId, ruleEntry]) => {
const meta = rulesMeta[ruleId];
if (!meta) {
ui().logger.warning(`Metadata not found for ESLint rule ${ruleId}`);
return acc;
}
const options = toArray(ruleEntry).slice(1);
const optionsHash = jsonHash(options);
const ruleData: RuleData = {
ruleId,
meta,
options,
};
return {
...acc,
[ruleId]: {
...acc[ruleId],
[optionsHash]: ruleData,
},
};
},
{},
);
.reduce<RulesMap>((acc, [ruleId, ruleEntry]) => {
const meta = rulesMeta[ruleId];
if (!meta) {
ui().logger.warning(`Metadata not found for ESLint rule ${ruleId}`);
return acc;
}
const options = toArray(ruleEntry).slice(1);
const optionsHash = jsonHash(options);
const ruleData: RuleData = {
ruleId,
meta,
options,
};
return {
...acc,
[ruleId]: {
...acc[ruleId],
[optionsHash]: ruleData,
},
};
}, {});
}

return Object.values(rulesMap).flatMap<RuleData>(Object.values);
function mergeRulesMaps(prev: RulesMap, curr: RulesMap): RulesMap {
return Object.entries(curr).reduce(
(acc, [ruleId, ruleVariants]) => ({
...acc,
[ruleId]: {
...acc[ruleId],
...ruleVariants,
},
}),
prev,
);
}

function isRuleOff(entry: Linter.RuleEntry<unknown[]>): boolean {
Expand Down
30 changes: 12 additions & 18 deletions packages/plugin-eslint/src/lib/meta/rules.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ESLint } from 'eslint';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { MockInstance } from 'vitest';
import type { ESLintTarget } from '../config';
import { RuleData, listRules, parseRuleId } from './rules';

describe('listRules', () => {
Expand All @@ -28,22 +28,19 @@ describe('listRules', () => {
const appRootDir = join(fixturesDir, 'todos-app');
const eslintrc = join(appRootDir, '.eslintrc.js');

const eslint = new ESLint({
useEslintrc: false,
baseConfig: { extends: eslintrc },
});
const patterns = ['src/**/*.js', 'src/**/*.jsx'];
const targets: ESLintTarget[] = [{ eslintrc, patterns }];

beforeAll(() => {
cwdSpy.mockReturnValue(appRootDir);
});

it('should list expected number of rules', async () => {
await expect(listRules(eslint, patterns)).resolves.toHaveLength(47);
await expect(listRules(targets)).resolves.toHaveLength(47);
});

it('should include explicitly set built-in rule', async () => {
await expect(listRules(eslint, patterns)).resolves.toContainEqual({
await expect(listRules(targets)).resolves.toContainEqual({
ruleId: 'no-const-assign',
meta: {
docs: {
Expand All @@ -62,7 +59,7 @@ describe('listRules', () => {
});

it('should include explicitly set plugin rule', async () => {
await expect(listRules(eslint, patterns)).resolves.toContainEqual({
await expect(listRules(targets)).resolves.toContainEqual({
ruleId: 'react/jsx-key',
meta: {
docs: {
Expand Down Expand Up @@ -94,24 +91,21 @@ describe('listRules', () => {
const nxRootDir = join(fixturesDir, 'nx-monorepo');
const eslintrc = join(nxRootDir, 'packages/utils/.eslintrc.json');

const eslint = new ESLint({
useEslintrc: false,
baseConfig: { extends: eslintrc },
});
const patterns = ['packages/utils/**/*.ts', 'packages/utils/**/*.json'];
const targets: ESLintTarget[] = [{ eslintrc, patterns }];

beforeAll(() => {
cwdSpy.mockReturnValue(nxRootDir);
});

it('should list expected number of rules', async () => {
const rules = await listRules(eslint, patterns);
const rules = await listRules(targets);
expect(rules.length).toBeGreaterThanOrEqual(50);
});

it('should include explicitly set plugin rule with custom options', async () => {
// set in root .eslintrc.json
await expect(listRules(eslint, patterns)).resolves.toContainEqual({
await expect(listRules(targets)).resolves.toContainEqual({
ruleId: '@nx/enforce-module-boundaries',
meta: expect.any(Object),
options: [
Expand All @@ -131,7 +125,7 @@ describe('listRules', () => {

it('should include built-in rule set implicitly by extending recommended config', async () => {
// extended via @nx/typescript -> @typescript-eslint/eslint-recommended
await expect(listRules(eslint, patterns)).resolves.toContainEqual({
await expect(listRules(targets)).resolves.toContainEqual({
ruleId: 'no-var',
meta: expect.any(Object),
options: [],
Expand All @@ -140,7 +134,7 @@ describe('listRules', () => {

it('should include plugin rule set implicitly by extending recommended config', async () => {
// extended via @nx/typescript -> @typescript-eslint/recommended
await expect(listRules(eslint, patterns)).resolves.toContainEqual({
await expect(listRules(targets)).resolves.toContainEqual({
ruleId: '@typescript-eslint/no-unused-vars',
meta: expect.any(Object),
options: [],
Expand All @@ -149,7 +143,7 @@ describe('listRules', () => {

it('should not include rule which was turned off in extended config', async () => {
// extended TypeScript config sets "no-unused-semi": "off"
await expect(listRules(eslint, patterns)).resolves.not.toContainEqual(
await expect(listRules(targets)).resolves.not.toContainEqual(
expect.objectContaining({
ruleId: 'no-unused-vars',
} satisfies Partial<RuleData>),
Expand All @@ -158,7 +152,7 @@ describe('listRules', () => {

it('should include rule added to root config by project config', async () => {
// set only in packages/utils/.eslintrc.json
await expect(listRules(eslint, patterns)).resolves.toContainEqual({
await expect(listRules(targets)).resolves.toContainEqual({
ruleId: '@nx/dependency-checks',
meta: expect.any(Object),
options: [],
Expand Down
8 changes: 4 additions & 4 deletions packages/plugin-eslint/src/lib/runner.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { MockInstance, describe, expect, it } from 'vitest';
import type { AuditOutput, AuditOutputs, Issue } from '@code-pushup/models';
import { osAgnosticAuditOutputs } from '@code-pushup/test-utils';
import { ensureDirectoryExists, readJsonFile } from '@code-pushup/utils';
import type { ESLintTarget } from './config';
import { listAuditsAndGroups } from './meta';
import {
ESLINTRC_PATH,
Expand All @@ -15,17 +16,16 @@ import {
createRunnerConfig,
executeRunner,
} from './runner';
import { setupESLint } from './setup';

describe('executeRunner', () => {
let cwdSpy: MockInstance<[], string>;
let platformSpy: MockInstance<[], NodeJS.Platform>;

const createPluginConfig = async (eslintrc: string) => {
const patterns = ['src/**/*.js', 'src/**/*.jsx'];
const eslint = setupESLint(eslintrc);
const { audits } = await listAuditsAndGroups(eslint, patterns);
await createRunnerConfig('bin.js', audits, eslintrc, patterns);
const targets: ESLintTarget[] = [{ eslintrc, patterns }];
const { audits } = await listAuditsAndGroups(targets);
await createRunnerConfig('bin.js', audits, targets);
};

const appDir = join(
Expand Down
28 changes: 13 additions & 15 deletions packages/plugin-eslint/src/lib/runner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import {
ensureDirectoryExists,
pluginWorkDir,
readJsonFile,
toArray,
} from '@code-pushup/utils';
import { ESLintPluginRunnerConfig } from '../config';
import { ESLintPluginRunnerConfig, type ESLintTarget } from '../config';
import { lint } from './lint';
import { lintResultsToAudits } from './transform';
import { lintResultsToAudits, mergeLinterOutputs } from './transform';
import type { LinterOutput } from './types';

export const WORKDIR = pluginWorkDir('eslint');
export const RUNNER_OUTPUT_PATH = join(WORKDIR, 'runner-output.json');
Expand All @@ -21,15 +21,15 @@ export const PLUGIN_CONFIG_PATH = join(
);

export async function executeRunner(): Promise<void> {
const { slugs, eslintrc, patterns } =
await readJsonFile<ESLintPluginRunnerConfig>(PLUGIN_CONFIG_PATH);
const { slugs, targets } = await readJsonFile<ESLintPluginRunnerConfig>(
PLUGIN_CONFIG_PATH,
);

const lintResults = await lint({
// if file created from inline object, provide inline to preserve relative links
eslintrc:
eslintrc === ESLINTRC_PATH ? await readJsonFile(eslintrc) : eslintrc,
patterns,
});
const linterOutputs = await targets.reduce(
async (acc, target) => [...(await acc), await lint(target)],
Promise.resolve<LinterOutput[]>([]),
);
const lintResults = mergeLinterOutputs(linterOutputs);
const failedAudits = lintResultsToAudits(lintResults);

const audits = slugs.map(
Expand All @@ -50,13 +50,11 @@ export async function executeRunner(): Promise<void> {
export async function createRunnerConfig(
scriptPath: string,
audits: Audit[],
eslintrc: string,
patterns: string | string[],
targets: ESLintTarget[],
): Promise<RunnerConfig> {
const config: ESLintPluginRunnerConfig = {
eslintrc,
targets,
slugs: audits.map(audit => audit.slug),
patterns: toArray(patterns),
};
await ensureDirectoryExists(dirname(PLUGIN_CONFIG_PATH));
await writeFile(PLUGIN_CONFIG_PATH, JSON.stringify(config));
Expand Down
Loading

0 comments on commit 7b1e458

Please sign in to comment.