Skip to content

Commit

Permalink
feat(plugin-eslint): support new config format in nx helpers
Browse files Browse the repository at this point in the history
  • Loading branch information
matejchalk committed Nov 29, 2024
1 parent 13579f3 commit effd5d2
Show file tree
Hide file tree
Showing 6 changed files with 507 additions and 144 deletions.
2 changes: 2 additions & 0 deletions packages/plugin-eslint/src/lib/meta/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { groupsFromRuleCategories, groupsFromRuleTypes } from './groups';
import { listRules } from './rules';
import { ruleToAudit } from './transform';

export { detectConfigVersion, type ConfigFormat } from './versions';

export async function listAuditsAndGroups(
targets: ESLintTarget[],
): Promise<{ audits: Audit[]; groups: Group[] }> {
Expand Down
40 changes: 20 additions & 20 deletions packages/plugin-eslint/src/lib/nx.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,21 @@ describe('Nx helpers', () => {
patterns: [
'packages/cli/**/*.ts',
'packages/cli/package.json',
'packages/cli/src/*.spec.ts',
'packages/cli/src/*.cy.ts',
'packages/cli/src/*.stories.ts',
'packages/cli/src/.storybook/main.ts',
'packages/cli/*.spec.ts',
'packages/cli/*.cy.ts',
'packages/cli/*.stories.ts',
'packages/cli/.storybook/main.ts',
],
},
{
eslintrc: './packages/core/.eslintrc.json',
patterns: [
'packages/core/**/*.ts',
'packages/core/package.json',
'packages/core/src/*.spec.ts',
'packages/core/src/*.cy.ts',
'packages/core/src/*.stories.ts',
'packages/core/src/.storybook/main.ts',
'packages/core/*.spec.ts',
'packages/core/*.cy.ts',
'packages/core/*.stories.ts',
'packages/core/.storybook/main.ts',
],
},
{
Expand All @@ -69,21 +69,21 @@ describe('Nx helpers', () => {
'packages/nx-plugin/**/*.ts',
'packages/nx-plugin/package.json',
'packages/nx-plugin/generators.json',
'packages/nx-plugin/src/*.spec.ts',
'packages/nx-plugin/src/*.cy.ts',
'packages/nx-plugin/src/*.stories.ts',
'packages/nx-plugin/src/.storybook/main.ts',
'packages/nx-plugin/*.spec.ts',
'packages/nx-plugin/*.cy.ts',
'packages/nx-plugin/*.stories.ts',
'packages/nx-plugin/.storybook/main.ts',
],
},
{
eslintrc: './packages/utils/.eslintrc.json',
patterns: [
'packages/utils/**/*.ts',
'packages/utils/package.json',
'packages/utils/src/*.spec.ts',
'packages/utils/src/*.cy.ts',
'packages/utils/src/*.stories.ts',
'packages/utils/src/.storybook/main.ts',
'packages/utils/*.spec.ts',
'packages/utils/*.cy.ts',
'packages/utils/*.stories.ts',
'packages/utils/.storybook/main.ts',
],
},
] satisfies ESLintTarget[]);
Expand All @@ -99,10 +99,10 @@ describe('Nx helpers', () => {
'packages/nx-plugin/**/*.ts',
'packages/nx-plugin/package.json',
'packages/nx-plugin/generators.json',
'packages/nx-plugin/src/*.spec.ts',
'packages/nx-plugin/src/*.cy.ts',
'packages/nx-plugin/src/*.stories.ts',
'packages/nx-plugin/src/.storybook/main.ts',
'packages/nx-plugin/*.spec.ts',
'packages/nx-plugin/*.cy.ts',
'packages/nx-plugin/*.stories.ts',
'packages/nx-plugin/.storybook/main.ts',
],
},
] satisfies ESLintTarget[]);
Expand Down
21 changes: 8 additions & 13 deletions packages/plugin-eslint/src/lib/nx/projects-to-config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { ProjectConfiguration, ProjectGraph } from '@nx/devkit';
import type { ESLintTarget } from '../config';
import { detectConfigVersion } from '../meta';
import {
findCodePushupEslintrc,
getEslintConfig,
findCodePushupEslintConfig,
findEslintConfig,
getLintFilePatterns,
} from './utils';

