Revert "Revert "feat(linter): add generator for converting to flat co… (#18631)

This commit is contained in:
Jason Jean 2023-08-15 15:25:09 -04:00 committed by GitHub
parent e2ac4e38e7
commit 968bd38218
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1879 additions and 5 deletions

View File

@ -6384,6 +6384,14 @@
"children": [], "children": [],
"isExternal": false, "isExternal": false,
"disableCollapsible": 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, "isExternal": false,

View File

@ -1140,6 +1140,15 @@
"originalFilePath": "/packages/linter/src/generators/workspace-rule/schema.json", "originalFilePath": "/packages/linter/src/generators/workspace-rule/schema.json",
"path": "/packages/linter/generators/workspace-rule", "path": "/packages/linter/generators/workspace-rule",
"type": "generator" "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" "path": "/packages/linter"

View File

@ -1123,6 +1123,15 @@
"originalFilePath": "/packages/linter/src/generators/workspace-rule/schema.json", "originalFilePath": "/packages/linter/src/generators/workspace-rule/schema.json",
"path": "linter/generators/workspace-rule", "path": "linter/generators/workspace-rule",
"type": "generator" "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", "githubRoot": "https://github.com/nrwl/nx/blob/master",

View File

@ -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"
}

View File

@ -413,6 +413,7 @@
- [generators](/packages/linter/generators) - [generators](/packages/linter/generators)
- [workspace-rules-project](/packages/linter/generators/workspace-rules-project) - [workspace-rules-project](/packages/linter/generators/workspace-rules-project)
- [workspace-rule](/packages/linter/generators/workspace-rule) - [workspace-rule](/packages/linter/generators/workspace-rule)
- [convert-to-flat-config](/packages/linter/generators/convert-to-flat-config)
- [nest](/packages/nest) - [nest](/packages/nest)
- [documents](/packages/nest/documents) - [documents](/packages/nest/documents)
- [Overview](/packages/nest/documents/overview) - [Overview](/packages/nest/documents/overview)

View File

@ -1,12 +1,15 @@
import * as path from 'path'; import * as path from 'path';
import { import {
checkFilesDoNotExist,
checkFilesExist, checkFilesExist,
cleanupProject, cleanupProject,
createFile, createFile,
getSelectedPackageManager,
newProject, newProject,
readFile, readFile,
readJson, readJson,
runCLI, runCLI,
runCreateWorkspace,
uniq, uniq,
updateFile, updateFile,
updateJson, 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', () => { describe('Root projects migration', () => {
beforeEach(() => newProject()); beforeEach(() => newProject());
afterEach(() => cleanupProject()); afterEach(() => cleanupProject());

View File

@ -46,6 +46,8 @@
"@babel/preset-react": "^7.22.5", "@babel/preset-react": "^7.22.5",
"@babel/preset-typescript": "^7.22.5", "@babel/preset-typescript": "^7.22.5",
"@babel/runtime": "^7.22.6", "@babel/runtime": "^7.22.6",
"@eslint/eslintrc": "^2.1.1",
"@eslint/js": "^8.46.0",
"@floating-ui/react": "0.19.2", "@floating-ui/react": "0.19.2",
"@jest/reporters": "^29.4.1", "@jest/reporters": "^29.4.1",
"@jest/test-result": "^29.4.1", "@jest/test-result": "^29.4.1",

View File

@ -95,6 +95,7 @@ exports[`workspace move to nx layout should create nx.json 1`] = `
"!{projectRoot}/**/*.spec.[jt]s", "!{projectRoot}/**/*.spec.[jt]s",
"!{projectRoot}/karma.conf.js", "!{projectRoot}/karma.conf.js",
"!{projectRoot}/.eslintrc.json", "!{projectRoot}/.eslintrc.json",
"!{projectRoot}/eslint.config.js",
], ],
"sharedGlobals": [], "sharedGlobals": [],
}, },
@ -118,6 +119,7 @@ exports[`workspace move to nx layout should create nx.json 1`] = `
"inputs": [ "inputs": [
"default", "default",
"{workspaceRoot}/.eslintrc.json", "{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/eslint.config.js",
], ],
}, },
"test": { "test": {

View File

@ -76,7 +76,9 @@ export function createNxJson(
'!{projectRoot}/karma.conf.js', '!{projectRoot}/karma.conf.js',
] ]
: []), : []),
targets.lint ? '!{projectRoot}/.eslintrc.json' : undefined, ...(targets.lint
? ['!{projectRoot}/.eslintrc.json', '!{projectRoot}/eslint.config.js']
: []),
].filter(Boolean), ].filter(Boolean),
}, },
targetDefaults: { targetDefaults: {
@ -91,7 +93,11 @@ export function createNxJson(
: undefined, : undefined,
lint: targets.lint lint: targets.lint
? { ? {
inputs: ['default', '{workspaceRoot}/.eslintrc.json'], inputs: [
'default',
'{workspaceRoot}/.eslintrc.json',
'{workspaceRoot}/eslint.config.js',
],
} }
: undefined, : undefined,
e2e: targets.e2e e2e: targets.e2e

View File

@ -25,6 +25,11 @@
"factory": "./src/generators/workspace-rule/workspace-rule#lintWorkspaceRuleGenerator", "factory": "./src/generators/workspace-rule/workspace-rule#lintWorkspaceRuleGenerator",
"schema": "./src/generators/workspace-rule/schema.json", "schema": "./src/generators/workspace-rule/schema.json",
"description": "Create a new Workspace ESLint rule." "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."
} }
} }
} }

View File

