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
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

Check failure on line 66 in packages/plugin-eslint/src/lib/nx/utils.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> Code coverage | Branch coverage

1st branch is not taken in any test case.
: project.root;
const patterns =
options?.lintFilePatterns == null

Check failure on line 69 in packages/plugin-eslint/src/lib/nx/utils.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> Code coverage | Branch coverage

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

Check failure on line 83 in packages/plugin-eslint/src/lib/nx/utils.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> Code coverage | Branch coverage

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