From e34219ab96e3485d00c41216087e5ca6831d2de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Jona=C5=A1?= Date: Wed, 23 Aug 2023 01:36:58 +0200 Subject: [PATCH] feat(linter): add flat config support to generators (#18534) --- package.json | 3 +- .../src/generators/add-linting/add-linting.ts | 64 +- .../lib/create-eslint-configuration.ts | 3 + .../configuration/configuration.spec.ts | 1 + packages/cypress/src/utils/add-linter.ts | 108 ++- .../generators/application/lib/add-linting.ts | 15 +- packages/expo/src/utils/add-linting.ts | 38 +- packages/js/src/generators/library/library.ts | 16 +- .../linter/src/executors/eslint/lint.impl.ts | 12 +- .../linter/src/executors/eslint/schema.d.ts | 2 +- .../converters/generate-ast.ts | 68 -- .../converters/json-converter.ts | 244 +---- .../generators/init/global-eslint-config.ts | 95 +- .../src/generators/init/init-migration.ts | 149 ++- .../generators/lint-project/lint-project.ts | 156 +-- .../src/generators/utils/eslint-file.ts | 350 ++++++- .../utils/flat-config/ast-utils.spec.ts | 836 +++++++++++++++++ .../generators/utils/flat-config/ast-utils.ts | 886 ++++++++++++++++++ .../utils/flat-config/path-utils.ts | 31 + .../workspace-rule/workspace-rule.ts | 4 +- packages/linter/src/utils/flat-config.ts | 5 + .../application/application.spec.ts | 25 +- .../application/lib/add-linting.spec.ts | 181 ++++ .../generators/application/lib/add-linting.ts | 119 ++- .../src/generators/e2e-project/e2e-project.ts | 44 +- .../angular/standalone-workspace.ts | 5 +- packages/playwright/src/utils/add-linter.ts | 63 +- .../src/generators/lint-checks/generator.ts | 155 +-- .../react-native/src/utils/add-linting.ts | 35 +- .../src/generators/application/application.ts | 18 +- .../src/generators/application/lib/add-e2e.ts | 1 - .../src/generators/library/lib/add-linting.ts | 16 +- packages/react/src/utils/lint.ts | 6 +- .../configuration/lib/util-functions.ts | 81 +- .../generators/move/lib/move-project-files.ts | 1 + .../move/lib/update-eslint-config.spec.ts | 455 +++++++++ .../move/lib/update-eslint-config.ts | 36 + .../move/lib/update-eslintrc-json.spec.ts | 232 ----- .../move/lib/update-eslintrc-json.ts | 83 -- .../move/lib/update-project-root-files.ts | 4 +- .../workspace/src/generators/move/move.ts | 4 +- pnpm-lock.yaml | 44 +- 42 files changed, 3537 insertions(+), 1157 deletions(-) delete mode 100644 packages/linter/src/generators/convert-to-flat-config/converters/generate-ast.ts create mode 100644 packages/linter/src/generators/utils/flat-config/ast-utils.spec.ts create mode 100644 packages/linter/src/generators/utils/flat-config/ast-utils.ts create mode 100644 packages/linter/src/generators/utils/flat-config/path-utils.ts create mode 100644 packages/linter/src/utils/flat-config.ts create mode 100644 packages/next/src/generators/application/lib/add-linting.spec.ts create mode 100644 packages/workspace/src/generators/move/lib/update-eslint-config.spec.ts create mode 100644 packages/workspace/src/generators/move/lib/update-eslint-config.ts delete mode 100644 packages/workspace/src/generators/move/lib/update-eslintrc-json.spec.ts delete mode 100644 packages/workspace/src/generators/move/lib/update-eslintrc-json.ts diff --git a/package.json b/package.json index 9cb43050fdd34..af24cde847c7e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/angular/src/generators/add-linting/add-linting.ts b/packages/angular/src/generators/add-linting/add-linting.ts index c6509bfe4a35c..8ac03674e1525 100755 --- a/packages/angular/src/generators/add-linting/add-linting.ts +++ b/packages/angular/src/generators/add-linting/add-linting.ts @@ -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); diff --git a/packages/angular/src/generators/add-linting/lib/create-eslint-configuration.ts b/packages/angular/src/generators/add-linting/lib/create-eslint-configuration.ts index 5a1b791571b70..eed5f8f3ad672 100644 --- a/packages/angular/src/generators/add-linting/lib/create-eslint-configuration.ts +++ b/packages/angular/src/generators/add-linting/lib/create-eslint-configuration.ts @@ -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 diff --git a/packages/cypress/src/generators/configuration/configuration.spec.ts b/packages/cypress/src/generators/configuration/configuration.spec.ts index db32cf1cba186..68c0ed93813eb 100644 --- a/packages/cypress/src/generators/configuration/configuration.spec.ts +++ b/packages/cypress/src/generators/configuration/configuration.spec.ts @@ -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(() => { diff --git a/packages/cypress/src/utils/add-linter.ts b/packages/cypress/src/utils/add-linter.ts index 3a476c69c2094..7f333f7b5c96d 100644 --- a/packages/cypress/src/utils/add-linter.ts +++ b/packages/cypress/src/utils/add-linter.ts @@ -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 (options.rootProject) { - json.plugins = ['@nx']; - json.extends = ['plugin:cypress/recommended']; - } else { - json.extends = ['plugin:cypress/recommended', ...json.extends]; - } - json.overrides ??= []; - const globals = options.rootProject ? [globalJavaScriptOverrides] : []; - const override = { + if (isEslintConfigSupported(tree)) { + const overrides = []; + if (options.rootProject) { + addPluginsToLintConfig(tree, projectConfig.root, '@nx'); + overrides.push(javaScriptOverride); + } + 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, - files: [ - '*.cy.{ts,js,tsx,jsx}', - `${options.cypressDir}/**/*.{ts,js,tsx,jsx}`, - ], - }, - ]; - if (options.overwriteExisting) { - json.overrides = [...globals, override]; - } else { - json.overrides.push(...globals); - json.overrides.push(...cyFiles); + }); + if (addCy6Override) { + overrides.push(cy6Override); } - - 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', - }, - }); + 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`, + }, + rules: {}, + }); + if (addCy6Override) { + overrides.push(cy6Override); } - return json; + overrides.forEach((override) => + addOverrideToLintConfig(tree, projectConfig.root, override) + ); } - ); + } return runTasksInSerial(...tasks); } diff --git a/packages/detox/src/generators/application/lib/add-linting.ts b/packages/detox/src/generators/application/lib/add-linting.ts index d1b578bcf05e9..180ecc0d7f124 100644 --- a/packages/detox/src/generators/application/lib/add-linting.ts +++ b/packages/detox/src/generators/application/lib/add-linting.ts @@ -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, diff --git a/packages/expo/src/utils/add-linting.ts b/packages/expo/src/utils/add-linting.ts index 438d91f183be6..884f822776eb9 100644 --- a/packages/expo/src/utils/add-linting.ts +++ b/packages/expo/src/utils/add-linting.ts @@ -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, - '.expo', - 'node_modules', - 'web-build', - 'cache', - 'dist', - ]; - - return json; - } - ); + if (isEslintConfigSupported(host)) { + addExtendsToLintConfig(host, options.projectRoot, 'plugin:@nx/react'); + addIgnoresToLintConfig(host, options.projectRoot, [ + '.expo', + 'web-build', + 'cache', + 'dist', + ]); + } if (!options.skipPackageJson) { const installTask = await addDependenciesToPackageJson( diff --git a/packages/js/src/generators/library/library.ts b/packages/js/src/generators/library/library.ts index 362e302e672db..810e7dc4a7344 100644 --- a/packages/js/src/generators/library/library.ts +++ b/packages/js/src/generators/library/library.ts @@ -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; } diff --git a/packages/linter/src/executors/eslint/lint.impl.ts b/packages/linter/src/executors/eslint/lint.impl.ts index f7e7721ad729e..e4953f806ef7d 100644 --- a/packages/linter/src/executors/eslint/lint.impl.ts +++ b/packages/linter/src/executors/eslint/lint.impl.ts @@ -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( diff --git a/packages/linter/src/executors/eslint/schema.d.ts b/packages/linter/src/executors/eslint/schema.d.ts index af1d5bdf3ce47..75f3383d2041d 100644 --- a/packages/linter/src/executors/eslint/schema.d.ts +++ b/packages/linter/src/executors/eslint/schema.d.ts @@ -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; } diff --git a/packages/linter/src/generators/convert-to-flat-config/converters/generate-ast.ts b/packages/linter/src/generators/convert-to-flat-config/converters/generate-ast.ts deleted file mode 100644 index 5a956bc2ba051..0000000000000 --- a/packages/linter/src/generators/convert-to-flat-config/converters/generate-ast.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as ts from 'typescript'; - -/** - * Generates an AST from a JSON-type input - */ -export function generateAst(input: unknown): T { - if (Array.isArray(input)) { - return ts.factory.createArrayLiteralExpression( - input.map((item) => generateAst(item)), - input.length > 1 // multiline only if more than one item - ) as T; - } - if (input === null) { - return ts.factory.createNull() as T; - } - if (typeof input === 'object') { - return ts.factory.createObjectLiteralExpression( - Object.entries(input) - .filter(([_, value]) => value !== undefined) - .map(([key, value]) => - ts.factory.createPropertyAssignment( - isValidKey(key) ? key : ts.factory.createStringLiteral(key), - generateAst(value) - ) - ), - Object.keys(input).length > 1 // multiline only if more than one property - ) as T; - } - if (typeof input === 'string') { - return ts.factory.createStringLiteral(input) as T; - } - if (typeof input === 'number') { - return ts.factory.createNumericLiteral(input) as T; - } - if (typeof input === 'boolean') { - return (input ? ts.factory.createTrue() : ts.factory.createFalse()) as T; - } - // since we are parsing JSON, this should never happen - throw new Error(`Unknown type: ${typeof input}`); -} - -export function generateRequire( - variableName: string | ts.ObjectBindingPattern, - imp: string -): ts.VariableStatement { - return ts.factory.createVariableStatement( - undefined, - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - variableName, - undefined, - undefined, - ts.factory.createCallExpression( - ts.factory.createIdentifier('require'), - undefined, - [ts.factory.createStringLiteral(imp)] - ) - ), - ], - ts.NodeFlags.Const - ) - ); -} - -function isValidKey(key: string): boolean { - return /^[a-zA-Z0-9_]+$/.test(key); -} diff --git a/packages/linter/src/generators/convert-to-flat-config/converters/json-converter.ts b/packages/linter/src/generators/convert-to-flat-config/converters/json-converter.ts index a745f4efb38ec..87cdee1e54680 100644 --- a/packages/linter/src/generators/convert-to-flat-config/converters/json-converter.ts +++ b/packages/linter/src/generators/convert-to-flat-config/converters/json-converter.ts @@ -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, - 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, @@ -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, configBlocks: ts.Expression[], @@ -382,143 +330,3 @@ function addPlugins( ); configBlocks.push(pluginsAst); } - -function addParser( - importsMap: Map, - config: ESLint.ConfigData -): ts.PropertyAssignment { - const imp = config.parser; - const parserName = names(imp).propertyName; - importsMap.set(imp, parserName); - - return ts.factory.createPropertyAssignment( - 'parser', - ts.factory.createIdentifier(parserName) - ); -} - -function addFlattenedOverride( - override: Linter.ConfigOverride, - configBlocks: ts.Expression[] -) { - const { files, excludedFiles, rules, ...rest } = override; - - const objectLiteralElements: ts.ObjectLiteralElementLike[] = [ - ts.factory.createSpreadAssignment(ts.factory.createIdentifier('config')), - ]; - if (files) { - objectLiteralElements.push( - ts.factory.createPropertyAssignment('files', generateAst(files)) - ); - } - if (excludedFiles) { - objectLiteralElements.push( - ts.factory.createPropertyAssignment( - 'excludedFiles', - generateAst(excludedFiles) - ) - ); - } - if (rules) { - objectLiteralElements.push( - ts.factory.createPropertyAssignment('rules', generateAst(rules)) - ); - } - - const overrideSpread = ts.factory.createSpreadElement( - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('compat'), - ts.factory.createIdentifier('config') - ), - undefined, - [generateAst(rest)] - ), - ts.factory.createIdentifier('map') - ), - undefined, - [ - ts.factory.createArrowFunction( - undefined, - undefined, - [ - ts.factory.createParameterDeclaration( - undefined, - undefined, - 'config' - ), - ], - undefined, - ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - ts.factory.createParenthesizedExpression( - ts.factory.createObjectLiteralExpression( - objectLiteralElements, - true - ) - ) - ), - ] - ) - ); - configBlocks.push(overrideSpread); -} - -const DEFAULT_FLAT_CONFIG = ` -const compat = new FlatCompat({ - baseDirectory: __dirname, - recommendedConfig: js.configs.recommended, -}); -`; - -function createNodeList( - importsMap: Map, - exportElements: ts.Expression[], - isFlatCompatNeeded: boolean -): ts.NodeArray< - ts.VariableStatement | ts.Identifier | ts.ExpressionStatement | ts.SourceFile -> { - const importsList = []; - if (isFlatCompatNeeded) { - importsMap.set('@eslint/js', 'js'); - - importsList.push( - generateRequire( - ts.factory.createObjectBindingPattern([ - ts.factory.createBindingElement(undefined, undefined, 'FlatCompat'), - ]), - '@eslint/eslintrc' - ) - ); - } - - // generateRequire(varName, imp, ts.factory); - Array.from(importsMap.entries()).forEach(([imp, varName]) => { - importsList.push(generateRequire(varName, imp)); - }); - - return ts.factory.createNodeArray([ - // add plugin imports - ...importsList, - ts.createSourceFile( - '', - isFlatCompatNeeded ? DEFAULT_FLAT_CONFIG : '', - ts.ScriptTarget.Latest, - false, - ts.ScriptKind.JS - ), - // creates: - // module.exports = [ ... ]; - ts.factory.createExpressionStatement( - ts.factory.createBinaryExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('module'), - ts.factory.createIdentifier('exports') - ), - ts.factory.createToken(ts.SyntaxKind.EqualsToken), - ts.factory.createArrayLiteralExpression(exportElements, true) - ) - ), - ]); -} diff --git a/packages/linter/src/generators/init/global-eslint-config.ts b/packages/linter/src/generators/init/global-eslint-config.ts index b3d0d3bb9e934..464a080a73f10 100644 --- a/packages/linter/src/generators/init/global-eslint-config.ts +++ b/packages/linter/src/generators/init/global-eslint-config.ts @@ -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] : []), ], }; + 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') { - config.overrides.push({ - files: ['*.spec.ts', '*.spec.tsx', '*.spec.js', '*.spec.jsx'], - env: { - jest: true, - }, - rules: {}, - }); + content = addBlockToFlatConfigExport( + content, + generateFlatOverride(jestOverride, '') + ); } - return config; + + return content; }; diff --git a/packages/linter/src/generators/init/init-migration.ts b/packages/linter/src/generators/init/init-migration.ts index 1951efab243f3..c7205f8ef6288 100644 --- a/packages/linter/src/generators/init/init-migration.ts +++ b/packages/linter/src/generators/init/init-migration.ts @@ -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 { - writeJson( - tree, - '.eslintrc.base.json', - getGlobalEsLintConfiguration(unitTestRunner) - ); + 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,49 +75,72 @@ export function findLintTarget( } function migrateEslintFile(projectEslintPath: string, tree: Tree) { - if ( - projectEslintPath.endsWith('.json') || - projectEslintPath.endsWith('.eslintrc') - ) { - updateJson(tree, projectEslintPath, (json) => { - // we have a new root now - delete json.root; - // remove nrwl/nx plugins - if (json.plugins) { - json.plugins = json.plugins.filter( - (p) => p !== '@nx' && p !== '@nrwl/nx' - ); - if (json.plugins.length === 0) { - delete json.plugins; + 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; + // remove nrwl/nx plugins + if (json.plugins) { + json.plugins = json.plugins.filter( + (p) => p !== '@nx' && p !== '@nrwl/nx' + ); + if (json.plugins.length === 0) { + delete json.plugins; + } } - } - // add extends - json.extends = json.extends || []; - const pathToRootConfig = `${offsetFromRoot( - dirname(projectEslintPath) - )}.eslintrc.base.json`; - if (json.extends.indexOf(pathToRootConfig) === -1) { - json.extends.push(pathToRootConfig); - } - // cleanup overrides - if (json.overrides) { - json.overrides.forEach((override) => { - if (override.extends) { - override.extends = override.extends.filter( - (ext) => - ext !== 'plugin:@nx/typescript' && - ext !== 'plugin:@nrwl/nx/typescript' && - ext !== 'plugin:@nx/javascript' && - ext !== 'plugin:@nrwl/nx/javascript' - ); - if (override.extends.length === 0) { - delete override.extends; + // add extends + json.extends = json.extends || []; + const pathToRootConfig = `${offsetFromRoot( + dirname(projectEslintPath) + )}.eslintrc.base.json`; + if (json.extends.indexOf(pathToRootConfig) === -1) { + json.extends.push(pathToRootConfig); + } + // cleanup overrides + if (json.overrides) { + json.overrides.forEach((override) => { + if (override.extends) { + override.extends = override.extends.filter( + (ext) => + ext !== 'plugin:@nx/typescript' && + ext !== 'plugin:@nrwl/nx/typescript' && + ext !== 'plugin:@nx/javascript' && + ext !== 'plugin:@nrwl/nx/javascript' + ); + if (override.extends.length === 0) { + delete override.extends; + } } - } - }); - } - return json; - }); + }); + } + return 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'); } } diff --git a/packages/linter/src/generators/lint-project/lint-project.ts b/packages/linter/src/generators/lint-project/lint-project.ts index 0b517238189ed..0cc2012f6b068 100644 --- a/packages/linter/src/generators/lint-project/lint-project.ts +++ b/packages/linter/src/generators/lint-project/lint-project.ts @@ -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,60 +123,85 @@ 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: [ - { - files: ['*.ts', '*.tsx', '*.js', '*.jsx'], - /** - * NOTE: We no longer set parserOptions.project by default when creating new projects. - * - * We have observed that users rarely add rules requiring type-checking to their Nx workspaces, and therefore - * do not actually need the capabilites which parserOptions.project provides. When specifying parserOptions.project, - * typescript-eslint needs to create full TypeScript Programs for you. When omitting it, it can perform a simple - * parse (and AST tranformation) of the source files it encounters during a lint run, which is much faster and much - * less memory intensive. - * - * In the rare case that users attempt to add rules requiring type-checking to their setup later on (and haven't set - * parserOptions.project), the executor will attempt to look for the particular error typescript-eslint gives you - * and provide feedback to the user. - */ - parserOptions: !setParserOptionsProject - ? undefined - : { - project: [`${projectConfig.root}/tsconfig.*?.json`], - }, - /** - * Having an empty rules object present makes it more obvious to the user where they would - * extend things from if they needed to - */ - rules: {}, - }, - { - files: ['*.ts', '*.tsx'], - rules: {}, - }, - { - files: ['*.js', '*.jsx'], - rules: {}, - }, - ...(isBuildableLibraryProject(projectConfig) - ? [ - { - files: ['*.json'], - parser: 'jsonc-eslint-parser', - rules: { - '@nx/dependency-checks': 'error', - }, - }, - ] - : []), - ], - }); + const pathToRootConfig = eslintConfig + ? `${offsetFromRoot(projectConfig.root)}${eslintConfig}` + : undefined; + const addDependencyChecks = isBuildableLibraryProject(projectConfig); + + const overrides: Linter.ConfigOverride[] = [ + { + files: ['*.ts', '*.tsx', '*.js', '*.jsx'], + /** + * NOTE: We no longer set parserOptions.project by default when creating new projects. + * + * We have observed that users rarely add rules requiring type-checking to their Nx workspaces, and therefore + * do not actually need the capabilites which parserOptions.project provides. When specifying parserOptions.project, + * typescript-eslint needs to create full TypeScript Programs for you. When omitting it, it can perform a simple + * parse (and AST tranformation) of the source files it encounters during a lint run, which is much faster and much + * less memory intensive. + * + * In the rare case that users attempt to add rules requiring type-checking to their setup later on (and haven't set + * parserOptions.project), the executor will attempt to look for the particular error typescript-eslint gives you + * and provide feedback to the user. + */ + parserOptions: !setParserOptionsProject + ? undefined + : { + project: [`${projectConfig.root}/tsconfig.*?.json`], + }, + /** + * Having an empty rules object present makes it more obvious to the user where they would + * extend things from if they needed to + */ + rules: {}, + }, + { + files: ['*.ts', '*.tsx'], + rules: {}, + }, + { + files: ['*.js', '*.jsx'], + rules: {}, + }, + ...(isBuildableLibraryProject(projectConfig) + ? [ + { + files: ['*.json'], + 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; } diff --git a/packages/linter/src/generators/utils/eslint-file.ts b/packages/linter/src/generators/utils/eslint-file.ts index 3867121bcc631..b4c06b5384d53 100644 --- a/packages/linter/src/generators/utils/eslint-file.ts +++ b/packages/linter/src/generators/utils/eslint-file.ts @@ -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, + 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 +) { + return ( + !override.env && !override.extends && !override.plugins && !override.parser + ); +} + +export function updateOverrideInLintConfig( + tree: Tree, + root: string, + lookup: (override: Linter.ConfigOverride) => boolean, + update: ( + override: Linter.ConfigOverride + ) => Linter.ConfigOverride +) { + 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) => 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[] +) { + 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({ + 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}`; +} diff --git a/packages/linter/src/generators/utils/flat-config/ast-utils.spec.ts b/packages/linter/src/generators/utils/flat-config/ast-utils.spec.ts new file mode 100644 index 0000000000000..c7576c6c2a666 --- /dev/null +++ b/packages/linter/src/generators/utils/flat-config/ast-utils.spec.ts @@ -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: {} + } + ];" + `); + }); + }); +}); diff --git a/packages/linter/src/generators/utils/flat-config/ast-utils.ts b/packages/linter/src/generators/utils/flat-config/ast-utils.ts new file mode 100644 index 0000000000000..6e74612d62161 --- /dev/null +++ b/packages/linter/src/generators/utils/flat-config/ast-utils.ts @@ -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 { + 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) => 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) => boolean, + update: ( + override: Linter.ConfigOverride + ) => Linter.ConfigOverride +): 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.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, + 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, + 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, + 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(input: unknown): T { + if (Array.isArray(input)) { + return ts.factory.createArrayLiteralExpression( + input.map((item) => generateAst(item)), + input.length > 1 // multiline only if more than one item + ) as T; + } + if (input === null) { + return ts.factory.createNull() as T; + } + if (typeof input === 'object') { + return ts.factory.createObjectLiteralExpression( + Object.entries(input) + .filter(([_, value]) => value !== undefined) + .map(([key, value]) => + ts.factory.createPropertyAssignment( + isValidKey(key) ? key : ts.factory.createStringLiteral(key), + generateAst(value) + ) + ), + Object.keys(input).length > 1 // multiline only if more than one property + ) as T; + } + if (typeof input === 'string') { + return ts.factory.createStringLiteral(input) as T; + } + if (typeof input === 'number') { + return ts.factory.createNumericLiteral(input) as T; + } + if (typeof input === 'boolean') { + return (input ? ts.factory.createTrue() : ts.factory.createFalse()) as T; + } + // since we are parsing JSON, this should never happen + throw new Error(`Unknown type: ${typeof input} `); +} + +function isValidKey(key: string): boolean { + return /^[a-zA-Z0-9_]+$/.test(key); +} diff --git a/packages/linter/src/generators/utils/flat-config/path-utils.ts b/packages/linter/src/generators/utils/flat-config/path-utils.ts new file mode 100644 index 0000000000000..eae969773c231 --- /dev/null +++ b/packages/linter/src/generators/utils/flat-config/path-utils.ts @@ -0,0 +1,31 @@ +import { joinPathFragments } from '@nx/devkit'; +import type { Linter } from 'eslint'; + +export function updateFiles( + override: Linter.ConfigOverride, + root: string +) { + if (override.files) { + override.files = Array.isArray(override.files) + ? override.files + : [override.files]; + override.files = override.files.map((file) => mapFilePath(file, root)); + } + return override; +} + +function mapFilePath(filePath: string, root: string) { + if (filePath.startsWith('!')) { + const fileWithoutBang = filePath.slice(1); + if (fileWithoutBang.startsWith('*.')) { + return `!${joinPathFragments(root, '**', fileWithoutBang)}`; + } else { + return `!${joinPathFragments(root, fileWithoutBang)}`; + } + } + if (filePath.startsWith('*.')) { + return joinPathFragments(root, '**', filePath); + } else { + return joinPathFragments(root, filePath); + } +} diff --git a/packages/linter/src/generators/workspace-rule/workspace-rule.ts b/packages/linter/src/generators/workspace-rule/workspace-rule.ts index 505fc968f0ef1..ee9a7ea8ffb3d 100644 --- a/packages/linter/src/generators/workspace-rule/workspace-rule.ts +++ b/packages/linter/src/generators/workspace-rule/workspace-rule.ts @@ -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" diff --git a/packages/linter/src/utils/flat-config.ts b/packages/linter/src/utils/flat-config.ts new file mode 100644 index 0000000000000..2943a4948e39a --- /dev/null +++ b/packages/linter/src/utils/flat-config.ts @@ -0,0 +1,5 @@ +import { Tree } from '@nx/devkit'; + +export function useFlatConfig(tree: Tree): boolean { + return tree.exists('eslint.config.js'); +} diff --git a/packages/next/src/generators/application/application.spec.ts b/packages/next/src/generators/application/application.spec.ts index 923ed2aaeb1aa..07d352729ba3c 100644 --- a/packages/next/src/generators/application/application.spec.ts +++ b/packages/next/src/generators/application/application.spec.ts @@ -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": {}, }, + { + "env": { + "jest": true, + }, + "files": [ + "*.spec.ts", + "*.spec.tsx", + "*.spec.js", + "*.spec.jsx", + ], + }, ], - "rules": { - "@next/next/no-html-link-for-pages": "off", - }, } `); }); diff --git a/packages/next/src/generators/application/lib/add-linting.spec.ts b/packages/next/src/generators/application/lib/add-linting.spec.ts new file mode 100644 index 0000000000000..8b1db70527be1 --- /dev/null +++ b/packages/next/src/generators/application/lib/add-linting.spec.ts @@ -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/**/*"] } + ]; + " + `); + }); +}); diff --git a/packages/next/src/generators/application/lib/add-linting.ts b/packages/next/src/generators/application/lib/add-linting.ts index da143412e36bf..56f5d37a12d4a 100644 --- a/packages/next/src/generators/application/lib/add-linting.ts +++ b/packages/next/src/generators/application/lib/add-linting.ts @@ -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 && isEslintConfigSupported(host)) { + addExtendsToLintConfig(host, options.appProjectRoot, [ + 'plugin:@nx/react-typescript', + 'next', + 'next/core-web-vitals', + ]); - if (options.linter === Linter.EsLint) { - updateJson( + // 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"] + addOverrideToLintConfig( host, - joinPathFragments(options.appProjectRoot, '.eslintrc.json'), - (json) => { - json = extendReactEslintJson(json); - - // 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 = { + 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) - ) - ); - if (commonOverride) { - // Only set parserOptions.project if it already exists (defined by options.setParserOptionsProject) - if (commonOverride.parserOptions?.project) { - commonOverride.parserOptions.project = [ - `${options.appProjectRoot}/tsconfig(.*)?.json`, - ]; - } - // Configure custom pages directory for next rule - if (commonOverride.rules) { - commonOverride.rules = { - ...commonOverride.rules, - '@next/next/no-html-link-for-pages': [ - 'error', - `${options.appProjectRoot}/pages`, - ], - }; - } - } - - json.extends ??= []; - if (typeof json.extends === 'string') { - json.extends = [json.extends]; - } - // add next.js configuration - json.extends.unshift(...['next', 'next/core-web-vitals']); - // remove nx/react plugin, as it conflicts with the next.js one - json.extends = json.extends.filter( - (name) => - name !== 'plugin:@nx/react' && name !== 'plugin:@nrwl/nx/react' - ); - - json.extends.unshift('plugin:@nx/react-typescript'); - if (!json.env) { - json.env = {}; - } - json.env.jest = true; - - return json; - } + }, + }, + { insertAtTheEnd: false } + ); + updateOverrideInLintConfig( + host, + options.appProjectRoot, + (o) => + Array.isArray(o.files) && + o.files.some((f) => f.match(/\*\.ts$/)) && + o.files.some((f) => f.match(/\*\.tsx$/)) && + o.files.some((f) => f.match(/\*\.js$/)) && + o.files.some((f) => f.match(/\*\.jsx$/)), + (o) => ({ + ...o, + rules: { + ...o.rules, + '@next/next/no-html-link-for-pages': [ + 'error', + `${options.appProjectRoot}/pages`, + ], + }, + }) ); + // add jest specific config + if (options.unitTestRunner === 'jest') { + addOverrideToLintConfig(host, options.appProjectRoot, { + files: ['*.spec.ts', '*.spec.tsx', '*.spec.js', '*.spec.jsx'], + env: { + jest: true, + }, + }); + } + addIgnoresToLintConfig(host, options.appProjectRoot, ['.next/**/*']); } const installTask = addDependenciesToPackageJson( diff --git a/packages/node/src/generators/e2e-project/e2e-project.ts b/packages/node/src/generators/e2e-project/e2e-project.ts index 4569436ff7747..d7c2f086b3fe0 100644 --- a/packages/node/src/generators/e2e-project/e2e-project.ts +++ b/packages/node/src/generators/e2e-project/e2e-project.ts @@ -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 = []; - } - 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.rootProject && isEslintConfigSupported(host)) { + addPluginsToLintConfig(host, options.e2eProjectRoot, '@nx'); + replaceOverridesInLintConfig(host, options.e2eProjectRoot, [ + typeScriptOverride, + javaScriptOverride, + ]); + } } if (!options.skipFormat) { diff --git a/packages/nx/src/command-line/init/implementation/angular/standalone-workspace.ts b/packages/nx/src/command-line/init/implementation/angular/standalone-workspace.ts index fbf9b8cbda4c2..ad421026cbc86 100644 --- a/packages/nx/src/command-line/init/implementation/angular/standalone-workspace.ts +++ b/packages/nx/src/command-line/init/implementation/angular/standalone-workspace.ts @@ -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 { diff --git a/packages/playwright/src/utils/add-linter.ts b/packages/playwright/src/utils/add-linter.ts index 30405da38940b..b14d133411991 100644 --- a/packages/playwright/src/utils/add-linter.ts +++ b/packages/playwright/src/utils/add-linter.ts @@ -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( - tree, - joinPathFragments(projectConfig.root, '.eslintrc.json'), - (json) => { - if (options.rootProject) { - json.plugins = ['@nx']; - json.extends = ['plugin:playwright/recommended']; - } else { - json.extends = ['plugin:playwright/recommended', ...json.extends]; - } - json.overrides ??= []; - const globals = options.rootProject ? [globalJavaScriptOverrides] : []; - const override = { - files: ['*.ts', '*.tsx', '*.js', '*.jsx'], - parserOptions: !options.setParserOptionsProject - ? undefined - : { - project: `${projectConfig.root}/tsconfig.*?.json`, - }, - rules: {}, - }; - const palywrightFiles = [ - { - ...override, - files: [`${options.directory}/**/*.{ts,js,tsx,jsx}`], - }, - ]; - json.overrides.push(...globals); - json.overrides.push(...palywrightFiles); - return json; + if (isEslintConfigSupported(tree)) { + addExtendsToLintConfig( + tree, + projectConfig.root, + 'plugin:playwright/recommended' + ); + if (options.rootProject) { + addPluginsToLintConfig(tree, projectConfig.root, '@nx'); + addOverrideToLintConfig(tree, projectConfig.root, javaScriptOverride); } - ); + addOverrideToLintConfig(tree, projectConfig.root, { + files: [`${options.directory}/**/*.{ts,js,tsx,jsx}`], + parserOptions: !options.setParserOptionsProject + ? undefined + : { + project: `${projectConfig.root}/tsconfig.*?.json`, + }, + rules: {}, + }); + } return runTasksInSerial(...tasks); } diff --git a/packages/plugin/src/generators/lint-checks/generator.ts b/packages/plugin/src/generators/lint-checks/generator.ts index c60018e856749..16345f6715375 100644 --- a/packages/plugin/src/generators/lint-checks/generator.ts +++ b/packages/plugin/src/generators/lint-checks/generator.ts @@ -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( + updateOverrideInLintConfig( host, - `${projectConfiguration.root}/.eslintrc.json`, - (c) => { - const override = c.overrides.find( - (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; + projectConfiguration.root, + (o) => + Object.keys(o.rules ?? {})?.includes('@nx/nx-plugin-checks') || + 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(host, eslintPath); - eslintConfig.overrides ??= []; - let entry: ESLint.ConfigOverride = - eslintConfig.overrides.find( - (x) => - Object.keys(x.rules ?? {}).includes('@nx/nx-plugin-checks') || - Object.keys(x.rules ?? {}).includes('@nrwl/nx/nx-plugin-checks') - ); - const newentry = !entry; - entry ??= { files: [] }; - entry.files = [ - ...new Set([ - ...(entry.files ?? []), - ...[ - './package.json', - packageJson.generators, - packageJson.executors, - packageJson.schematics, - packageJson.builders, - ].filter((f) => !!f), - ]), - ]; - entry.parser = 'jsonc-eslint-parser'; - entry.rules ??= { - '@nx/nx-plugin-checks': 'error', - }; + if (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'); - if (newentry) { - eslintConfig.overrides.push(entry); - } + const files = [ + './package.json', + packageJson.generators, + packageJson.executors, + packageJson.schematics, + packageJson.builders, + ].filter((f) => !!f); + + const parser = useFlatConfig(host) + ? { languageOptions: { parser: 'jsonc-eslint-parser' } } + : { parser: 'jsonc-eslint-parser' }; - writeJson(host, eslintPath, eslintConfig); + if (lintConfigHasOverride(host, options.root, lookup)) { + // update it + updateOverrideInLintConfig(host, options.root, lookup, (o) => ({ + ...o, + files: [ + ...new Set([ + ...(Array.isArray(o.files) ? o.files : [o.files]), + ...files, + ]), + ], + ...parser, + rules: { + ...o.rules, + '@nx/nx-plugin-checks': 'error', + }, + })); + } else { + // add it + addOverrideToLintConfig(host, options.root, { + files, + ...parser, + rules: { + '@nx/nx-plugin-checks': 'error', + }, + }); + } } } @@ -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(host, '.eslintrc.json'); - rootESLint.overrides ??= []; - if (!eslintConfigContainsJsonOverride(rootESLint)) { - rootESLint.overrides.push({ - files: '*.json', - parser: 'jsonc-eslint-parser', - rules: {}, - }); - writeJson(host, '.eslintrc.json', rootESLint); + 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: {}, + }, + { 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); } diff --git a/packages/react-native/src/utils/add-linting.ts b/packages/react-native/src/utils/add-linting.ts index 125d830624609..cc3617e4d5e27 100644 --- a/packages/react-native/src/utils/add-linting.ts +++ b/packages/react-native/src/utils/add-linting.ts @@ -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, - 'public', - '.cache', - 'node_modules', - ]; - - return json; - } - ); + if (isEslintConfigSupported(host)) { + addExtendsToLintConfig(host, options.projectRoot, 'plugin:@nx/react'); + addIgnoresToLintConfig(host, options.projectRoot, [ + 'public', + '.cache', + 'node_modules', + ]); + } if (!options.skipPackageJson) { const installTask = await addDependenciesToPackageJson( diff --git a/packages/react/src/generators/application/application.ts b/packages/react/src/generators/application/application.ts index abba9602efe1f..9e6fe7d9e525b 100644 --- a/packages/react/src/generators/application/application.ts +++ b/packages/react/src/generators/application/application.ts @@ -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( diff --git a/packages/react/src/generators/application/lib/add-e2e.ts b/packages/react/src/generators/application/lib/add-e2e.ts index 9a198d42ee829..ad63dedf74c64 100644 --- a/packages/react/src/generators/application/lib/add-e2e.ts +++ b/packages/react/src/generators/application/lib/add-e2e.ts @@ -4,7 +4,6 @@ import { ensurePackage, getPackageManagerCommand, joinPathFragments, - readProjectConfiguration, } from '@nx/devkit'; import { webStaticServeGenerator } from '@nx/web'; diff --git a/packages/react/src/generators/library/lib/add-linting.ts b/packages/react/src/generators/library/lib/add-linting.ts index b78b11ba55256..0834a21f8665b 100644 --- a/packages/react/src/generators/library/lib/add-linting.ts +++ b/packages/react/src/generators/library/lib/add-linting.ts @@ -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) { diff --git a/packages/react/src/utils/lint.ts b/packages/react/src/utils/lint.ts index 88157c6ed63d2..05e8641d6fafb 100644 --- a/packages/react/src/utils/lint.ts +++ b/packages/react/src/utils/lint.ts @@ -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; diff --git a/packages/storybook/src/generators/configuration/lib/util-functions.ts b/packages/storybook/src/generators/configuration/lib/util-functions.ts index 3150eb448e40e..0983b52851fc7 100644 --- a/packages/storybook/src/generators/configuration/lib/util-functions.ts +++ b/packages/storybook/src/generators/configuration/lib/util-functions.ts @@ -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; @@ -173,7 +174,7 @@ export function createStorybookTsconfigFile( if (tree.exists(oldStorybookTsConfigPath)) { logger.warn(`.storybook/tsconfig.json already exists for this project`); logger.warn( - `It will be renamed and moved to tsconfig.storybook.json. + `It will be renamed and moved to tsconfig.storybook.json. Please make sure all settings look correct after this change. Also, please make sure to use "nx migrate" to move from one version of Nx to another. ` @@ -365,7 +366,7 @@ export function configureTsSolutionConfig( * which includes *.stories files. * * 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, ]); } } @@ -629,15 +656,15 @@ export function rootFileIsTs( ): boolean { if (tree.exists(`.storybook/${rootFileName}.ts`) && !tsConfiguration) { logger.info( - `The root Storybook configuration is in TypeScript, - so Nx will generate TypeScript Storybook configuration files + `The root Storybook configuration is in TypeScript, + so Nx will generate TypeScript Storybook configuration files in this project's .storybook folder as well.` ); return true; } else if (tree.exists(`.storybook/${rootFileName}.js`) && tsConfiguration) { logger.info( - `The root Storybook configuration is in JavaScript, - so Nx will generate JavaScript Storybook configuration files + `The root Storybook configuration is in JavaScript, + so Nx will generate JavaScript Storybook configuration files in this project's .storybook folder as well.` ); return false; @@ -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' - ); - json = JSON.parse(newJsonString); - return 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') + ); } } diff --git a/packages/workspace/src/generators/move/lib/move-project-files.ts b/packages/workspace/src/generators/move/lib/move-project-files.ts index 5e5f0b066e79e..e10c0ca640f64 100644 --- a/packages/workspace/src/generators/move/lib/move-project-files.ts +++ b/packages/workspace/src/generators/move/lib/move-project-files.ts @@ -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$/, diff --git a/packages/workspace/src/generators/move/lib/update-eslint-config.spec.ts b/packages/workspace/src/generators/move/lib/update-eslint-config.spec.ts new file mode 100644 index 0000000000000..425864a542e49 --- /dev/null +++ b/packages/workspace/src/generators/move/lib/update-eslint-config.spec.ts @@ -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" } + })) + ];` + ); +} diff --git a/packages/workspace/src/generators/move/lib/update-eslint-config.ts b/packages/workspace/src/generators/move/lib/update-eslint-config.ts new file mode 100644 index 0000000000000..55e6482d5b397 --- /dev/null +++ b/packages/workspace/src/generators/move/lib/update-eslint-config.ts @@ -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.', + ], + }); + } +} diff --git a/packages/workspace/src/generators/move/lib/update-eslintrc-json.spec.ts b/packages/workspace/src/generators/move/lib/update-eslintrc-json.spec.ts deleted file mode 100644 index c627bad8df4cc..0000000000000 --- a/packages/workspace/src/generators/move/lib/update-eslintrc-json.spec.ts +++ /dev/null @@ -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}` }); - }); -}); diff --git a/packages/workspace/src/generators/move/lib/update-eslintrc-json.ts b/packages/workspace/src/generators/move/lib/update-eslintrc-json.ts deleted file mode 100644 index 4dd526cdf3777..0000000000000 --- a/packages/workspace/src/generators/move/lib/update-eslintrc-json.ts +++ /dev/null @@ -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(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; - }); -} diff --git a/packages/workspace/src/generators/move/lib/update-project-root-files.ts b/packages/workspace/src/generators/move/lib/update-project-root-files.ts index ee9695dcb5981..d2c68ac287200 100644 --- a/packages/workspace/src/generators/move/lib/update-project-root-files.ts +++ b/packages/workspace/src/generators/move/lib/update-project-root-files.ts @@ -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; } diff --git a/packages/workspace/src/generators/move/move.ts b/packages/workspace/src/generators/move/move.ts index 641287d526aa2..6c868db0a2a30 100644 --- a/packages/workspace/src/generators/move/move.ts +++ b/packages/workspace/src/generators/move/move.ts @@ -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); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3931dab86de7..dd2aa0b9dd1c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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==}