Expand All @@ -21,21 +22,15 @@ export async function nxProjectsToConfig(
.filter(predicate) // apply predicate
.sort((a, b) => a.root.localeCompare(b.root));

const format = await detectConfigVersion();

return Promise.all(
projects.map(
async (project): Promise<ESLintTarget> => ({
eslintrc:
(await findCodePushupEslintrc(project)) ?? getEslintConfig(project),
patterns: [
...getLintFilePatterns(project),
// HACK: ESLint.calculateConfigForFile won't find rules included only for subsets of *.ts when globs used
// so we explicitly provide additional patterns used by @code-pushup/eslint-config to ensure those rules are included
// this workaround won't be necessary once flat configs are stable (much easier to find all rules)
`${project.sourceRoot}/*.spec.ts`, // jest/* and vitest/* rules
`${project.sourceRoot}/*.cy.ts`, // cypress/* rules
`${project.sourceRoot}/*.stories.ts`, // storybook/* rules
`${project.sourceRoot}/.storybook/main.ts`, // storybook/no-uninstalled-addons rule
],
(await findCodePushupEslintConfig(project, format)) ??
(await findEslintConfig(project, format)),
patterns: getLintFilePatterns(project, format),
}),
),
);
Expand Down
74 changes: 47 additions & 27 deletions packages/plugin-eslint/src/lib/nx/projects-to-config.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,9 @@ describe('nxProjectsToConfig', () => {
const config = await nxProjectsToConfig(projectGraph);

expect(config).toEqual<ESLintPluginConfig>([
{
eslintrc: './apps/client/.eslintrc.json',
patterns: expect.arrayContaining(['apps/client/**/*.ts']),
},
{
eslintrc: './apps/server/.eslintrc.json',
patterns: expect.arrayContaining(['apps/server/**/*.ts']),
},
{
eslintrc: './libs/models/.eslintrc.json',
patterns: expect.arrayContaining(['libs/models/**/*.ts']),
},
{ patterns: expect.arrayContaining(['apps/client/**/*.ts']) },
{ patterns: expect.arrayContaining(['apps/server/**/*.ts']) },
{ patterns: expect.arrayContaining(['libs/models/**/*.ts']) },
]);
});

Expand Down Expand Up @@ -65,10 +56,7 @@ describe('nxProjectsToConfig', () => {
);

expect(config).toEqual<ESLintPluginConfig>([
{
eslintrc: './libs/models/.eslintrc.json',
patterns: expect.arrayContaining(['libs/models/**/*.ts']),
},
{ patterns: expect.arrayContaining(['libs/models/**/*.ts']) },
]);
});

Expand Down Expand Up @@ -107,18 +95,13 @@ describe('nxProjectsToConfig', () => {
const config = await nxProjectsToConfig(projectGraph);

expect(config).toEqual<ESLintPluginConfig>([
{
eslintrc: './apps/client/.eslintrc.json',
patterns: expect.arrayContaining(['apps/client/**/*.ts']),
},
{
eslintrc: './apps/server/.eslintrc.json',
patterns: expect.arrayContaining(['apps/server/**/*.ts']),
},
{ patterns: expect.arrayContaining(['apps/client/**/*.ts']) },
{ patterns: expect.arrayContaining(['apps/server/**/*.ts']) },
]);
});