@ -37,7 +37,8 @@
"tmp": "~0.2.1", "tmp": "~0.2.1",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"@nx/devkit": "file:../devkit", "@nx/devkit": "file:../devkit",
"@nx/js": "file:../js" "@nx/js": "file:../js",
"typescript": "~5.1.3"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"eslint": { "eslint": {

View File

@ -130,10 +130,13 @@ Please see https://nx.dev/guides/eslint for full guidance on how to resolve this
.filter((pattern) => !!pattern) .filter((pattern) => !!pattern)
.map((pattern) => `- '${pattern}'`); .map((pattern) => `- '${pattern}'`);
if (ignoredPatterns.length) { if (ignoredPatterns.length) {
const ignoreSection = useFlatConfig
? `'ignores' configuration`
: `'.eslintignore' file`;
throw new Error( throw new Error(
`All files matching the following patterns are ignored:\n${ignoredPatterns.join( `All files matching the following patterns are ignored:\n${ignoredPatterns.join(
'\n' '\n'
)}\n\nPlease check your '.eslintignore' file.` )}\n\nPlease check your ${ignoreSection}.`
); );
} }
throw new Error( throw new Error(

View File

@ -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: {},
},
];
"
`;

View File

@ -0,0 +1,68 @@
import * as ts from 'typescript';
/**
* Generates an AST from a JSON-type input
*/
export function generateAst<T>(input: unknown): T {
if (Array.isArray(input)) {
return ts.factory.createArrayLiteralExpression(
input.map((item) => generateAst<ts.Expression>(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<ts.Expression>(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);
}

View File

@ -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();
});
});

View File

@ -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<string, string>();
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<Linter.RulesRecord>,
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<string, string | string[]>,
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<string, string | string[]>,
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<string, string>,
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<Linter.RulesRecord>,
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<string, string>,
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)
)
),
]);
}

View File

@ -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: {},
})),
];
"
`);
});
});

View File

@ -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);
}

View File

@ -0,0 +1,3 @@
export interface ConvertToFlatConfigGeneratorSchema {
skipFormat?: boolean;
}

View File

@ -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": []
}

View File

@ -34,6 +34,7 @@ describe('@nx/linter:init', () => {
'default', 'default',
'{workspaceRoot}/.eslintrc.json', '{workspaceRoot}/.eslintrc.json',
'{workspaceRoot}/.eslintignore', '{workspaceRoot}/.eslintignore',
'{workspaceRoot}/eslint.config.js',
], ],
}); });
}); });

View File

@ -32,6 +32,7 @@ function addTargetDefaults(tree: Tree) {
if (productionFileSet) { if (productionFileSet) {
// Remove .eslintrc.json // Remove .eslintrc.json
productionFileSet.push('!{projectRoot}/.eslintrc.json'); productionFileSet.push('!{projectRoot}/.eslintrc.json');
productionFileSet.push('!{projectRoot}/eslint.config.js');
// Dedupe and set // Dedupe and set
nxJson.namedInputs.production = Array.from(new Set(productionFileSet)); nxJson.namedInputs.production = Array.from(new Set(productionFileSet));
} }
@ -43,6 +44,7 @@ function addTargetDefaults(tree: Tree) {
'default', 'default',
`{workspaceRoot}/.eslintrc.json`, `{workspaceRoot}/.eslintrc.json`,
`{workspaceRoot}/.eslintignore`, `{workspaceRoot}/.eslintignore`,
`{workspaceRoot}/eslint.config.js`,
]; ];
updateNxJson(tree, nxJson); updateNxJson(tree, nxJson);
} }

View File

@ -1,6 +1,7 @@
export const nxVersion = require('../../package.json').version; export const nxVersion = require('../../package.json').version;
export const eslintVersion = '~8.46.0'; export const eslintVersion = '~8.46.0';
export const eslintrcVersion = '^2.1.1';
export const eslintConfigPrettierVersion = '8.1.0'; export const eslintConfigPrettierVersion = '8.1.0';
/** @deprecated This will be removed in v17 */ /** @deprecated This will be removed in v17 */
export const tslintToEslintConfigVersion = '^2.13.0'; export const tslintToEslintConfigVersion = '^2.13.0';

View File

@ -90,7 +90,9 @@ function createNxJson(
karmaProjectConfigFile ? '!{projectRoot}/karma.conf.js' : undefined, karmaProjectConfigFile ? '!{projectRoot}/karma.conf.js' : undefined,
].filter(Boolean) ].filter(Boolean)
: []), : []),
eslintProjectConfigFile ? '!{projectRoot}/.eslintrc.json' : undefined, ...(eslintProjectConfigFile
? ['!{projectRoot}/.eslintrc.json', '!{projectRoot}/eslint.config.js']
: []),
].filter(Boolean), ].filter(Boolean),
}; };
nxJson.targetDefaults = {}; nxJson.targetDefaults = {};
@ -115,6 +117,9 @@ function createNxJson(
if (fileExists(join(repoRoot, '.eslintrc.json'))) { if (fileExists(join(repoRoot, '.eslintrc.json'))) {
inputs.push('{workspaceRoot}/.eslintrc.json'); inputs.push('{workspaceRoot}/.eslintrc.json');
} }
if (fileExists(join(repoRoot, 'eslint.config.js'))) {
inputs.push('{workspaceRoot}/eslint.config.js');
}
nxJson.targetDefaults.lint = { inputs }; nxJson.targetDefaults.lint = { inputs };
} }
if (workspaceTargets.includes('e2e')) { if (workspaceTargets.includes('e2e')) {

6
pnpm-lock.yaml generated
View File

@ -217,6 +217,12 @@ devDependencies:
'@babel/runtime': '@babel/runtime':
specifier: ^7.22.6 specifier: ^7.22.6
version: 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': '@floating-ui/react':
specifier: 0.19.2 specifier: 0.19.2
version: 0.19.2(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) version: 0.19.2(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0)