diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 2e505250ad5a1f..fae423ba1bd031 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -6384,6 +6384,14 @@ "children": [], "isExternal": false, "disableCollapsible": false + }, + { + "id": "convert-to-flat-config", + "path": "/packages/linter/generators/convert-to-flat-config", + "name": "convert-to-flat-config", + "children": [], + "isExternal": false, + "disableCollapsible": false } ], "isExternal": false, diff --git a/docs/generated/manifests/packages.json b/docs/generated/manifests/packages.json index 67c246a9950ea2..faf7598da242f5 100644 --- a/docs/generated/manifests/packages.json +++ b/docs/generated/manifests/packages.json @@ -1140,6 +1140,15 @@ "originalFilePath": "/packages/linter/src/generators/workspace-rule/schema.json", "path": "/packages/linter/generators/workspace-rule", "type": "generator" + }, + "/packages/linter/generators/convert-to-flat-config": { + "description": "Convert an Nx workspace to a Flat ESLint config.", + "file": "generated/packages/linter/generators/convert-to-flat-config.json", + "hidden": false, + "name": "convert-to-flat-config", + "originalFilePath": "/packages/linter/src/generators/convert-to-flat-config/schema.json", + "path": "/packages/linter/generators/convert-to-flat-config", + "type": "generator" } }, "path": "/packages/linter" diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index a4608999b24161..ab7e810ac34107 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -1123,6 +1123,15 @@ "originalFilePath": "/packages/linter/src/generators/workspace-rule/schema.json", "path": "linter/generators/workspace-rule", "type": "generator" + }, + { + "description": "Convert an Nx workspace to a Flat ESLint config.", + "file": "generated/packages/linter/generators/convert-to-flat-config.json", + "hidden": false, + "name": "convert-to-flat-config", + "originalFilePath": "/packages/linter/src/generators/convert-to-flat-config/schema.json", + "path": "linter/generators/convert-to-flat-config", + "type": "generator" } ], "githubRoot": "https://github.com/nrwl/nx/blob/master", diff --git a/docs/generated/packages/linter/generators/convert-to-flat-config.json b/docs/generated/packages/linter/generators/convert-to-flat-config.json new file mode 100644 index 00000000000000..c06ebc0738ec5c --- /dev/null +++ b/docs/generated/packages/linter/generators/convert-to-flat-config.json @@ -0,0 +1,28 @@ +{ + "name": "convert-to-flat-config", + "factory": "./src/generators/convert-to-flat-config/generator", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "ConvertToFlatConfig", + "cli": "nx", + "description": "Convert an Nx workspace to a Flat ESLint config.", + "type": "object", + "properties": { + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files.", + "default": false, + "x-priority": "internal" + } + }, + "additionalProperties": false, + "required": [], + "presets": [] + }, + "description": "Convert an Nx workspace to a Flat ESLint config.", + "implementation": "/packages/linter/src/generators/convert-to-flat-config/generator.ts", + "aliases": [], + "hidden": false, + "path": "/packages/linter/src/generators/convert-to-flat-config/schema.json", + "type": "generator" +} diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index d252ee7eb7ba60..0b3349a93a2f5c 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -413,6 +413,7 @@ - [generators](/packages/linter/generators) - [workspace-rules-project](/packages/linter/generators/workspace-rules-project) - [workspace-rule](/packages/linter/generators/workspace-rule) + - [convert-to-flat-config](/packages/linter/generators/convert-to-flat-config) - [nest](/packages/nest) - [documents](/packages/nest/documents) - [Overview](/packages/nest/documents/overview) diff --git a/e2e/linter/src/linter.test.ts b/e2e/linter/src/linter.test.ts index 01e83670d880c4..a4158028eb26ac 100644 --- a/e2e/linter/src/linter.test.ts +++ b/e2e/linter/src/linter.test.ts @@ -1,12 +1,15 @@ import * as path from 'path'; import { + checkFilesDoNotExist, checkFilesExist, cleanupProject, createFile, + getSelectedPackageManager, newProject, readFile, readJson, runCLI, + runCreateWorkspace, uniq, updateFile, updateJson, @@ -537,6 +540,78 @@ describe('Linter', () => { }); }); + describe('Flat config', () => { + const packageManager = getSelectedPackageManager() || 'pnpm'; + + afterEach(() => cleanupProject()); + + it('should convert integrated to flat config', () => { + const myapp = uniq('myapp'); + const mylib = uniq('mylib'); + + runCreateWorkspace(myapp, { + preset: 'react-monorepo', + appName: myapp, + style: 'css', + packageManager, + bundler: 'vite', + e2eTestRunner: 'none', + }); + runCLI(`generate @nx/js:lib ${mylib}`); + + // migrate to flat structure + runCLI(`generate @nx/linter:convert-to-flat-config`); + checkFilesExist( + 'eslint.config.js', + `apps/${myapp}/eslint.config.js`, + `libs/${mylib}/eslint.config.js` + ); + checkFilesDoNotExist( + '.eslintrc.json', + `apps/${myapp}/.eslintrc.json`, + `libs/${mylib}/.eslintrc.json` + ); + + const outFlat = runCLI(`affected -t lint`, { + silenceError: true, + }); + expect(outFlat).toContain('All files pass linting'); + }, 1000000); + + it('should convert standalone to flat config', () => { + const myapp = uniq('myapp'); + const mylib = uniq('mylib'); + + runCreateWorkspace(myapp, { + preset: 'react-standalone', + appName: myapp, + style: 'css', + packageManager, + bundler: 'vite', + e2eTestRunner: 'none', + }); + runCLI(`generate @nx/js:lib ${mylib}`); + + // migrate to flat structure + runCLI(`generate @nx/linter:convert-to-flat-config`); + checkFilesExist( + 'eslint.config.js', + `${mylib}/eslint.config.js`, + 'eslint.base.config.js' + ); + checkFilesDoNotExist( + '.eslintrc.json', + `${mylib}/.eslintrc.json`, + '.eslintrc.base.json' + ); + + const outFlat = runCLI(`affected -t lint`, { + silenceError: true, + }); + expect(outFlat).toContain('All files pass linting'); + }, 1000000); + }); + describe('Root projects migration', () => { beforeEach(() => newProject()); afterEach(() => cleanupProject()); diff --git a/package.json b/package.json index 5d5e86c21ef343..14510f61c45af2 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "@babel/preset-react": "^7.22.5", "@babel/preset-typescript": "^7.22.5", "@babel/runtime": "^7.22.6", + "@eslint/eslintrc": "^2.1.1", + "@eslint/js": "^8.46.0", "@floating-ui/react": "0.19.2", "@jest/reporters": "^29.4.1", "@jest/test-result": "^29.4.1", diff --git a/packages/angular/src/generators/ng-add/__snapshots__/migrate-from-angular-cli.spec.ts.snap b/packages/angular/src/generators/ng-add/__snapshots__/migrate-from-angular-cli.spec.ts.snap index a591aba84e1027..f23d2916aa358f 100644 --- a/packages/angular/src/generators/ng-add/__snapshots__/migrate-from-angular-cli.spec.ts.snap +++ b/packages/angular/src/generators/ng-add/__snapshots__/migrate-from-angular-cli.spec.ts.snap @@ -95,6 +95,7 @@ exports[`workspace move to nx layout should create nx.json 1`] = ` "!{projectRoot}/**/*.spec.[jt]s", "!{projectRoot}/karma.conf.js", "!{projectRoot}/.eslintrc.json", + "!{projectRoot}/eslint.config.js", ], "sharedGlobals": [], }, @@ -118,6 +119,7 @@ exports[`workspace move to nx layout should create nx.json 1`] = ` "inputs": [ "default", "{workspaceRoot}/.eslintrc.json", + "{workspaceRoot}/eslint.config.js", ], }, "test": { diff --git a/packages/angular/src/generators/ng-add/utilities/workspace.ts b/packages/angular/src/generators/ng-add/utilities/workspace.ts index 696d68609cd260..69a6985c2d4cb7 100644 --- a/packages/angular/src/generators/ng-add/utilities/workspace.ts +++ b/packages/angular/src/generators/ng-add/utilities/workspace.ts @@ -76,7 +76,9 @@ export function createNxJson( '!{projectRoot}/karma.conf.js', ] : []), - targets.lint ? '!{projectRoot}/.eslintrc.json' : undefined, + ...(targets.lint + ? ['!{projectRoot}/.eslintrc.json', '!{projectRoot}/eslint.config.js'] + : []), ].filter(Boolean), }, targetDefaults: { @@ -91,7 +93,11 @@ export function createNxJson( : undefined, lint: targets.lint ? { - inputs: ['default', '{workspaceRoot}/.eslintrc.json'], + inputs: [ + 'default', + '{workspaceRoot}/.eslintrc.json', + '{workspaceRoot}/eslint.config.js', + ], } : undefined, e2e: targets.e2e diff --git a/packages/linter/generators.json b/packages/linter/generators.json index 79aae1043216ba..8a40aeba824aeb 100644 --- a/packages/linter/generators.json +++ b/packages/linter/generators.json @@ -25,6 +25,11 @@ "factory": "./src/generators/workspace-rule/workspace-rule#lintWorkspaceRuleGenerator", "schema": "./src/generators/workspace-rule/schema.json", "description": "Create a new Workspace ESLint rule." + }, + "convert-to-flat-config": { + "factory": "./src/generators/convert-to-flat-config/generator", + "schema": "./src/generators/convert-to-flat-config/schema.json", + "description": "Convert an Nx workspace to a Flat ESLint config." } } } diff --git a/packages/linter/package.json b/packages/linter/package.json index 0dd3d494d10c53..fe353a6c4459e8 100644 --- a/packages/linter/package.json +++ b/packages/linter/package.json @@ -37,7 +37,8 @@ "tmp": "~0.2.1", "tslib": "^2.3.0", "@nx/devkit": "file:../devkit", - "@nx/js": "file:../js" + "@nx/js": "file:../js", + "typescript": "~5.1.3" }, "peerDependenciesMeta": { "eslint": { diff --git a/packages/linter/src/executors/eslint/lint.impl.ts b/packages/linter/src/executors/eslint/lint.impl.ts index 263250a3871f64..f7e7721ad729ed 100644 --- a/packages/linter/src/executors/eslint/lint.impl.ts +++ b/packages/linter/src/executors/eslint/lint.impl.ts @@ -130,10 +130,13 @@ Please see https://nx.dev/guides/eslint for full guidance on how to resolve this .filter((pattern) => !!pattern) .map((pattern) => `- '${pattern}'`); if (ignoredPatterns.length) { + const ignoreSection = useFlatConfig + ? `'ignores' configuration` + : `'.eslintignore' file`; throw new Error( `All files matching the following patterns are ignored:\n${ignoredPatterns.join( '\n' - )}\n\nPlease check your '.eslintignore' file.` + )}\n\nPlease check your ${ignoreSection}.` ); } throw new Error( diff --git a/packages/linter/src/generators/convert-to-flat-config/__snapshots__/generator.spec.ts.snap b/packages/linter/src/generators/convert-to-flat-config/__snapshots__/generator.spec.ts.snap new file mode 100644 index 00000000000000..3e7d29711e3089 --- /dev/null +++ b/packages/linter/src/generators/convert-to-flat-config/__snapshots__/generator.spec.ts.snap @@ -0,0 +1,385 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`convert-to-flat-config generator should add env configuration 1`] = ` +"const { FlatCompat } = require('@eslint/eslintrc'); +const nxEslintPlugin = require('@nx/eslint-plugin'); +const globals = require('globals'); +const js = require('@eslint/js'); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}); +module.exports = [ + { plugins: { '@nx': nxEslintPlugin } }, + { languageOptions: { globals: { ...globals.browser, ...globals.node } } }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: { + '@nx/enforce-module-boundaries': [ + 'error', + { + enforceBuildableLibDependency: true, + allow: [], + depConstraints: [ + { + sourceTag: '*', + onlyDependOnLibsWithTags: ['*'], + }, + ], + }, + ], + }, + }, + ...compat.config({ extends: ['plugin:@nx/typescript'] }).map((config) => ({ + ...config, + files: ['**/*.ts', '**/*.tsx'], + rules: {}, + })), + ...compat.config({ extends: ['plugin:@nx/javascript'] }).map((config) => ({ + ...config, + files: ['**/*.js', '**/*.jsx'], + rules: {}, + })), +]; +" +`; + +exports[`convert-to-flat-config generator should add global and env configuration 1`] = ` +"const { FlatCompat } = require('@eslint/eslintrc'); +const nxEslintPlugin = require('@nx/eslint-plugin'); +const globals = require('globals'); +const js = require('@eslint/js'); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}); +module.exports = [ + { plugins: { '@nx': nxEslintPlugin } }, + { + languageOptions: { + globals: { ...globals.browser, myCustomGlobal: 'readonly' }, + }, + }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: { + '@nx/enforce-module-boundaries': [ + 'error', + { + enforceBuildableLibDependency: true, + allow: [], + depConstraints: [ + { + sourceTag: '*', + onlyDependOnLibsWithTags: ['*'], + }, + ], + }, + ], + }, + }, + ...compat.config({ extends: ['plugin:@nx/typescript'] }).map((config) => ({ + ...config, + files: ['**/*.ts', '**/*.tsx'], + rules: {}, + })), + ...compat.config({ extends: ['plugin:@nx/javascript'] }).map((config) => ({ + ...config, + files: ['**/*.js', '**/*.jsx'], + rules: {}, + })), +]; +" +`; + +exports[`convert-to-flat-config generator should add global configuration 1`] = ` +"const { FlatCompat } = require('@eslint/eslintrc'); +const nxEslintPlugin = require('@nx/eslint-plugin'); +const js = require('@eslint/js'); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}); +module.exports = [ + { plugins: { '@nx': nxEslintPlugin } }, + { languageOptions: { globals: { myCustomGlobal: 'readonly' } } }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: { + '@nx/enforce-module-boundaries': [ + 'error', + { + enforceBuildableLibDependency: true, + allow: [], + depConstraints: [ + { + sourceTag: '*', + onlyDependOnLibsWithTags: ['*'], + }, + ], + }, + ], + }, + }, + ...compat.config({ extends: ['plugin:@nx/typescript'] }).map((config) => ({ + ...config, + files: ['**/*.ts', '**/*.tsx'], + rules: {}, + })), + ...compat.config({ extends: ['plugin:@nx/javascript'] }).map((config) => ({ + ...config, + files: ['**/*.js', '**/*.jsx'], + rules: {}, + })), +]; +" +`; + +exports[`convert-to-flat-config generator should add global gitignores 1`] = ` +"const { FlatCompat } = require('@eslint/eslintrc'); +const nxEslintPlugin = require('@nx/eslint-plugin'); +const js = require('@eslint/js'); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}); +module.exports = [ + { plugins: { '@nx': nxEslintPlugin } }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: { + '@nx/enforce-module-boundaries': [ + 'error', + { + enforceBuildableLibDependency: true, + allow: [], + depConstraints: [ + { + sourceTag: '*', + onlyDependOnLibsWithTags: ['*'], + }, + ], + }, + ], + }, + }, + ...compat.config({ extends: ['plugin:@nx/typescript'] }).map((config) => ({ + ...config, + files: ['**/*.ts', '**/*.tsx'], + rules: {}, + })), + ...compat.config({ extends: ['plugin:@nx/javascript'] }).map((config) => ({ + ...config, + files: ['**/*.js', '**/*.jsx'], + rules: {}, + })), + { ignores: ['ignore/me'] }, +]; +" +`; + +exports[`convert-to-flat-config generator should add parser 1`] = ` +"const { FlatCompat } = require('@eslint/eslintrc'); +const nxEslintPlugin = require('@nx/eslint-plugin'); +const typescriptEslintParser = require('@typescript-eslint/parser'); +const js = require('@eslint/js'); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}); +module.exports = [ + { plugins: { '@nx': nxEslintPlugin } }, + { languageOptions: { parser: typescriptEslintParser } }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: { + '@nx/enforce-module-boundaries': [ + 'error', + { + enforceBuildableLibDependency: true, + allow: [], + depConstraints: [ + { + sourceTag: '*', + onlyDependOnLibsWithTags: ['*'], + }, + ], + }, + ], + }, + }, + ...compat.config({ extends: ['plugin:@nx/typescript'] }).map((config) => ({ + ...config, + files: ['**/*.ts', '**/*.tsx'], + rules: {}, + })), + ...compat.config({ extends: ['plugin:@nx/javascript'] }).map((config) => ({ + ...config, + files: ['**/*.js', '**/*.jsx'], + rules: {}, + })), +]; +" +`; + +exports[`convert-to-flat-config generator should add plugins 1`] = ` +"const { FlatCompat } = require('@eslint/eslintrc'); +const eslintPluginImport = require('eslint-plugin-import'); +const eslintPluginSingleName = require('eslint-plugin-single-name'); +const scopeEslintPluginWithName = require('@scope/eslint-plugin-with-name'); +const justScopeEslintPlugin = require('@just-scope/eslint-plugin'); +const js = require('@eslint/js'); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}); +module.exports = [ + { + plugins: { + 'eslint-plugin-import': eslintPluginImport, + 'single-name': eslintPluginSingleName, + '@scope/with-name': scopeEslintPluginWithName, + '@just-scope': justScopeEslintPlugin, + }, + }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: { + '@nx/enforce-module-boundaries': [ + 'error', + { + enforceBuildableLibDependency: true, + allow: [], + depConstraints: [ + { + sourceTag: '*', + onlyDependOnLibsWithTags: ['*'], + }, + ], + }, + ], + }, + }, + ...compat.config({ extends: ['plugin:@nx/typescript'] }).map((config) => ({ + ...config, + files: ['**/*.ts', '**/*.tsx'], + rules: {}, + })), + ...compat.config({ extends: ['plugin:@nx/javascript'] }).map((config) => ({ + ...config, + files: ['**/*.js', '**/*.jsx'], + rules: {}, + })), +]; +" +`; + +exports[`convert-to-flat-config generator should add settings 1`] = ` +"const { FlatCompat } = require('@eslint/eslintrc'); +const nxEslintPlugin = require('@nx/eslint-plugin'); +const js = require('@eslint/js'); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}); +module.exports = [ + { plugins: { '@nx': nxEslintPlugin } }, + { settings: { sharedData: 'Hello' } }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: { + '@nx/enforce-module-boundaries': [ + 'error', + { + enforceBuildableLibDependency: true, + allow: [], + depConstraints: [ + { + sourceTag: '*', + onlyDependOnLibsWithTags: ['*'], + }, + ], + }, + ], + }, + }, + ...compat.config({ extends: ['plugin:@nx/typescript'] }).map((config) => ({ + ...config, + files: ['**/*.ts', '**/*.tsx'], + rules: {}, + })), + ...compat.config({ extends: ['plugin:@nx/javascript'] }).map((config) => ({ + ...config, + files: ['**/*.js', '**/*.jsx'], + rules: {}, + })), +]; +" +`; + +exports[`convert-to-flat-config generator should run successfully 1`] = ` +"const { FlatCompat } = require('@eslint/eslintrc'); +const nxEslintPlugin = require('@nx/eslint-plugin'); +const js = require('@eslint/js'); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}); +module.exports = [ + { plugins: { '@nx': nxEslintPlugin } }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: { + '@nx/enforce-module-boundaries': [ + 'error', + { + enforceBuildableLibDependency: true, + allow: [], + depConstraints: [ + { + sourceTag: '*', + onlyDependOnLibsWithTags: ['*'], + }, + ], + }, + ], + }, + }, + ...compat.config({ extends: ['plugin:@nx/typescript'] }).map((config) => ({ + ...config, + files: ['**/*.ts', '**/*.tsx'], + rules: {}, + })), + ...compat.config({ extends: ['plugin:@nx/javascript'] }).map((config) => ({ + ...config, + files: ['**/*.js', '**/*.jsx'], + rules: {}, + })), +]; +" +`; + +exports[`convert-to-flat-config generator should run successfully 2`] = ` +"const baseConfig = require('../../eslint.config.js'); +module.exports = [ + ...baseConfig, + { + files: [ + 'libs/test-lib/**/*.ts', + 'libs/test-lib/**/*.tsx', + 'libs/test-lib/**/*.js', + 'libs/test-lib/**/*.jsx', + ], + rules: {}, + }, + { + files: ['libs/test-lib/**/*.ts', 'libs/test-lib/**/*.tsx'], + rules: {}, + }, + { + files: ['libs/test-lib/**/*.js', 'libs/test-lib/**/*.jsx'], + rules: {}, + }, +]; +" +`; diff --git a/packages/linter/src/generators/convert-to-flat-config/converters/generate-ast.ts b/packages/linter/src/generators/convert-to-flat-config/converters/generate-ast.ts new file mode 100644 index 00000000000000..5a956bc2ba0511 --- /dev/null +++ b/packages/linter/src/generators/convert-to-flat-config/converters/generate-ast.ts @@ -0,0 +1,68 @@ +import * as ts from 'typescript'; + +/** + * Generates an AST from a JSON-type input + */ +export function generateAst(input: unknown): T { + if (Array.isArray(input)) { + return ts.factory.createArrayLiteralExpression( + input.map((item) => generateAst(item)), + input.length > 1 // multiline only if more than one item + ) as T; + } + if (input === null) { + return ts.factory.createNull() as T; + } + if (typeof input === 'object') { + return ts.factory.createObjectLiteralExpression( + Object.entries(input) + .filter(([_, value]) => value !== undefined) + .map(([key, value]) => + ts.factory.createPropertyAssignment( + isValidKey(key) ? key : ts.factory.createStringLiteral(key), + generateAst(value) + ) + ), + Object.keys(input).length > 1 // multiline only if more than one property + ) as T; + } + if (typeof input === 'string') { + return ts.factory.createStringLiteral(input) as T; + } + if (typeof input === 'number') { + return ts.factory.createNumericLiteral(input) as T; + } + if (typeof input === 'boolean') { + return (input ? ts.factory.createTrue() : ts.factory.createFalse()) as T; + } + // since we are parsing JSON, this should never happen + throw new Error(`Unknown type: ${typeof input}`); +} + +export function generateRequire( + variableName: string | ts.ObjectBindingPattern, + imp: string +): ts.VariableStatement { + return ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + variableName, + undefined, + undefined, + ts.factory.createCallExpression( + ts.factory.createIdentifier('require'), + undefined, + [ts.factory.createStringLiteral(imp)] + ) + ), + ], + ts.NodeFlags.Const + ) + ); +} + +function isValidKey(key: string): boolean { + return /^[a-zA-Z0-9_]+$/.test(key); +} diff --git a/packages/linter/src/generators/convert-to-flat-config/converters/json-converter.spec.ts b/packages/linter/src/generators/convert-to-flat-config/converters/json-converter.spec.ts new file mode 100644 index 00000000000000..93b33ab9fbc053 --- /dev/null +++ b/packages/linter/src/generators/convert-to-flat-config/converters/json-converter.spec.ts @@ -0,0 +1,234 @@ +import { Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { convertEslintJsonToFlatConfig } from './json-converter'; + +describe('convertEslintJsonToFlatConfig', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + it('should convert root configs', async () => { + tree.write( + '.eslintrc.json', + JSON.stringify({ + root: true, + ignorePatterns: ['**/*', 'src/ignore/to/keep.ts'], + plugins: ['@nx'], + overrides: [ + { + files: ['*.ts', '*.tsx', '*.js', '*.jsx'], + rules: { + '@nx/enforce-module-boundaries': [ + 'error', + { + enforceBuildableLibDependency: true, + allow: [], + depConstraints: [ + { + sourceTag: '*', + onlyDependOnLibsWithTags: ['*'], + }, + ], + }, + ], + }, + }, + { + files: ['*.ts', '*.tsx'], + extends: ['plugin:@nx/typescript'], + rules: {}, + }, + { + files: [ + '**/*.spec.ts', + '**/*.spec.tsx', + '**/*.spec.js', + '**/*.spec.jsx', + ], + env: { + jest: true, + }, + rules: {}, + }, + ], + }) + ); + + tree.write('.eslintignore', 'node_modules\nsomething/else'); + + convertEslintJsonToFlatConfig( + tree, + '', + '.eslintrc.json', + 'eslint.config.js' + ); + + expect(tree.read('eslint.config.js', 'utf-8')).toMatchInlineSnapshot(` + "const { FlatCompat } = require("@eslint/eslintrc"); + const nxEslintPlugin = require("@nx/eslint-plugin"); + const js = require("@eslint/js"); + const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + }); + module.exports = [ + { plugins: { "@nx": nxEslintPlugin } }, + { + files: [ + "**/*.ts", + "**/*.tsx", + "**/*.js", + "**/*.jsx" + ], + rules: { "@nx/enforce-module-boundaries": [ + "error", + { + enforceBuildableLibDependency: true, + allow: [], + depConstraints: [{ + sourceTag: "*", + onlyDependOnLibsWithTags: ["*"] + }] + } + ] } + }, + ...compat.config({ extends: ["plugin:@nx/typescript"] }).map(config => ({ + ...config, + files: [ + "**/*.ts", + "**/*.tsx" + ], + rules: {} + })), + ...compat.config({ env: { jest: true } }).map(config => ({ + ...config, + files: [ + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.spec.js", + "**/*.spec.jsx" + ], + rules: {} + })), + { ignores: ["src/ignore/to/keep.ts"] }, + { ignores: ["something/else"] } + ]; + " + `); + + expect(tree.exists('.eslintrc.json')).toBeFalsy(); + expect(tree.exists('.eslintignore')).toBeFalsy(); + }); + + it('should convert project configs', async () => { + tree.write( + 'mylib/.eslintrc.json', + JSON.stringify({ + extends: [ + 'plugin:@nx/react-typescript', + 'next', + 'next/core-web-vitals', + '../../.eslintrc.json', + ], + ignorePatterns: ['!**/*', '.next/**/*'], + overrides: [ + { + files: ['*.ts', '*.tsx', '*.js', '*.jsx'], + rules: { + '@next/next/no-html-link-for-pages': [ + 'error', + 'apps/test-next/pages', + ], + }, + }, + { + files: ['*.ts', '*.tsx'], + rules: {}, + }, + { + files: ['*.js', '*.jsx'], + rules: {}, + }, + { + files: ['*.json'], + parser: 'jsonc-eslint-parser', + rules: { + '@nx/dependency-checks': 'error', + }, + }, + ], + rules: { + '@next/next/no-html-link-for-pages': 'off', + }, + env: { + jest: true, + }, + }) + ); + + tree.write('mylib/.eslintignore', 'node_modules\nsomething/else'); + + convertEslintJsonToFlatConfig( + tree, + 'mylib', + '.eslintrc.json', + 'eslint.config.js' + ); + + expect(tree.read('mylib/eslint.config.js', 'utf-8')).toMatchInlineSnapshot(` + "const { FlatCompat } = require("@eslint/eslintrc"); + const baseConfig = require("../../eslint.config.js"); + const globals = require("globals"); + const js = require("@eslint/js"); + const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + }); + module.exports = [ + ...baseConfig, + ...compat.extends("plugin:@nx/react-typescript", "next", "next/core-web-vitals"), + { languageOptions: { globals: { ...globals.jest } } }, + { rules: { "@next/next/no-html-link-for-pages": "off" } }, + { + files: [ + "mylib/**/*.ts", + "mylib/**/*.tsx", + "mylib/**/*.js", + "mylib/**/*.jsx" + ], + rules: { "@next/next/no-html-link-for-pages": [ + "error", + "apps/test-next/pages" + ] } + }, + { + files: [ + "mylib/**/*.ts", + "mylib/**/*.tsx" + ], + rules: {} + }, + { + files: [ + "mylib/**/*.js", + "mylib/**/*.jsx" + ], + rules: {} + }, + ...compat.config({ parser: "jsonc-eslint-parser" }).map(config => ({ + ...config, + files: ["mylib/**/*.json"], + rules: { "@nx/dependency-checks": "error" } + })), + { ignores: ["mylib/.next/**/*"] }, + { ignores: ["mylib/something/else"] } + ]; + " + `); + + expect(tree.exists('mylib/.eslintrc.json')).toBeFalsy(); + expect(tree.exists('mylib/.eslintignore')).toBeFalsy(); + }); +}); diff --git a/packages/linter/src/generators/convert-to-flat-config/converters/json-converter.ts b/packages/linter/src/generators/convert-to-flat-config/converters/json-converter.ts new file mode 100644 index 00000000000000..a745f4efb38ec9 --- /dev/null +++ b/packages/linter/src/generators/convert-to-flat-config/converters/json-converter.ts @@ -0,0 +1,524 @@ +import { + Tree, + addDependenciesToPackageJson, + names, + readJson, +} from '@nx/devkit'; +import { join } from 'path'; +import { ESLint, Linter } from 'eslint'; +import * as ts from 'typescript'; +import { generateAst, generateRequire } from './generate-ast'; +import { eslintrcVersion } from '../../../utils/versions'; + +/** + * Converts an ESLint JSON config to a flat config. + * Deletes the original file along with .eslintignore if it exists. + */ +export function convertEslintJsonToFlatConfig( + tree: Tree, + root: string, + sourceFile: string, + destinationFile: string +) { + const importsMap = new Map(); + const exportElements: ts.Expression[] = []; + let isFlatCompatNeeded = false; + let combinedConfig: ts.PropertyAssignment[] = []; + let languageOptions: ts.PropertyAssignment[] = []; + + // read original config + const config: ESLint.ConfigData = readJson(tree, `${root}/${sourceFile}`); + + if (config.extends) { + isFlatCompatNeeded = addExtends(importsMap, exportElements, config, tree); + } + + if (config.plugins) { + addPlugins(importsMap, exportElements, config); + } + + if (config.parser) { + languageOptions.push(addParser(importsMap, config)); + } + + if (config.parserOptions) { + languageOptions.push( + ts.factory.createPropertyAssignment( + 'parserOptions', + generateAst(config.parserOptions) + ) + ); + } + + if (config.globals || config.env) { + if (config.env) { + importsMap.set('globals', 'globals'); + } + + languageOptions.push( + ts.factory.createPropertyAssignment( + 'globals', + ts.factory.createObjectLiteralExpression([ + ...Object.keys(config.env || {}).map((env) => + ts.factory.createSpreadAssignment( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('globals'), + ts.factory.createIdentifier(env) + ) + ) + ), + ...Object.keys(config.globals || {}).map((key) => + ts.factory.createPropertyAssignment( + key, + generateAst(config.globals[key]) + ) + ), + ]) + ) + ); + } + + if (config.settings) { + combinedConfig.push( + ts.factory.createPropertyAssignment( + 'settings', + generateAst(config.settings) + ) + ); + } + + if ( + config.noInlineConfig !== undefined || + config.reportUnusedDisableDirectives !== undefined + ) { + combinedConfig.push( + ts.factory.createPropertyAssignment( + 'linterOptions', + generateAst({ + noInlineConfig: config.noInlineConfig, + reportUnusedDisableDirectives: config.reportUnusedDisableDirectives, + }) + ) + ); + } + + if (languageOptions.length > 0) { + combinedConfig.push( + ts.factory.createPropertyAssignment( + 'languageOptions', + ts.factory.createObjectLiteralExpression( + languageOptions, + languageOptions.length > 1 + ) + ) + ); + } + + if (combinedConfig.length > 0) { + exportElements.push( + ts.factory.createObjectLiteralExpression( + combinedConfig, + combinedConfig.length > 1 + ) + ); + } + + if (config.rules) { + exportElements.push(generateAst({ rules: config.rules })); + } + + if (config.overrides) { + config.overrides.forEach((override) => { + updateFiles(override, root); + if ( + override.env || + override.extends || + override.plugins || + override.parser + ) { + isFlatCompatNeeded = true; + addFlattenedOverride(override, exportElements); + } else { + exportElements.push(generateAst(override)); + } + }); + } + + if (config.ignorePatterns) { + const patterns = ( + Array.isArray(config.ignorePatterns) + ? config.ignorePatterns + : [config.ignorePatterns] + ).filter((pattern) => !['**/*', '!**/*', 'node_modules'].includes(pattern)); // these are useless in a flat config + if (patterns.length > 0) { + exportElements.push( + generateAst({ + ignores: patterns.map((path) => mapFilePath(path, root)), + }) + ); + } + } + + if (tree.exists(`${root}/.eslintignore`)) { + const patterns = tree + .read(`${root}/.eslintignore`, 'utf-8') + .split('\n') + .filter((line) => line.length > 0 && line !== 'node_modules') + .map((path) => mapFilePath(path, root)); + if (patterns.length > 0) { + exportElements.push(generateAst({ ignores: patterns })); + } + } + + tree.delete(join(root, sourceFile)); + tree.delete(join(root, '.eslintignore')); + + // create the node list and print it to new file + const nodeList = createNodeList( + importsMap, + exportElements, + isFlatCompatNeeded + ); + const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); + const resultFile = ts.createSourceFile( + join(root, destinationFile), + '', + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.JS + ); + const result = printer.printList( + ts.ListFormat.MultiLine, + nodeList, + resultFile + ); + tree.write(join(root, destinationFile), result); + + if (isFlatCompatNeeded) { + addDependenciesToPackageJson( + tree, + {}, + { + '@eslint/eslintrc': eslintrcVersion, + } + ); + } +} + +function updateFiles( + override: Linter.ConfigOverride, + root: string +) { + if (override.files) { + override.files = Array.isArray(override.files) + ? override.files + : [override.files]; + override.files = override.files.map((file) => mapFilePath(file, root)); + } + return override; +} + +function mapFilePath(filePath: string, root: string) { + if (filePath.startsWith('!')) { + const fileWithoutBang = filePath.slice(1); + if (fileWithoutBang.startsWith('*.')) { + return `!${join(root, '**', fileWithoutBang)}`; + } else { + return `!${join(root, fileWithoutBang)}`; + } + } + if (filePath.startsWith('*.')) { + return join(root, '**', filePath); + } else { + return join(root, filePath); + } +} + +// add parsed extends to export blocks and add import statements +function addExtends( + importsMap: Map, + configBlocks: ts.Expression[], + config: ESLint.ConfigData, + tree: Tree +): boolean { + let isFlatCompatNeeded = false; + const extendsConfig = Array.isArray(config.extends) + ? config.extends + : [config.extends]; + + const eslintrcConfigs = []; + + // add base extends + extendsConfig + .filter((imp) => imp.match(/^\.?(\.\/)/)) + .forEach((imp, index) => { + if (imp.match(/\.eslintrc(.base)?\.json$/)) { + const localName = index ? `baseConfig${index}` : 'baseConfig'; + configBlocks.push( + ts.factory.createSpreadElement(ts.factory.createIdentifier(localName)) + ); + const newImport = imp.replace( + /^(.*)\.eslintrc(.base)?\.json$/, + '$1eslint$2.config.js' + ); + importsMap.set(newImport, localName); + } else { + eslintrcConfigs.push(imp); + } + }); + // add plugin extends + const pluginExtends = extendsConfig.filter((imp) => !imp.match(/^\.?(\.\/)/)); + if (pluginExtends.length) { + const eslintPluginExtends = pluginExtends.filter((imp) => + imp.startsWith('eslint:') + ); + pluginExtends.forEach((imp) => { + if (!imp.startsWith('eslint:')) { + eslintrcConfigs.push(imp); + } + }); + + if (eslintPluginExtends.length) { + addDependenciesToPackageJson( + tree, + {}, + { + '@eslint/js': eslintrcVersion, + } + ); + + importsMap.set('@eslint/js', 'js'); + eslintPluginExtends.forEach((plugin) => { + configBlocks.push( + ts.factory.createPropertyAccessExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('js'), + ts.factory.createIdentifier('configs') + ), + ts.factory.createIdentifier(plugin.slice(7)) // strip 'eslint:' prefix + ) + ); + }); + } + } + if (eslintrcConfigs.length) { + isFlatCompatNeeded = true; + addDependenciesToPackageJson( + tree, + {}, + { + '@eslint/js': eslintrcVersion, + } + ); + + const pluginExtendsSpread = ts.factory.createSpreadElement( + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('compat'), + ts.factory.createIdentifier('extends') + ), + undefined, + eslintrcConfigs.map((plugin) => ts.factory.createStringLiteral(plugin)) + ) + ); + configBlocks.push(pluginExtendsSpread); + } + + return isFlatCompatNeeded; +} + +function getPluginImport(pluginName: string): string { + if (pluginName.includes('eslint-plugin-')) { + return pluginName; + } + if (!pluginName.startsWith('@')) { + return `eslint-plugin-${pluginName}`; + } + if (!pluginName.includes('/')) { + return `${pluginName}/eslint-plugin`; + } + const [scope, name] = pluginName.split('/'); + return `${scope}/eslint-plugin-${name}`; +} + +function addPlugins( + importsMap: Map, + configBlocks: ts.Expression[], + config: ESLint.ConfigData +) { + const mappedPlugins: { name: string; varName: string; imp: string }[] = []; + config.plugins.forEach((name) => { + const imp = getPluginImport(name); + const varName = names(imp).propertyName; + mappedPlugins.push({ name, varName, imp }); + }); + mappedPlugins.forEach(({ varName, imp }) => { + importsMap.set(imp, varName); + }); + const pluginsAst = ts.factory.createObjectLiteralExpression( + [ + ts.factory.createPropertyAssignment( + 'plugins', + ts.factory.createObjectLiteralExpression( + mappedPlugins.map(({ name, varName }) => { + return ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral(name), + ts.factory.createIdentifier(varName) + ); + }), + mappedPlugins.length > 1 + ) + ), + ...(config.processor + ? [ + ts.factory.createPropertyAssignment( + 'processor', + ts.factory.createStringLiteral(config.processor) + ), + ] + : []), + ], + false + ); + configBlocks.push(pluginsAst); +} + +function addParser( + importsMap: Map, + config: ESLint.ConfigData +): ts.PropertyAssignment { + const imp = config.parser; + const parserName = names(imp).propertyName; + importsMap.set(imp, parserName); + + return ts.factory.createPropertyAssignment( + 'parser', + ts.factory.createIdentifier(parserName) + ); +} + +function addFlattenedOverride( + override: Linter.ConfigOverride, + configBlocks: ts.Expression[] +) { + const { files, excludedFiles, rules, ...rest } = override; + + const objectLiteralElements: ts.ObjectLiteralElementLike[] = [ + ts.factory.createSpreadAssignment(ts.factory.createIdentifier('config')), + ]; + if (files) { + objectLiteralElements.push( + ts.factory.createPropertyAssignment('files', generateAst(files)) + ); + } + if (excludedFiles) { + objectLiteralElements.push( + ts.factory.createPropertyAssignment( + 'excludedFiles', + generateAst(excludedFiles) + ) + ); + } + if (rules) { + objectLiteralElements.push( + ts.factory.createPropertyAssignment('rules', generateAst(rules)) + ); + } + + const overrideSpread = ts.factory.createSpreadElement( + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('compat'), + ts.factory.createIdentifier('config') + ), + undefined, + [generateAst(rest)] + ), + ts.factory.createIdentifier('map') + ), + undefined, + [ + ts.factory.createArrowFunction( + undefined, + undefined, + [ + ts.factory.createParameterDeclaration( + undefined, + undefined, + 'config' + ), + ], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createParenthesizedExpression( + ts.factory.createObjectLiteralExpression( + objectLiteralElements, + true + ) + ) + ), + ] + ) + ); + configBlocks.push(overrideSpread); +} + +const DEFAULT_FLAT_CONFIG = ` +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}); +`; + +function createNodeList( + importsMap: Map, + exportElements: ts.Expression[], + isFlatCompatNeeded: boolean +): ts.NodeArray< + ts.VariableStatement | ts.Identifier | ts.ExpressionStatement | ts.SourceFile +> { + const importsList = []; + if (isFlatCompatNeeded) { + importsMap.set('@eslint/js', 'js'); + + importsList.push( + generateRequire( + ts.factory.createObjectBindingPattern([ + ts.factory.createBindingElement(undefined, undefined, 'FlatCompat'), + ]), + '@eslint/eslintrc' + ) + ); + } + + // generateRequire(varName, imp, ts.factory); + Array.from(importsMap.entries()).forEach(([imp, varName]) => { + importsList.push(generateRequire(varName, imp)); + }); + + return ts.factory.createNodeArray([ + // add plugin imports + ...importsList, + ts.createSourceFile( + '', + isFlatCompatNeeded ? DEFAULT_FLAT_CONFIG : '', + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.JS + ), + // creates: + // module.exports = [ ... ]; + ts.factory.createExpressionStatement( + ts.factory.createBinaryExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('module'), + ts.factory.createIdentifier('exports') + ), + ts.factory.createToken(ts.SyntaxKind.EqualsToken), + ts.factory.createArrayLiteralExpression(exportElements, true) + ) + ), + ]); +} diff --git a/packages/linter/src/generators/convert-to-flat-config/generator.spec.ts b/packages/linter/src/generators/convert-to-flat-config/generator.spec.ts new file mode 100644 index 00000000000000..59cd3cb591530d --- /dev/null +++ b/packages/linter/src/generators/convert-to-flat-config/generator.spec.ts @@ -0,0 +1,350 @@ +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { + NxJsonConfiguration, + Tree, + addProjectConfiguration, + readJson, + updateJson, +} from '@nx/devkit'; + +import { convertToFlatConfigGenerator } from './generator'; +import { ConvertToFlatConfigGeneratorSchema } from './schema'; +import { lintProjectGenerator } from '../lint-project/lint-project'; +import { Linter } from '../utils/linter'; +import { eslintrcVersion } from '../../utils/versions'; + +describe('convert-to-flat-config generator', () => { + let tree: Tree; + const options: ConvertToFlatConfigGeneratorSchema = { skipFormat: false }; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(tree, 'test-lib', { + root: 'libs/test-lib', + targets: {}, + }); + updateJson(tree, 'nx.json', (json: NxJsonConfiguration) => { + json.targetDefaults = { + lint: { + inputs: ['default'], + }, + }; + json.namedInputs = { + default: ['{projectRoot}/**/*', 'sharedGlobals'], + production: [ + 'default', + '!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)', + ], + sharedGlobals: [], + }; + return json; + }); + }); + + it('should run successfully', async () => { + await lintProjectGenerator(tree, { + skipFormat: false, + linter: Linter.EsLint, + eslintFilePatterns: ['**/*.ts'], + project: 'test-lib', + setParserOptionsProject: false, + }); + await convertToFlatConfigGenerator(tree, options); + + expect(tree.exists('eslint.config.js')).toBeTruthy(); + expect(tree.read('eslint.config.js', 'utf-8')).toMatchSnapshot(); + expect(tree.exists('libs/test-lib/eslint.config.js')).toBeTruthy(); + expect( + tree.read('libs/test-lib/eslint.config.js', 'utf-8') + ).toMatchSnapshot(); + // check nx.json changes + const nxJson = readJson(tree, 'nx.json'); + expect(nxJson.targetDefaults.lint.inputs).toContain( + '{workspaceRoot}/eslint.config.js' + ); + expect(nxJson.namedInputs.production).toContain( + '!{projectRoot}/eslint.config.js' + ); + }); + + it('should add plugin extends', async () => { + await lintProjectGenerator(tree, { + skipFormat: false, + linter: Linter.EsLint, + eslintFilePatterns: ['**/*.ts'], + project: 'test-lib', + setParserOptionsProject: false, + }); + updateJson(tree, '.eslintrc.json', (json) => { + json.extends = ['plugin:storybook/recommended']; + return json; + }); + await convertToFlatConfigGenerator(tree, options); + + expect(tree.read('eslint.config.js', 'utf-8')).toMatchInlineSnapshot(` + "const { FlatCompat } = require('@eslint/eslintrc'); + const nxEslintPlugin = require('@nx/eslint-plugin'); + const js = require('@eslint/js'); + const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + }); + module.exports = [ + ...compat.extends('plugin:storybook/recommended'), + { plugins: { '@nx': nxEslintPlugin } }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: { + '@nx/enforce-module-boundaries': [ + 'error', + { + enforceBuildableLibDependency: true, + allow: [], + depConstraints: [ + { + sourceTag: '*', + onlyDependOnLibsWithTags: ['*'], + }, + ], + }, + ], + }, + }, + ...compat.config({ extends: ['plugin:@nx/typescript'] }).map((config) => ({ + ...config, + files: ['**/*.ts', '**/*.tsx'], + rules: {}, + })), + ...compat.config({ extends: ['plugin:@nx/javascript'] }).map((config) => ({ + ...config, + files: ['**/*.js', '**/*.jsx'], + rules: {}, + })), + ]; + " + `); + expect(tree.read('libs/test-lib/eslint.config.js', 'utf-8')) + .toMatchInlineSnapshot(` + "const baseConfig = require('../../eslint.config.js'); + module.exports = [ + ...baseConfig, + { + files: [ + 'libs/test-lib/**/*.ts', + 'libs/test-lib/**/*.tsx', + 'libs/test-lib/**/*.js', + 'libs/test-lib/**/*.jsx', + ], + rules: {}, + }, + { + files: ['libs/test-lib/**/*.ts', 'libs/test-lib/**/*.tsx'], + rules: {}, + }, + { + files: ['libs/test-lib/**/*.js', 'libs/test-lib/**/*.jsx'], + rules: {}, + }, + ]; + " + `); + expect( + readJson(tree, 'package.json').devDependencies['@eslint/eslintrc'] + ).toEqual(eslintrcVersion); + }); + + it('should add global gitignores', async () => { + await lintProjectGenerator(tree, { + skipFormat: false, + linter: Linter.EsLint, + eslintFilePatterns: ['**/*.ts'], + project: 'test-lib', + setParserOptionsProject: false, + }); + tree.write('.eslintignore', 'ignore/me'); + await convertToFlatConfigGenerator(tree, options); + + expect(tree.read('eslint.config.js', 'utf-8')).toMatchSnapshot(); + }); + + it('should add settings', async () => { + await lintProjectGenerator(tree, { + skipFormat: false, + linter: Linter.EsLint, + eslintFilePatterns: ['**/*.ts'], + project: 'test-lib', + setParserOptionsProject: false, + }); + updateJson(tree, '.eslintrc.json', (json) => { + json.settings = { + sharedData: 'Hello', + }; + return json; + }); + await convertToFlatConfigGenerator(tree, options); + + expect(tree.read('eslint.config.js', 'utf-8')).toMatchSnapshot(); + }); + + it('should add env configuration', async () => { + await lintProjectGenerator(tree, { + skipFormat: false, + linter: Linter.EsLint, + eslintFilePatterns: ['**/*.ts'], + project: 'test-lib', + setParserOptionsProject: false, + }); + updateJson(tree, '.eslintrc.json', (json) => { + json.env = { + browser: true, + node: true, + }; + return json; + }); + await convertToFlatConfigGenerator(tree, options); + + expect(tree.read('eslint.config.js', 'utf-8')).toMatchSnapshot(); + }); + + it('should add global configuration', async () => { + await lintProjectGenerator(tree, { + skipFormat: false, + linter: Linter.EsLint, + eslintFilePatterns: ['**/*.ts'], + project: 'test-lib', + setParserOptionsProject: false, + }); + updateJson(tree, '.eslintrc.json', (json) => { + json.globals = { + myCustomGlobal: 'readonly', + }; + return json; + }); + await convertToFlatConfigGenerator(tree, options); + + expect(tree.read('eslint.config.js', 'utf-8')).toMatchSnapshot(); + }); + + it('should add global and env configuration', async () => { + await lintProjectGenerator(tree, { + skipFormat: false, + linter: Linter.EsLint, + eslintFilePatterns: ['**/*.ts'], + project: 'test-lib', + setParserOptionsProject: false, + }); + updateJson(tree, '.eslintrc.json', (json) => { + json.globals = { + myCustomGlobal: 'readonly', + }; + json.env = { + browser: true, + }; + return json; + }); + await convertToFlatConfigGenerator(tree, options); + + expect(tree.read('eslint.config.js', 'utf-8')).toMatchSnapshot(); + }); + + it('should add plugins', async () => { + await lintProjectGenerator(tree, { + skipFormat: false, + linter: Linter.EsLint, + eslintFilePatterns: ['**/*.ts'], + project: 'test-lib', + setParserOptionsProject: false, + }); + updateJson(tree, '.eslintrc.json', (json) => { + json.plugins = [ + 'eslint-plugin-import', + 'single-name', + '@scope/with-name', + '@just-scope', + ]; + return json; + }); + await convertToFlatConfigGenerator(tree, options); + + expect(tree.read('eslint.config.js', 'utf-8')).toMatchSnapshot(); + }); + + it('should add parser', async () => { + await lintProjectGenerator(tree, { + skipFormat: false, + linter: Linter.EsLint, + eslintFilePatterns: ['**/*.ts'], + project: 'test-lib', + setParserOptionsProject: false, + }); + updateJson(tree, '.eslintrc.json', (json) => { + json.parser = '@typescript-eslint/parser'; + return json; + }); + await convertToFlatConfigGenerator(tree, options); + + expect(tree.read('eslint.config.js', 'utf-8')).toMatchSnapshot(); + }); + + it('should add linter options', async () => { + await lintProjectGenerator(tree, { + skipFormat: false, + linter: Linter.EsLint, + eslintFilePatterns: ['**/*.ts'], + project: 'test-lib', + setParserOptionsProject: false, + }); + updateJson(tree, '.eslintrc.json', (json) => { + json.noInlineConfig = true; + return json; + }); + await convertToFlatConfigGenerator(tree, options); + + expect(tree.read('eslint.config.js', 'utf-8')).toMatchInlineSnapshot(` + "const { FlatCompat } = require('@eslint/eslintrc'); + const nxEslintPlugin = require('@nx/eslint-plugin'); + const js = require('@eslint/js'); + const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + }); + module.exports = [ + { plugins: { '@nx': nxEslintPlugin } }, + { + linterOptions: { + noInlineConfig: true, + }, + }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: { + '@nx/enforce-module-boundaries': [ + 'error', + { + enforceBuildableLibDependency: true, + allow: [], + depConstraints: [ + { + sourceTag: '*', + onlyDependOnLibsWithTags: ['*'], + }, + ], + }, + ], + }, + }, + ...compat.config({ extends: ['plugin:@nx/typescript'] }).map((config) => ({ + ...config, + files: ['**/*.ts', '**/*.tsx'], + rules: {}, + })), + ...compat.config({ extends: ['plugin:@nx/javascript'] }).map((config) => ({ + ...config, + files: ['**/*.js', '**/*.jsx'], + rules: {}, + })), + ]; + " + `); + }); +}); diff --git a/packages/linter/src/generators/convert-to-flat-config/generator.ts b/packages/linter/src/generators/convert-to-flat-config/generator.ts new file mode 100644 index 00000000000000..8047e18cf76eed --- /dev/null +++ b/packages/linter/src/generators/convert-to-flat-config/generator.ts @@ -0,0 +1,129 @@ +import { + formatFiles, + getProjects, + NxJsonConfiguration, + ProjectConfiguration, + readNxJson, + Tree, + updateJson, + updateProjectConfiguration, +} from '@nx/devkit'; +import { ConvertToFlatConfigGeneratorSchema } from './schema'; +import { findEslintFile } from '../utils/eslint-file'; +import { convertEslintJsonToFlatConfig } from './converters/json-converter'; + +export async function convertToFlatConfigGenerator( + tree: Tree, + options: ConvertToFlatConfigGeneratorSchema +) { + const eslintFile = findEslintFile(tree); + if (!eslintFile) { + throw new Error('Could not find root eslint file'); + } + if (!eslintFile.endsWith('.json')) { + throw new Error( + 'Only json eslint config files are supported for conversion' + ); + } + + // rename root eslint config to eslint.config.js + convertRootToFlatConfig(tree, eslintFile); + // rename and map files + const projects = getProjects(tree); + for (const [project, projectConfig] of projects) { + convertProjectToFlatConfig(tree, project, projectConfig, readNxJson(tree)); + } + // replace references in nx.json + updateNxJsonConfig(tree); + // install missing packages + + if (!options.skipFormat) { + await formatFiles(tree); + } +} + +export default convertToFlatConfigGenerator; + +function convertRootToFlatConfig(tree: Tree, eslintFile: string) { + if (eslintFile.endsWith('.base.json')) { + convertConfigToFlatConfig( + tree, + '', + '.eslintrc.base.json', + 'eslint.base.config.js' + ); + } + convertConfigToFlatConfig(tree, '', '.eslintrc.json', 'eslint.config.js'); +} + +function convertProjectToFlatConfig( + tree: Tree, + project: string, + projectConfig: ProjectConfiguration, + nxJson: NxJsonConfiguration +) { + if (tree.exists(`${projectConfig.root}/.eslintrc.json`)) { + if (projectConfig.targets) { + const eslintTargets = Object.keys(projectConfig.targets || {}).filter( + (t) => projectConfig.targets[t].executor === '@nx/linter:eslint' + ); + for (const target of eslintTargets) { + // remove any obsolete `eslintConfig` options pointing to the old config file + if (projectConfig.targets[target].options?.eslintConfig) { + delete projectConfig.targets[target].options.eslintConfig; + } + updateProjectConfiguration(tree, project, projectConfig); + } + const nxHasLintTargets = Object.keys(nxJson.targetDefaults || {}).some( + (t) => + (t === '@nx/linter:eslint' || + nxJson.targetDefaults[t].executor === '@nx/linter:eslint') && + projectConfig.targets?.[t] + ); + if (nxHasLintTargets || eslintTargets.length > 0) { + convertConfigToFlatConfig( + tree, + projectConfig.root, + '.eslintrc.json', + 'eslint.config.js' + ); + } + } + } +} + +// update names of eslint files in nx.json +// and remove eslintignore +function updateNxJsonConfig(tree: Tree) { + if (tree.exists('nx.json')) { + updateJson(tree, 'nx.json', (json: NxJsonConfiguration) => { + if (json.targetDefaults?.lint?.inputs) { + const inputSet = new Set(json.targetDefaults.lint.inputs); + inputSet.add('{workspaceRoot}/eslint.config.js'); + json.targetDefaults.lint.inputs = Array.from(inputSet); + } + if (json.targetDefaults?.['@nx/linter:eslint']?.inputs) { + const inputSet = new Set( + json.targetDefaults['@nx/linter:eslint'].inputs + ); + inputSet.add('{workspaceRoot}/eslint.config.js'); + json.targetDefaults['@nx/linter:eslint'].inputs = Array.from(inputSet); + } + if (json.namedInputs?.production) { + const inputSet = new Set(json.namedInputs.production); + inputSet.add('!{projectRoot}/eslint.config.js'); + json.namedInputs.production = Array.from(inputSet); + } + return json; + }); + } +} + +function convertConfigToFlatConfig( + tree: Tree, + root: string, + source: string, + target: string +) { + convertEslintJsonToFlatConfig(tree, root, source, target); +} diff --git a/packages/linter/src/generators/convert-to-flat-config/schema.d.ts b/packages/linter/src/generators/convert-to-flat-config/schema.d.ts new file mode 100644 index 00000000000000..67eeb4ddadf421 --- /dev/null +++ b/packages/linter/src/generators/convert-to-flat-config/schema.d.ts @@ -0,0 +1,3 @@ +export interface ConvertToFlatConfigGeneratorSchema { + skipFormat?: boolean; +} diff --git a/packages/linter/src/generators/convert-to-flat-config/schema.json b/packages/linter/src/generators/convert-to-flat-config/schema.json new file mode 100644 index 00000000000000..55297951cf0de2 --- /dev/null +++ b/packages/linter/src/generators/convert-to-flat-config/schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "ConvertToFlatConfig", + "cli": "nx", + "description": "Convert an Nx workspace to a Flat ESLint config.", + "type": "object", + "properties": { + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files.", + "default": false, + "x-priority": "internal" + } + }, + "additionalProperties": false, + "required": [] +} diff --git a/packages/linter/src/generators/init/init.spec.ts b/packages/linter/src/generators/init/init.spec.ts index 4f579e5bb734f0..0b0c2aef641bf1 100644 --- a/packages/linter/src/generators/init/init.spec.ts +++ b/packages/linter/src/generators/init/init.spec.ts @@ -34,6 +34,7 @@ describe('@nx/linter:init', () => { 'default', '{workspaceRoot}/.eslintrc.json', '{workspaceRoot}/.eslintignore', + '{workspaceRoot}/eslint.config.js', ], }); }); diff --git a/packages/linter/src/generators/init/init.ts b/packages/linter/src/generators/init/init.ts index d9023389f943a2..03e889b71bd8ef 100644 --- a/packages/linter/src/generators/init/init.ts +++ b/packages/linter/src/generators/init/init.ts @@ -32,6 +32,7 @@ function addTargetDefaults(tree: Tree) { if (productionFileSet) { // Remove .eslintrc.json productionFileSet.push('!{projectRoot}/.eslintrc.json'); + productionFileSet.push('!{projectRoot}/eslint.config.js'); // Dedupe and set nxJson.namedInputs.production = Array.from(new Set(productionFileSet)); } @@ -43,6 +44,7 @@ function addTargetDefaults(tree: Tree) { 'default', `{workspaceRoot}/.eslintrc.json`, `{workspaceRoot}/.eslintignore`, + `{workspaceRoot}/eslint.config.js`, ]; updateNxJson(tree, nxJson); } diff --git a/packages/linter/src/utils/versions.ts b/packages/linter/src/utils/versions.ts index 2eb1fe0994d3a0..da83374b056a30 100644 --- a/packages/linter/src/utils/versions.ts +++ b/packages/linter/src/utils/versions.ts @@ -1,6 +1,7 @@ export const nxVersion = require('../../package.json').version; export const eslintVersion = '~8.46.0'; +export const eslintrcVersion = '^2.1.1'; export const eslintConfigPrettierVersion = '8.1.0'; /** @deprecated This will be removed in v17 */ export const tslintToEslintConfigVersion = '^2.13.0'; diff --git a/packages/nx/src/command-line/init/implementation/angular/standalone-workspace.ts b/packages/nx/src/command-line/init/implementation/angular/standalone-workspace.ts index e861f084494e44..fbf9b8cbda4c26 100644 --- a/packages/nx/src/command-line/init/implementation/angular/standalone-workspace.ts +++ b/packages/nx/src/command-line/init/implementation/angular/standalone-workspace.ts @@ -90,7 +90,9 @@ function createNxJson( karmaProjectConfigFile ? '!{projectRoot}/karma.conf.js' : undefined, ].filter(Boolean) : []), - eslintProjectConfigFile ? '!{projectRoot}/.eslintrc.json' : undefined, + ...(eslintProjectConfigFile + ? ['!{projectRoot}/.eslintrc.json', '!{projectRoot}/eslint.config.js'] + : []), ].filter(Boolean), }; nxJson.targetDefaults = {}; @@ -115,6 +117,9 @@ function createNxJson( if (fileExists(join(repoRoot, '.eslintrc.json'))) { inputs.push('{workspaceRoot}/.eslintrc.json'); } + if (fileExists(join(repoRoot, 'eslint.config.js'))) { + inputs.push('{workspaceRoot}/eslint.config.js'); + } nxJson.targetDefaults.lint = { inputs }; } if (workspaceTargets.includes('e2e')) { diff --git a/packages/nx/src/hasher/task-hasher.ts b/packages/nx/src/hasher/task-hasher.ts index c6ee6231a6150b..a3651aa26574a2 100644 --- a/packages/nx/src/hasher/task-hasher.ts +++ b/packages/nx/src/hasher/task-hasher.ts @@ -743,6 +743,7 @@ class TaskHasherImpl { runtime, { windowsHide: true, + cwd: workspaceRoot, }, (err, stdout, stderr) => { if (err) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b66a47539a506..385f93e221f3ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,6 +217,12 @@ devDependencies: '@babel/runtime': specifier: ^7.22.6 version: 7.22.6 + '@eslint/eslintrc': + specifier: ^2.1.1 + version: 2.1.1 + '@eslint/js': + specifier: ^8.46.0 + version: 8.46.0 '@floating-ui/react': specifier: 0.19.2 version: 0.19.2(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0)