diff --git a/packages/eslint/migrations.json b/packages/eslint/migrations.json index c9a2ee1aac98f..0be15e3c7aae7 100644 --- a/packages/eslint/migrations.json +++ b/packages/eslint/migrations.json @@ -24,6 +24,11 @@ "version": "20.2.0-beta.5", "description": "Update TypeScript ESLint packages to v8.13.0 if they are already on v8", "implementation": "./src/migrations/update-20-2-0/update-typescript-eslint-v8-13-0" + }, + "add-file-extensions-to-overrides": { + "version": "20.3.0-beta.1", + "description": "Update ESLint flat config to include .cjs, .mjs, .cts, and .mts files in overrides (if needed)", + "implementation": "./src/migrations/update-20-3-0/add-file-extensions-to-overrides" } }, "packageJsonUpdates": { diff --git a/packages/eslint/src/generators/utils/flat-config/ast-utils.spec.ts b/packages/eslint/src/generators/utils/flat-config/ast-utils.spec.ts index 5f19e95f522f3..7726ffbfaf58a 100644 --- a/packages/eslint/src/generators/utils/flat-config/ast-utils.spec.ts +++ b/packages/eslint/src/generators/utils/flat-config/ast-utils.spec.ts @@ -125,6 +125,8 @@ describe('ast-utils', () => { }).map(config => ({ ...config, files: [ + "**/*.ts", + "**/*.tsx", "**/*.cts", "**/*.mts" ], diff --git a/packages/eslint/src/generators/utils/flat-config/ast-utils.ts b/packages/eslint/src/generators/utils/flat-config/ast-utils.ts index a80e493893eea..80f52c860ec4d 100644 --- a/packages/eslint/src/generators/utils/flat-config/ast-utils.ts +++ b/packages/eslint/src/generators/utils/flat-config/ast-utils.ts @@ -1016,6 +1016,8 @@ export function generateFlatOverride( rest.extends === 'plugin:@nx/javascript' ) { const newFiles = new Set(files); + newFiles.add('**/*.js'); + newFiles.add('**/*.jsx'); newFiles.add('**/*.cjs'); newFiles.add('**/*.mjs'); files = Array.from(newFiles); @@ -1027,6 +1029,8 @@ export function generateFlatOverride( rest.extends === 'plugin:@nx/typescript' ) { const newFiles = new Set(files); + newFiles.add('**/*.ts'); + newFiles.add('**/*.tsx'); newFiles.add('**/*.cts'); newFiles.add('**/*.mts'); files = Array.from(newFiles); diff --git a/packages/eslint/src/migrations/update-20-3-0/add-file-extensions-to-overrides.spec.ts b/packages/eslint/src/migrations/update-20-3-0/add-file-extensions-to-overrides.spec.ts new file mode 100644 index 0000000000000..c9dec8aae46f0 --- /dev/null +++ b/packages/eslint/src/migrations/update-20-3-0/add-file-extensions-to-overrides.spec.ts @@ -0,0 +1,187 @@ +import { type Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import migration from './add-file-extensions-to-overrides'; + +describe('add-file-extensions-to-overrides', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + it('should add .cjs, .mjs, .cts, .mts file extensions to overrides converted using convert-to-flat-config', async () => { + tree.write( + 'eslint.config.js', + `const { FlatCompat } = require('@eslint/eslintrc'); +const js = require('@eslint/js'); +const nxEslintPlugin = require('@nx/eslint-plugin'); + +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}); + +module.exports = [ + ...compat + .config({ + extends: ['plugin:@nx/typescript'], + }) + .map((config) => ({ + ...config, + files: ['**/*.ts', '**/*.tsx'], + rules: { + ...config.rules, + }, + })), + ...compat + .config({ + extends: ['plugin:@nx/javascript'], + }) + .map((config) => ({ + ...config, + files: ['**/*.js', '**/*.jsx'], + rules: { + ...config.rules, + }, + })), +];` + ); + + await migration(tree); + + const updated = tree.read('eslint.config.js', 'utf-8'); + expect(updated).toMatchInlineSnapshot(` + "const { FlatCompat } = require('@eslint/eslintrc'); + const js = require('@eslint/js'); + const nxEslintPlugin = require('@nx/eslint-plugin'); + + const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + }); + + module.exports = [ + ...compat + .config({ + extends: ['plugin:@nx/typescript'], + }) + .map((config) => ({ + ...config, + files: ['**/*.ts', '**/*.tsx', '**/*.cts', '**/*.mts'], + rules: { + ...config.rules, + }, + })), + ...compat + .config({ + extends: ['plugin:@nx/javascript'], + }) + .map((config) => ({ + ...config, + files: ['**/*.js', '**/*.jsx', '**/*.cjs', '**/*.mjs'], + rules: { + ...config.rules, + }, + })), + ];" + `); + }); + + it('should handle duplicates', async () => { + tree.write( + 'eslint.config.js', + `const { FlatCompat } = require('@eslint/eslintrc'); +const js = require('@eslint/js'); +const nxEslintPlugin = require('@nx/eslint-plugin'); + +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}); + +module.exports = [ + ...compat + .config({ + extends: ['plugin:@nx/javascript'], + }) + .map((config) => ({ + ...config, + files: ['**/*.js', '**/*.jsx', '**/*.mjs'], + rules: { + ...config.rules, + }, + })), +];` + ); + + await migration(tree); + + const updated = tree.read('eslint.config.js', 'utf-8'); + expect(updated).toMatchInlineSnapshot(` + "const { FlatCompat } = require('@eslint/eslintrc'); + const js = require('@eslint/js'); + const nxEslintPlugin = require('@nx/eslint-plugin'); + + const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + }); + + module.exports = [ + ...compat + .config({ + extends: ['plugin:@nx/javascript'], + }) + .map((config) => ({ + ...config, + files: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'], + rules: { + ...config.rules, + }, + })), + ];" + `); + }); + + it('should not update if plugin:@nx/javascript and plugin:@nx/typescript are not used', async () => { + const original = `const { FlatCompat } = require('@eslint/eslintrc'); +const js = require('@eslint/js'); +const nxEslintPlugin = require('@nx/eslint-plugin'); + +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}); + +module.exports = [ + ...compat + .config({ + extends: ['plugin:@acme/foo'], + }) + .map((config) => ({ + ...config, + files: ['**/*.ts', '**/*.tsx'], + rules: { + ...config.rules, + }, + })), + ...compat + .config({ + extends: ['plugin:@acme/bar'], + }) + .map((config) => ({ + ...config, + files: ['**/*.js', '**/*.jsx'], + rules: { + ...config.rules, + }, + })), +];`; + tree.write('eslint.config.js', original); + + await migration(tree); + + const updated = tree.read('eslint.config.js', 'utf-8'); + expect(updated).toEqual(original); + }); +}); diff --git a/packages/eslint/src/migrations/update-20-3-0/add-file-extensions-to-overrides.ts b/packages/eslint/src/migrations/update-20-3-0/add-file-extensions-to-overrides.ts new file mode 100644 index 0000000000000..d2bd661cd84e0 --- /dev/null +++ b/packages/eslint/src/migrations/update-20-3-0/add-file-extensions-to-overrides.ts @@ -0,0 +1,97 @@ +import { type Tree } from '@nx/devkit'; +import * as ts from 'typescript'; +import { findNodes, replaceChange } from '@nx/js'; + +export default async function (tree: Tree): Promise { + let rootConfig: string; + + // NOTE: we don't support generating ESM base config currently so they are not handled. + for (const candidate of ['eslint.config.js', 'eslint.config.cjs']) { + if (tree.exists(candidate)) { + rootConfig = candidate; + break; + } + } + + if (!rootConfig) return; + + updateOverrideFileExtensions( + tree, + rootConfig, + 'plugin:@nx/typescript', + [`'**/*.ts'`, `'**/*.tsx'`], + [`'**/*.cts'`, `'**/*.mts'`] + ); + + updateOverrideFileExtensions( + tree, + rootConfig, + 'plugin:@nx/javascript', + [`'**/*.js'`, `'**/*.jsx'`], + [`'**/*.cjs'`, `'**/*.mjs'`] + ); +} + +function updateOverrideFileExtensions( + tree: Tree, + configFile: string, + plugin: string, + matchingExts: string[], + newExts: string[] +): void { + const content = tree.read(configFile, 'utf-8'); + const source = ts.createSourceFile( + '', + content, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.JS + ); + let compatNode: ts.SpreadElement; + + const spreadElementNodes = findNodes( + source, + ts.SyntaxKind.SpreadElement + ) as ts.SpreadElement[]; + for (const a of spreadElementNodes) { + const assignmentNodes = findNodes( + a, + ts.SyntaxKind.PropertyAssignment + ) as ts.PropertyAssignment[]; + if (assignmentNodes.length === 0) continue; + for (const b of assignmentNodes) { + if ( + b.name.getText() === 'extends' && + b.initializer.getText().includes(plugin) + ) { + compatNode = a; + break; + } + } + } + + if (compatNode) { + const arrayNodes = findNodes( + compatNode, + ts.SyntaxKind.ArrayLiteralExpression + ) as ts.ArrayLiteralExpression[]; + for (const a of arrayNodes) { + if ( + matchingExts.every((ext) => a.elements.some((e) => e.getText() === ext)) + ) { + const exts = new Set(a.elements.map((e) => e.getText())); + for (const ext of newExts) { + exts.add(ext); + } + replaceChange( + tree, + source, + configFile, + a.getStart(a.getSourceFile()), + `[${Array.from(exts).join(', ')}]`, + a.getText() + ).getText(); + } + } + } +}