diff --git a/packages/eslint/src/executors/lint/lint.impl.ts b/packages/eslint/src/executors/lint/lint.impl.ts index dc11e30469f..b93e60d5eb0 100644 --- a/packages/eslint/src/executors/lint/lint.impl.ts +++ b/packages/eslint/src/executors/lint/lint.impl.ts @@ -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'; @@ -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); diff --git a/packages/eslint/src/generators/workspace-rule/__snapshots__/workspace-rule.spec.ts.snap b/packages/eslint/src/generators/workspace-rule/__snapshots__/workspace-rule.spec.ts.snap index 89fde4b4cbd..a53561bca1b 100644 --- a/packages/eslint/src/generators/workspace-rule/__snapshots__/workspace-rule.spec.ts.snap +++ b/packages/eslint/src/generators/workspace-rule/__snapshots__/workspace-rule.spec.ts.snap @@ -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;\`], @@ -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;\`], @@ -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;\`], diff --git a/packages/eslint/src/generators/workspace-rule/files/__name__.spec.ts__tmpl__ b/packages/eslint/src/generators/workspace-rule/files/__name__.spec.ts__tmpl__ index b395a62dde8..3c258ffbb35 100644 --- a/packages/eslint/src/generators/workspace-rule/files/__name__.spec.ts__tmpl__ +++ b/packages/eslint/src/generators/workspace-rule/files/__name__.spec.ts__tmpl__ @@ -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 %>'; diff --git a/packages/eslint/src/generators/workspace-rule/workspace-rule.spec.ts b/packages/eslint/src/generators/workspace-rule/workspace-rule.spec.ts index c4a85162d67..e1af8b2c1fd 100644 --- a/packages/eslint/src/generators/workspace-rule/workspace-rule.spec.ts +++ b/packages/eslint/src/generators/workspace-rule/workspace-rule.spec.ts @@ -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'; @@ -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, { diff --git a/packages/eslint/src/generators/workspace-rule/workspace-rule.ts b/packages/eslint/src/generators/workspace-rule/workspace-rule.ts index 3a397092ab1..434589607ea 100644 --- a/packages/eslint/src/generators/workspace-rule/workspace-rule.ts +++ b/packages/eslint/src/generators/workspace-rule/workspace-rule.ts @@ -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'; @@ -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 @@ -46,8 +56,7 @@ export async function lintWorkspaceRuleGenerator( }) ); - if (flatConfig) { - const { typescriptESLintVersion } = versions(tree); + if (useFlatRuleTester) { tasks.push( addDependenciesToPackageJson( tree, @@ -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); diff --git a/packages/eslint/src/generators/workspace-rules-project/__snapshots__/workspace-rules-project.spec.ts.snap b/packages/eslint/src/generators/workspace-rules-project/__snapshots__/workspace-rules-project.spec.ts.snap index fb771952ee7..4bc20e651f5 100644 --- a/packages/eslint/src/generators/workspace-rules-project/__snapshots__/workspace-rules-project.spec.ts.snap +++ b/packages/eslint/src/generators/workspace-rules-project/__snapshots__/workspace-rules-project.spec.ts.snap @@ -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", diff --git a/packages/eslint/src/generators/workspace-rules-project/workspace-rules-project.ts b/packages/eslint/src/generators/workspace-rules-project/workspace-rules-project.ts index 73179a4e6d4..336614e1f15 100644 --- a/packages/eslint/src/generators/workspace-rules-project/workspace-rules-project.ts +++ b/packages/eslint/src/generators/workspace-rules-project/workspace-rules-project.ts @@ -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) => { diff --git a/packages/eslint/src/utils/assert-supported-eslint-version.ts b/packages/eslint/src/utils/assert-supported-eslint-version.ts index f1d32651c25..582cbebd78d 100644 --- a/packages/eslint/src/utils/assert-supported-eslint-version.ts +++ b/packages/eslint/src/utils/assert-supported-eslint-version.ts @@ -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(); + } } diff --git a/packages/eslint/src/utils/deprecation.ts b/packages/eslint/src/utils/deprecation.ts index 56e9fa0f988..febdf5e4221 100644 --- a/packages/eslint/src/utils/deprecation.ts +++ b/packages/eslint/src/utils/deprecation.ts @@ -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); +} diff --git a/packages/eslint/src/utils/versions.spec.ts b/packages/eslint/src/utils/versions.spec.ts new file mode 100644 index 00000000000..d55a1a4b2cb --- /dev/null +++ b/packages/eslint/src/utils/versions.spec.ts @@ -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\./); + }); +}); diff --git a/packages/eslint/src/utils/versions.ts b/packages/eslint/src/utils/versions.ts index c706fe9d73d..8b0d6fbdf62 100644 --- a/packages/eslint/src/utils/versions.ts +++ b/packages/eslint/src/utils/versions.ts @@ -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; @@ -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 {