feat(linter): add flat config support to generators (#18534)

This commit is contained in:
Miroslav Jonaš 2023-08-23 01:36:58 +02:00 committed by GitHub
parent 1c058ded80
commit e34219ab96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 3539 additions and 1159 deletions

View File

@ -105,10 +105,9 @@
"@swc/core": "^1.3.51", "@swc/core": "^1.3.51",
"@swc/jest": "^0.2.20", "@swc/jest": "^0.2.20",
"@testing-library/react": "13.4.0", "@testing-library/react": "13.4.0",
"@types/css-minimizer-webpack-plugin": "^3.2.1",
"@types/cytoscape": "^3.18.2", "@types/cytoscape": "^3.18.2",
"@types/detect-port": "^1.3.2", "@types/detect-port": "^1.3.2",
"@types/eslint": "~8.4.1", "@types/eslint": "~8.44.2",
"@types/express": "4.17.14", "@types/express": "4.17.14",
"@types/flat": "^5.0.1", "@types/flat": "^5.0.1",
"@types/fs-extra": "^11.0.0", "@types/fs-extra": "^11.0.0",

View File

@ -4,13 +4,17 @@ import {
joinPathFragments, joinPathFragments,
runTasksInSerial, runTasksInSerial,
Tree, Tree,
updateJson,
} from '@nx/devkit'; } from '@nx/devkit';
import { Linter, lintProjectGenerator } from '@nx/linter'; import { Linter, lintProjectGenerator } from '@nx/linter';
import { mapLintPattern } from '@nx/linter/src/generators/lint-project/lint-project'; import { mapLintPattern } from '@nx/linter/src/generators/lint-project/lint-project';
import { addAngularEsLintDependencies } from './lib/add-angular-eslint-dependencies'; import { addAngularEsLintDependencies } from './lib/add-angular-eslint-dependencies';
import { extendAngularEslintJson } from './lib/create-eslint-configuration';
import type { AddLintingGeneratorSchema } from './schema'; import type { AddLintingGeneratorSchema } from './schema';
import {
findEslintFile,
isEslintConfigSupported,
replaceOverridesInLintConfig,
} from '@nx/linter/src/generators/utils/eslint-file';
import { camelize, dasherize } from '@nx/devkit/src/utils/string-utils';
export async function addLintingGenerator( export async function addLintingGenerator(
tree: Tree, tree: Tree,
@ -35,11 +39,57 @@ export async function addLintingGenerator(
}); });
tasks.push(lintTask); tasks.push(lintTask);
updateJson( if (isEslintConfigSupported(tree)) {
tree, const eslintFile = findEslintFile(tree, options.projectRoot);
joinPathFragments(options.projectRoot, '.eslintrc.json'), // keep parser options if they exist
(json) => extendAngularEslintJson(json, options) const hasParserOptions = tree
); .read(joinPathFragments(options.projectRoot, eslintFile), 'utf8')
.includes(`${options.projectRoot}/tsconfig.*?.json`);
replaceOverridesInLintConfig(tree, options.projectRoot, [
{
files: ['*.ts'],
...(hasParserOptions
? {
parserOptions: {
project: [`${options.projectRoot}/tsconfig.*?.json`],
},
}
: {}),
extends: [
'plugin:@nx/angular',
'plugin:@angular-eslint/template/process-inline-templates',
],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: camelize(options.prefix),
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: dasherize(options.prefix),
style: 'kebab-case',
},
],
},
},
{
files: ['*.html'],
extends: ['plugin:@nx/angular-template'],
/**
* Having an empty rules object present makes it more obvious to the user where they would
* extend things from if they needed to
*/
rules: {},
},
]);
}
if (!options.skipPackageJson) { if (!options.skipPackageJson) {
const installTask = addAngularEsLintDependencies(tree); const installTask = addAngularEsLintDependencies(tree);

View File

@ -8,6 +8,9 @@ type EslintExtensionSchema = {
prefix: string; prefix: string;
}; };
/**
* @deprecated Use tools from `@nx/linter/src/generators/utils/eslint-file` instead
*/
export const extendAngularEslintJson = ( export const extendAngularEslintJson = (
json: Linter.Config, json: Linter.Config,
options: EslintExtensionSchema options: EslintExtensionSchema

View File

@ -21,6 +21,7 @@ describe('Cypress e2e configuration', () => {
beforeEach(() => { beforeEach(() => {
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
tree.write('.eslintrc.json', '{}'); // we are explicitly checking for existance of config type
}); });
afterAll(() => { afterAll(() => {

View File

@ -5,12 +5,19 @@ import {
readProjectConfiguration, readProjectConfiguration,
runTasksInSerial, runTasksInSerial,
Tree, Tree,
updateJson,
} from '@nx/devkit'; } from '@nx/devkit';
import { Linter, lintProjectGenerator } from '@nx/linter'; import { Linter, lintProjectGenerator } from '@nx/linter';
import { globalJavaScriptOverrides } from '@nx/linter/src/generators/init/global-eslint-config';
import { installedCypressVersion } from './cypress-version'; import { installedCypressVersion } from './cypress-version';
import { eslintPluginCypressVersion } from './versions'; import { eslintPluginCypressVersion } from './versions';
import {
addExtendsToLintConfig,
addOverrideToLintConfig,
addPluginsToLintConfig,
findEslintFile,
isEslintConfigSupported,
replaceOverridesInLintConfig,
} from '@nx/linter/src/generators/utils/eslint-file';
import { javaScriptOverride } from '@nx/linter/src/generators/init/global-eslint-config';
export interface CyLinterOptions { export interface CyLinterOptions {
project: string; project: string;
@ -42,7 +49,8 @@ export async function addLinterToCyProject(
const tasks: GeneratorCallback[] = []; const tasks: GeneratorCallback[] = [];
const projectConfig = readProjectConfiguration(tree, options.project); const projectConfig = readProjectConfiguration(tree, options.project);
if (!tree.exists(joinPathFragments(projectConfig.root, '.eslintrc.json'))) { const eslintFile = findEslintFile(tree, projectConfig.root);
if (!eslintFile) {
tasks.push( tasks.push(
await lintProjectGenerator(tree, { await lintProjectGenerator(tree, {
project: options.project, project: options.project,
@ -73,19 +81,33 @@ export async function addLinterToCyProject(
: () => {} : () => {}
); );
updateJson( if (isEslintConfigSupported(tree)) {
tree, const overrides = [];
joinPathFragments(projectConfig.root, '.eslintrc.json'), if (options.rootProject) {
(json) => { addPluginsToLintConfig(tree, projectConfig.root, '@nx');
if (options.rootProject) { overrides.push(javaScriptOverride);
json.plugins = ['@nx']; }
json.extends = ['plugin:cypress/recommended']; addExtendsToLintConfig(
} else { tree,
json.extends = ['plugin:cypress/recommended', ...json.extends]; projectConfig.root,
} 'plugin:cypress/recommended'
json.overrides ??= []; );
const globals = options.rootProject ? [globalJavaScriptOverrides] : []; const cyVersion = installedCypressVersion();
const override = { /**
* We need this override because we enabled allowJS in the tsconfig to allow for JS based Cypress tests.
* That however leads to issues with the CommonJS Cypress plugin file.
*/
const cy6Override = {
files: [`${options.cypressDir}/plugins/index.js`],
rules: {
'@typescript-eslint/no-var-requires': 'off',
'no-undef': 'off',
},
};
const addCy6Override = cyVersion && cyVersion < 7;
if (options.overwriteExisting) {
overrides.push({
files: ['*.ts', '*.tsx', '*.js', '*.jsx'], files: ['*.ts', '*.tsx', '*.js', '*.jsx'],
parserOptions: !options.setParserOptionsProject parserOptions: !options.setParserOptionsProject
? undefined ? undefined
@ -93,40 +115,32 @@ export async function addLinterToCyProject(
project: `${projectConfig.root}/tsconfig.*?.json`, project: `${projectConfig.root}/tsconfig.*?.json`,
}, },
rules: {}, rules: {},
}; });
const cyFiles = [ if (addCy6Override) {
{ overrides.push(cy6Override);
...override,
files: [
'*.cy.{ts,js,tsx,jsx}',
`${options.cypressDir}/**/*.{ts,js,tsx,jsx}`,
],
},
];
if (options.overwriteExisting) {
json.overrides = [...globals, override];
} else {
json.overrides.push(...globals);
json.overrides.push(...cyFiles);
} }
replaceOverridesInLintConfig(tree, projectConfig.root, overrides);
const cyVersion = installedCypressVersion(); } else {
if (cyVersion && cyVersion < 7) { overrides.unshift({
/** files: [
* We need this override because we enabled allowJS in the tsconfig to allow for JS based Cypress tests. '*.cy.{ts,js,tsx,jsx}',
* That however leads to issues with the CommonJS Cypress plugin file. `${options.cypressDir}/**/*.{ts,js,tsx,jsx}`,
*/ ],
json.overrides.push({ parserOptions: !options.setParserOptionsProject
files: [`${options.cypressDir}/plugins/index.js`], ? undefined
rules: { : {
'@typescript-eslint/no-var-requires': 'off', project: `${projectConfig.root}/tsconfig.*?.json`,
'no-undef': 'off', },
}, rules: {},
}); });
if (addCy6Override) {
overrides.push(cy6Override);
} }
return json; overrides.forEach((override) =>
addOverrideToLintConfig(tree, projectConfig.root, override)
);
} }
); }
return runTasksInSerial(...tasks); return runTasksInSerial(...tasks);
} }

View File

@ -4,10 +4,13 @@ import {
joinPathFragments, joinPathFragments,
runTasksInSerial, runTasksInSerial,
Tree, Tree,
updateJson,
} from '@nx/devkit'; } from '@nx/devkit';
import { extendReactEslintJson, extraEslintDependencies } from '@nx/react'; import { extraEslintDependencies } from '@nx/react';
import { NormalizedSchema } from './normalize-options'; import { NormalizedSchema } from './normalize-options';
import {
addExtendsToLintConfig,
isEslintConfigSupported,
} from '@nx/linter/src/generators/utils/eslint-file';
export async function addLinting(host: Tree, options: NormalizedSchema) { export async function addLinting(host: Tree, options: NormalizedSchema) {
if (options.linter === Linter.None) { if (options.linter === Linter.None) {
@ -24,11 +27,9 @@ export async function addLinting(host: Tree, options: NormalizedSchema) {
skipFormat: true, skipFormat: true,
}); });
updateJson( if (isEslintConfigSupported(host)) {
host, addExtendsToLintConfig(host, options.e2eProjectRoot, 'plugin:@nx/react');
joinPathFragments(options.e2eProjectRoot, '.eslintrc.json'), }
extendReactEslintJson
);
const installTask = addDependenciesToPackageJson( const installTask = addDependenciesToPackageJson(
host, host,

View File

@ -2,16 +2,15 @@ import { Linter, lintProjectGenerator } from '@nx/linter';
import { import {
addDependenciesToPackageJson, addDependenciesToPackageJson,
GeneratorCallback, GeneratorCallback,
joinPathFragments,
runTasksInSerial, runTasksInSerial,
Tree, Tree,
updateJson,
} from '@nx/devkit'; } from '@nx/devkit';
import { extraEslintDependencies } from '@nx/react/src/utils/lint';
import { import {
extendReactEslintJson, addExtendsToLintConfig,
extraEslintDependencies, addIgnoresToLintConfig,
} from '@nx/react/src/utils/lint'; isEslintConfigSupported,
import type { Linter as ESLintLinter } from 'eslint'; } from '@nx/linter/src/generators/utils/eslint-file';
interface NormalizedSchema { interface NormalizedSchema {
linter?: Linter; linter?: Linter;
@ -39,24 +38,15 @@ export async function addLinting(host: Tree, options: NormalizedSchema) {
tasks.push(lintTask); tasks.push(lintTask);
updateJson( if (isEslintConfigSupported(host)) {
host, addExtendsToLintConfig(host, options.projectRoot, 'plugin:@nx/react');
joinPathFragments(options.projectRoot, '.eslintrc.json'), addIgnoresToLintConfig(host, options.projectRoot, [
(json: ESLintLinter.Config) => { '.expo',
json = extendReactEslintJson(json); 'web-build',
'cache',
json.ignorePatterns = [ 'dist',
...json.ignorePatterns, ]);
'.expo', }
'node_modules',
'web-build',
'cache',
'dist',
];
return json;
}
);
if (!options.skipPackageJson) { if (!options.skipPackageJson) {
const installTask = await addDependenciesToPackageJson( const installTask = await addDependenciesToPackageJson(

View File

@ -256,20 +256,24 @@ export async function addLint(
setParserOptionsProject: options.setParserOptionsProject, setParserOptionsProject: options.setParserOptionsProject,
rootProject: options.rootProject, rootProject: options.rootProject,
}); });
// Also update the root .eslintrc.json lintProjectGenerator will not generate it for root projects. // Also update the root ESLint config. The lintProjectGenerator will not generate it for root projects.
// But we need to set the package.json checks. // But we need to set the package.json checks.
if (options.rootProject) { if (options.rootProject) {
updateJson(tree, '.eslintrc.json', (json) => { const {
json.overrides ??= []; addOverrideToLintConfig,
json.overrides.push({ isEslintConfigSupported,
// nx-ignore-next-line
} = require('@nx/linter/src/generators/utils/eslint-file');
if (isEslintConfigSupported(tree)) {
addOverrideToLintConfig(tree, '', {
files: ['*.json'], files: ['*.json'],
parser: 'jsonc-eslint-parser', parser: 'jsonc-eslint-parser',
rules: { rules: {
'@nx/dependency-checks': 'error', '@nx/dependency-checks': 'error',
}, },
}); });
return json; }
});
} }
return task; return task;
} }

View File

@ -1,4 +1,4 @@
import { ExecutorContext, joinPathFragments } from '@nx/devkit'; import { ExecutorContext, joinPathFragments, workspaceRoot } from '@nx/devkit';
import { ESLint } from 'eslint'; import { ESLint } from 'eslint';
import { existsSync, mkdirSync, writeFileSync } from 'fs'; import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path'; import { dirname, resolve } from 'path';
@ -46,11 +46,11 @@ export default async function run(
* we only want to support it if the user has explicitly opted into it by converting * we only want to support it if the user has explicitly opted into it by converting
* their root ESLint config to use eslint.config.js * their root ESLint config to use eslint.config.js
*/ */
const useFlatConfig = existsSync( const hasFlatConfig = existsSync(
joinPathFragments(systemRoot, 'eslint.config.js') joinPathFragments(workspaceRoot, 'eslint.config.js')
); );
if (!eslintConfigPath && useFlatConfig) { if (!eslintConfigPath && hasFlatConfig) {
const projectRoot = const projectRoot =
context.projectsConfigurations.projects[context.projectName].root; context.projectsConfigurations.projects[context.projectName].root;
eslintConfigPath = joinPathFragments(projectRoot, 'eslint.config.js'); eslintConfigPath = joinPathFragments(projectRoot, 'eslint.config.js');
@ -59,7 +59,7 @@ export default async function run(
const { eslint, ESLint } = await resolveAndInstantiateESLint( const { eslint, ESLint } = await resolveAndInstantiateESLint(
eslintConfigPath, eslintConfigPath,
normalizedOptions, normalizedOptions,
useFlatConfig hasFlatConfig
); );
const version = ESLint.version?.split('.'); const version = ESLint.version?.split('.');
@ -130,7 +130,7 @@ 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 const ignoreSection = hasFlatConfig
? `'ignores' configuration` ? `'ignores' configuration`
: `'.eslintignore' file`; : `'.eslintignore' file`;
throw new Error( throw new Error(

View File

@ -19,7 +19,7 @@ export interface Schema extends JsonObject {
cacheStrategy: 'content' | 'metadata' | null; cacheStrategy: 'content' | 'metadata' | null;
rulesdir: string[]; rulesdir: string[];
resolvePluginsRelativeTo: string | null; resolvePluginsRelativeTo: string | null;
reportUnusedDisableDirectives: Linter.RuleLevel | null; reportUnusedDisableDirectives: Linter.StringSeverity | null;
printConfig?: string | null; printConfig?: string | null;
} }

View File

@ -1,68 +0,0 @@
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

@ -5,10 +5,19 @@ import {
readJson, readJson,
} from '@nx/devkit'; } from '@nx/devkit';
import { join } from 'path'; import { join } from 'path';
import { ESLint, Linter } from 'eslint'; import { ESLint } from 'eslint';
import * as ts from 'typescript'; import * as ts from 'typescript';
import { generateAst, generateRequire } from './generate-ast';
import { eslintrcVersion } from '../../../utils/versions'; import { eslintrcVersion } from '../../../utils/versions';
import {
createNodeList,
generateAst,
generateFlatOverride,
generatePluginExtendsElement,
generateSpreadElement,
mapFilePath,
stringifyNodeList,
} from '../../utils/flat-config/ast-utils';
import { getPluginImport } from '../../utils/eslint-file';
/** /**
* Converts an ESLint JSON config to a flat config. * Converts an ESLint JSON config to a flat config.
@ -38,7 +47,16 @@ export function convertEslintJsonToFlatConfig(
} }
if (config.parser) { if (config.parser) {
languageOptions.push(addParser(importsMap, config)); const imp = config.parser;
const parserName = names(imp).propertyName;
importsMap.set(imp, parserName);
languageOptions.push(
ts.factory.createPropertyAssignment(
'parser',
ts.factory.createIdentifier(parserName)
)
);
} }
if (config.parserOptions) { if (config.parserOptions) {
@ -129,7 +147,6 @@ export function convertEslintJsonToFlatConfig(
if (config.overrides) { if (config.overrides) {
config.overrides.forEach((override) => { config.overrides.forEach((override) => {
updateFiles(override, root);
if ( if (
override.env || override.env ||
override.extends || override.extends ||
@ -137,10 +154,8 @@ export function convertEslintJsonToFlatConfig(
override.parser override.parser
) { ) {
isFlatCompatNeeded = true; isFlatCompatNeeded = true;
addFlattenedOverride(override, exportElements);
} else {
exportElements.push(generateAst(override));
} }
exportElements.push(generateFlatOverride(override, root));
}); });
} }
@ -179,20 +194,8 @@ export function convertEslintJsonToFlatConfig(
exportElements, exportElements,
isFlatCompatNeeded isFlatCompatNeeded
); );
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); const content = stringifyNodeList(nodeList, root, destinationFile);
const resultFile = ts.createSourceFile( tree.write(join(root, destinationFile), content);
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) { if (isFlatCompatNeeded) {
addDependenciesToPackageJson( addDependenciesToPackageJson(
@ -205,35 +208,6 @@ export function convertEslintJsonToFlatConfig(
} }
} }
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 // add parsed extends to export blocks and add import statements
function addExtends( function addExtends(
importsMap: Map<string, string | string[]>, importsMap: Map<string, string | string[]>,
@ -254,9 +228,7 @@ function addExtends(
.forEach((imp, index) => { .forEach((imp, index) => {
if (imp.match(/\.eslintrc(.base)?\.json$/)) { if (imp.match(/\.eslintrc(.base)?\.json$/)) {
const localName = index ? `baseConfig${index}` : 'baseConfig'; const localName = index ? `baseConfig${index}` : 'baseConfig';
configBlocks.push( configBlocks.push(generateSpreadElement(localName));
ts.factory.createSpreadElement(ts.factory.createIdentifier(localName))
);
const newImport = imp.replace( const newImport = imp.replace(
/^(.*)\.eslintrc(.base)?\.json$/, /^(.*)\.eslintrc(.base)?\.json$/,
'$1eslint$2.config.js' '$1eslint$2.config.js'
@ -311,36 +283,12 @@ function addExtends(
} }
); );
const pluginExtendsSpread = ts.factory.createSpreadElement( configBlocks.push(generatePluginExtendsElement(eslintrcConfigs));
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; 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( function addPlugins(
importsMap: Map<string, string | string[]>, importsMap: Map<string, string | string[]>,
configBlocks: ts.Expression[], configBlocks: ts.Expression[],
@ -382,143 +330,3 @@ function addPlugins(
); );
configBlocks.push(pluginsAst); 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

@ -1,10 +1,19 @@
import { Linter as LinterType } from 'eslint'; import { Linter } from 'eslint';
import {
addBlockToFlatConfigExport,
addImportToFlatConfig,
addPluginsToExportsBlock,
createNodeList,
generateFlatOverride,
stringifyNodeList,
} from '../utils/flat-config/ast-utils';
import { addPluginsToLintConfig } from '../utils/eslint-file';
/** /**
* This configuration is intended to apply to all TypeScript source files. * This configuration is intended to apply to all TypeScript source files.
* See the eslint-plugin package for what is in the referenced shareable config. * See the eslint-plugin package for what is in the referenced shareable config.
*/ */
export const globalTypeScriptOverrides = { export const typeScriptOverride = {
files: ['*.ts', '*.tsx'], files: ['*.ts', '*.tsx'],
extends: ['plugin:@nx/typescript'], extends: ['plugin:@nx/typescript'],
/** /**
@ -18,7 +27,7 @@ export const globalTypeScriptOverrides = {
* This configuration is intended to apply to all JavaScript source files. * This configuration is intended to apply to all JavaScript source files.
* See the eslint-plugin package for what is in the referenced shareable config. * See the eslint-plugin package for what is in the referenced shareable config.
*/ */
export const globalJavaScriptOverrides = { export const javaScriptOverride = {
files: ['*.js', '*.jsx'], files: ['*.js', '*.jsx'],
extends: ['plugin:@nx/javascript'], extends: ['plugin:@nx/javascript'],
/** /**
@ -28,25 +37,11 @@ export const globalJavaScriptOverrides = {
rules: {}, rules: {},
}; };
/**
* This configuration is intended to apply to all JSON source files.
* See the eslint-plugin package for what is in the referenced shareable config.
*/
export const globalJsonOverrides = {
files: ['*.json'],
parser: 'jsonc-eslint-parser',
/**
* Having an empty rules object present makes it more obvious to the user where they would
* extend things from if they needed to
*/
rules: {},
};
/** /**
* This configuration is intended to apply to all "source code" (but not * This configuration is intended to apply to all "source code" (but not
* markup like HTML, or other custom file types like GraphQL) * markup like HTML, or other custom file types like GraphQL)
*/ */
export const moduleBoundariesOverride = { const moduleBoundariesOverride = {
files: ['*.ts', '*.tsx', '*.js', '*.jsx'], files: ['*.ts', '*.tsx', '*.js', '*.jsx'],
rules: { rules: {
'@nx/enforce-module-boundaries': [ '@nx/enforce-module-boundaries': [
@ -57,14 +52,26 @@ export const moduleBoundariesOverride = {
depConstraints: [{ sourceTag: '*', onlyDependOnLibsWithTags: ['*'] }], depConstraints: [{ sourceTag: '*', onlyDependOnLibsWithTags: ['*'] }],
}, },
], ],
} as LinterType.RulesRecord, } as Linter.RulesRecord,
};
/**
* This configuration is intended to apply to all "source code" (but not
* markup like HTML, or other custom file types like GraphQL)
*/
const jestOverride = {
files: ['*.spec.ts', '*.spec.tsx', '*.spec.js', '*.spec.jsx'],
env: {
jest: true,
},
rules: {},
}; };
export const getGlobalEsLintConfiguration = ( export const getGlobalEsLintConfiguration = (
unitTestRunner?: string, unitTestRunner?: string,
rootProject?: boolean rootProject?: boolean
) => { ): Linter.Config => {
const config: LinterType.Config = { const config: Linter.Config = {
root: true, root: true,
ignorePatterns: rootProject ? ['!**/*'] : ['**/*'], ignorePatterns: rootProject ? ['!**/*'] : ['**/*'],
plugins: ['@nx'], plugins: ['@nx'],
@ -77,18 +84,44 @@ export const getGlobalEsLintConfiguration = (
*/ */
overrides: [ overrides: [
...(rootProject ? [] : [moduleBoundariesOverride]), ...(rootProject ? [] : [moduleBoundariesOverride]),
globalTypeScriptOverrides, typeScriptOverride,
globalJavaScriptOverrides, javaScriptOverride,
...(unitTestRunner === 'jest' ? [jestOverride] : []),
], ],
}; };
if (unitTestRunner === 'jest') {
config.overrides.push({
files: ['*.spec.ts', '*.spec.tsx', '*.spec.js', '*.spec.jsx'],
env: {
jest: true,
},
rules: {},
});
}
return config; return config;
}; };
export const getGlobalFlatEslintConfiguration = (
unitTestRunner?: string,
rootProject?: boolean
): string => {
const nodeList = createNodeList(new Map(), [], true);
let content = stringifyNodeList(nodeList, '', 'eslint.config.js');
content = addImportToFlatConfig(content, 'nxPlugin', '@nx/eslint-plugin');
content = addPluginsToExportsBlock(content, [
{ name: '@nx', varName: 'nxPlugin', imp: '@nx/eslint-plugin' },
]);
if (!rootProject) {
content = addBlockToFlatConfigExport(
content,
generateFlatOverride(moduleBoundariesOverride, '')
);
}
content = addBlockToFlatConfigExport(
content,
generateFlatOverride(typeScriptOverride, '')
);
content = addBlockToFlatConfigExport(
content,
generateFlatOverride(javaScriptOverride, '')
);
if (unitTestRunner === 'jest') {
content = addBlockToFlatConfigExport(
content,
generateFlatOverride(jestOverride, '')
);
}
return content;
};

View File

@ -1,4 +1,5 @@
import { import {
addDependenciesToPackageJson,
joinPathFragments, joinPathFragments,
offsetFromRoot, offsetFromRoot,
ProjectConfiguration, ProjectConfiguration,
@ -8,19 +9,46 @@ import {
writeJson, writeJson,
} from '@nx/devkit'; } from '@nx/devkit';
import { dirname } from 'path'; import { dirname } from 'path';
import { findEslintFile } from '../utils/eslint-file'; import { findEslintFile, isEslintConfigSupported } from '../utils/eslint-file';
import { getGlobalEsLintConfiguration } from './global-eslint-config'; import {
getGlobalEsLintConfiguration,
getGlobalFlatEslintConfiguration,
} from './global-eslint-config';
import { useFlatConfig } from '../../utils/flat-config';
import { eslintrcVersion } from '../../utils/versions';
import {
addBlockToFlatConfigExport,
addImportToFlatConfig,
generateSpreadElement,
removeCompatExtends,
removePlugin,
} from '../utils/flat-config/ast-utils';
export function migrateConfigToMonorepoStyle( export function migrateConfigToMonorepoStyle(
projects: ProjectConfiguration[], projects: ProjectConfiguration[],
tree: Tree, tree: Tree,
unitTestRunner: string unitTestRunner: string
): void { ): void {
writeJson( if (useFlatConfig(tree)) {
tree, // we need this for the compat
'.eslintrc.base.json', addDependenciesToPackageJson(
getGlobalEsLintConfiguration(unitTestRunner) tree,
); {},
{
'@eslint/js': eslintrcVersion,
}
);
tree.write(
'eslint.base.config.js',
getGlobalFlatEslintConfiguration(unitTestRunner)
);
} else {
writeJson(
tree,
'.eslintrc.base.json',
getGlobalEsLintConfiguration(unitTestRunner)
);
}
// update extens in all projects' eslint configs // update extens in all projects' eslint configs
projects.forEach((project) => { projects.forEach((project) => {
@ -47,49 +75,72 @@ export function findLintTarget(
} }
function migrateEslintFile(projectEslintPath: string, tree: Tree) { function migrateEslintFile(projectEslintPath: string, tree: Tree) {
if ( if (isEslintConfigSupported(tree)) {
projectEslintPath.endsWith('.json') || if (useFlatConfig(tree)) {
projectEslintPath.endsWith('.eslintrc') let config = tree.read(projectEslintPath, 'utf-8');
) { // remove @nx plugin
updateJson(tree, projectEslintPath, (json) => { config = removePlugin(config, '@nx', '@nx/eslint-plugin-nx');
// we have a new root now // extend eslint.base.config.js
delete json.root; config = addImportToFlatConfig(
// remove nrwl/nx plugins config,
if (json.plugins) { 'baseConfig',
json.plugins = json.plugins.filter( `${offsetFromRoot(dirname(projectEslintPath))}eslint.base.config.js`
(p) => p !== '@nx' && p !== '@nrwl/nx' );
); config = addBlockToFlatConfigExport(
if (json.plugins.length === 0) { config,
delete json.plugins; generateSpreadElement('baseConfig'),
} { insertAtTheEnd: false }
} );
// add extends // cleanup file extends
json.extends = json.extends || []; config = removeCompatExtends(config, [
const pathToRootConfig = `${offsetFromRoot( 'plugin:@nx/typescript',
dirname(projectEslintPath) 'plugin:@nx/javascript',
)}.eslintrc.base.json`; 'plugin:@nrwl/typescript',
if (json.extends.indexOf(pathToRootConfig) === -1) { 'plugin:@nrwl/javascript',
json.extends.push(pathToRootConfig); ]);
} console.warn('Flat eslint config is not supported yet for migration');
// cleanup overrides tree.write(projectEslintPath, config);
if (json.overrides) { } else {
json.overrides.forEach((override) => { updateJson(tree, projectEslintPath, (json) => {
if (override.extends) { // we have a new root now
override.extends = override.extends.filter( delete json.root;
(ext) => // remove nrwl/nx plugins
ext !== 'plugin:@nx/typescript' && if (json.plugins) {
ext !== 'plugin:@nrwl/nx/typescript' && json.plugins = json.plugins.filter(
ext !== 'plugin:@nx/javascript' && (p) => p !== '@nx' && p !== '@nrwl/nx'
ext !== 'plugin:@nrwl/nx/javascript' );
); if (json.plugins.length === 0) {
if (override.extends.length === 0) { delete json.plugins;
delete override.extends;
}
} }
}); }
} // add extends
return json; json.extends = json.extends || [];
}); const pathToRootConfig = `${offsetFromRoot(
dirname(projectEslintPath)
)}.eslintrc.base.json`;
if (json.extends.indexOf(pathToRootConfig) === -1) {
json.extends.push(pathToRootConfig);
}
// cleanup overrides
if (json.overrides) {
json.overrides.forEach((override) => {
if (override.extends) {
override.extends = override.extends.filter(
(ext) =>
ext !== 'plugin:@nx/typescript' &&
ext !== 'plugin:@nrwl/nx/typescript' &&
ext !== 'plugin:@nx/javascript' &&
ext !== 'plugin:@nrwl/nx/javascript'
);
if (override.extends.length === 0) {
delete override.extends;
}
}
});
}
return json;
});
}
return; return;
} }
if ( if (
@ -99,6 +150,6 @@ function migrateEslintFile(projectEslintPath: string, tree: Tree) {
console.warn('YAML eslint config is not supported yet for migration'); console.warn('YAML eslint config is not supported yet for migration');
} }
if (projectEslintPath.endsWith('.js') || projectEslintPath.endsWith('.cjs')) { if (projectEslintPath.endsWith('.js') || projectEslintPath.endsWith('.cjs')) {
console.warn('YAML eslint config is not supported yet for migration'); console.warn('JS eslint config is not supported yet for migration');
} }
} }

View File

@ -7,19 +7,31 @@ import {
writeJson, writeJson,
} from '@nx/devkit'; } from '@nx/devkit';
import { Linter } from '../utils/linter'; import { Linter as LinterEnum } from '../utils/linter';
import { findEslintFile } from '../utils/eslint-file'; import {
baseEsLintConfigFile,
baseEsLintFlatConfigFile,
findEslintFile,
} from '../utils/eslint-file';
import { join } from 'path'; import { join } from 'path';
import { lintInitGenerator } from '../init/init'; import { lintInitGenerator } from '../init/init';
import type { Linter } from 'eslint';
import { import {
findLintTarget, findLintTarget,
migrateConfigToMonorepoStyle, migrateConfigToMonorepoStyle,
} from '../init/init-migration'; } from '../init/init-migration';
import { getProjects } from 'nx/src/generators/utils/project-configuration'; import { getProjects } from 'nx/src/generators/utils/project-configuration';
import { useFlatConfig } from '../../utils/flat-config';
import {
createNodeList,
generateFlatOverride,
generateSpreadElement,
stringifyNodeList,
} from '../utils/flat-config/ast-utils';
interface LintProjectOptions { interface LintProjectOptions {
project: string; project: string;
linter?: Linter; linter?: LinterEnum;
eslintFilePatterns?: string[]; eslintFilePatterns?: string[];
tsConfigPaths?: string[]; tsConfigPaths?: string[];
skipFormat: boolean; skipFormat: boolean;
@ -111,60 +123,85 @@ function createEsLintConfiguration(
setParserOptionsProject: boolean setParserOptionsProject: boolean
) { ) {
const eslintConfig = findEslintFile(tree); const eslintConfig = findEslintFile(tree);
writeJson(tree, join(projectConfig.root, `.eslintrc.json`), { const pathToRootConfig = eslintConfig
extends: eslintConfig ? `${offsetFromRoot(projectConfig.root)}${eslintConfig}`
? [`${offsetFromRoot(projectConfig.root)}${eslintConfig}`] : undefined;
: undefined, const addDependencyChecks = isBuildableLibraryProject(projectConfig);
// Include project files to be linted since the global one excludes all files.
ignorePatterns: ['!**/*'], const overrides: Linter.ConfigOverride<Linter.RulesRecord>[] = [
overrides: [ {
{ files: ['*.ts', '*.tsx', '*.js', '*.jsx'],
files: ['*.ts', '*.tsx', '*.js', '*.jsx'], /**
/** * NOTE: We no longer set parserOptions.project by default when creating new projects.
* NOTE: We no longer set parserOptions.project by default when creating new projects. *
* * We have observed that users rarely add rules requiring type-checking to their Nx workspaces, and therefore
* We have observed that users rarely add rules requiring type-checking to their Nx workspaces, and therefore * do not actually need the capabilites which parserOptions.project provides. When specifying parserOptions.project,
* do not actually need the capabilites which parserOptions.project provides. When specifying parserOptions.project, * typescript-eslint needs to create full TypeScript Programs for you. When omitting it, it can perform a simple
* typescript-eslint needs to create full TypeScript Programs for you. When omitting it, it can perform a simple * parse (and AST tranformation) of the source files it encounters during a lint run, which is much faster and much
* parse (and AST tranformation) of the source files it encounters during a lint run, which is much faster and much * less memory intensive.
* less memory intensive. *
* * In the rare case that users attempt to add rules requiring type-checking to their setup later on (and haven't set
* In the rare case that users attempt to add rules requiring type-checking to their setup later on (and haven't set * parserOptions.project), the executor will attempt to look for the particular error typescript-eslint gives you
* parserOptions.project), the executor will attempt to look for the particular error typescript-eslint gives you * and provide feedback to the user.
* and provide feedback to the user. */
*/ parserOptions: !setParserOptionsProject
parserOptions: !setParserOptionsProject ? undefined
? undefined : {
: { project: [`${projectConfig.root}/tsconfig.*?.json`],
project: [`${projectConfig.root}/tsconfig.*?.json`], },
}, /**
/** * Having an empty rules object present makes it more obvious to the user where they would
* Having an empty rules object present makes it more obvious to the user where they would * extend things from if they needed to
* extend things from if they needed to */
*/ rules: {},
rules: {}, },
}, {
{ files: ['*.ts', '*.tsx'],
files: ['*.ts', '*.tsx'], rules: {},
rules: {}, },
}, {
{ files: ['*.js', '*.jsx'],
files: ['*.js', '*.jsx'], rules: {},
rules: {}, },
}, ...(isBuildableLibraryProject(projectConfig)
...(isBuildableLibraryProject(projectConfig) ? [
? [ {
{ files: ['*.json'],
files: ['*.json'], parser: 'jsonc-eslint-parser',
parser: 'jsonc-eslint-parser', rules: {
rules: { '@nx/dependency-checks': 'error',
'@nx/dependency-checks': 'error', } as Linter.RulesRecord,
}, },
}, ]
] : []),
: []), ];
],
}); if (useFlatConfig(tree)) {
const isCompatNeeded = addDependencyChecks;
const nodes = [];
const importMap = new Map();
if (eslintConfig) {
importMap.set(pathToRootConfig, 'baseConfig');
nodes.push(generateSpreadElement('baseConfig'));
}
overrides.forEach((override) => {
nodes.push(generateFlatOverride(override, projectConfig.root));
});
const nodeList = createNodeList(importMap, nodes, isCompatNeeded);
const content = stringifyNodeList(
nodeList,
projectConfig.root,
'eslint.config.js'
);
tree.write(join(projectConfig.root, 'eslint.config.js'), content);
} else {
writeJson(tree, join(projectConfig.root, `.eslintrc.json`), {
extends: eslintConfig ? [pathToRootConfig] : undefined,
// Include project files to be linted since the global one excludes all files.
ignorePatterns: ['!**/*'],
overrides,
});
}
} }
function isBuildableLibraryProject( function isBuildableLibraryProject(
@ -186,7 +223,10 @@ function isMigrationToMonorepoNeeded(
tree: Tree tree: Tree
): boolean { ): boolean {
// the base config is already created, migration has been done // the base config is already created, migration has been done
if (tree.exists('.eslintrc.base.json')) { if (
tree.exists(baseEsLintConfigFile) ||
tree.exists(baseEsLintFlatConfigFile)
) {
return false; return false;
} }

View File

@ -1,4 +1,27 @@
import { joinPathFragments, Tree } from '@nx/devkit'; import {
joinPathFragments,
names,
offsetFromRoot,
readJson,
Tree,
updateJson,
} from '@nx/devkit';
import { Linter } from 'eslint';
import { useFlatConfig } from '../../utils/flat-config';
import {
addBlockToFlatConfigExport,
addCompatToFlatConfig,
addImportToFlatConfig,
addPluginsToExportsBlock,
generateAst,
generateFlatOverride,
generatePluginExtendsElement,
hasOverride,
mapFilePath,
removeOverridesFromLintConfig,
replaceOverride,
} from './flat-config/ast-utils';
import ts = require('typescript');
export const eslintConfigFileWhitelist = [ export const eslintConfigFileWhitelist = [
'.eslintrc', '.eslintrc',
@ -7,15 +30,19 @@ export const eslintConfigFileWhitelist = [
'.eslintrc.yaml', '.eslintrc.yaml',
'.eslintrc.yml', '.eslintrc.yml',
'.eslintrc.json', '.eslintrc.json',
'eslint.config.js', // new format that requires `ESLINT_USE_FLAT_CONFIG=true` 'eslint.config.js',
]; ];
export const baseEsLintConfigFile = '.eslintrc.base.json'; export const baseEsLintConfigFile = '.eslintrc.base.json';
export const baseEsLintFlatConfigFile = 'eslint.base.config.js';
export function findEslintFile(tree: Tree, projectRoot = ''): string | null { export function findEslintFile(tree: Tree, projectRoot = ''): string | null {
if (projectRoot === '' && tree.exists(baseEsLintConfigFile)) { if (projectRoot === '' && tree.exists(baseEsLintConfigFile)) {
return baseEsLintConfigFile; return baseEsLintConfigFile;
} }
if (projectRoot === '' && tree.exists(baseEsLintFlatConfigFile)) {
return baseEsLintFlatConfigFile;
}
for (const file of eslintConfigFileWhitelist) { for (const file of eslintConfigFileWhitelist) {
if (tree.exists(joinPathFragments(projectRoot, file))) { if (tree.exists(joinPathFragments(projectRoot, file))) {
return file; return file;
@ -24,3 +51,322 @@ export function findEslintFile(tree: Tree, projectRoot = ''): string | null {
return null; return null;
} }
export function isEslintConfigSupported(tree: Tree, projectRoot = ''): boolean {
const eslintFile = findEslintFile(tree, projectRoot);
if (!eslintFile) {
return;
}
return eslintFile.endsWith('.json') || eslintFile.endsWith('.config.js');
}
export function updateRelativePathsInConfig(
tree: Tree,
sourcePath: string,
destinationPath: string
) {
if (
sourcePath === destinationPath ||
!isEslintConfigSupported(tree, destinationPath)
) {
return;
}
const configPath = joinPathFragments(
destinationPath,
findEslintFile(tree, destinationPath)
);
const offset = offsetFromRoot(destinationPath);
if (useFlatConfig(tree)) {
const config = tree.read(configPath, 'utf-8');
tree.write(
configPath,
replaceFlatConfigPaths(config, sourcePath, offset, destinationPath)
);
} else {
updateJson(tree, configPath, (json) => {
if (typeof json.extends === 'string') {
json.extends = offsetFilePath(sourcePath, json.extends, offset);
} else if (json.extends) {
json.extends = json.extends.map((extend: string) =>
offsetFilePath(sourcePath, extend, offset)
);
}
json.overrides?.forEach(
(o: { parserOptions?: { project?: string | string[] } }) => {
if (o.parserOptions?.project) {
o.parserOptions.project = Array.isArray(o.parserOptions.project)
? o.parserOptions.project.map((p) =>
p.replace(sourcePath, destinationPath)
)
: o.parserOptions.project.replace(sourcePath, destinationPath);
}
}
);
return json;
});
}
}
function replaceFlatConfigPaths(
config: string,
sourceRoot: string,
offset: string,
destinationRoot: string
): string {
let match;
let newConfig = config;
// replace requires
const requireRegex = RegExp(/require\(['"](.*)['"]\)/g);
while ((match = requireRegex.exec(newConfig)) !== null) {
const newPath = offsetFilePath(sourceRoot, match[1], offset);
newConfig =
newConfig.slice(0, match.index) +
`require('${newPath}')` +
newConfig.slice(match.index + match[0].length);
}
// replace projects
const projectRegex = RegExp(/project:\s?\[?['"](.*)['"]\]?/g);
while ((match = projectRegex.exec(newConfig)) !== null) {
const newProjectDef = match[0].replaceAll(sourceRoot, destinationRoot);
newConfig =
newConfig.slice(0, match.index) +
newProjectDef +
newConfig.slice(match.index + match[0].length);
}
return newConfig;
}
function offsetFilePath(
projectRoot: string,
pathToFile: string,
offset: string
): string {
if (!pathToFile.startsWith('..')) {
// not a relative path
return pathToFile;
}
return joinPathFragments(offset, projectRoot, pathToFile);
}
export function addOverrideToLintConfig(
tree: Tree,
root: string,
override: Linter.ConfigOverride<Linter.RulesRecord>,
options: { insertAtTheEnd?: boolean; checkBaseConfig?: boolean } = {
insertAtTheEnd: true,
}
) {
const isBase =
options.checkBaseConfig && findEslintFile(tree, root).includes('.base');
if (useFlatConfig(tree)) {
const fileName = joinPathFragments(
root,
isBase ? baseEsLintFlatConfigFile : 'eslint.config.js'
);
const flatOverride = generateFlatOverride(override, root);
let content = tree.read(fileName, 'utf8');
// we will be using compat here so we need to make sure it's added
if (overrideNeedsCompat(override)) {
content = addCompatToFlatConfig(content);
}
tree.write(
fileName,
addBlockToFlatConfigExport(content, flatOverride, options)
);
} else {
const fileName = joinPathFragments(
root,
isBase ? baseEsLintConfigFile : '.eslintrc.json'
);
updateJson(tree, fileName, (json) => {
json.overrides ?? [];
if (options.insertAtTheEnd) {
json.overrides.push(override);
} else {
json.overrides.unshift(override);
}
return json;
});
}
}
function overrideNeedsCompat(
override: Linter.ConfigOverride<Linter.RulesRecord>
) {
return (
!override.env && !override.extends && !override.plugins && !override.parser
);
}
export function updateOverrideInLintConfig(
tree: Tree,
root: string,
lookup: (override: Linter.ConfigOverride<Linter.RulesRecord>) => boolean,
update: (
override: Linter.ConfigOverride<Linter.RulesRecord>
) => Linter.ConfigOverride<Linter.RulesRecord>
) {
if (useFlatConfig(tree)) {
const fileName = joinPathFragments(root, 'eslint.config.js');
let content = tree.read(fileName, 'utf8');
content = replaceOverride(content, root, lookup, update);
tree.write(fileName, content);
} else {
const fileName = joinPathFragments(root, '.eslintrc.json');
updateJson(tree, fileName, (json: Linter.Config) => {
const index = json.overrides.findIndex(lookup);
if (index !== -1) {
json.overrides[index] = update(json.overrides[index]);
}
return json;
});
}
}
export function lintConfigHasOverride(
tree: Tree,
root: string,
lookup: (override: Linter.ConfigOverride<Linter.RulesRecord>) => boolean,
checkBaseConfig = false
): boolean {
const isBase =
checkBaseConfig && findEslintFile(tree, root).includes('.base');
if (useFlatConfig(tree)) {
const fileName = joinPathFragments(
root,
isBase ? baseEsLintFlatConfigFile : 'eslint.config.js'
);
const content = tree.read(fileName, 'utf8');
return hasOverride(content, lookup);
} else {
const fileName = joinPathFragments(
root,
isBase ? baseEsLintConfigFile : '.eslintrc.json'
);
return readJson(tree, fileName).overrides?.some(lookup) || false;
}
}
export function replaceOverridesInLintConfig(
tree: Tree,
root: string,
overrides: Linter.ConfigOverride<Linter.RulesRecord>[]
) {
if (useFlatConfig(tree)) {
const fileName = joinPathFragments(root, 'eslint.config.js');
let content = tree.read(fileName, 'utf8');
// we will be using compat here so we need to make sure it's added
if (overrides.some(overrideNeedsCompat)) {
content = addCompatToFlatConfig(content);
}
content = removeOverridesFromLintConfig(content);
overrides.forEach((override) => {
const flatOverride = generateFlatOverride(override, root);
addBlockToFlatConfigExport(content, flatOverride);
});
tree.write(fileName, content);
} else {
const fileName = joinPathFragments(root, '.eslintrc.json');
updateJson(tree, fileName, (json) => {
json.overrides = overrides;
return json;
});
}
}
export function addExtendsToLintConfig(
tree: Tree,
root: string,
plugin: string | string[]
) {
const plugins = Array.isArray(plugin) ? plugin : [plugin];
if (useFlatConfig(tree)) {
const fileName = joinPathFragments(root, 'eslint.config.js');
const pluginExtends = generatePluginExtendsElement(plugins);
tree.write(
fileName,
addBlockToFlatConfigExport(tree.read(fileName, 'utf8'), pluginExtends)
);
} else {
const fileName = joinPathFragments(root, '.eslintrc.json');
updateJson(tree, fileName, (json) => {
json.extends = [...plugins, ...(json.extends ?? [])];
return json;
});
}
}
export function addPluginsToLintConfig(
tree: Tree,
root: string,
plugin: string | string[]
) {
const plugins = Array.isArray(plugin) ? plugin : [plugin];
if (useFlatConfig(tree)) {
const fileName = joinPathFragments(root, 'eslint.config.js');
let content = tree.read(fileName, 'utf8');
const mappedPlugins: { name: string; varName: string; imp: string }[] = [];
plugins.forEach((name) => {
const imp = getPluginImport(name);
const varName = names(imp).propertyName;
mappedPlugins.push({ name, varName, imp });
});
mappedPlugins.forEach(({ varName, imp }) => {
content = addImportToFlatConfig(content, varName, imp);
});
content = addPluginsToExportsBlock(content, mappedPlugins);
tree.write(fileName, content);
} else {
const fileName = joinPathFragments(root, '.eslintrc.json');
updateJson(tree, fileName, (json) => {
json.plugins = [...plugins, ...(json.plugins ?? [])];
return json;
});
}
}
export function addIgnoresToLintConfig(
tree: Tree,
root: string,
ignorePatterns: string[]
) {
if (useFlatConfig(tree)) {
const fileName = joinPathFragments(root, 'eslint.config.js');
const block = generateAst<ts.ObjectLiteralExpression>({
ignores: ignorePatterns.map((path) => mapFilePath(path, root)),
});
tree.write(
fileName,
addBlockToFlatConfigExport(tree.read(fileName, 'utf8'), block)
);
} else {
const fileName = joinPathFragments(root, '.eslintrc.json');
updateJson(tree, fileName, (json) => {
const ignoreSet = new Set([
...(json.ignorePatterns ?? []),
...ignorePatterns,
]);
json.ignorePatterns = Array.from(ignoreSet);
return json;
});
}
}
export 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}`;
}

View File

@ -0,0 +1,836 @@
import ts = require('typescript');
import {
addBlockToFlatConfigExport,
generateAst,
addImportToFlatConfig,
addCompatToFlatConfig,
removeOverridesFromLintConfig,
replaceOverride,
removePlugin,
removeCompatExtends,
} from './ast-utils';
describe('ast-utils', () => {
describe('addBlockToFlatConfigExport', () => {
it('should inject block to the end of the file', () => {
const content = `const baseConfig = require("../../eslint.config.js");
module.exports = [
...baseConfig,
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
{ ignores: ["my-lib/.cache/**/*"] },
];`;
const result = addBlockToFlatConfigExport(
content,
generateAst({
files: ['**/*.svg'],
rules: {
'@nx/do-something-with-svg': 'error',
},
})
);
expect(result).toMatchInlineSnapshot(`
"const baseConfig = require("../../eslint.config.js");
module.exports = [
...baseConfig,
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
{ ignores: ["my-lib/.cache/**/*"] },
{
files: ["**/*.svg"],
rules: { "@nx/do-something-with-svg": "error" }
},
];"
`);
});
it('should inject spread to the beginning of the file', () => {
const content = `const baseConfig = require("../../eslint.config.js");
module.exports = [
...baseConfig,
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
{ ignores: ["my-lib/.cache/**/*"] },
];`;
const result = addBlockToFlatConfigExport(
content,
ts.factory.createSpreadElement(ts.factory.createIdentifier('config')),
{ insertAtTheEnd: false }
);
expect(result).toMatchInlineSnapshot(`
"const baseConfig = require("../../eslint.config.js");
module.exports = [
...config,
...baseConfig,
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
{ ignores: ["my-lib/.cache/**/*"] },
];"
`);
});
});
describe('addImportToFlatConfig', () => {
it('should inject import if not found', () => {
const content = `const baseConfig = require("../../eslint.config.js");
module.exports = [
...baseConfig,
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
{ ignores: ["my-lib/.cache/**/*"] },
];`;
const result = addImportToFlatConfig(
content,
'varName',
'@myorg/awesome-config'
);
expect(result).toMatchInlineSnapshot(`
"const varName = require("@myorg/awesome-config");
const baseConfig = require("../../eslint.config.js");
module.exports = [
...baseConfig,
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
{ ignores: ["my-lib/.cache/**/*"] },
];"
`);
});
it('should update import if already found', () => {
const content = `const { varName } = require("@myorg/awesome-config");
const baseConfig = require("../../eslint.config.js");
module.exports = [
...baseConfig,
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
{ ignores: ["my-lib/.cache/**/*"] },
];`;
const result = addImportToFlatConfig(
content,
['otherName', 'someName'],
'@myorg/awesome-config'
);
expect(result).toMatchInlineSnapshot(`
"const { varName, otherName, someName } = require("@myorg/awesome-config");
const baseConfig = require("../../eslint.config.js");
module.exports = [
...baseConfig,
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
{ ignores: ["my-lib/.cache/**/*"] },
];"
`);
});
it('should not inject import if already exists', () => {
const content = `const { varName, otherName } = require("@myorg/awesome-config");
const baseConfig = require("../../eslint.config.js");
module.exports = [
...baseConfig,
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
{ ignores: ["my-lib/.cache/**/*"] },
];`;
const result = addImportToFlatConfig(
content,
['otherName'],
'@myorg/awesome-config'
);
expect(result).toEqual(content);
});
it('should not update import if already exists', () => {
const content = `const varName = require("@myorg/awesome-config");
const baseConfig = require("../../eslint.config.js");
module.exports = [
...baseConfig,
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
{ ignores: ["my-lib/.cache/**/*"] },
];`;
const result = addImportToFlatConfig(
content,
'varName',
'@myorg/awesome-config'
);
expect(result).toEqual(content);
});
});
describe('addCompatToFlatConfig', () => {
it('should add compat to config', () => {
const content = `const baseConfig = require("../../eslint.config.js");
module.exports = [
...baseConfig,
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
{ ignores: ["my-lib/.cache/**/*"] },
];`;
const result = addCompatToFlatConfig(content);
expect(result).toMatchInlineSnapshot(`
"const FlatCompat = require("@eslint/eslintrc");
const js = require("@eslint/js");
const baseConfig = require("../../eslint.config.js");
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
module.exports = [
...baseConfig,
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
{ ignores: ["my-lib/.cache/**/*"] },
];"
`);
});
it('should add only partially compat to config if parts exist', () => {
const content = `const baseConfig = require("../../eslint.config.js");
const js = require("@eslint/js");
module.exports = [
...baseConfig,
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
{ ignores: ["my-lib/.cache/**/*"] },
];`;
const result = addCompatToFlatConfig(content);
expect(result).toMatchInlineSnapshot(`
"const FlatCompat = require("@eslint/eslintrc");
const baseConfig = require("../../eslint.config.js");
const js = require("@eslint/js");
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
module.exports = [
...baseConfig,
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
{ ignores: ["my-lib/.cache/**/*"] },
];"
`);
});
it('should not add compat to config if exist', () => {
const content = `const FlatCompat = require("@eslint/eslintrc");
const baseConfig = require("../../eslint.config.js");
const js = require("@eslint/js");
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
module.exports = [
...baseConfig,
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
{ ignores: ["my-lib/.cache/**/*"] },
];`;
const result = addCompatToFlatConfig(content);
expect(result).toEqual(content);
});
});
describe('removeOverridesFromLintConfig', () => {
it('should remove all rules from config', () => {
const content = `const FlatCompat = require("@eslint/eslintrc");
const baseConfig = require("../../eslint.config.js");
const js = require("@eslint/js");
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
module.exports = [
...baseConfig,
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
...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: ["my-lib/.cache/**/*"] },
];`;
const result = removeOverridesFromLintConfig(content);
expect(result).toMatchInlineSnapshot(`
"const FlatCompat = require("@eslint/eslintrc");
const baseConfig = require("../../eslint.config.js");
const js = require("@eslint/js");
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
module.exports = [
...baseConfig,
{ ignores: ["my-lib/.cache/**/*"] },
];"
`);
});
it('should remove all rules from starting with first', () => {
const content = `const baseConfig = require("../../eslint.config.js");
module.exports = [
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {}
},
...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: {}
}))
];`;
const result = removeOverridesFromLintConfig(content);
expect(result).toMatchInlineSnapshot(`
"const baseConfig = require("../../eslint.config.js");
module.exports = [
];"
`);
});
});
describe('replaceOverride', () => {
it('should find and replace rules in override', () => {
const content = `const baseConfig = require("../../eslint.config.js");
module.exports = [
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {
'my-ts-rule': 'error'
}
},
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.js"
],
rules: {}
},
{
files: [
"my-lib/**/*.js",
"my-lib/**/*.jsx"
],
rules: {
'my-js-rule': 'error'
}
},
];`;
const result = replaceOverride(
content,
'my-lib',
(o) => o.files.includes('my-lib/**/*.ts'),
(o) => ({
...o,
rules: {
'my-rule': 'error',
},
})
);
expect(result).toMatchInlineSnapshot(`
"const baseConfig = require("../../eslint.config.js");
module.exports = [
{
"files": [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
"rules": {
"my-rule": "error"
}
},
{
"files": [
"my-lib/**/*.ts",
"my-lib/**/*.js"
],
"rules": {
"my-rule": "error"
}
},
{
files: [
"my-lib/**/*.js",
"my-lib/**/*.jsx"
],
rules: {
'my-js-rule': 'error'
}
},
];"
`);
});
it('should append rules in override', () => {
const content = `const baseConfig = require("../../eslint.config.js");
module.exports = [
{
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {
'my-ts-rule': 'error'
}
},
{
files: [
"my-lib/**/*.js",
"my-lib/**/*.jsx"
],
rules: {
'my-js-rule': 'error'
}
},
];`;
const result = replaceOverride(
content,
'my-lib',
(o) => o.files.includes('my-lib/**/*.ts'),
(o) => ({
...o,
rules: {
...o.rules,
'my-new-rule': 'error',
},
})
);
expect(result).toMatchInlineSnapshot(`
"const baseConfig = require("../../eslint.config.js");
module.exports = [
{
"files": [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
"rules": {
"my-ts-rule": "error",
"my-new-rule": "error"
}
},
{
files: [
"my-lib/**/*.js",
"my-lib/**/*.jsx"
],
rules: {
'my-js-rule': 'error'
}
},
];"
`);
});
it('should work for compat overrides', () => {
const content = `const baseConfig = require("../../eslint.config.js");
module.exports = [
...compat.config({ extends: ["plugin:@nx/typescript"] }).map(config => ({
...config,
files: [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
rules: {
'my-ts-rule': 'error'
}
}),
];`;
const result = replaceOverride(
content,
'my-lib',
(o) => o.files.includes('my-lib/**/*.ts'),
(o) => ({
...o,
rules: {
...o.rules,
'my-new-rule': 'error',
},
})
);
expect(result).toMatchInlineSnapshot(`
"const baseConfig = require("../../eslint.config.js");
module.exports = [
...compat.config({ extends: ["plugin:@nx/typescript"] }).map(config => ({
...config,
"files": [
"my-lib/**/*.ts",
"my-lib/**/*.tsx"
],
"rules": {
"my-ts-rule": "error",
"my-new-rule": "error"
}
}),
];"
`);
});
});
describe('removePlugin', () => {
it('should remove plugins from config', () => {
const content = `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 } },
{ ignores: ["src/ignore/to/keep.ts"] },
{ ignores: ["something/else"] }
];`;
const result = removePlugin(content, '@nx', '@nx/eslint-plugin');
expect(result).toMatchInlineSnapshot(`
"const { FlatCompat } = require("@eslint/eslintrc");
const js = require("@eslint/js");
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
module.exports = [
{ ignores: ["src/ignore/to/keep.ts"] },
{ ignores: ["something/else"] }
];"
`);
});
it('should remove single plugin from config', () => {
const content = `const { FlatCompat } = require("@eslint/eslintrc");
const nxEslintPlugin = require("@nx/eslint-plugin");
const otherPlugin = require("other/eslint-plugin");
const js = require("@eslint/js");
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
module.exports = [
{ plugins: { "@nx": nxEslintPlugin, "@other": otherPlugin } },
{ ignores: ["src/ignore/to/keep.ts"] },
{ ignores: ["something/else"] }
];`;
const result = removePlugin(content, '@nx', '@nx/eslint-plugin');
expect(result).toMatchInlineSnapshot(`
"const { FlatCompat } = require("@eslint/eslintrc");
const otherPlugin = require("other/eslint-plugin");
const js = require("@eslint/js");
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
module.exports = [
{ plugins: { "@other": otherPlugin } },
{ ignores: ["src/ignore/to/keep.ts"] },
{ ignores: ["something/else"] }
];"
`);
});
it('should leave other properties in config', () => {
const content = `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 }, rules: {} },
{ ignores: ["src/ignore/to/keep.ts"] },
{ ignores: ["something/else"] }
];`;
const result = removePlugin(content, '@nx', '@nx/eslint-plugin');
expect(result).toMatchInlineSnapshot(`
"const { FlatCompat } = require("@eslint/eslintrc");
const js = require("@eslint/js");
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
module.exports = [
{ rules: {} },
{ ignores: ["src/ignore/to/keep.ts"] },
{ ignores: ["something/else"] }
];"
`);
});
it('should remove single plugin from config array', () => {
const content = `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", "something-else"] },
{ ignores: ["src/ignore/to/keep.ts"] },
{ ignores: ["something/else"] }
];`;
const result = removePlugin(content, '@nx', '@nx/eslint-plugin');
expect(result).toMatchInlineSnapshot(`
"const { FlatCompat } = require("@eslint/eslintrc");
const js = require("@eslint/js");
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
module.exports = [
{ plugins:["something-else"] },
{ ignores: ["src/ignore/to/keep.ts"] },
{ ignores: ["something/else"] }
];"
`);
});
it('should leave other fields in the object', () => {
const content = `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"], rules: { } },
{ ignores: ["src/ignore/to/keep.ts"] },
{ ignores: ["something/else"] }
];`;
const result = removePlugin(content, '@nx', '@nx/eslint-plugin');
expect(result).toMatchInlineSnapshot(`
"const { FlatCompat } = require("@eslint/eslintrc");
const js = require("@eslint/js");
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
module.exports = [
{ rules: { } },
{ ignores: ["src/ignore/to/keep.ts"] },
{ ignores: ["something/else"] }
];"
`);
});
it('should remove entire plugin when array with single element', () => {
const content = `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"] },
{ ignores: ["src/ignore/to/keep.ts"] },
{ ignores: ["something/else"] }
];`;
const result = removePlugin(content, '@nx', '@nx/eslint-plugin');
expect(result).toMatchInlineSnapshot(`
"const { FlatCompat } = require("@eslint/eslintrc");
const js = require("@eslint/js");
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
module.exports = [
{ ignores: ["src/ignore/to/keep.ts"] },
{ ignores: ["something/else"] }
];"
`);
});
});
describe('removeCompatExtends', () => {
it('should remove compat extends from config', () => {
const content = `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 } },
...compat.config({ extends: ["plugin:@nx/typescript"] }).map(config => ({
...config,
files: ['*.ts', '*.tsx', '*.js', '*.jsx'],
rules: {}
})),
{ ignores: ["src/ignore/to/keep.ts"] },
...compat.config({ extends: ["plugin:@nrwl/javascript"] }).map(config => ({
files: ['*.js', '*.jsx'],
...config,
rules: {}
}))
];`;
const result = removeCompatExtends(content, [
'plugin:@nx/typescript',
'plugin:@nx/javascript',
'plugin:@nrwl/typescript',
'plugin:@nrwl/javascript',
]);
expect(result).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: {}
},
{ ignores: ["src/ignore/to/keep.ts"] },
{
files: ['*.js', '*.jsx'],
rules: {}
}
];"
`);
});
});
});

View File

@ -0,0 +1,886 @@
import {
ChangeType,
StringChange,
applyChangesToString,
joinPathFragments,
} from '@nx/devkit';
import { Linter } from 'eslint';
import * as ts from 'typescript';
/**
* Remove all overrides from the config file
*/
export function removeOverridesFromLintConfig(content: string): string {
const source = ts.createSourceFile(
'',
content,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const exportsArray = findAllBlocks(source);
if (!exportsArray) {
return content;
}
const changes: StringChange[] = [];
exportsArray.forEach((node, i) => {
if (isOverride(node)) {
const commaOffset =
i < exportsArray.length - 1 || exportsArray.hasTrailingComma ? 1 : 0;
changes.push({
type: ChangeType.Delete,
start: node.pos,
length: node.end - node.pos + commaOffset,
});
}
});
return applyChangesToString(content, changes);
}
function findAllBlocks(source: ts.SourceFile): ts.NodeArray<ts.Node> {
return ts.forEachChild(source, function analyze(node) {
if (
ts.isExpressionStatement(node) &&
ts.isBinaryExpression(node.expression) &&
node.expression.left.getText() === 'module.exports' &&
ts.isArrayLiteralExpression(node.expression.right)
) {
return node.expression.right.elements;
}
});
}
function isOverride(node: ts.Node): boolean {
return (
(ts.isObjectLiteralExpression(node) &&
node.properties.some((p) => p.name.getText() === 'files')) ||
// detect ...compat.config(...).map(...)
(ts.isSpreadElement(node) &&
ts.isCallExpression(node.expression) &&
ts.isPropertyAccessExpression(node.expression.expression) &&
ts.isArrowFunction(node.expression.arguments[0]) &&
ts.isParenthesizedExpression(node.expression.arguments[0].body))
);
}
export function hasOverride(
content: string,
lookup: (override: Linter.ConfigOverride<Linter.RulesRecord>) => boolean
): boolean {
const source = ts.createSourceFile(
'',
content,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const exportsArray = findAllBlocks(source);
if (!exportsArray) {
return false;
}
for (const node of exportsArray) {
if (isOverride(node)) {
let objSource;
if (ts.isObjectLiteralExpression(node)) {
objSource = node.getFullText();
} else {
const fullNodeText =
node['expression'].arguments[0].body.expression.getFullText();
// strip any spread elements
objSource = fullNodeText.replace(/\s*\.\.\.[a-zA-Z0-9_]+,?\n?/, '');
}
const data = JSON.parse(
objSource
// ensure property names have double quotes so that JSON.parse works
.replace(/'/g, '"')
.replace(/\s([a-zA-Z0-9_]+)\s*:/g, ' "$1": ')
);
if (lookup(data)) {
return true;
}
}
}
return false;
}
const STRIP_SPREAD_ELEMENTS = /\s*\.\.\.[a-zA-Z0-9_]+,?\n?/g;
function parseTextToJson(text: string): any {
return JSON.parse(
text
// ensure property names have double quotes so that JSON.parse works
.replace(/'/g, '"')
.replace(/\s([a-zA-Z0-9_]+)\s*:/g, ' "$1": ')
);
}
/**
* Finds an override matching the lookup function and applies the update function to it
*/
export function replaceOverride(
content: string,
root: string,
lookup: (override: Linter.ConfigOverride<Linter.RulesRecord>) => boolean,
update: (
override: Linter.ConfigOverride<Linter.RulesRecord>
) => Linter.ConfigOverride<Linter.RulesRecord>
): string {
const source = ts.createSourceFile(
'',
content,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const exportsArray = findAllBlocks(source);
if (!exportsArray) {
return content;
}
const changes: StringChange[] = [];
exportsArray.forEach((node) => {
if (isOverride(node)) {
let objSource;
let start, end;
if (ts.isObjectLiteralExpression(node)) {
objSource = node.getFullText();
start = node.properties.pos + 1; // keep leading line break
end = node.properties.end;
} else {
const fullNodeText =
node['expression'].arguments[0].body.expression.getFullText();
// strip any spread elements
objSource = fullNodeText.replace(STRIP_SPREAD_ELEMENTS, '');
start =
node['expression'].arguments[0].body.expression.properties.pos +
(fullNodeText.length - objSource.length);
end = node['expression'].arguments[0].body.expression.properties.end;
}
const data = parseTextToJson(objSource);
if (lookup(data)) {
changes.push({
type: ChangeType.Delete,
start,
length: end - start,
});
const updatedData = update(data);
mapFilePaths(updatedData, root);
changes.push({
type: ChangeType.Insert,
index: start,
text: JSON.stringify(updatedData, null, 2).slice(2, -2), // remove curly braces and start/end line breaks since we are injecting just properties
});
}
}
});
return applyChangesToString(content, changes);
}
/**
* Adding require statement to the top of the file
*/
export function addImportToFlatConfig(
content: string,
variable: string | string[],
imp: string
): string {
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const source = ts.createSourceFile(
'',
content,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const foundBindingVars: ts.NodeArray<ts.BindingElement> = ts.forEachChild(
source,
function analyze(node) {
// we can only combine object binding patterns
if (!Array.isArray(variable)) {
return;
}
if (
ts.isVariableStatement(node) &&
ts.isVariableDeclaration(node.declarationList.declarations[0]) &&
ts.isObjectBindingPattern(node.declarationList.declarations[0].name) &&
ts.isCallExpression(node.declarationList.declarations[0].initializer) &&
node.declarationList.declarations[0].initializer.expression.getText() ===
'require' &&
ts.isStringLiteral(
node.declarationList.declarations[0].initializer.arguments[0]
) &&
node.declarationList.declarations[0].initializer.arguments[0].text ===
imp
) {
return node.declarationList.declarations[0].name.elements;
}
}
);
if (foundBindingVars && Array.isArray(variable)) {
const newVariables = variable.filter(
(v) => !foundBindingVars.some((fv) => v === fv.name.getText())
);
if (newVariables.length === 0) {
return content;
}
const isMultiLine = foundBindingVars.hasTrailingComma;
const pos = foundBindingVars.end;
const nodes = ts.factory.createNodeArray(
newVariables.map((v) =>
ts.factory.createBindingElement(undefined, undefined, v)
)
);
const insert = printer.printList(
ts.ListFormat.ObjectBindingPatternElements,
nodes,
source
);
return applyChangesToString(content, [
{
type: ChangeType.Insert,
index: pos,
text: isMultiLine ? `,\n${insert}` : `,${insert}`,
},
]);
}
const hasSameIdentifierVar: boolean = ts.forEachChild(
source,
function analyze(node) {
// we are searching for a single variable
if (Array.isArray(variable)) {
return;
}
if (
ts.isVariableStatement(node) &&
ts.isVariableDeclaration(node.declarationList.declarations[0]) &&
ts.isIdentifier(node.declarationList.declarations[0].name) &&
node.declarationList.declarations[0].name.getText() === variable &&
ts.isCallExpression(node.declarationList.declarations[0].initializer) &&
node.declarationList.declarations[0].initializer.expression.getText() ===
'require' &&
ts.isStringLiteral(
node.declarationList.declarations[0].initializer.arguments[0]
) &&
node.declarationList.declarations[0].initializer.arguments[0].text ===
imp
) {
return true;
}
}
);
if (hasSameIdentifierVar) {
return content;
}
// the import was not found, create a new one
const requireStatement = generateRequire(
typeof variable === 'string'
? variable
: ts.factory.createObjectBindingPattern(
variable.map((v) =>
ts.factory.createBindingElement(undefined, undefined, v)
)
),
imp
);
const insert = printer.printNode(
ts.EmitHint.Unspecified,
requireStatement,
source
);
return applyChangesToString(content, [
{
type: ChangeType.Insert,
index: 0,
text: `${insert}\n`,
},
]);
}
/**
* Injects new ts.expression to the end of the module.exports array.
*/
export function addBlockToFlatConfigExport(
content: string,
config: ts.Expression | ts.SpreadElement,
options: { insertAtTheEnd?: boolean; checkBaseConfig?: boolean } = {
insertAtTheEnd: true,
}
): string {
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const source = ts.createSourceFile(
'',
content,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const exportsArray = ts.forEachChild(source, function analyze(node) {
if (
ts.isExpressionStatement(node) &&
ts.isBinaryExpression(node.expression) &&
node.expression.left.getText() === 'module.exports' &&
ts.isArrayLiteralExpression(node.expression.right)
) {
return node.expression.right.elements;
}
});
const insert = printer.printNode(ts.EmitHint.Expression, config, source);
if (options.insertAtTheEnd) {
return applyChangesToString(content, [
{
type: ChangeType.Insert,
index: exportsArray[exportsArray.length - 1].end,
text: `,\n${insert}`,
},
]);
} else {
return applyChangesToString(content, [
{
type: ChangeType.Insert,
index: exportsArray[0].pos,
text: `\n${insert},`,
},
]);
}
}
export function removePlugin(
content: string,
pluginName: string,
pluginImport: string
) {
const source = ts.createSourceFile(
'',
content,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const changes: StringChange[] = [];
ts.forEachChild(source, function analyze(node) {
if (
ts.isVariableStatement(node) &&
ts.isVariableDeclaration(node.declarationList.declarations[0]) &&
ts.isCallExpression(node.declarationList.declarations[0].initializer) &&
node.declarationList.declarations[0].initializer.arguments.length &&
ts.isStringLiteral(
node.declarationList.declarations[0].initializer.arguments[0]
) &&
node.declarationList.declarations[0].initializer.arguments[0].text ===
pluginImport
) {
changes.push({
type: ChangeType.Delete,
start: node.pos,
length: node.end - node.pos,
});
}
});
ts.forEachChild(source, function analyze(node) {
if (
ts.isExpressionStatement(node) &&
ts.isBinaryExpression(node.expression) &&
node.expression.left.getText() === 'module.exports' &&
ts.isArrayLiteralExpression(node.expression.right)
) {
const blockElements = node.expression.right.elements;
blockElements.forEach((element) => {
if (ts.isObjectLiteralExpression(element)) {
const pluginsElem = element.properties.find(
(prop) => prop.name?.getText() === 'plugins'
) as ts.PropertyAssignment;
if (!pluginsElem) {
return;
}
if (ts.isArrayLiteralExpression(pluginsElem.initializer)) {
const pluginsArray = pluginsElem.initializer;
const plugins = parseTextToJson(
pluginsElem.initializer
.getText()
.replace(STRIP_SPREAD_ELEMENTS, '')
);
if (plugins.length > 1) {
changes.push({
type: ChangeType.Delete,
start: pluginsArray.pos,
length: pluginsArray.end - pluginsArray.pos,
});
changes.push({
type: ChangeType.Insert,
index: pluginsArray.pos,
text: JSON.stringify(plugins.filter((p) => p !== pluginName)),
});
} else {
const keys = element.properties.map((prop) =>
prop.name?.getText()
);
if (keys.length > 1) {
const removeComma =
keys.indexOf('plugins') < keys.length - 1 ||
element.properties.hasTrailingComma;
changes.push({
type: ChangeType.Delete,
start: pluginsElem.pos + (removeComma ? 1 : 0),
length:
pluginsElem.end - pluginsElem.pos + (removeComma ? 1 : 0),
});
} else {
const removeComma =
blockElements.indexOf(element) < blockElements.length - 1 ||
blockElements.hasTrailingComma;
changes.push({
type: ChangeType.Delete,
start: element.pos + (removeComma ? 1 : 0),
length: element.end - element.pos + (removeComma ? 1 : 0),
});
}
}
} else if (ts.isObjectLiteralExpression(pluginsElem.initializer)) {
const pluginsObj = pluginsElem.initializer;
if (pluginsElem.initializer.properties.length > 1) {
const plugin = pluginsObj.properties.find(
(prop) => prop.name?.['text'] === pluginName
) as ts.PropertyAssignment;
const removeComma =
pluginsObj.properties.indexOf(plugin) <
pluginsObj.properties.length - 1 ||
pluginsObj.properties.hasTrailingComma;
changes.push({
type: ChangeType.Delete,
start: plugin.pos + (removeComma ? 1 : 0),
length: plugin.end - plugin.pos + (removeComma ? 1 : 0),
});
} else {
const keys = element.properties.map((prop) =>
prop.name?.getText()
);
if (keys.length > 1) {
const removeComma =
keys.indexOf('plugins') < keys.length - 1 ||
element.properties.hasTrailingComma;
changes.push({
type: ChangeType.Delete,
start: pluginsElem.pos + (removeComma ? 1 : 0),
length:
pluginsElem.end - pluginsElem.pos + (removeComma ? 1 : 0),
});
} else {
const removeComma =
blockElements.indexOf(element) < blockElements.length - 1 ||
blockElements.hasTrailingComma;
changes.push({
type: ChangeType.Delete,
start: element.pos + (removeComma ? 1 : 0),
length: element.end - element.pos + (removeComma ? 1 : 0),
});
}
}
}
}
});
}
});
return applyChangesToString(content, changes);
}
export function removeCompatExtends(
content: string,
compatExtends: string[]
): string {
const source = ts.createSourceFile(
'',
content,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const changes: StringChange[] = [];
findAllBlocks(source).forEach((node) => {
if (
ts.isSpreadElement(node) &&
ts.isCallExpression(node.expression) &&
ts.isArrowFunction(node.expression.arguments[0]) &&
ts.isParenthesizedExpression(node.expression.arguments[0].body) &&
ts.isPropertyAccessExpression(node.expression.expression) &&
ts.isCallExpression(node.expression.expression.expression)
) {
const callExp = node.expression.expression.expression;
if (
((callExp.expression.getText() === 'compat.config' &&
callExp.arguments[0].getText().includes('extends')) ||
callExp.expression.getText() === 'compat.extends') &&
compatExtends.some((ext) =>
callExp.arguments[0].getText().includes(ext)
)
) {
// remove the whole node
changes.push({
type: ChangeType.Delete,
start: node.pos,
length: node.end - node.pos,
});
// and replace it with new one
const paramName =
node.expression.arguments[0].parameters[0].name.getText();
const body = node.expression.arguments[0].body.expression.getFullText();
changes.push({
type: ChangeType.Insert,
index: node.pos,
text:
'\n' +
body.replace(
new RegExp('[ \t]s*...' + paramName + '[ \t]*,?\\s*', 'g'),
''
),
});
}
}
});
return applyChangesToString(content, changes);
}
/**
* Add plugins block to the top of the export blocks
*/
export function addPluginsToExportsBlock(
content: string,
plugins: { name: string; varName: string; imp: string }[]
): string {
const pluginsBlock = ts.factory.createObjectLiteralExpression(
[
ts.factory.createPropertyAssignment(
'plugins',
ts.factory.createObjectLiteralExpression(
plugins.map(({ name, varName }) => {
return ts.factory.createPropertyAssignment(
ts.factory.createStringLiteral(name),
ts.factory.createIdentifier(varName)
);
})
)
),
],
false
);
return addBlockToFlatConfigExport(content, pluginsBlock, {
insertAtTheEnd: false,
});
}
/**
* Adds compat if missing to flat config
*/
export function addCompatToFlatConfig(content: string) {
let result = content;
result = addImportToFlatConfig(result, 'js', '@eslint/js');
if (result.includes('const compat = new FlatCompat')) {
return result;
}
result = addImportToFlatConfig(result, 'FlatCompat', '@eslint/eslintrc');
const index = result.indexOf('module.exports');
return applyChangesToString(result, [
{
type: ChangeType.Insert,
index: index - 1,
text: `${DEFAULT_FLAT_CONFIG}\n`,
},
]);
}
const DEFAULT_FLAT_CONFIG = `
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
`;
/**
* Generate node list representing the imports and the exports blocks
* Optionally add flat compat initialization
*/
export 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)
)
),
]);
}
export function generateSpreadElement(name: string): ts.SpreadElement {
return ts.factory.createSpreadElement(ts.factory.createIdentifier(name));
}
export function generatePluginExtendsElement(
plugins: string[]
): ts.SpreadElement {
return ts.factory.createSpreadElement(
ts.factory.createCallExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier('compat'),
ts.factory.createIdentifier('extends')
),
undefined,
plugins.map((plugin) => ts.factory.createStringLiteral(plugin))
)
);
}
/**
* Stringifies TS nodes to file content string
*/
export function stringifyNodeList(
nodes: ts.NodeArray<
| ts.VariableStatement
| ts.Identifier
| ts.ExpressionStatement
| ts.SourceFile
>,
root: string,
fileName: string
): string {
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const resultFile = ts.createSourceFile(
joinPathFragments(root, fileName),
'',
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
return printer.printList(ts.ListFormat.MultiLine, nodes, resultFile);
}
/**
* generates AST require statement
*/
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
)
);
}
/**
* Generates AST object or spread element based on JSON override object
*/
export function generateFlatOverride(
override: Linter.ConfigOverride<Linter.RulesRecord>,
root: string
): ts.ObjectLiteralExpression | ts.SpreadElement {
mapFilePaths(override, root);
if (
!override.env &&
!override.extends &&
!override.plugins &&
!override.parser
) {
return generateAst(override);
}
const { files, excludedFiles, rules, ...rest } = override;
const objectLiteralElements: ts.ObjectLiteralElementLike[] = [
ts.factory.createSpreadAssignment(ts.factory.createIdentifier('config')),
];
addTSObjectProperty(objectLiteralElements, 'files', files);
addTSObjectProperty(objectLiteralElements, 'excludedFiles', excludedFiles);
addTSObjectProperty(objectLiteralElements, 'rules', rules);
return 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
)
)
),
]
)
);
}
export function mapFilePaths(
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));
}
if (override.excludedFiles) {
override.excludedFiles = Array.isArray(override.excludedFiles)
? override.excludedFiles
: [override.excludedFiles];
override.excludedFiles = override.excludedFiles.map((file) =>
mapFilePath(file, root)
);
}
}
export function mapFilePath(filePath: string, root: string) {
if (filePath.startsWith('!')) {
const fileWithoutBang = filePath.slice(1);
if (fileWithoutBang.startsWith('*.')) {
return `!${joinPathFragments(root, '**', fileWithoutBang)}`;
} else if (!fileWithoutBang.startsWith(root)) {
return `!${joinPathFragments(root, fileWithoutBang)}`;
}
return filePath;
}
if (filePath.startsWith('*.')) {
return joinPathFragments(root, '**', filePath);
} else if (!filePath.startsWith(root)) {
return joinPathFragments(root, filePath);
}
return filePath;
}
function addTSObjectProperty(
elements: ts.ObjectLiteralElementLike[],
key: string,
value: unknown
) {
if (value) {
elements.push(ts.factory.createPropertyAssignment(key, generateAst(value)));
}
}
/**
* 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} `);
}
function isValidKey(key: string): boolean {
return /^[a-zA-Z0-9_]+$/.test(key);
}

View File

@ -0,0 +1,31 @@
import { joinPathFragments } from '@nx/devkit';
import type { Linter } from 'eslint';
export 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 `!${joinPathFragments(root, '**', fileWithoutBang)}`;
} else {
return `!${joinPathFragments(root, fileWithoutBang)}`;
}
}
if (filePath.startsWith('*.')) {
return joinPathFragments(root, '**', filePath);
} else {
return joinPathFragments(root, filePath);
}
}

View File

@ -44,7 +44,7 @@ export async function lintWorkspaceRuleGenerator(
/** /**
* Import the new rule into the workspace plugin index.ts and * Import the new rule into the workspace plugin index.ts and
* register it ready for use in .eslintrc.json configs. * register it ready for use in lint configs.
*/ */
const pluginIndexPath = joinPathFragments(workspaceLintPluginDir, 'index.ts'); const pluginIndexPath = joinPathFragments(workspaceLintPluginDir, 'index.ts');
const existingPluginIndexContents = tree.read(pluginIndexPath, 'utf-8'); const existingPluginIndexContents = tree.read(pluginIndexPath, 'utf-8');
@ -106,7 +106,7 @@ export async function lintWorkspaceRuleGenerator(
await formatFiles(tree); await formatFiles(tree);
logger.info(`NX Reminder: Once you have finished writing your rule logic, you need to actually enable the rule within an appropriate .eslintrc.json in your workspace, for example: logger.info(`NX Reminder: Once you have finished writing your rule logic, you need to actually enable the rule within an appropriate ESLint config in your workspace, for example:
"rules": { "rules": {
"@nx/workspace/${options.name}": "error" "@nx/workspace/${options.name}": "error"

View File

@ -0,0 +1,5 @@
import { Tree } from '@nx/devkit';
export function useFlatConfig(tree: Tree): boolean {
return tree.exists('eslint.config.js');
}

View File

@ -573,9 +573,6 @@ describe('app', () => {
const eslintJson = readJson(tree, '/apps/my-app/.eslintrc.json'); const eslintJson = readJson(tree, '/apps/my-app/.eslintrc.json');
expect(eslintJson).toMatchInlineSnapshot(` expect(eslintJson).toMatchInlineSnapshot(`
{ {
"env": {
"jest": true,
},
"extends": [ "extends": [
"plugin:@nx/react-typescript", "plugin:@nx/react-typescript",
"next", "next",
@ -587,6 +584,14 @@ describe('app', () => {
".next/**/*", ".next/**/*",
], ],
"overrides": [ "overrides": [
{
"files": [
"*.*",
],
"rules": {
"@next/next/no-html-link-for-pages": "off",
},
},
{ {
"files": [ "files": [
"*.ts", "*.ts",
@ -615,10 +620,18 @@ describe('app', () => {
], ],
"rules": {}, "rules": {},
}, },
{
"env": {
"jest": true,
},
"files": [
"*.spec.ts",
"*.spec.tsx",
"*.spec.js",
"*.spec.jsx",
],
},
], ],
"rules": {
"@next/next/no-html-link-for-pages": "off",
},
} }
`); `);
}); });

View File

@ -0,0 +1,181 @@
import {
ProjectConfiguration,
Tree,
addProjectConfiguration,
readJson,
} from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { addLinting } from './add-linting';
import { Linter } from '@nx/linter';
import { NormalizedSchema } from './normalize-options';
describe('updateEslint', () => {
let tree: Tree;
let schema: NormalizedSchema;
beforeEach(async () => {
schema = {
projectName: 'my-app',
appProjectRoot: 'apps/my-app',
linter: Linter.EsLint,
unitTestRunner: 'jest',
e2eProjectName: 'my-app-e2e',
e2eProjectRoot: 'apps/my-app-e2e',
outputPath: 'dist/apps/my-app',
name: 'my-app',
parsedTags: [],
fileName: 'index',
e2eTestRunner: 'cypress',
styledModule: null,
};
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
const project: ProjectConfiguration = {
root: schema.appProjectRoot,
sourceRoot: schema.appProjectRoot,
projectType: 'application',
targets: {},
tags: schema.parsedTags,
};
addProjectConfiguration(tree, schema.projectName, {
...project,
});
});
it('should update the eslintrc config', async () => {
tree.write('.eslintrc.json', JSON.stringify({ extends: ['some-config'] }));
await addLinting(tree, schema);
expect(readJson(tree, `${schema.appProjectRoot}/.eslintrc.json`))
.toMatchInlineSnapshot(`
{
"extends": [
"plugin:@nx/react-typescript",
"next",
"next/core-web-vitals",
"../../.eslintrc.json",
],
"ignorePatterns": [
"!**/*",
".next/**/*",
],
"overrides": [
{
"files": [
"*.*",
],
"rules": {
"@next/next/no-html-link-for-pages": "off",
},
},
{
"files": [
"*.ts",
"*.tsx",
"*.js",
"*.jsx",
],
"rules": {
"@next/next/no-html-link-for-pages": [
"error",
"apps/my-app/pages",
],
},
},
{
"files": [
"*.ts",
"*.tsx",
],
"rules": {},
},
{
"files": [
"*.js",
"*.jsx",
],
"rules": {},
},
{
"env": {
"jest": true,
},
"files": [
"*.spec.ts",
"*.spec.tsx",
"*.spec.js",
"*.spec.jsx",
],
},
],
}
`);
});
it('should update the flat config', async () => {
tree.write('eslint.config.js', `module.exports = []`);
await addLinting(tree, schema);
expect(tree.read(`${schema.appProjectRoot}/eslint.config.js`, 'utf-8'))
.toMatchInlineSnapshot(`
"const FlatCompat = require("@eslint/eslintrc");
const js = require("@eslint/js");
const baseConfig = require("../../eslint.config.js");
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
module.exports = [
{
files: ["apps/my-app/**/*.*"],
rules: { "@next/next/no-html-link-for-pages": "off" }
},
...baseConfig,
{
"files": [
"apps/my-app/**/*.ts",
"apps/my-app/**/*.tsx",
"apps/my-app/**/*.js",
"apps/my-app/**/*.jsx"
],
"rules": {
"@next/next/no-html-link-for-pages": [
"error",
"apps/my-app/pages"
]
}
},
{
files: [
"apps/my-app/**/*.ts",
"apps/my-app/**/*.tsx"
],
rules: {}
},
{
files: [
"apps/my-app/**/*.js",
"apps/my-app/**/*.jsx"
],
rules: {}
},
...compat.extends("plugin:@nx/react-typescript", "next", "next/core-web-vitals"),
...compat.config({ env: { jest: true } }).map(config => ({
...config,
files: [
"apps/my-app/**/*.spec.ts",
"apps/my-app/**/*.spec.tsx",
"apps/my-app/**/*.spec.js",
"apps/my-app/**/*.spec.jsx"
]
})),
{ ignores: ["apps/my-app/.next/**/*"] }
];
"
`);
});
});

View File

@ -5,13 +5,16 @@ import {
joinPathFragments, joinPathFragments,
runTasksInSerial, runTasksInSerial,
Tree, Tree,
updateJson,
} from '@nx/devkit'; } from '@nx/devkit';
import { import { extraEslintDependencies } from '@nx/react/src/utils/lint';
extendReactEslintJson,
extraEslintDependencies,
} from '@nx/react/src/utils/lint';
import { NormalizedSchema } from './normalize-options'; import { NormalizedSchema } from './normalize-options';
import {
addExtendsToLintConfig,
addIgnoresToLintConfig,
addOverrideToLintConfig,
isEslintConfigSupported,
updateOverrideInLintConfig,
} from '@nx/linter/src/generators/utils/eslint-file';
export async function addLinting( export async function addLinting(
host: Tree, host: Tree,
@ -28,70 +31,56 @@ export async function addLinting(
skipFormat: true, skipFormat: true,
rootProject: options.rootProject, rootProject: options.rootProject,
}); });
if (options.linter === Linter.EsLint && isEslintConfigSupported(host)) {
addExtendsToLintConfig(host, options.appProjectRoot, [
'plugin:@nx/react-typescript',
'next',
'next/core-web-vitals',
]);
if (options.linter === Linter.EsLint) { // Turn off @next/next/no-html-link-for-pages since there is an issue with nextjs throwing linting errors
updateJson( // TODO(nicholas): remove after Vercel updates nextjs linter to only lint ["*.ts", "*.tsx", "*.js", "*.jsx"]
addOverrideToLintConfig(
host, host,
joinPathFragments(options.appProjectRoot, '.eslintrc.json'), options.appProjectRoot,
(json) => { {
json = extendReactEslintJson(json); files: ['*.*'],
rules: {
// Turn off @next/next/no-html-link-for-pages since there is an issue with nextjs throwing linting errors
// TODO(nicholas): remove after Vercel updates nextjs linter to only lint ["*.ts", "*.tsx", "*.js", "*.jsx"]
json.ignorePatterns = [...json.ignorePatterns, '.next/**/*'];
json.rules = {
'@next/next/no-html-link-for-pages': 'off', '@next/next/no-html-link-for-pages': 'off',
...json.rules, },
}; },
{ insertAtTheEnd: false }
// Find the override that handles both TS and JS files.
const commonOverride = json.overrides?.find((o) =>
['*.ts', '*.tsx', '*.js', '*.jsx'].every((ext) =>
o.files.includes(ext)
)
);
if (commonOverride) {
// Only set parserOptions.project if it already exists (defined by options.setParserOptionsProject)
if (commonOverride.parserOptions?.project) {
commonOverride.parserOptions.project = [
`${options.appProjectRoot}/tsconfig(.*)?.json`,
];
}
// Configure custom pages directory for next rule
if (commonOverride.rules) {
commonOverride.rules = {
...commonOverride.rules,
'@next/next/no-html-link-for-pages': [
'error',
`${options.appProjectRoot}/pages`,
],
};
}
}
json.extends ??= [];
if (typeof json.extends === 'string') {
json.extends = [json.extends];
}
// add next.js configuration
json.extends.unshift(...['next', 'next/core-web-vitals']);
// remove nx/react plugin, as it conflicts with the next.js one
json.extends = json.extends.filter(
(name) =>
name !== 'plugin:@nx/react' && name !== 'plugin:@nrwl/nx/react'
);
json.extends.unshift('plugin:@nx/react-typescript');
if (!json.env) {
json.env = {};
}
json.env.jest = true;
return json;
}
); );
updateOverrideInLintConfig(
host,
options.appProjectRoot,
(o) =>
Array.isArray(o.files) &&
o.files.some((f) => f.match(/\*\.ts$/)) &&
o.files.some((f) => f.match(/\*\.tsx$/)) &&
o.files.some((f) => f.match(/\*\.js$/)) &&
o.files.some((f) => f.match(/\*\.jsx$/)),
(o) => ({
...o,
rules: {
...o.rules,
'@next/next/no-html-link-for-pages': [
'error',
`${options.appProjectRoot}/pages`,
],
},
})
);
// add jest specific config
if (options.unitTestRunner === 'jest') {
addOverrideToLintConfig(host, options.appProjectRoot, {
files: ['*.spec.ts', '*.spec.tsx', '*.spec.js', '*.spec.jsx'],
env: {
jest: true,
},
});
}
addIgnoresToLintConfig(host, options.appProjectRoot, ['.next/**/*']);
} }
const installTask = addDependenciesToPackageJson( const installTask = addDependenciesToPackageJson(

View File

@ -11,18 +11,21 @@ import {
readProjectConfiguration, readProjectConfiguration,
runTasksInSerial, runTasksInSerial,
Tree, Tree,
updateJson,
} from '@nx/devkit'; } from '@nx/devkit';
import { determineProjectNameAndRootOptions } from '@nx/devkit/src/generators/project-name-and-root-utils'; import { determineProjectNameAndRootOptions } from '@nx/devkit/src/generators/project-name-and-root-utils';
import { Linter, lintProjectGenerator } from '@nx/linter'; import { Linter, lintProjectGenerator } from '@nx/linter';
import { import {
globalJavaScriptOverrides, javaScriptOverride,
globalTypeScriptOverrides, typeScriptOverride,
} from '@nx/linter/src/generators/init/global-eslint-config'; } from '@nx/linter/src/generators/init/global-eslint-config';
import * as path from 'path'; import * as path from 'path';
import { join } from 'path';
import { axiosVersion } from '../../utils/versions'; import { axiosVersion } from '../../utils/versions';
import { Schema } from './schema'; import { Schema } from './schema';
import {
addPluginsToLintConfig,
isEslintConfigSupported,
replaceOverridesInLintConfig,
} from '@nx/linter/src/generators/utils/eslint-file';
export async function e2eProjectGenerator(host: Tree, options: Schema) { export async function e2eProjectGenerator(host: Tree, options: Schema) {
return await e2eProjectGeneratorInternal(host, { return await e2eProjectGeneratorInternal(host, {
@ -119,32 +122,13 @@ export async function e2eProjectGeneratorInternal(
}); });
tasks.push(linterTask); tasks.push(linterTask);
updateJson(host, join(options.e2eProjectRoot, '.eslintrc.json'), (json) => { if (options.rootProject && isEslintConfigSupported(host)) {
if (options.rootProject) { addPluginsToLintConfig(host, options.e2eProjectRoot, '@nx');
json.plugins = ['@nx']; replaceOverridesInLintConfig(host, options.e2eProjectRoot, [
json.extends = []; typeScriptOverride,
} javaScriptOverride,
json.overrides = [ ]);
...(options.rootProject }
? [globalTypeScriptOverrides, globalJavaScriptOverrides]
: []),
/**
* In order to ensure maximum efficiency when typescript-eslint generates TypeScript Programs
* behind the scenes during lint runs, we need to make sure the project is configured to use its
* own specific tsconfigs, and not fall back to the ones in the root of the workspace.
*/
{
files: ['*.ts', '*.tsx', '*.js', '*.jsx'],
/**
* Having an empty rules object present makes it more obvious to the user where they would
* extend things from if they needed to
*/
rules: {},
},
];
return json;
});
} }
if (!options.skipFormat) { if (!options.skipFormat) {

View File

@ -217,7 +217,10 @@ function projectHasKarmaConfig(
function projectHasEslintConfig( function projectHasEslintConfig(
project: AngularJsonProjectConfiguration project: AngularJsonProjectConfiguration
): boolean { ): boolean {
return fileExists(join(project.root, '.eslintrc.json')); return (
fileExists(join(project.root, '.eslintrc.json')) ||
fileExists(join(project.root, 'eslint.config.js'))
);
} }
function replaceNgWithNxInPackageJsonScripts(repoRoot: string): void { function replaceNgWithNxInPackageJsonScripts(repoRoot: string): void {

View File

@ -5,11 +5,17 @@ import {
readProjectConfiguration, readProjectConfiguration,
runTasksInSerial, runTasksInSerial,
Tree, Tree,
updateJson,
} from '@nx/devkit'; } from '@nx/devkit';
import { Linter, lintProjectGenerator } from '@nx/linter'; import { Linter, lintProjectGenerator } from '@nx/linter';
import { globalJavaScriptOverrides } from '@nx/linter/src/generators/init/global-eslint-config'; import { javaScriptOverride } from '@nx/linter/src/generators/init/global-eslint-config';
import { eslintPluginPlaywrightVersion } from './versions'; import { eslintPluginPlaywrightVersion } from './versions';
import {
addExtendsToLintConfig,
addOverrideToLintConfig,
addPluginsToLintConfig,
findEslintFile,
isEslintConfigSupported,
} from '@nx/linter/src/generators/utils/eslint-file';
export interface PlaywrightLinterOptions { export interface PlaywrightLinterOptions {
project: string; project: string;
@ -35,7 +41,8 @@ export async function addLinterToPlaywrightProject(
const tasks: GeneratorCallback[] = []; const tasks: GeneratorCallback[] = [];
const projectConfig = readProjectConfiguration(tree, options.project); const projectConfig = readProjectConfiguration(tree, options.project);
if (!tree.exists(joinPathFragments(projectConfig.root, '.eslintrc.json'))) { const eslintFile = findEslintFile(tree, projectConfig.root);
if (!eslintFile) {
tasks.push( tasks.push(
await lintProjectGenerator(tree, { await lintProjectGenerator(tree, {
project: options.project, project: options.project,
@ -66,38 +73,26 @@ export async function addLinterToPlaywrightProject(
: () => {} : () => {}
); );
updateJson( if (isEslintConfigSupported(tree)) {
tree, addExtendsToLintConfig(
joinPathFragments(projectConfig.root, '.eslintrc.json'), tree,
(json) => { projectConfig.root,
if (options.rootProject) { 'plugin:playwright/recommended'
json.plugins = ['@nx']; );
json.extends = ['plugin:playwright/recommended']; if (options.rootProject) {
} else { addPluginsToLintConfig(tree, projectConfig.root, '@nx');
json.extends = ['plugin:playwright/recommended', ...json.extends]; addOverrideToLintConfig(tree, projectConfig.root, javaScriptOverride);
}
json.overrides ??= [];
const globals = options.rootProject ? [globalJavaScriptOverrides] : [];
const override = {
files: ['*.ts', '*.tsx', '*.js', '*.jsx'],
parserOptions: !options.setParserOptionsProject
? undefined
: {
project: `${projectConfig.root}/tsconfig.*?.json`,
},
rules: {},
};
const palywrightFiles = [
{
...override,
files: [`${options.directory}/**/*.{ts,js,tsx,jsx}`],
},
];
json.overrides.push(...globals);
json.overrides.push(...palywrightFiles);
return json;
} }
); addOverrideToLintConfig(tree, projectConfig.root, {
files: [`${options.directory}/**/*.{ts,js,tsx,jsx}`],
parserOptions: !options.setParserOptionsProject
? undefined
: {
project: `${projectConfig.root}/tsconfig.*?.json`,
},
rules: {},
});
}
return runTasksInSerial(...tasks); return runTasksInSerial(...tasks);
} }

View File

@ -8,18 +8,22 @@ import {
readProjectConfiguration, readProjectConfiguration,
TargetConfiguration, TargetConfiguration,
Tree, Tree,
updateJson,
updateProjectConfiguration, updateProjectConfiguration,
writeJson, writeJson,
} from '@nx/devkit'; } from '@nx/devkit';
import type { Linter as ESLint } from 'eslint';
import type { Schema as EsLintExecutorOptions } from '@nx/linter/src/executors/eslint/schema'; import type { Schema as EsLintExecutorOptions } from '@nx/linter/src/executors/eslint/schema';
import { PluginLintChecksGeneratorSchema } from './schema'; import { PluginLintChecksGeneratorSchema } from './schema';
import { NX_PREFIX } from 'nx/src/utils/logger'; import { NX_PREFIX } from 'nx/src/utils/logger';
import { PackageJson, readNxMigrateConfig } from 'nx/src/utils/package-json'; import { PackageJson, readNxMigrateConfig } from 'nx/src/utils/package-json';
import {
addOverrideToLintConfig,
isEslintConfigSupported,
lintConfigHasOverride,
updateOverrideInLintConfig,
} from '@nx/linter/src/generators/utils/eslint-file';
import { useFlatConfig } from '@nx/linter/src/utils/flat-config';
export default async function pluginLintCheckGenerator( export default async function pluginLintCheckGenerator(
host: Tree, host: Tree,
@ -101,22 +105,19 @@ export function addMigrationJsonChecks(
updateProjectConfiguration(host, options.projectName, projectConfiguration); updateProjectConfiguration(host, options.projectName, projectConfiguration);
// Update project level eslintrc // Update project level eslintrc
updateJson<ESLint.Config>( updateOverrideInLintConfig(
host, host,
`${projectConfiguration.root}/.eslintrc.json`, projectConfiguration.root,
(c) => { (o) =>
const override = c.overrides.find( Object.keys(o.rules ?? {})?.includes('@nx/nx-plugin-checks') ||
(o) => Object.keys(o.rules ?? {})?.includes('@nrwl/nx/nx-plugin-checks'),
Object.keys(o.rules ?? {})?.includes('@nx/nx-plugin-checks') || (o) => {
Object.keys(o.rules ?? {})?.includes('@nrwl/nx/nx-plugin-checks') const fileSet = new Set(Array.isArray(o.files) ? o.files : [o.files]);
); fileSet.add(relativeMigrationsJsonPath);
if ( return {
Array.isArray(override?.files) && ...o,
!override.files.includes(relativeMigrationsJsonPath) files: Array.from(fileSet),
) { };
override.files.push(relativeMigrationsJsonPath);
}
return c;
} }
); );
} }
@ -179,42 +180,49 @@ function updateProjectEslintConfig(
options: ProjectConfiguration, options: ProjectConfiguration,
packageJson: PackageJson packageJson: PackageJson
) { ) {
// Update the project level lint configuration to specify if (isEslintConfigSupported(host, options.root)) {
// the plugin schema rule for generated files const lookup = (o) =>
const eslintPath = `${options.root}/.eslintrc.json`; Object.keys(o.rules ?? {}).includes('@nx/nx-plugin-checks') ||
if (host.exists(eslintPath)) { Object.keys(o.rules ?? {}).includes('@nrwl/nx/nx-plugin-checks');
const eslintConfig = readJson<ESLint.Config>(host, eslintPath);
eslintConfig.overrides ??= [];
let entry: ESLint.ConfigOverride<ESLint.RulesRecord> =
eslintConfig.overrides.find(
(x) =>
Object.keys(x.rules ?? {}).includes('@nx/nx-plugin-checks') ||
Object.keys(x.rules ?? {}).includes('@nrwl/nx/nx-plugin-checks')
);
const newentry = !entry;
entry ??= { files: [] };
entry.files = [
...new Set([
...(entry.files ?? []),
...[
'./package.json',
packageJson.generators,
packageJson.executors,
packageJson.schematics,
packageJson.builders,
].filter((f) => !!f),
]),
];
entry.parser = 'jsonc-eslint-parser';
entry.rules ??= {
'@nx/nx-plugin-checks': 'error',
};
if (newentry) { const files = [
eslintConfig.overrides.push(entry); './package.json',
packageJson.generators,
packageJson.executors,
packageJson.schematics,
packageJson.builders,
].filter((f) => !!f);
const parser = useFlatConfig(host)
? { languageOptions: { parser: 'jsonc-eslint-parser' } }
: { parser: 'jsonc-eslint-parser' };
if (lintConfigHasOverride(host, options.root, lookup)) {
// update it
updateOverrideInLintConfig(host, options.root, lookup, (o) => ({
...o,
files: [
...new Set([
...(Array.isArray(o.files) ? o.files : [o.files]),
...files,
]),
],
...parser,
rules: {
...o.rules,
'@nx/nx-plugin-checks': 'error',
},
}));
} else {
// add it
addOverrideToLintConfig(host, options.root, {
files,
...parser,
rules: {
'@nx/nx-plugin-checks': 'error',
},
});
} }
writeJson(host, eslintPath, eslintConfig);
} }
} }
@ -222,22 +230,34 @@ function updateProjectEslintConfig(
// This is required, otherwise every json file that is not overriden // This is required, otherwise every json file that is not overriden
// will display false errors in the IDE // will display false errors in the IDE
function updateRootEslintConfig(host: Tree) { function updateRootEslintConfig(host: Tree) {
if (host.exists('.eslintrc.json')) { if (isEslintConfigSupported(host)) {
const rootESLint = readJson<ESLint.Config>(host, '.eslintrc.json'); if (
rootESLint.overrides ??= []; !lintConfigHasOverride(
if (!eslintConfigContainsJsonOverride(rootESLint)) { host,
rootESLint.overrides.push({ '',
files: '*.json', (o) =>
parser: 'jsonc-eslint-parser', Array.isArray(o.files)
rules: {}, ? o.files.some((f) => f.match(/\.json$/))
}); : !!o.files?.match(/\.json$/),
writeJson(host, '.eslintrc.json', rootESLint); true
)
) {
addOverrideToLintConfig(
host,
'',
{
files: '*.json',
parser: 'jsonc-eslint-parser',
rules: {},
},
{ checkBaseConfig: true }
);
} }
} else { } else {
output.note({ output.note({
title: 'Unable to update root eslint config.', title: 'Unable to update root eslint config.',
bodyLines: [ bodyLines: [
'We only automatically update the root eslint config if it is json.', 'We only automatically update the root eslint config if it is json or flat config.',
'If you are using a different format, you will need to update it manually.', 'If you are using a different format, you will need to update it manually.',
'You need to set the parser to jsonc-eslint-parser for json files.', 'You need to set the parser to jsonc-eslint-parser for json files.',
], ],
@ -263,15 +283,6 @@ function setupVsCodeLintingForJsonFiles(host: Tree) {
writeJson(host, '.vscode/settings.json', existing); writeJson(host, '.vscode/settings.json', existing);
} }
function eslintConfigContainsJsonOverride(eslintConfig: ESLint.Config) {
return eslintConfig.overrides.some((x) => {
if (typeof x.files === 'string' && x.files.includes('.json')) {
return true;
}
return Array.isArray(x.files) && x.files.some((f) => f.includes('.json'));
});
}
function projectIsEsLintEnabled(project: ProjectConfiguration) { function projectIsEsLintEnabled(project: ProjectConfiguration) {
return !!getEsLintOptions(project); return !!getEsLintOptions(project);
} }

View File

@ -2,16 +2,15 @@ import { Linter, lintProjectGenerator } from '@nx/linter';
import { import {
addDependenciesToPackageJson, addDependenciesToPackageJson,
GeneratorCallback, GeneratorCallback,
joinPathFragments,
runTasksInSerial, runTasksInSerial,
Tree, Tree,
updateJson,
} from '@nx/devkit'; } from '@nx/devkit';
import { extraEslintDependencies } from '@nx/react/src/utils/lint';
import { import {
extendReactEslintJson, addExtendsToLintConfig,
extraEslintDependencies, addIgnoresToLintConfig,
} from '@nx/react/src/utils/lint'; isEslintConfigSupported,
import type { Linter as ESLintLinter } from 'eslint'; } from '@nx/linter/src/generators/utils/eslint-file';
interface NormalizedSchema { interface NormalizedSchema {
linter?: Linter; linter?: Linter;
@ -39,22 +38,14 @@ export async function addLinting(host: Tree, options: NormalizedSchema) {
tasks.push(lintTask); tasks.push(lintTask);
updateJson( if (isEslintConfigSupported(host)) {
host, addExtendsToLintConfig(host, options.projectRoot, 'plugin:@nx/react');
joinPathFragments(options.projectRoot, '.eslintrc.json'), addIgnoresToLintConfig(host, options.projectRoot, [
(json: ESLintLinter.Config) => { 'public',
json = extendReactEslintJson(json); '.cache',
'node_modules',
json.ignorePatterns = [ ]);
...json.ignorePatterns, }
'public',
'.cache',
'node_modules',
];
return json;
}
);
if (!options.skipPackageJson) { if (!options.skipPackageJson) {
const installTask = await addDependenciesToPackageJson( const installTask = await addDependenciesToPackageJson(

View File

@ -1,7 +1,4 @@
import { import { extraEslintDependencies } from '../../utils/lint';
extendReactEslintJson,
extraEslintDependencies,
} from '../../utils/lint';
import { NormalizedSchema, Schema } from './schema'; import { NormalizedSchema, Schema } from './schema';
import { createApplicationFiles } from './lib/create-application-files'; import { createApplicationFiles } from './lib/create-application-files';
import { updateSpecConfig } from './lib/update-jest-config'; import { updateSpecConfig } from './lib/update-jest-config';
@ -22,7 +19,6 @@ import {
runTasksInSerial, runTasksInSerial,
stripIndents, stripIndents,
Tree, Tree,
updateJson,
} from '@nx/devkit'; } from '@nx/devkit';
import reactInitGenerator from '../init/init'; import reactInitGenerator from '../init/init';
@ -39,6 +35,10 @@ import { addSwcDependencies } from '@nx/js/src/utils/swc/add-swc-dependencies';
import * as chalk from 'chalk'; import * as chalk from 'chalk';
import { showPossibleWarnings } from './lib/show-possible-warnings'; import { showPossibleWarnings } from './lib/show-possible-warnings';
import { addE2e } from './lib/add-e2e'; import { addE2e } from './lib/add-e2e';
import {
addExtendsToLintConfig,
isEslintConfigSupported,
} from '@nx/linter/src/generators/utils/eslint-file';
async function addLinting(host: Tree, options: NormalizedSchema) { async function addLinting(host: Tree, options: NormalizedSchema) {
const tasks: GeneratorCallback[] = []; const tasks: GeneratorCallback[] = [];
@ -63,11 +63,9 @@ async function addLinting(host: Tree, options: NormalizedSchema) {
}); });
tasks.push(lintTask); tasks.push(lintTask);
updateJson( if (isEslintConfigSupported(host)) {
host, addExtendsToLintConfig(host, options.appProjectRoot, 'plugin:@nx/react');
joinPathFragments(options.appProjectRoot, '.eslintrc.json'), }
extendReactEslintJson
);
if (!options.skipPackageJson) { if (!options.skipPackageJson) {
const installTask = addDependenciesToPackageJson( const installTask = addDependenciesToPackageJson(

View File

@ -4,7 +4,6 @@ import {
ensurePackage, ensurePackage,
getPackageManagerCommand, getPackageManagerCommand,
joinPathFragments, joinPathFragments,
readProjectConfiguration,
} from '@nx/devkit'; } from '@nx/devkit';
import { webStaticServeGenerator } from '@nx/web'; import { webStaticServeGenerator } from '@nx/web';

View File

@ -1,14 +1,14 @@
import { Tree } from 'nx/src/generators/tree'; import { Tree } from 'nx/src/generators/tree';
import { Linter, lintProjectGenerator } from '@nx/linter'; import { Linter, lintProjectGenerator } from '@nx/linter';
import { joinPathFragments } from 'nx/src/utils/path'; import { joinPathFragments } from 'nx/src/utils/path';
import { updateJson } from 'nx/src/generators/utils/json';
import { addDependenciesToPackageJson, runTasksInSerial } from '@nx/devkit'; import { addDependenciesToPackageJson, runTasksInSerial } from '@nx/devkit';
import { NormalizedSchema } from '../schema'; import { NormalizedSchema } from '../schema';
import { extraEslintDependencies } from '../../../utils/lint';
import { import {
extendReactEslintJson, addExtendsToLintConfig,
extraEslintDependencies, isEslintConfigSupported,
} from '../../../utils/lint'; } from '@nx/linter/src/generators/utils/eslint-file';
export async function addLinting(host: Tree, options: NormalizedSchema) { export async function addLinting(host: Tree, options: NormalizedSchema) {
if (options.linter === Linter.EsLint) { if (options.linter === Linter.EsLint) {
@ -25,11 +25,9 @@ export async function addLinting(host: Tree, options: NormalizedSchema) {
setParserOptionsProject: options.setParserOptionsProject, setParserOptionsProject: options.setParserOptionsProject,
}); });
updateJson( if (isEslintConfigSupported(host)) {
host, addExtendsToLintConfig(host, options.projectRoot, 'plugin:@nx/react');
joinPathFragments(options.projectRoot, '.eslintrc.json'), }
extendReactEslintJson
);
let installTask = () => {}; let installTask = () => {};
if (!options.skipPackageJson) { if (!options.skipPackageJson) {

View File

@ -1,5 +1,4 @@
import { offsetFromRoot } from '@nx/devkit'; import { Linter } from 'eslint';
import type { Linter } from 'eslint';
import { import {
eslintPluginImportVersion, eslintPluginImportVersion,
eslintPluginReactVersion, eslintPluginReactVersion,
@ -17,6 +16,9 @@ export const extraEslintDependencies = {
}, },
}; };
/**
* @deprecated Use `addExtendsToLintConfig` from `@nx/linter` instead.
*/
export const extendReactEslintJson = (json: Linter.Config) => { export const extendReactEslintJson = (json: Linter.Config) => {
const { extends: pluginExtends, ...config } = json; const { extends: pluginExtends, ...config } = json;

View File

@ -28,7 +28,8 @@ import {
import { StorybookConfigureSchema } from '../schema'; import { StorybookConfigureSchema } from '../schema';
import { UiFramework7 } from '../../../utils/models'; import { UiFramework7 } from '../../../utils/models';
import { nxVersion } from '../../../utils/versions'; import { nxVersion } from '../../../utils/versions';
import ts = require('typescript'); import { findEslintFile } from '@nx/linter/src/generators/utils/eslint-file';
import { useFlatConfig } from '@nx/linter/src/utils/flat-config';
const DEFAULT_PORT = 4400; const DEFAULT_PORT = 4400;
@ -173,7 +174,7 @@ export function createStorybookTsconfigFile(
if (tree.exists(oldStorybookTsConfigPath)) { if (tree.exists(oldStorybookTsConfigPath)) {
logger.warn(`.storybook/tsconfig.json already exists for this project`); logger.warn(`.storybook/tsconfig.json already exists for this project`);
logger.warn( logger.warn(
`It will be renamed and moved to tsconfig.storybook.json. `It will be renamed and moved to tsconfig.storybook.json.
Please make sure all settings look correct after this change. Please make sure all settings look correct after this change.
Also, please make sure to use "nx migrate" to move from one version of Nx to another. Also, please make sure to use "nx migrate" to move from one version of Nx to another.
` `
@ -365,7 +366,7 @@ export function configureTsSolutionConfig(
* which includes *.stories files. * which includes *.stories files.
* *
* For TSLint this is done via the builder config, for ESLint this is * For TSLint this is done via the builder config, for ESLint this is
* done within the .eslintrc.json file. * done within the eslint config file.
*/ */
export function updateLintConfig(tree: Tree, schema: StorybookConfigureSchema) { export function updateLintConfig(tree: Tree, schema: StorybookConfigureSchema) {
const { name: projectName } = schema; const { name: projectName } = schema;
@ -382,18 +383,46 @@ export function updateLintConfig(tree: Tree, schema: StorybookConfigureSchema) {
]); ]);
}); });
if (tree.exists(join(root, '.eslintrc.json'))) { const eslintFile = findEslintFile(tree, root);
updateJson(tree, join(root, '.eslintrc.json'), (json) => { if (!eslintFile) {
return;
}
const parserConfigPath = join(
root,
schema.uiFramework === '@storybook/angular'
? '.storybook/tsconfig.json'
: 'tsconfig.storybook.json'
);
if (useFlatConfig(tree)) {
let config = tree.read(eslintFile, 'utf-8');
const projectRegex = RegExp(/project:\s?\[?['"](.*)['"]\]?/g);
let match;
while ((match = projectRegex.exec(config)) !== null) {
const matchSet = new Set(
match[1].split(',').map((p) => p.trim().replace(/['"]/g, ''))
);
matchSet.add(parserConfigPath);
const insert = `project: [${Array.from(matchSet)
.map((p) => `'${p}'`)
.join(', ')}]`;
config =
config.slice(0, match.index) +
insert +
config.slice(match.index + match[0].length);
}
tree.write(eslintFile, config);
} else {
updateJson(tree, join(root, eslintFile), (json) => {
if (typeof json.parserOptions?.project === 'string') { if (typeof json.parserOptions?.project === 'string') {
json.parserOptions.project = [json.parserOptions.project]; json.parserOptions.project = [json.parserOptions.project];
} }
if (Array.isArray(json.parserOptions?.project)) { if (json.parserOptions?.project) {
json.parserOptions.project = dedupe([ json.parserOptions.project = dedupe([
...json.parserOptions.project, ...json.parserOptions.project,
schema.uiFramework === '@storybook/angular' parserConfigPath,
? join(root, '.storybook/tsconfig.json')
: join(root, 'tsconfig.storybook.json'),
]); ]);
} }
@ -402,12 +431,10 @@ export function updateLintConfig(tree: Tree, schema: StorybookConfigureSchema) {
if (typeof o.parserOptions?.project === 'string') { if (typeof o.parserOptions?.project === 'string') {
o.parserOptions.project = [o.parserOptions.project]; o.parserOptions.project = [o.parserOptions.project];
} }
if (Array.isArray(o.parserOptions?.project)) { if (o.parserOptions?.project) {
o.parserOptions.project = dedupe([ o.parserOptions.project = dedupe([
...o.parserOptions.project, ...o.parserOptions.project,
schema.uiFramework === '@storybook/angular' parserConfigPath,
? join(root, '.storybook/tsconfig.json')
: join(root, 'tsconfig.storybook.json'),
]); ]);
} }
} }
@ -629,15 +656,15 @@ export function rootFileIsTs(
): boolean { ): boolean {
if (tree.exists(`.storybook/${rootFileName}.ts`) && !tsConfiguration) { if (tree.exists(`.storybook/${rootFileName}.ts`) && !tsConfiguration) {
logger.info( logger.info(
`The root Storybook configuration is in TypeScript, `The root Storybook configuration is in TypeScript,
so Nx will generate TypeScript Storybook configuration files so Nx will generate TypeScript Storybook configuration files
in this project's .storybook folder as well.` in this project's .storybook folder as well.`
); );
return true; return true;
} else if (tree.exists(`.storybook/${rootFileName}.js`) && tsConfiguration) { } else if (tree.exists(`.storybook/${rootFileName}.js`) && tsConfiguration) {
logger.info( logger.info(
`The root Storybook configuration is in JavaScript, `The root Storybook configuration is in JavaScript,
so Nx will generate JavaScript Storybook configuration files so Nx will generate JavaScript Storybook configuration files
in this project's .storybook folder as well.` in this project's .storybook folder as well.`
); );
return false; return false;
@ -751,17 +778,13 @@ export function renameAndMoveOldTsConfig(
}); });
} }
const projectEsLintFile = joinPathFragments(projectRoot, '.eslintrc.json'); const eslintFile = findEslintFile(tree, projectRoot);
if (eslintFile) {
if (tree.exists(projectEsLintFile)) { const fileName = joinPathFragments(projectRoot, eslintFile);
updateJson(tree, projectEsLintFile, (json) => { const config = tree.read(fileName, 'utf-8');
const jsonString = JSON.stringify(json); tree.write(
const newJsonString = jsonString.replace( fileName,
/\.storybook\/tsconfig\.json/g, config.replace(/\.storybook\/tsconfig\.json/g, 'tsconfig.storybook.json')
'tsconfig.storybook.json' );
);
json = JSON.parse(newJsonString);
return json;
});
} }
} }

View File

@ -24,6 +24,7 @@ export function moveProjectFiles(
'tsconfig.spec.json', 'tsconfig.spec.json',
'.babelrc', '.babelrc',
'.eslintrc.json', '.eslintrc.json',
'eslint.config.js',
/^jest\.config\.(app|lib)\.[jt]s$/, /^jest\.config\.(app|lib)\.[jt]s$/,
'vite.config.ts', 'vite.config.ts',
/^webpack.*\.js$/, /^webpack.*\.js$/,

View File

@ -0,0 +1,455 @@
import {
joinPathFragments,
offsetFromRoot,
readJson,
readProjectConfiguration,
Tree,
updateJson,
} from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { Linter } from '../../../utils/lint';
import { NormalizedSchema } from '../schema';
import { updateEslintConfig } from './update-eslint-config';
// nx-ignore-next-line
const { libraryGenerator } = require('@nx/js');
describe('updateEslint', () => {
let tree: Tree;
let schema: NormalizedSchema;
beforeEach(async () => {
schema = {
projectName: 'my-lib',
destination: 'shared/my-destination',
importPath: '@proj/shared-my-destination',
updateImportPath: true,
newProjectName: 'shared-my-destination',
relativeToRootDestination: 'libs/shared/my-destination',
};
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
});
it('should handle .eslintrc.json not existing', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.None,
});
const projectConfig = readProjectConfiguration(tree, 'my-lib');
expect(() => {
updateEslintConfig(tree, schema, projectConfig);
}).not.toThrow();
});
it('should update .eslintrc.json extends path when project is moved to subdirectory', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.EsLint,
});
// This step is usually handled elsewhere
tree.rename(
'libs/my-lib/.eslintrc.json',
'libs/shared/my-destination/.eslintrc.json'
);
const projectConfig = readProjectConfiguration(tree, 'my-lib');
updateEslintConfig(tree, schema, projectConfig);
expect(
readJson(tree, '/libs/shared/my-destination/.eslintrc.json')
).toEqual(
expect.objectContaining({
extends: ['../../../.eslintrc.json'],
})
);
});
it('should update .eslintrc.json extends path when project is moved from subdirectory', async () => {
await libraryGenerator(tree, {
name: 'test',
directory: 'api',
linter: Linter.EsLint,
});
// This step is usually handled elsewhere
tree.rename('libs/api/test/.eslintrc.json', 'libs/test/.eslintrc.json');
const projectConfig = readProjectConfiguration(tree, 'api-test');
const newSchema = {
projectName: 'api-test',
destination: 'test',
importPath: '@proj/test',
updateImportPath: true,
newProjectName: 'test',
relativeToRootDestination: 'libs/test',
};
updateEslintConfig(tree, newSchema, projectConfig);
expect(readJson(tree, '/libs/test/.eslintrc.json')).toEqual(
expect.objectContaining({
extends: ['../../.eslintrc.json'],
})
);
});
it('should preserve .eslintrc.json non-relative extends when project is moved to subdirectory', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.EsLint,
});
updateJson(tree, 'libs/my-lib/.eslintrc.json', (eslintRcJson) => {
eslintRcJson.extends = [
'plugin:@nx/react',
'../../.eslintrc.json',
'./customrc.json',
];
return eslintRcJson;
});
// This step is usually handled elsewhere
tree.rename(
'libs/my-lib/.eslintrc.json',
'libs/shared/my-destination/.eslintrc.json'
);
const projectConfig = readProjectConfiguration(tree, 'my-lib');
updateEslintConfig(tree, schema, projectConfig);
expect(
readJson(tree, '/libs/shared/my-destination/.eslintrc.json')
).toEqual(
expect.objectContaining({
extends: [
'plugin:@nx/react',
'../../../.eslintrc.json',
'./customrc.json',
],
})
);
});
it('should update .eslintrc.json overrides parser project when project is moved', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.EsLint,
setParserOptionsProject: true,
});
// This step is usually handled elsewhere
tree.rename(
'libs/my-lib/.eslintrc.json',
'libs/shared/my-destination/.eslintrc.json'
);
const projectConfig = readProjectConfiguration(tree, 'my-lib');
updateEslintConfig(tree, schema, projectConfig);
expect(
readJson(tree, '/libs/shared/my-destination/.eslintrc.json')
).toEqual(
expect.objectContaining({
overrides: expect.arrayContaining([
expect.objectContaining({
parserOptions: expect.objectContaining({
project: ['libs/shared/my-destination/tsconfig.*?.json'],
}),
}),
]),
})
);
});
it('should update multiple .eslintrc.json overrides parser project when project is moved', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.EsLint,
setParserOptionsProject: true,
});
// Add another parser project to eslint.json
const storybookProject = '.storybook/tsconfig.json';
updateJson(tree, '/libs/my-lib/.eslintrc.json', (eslintRcJson) => {
eslintRcJson.overrides[0].parserOptions.project.push(
`libs/my-lib/${storybookProject}`
);
return eslintRcJson;
});
// This step is usually handled elsewhere
tree.rename(
'libs/my-lib/.eslintrc.json',
'libs/shared/my-destination/.eslintrc.json'
);
const projectConfig = readProjectConfiguration(tree, 'my-lib');
updateEslintConfig(tree, schema, projectConfig);
expect(
readJson(tree, '/libs/shared/my-destination/.eslintrc.json')
).toEqual(
expect.objectContaining({
overrides: expect.arrayContaining([
expect.objectContaining({
parserOptions: expect.objectContaining({
project: [
'libs/shared/my-destination/tsconfig.*?.json',
`libs/shared/my-destination/${storybookProject}`,
],
}),
}),
]),
})
);
});
it('should update .eslintrc.json parserOptions.project as a string', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.EsLint,
setParserOptionsProject: true,
});
// Add another parser project to eslint.json
const storybookProject = '.storybook/tsconfig.json';
updateJson(tree, '/libs/my-lib/.eslintrc.json', (eslintRcJson) => {
eslintRcJson.overrides[0].parserOptions.project = `libs/my-lib/${storybookProject}`;
return eslintRcJson;
});
// This step is usually handled elsewhere
tree.rename(
'libs/my-lib/.eslintrc.json',
'libs/shared/my-destination/.eslintrc.json'
);
const projectConfig = readProjectConfiguration(tree, 'my-lib');
updateEslintConfig(tree, schema, projectConfig);
expect(
readJson(tree, '/libs/shared/my-destination/.eslintrc.json').overrides[0]
.parserOptions
).toEqual({ project: `libs/shared/my-destination/${storybookProject}` });
});
});
describe('updateEslint (flat config)', () => {
let tree: Tree;
let schema: NormalizedSchema;
beforeEach(async () => {
schema = {
projectName: 'my-lib',
destination: 'shared/my-destination',
importPath: '@proj/shared-my-destination',
updateImportPath: true,
newProjectName: 'shared-my-destination',
relativeToRootDestination: 'libs/shared/my-destination',
};
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
tree.delete('.eslintrc.json');
tree.write('eslint.config.js', `module.exports = [];`);
});
it('should handle config not existing', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.None,
});
const projectConfig = readProjectConfiguration(tree, 'my-lib');
expect(() => {
updateEslintConfig(tree, schema, projectConfig);
}).not.toThrow();
});
it('should update config extends path when project is moved to subdirectory', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.EsLint,
});
convertToFlat(tree, 'libs/my-lib');
// This step is usually handled elsewhere
tree.rename(
'libs/my-lib/eslint.config.js',
'libs/shared/my-destination/eslint.config.js'
);
const projectConfig = readProjectConfiguration(tree, 'my-lib');
updateEslintConfig(tree, schema, projectConfig);
expect(
tree.read('libs/shared/my-destination/eslint.config.js', 'utf-8')
).toEqual(expect.stringContaining(`require('../../../eslint.config.js')`));
});
it('should update config extends path when project is moved from subdirectory', async () => {
await libraryGenerator(tree, {
name: 'test',
directory: 'api',
linter: Linter.EsLint,
});
convertToFlat(tree, 'libs/api/test');
// This step is usually handled elsewhere
tree.rename('libs/api/test/eslint.config.js', 'libs/test/eslint.config.js');
const projectConfig = readProjectConfiguration(tree, 'api-test');
const newSchema = {
projectName: 'api-test',
destination: 'test',
importPath: '@proj/test',
updateImportPath: true,
newProjectName: 'test',
relativeToRootDestination: 'libs/test',
};
updateEslintConfig(tree, newSchema, projectConfig);
expect(tree.read('libs/test/eslint.config.js', 'utf-8')).toEqual(
expect.stringContaining(`require('../../eslint.config.js')`)
);
});
it('should update config overrides parser project when project is moved', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.EsLint,
setParserOptionsProject: true,
});
convertToFlat(tree, 'libs/my-lib', { hasParser: true });
// This step is usually handled elsewhere
tree.rename(
'libs/my-lib/eslint.config.js',
'libs/shared/my-destination/eslint.config.js'
);
const projectConfig = readProjectConfiguration(tree, 'my-lib');
updateEslintConfig(tree, schema, projectConfig);
expect(
tree.read('libs/shared/my-destination/eslint.config.js', 'utf-8')
).toEqual(
expect.stringContaining(
`project: ["libs/shared/my-destination/tsconfig.*?.json"]`
)
);
});
it('should update multiple config overrides parser project when project is moved', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.EsLint,
setParserOptionsProject: true,
});
// Add another parser project to eslint.json
const storybookProject = '.storybook/tsconfig.json';
convertToFlat(tree, 'libs/my-lib', {
hasParser: true,
anotherProject: storybookProject,
});
// This step is usually handled elsewhere
tree.rename(
'libs/my-lib/eslint.config.js',
'libs/shared/my-destination/eslint.config.js'
);
const projectConfig = readProjectConfiguration(tree, 'my-lib');
updateEslintConfig(tree, schema, projectConfig);
expect(
tree.read('libs/shared/my-destination/eslint.config.js', 'utf-8')
).toEqual(
expect.stringContaining(
`project: ["libs/shared/my-destination/tsconfig.*?.json", "libs/shared/my-destination/${storybookProject}"]`
)
);
});
it('should update config parserOptions.project as a string', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.EsLint,
setParserOptionsProject: true,
});
convertToFlat(tree, 'libs/my-lib', { hasParser: true, isString: true });
// This step is usually handled elsewhere
tree.rename(
'libs/my-lib/eslint.config.js',
'libs/shared/my-destination/eslint.config.js'
);
const projectConfig = readProjectConfiguration(tree, 'my-lib');
updateEslintConfig(tree, schema, projectConfig);
expect(
tree.read('libs/shared/my-destination/eslint.config.js', 'utf-8')
).toEqual(
expect.stringContaining(
`project: "libs/shared/my-destination/tsconfig.*?.json"`
)
);
});
});
function convertToFlat(
tree: Tree,
path: string,
options: {
hasParser?: boolean;
anotherProject?: string;
isString?: boolean;
} = {}
) {
const offset = offsetFromRoot(path);
tree.delete(joinPathFragments(path, '.eslintrc.json'));
let parserOptions = '';
if (options.hasParser) {
const paths = options.anotherProject
? `["${path}/tsconfig.*?.json", "${path}/${options.anotherProject}"]`
: options.isString
? `"${path}/tsconfig.*?.json"`
: `["${path}/tsconfig.*?.json"]`;
parserOptions = `languageOptions: {
parserOptions: {
project: ${paths}
}
},
`;
}
tree.write(
joinPathFragments(path, 'eslint.config.js'),
`const { FlatCompat } = require("@eslint/eslintrc");
const baseConfig = require("${offset}eslint.config.js");
const js = require("@eslint/js");
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
module.exports = [
...baseConfig,
{
files: ["${path}/**/*.ts", "${path}/**/*.tsx", "${path}/**/*.js", "${path}/**/*.jsx"],
${parserOptions}rules: {}
},
{
files: ["${path}/**/*.ts", "${path}/**/*.tsx"],
rules: {}
},
{
files: ["${path}/**/*.js", "${path}/**/*.jsx"],
rules: {}
},
...compat.config({ parser: "jsonc-eslint-parser" }).map(config => ({
...config,
files: ["${path}/**/*.json"],
rules: { "@nx/dependency-checks": "error" }
}))
];`
);
}

View File

@ -0,0 +1,36 @@
import { output, ProjectConfiguration, Tree } from '@nx/devkit';
import { NormalizedSchema } from '../schema';
/**
* Update the .eslintrc file of the project if it exists.
*
* @param schema The options provided to the schematic
*/
export function updateEslintConfig(
tree: Tree,
schema: NormalizedSchema,
project: ProjectConfiguration
) {
// if there is no suitable eslint config, we don't need to do anything
if (!tree.exists('.eslintrc.json') && !tree.exists('eslint.config.js')) {
return;
}
try {
const {
updateRelativePathsInConfig,
// nx-ignore-next-line
} = require('@nx/linter/src/generators/utils/eslint-file');
updateRelativePathsInConfig(
tree,
project.root,
schema.relativeToRootDestination
);
} catch {
output.warn({
title: `Could not update the eslint config file.`,
bodyLines: [
'The @nx/linter package could not be loaded. Please update the paths in eslint config manually.',
],
});
}
}

View File

@ -1,232 +0,0 @@
import {
readJson,
readProjectConfiguration,
Tree,
updateJson,
} from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { Linter } from '../../../utils/lint';
import { NormalizedSchema } from '../schema';
import { updateEslintrcJson } from './update-eslintrc-json';
// nx-ignore-next-line
const { libraryGenerator } = require('@nx/js');
describe('updateEslint', () => {
let tree: Tree;
let schema: NormalizedSchema;
beforeEach(async () => {
schema = {
projectName: 'my-lib',
destination: 'shared/my-destination',
importPath: '@proj/shared-my-destination',
updateImportPath: true,
newProjectName: 'shared-my-destination',
relativeToRootDestination: 'libs/shared/my-destination',
};
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
});
it('should handle .eslintrc.json not existing', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.None,
});
const projectConfig = readProjectConfiguration(tree, 'my-lib');
expect(() => {
updateEslintrcJson(tree, schema, projectConfig);
}).not.toThrow();
});
it('should update .eslintrc.json extends path when project is moved to subdirectory', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.EsLint,
});
// This step is usually handled elsewhere
tree.rename(
'libs/my-lib/.eslintrc.json',
'libs/shared/my-destination/.eslintrc.json'
);
const projectConfig = readProjectConfiguration(tree, 'my-lib');
updateEslintrcJson(tree, schema, projectConfig);
expect(
readJson(tree, '/libs/shared/my-destination/.eslintrc.json')
).toEqual(
expect.objectContaining({
extends: ['../../../.eslintrc.json'],
})
);
});
it('should update .eslintrc.json extends path when project is moved from subdirectory', async () => {
await libraryGenerator(tree, {
name: 'test',
directory: 'api',
linter: Linter.EsLint,
});
// This step is usually handled elsewhere
tree.rename('libs/api/test/.eslintrc.json', 'libs/test/.eslintrc.json');
const projectConfig = readProjectConfiguration(tree, 'api-test');
const newSchema = {
projectName: 'api-test',
destination: 'test',
importPath: '@proj/test',
updateImportPath: true,
newProjectName: 'test',
relativeToRootDestination: 'libs/test',
};
updateEslintrcJson(tree, newSchema, projectConfig);
expect(readJson(tree, '/libs/test/.eslintrc.json')).toEqual(
expect.objectContaining({
extends: ['../../.eslintrc.json'],
})
);
});
it('should preserve .eslintrc.json non-relative extends when project is moved to subdirectory', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.EsLint,
});
updateJson(tree, 'libs/my-lib/.eslintrc.json', (eslintRcJson) => {
eslintRcJson.extends = [
'plugin:@nx/react',
'../../.eslintrc.json',
'./customrc.json',
];
return eslintRcJson;
});
// This step is usually handled elsewhere
tree.rename(
'libs/my-lib/.eslintrc.json',
'libs/shared/my-destination/.eslintrc.json'
);
const projectConfig = readProjectConfiguration(tree, 'my-lib');
updateEslintrcJson(tree, schema, projectConfig);
expect(
readJson(tree, '/libs/shared/my-destination/.eslintrc.json')
).toEqual(
expect.objectContaining({
extends: [
'plugin:@nx/react',
'../../../.eslintrc.json',
'./customrc.json',
],
})
);
});
it('should update .eslintrc.json overrides parser project when project is moved', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.EsLint,
setParserOptionsProject: true,
});
// This step is usually handled elsewhere
tree.rename(
'libs/my-lib/.eslintrc.json',
'libs/shared/my-destination/.eslintrc.json'
);
const projectConfig = readProjectConfiguration(tree, 'my-lib');
updateEslintrcJson(tree, schema, projectConfig);
expect(
readJson(tree, '/libs/shared/my-destination/.eslintrc.json')
).toEqual(
expect.objectContaining({
overrides: expect.arrayContaining([
expect.objectContaining({
parserOptions: expect.objectContaining({
project: ['libs/shared/my-destination/tsconfig.*?.json'],
}),
}),
]),
})
);
});
it('should update multiple .eslintrc.json overrides parser project when project is moved', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.EsLint,
setParserOptionsProject: true,
});
// Add another parser project to eslint.json
const storybookProject = '.storybook/tsconfig.json';
updateJson(tree, '/libs/my-lib/.eslintrc.json', (eslintRcJson) => {
eslintRcJson.overrides[0].parserOptions.project.push(
`libs/my-lib/${storybookProject}`
);
return eslintRcJson;
});
// This step is usually handled elsewhere
tree.rename(
'libs/my-lib/.eslintrc.json',
'libs/shared/my-destination/.eslintrc.json'
);
const projectConfig = readProjectConfiguration(tree, 'my-lib');
updateEslintrcJson(tree, schema, projectConfig);
expect(
readJson(tree, '/libs/shared/my-destination/.eslintrc.json')
).toEqual(
expect.objectContaining({
overrides: expect.arrayContaining([
expect.objectContaining({
parserOptions: expect.objectContaining({
project: [
'libs/shared/my-destination/tsconfig.*?.json',
`libs/shared/my-destination/${storybookProject}`,
],
}),
}),
]),
})
);
});
it('should update .eslintrc.json parserOptions.project as a string', async () => {
await libraryGenerator(tree, {
name: 'my-lib',
linter: Linter.EsLint,
setParserOptionsProject: true,
});
// Add another parser project to eslint.json
const storybookProject = '.storybook/tsconfig.json';
updateJson(tree, '/libs/my-lib/.eslintrc.json', (eslintRcJson) => {
eslintRcJson.overrides[0].parserOptions.project = `libs/my-lib/${storybookProject}`;
return eslintRcJson;
});
// This step is usually handled elsewhere
tree.rename(
'libs/my-lib/.eslintrc.json',
'libs/shared/my-destination/.eslintrc.json'
);
const projectConfig = readProjectConfiguration(tree, 'my-lib');
updateEslintrcJson(tree, schema, projectConfig);
expect(
readJson(tree, '/libs/shared/my-destination/.eslintrc.json').overrides[0]
.parserOptions
).toEqual({ project: `libs/shared/my-destination/${storybookProject}` });
});
});

View File

@ -1,83 +0,0 @@
import {
joinPathFragments,
offsetFromRoot,
ProjectConfiguration,
Tree,
updateJson,
} from '@nx/devkit';
import { join } from 'path';
import { NormalizedSchema } from '../schema';
interface PartialEsLintrcOverride {
parserOptions?: {
project?: string[];
};
}
interface PartialEsLintRcJson {
extends: string | string[];
overrides?: PartialEsLintrcOverride[];
}
function offsetFilePath(
project: ProjectConfiguration,
pathToFile: string,
offset: string
): string {
if (!pathToFile.startsWith('..')) {
// not a relative path
return pathToFile;
}
const pathFromRoot = join(project.root, pathToFile);
return joinPathFragments(offset, pathFromRoot);
}
/**
* Update the .eslintrc file of the project if it exists.
*
* @param schema The options provided to the schematic
*/
export function updateEslintrcJson(
tree: Tree,
schema: NormalizedSchema,
project: ProjectConfiguration
) {
const eslintRcPath = join(schema.relativeToRootDestination, '.eslintrc.json');
if (!tree.exists(eslintRcPath)) {
// no .eslintrc found. nothing to do
return;
}
const offset = offsetFromRoot(schema.relativeToRootDestination);
updateJson<PartialEsLintRcJson>(tree, eslintRcPath, (eslintRcJson) => {
if (typeof eslintRcJson.extends === 'string') {
eslintRcJson.extends = offsetFilePath(
project,
eslintRcJson.extends,
offset
);
} else if (eslintRcJson.extends) {
eslintRcJson.extends = eslintRcJson.extends.map((extend: string) =>
offsetFilePath(project, extend, offset)
);
}
eslintRcJson.overrides?.forEach(
(o: { parserOptions?: { project?: string | string[] } }) => {
if (o.parserOptions?.project) {
o.parserOptions.project = Array.isArray(o.parserOptions.project)
? o.parserOptions.project.map((p) =>
p.replace(project.root, schema.relativeToRootDestination)
)
: o.parserOptions.project.replace(
project.root,
schema.relativeToRootDestination
);
}
}
);
return eslintRcJson;
});
}

View File

@ -56,7 +56,7 @@ export function updateFilesForRootProjects(
if (!allowedExt.includes(ext)) { if (!allowedExt.includes(ext)) {
continue; continue;
} }
if (file === '.eslintrc.json') { if (file === '.eslintrc.json' || file === 'eslint.config.js') {
continue; continue;
} }
@ -108,7 +108,7 @@ export function updateFilesForNonRootProjects(
if (!allowedExt.includes(ext)) { if (!allowedExt.includes(ext)) {
continue; continue;
} }
if (file === '.eslintrc.json') { if (file === '.eslintrc.json' || file === 'eslint.config.js') {
continue; continue;
} }

View File

@ -12,7 +12,7 @@ import { normalizeSchema } from './lib/normalize-schema';
import { updateBuildTargets } from './lib/update-build-targets'; import { updateBuildTargets } from './lib/update-build-targets';
import { updateCypressConfig } from './lib/update-cypress-config'; import { updateCypressConfig } from './lib/update-cypress-config';
import { updateDefaultProject } from './lib/update-default-project'; import { updateDefaultProject } from './lib/update-default-project';
import { updateEslintrcJson } from './lib/update-eslintrc-json'; import { updateEslintConfig } from './lib/update-eslint-config';
import { updateImplicitDependencies } from './lib/update-implicit-dependencies'; import { updateImplicitDependencies } from './lib/update-implicit-dependencies';
import { updateImports } from './lib/update-imports'; import { updateImports } from './lib/update-imports';
import { updateJestConfig } from './lib/update-jest-config'; import { updateJestConfig } from './lib/update-jest-config';
@ -48,7 +48,7 @@ export async function moveGenerator(tree: Tree, rawSchema: Schema) {
updateCypressConfig(tree, schema, projectConfig); updateCypressConfig(tree, schema, projectConfig);
updateJestConfig(tree, schema, projectConfig); updateJestConfig(tree, schema, projectConfig);
updateStorybookConfig(tree, schema, projectConfig); updateStorybookConfig(tree, schema, projectConfig);
updateEslintrcJson(tree, schema, projectConfig); updateEslintConfig(tree, schema, projectConfig);
updateReadme(tree, schema); updateReadme(tree, schema);
updatePackageJson(tree, schema); updatePackageJson(tree, schema);
updateBuildTargets(tree, schema); updateBuildTargets(tree, schema);

44
pnpm-lock.yaml generated
View File

@ -394,9 +394,6 @@ devDependencies:
'@testing-library/react': '@testing-library/react':
specifier: 13.4.0 specifier: 13.4.0
version: 13.4.0(react-dom@18.2.0)(react@18.2.0) version: 13.4.0(react-dom@18.2.0)(react@18.2.0)
'@types/css-minimizer-webpack-plugin':
specifier: ^3.2.1
version: 3.2.1(esbuild@0.17.18)(webpack@5.88.0)
'@types/cytoscape': '@types/cytoscape':
specifier: ^3.18.2 specifier: ^3.18.2
version: 3.19.9 version: 3.19.9
@ -404,8 +401,8 @@ devDependencies:
specifier: ^1.3.2 specifier: ^1.3.2
version: 1.3.2 version: 1.3.2
'@types/eslint': '@types/eslint':
specifier: ~8.4.1 specifier: ~8.44.2
version: 8.4.8 version: 8.44.2
'@types/express': '@types/express':
specifier: 4.17.14 specifier: 4.17.14
version: 4.17.14 version: 4.17.14
@ -10055,21 +10052,6 @@ packages:
'@types/node': 18.16.9 '@types/node': 18.16.9
dev: true dev: true
/@types/css-minimizer-webpack-plugin@3.2.1(esbuild@0.17.18)(webpack@5.88.0):
resolution: {integrity: sha512-MIlnEVQDTX0Y1/ZBY0RyD+F6+ZHlG42qCeSoCVhxI5N1atm+RnmDLQWUCWrdNqebFozUTRLDZJ04v5aYzGG5CA==}
deprecated: This is a stub types definition. css-minimizer-webpack-plugin provides its own type definitions, so you do not need this installed.
dependencies:
css-minimizer-webpack-plugin: 5.0.0(esbuild@0.17.18)(webpack@5.88.0)
transitivePeerDependencies:
- '@parcel/css'
- '@swc/css'
- clean-css
- csso
- esbuild
- lightningcss
- webpack
dev: true
/@types/cytoscape@3.19.9: /@types/cytoscape@3.19.9:
resolution: {integrity: sha512-oqCx0ZGiBO0UESbjgq052vjDAy2X53lZpMrWqiweMpvVwKw/2IiYDdzPFK6+f4tMfdv9YKEM9raO5bAZc3UYBg==} resolution: {integrity: sha512-oqCx0ZGiBO0UESbjgq052vjDAy2X53lZpMrWqiweMpvVwKw/2IiYDdzPFK6+f4tMfdv9YKEM9raO5bAZc3UYBg==}
dev: true dev: true
@ -10099,15 +10081,22 @@ packages:
/@types/eslint-scope@3.7.4: /@types/eslint-scope@3.7.4:
resolution: {integrity: sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==} resolution: {integrity: sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==}
dependencies: dependencies:
'@types/eslint': 8.4.8 '@types/eslint': 8.4.1
'@types/estree': 1.0.1 '@types/estree': 1.0.1
dev: true dev: true
/@types/eslint@8.4.8: /@types/eslint@8.4.1:
resolution: {integrity: sha512-zUCKQI1bUCTi+0kQs5ZQzQ/XILWRLIlh15FXWNykJ+NG3TMKMVvwwC6GP3DR1Ylga15fB7iAExSzc4PNlR5i3w==} resolution: {integrity: sha512-GE44+DNEyxxh2Kc6ro/VkIj+9ma0pO0bwv9+uHSyBrikYOHr8zYcdPvnBOp1aw8s+CjRvuSx7CyWqRrNFQ59mA==}
dependencies: dependencies:
'@types/estree': 1.0.0 '@types/estree': 1.0.1
'@types/json-schema': 7.0.11 '@types/json-schema': 7.0.12
dev: true
/@types/eslint@8.44.2:
resolution: {integrity: sha512-sdPRb9K6iL5XZOmBubg8yiFp5yS/JdUDQsq5e6h95km91MCYMuvp7mh1fjPEYUhvHepKpZOjnEaMBR4PxjWDzg==}
dependencies:
'@types/estree': 1.0.1
'@types/json-schema': 7.0.12
dev: true dev: true
/@types/estree@0.0.39: /@types/estree@0.0.39:
@ -10118,10 +10107,6 @@ packages:
resolution: {integrity: sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==} resolution: {integrity: sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==}
dev: true dev: true
/@types/estree@1.0.0:
resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==}
dev: true
/@types/estree@1.0.1: /@types/estree@1.0.1:
resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==}
dev: true dev: true
@ -10277,6 +10262,7 @@ packages:
/@types/json-schema@7.0.11: /@types/json-schema@7.0.11:
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==} resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
dev: false
/@types/json-schema@7.0.12: /@types/json-schema@7.0.12:
resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==}