Skip to content
16 changes: 14 additions & 2 deletions packages/eslint/src/executors/lint/lint.impl.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { joinPathFragments, output, type ExecutorContext } from '@nx/devkit';
import { assertSupportedInstalledPackageVersion } from '@nx/devkit/internal';
import {
assertSupportedInstalledPackageVersion,
getInstalledPackageVersion,
} from '@nx/devkit/internal';
import type { ESLint } from 'eslint';
import { mkdirSync, writeFileSync } from 'fs';
import { interpolate } from 'nx/src/tasks-runner/utils';
import { dirname, posix, relative, resolve } from 'path';
import { major } from 'semver';
import { findFlatConfigFile, findOldConfigFile } from '../../utils/config-file';
import { warnEslintExecutorDeprecation } from '../../utils/deprecation';
import {
warnEslintExecutorDeprecation,
warnEslintV8Deprecation,
} from '../../utils/deprecation';
import { minSupportedEslintVersion } from '../../utils/versions';
import type { Schema } from './schema';
import { resolveAndInstantiateESLint } from './utility/eslint-utils';
Expand Down Expand Up @@ -69,6 +76,11 @@ export default async function run(

assertSupportedInstalledPackageVersion('eslint', minSupportedEslintVersion);

const installedEslintVersion = getInstalledPackageVersion('eslint');
if (installedEslintVersion && major(installedEslintVersion) === 8) {
warnEslintV8Deprecation();
}

if (printConfig) {
try {
const fileConfig = await eslint.calculateConfigForFile(printConfig);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,15 @@ export const rule = ESLintUtils.RuleCreator(() => __filename)({
`;

exports[`@nx/eslint:workspace-rule --dir should support creating the rule in a nested directory 2`] = `
"import { TSESLint } from '@typescript-eslint/utils';
"import { RuleTester } from '@typescript-eslint/rule-tester';
import type { RuleTesterConfig } from '@typescript-eslint/rule-tester';
import { rule, RULE_NAME } from './another-rule';

const ruleTester = new TSESLint.RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
});
const ruleTester = new RuleTester({
languageOptions: {
parser: require('@typescript-eslint/parser'),
},
} as RuleTesterConfig);

ruleTester.run(RULE_NAME, rule, {
valid: [\`const example = true;\`],
Expand Down Expand Up @@ -96,12 +99,15 @@ export const rule = ESLintUtils.RuleCreator(() => __filename)({
`;

exports[`@nx/eslint:workspace-rule --dir should support creating the rule in a nested directory with multiple levels of nesting 2`] = `
"import { TSESLint } from '@typescript-eslint/utils';
"import { RuleTester } from '@typescript-eslint/rule-tester';
import type { RuleTesterConfig } from '@typescript-eslint/rule-tester';
import { rule, RULE_NAME } from './one-more-rule';

const ruleTester = new TSESLint.RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
});
const ruleTester = new RuleTester({
languageOptions: {
parser: require('@typescript-eslint/parser'),
},
} as RuleTesterConfig);

ruleTester.run(RULE_NAME, rule, {
valid: [\`const example = true;\`],
Expand Down Expand Up @@ -151,12 +157,15 @@ export const rule = ESLintUtils.RuleCreator(() => __filename)({
`;

exports[`@nx/eslint:workspace-rule should generate the required files 2`] = `
"import { TSESLint } from '@typescript-eslint/utils';
"import { RuleTester } from '@typescript-eslint/rule-tester';
import type { RuleTesterConfig } from '@typescript-eslint/rule-tester';
import { rule, RULE_NAME } from './my-rule';

const ruleTester = new TSESLint.RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
});
const ruleTester = new RuleTester({
languageOptions: {
parser: require('@typescript-eslint/parser'),
},
} as RuleTesterConfig);

ruleTester.run(RULE_NAME, rule, {
valid: [\`const example = true;\`],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<% if (flatConfig) { %>import { RuleTester } from '@typescript-eslint/rule-tester';
<% if (useFlatRuleTester) { %>import { RuleTester } from '@typescript-eslint/rule-tester';
import type { RuleTesterConfig } from '@typescript-eslint/rule-tester';
import { rule, RULE_NAME } from './<%= name %>';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'nx/src/internal-testing-utils/mock-project-graph';

import { Tree } from '@nx/devkit';
import { readJson, Tree, updateJson } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { lintWorkspaceRuleGenerator } from './workspace-rule';

Expand Down Expand Up @@ -152,6 +152,49 @@ describe('@nx/eslint:workspace-rule', () => {
`);
});

describe('ESLint v9 + eslintrc workspaces', () => {
let originalEnv: string | undefined;

beforeEach(() => {
originalEnv = process.env.ESLINT_USE_FLAT_CONFIG;
process.env.ESLINT_USE_FLAT_CONFIG = 'false';
updateJson(tree, 'package.json', (json) => {
json.devDependencies = {
...json.devDependencies,
eslint: '^9.8.0',
};
return json;
});
});

afterEach(() => {
if (originalEnv === undefined) {
delete process.env.ESLINT_USE_FLAT_CONFIG;
} else {
process.env.ESLINT_USE_FLAT_CONFIG = originalEnv;
}
});

it('should generate the flat-style rule-test template and install @typescript-eslint/rule-tester', async () => {
await lintWorkspaceRuleGenerator(tree, {
name: 'my-rule',
directory: 'rules',
});

const spec = tree.read(
'tools/eslint-rules/rules/my-rule.spec.ts',
'utf-8'
);
expect(spec).toContain("from '@typescript-eslint/rule-tester'");
expect(spec).not.toContain("from '@typescript-eslint/utils'");

const packageJson = readJson(tree, 'package.json');
expect(
packageJson.devDependencies['@typescript-eslint/rule-tester']
).toBeDefined();
});
});

describe('--dir', () => {
it('should support creating the rule in a nested directory', async () => {
await lintWorkspaceRuleGenerator(tree, {
Expand Down
15 changes: 12 additions & 3 deletions packages/eslint/src/generators/workspace-rule/workspace-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Tree,
} from '@nx/devkit';
import { join } from 'path';
import { coerce, major } from 'semver';
import * as ts from 'typescript';
import { workspaceLintPluginDir } from '../../utils/workspace-lint-rules';
import { lintWorkspaceRulesProjectGenerator } from '../workspace-rules-project/workspace-rules-project';
Expand All @@ -34,6 +35,15 @@ export async function lintWorkspaceRuleGenerator(
const tasks: GeneratorCallback[] = [];

const flatConfig = useFlatConfig(tree);
// ESLint v9 dropped the eslintrc-style `RuleTester` API. typescript-eslint's
// recommended replacement for any v9 workspace (flat or eslintrc) is the
// separate `@typescript-eslint/rule-tester` package, which has a flat-style
// API that works with ESLint v8.57+ and v9 alike. We resolve the effective
// major from `versions(tree)` to cover both declared workspaces and fresh
// installs that will be bumped to v9.
const { eslintVersion, typescriptESLintVersion } = versions(tree);
const effectiveEslintMajor = major(coerce(eslintVersion));
const useFlatRuleTester = flatConfig || effectiveEslintMajor >= 9;

const nxJson = readNxJson(tree);
// Ensure that the workspace rules project has been created
Expand All @@ -46,8 +56,7 @@ export async function lintWorkspaceRuleGenerator(
})
);

if (flatConfig) {
const { typescriptESLintVersion } = versions(tree);
if (useFlatRuleTester) {
tasks.push(
addDependenciesToPackageJson(
tree,
Expand All @@ -68,7 +77,7 @@ export async function lintWorkspaceRuleGenerator(
generateFiles(tree, join(__dirname, 'files'), ruleDir, {
tmpl: '',
name: options.name,
flatConfig,
useFlatRuleTester,
});

const nameCamelCase = camelize(options.name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ exports[`@nx/eslint:workspace-rules-project should generate the required files 4
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["jest", "node"]
"types": ["jest", "node"],
"isolatedModules": true
},
"include": [
"jest.config.ts",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ export async function lintWorkspaceRulesProjectGenerator(
(json) => {
delete json.compilerOptions?.module;
delete json.compilerOptions?.moduleResolution;
// Inherits `module: node16` from the project's base `tsconfig.json`,
// which requires `isolatedModules: true` to reliably honor packages'
// `exports` maps (e.g. `@typescript-eslint/rule-tester`).
json.compilerOptions = {
...json.compilerOptions,
isolatedModules: true,
};

if (json.include) {
json.include = json.include.map((v) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { type Tree } from '@nx/devkit';
import { assertSupportedPackageVersion } from '@nx/devkit/internal';
import { minSupportedEslintVersion } from './versions';
import { warnEslintV8Deprecation } from './deprecation';
import {
getInstalledEslintMajorVersion,
minSupportedEslintVersion,
} from './versions';

export function assertSupportedEslintVersion(tree: Tree): void {
assertSupportedPackageVersion(tree, 'eslint', minSupportedEslintVersion);
if (getInstalledEslintMajorVersion(tree) === 8) {
warnEslintV8Deprecation();
}
}
12 changes: 12 additions & 0 deletions packages/eslint/src/utils/deprecation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,15 @@ export function warnEslintExecutorGenerating(): void {
'Generating a target that uses the deprecated `@nx/eslint:lint` executor. The executor will be removed in Nx v24. Run `nx g @nx/eslint:convert-to-inferred` next to migrate this target to the `@nx/eslint/plugin` inferred plugin and prevent future generators from emitting executor targets. See https://nx.dev/docs/guides/tasks--caching/convert-to-inferred for details.'
);
}

// TODO(v24): Remove ESLint v8 support. Concrete removals:
// - Raise `minSupportedEslintVersion` to '9.0.0' in versions.ts.
// - Delete `versionMap[8]` and the `CompatVersions` type alias.
// - Delete this constant + `warnEslintV8Deprecation` and their call sites.
// - Drop the v8 branch in the workspace-rule generator/template.
export const ESLINT_V8_DEPRECATION_MESSAGE =
'Support for ESLint v8 is deprecated and will be removed in Nx v24. Please upgrade to ESLint v9.';

export function warnEslintV8Deprecation(): void {
logger.warn(ESLINT_V8_DEPRECATION_MESSAGE);
}
31 changes: 31 additions & 0 deletions packages/eslint/src/utils/versions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { versions } from './versions';

describe('versions(tree)', () => {
let originalEnv: string | undefined;

beforeEach(() => {
originalEnv = process.env.ESLINT_USE_FLAT_CONFIG;
});

afterEach(() => {
if (originalEnv === undefined) {
delete process.env.ESLINT_USE_FLAT_CONFIG;
} else {
process.env.ESLINT_USE_FLAT_CONFIG = originalEnv;
}
});

it('should return the latest ESLint stack for fresh installs regardless of the flat-config preference', () => {
const tree = createTreeWithEmptyWorkspace();

process.env.ESLINT_USE_FLAT_CONFIG = 'false';
const eslintrcResult = versions(tree);

process.env.ESLINT_USE_FLAT_CONFIG = 'true';
const flatResult = versions(tree);

expect(eslintrcResult).toEqual(flatResult);
expect(eslintrcResult.eslintVersion).toMatch(/^\^9\./);
});
});
11 changes: 5 additions & 6 deletions packages/eslint/src/utils/versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
} from '@nx/devkit/internal';
import { join } from 'path';
import { major } from 'semver';
import { useFlatConfig } from './flat-config';

export const nxVersion = require(join('@nx/eslint', 'package.json')).version;

Expand Down Expand Up @@ -43,11 +42,11 @@ export function versions(tree: Tree): EslintVersions {
const eslintMajorVersion = major(installedEslintVersion);
return versionMap[eslintMajorVersion as CompatVersions] ?? latestVersions;
}
// No ESLint declared yet — fresh installs honor the user's flat-config
// preference. Without flat config, install ESLint v8 (eslintrc lane); with
// flat config, install ESLint v9. Both lanes ship typescript-eslint v8 to
// match the `@nx/eslint-plugin` `@typescript-eslint/parser` peer.
return useFlatConfig(tree) ? latestVersions : versionMap[8];
// No ESLint declared yet — fresh installs always go to the latest supported
// ESLint stack (v9 + typescript-eslint v8). The eslintrc config shape is
// still respected at the config-file level when `useFlatConfig(tree)` is
// false; only the installed package versions move forward.
return latestVersions;
}

export function getInstalledEslintVersion(tree?: Tree): string | null {
Expand Down
Loading