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/jest": "^0.2.20",
"@testing-library/react": "13.4.0",
"@types/css-minimizer-webpack-plugin": "^3.2.1",
"@types/cytoscape": "^3.18.2",
"@types/detect-port": "^1.3.2",
"@types/eslint": "~8.4.1",
"@types/eslint": "~8.44.2",
"@types/express": "4.17.14",
"@types/flat": "^5.0.1",
"@types/fs-extra": "^11.0.0",

View File

@ -4,13 +4,17 @@ import {
joinPathFragments,
runTasksInSerial,
Tree,
updateJson,
} from '@nx/devkit';
import { Linter, lintProjectGenerator } from '@nx/linter';
import { mapLintPattern } from '@nx/linter/src/generators/lint-project/lint-project';
import { addAngularEsLintDependencies } from './lib/add-angular-eslint-dependencies';
import { extendAngularEslintJson } from './lib/create-eslint-configuration';
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(
tree: Tree,
@ -35,11 +39,57 @@ export async function addLintingGenerator(
});
tasks.push(lintTask);
updateJson(
tree,
joinPathFragments(options.projectRoot, '.eslintrc.json'),
(json) => extendAngularEslintJson(json, options)
);
if (isEslintConfigSupported(tree)) {
const eslintFile = findEslintFile(tree, options.projectRoot);
// keep parser options if they exist
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) {
const installTask = addAngularEsLintDependencies(tree);

View File

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

View File

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

View File

@ -5,12 +5,19 @@ import {
readProjectConfiguration,
runTasksInSerial,
Tree,
updateJson,
} from '@nx/devkit';
import { Linter, lintProjectGenerator } from '@nx/linter';
import { globalJavaScriptOverrides } from '@nx/linter/src/generators/init/global-eslint-config';
import { installedCypressVersion } from './cypress-version';
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 {
project: string;
@ -42,7 +49,8 @@ export async function addLinterToCyProject(
const tasks: GeneratorCallback[] = [];
const projectConfig = readProjectConfiguration(tree, options.project);
if (!tree.exists(joinPathFragments(projectConfig.root, '.eslintrc.json'))) {
const eslintFile = findEslintFile(tree, projectConfig.root);
if (!eslintFile) {
tasks.push(
await lintProjectGenerator(tree, {
project: options.project,
@ -73,19 +81,33 @@ export async function addLinterToCyProject(
: () => {}
);
updateJson(
tree,
joinPathFragments(projectConfig.root, '.eslintrc.json'),
(json) => {
if (isEslintConfigSupported(tree)) {
const overrides = [];
if (options.rootProject) {
json.plugins = ['@nx'];
json.extends = ['plugin:cypress/recommended'];
} else {
json.extends = ['plugin:cypress/recommended', ...json.extends];
addPluginsToLintConfig(tree, projectConfig.root, '@nx');
overrides.push(javaScriptOverride);
}
json.overrides ??= [];
const globals = options.rootProject ? [globalJavaScriptOverrides] : [];
const override = {
addExtendsToLintConfig(
tree,
projectConfig.root,
'plugin:cypress/recommended'
);
const cyVersion = installedCypressVersion();
/**
* 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'],
parserOptions: !options.setParserOptionsProject
? undefined
@ -93,40 +115,32 @@ export async function addLinterToCyProject(
project: `${projectConfig.root}/tsconfig.*?.json`,
},
rules: {},
};
const cyFiles = [
{
...override,
});
if (addCy6Override) {
overrides.push(cy6Override);
}
replaceOverridesInLintConfig(tree, projectConfig.root, overrides);
} else {
overrides.unshift({
files: [
'*.cy.{ts,js,tsx,jsx}',
`${options.cypressDir}/**/*.{ts,js,tsx,jsx}`,
],
parserOptions: !options.setParserOptionsProject
? undefined
: {
project: `${projectConfig.root}/tsconfig.*?.json`,
},
];
if (options.overwriteExisting) {
json.overrides = [...globals, override];
} else {
json.overrides.push(...globals);
json.overrides.push(...cyFiles);
}
const cyVersion = installedCypressVersion();
if (cyVersion && cyVersion < 7) {
/**
* 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.
*/
json.overrides.push({
files: [`${options.cypressDir}/plugins/index.js`],
rules: {
'@typescript-eslint/no-var-requires': 'off',
'no-undef': 'off',
},
rules: {},
});
if (addCy6Override) {
overrides.push(cy6Override);
}
return json;
}
overrides.forEach((override) =>
addOverrideToLintConfig(tree, projectConfig.root, override)
);
}
}
return runTasksInSerial(...tasks);
}

View File

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

View File

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

View File

@ -256,20 +256,24 @@ export async function addLint(
setParserOptionsProject: options.setParserOptionsProject,
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.
if (options.rootProject) {
updateJson(tree, '.eslintrc.json', (json) => {
json.overrides ??= [];
json.overrides.push({
const {
addOverrideToLintConfig,
isEslintConfigSupported,
// nx-ignore-next-line
} = require('@nx/linter/src/generators/utils/eslint-file');
if (isEslintConfigSupported(tree)) {
addOverrideToLintConfig(tree, '', {
files: ['*.json'],
parser: 'jsonc-eslint-parser',
rules: {
'@nx/dependency-checks': 'error',
},
});
return json;
});
}
}
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 { existsSync, mkdirSync, writeFileSync } from 'fs';
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
* their root ESLint config to use eslint.config.js
*/
const useFlatConfig = existsSync(
joinPathFragments(systemRoot, 'eslint.config.js')
const hasFlatConfig = existsSync(
joinPathFragments(workspaceRoot, 'eslint.config.js')
);
if (!eslintConfigPath && useFlatConfig) {
if (!eslintConfigPath && hasFlatConfig) {
const projectRoot =
context.projectsConfigurations.projects[context.projectName].root;
eslintConfigPath = joinPathFragments(projectRoot, 'eslint.config.js');
@ -59,7 +59,7 @@ export default async function run(
const { eslint, ESLint } = await resolveAndInstantiateESLint(
eslintConfigPath,
normalizedOptions,
useFlatConfig
hasFlatConfig
);
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)
.map((pattern) => `- '${pattern}'`);
if (ignoredPatterns.length) {
const ignoreSection = useFlatConfig
const ignoreSection = hasFlatConfig
? `'ignores' configuration`
: `'.eslintignore' file`;
throw new Error(

View File

@ -19,7 +19,7 @@ export interface Schema extends JsonObject {
cacheStrategy: 'content' | 'metadata' | null;
rulesdir: string[];
resolvePluginsRelativeTo: string | null;
reportUnusedDisableDirectives: Linter.RuleLevel | null;
reportUnusedDisableDirectives: Linter.StringSeverity | 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,
} from '@nx/devkit';
import { join } from 'path';
import { ESLint, Linter } from 'eslint';
import { ESLint } from 'eslint';
import * as ts from 'typescript';
import { generateAst, generateRequire } from './generate-ast';
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.
@ -38,7 +47,16 @@ export function convertEslintJsonToFlatConfig(
}
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) {
@ -129,7 +147,6 @@ export function convertEslintJsonToFlatConfig(
if (config.overrides) {
config.overrides.forEach((override) => {
updateFiles(override, root);
if (
override.env ||
override.extends ||
@ -137,10 +154,8 @@ export function convertEslintJsonToFlatConfig(
override.parser
) {
isFlatCompatNeeded = true;
addFlattenedOverride(override, exportElements);
} else {
exportElements.push(generateAst(override));
}
exportElements.push(generateFlatOverride(override, root));
});
}
@ -179,20 +194,8 @@ export function convertEslintJsonToFlatConfig(
exportElements,
isFlatCompatNeeded
);
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const resultFile = ts.createSourceFile(
join(root, destinationFile),
'',
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const result = printer.printList(
ts.ListFormat.MultiLine,
nodeList,
resultFile
);
tree.write(join(root, destinationFile), result);
const content = stringifyNodeList(nodeList, root, destinationFile);
tree.write(join(root, destinationFile), content);
if (isFlatCompatNeeded) {
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
function addExtends(
importsMap: Map<string, string | string[]>,
@ -254,9 +228,7 @@ function addExtends(
.forEach((imp, index) => {
if (imp.match(/\.eslintrc(.base)?\.json$/)) {
const localName = index ? `baseConfig${index}` : 'baseConfig';
configBlocks.push(
ts.factory.createSpreadElement(ts.factory.createIdentifier(localName))
);
configBlocks.push(generateSpreadElement(localName));
const newImport = imp.replace(
/^(.*)\.eslintrc(.base)?\.json$/,
'$1eslint$2.config.js'
@ -311,36 +283,12 @@ function addExtends(
}
);
const pluginExtendsSpread = ts.factory.createSpreadElement(
ts.factory.createCallExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier('compat'),
ts.factory.createIdentifier('extends')
),
undefined,
eslintrcConfigs.map((plugin) => ts.factory.createStringLiteral(plugin))
)
);
configBlocks.push(pluginExtendsSpread);
configBlocks.push(generatePluginExtendsElement(eslintrcConfigs));
}
return isFlatCompatNeeded;
}
function getPluginImport(pluginName: string): string {
if (pluginName.includes('eslint-plugin-')) {
return pluginName;
}
if (!pluginName.startsWith('@')) {
return `eslint-plugin-${pluginName}`;
}
if (!pluginName.includes('/')) {
return `${pluginName}/eslint-plugin`;
}
const [scope, name] = pluginName.split('/');
return `${scope}/eslint-plugin-${name}`;
}
function addPlugins(
importsMap: Map<string, string | string[]>,
configBlocks: ts.Expression[],
@ -382,143 +330,3 @@ function addPlugins(
);
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.
* See the eslint-plugin package for what is in the referenced shareable config.
*/
export const globalTypeScriptOverrides = {
export const typeScriptOverride = {
files: ['*.ts', '*.tsx'],
extends: ['plugin:@nx/typescript'],
/**
@ -18,7 +27,7 @@ export const globalTypeScriptOverrides = {
* This configuration is intended to apply to all JavaScript source files.
* See the eslint-plugin package for what is in the referenced shareable config.
*/
export const globalJavaScriptOverrides = {
export const javaScriptOverride = {
files: ['*.js', '*.jsx'],
extends: ['plugin:@nx/javascript'],
/**
@ -28,25 +37,11 @@ export const globalJavaScriptOverrides = {
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
* markup like HTML, or other custom file types like GraphQL)
*/
export const moduleBoundariesOverride = {
const moduleBoundariesOverride = {
files: ['*.ts', '*.tsx', '*.js', '*.jsx'],
rules: {
'@nx/enforce-module-boundaries': [
@ -57,14 +52,26 @@ export const moduleBoundariesOverride = {
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 = (
unitTestRunner?: string,
rootProject?: boolean
) => {
const config: LinterType.Config = {
): Linter.Config => {
const config: Linter.Config = {
root: true,
ignorePatterns: rootProject ? ['!**/*'] : ['**/*'],
plugins: ['@nx'],
@ -77,18 +84,44 @@ export const getGlobalEsLintConfiguration = (
*/
overrides: [
...(rootProject ? [] : [moduleBoundariesOverride]),
globalTypeScriptOverrides,
globalJavaScriptOverrides,
typeScriptOverride,
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;
};
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 {
addDependenciesToPackageJson,
joinPathFragments,
offsetFromRoot,
ProjectConfiguration,
@ -8,19 +9,46 @@ import {
writeJson,
} from '@nx/devkit';
import { dirname } from 'path';
import { findEslintFile } from '../utils/eslint-file';
import { getGlobalEsLintConfiguration } from './global-eslint-config';
import { findEslintFile, isEslintConfigSupported } from '../utils/eslint-file';
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(
projects: ProjectConfiguration[],
tree: Tree,
unitTestRunner: string
): void {
if (useFlatConfig(tree)) {
// we need this for the compat
addDependenciesToPackageJson(
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
projects.forEach((project) => {
@ -47,10 +75,32 @@ export function findLintTarget(
}
function migrateEslintFile(projectEslintPath: string, tree: Tree) {
if (
projectEslintPath.endsWith('.json') ||
projectEslintPath.endsWith('.eslintrc')
) {
if (isEslintConfigSupported(tree)) {
if (useFlatConfig(tree)) {
let config = tree.read(projectEslintPath, 'utf-8');
// remove @nx plugin
config = removePlugin(config, '@nx', '@nx/eslint-plugin-nx');
// extend eslint.base.config.js
config = addImportToFlatConfig(
config,
'baseConfig',
`${offsetFromRoot(dirname(projectEslintPath))}eslint.base.config.js`
);
config = addBlockToFlatConfigExport(
config,
generateSpreadElement('baseConfig'),
{ insertAtTheEnd: false }
);
// cleanup file extends
config = removeCompatExtends(config, [
'plugin:@nx/typescript',
'plugin:@nx/javascript',
'plugin:@nrwl/typescript',
'plugin:@nrwl/javascript',
]);
console.warn('Flat eslint config is not supported yet for migration');
tree.write(projectEslintPath, config);
} else {
updateJson(tree, projectEslintPath, (json) => {
// we have a new root now
delete json.root;
@ -90,6 +140,7 @@ function migrateEslintFile(projectEslintPath: string, tree: Tree) {
}
return json;
});
}
return;
}
if (
@ -99,6 +150,6 @@ function migrateEslintFile(projectEslintPath: string, tree: Tree) {
console.warn('YAML eslint config is not supported yet for migration');
}
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,
} from '@nx/devkit';
import { Linter } from '../utils/linter';
import { findEslintFile } from '../utils/eslint-file';
import { Linter as LinterEnum } from '../utils/linter';
import {
baseEsLintConfigFile,
baseEsLintFlatConfigFile,
findEslintFile,
} from '../utils/eslint-file';
import { join } from 'path';
import { lintInitGenerator } from '../init/init';
import type { Linter } from 'eslint';
import {
findLintTarget,
migrateConfigToMonorepoStyle,
} from '../init/init-migration';
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 {
project: string;
linter?: Linter;
linter?: LinterEnum;
eslintFilePatterns?: string[];
tsConfigPaths?: string[];
skipFormat: boolean;
@ -111,13 +123,12 @@ function createEsLintConfiguration(
setParserOptionsProject: boolean
) {
const eslintConfig = findEslintFile(tree);
writeJson(tree, join(projectConfig.root, `.eslintrc.json`), {
extends: eslintConfig
? [`${offsetFromRoot(projectConfig.root)}${eslintConfig}`]
: undefined,
// Include project files to be linted since the global one excludes all files.
ignorePatterns: ['!**/*'],
overrides: [
const pathToRootConfig = eslintConfig
? `${offsetFromRoot(projectConfig.root)}${eslintConfig}`
: undefined;
const addDependencyChecks = isBuildableLibraryProject(projectConfig);
const overrides: Linter.ConfigOverride<Linter.RulesRecord>[] = [
{
files: ['*.ts', '*.tsx', '*.js', '*.jsx'],
/**
@ -159,12 +170,38 @@ function createEsLintConfiguration(
parser: 'jsonc-eslint-parser',
rules: {
'@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(
@ -186,7 +223,10 @@ function isMigrationToMonorepoNeeded(
tree: Tree
): boolean {
// 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;
}

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 = [
'.eslintrc',
@ -7,15 +30,19 @@ export const eslintConfigFileWhitelist = [
'.eslintrc.yaml',
'.eslintrc.yml',
'.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 baseEsLintFlatConfigFile = 'eslint.base.config.js';
export function findEslintFile(tree: Tree, projectRoot = ''): string | null {
if (projectRoot === '' && tree.exists(baseEsLintConfigFile)) {
return baseEsLintConfigFile;
}
if (projectRoot === '' && tree.exists(baseEsLintFlatConfigFile)) {
return baseEsLintFlatConfigFile;
}
for (const file of eslintConfigFileWhitelist) {
if (tree.exists(joinPathFragments(projectRoot, file))) {
return file;
@ -24,3 +51,322 @@ export function findEslintFile(tree: Tree, projectRoot = ''): string | 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
* register it ready for use in .eslintrc.json configs.
* register it ready for use in lint configs.
*/
const pluginIndexPath = joinPathFragments(workspaceLintPluginDir, 'index.ts');
const existingPluginIndexContents = tree.read(pluginIndexPath, 'utf-8');
@ -106,7 +106,7 @@ export async function lintWorkspaceRuleGenerator(
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": {
"@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');
expect(eslintJson).toMatchInlineSnapshot(`
{
"env": {
"jest": true,
},
"extends": [
"plugin:@nx/react-typescript",
"next",
@ -587,6 +584,14 @@ describe('app', () => {
".next/**/*",
],
"overrides": [
{
"files": [
"*.*",
],
"rules": {
"@next/next/no-html-link-for-pages": "off",
},
},
{
"files": [
"*.ts",
@ -615,10 +620,18 @@ describe('app', () => {
],
"rules": {},
},
],
"rules": {
"@next/next/no-html-link-for-pages": "off",
{
"env": {
"jest": true,
},
"files": [
"*.spec.ts",
"*.spec.tsx",
"*.spec.js",
"*.spec.jsx",
],
},
],
}
`);
});

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,
runTasksInSerial,
Tree,
updateJson,
} from '@nx/devkit';
import {
extendReactEslintJson,
extraEslintDependencies,
} from '@nx/react/src/utils/lint';
import { extraEslintDependencies } from '@nx/react/src/utils/lint';
import { NormalizedSchema } from './normalize-options';
import {
addExtendsToLintConfig,
addIgnoresToLintConfig,
addOverrideToLintConfig,
isEslintConfigSupported,
updateOverrideInLintConfig,
} from '@nx/linter/src/generators/utils/eslint-file';
export async function addLinting(
host: Tree,
@ -28,70 +31,56 @@ export async function addLinting(
skipFormat: true,
rootProject: options.rootProject,
});
if (options.linter === Linter.EsLint) {
updateJson(
host,
joinPathFragments(options.appProjectRoot, '.eslintrc.json'),
(json) => {
json = extendReactEslintJson(json);
if (options.linter === Linter.EsLint && isEslintConfigSupported(host)) {
addExtendsToLintConfig(host, options.appProjectRoot, [
'plugin:@nx/react-typescript',
'next',
'next/core-web-vitals',
]);
// 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 = {
addOverrideToLintConfig(
host,
options.appProjectRoot,
{
files: ['*.*'],
rules: {
'@next/next/no-html-link-for-pages': 'off',
...json.rules,
};
// 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)
)
},
},
{ insertAtTheEnd: false }
);
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,
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`,
],
};
}
}
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 = {};
// add jest specific config
if (options.unitTestRunner === 'jest') {
addOverrideToLintConfig(host, options.appProjectRoot, {
files: ['*.spec.ts', '*.spec.tsx', '*.spec.js', '*.spec.jsx'],
env: {
jest: true,
},
});
}
json.env.jest = true;
return json;
}
);
addIgnoresToLintConfig(host, options.appProjectRoot, ['.next/**/*']);
}
const installTask = addDependenciesToPackageJson(

View File

@ -11,18 +11,21 @@ import {
readProjectConfiguration,
runTasksInSerial,
Tree,
updateJson,
} from '@nx/devkit';
import { determineProjectNameAndRootOptions } from '@nx/devkit/src/generators/project-name-and-root-utils';
import { Linter, lintProjectGenerator } from '@nx/linter';
import {
globalJavaScriptOverrides,
globalTypeScriptOverrides,
javaScriptOverride,
typeScriptOverride,
} from '@nx/linter/src/generators/init/global-eslint-config';
import * as path from 'path';
import { join } from 'path';
import { axiosVersion } from '../../utils/versions';
import { Schema } from './schema';
import {
addPluginsToLintConfig,
isEslintConfigSupported,
replaceOverridesInLintConfig,
} from '@nx/linter/src/generators/utils/eslint-file';
export async function e2eProjectGenerator(host: Tree, options: Schema) {
return await e2eProjectGeneratorInternal(host, {
@ -119,32 +122,13 @@ export async function e2eProjectGeneratorInternal(
});
tasks.push(linterTask);
updateJson(host, join(options.e2eProjectRoot, '.eslintrc.json'), (json) => {
if (options.rootProject) {
json.plugins = ['@nx'];
json.extends = [];
if (options.rootProject && isEslintConfigSupported(host)) {
addPluginsToLintConfig(host, options.e2eProjectRoot, '@nx');
replaceOverridesInLintConfig(host, options.e2eProjectRoot, [
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) {

View File

@ -217,7 +217,10 @@ function projectHasKarmaConfig(
function projectHasEslintConfig(
project: AngularJsonProjectConfiguration
): 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 {

View File

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

View File

@ -8,18 +8,22 @@ import {
readProjectConfiguration,
TargetConfiguration,
Tree,
updateJson,
updateProjectConfiguration,
writeJson,
} from '@nx/devkit';
import type { Linter as ESLint } from 'eslint';
import type { Schema as EsLintExecutorOptions } from '@nx/linter/src/executors/eslint/schema';
import { PluginLintChecksGeneratorSchema } from './schema';
import { NX_PREFIX } from 'nx/src/utils/logger';
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(
host: Tree,
@ -101,22 +105,19 @@ export function addMigrationJsonChecks(
updateProjectConfiguration(host, options.projectName, projectConfiguration);
// Update project level eslintrc
updateJson<ESLint.Config>(
updateOverrideInLintConfig(
host,
`${projectConfiguration.root}/.eslintrc.json`,
(c) => {
const override = c.overrides.find(
projectConfiguration.root,
(o) =>
Object.keys(o.rules ?? {})?.includes('@nx/nx-plugin-checks') ||
Object.keys(o.rules ?? {})?.includes('@nrwl/nx/nx-plugin-checks')
);
if (
Array.isArray(override?.files) &&
!override.files.includes(relativeMigrationsJsonPath)
) {
override.files.push(relativeMigrationsJsonPath);
}
return c;
Object.keys(o.rules ?? {})?.includes('@nrwl/nx/nx-plugin-checks'),
(o) => {
const fileSet = new Set(Array.isArray(o.files) ? o.files : [o.files]);
fileSet.add(relativeMigrationsJsonPath);
return {
...o,
files: Array.from(fileSet),
};
}
);
}
@ -179,42 +180,49 @@ function updateProjectEslintConfig(
options: ProjectConfiguration,
packageJson: PackageJson
) {
// Update the project level lint configuration to specify
// the plugin schema rule for generated files
const eslintPath = `${options.root}/.eslintrc.json`;
if (host.exists(eslintPath)) {
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 ?? []),
...[
if (isEslintConfigSupported(host, options.root)) {
const lookup = (o) =>
Object.keys(o.rules ?? {}).includes('@nx/nx-plugin-checks') ||
Object.keys(o.rules ?? {}).includes('@nrwl/nx/nx-plugin-checks');
const files = [
'./package.json',
packageJson.generators,
packageJson.executors,
packageJson.schematics,
packageJson.builders,
].filter((f) => !!f),
].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,
]),
];
entry.parser = 'jsonc-eslint-parser';
entry.rules ??= {
],
...parser,
rules: {
...o.rules,
'@nx/nx-plugin-checks': 'error',
};
if (newentry) {
eslintConfig.overrides.push(entry);
},
}));
} 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
// will display false errors in the IDE
function updateRootEslintConfig(host: Tree) {
if (host.exists('.eslintrc.json')) {
const rootESLint = readJson<ESLint.Config>(host, '.eslintrc.json');
rootESLint.overrides ??= [];
if (!eslintConfigContainsJsonOverride(rootESLint)) {
rootESLint.overrides.push({
if (isEslintConfigSupported(host)) {
if (
!lintConfigHasOverride(
host,
'',
(o) =>
Array.isArray(o.files)
? o.files.some((f) => f.match(/\.json$/))
: !!o.files?.match(/\.json$/),
true
)
) {
addOverrideToLintConfig(
host,
'',
{
files: '*.json',
parser: 'jsonc-eslint-parser',
rules: {},
});
writeJson(host, '.eslintrc.json', rootESLint);
},
{ checkBaseConfig: true }
);
}
} else {
output.note({
title: 'Unable to update root eslint config.',
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.',
'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);
}
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) {
return !!getEsLintOptions(project);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,7 +28,8 @@ import {
import { StorybookConfigureSchema } from '../schema';
import { UiFramework7 } from '../../../utils/models';
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;
@ -365,7 +366,7 @@ export function configureTsSolutionConfig(
* which includes *.stories files.
*
* 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) {
const { name: projectName } = schema;
@ -382,18 +383,46 @@ export function updateLintConfig(tree: Tree, schema: StorybookConfigureSchema) {
]);
});
if (tree.exists(join(root, '.eslintrc.json'))) {
updateJson(tree, join(root, '.eslintrc.json'), (json) => {
const eslintFile = findEslintFile(tree, root);
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') {
json.parserOptions.project = [json.parserOptions.project];
}
if (Array.isArray(json.parserOptions?.project)) {
if (json.parserOptions?.project) {
json.parserOptions.project = dedupe([
...json.parserOptions.project,
schema.uiFramework === '@storybook/angular'
? join(root, '.storybook/tsconfig.json')
: join(root, 'tsconfig.storybook.json'),
parserConfigPath,
]);
}
@ -402,12 +431,10 @@ export function updateLintConfig(tree: Tree, schema: StorybookConfigureSchema) {
if (typeof o.parserOptions?.project === 'string') {
o.parserOptions.project = [o.parserOptions.project];
}
if (Array.isArray(o.parserOptions?.project)) {
if (o.parserOptions?.project) {
o.parserOptions.project = dedupe([
...o.parserOptions.project,
schema.uiFramework === '@storybook/angular'
? join(root, '.storybook/tsconfig.json')
: join(root, 'tsconfig.storybook.json'),
parserConfigPath,
]);
}
}
@ -751,17 +778,13 @@ export function renameAndMoveOldTsConfig(
});
}
const projectEsLintFile = joinPathFragments(projectRoot, '.eslintrc.json');
if (tree.exists(projectEsLintFile)) {
updateJson(tree, projectEsLintFile, (json) => {
const jsonString = JSON.stringify(json);
const newJsonString = jsonString.replace(
/\.storybook\/tsconfig\.json/g,
'tsconfig.storybook.json'
const eslintFile = findEslintFile(tree, projectRoot);
if (eslintFile) {
const fileName = joinPathFragments(projectRoot, eslintFile);
const config = tree.read(fileName, 'utf-8');
tree.write(
fileName,
config.replace(/\.storybook\/tsconfig\.json/g, 'tsconfig.storybook.json')
);
json = JSON.parse(newJsonString);
return json;
});
}
}

View File

@ -24,6 +24,7 @@ export function moveProjectFiles(
'tsconfig.spec.json',
'.babelrc',
'.eslintrc.json',
'eslint.config.js',
/^jest\.config\.(app|lib)\.[jt]s$/,
'vite.config.ts',
/^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)) {
continue;
}
if (file === '.eslintrc.json') {
if (file === '.eslintrc.json' || file === 'eslint.config.js') {
continue;
}
@ -108,7 +108,7 @@ export function updateFilesForNonRootProjects(
if (!allowedExt.includes(ext)) {
continue;
}
if (file === '.eslintrc.json') {
if (file === '.eslintrc.json' || file === 'eslint.config.js') {
continue;
}

View File

@ -12,7 +12,7 @@ import { normalizeSchema } from './lib/normalize-schema';
import { updateBuildTargets } from './lib/update-build-targets';
import { updateCypressConfig } from './lib/update-cypress-config';
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 { updateImports } from './lib/update-imports';
import { updateJestConfig } from './lib/update-jest-config';
@ -48,7 +48,7 @@ export async function moveGenerator(tree: Tree, rawSchema: Schema) {
updateCypressConfig(tree, schema, projectConfig);
updateJestConfig(tree, schema, projectConfig);
updateStorybookConfig(tree, schema, projectConfig);
updateEslintrcJson(tree, schema, projectConfig);
updateEslintConfig(tree, schema, projectConfig);
updateReadme(tree, schema);
updatePackageJson(tree, schema);
updateBuildTargets(tree, schema);

44
pnpm-lock.yaml generated
View File

@ -394,9 +394,6 @@ devDependencies:
'@testing-library/react':
specifier: 13.4.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':
specifier: ^3.18.2
version: 3.19.9
@ -404,8 +401,8 @@ devDependencies:
specifier: ^1.3.2
version: 1.3.2
'@types/eslint':
specifier: ~8.4.1
version: 8.4.8
specifier: ~8.44.2
version: 8.44.2
'@types/express':
specifier: 4.17.14
version: 4.17.14
@ -10055,21 +10052,6 @@ packages:
'@types/node': 18.16.9
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:
resolution: {integrity: sha512-oqCx0ZGiBO0UESbjgq052vjDAy2X53lZpMrWqiweMpvVwKw/2IiYDdzPFK6+f4tMfdv9YKEM9raO5bAZc3UYBg==}
dev: true
@ -10099,15 +10081,22 @@ packages:
/@types/eslint-scope@3.7.4:
resolution: {integrity: sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==}
dependencies:
'@types/eslint': 8.4.8
'@types/eslint': 8.4.1
'@types/estree': 1.0.1
dev: true
/@types/eslint@8.4.8:
resolution: {integrity: sha512-zUCKQI1bUCTi+0kQs5ZQzQ/XILWRLIlh15FXWNykJ+NG3TMKMVvwwC6GP3DR1Ylga15fB7iAExSzc4PNlR5i3w==}
/@types/eslint@8.4.1:
resolution: {integrity: sha512-GE44+DNEyxxh2Kc6ro/VkIj+9ma0pO0bwv9+uHSyBrikYOHr8zYcdPvnBOp1aw8s+CjRvuSx7CyWqRrNFQ59mA==}
dependencies:
'@types/estree': 1.0.0
'@types/json-schema': 7.0.11
'@types/estree': 1.0.1
'@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
/@types/estree@0.0.39:
@ -10118,10 +10107,6 @@ packages:
resolution: {integrity: sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==}
dev: true
/@types/estree@1.0.0:
resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==}
dev: true
/@types/estree@1.0.1:
resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==}
dev: true
@ -10277,6 +10262,7 @@ packages:
/@types/json-schema@7.0.11:
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
dev: false
/@types/json-schema@7.0.12:
resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==}