it('should use code-pushup.eslintrc.json if available', async () => {
it('should use code-pushup.eslintrc.json if available and using legacy config', async () => {
vi.stubEnv('ESLINT_USE_FLAT_CONFIG', 'false');
vol.fromJSON(
{
'apps/client/code-pushup.eslintrc.json':
Expand All @@ -139,6 +122,45 @@ describe('nxProjectsToConfig', () => {
]);
});

it('should use eslint.strict.config.js if available and using flat config', async () => {
vi.stubEnv('ESLINT_USE_FLAT_CONFIG', 'true');
vol.fromJSON(
{
'apps/client/eslint.strict.config.js': 'export default [/*...*/]',
},
MEMFS_VOLUME,
);
const projectGraph = toProjectGraph([
{ name: 'client', type: 'app', data: { root: 'apps/client' } },
]);

const config = await nxProjectsToConfig(projectGraph);

expect(config).toEqual([
expect.objectContaining<Partial<ESLintTarget>>({
eslintrc: './apps/client/eslint.strict.config.js',
}),
]);
});

it('should NOT use code-pushup.eslintrc.json if available but using flat config', async () => {
vi.stubEnv('ESLINT_USE_FLAT_CONFIG', 'true');
vol.fromJSON(
{
'apps/client/code-pushup.eslintrc.json':
'{ "eslintrc": "@code-pushup" }',
},
MEMFS_VOLUME,
);
const projectGraph = toProjectGraph([
{ name: 'client', type: 'app', data: { root: 'apps/client' } },
]);

const config = await nxProjectsToConfig(projectGraph);

expect(config[0]!.eslintrc).toBeUndefined();
});

it("should use each project's lint file patterns", async () => {
const projectGraph = toProjectGraph([
{
Expand Down Expand Up @@ -176,14 +198,12 @@ describe('nxProjectsToConfig', () => {

await expect(nxProjectsToConfig(projectGraph)).resolves.toEqual([
{
eslintrc: './apps/client/.eslintrc.json',
patterns: expect.arrayContaining([
'apps/client/**/*.ts',
'apps/client/**/*.html',
]),
},
{
eslintrc: './apps/server/.eslintrc.json',
patterns: expect.arrayContaining(['apps/server/**/*.ts']),
},
] satisfies ESLintPluginConfig);
Expand Down
110 changes: 88 additions & 22 deletions packages/plugin-eslint/src/lib/nx/utils.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,105 @@
import type { ProjectConfiguration } from '@nx/devkit';
import { join } from 'node:path';
import { fileExists, toArray } from '@code-pushup/utils';
import type { ConfigFormat } from '../meta';

export async function findCodePushupEslintrc(
project: ProjectConfiguration,
): Promise<string | null> {
const name = 'code-pushup.eslintrc';
const ESLINT_CONFIG_EXTENSIONS: Record<ConfigFormat, string[]> = {
// https://eslint.org/docs/latest/use/configure/configuration-files#configuration-file-formats
flat: ['js', 'mjs', 'cjs'],
// https://eslint.org/docs/latest/use/configure/configuration-files-deprecated
legacy: ['json', 'js', 'cjs', 'yml', 'yaml'],
};
const ESLINT_CONFIG_NAMES: Record<ConfigFormat, string[]> = {
// https://eslint.org/docs/latest/use/configure/configuration-files#configuration-file-formats
const extensions = ['json', 'js', 'cjs', 'yml', 'yaml'];
flat: ['eslint.config'],
// https://eslint.org/docs/latest/use/configure/configuration-files-deprecated
legacy: ['.eslintrc'],
};

// eslint-disable-next-line functional/no-loop-statements
for (const ext of extensions) {
const filename = `./${project.root}/${name}.${ext}`;
if (await fileExists(join(process.cwd(), filename))) {
return filename;
}
}
const CP_ESLINT_CONFIG_NAMES: Record<ConfigFormat, string[]> = {
flat: [
'code-pushup.eslint.config',
'eslint.code-pushup.config',
'eslint.config.code-pushup',
'eslint.strict.config',
'eslint.config.strict',
],
legacy: ['code-pushup.eslintrc', '.eslintrc.code-pushup', '.eslintrc.strict'],
};

return null;
export async function findCodePushupEslintConfig(
project: ProjectConfiguration,
format: ConfigFormat,
): Promise<string | undefined> {
return findProjectFile(project, {
names: CP_ESLINT_CONFIG_NAMES[format],
extensions: ESLINT_CONFIG_EXTENSIONS[format],
});
}

export function getLintFilePatterns(project: ProjectConfiguration): string[] {
export async function findEslintConfig(
project: ProjectConfiguration,
format: ConfigFormat,
): Promise<string | undefined> {
const options = project.targets?.['lint']?.options as
| { lintFilePatterns?: string | string[] }
| { eslintConfig?: string }
| undefined;
return options?.lintFilePatterns == null
? [`${project.root}/**/*`] // lintFilePatterns defaults to ["{projectRoot}"] - https://github.com/nrwl/nx/pull/20313
: toArray(options.lintFilePatterns);
return (
options?.eslintConfig ??
(await findProjectFile(project, {
names: ESLINT_CONFIG_NAMES[format],
extensions: ESLINT_CONFIG_EXTENSIONS[format],
}))
);
}

export function getEslintConfig(
export function getLintFilePatterns(
project: ProjectConfiguration,
): string | undefined {
format: ConfigFormat,
): string[] {
const options = project.targets?.['lint']?.options as
| { eslintConfig?: string }
| { lintFilePatterns?: string | string[] }
| undefined;
return options?.eslintConfig ?? `./${project.root}/.eslintrc.json`;
// lintFilePatterns defaults to ["{projectRoot}"] - https://github.com/nrwl/nx/pull/20313
const defaultPatterns =
format === 'legacy'
? `${project.root}/**/*` // files not folder needed for legacy because rules detected with ESLint.calculateConfigForFile
: project.root;
const patterns =
options?.lintFilePatterns == null
? [defaultPatterns]
: toArray(options.lintFilePatterns);
if (format === 'legacy') {
return [
...patterns,
// HACK: ESLint.calculateConfigForFile won't find rules included only for subsets of *.ts when globs used
// so we explicitly provide additional patterns used by @code-pushup/eslint-config to ensure those rules are included
// this workaround is only necessary for legacy configs (rules are detected more reliably in flat configs)
`${project.root}/*.spec.ts`, // jest/* and vitest/* rules
`${project.root}/*.cy.ts`, // cypress/* rules
`${project.root}/*.stories.ts`, // storybook/* rules
`${project.root}/.storybook/main.ts`, // storybook/no-uninstalled-addons rule
];
}
return patterns;
}

async function findProjectFile(
project: ProjectConfiguration,
file: {
names: string[];
extensions: string[];
},
): Promise<string | undefined> {
// eslint-disable-next-line functional/no-loop-statements
for (const name of file.names) {
// eslint-disable-next-line functional/no-loop-statements
for (const ext of file.extensions) {
const filename = `./${project.root}/${name}.${ext}`;
if (await fileExists(join(process.cwd(), filename))) {
return filename;
}
}
}
return undefined;
}
Loading

0 comments on commit effd5d2

Please sign in to comment.