feat(core): enable linter on root projects (#13347)

This commit is contained in:
Miroslav Jonaš 2022-11-24 22:51:24 +01:00 committed by GitHub
parent 8200870c8e
commit 110b5f2867
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 398 additions and 95 deletions

View File

@ -438,6 +438,78 @@ export function tslibC(): string {
);
});
});
describe('Root projects migration', () => {
afterEach(() => cleanupProject());
it('should set root project config to app and e2e app and migrate when another lib is added', () => {
const myapp = uniq('myapp');
const mylib = uniq('mylib');
newProject();
runCLI(`generate @nrwl/react:app ${myapp} --rootProject=true`);
let rootEslint = readJson('.eslintrc.json');
let e2eEslint = readJson('e2e/.eslintrc.json');
expect(() => checkFilesExist(`.eslintrc.${myapp}.json`)).toThrow();
// should directly refer to nx plugin
expect(rootEslint.plugins).toEqual(['@nrwl/nx']);
expect(e2eEslint.plugins).toEqual(['@nrwl/nx']);
// should only extend framework plugin
expect(rootEslint.extends).toEqual(['plugin:@nrwl/nx/react']);
expect(e2eEslint.extends).toEqual(['plugin:cypress/recommended']);
// should have plugin extends
expect(rootEslint.overrides[0].extends).toEqual([
'plugin:@nrwl/nx/typescript',
]);
expect(rootEslint.overrides[1].extends).toEqual([
'plugin:@nrwl/nx/javascript',
]);
expect(e2eEslint.overrides[0].extends).toEqual([
'plugin:@nrwl/nx/typescript',
]);
expect(e2eEslint.overrides[1].extends).toEqual([
'plugin:@nrwl/nx/javascript',
]);
console.log(JSON.stringify(rootEslint, null, 2));
console.log(JSON.stringify(e2eEslint, null, 2));
runCLI(`generate @nrwl/react:lib ${mylib}`);
// should add new tslint
expect(() => checkFilesExist(`.eslintrc.${myapp}.json`)).not.toThrow();
const appEslint = readJson(`.eslintrc.${myapp}.json`);
rootEslint = readJson('.eslintrc.json');
e2eEslint = readJson('e2e/.eslintrc.json');
const libEslint = readJson(`libs/${mylib}/.eslintrc.json`);
// should directly refer to nx plugin only in the root
expect(rootEslint.plugins).toEqual(['@nrwl/nx']);
expect(appEslint.plugins).toBeUndefined();
expect(e2eEslint.plugins).toBeUndefined();
// should extend framework plugin and root config
expect(appEslint.extends).toEqual([
'plugin:@nrwl/nx/react',
'./.eslintrc.json',
]);
expect(e2eEslint.extends).toEqual([
'plugin:cypress/recommended',
'../.eslintrc.json',
]);
expect(libEslint.extends).toEqual([
'plugin:@nrwl/nx/react',
'../../.eslintrc.json',
]);
// should have no plugin extends
expect(appEslint.overrides[0].extends).toBeUndefined();
expect(appEslint.overrides[1].extends).toBeUndefined();
expect(e2eEslint.overrides[0].extends).toBeUndefined();
expect(e2eEslint.overrides[1].extends).toBeUndefined();
expect(libEslint.overrides[1].extends).toBeUndefined();
expect(libEslint.overrides[1].extends).toBeUndefined();
});
});
});
/**

View File

@ -19,6 +19,10 @@ import {
import { Linter, lintProjectGenerator } from '@nrwl/linter';
import { runTasksInSerial } from '@nrwl/workspace/src/utilities/run-tasks-in-serial';
import { getRelativePathToRootTsConfig } from '@nrwl/workspace/src/utilities/typescript';
import {
globalJavaScriptOverrides,
globalTypeScriptOverrides,
} from '@nrwl/linter/src/generators/init/global-eslint-config';
import { join } from 'path';
import { installedCypressVersion } from '../../utils/cypress-version';
@ -177,6 +181,7 @@ export async function addLinter(host: Tree, options: CypressProjectSchema) {
],
setParserOptionsProject: options.setParserOptionsProject,
skipPackageJson: options.skipPackageJson,
rootProject: options.rootProject,
});
if (!options.linter || options.linter !== Linter.EsLint) {
@ -192,8 +197,16 @@ export async function addLinter(host: Tree, options: CypressProjectSchema) {
: () => {};
updateJson(host, join(options.projectRoot, '.eslintrc.json'), (json) => {
json.extends = ['plugin:cypress/recommended', ...json.extends];
if (options.rootProject) {
json.plugins = ['@nrwl/nx'];
json.extends = ['plugin:cypress/recommended'];
} else {
json.extends = ['plugin:cypress/recommended', ...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

View File

@ -30,7 +30,8 @@
"builders": "./executors.json",
"schematics": "./generators.json",
"peerDependencies": {
"eslint": "^8.0.0"
"eslint": "^8.0.0",
"js-yaml": "4.1.0"
},
"dependencies": {
"@nrwl/devkit": "file:../devkit",

View File

@ -0,0 +1,80 @@
import { ESLint, Linter as LinterType } from 'eslint';
/**
* This configuration is intended to apply to all TypeScript source files.
* See the eslint-plugin-nx package for what is in the referenced shareable config.
*/
export const globalTypeScriptOverrides = {
files: ['*.ts', '*.tsx'],
extends: ['plugin:@nrwl/nx/typescript'],
/**
* 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 JavaScript source files.
* See the eslint-plugin-nx package for what is in the referenced shareable config.
*/
export const globalJavaScriptOverrides = {
files: ['*.js', '*.jsx'],
extends: ['plugin:@nrwl/nx/javascript'],
/**
* 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 = {
files: ['*.ts', '*.tsx', '*.js', '*.jsx'],
rules: {
'@nrwl/nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [{ sourceTag: '*', onlyDependOnLibsWithTags: ['*'] }],
},
],
} as LinterType.RulesRecord,
};
export const getGlobalEsLintConfiguration = (
unitTestRunner?: string,
rootProject?: boolean
) => {
const config: ESLint.ConfigData = {
root: true,
ignorePatterns: rootProject ? ['!**/*'] : ['**/*'],
plugins: ['@nrwl/nx'],
/**
* We leverage ESLint's "overrides" capability so that we can set up a root config which will support
* all permutations of Nx workspaces across all frameworks, libraries and tools.
*
* The key point is that we need entirely different ESLint config to apply to different types of files,
* but we still want to share common config where possible.
*/
overrides: [
...(rootProject ? [] : [moduleBoundariesOverride]),
globalTypeScriptOverrides,
globalJavaScriptOverrides,
],
};
if (unitTestRunner === 'jest') {
config.overrides.push({
files: ['*.spec.ts', '*.spec.tsx', '*.spec.js', '*.spec.jsx'],
env: {
jest: true,
},
rules: {},
});
}
return config;
};

View File

@ -0,0 +1,116 @@
import {
joinPathFragments,
offsetFromRoot,
ProjectConfiguration,
TargetConfiguration,
Tree,
updateJson,
updateProjectConfiguration,
writeJson,
} from '@nrwl/devkit';
import { basename, dirname } from 'path';
import { findEslintFile } from '../utils/eslint-file';
import { getGlobalEsLintConfiguration } from './global-eslint-config';
const FILE_EXTENSION_REGEX = /(?<!(^|\/))(\.[^/.]+)$/;
export function migrateConfigToMonorepoStyle(
projects: ProjectConfiguration[],
tree: Tree,
unitTestRunner: string
): void {
// copy the root's .eslintrc.json to new name
const rootProject = projects.find((p) => p.root === '.');
const eslintPath =
rootProject.targets?.lint?.options?.eslintConfig || findEslintFile(tree);
const pathSegments = eslintPath.split(FILE_EXTENSION_REGEX).filter(Boolean);
const rootProjEslintPath =
pathSegments.length > 1
? pathSegments.join(`.${rootProject.name}`)
: `.${rootProject.name}.${rootProject.name}`;
tree.write(rootProjEslintPath, tree.read(eslintPath));
// update root project's configuration
const lintTarget = findLintTarget(rootProject);
lintTarget.options.eslintConfig = rootProjEslintPath;
updateProjectConfiguration(tree, rootProject.name, rootProject);
// replace root eslint with default global
tree.delete(eslintPath);
writeJson(
tree,
'.eslintrc.json',
getGlobalEsLintConfiguration(unitTestRunner)
);
// update extens in all projects' eslint configs
projects.forEach((project) => {
const lintTarget = findLintTarget(project);
if (lintTarget) {
const projectEslintPath = joinPathFragments(
project.root,
lintTarget.options.eslintConfig || findEslintFile(tree, project.root)
);
migrateEslintFile(projectEslintPath, tree);
}
});
}
export function findLintTarget(
project: ProjectConfiguration
): TargetConfiguration {
return Object.entries(project.targets).find(
([name, target]) =>
name === 'lint' || target.executor === '@nrwl/linter:eslint'
)?.[1];
}
function migrateEslintFile(projectEslintPath: string, tree: Tree) {
if (projectEslintPath.endsWith('.json')) {
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 !== '@nrwl/nx');
if (json.plugins.length === 0) {
delete json.plugins;
}
}
// add extends
json.extends = json.extends || [];
const pathToRootConfig = `${offsetFromRoot(
dirname(projectEslintPath)
)}.eslintrc.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:@nrwl/nx/typescript' &&
ext !== 'plugin:@nrwl/nx/javascript'
);
if (override.extends.length === 0) {
delete override.extends;
}
}
});
}
return json;
});
return;
}
if (
projectEslintPath.endsWith('.yml') ||
projectEslintPath.endsWith('.yaml')
) {
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');
}
}

View File

@ -16,88 +16,15 @@ import {
import { Linter } from '../utils/linter';
import { findEslintFile } from '../utils/eslint-file';
import { ESLint } from 'eslint';
import { getGlobalEsLintConfiguration } from './global-eslint-config';
export interface LinterInitOptions {
linter?: Linter;
unitTestRunner?: string;
skipPackageJson?: boolean;
rootProject?: boolean;
}
const getGlobalEsLintConfiguration = (unitTestRunner?: string) => {
const config: ESLint.ConfigData = {
root: true,
ignorePatterns: ['**/*'],
plugins: ['@nrwl/nx'],
/**
* We leverage ESLint's "overrides" capability so that we can set up a root config which will support
* all permutations of Nx workspaces across all frameworks, libraries and tools.
*
* The key point is that we need entirely different ESLint config to apply to different types of files,
* but we still want to share common config where possible.
*/
overrides: [
/**
* This configuration is intended to apply to all "source code" (but not
* markup like HTML, or other custom file types like GraphQL)
*/
{
files: ['*.ts', '*.tsx', '*.js', '*.jsx'],
rules: {
'@nrwl/nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{ sourceTag: '*', onlyDependOnLibsWithTags: ['*'] },
],
},
],
},
},
/**
* This configuration is intended to apply to all TypeScript source files.
* See the eslint-plugin-nx package for what is in the referenced shareable config.
*/
{
files: ['*.ts', '*.tsx'],
extends: ['plugin:@nrwl/nx/typescript'],
/**
* 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 JavaScript source files.
* See the eslint-plugin-nx package for what is in the referenced shareable config.
*/
{
files: ['*.js', '*.jsx'],
extends: ['plugin:@nrwl/nx/javascript'],
/**
* 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 (unitTestRunner === 'jest') {
config.overrides.push({
files: ['*.spec.ts', '*.spec.tsx', '*.spec.js', '*.spec.jsx'],
env: {
jest: true,
},
rules: {},
});
}
return config;
};
function addTargetDefaults(tree: Tree) {
const workspaceConfiguration = readWorkspaceConfiguration(tree);
@ -133,7 +60,7 @@ function initEsLint(tree: Tree, options: LinterInitOptions): GeneratorCallback {
writeJson(
tree,
'.eslintrc.json',
getGlobalEsLintConfiguration(options.unitTestRunner)
getGlobalEsLintConfiguration(options.unitTestRunner, options.rootProject)
);
addTargetDefaults(tree);

View File

@ -11,6 +11,11 @@ import { Linter } from '../utils/linter';
import { findEslintFile } from '../utils/eslint-file';
import { join } from 'path';
import { lintInitGenerator } from '../init/init';
import {
findLintTarget,
migrateConfigToMonorepoStyle,
} from '../init/init-migration';
import { readWorkspace } from 'nx/src/generators/utils/project-configuration';
interface LintProjectOptions {
project: string;
@ -21,6 +26,7 @@ interface LintProjectOptions {
setParserOptionsProject?: boolean;
skipPackageJson?: boolean;
unitTestRunner?: string;
rootProject?: boolean;
}
function createEsLintConfiguration(
@ -74,6 +80,15 @@ function createEsLintConfiguration(
});
}
export function mapLintPattern(
projectRoot: string,
extension: string,
rootProject?: boolean
) {
const infix = rootProject ? 'src/' : '';
return `${projectRoot}/${infix}**/*.${extension}`;
}
export async function lintProjectGenerator(
tree: Tree,
options: LintProjectOptions
@ -82,6 +97,7 @@ export async function lintProjectGenerator(
linter: options.linter,
unitTestRunner: options.unitTestRunner,
skipPackageJson: options.skipPackageJson,
rootProject: options.rootProject,
});
const projectConfig = readProjectConfiguration(tree, options.project);
@ -92,11 +108,38 @@ export async function lintProjectGenerator(
lintFilePatterns: options.eslintFilePatterns,
},
};
createEsLintConfiguration(
tree,
projectConfig,
options.setParserOptionsProject
);
// we are adding new project which is not the root project or
// companion e2e app so we should check if migration to
// monorepo style is needed
if (!options.rootProject) {
const projects = readWorkspace(tree).projects;
if (isMigrationToMonorepoNeeded(projects, tree)) {
// we only migrate project configurations that have been created
const filteredProjects = [];
Object.entries(projects).forEach(([name, project]) => {
if (name !== options.project) {
filteredProjects.push(project);
}
});
migrateConfigToMonorepoStyle(
filteredProjects,
tree,
options.unitTestRunner
);
}
}
// our root `.eslintrc` is already the project config, so we should not override it
// additionally, the companion e2e app would have `rootProject: true`
// so we need to check for the root path as well
if (!options.rootProject || projectConfig.root !== '.') {
createEsLintConfiguration(
tree,
projectConfig,
options.setParserOptionsProject
);
}
updateProjectConfiguration(tree, options.project, projectConfig);
@ -106,3 +149,40 @@ export async function lintProjectGenerator(
return installTask;
}
/**
* Detect based on the state of lint target configuration of the root project
* if we should migrate eslint configs to monorepo style
*
* @param tree
* @returns
*/
function isMigrationToMonorepoNeeded(
projects: Record<string, ProjectConfiguration>,
tree: Tree
): boolean {
const configs = Object.values(projects);
if (configs.length === 1) {
return false;
}
// get root project
const rootProject = configs.find((p) => p.root === '.');
if (!rootProject || !rootProject.targets) {
return false;
}
// find if root project has lint target
const lintTarget = findLintTarget(rootProject);
if (!lintTarget) {
return false;
}
// if there is no override for `eslintConfig` we should migrate
if (!lintTarget.options.eslintConfig) {
return true;
}
// check if target has `eslintConfig` override and if it's not pointing to the source .eslintrc
const rootEslintrc = findEslintFile(tree);
return (
lintTarget.options.eslintConfig === rootEslintrc ||
lintTarget.options.eslintConfig === `./${rootEslintrc}`
);
}

View File

@ -1,4 +1,4 @@
import type { Tree } from '@nrwl/devkit';
import { joinPathFragments, Tree } from '@nrwl/devkit';
export const eslintConfigFileWhitelist = [
'.eslintrc',
@ -9,9 +9,9 @@ export const eslintConfigFileWhitelist = [
'.eslintrc.json',
];
export function findEslintFile(tree: Tree): string | null {
export function findEslintFile(tree: Tree, projectRoot = ''): string | null {
for (const file of eslintConfigFileWhitelist) {
if (tree.exists(file)) {
if (tree.exists(joinPathFragments(projectRoot, file))) {
return file;
}
}

View File

@ -1,5 +1,5 @@
import {
createReactEslintJson,
extendReactEslintJson,
extraEslintDependencies,
} from '../../utils/lint';
import { NormalizedSchema, Schema } from './schema';
@ -27,6 +27,7 @@ import { Linter, lintProjectGenerator } from '@nrwl/linter';
import { swcCoreVersion } from '@nrwl/js/src/utils/versions';
import { swcLoaderVersion } from '@nrwl/webpack/src/utils/versions';
import { viteConfigurationGenerator, vitestGenerator } from '@nrwl/vite';
import { mapLintPattern } from '@nrwl/linter/src/generators/lint-project/lint-project';
async function addLinting(host: Tree, options: NormalizedSchema) {
const tasks: GeneratorCallback[] = [];
@ -38,20 +39,22 @@ async function addLinting(host: Tree, options: NormalizedSchema) {
joinPathFragments(options.appProjectRoot, 'tsconfig.app.json'),
],
unitTestRunner: options.unitTestRunner,
eslintFilePatterns: [`${options.appProjectRoot}/**/*.{ts,tsx,js,jsx}`],
eslintFilePatterns: [
mapLintPattern(
options.appProjectRoot,
'{ts,tsx,js,jsx}',
options.rootProject
),
],
skipFormat: true,
rootProject: options.rootProject,
});
tasks.push(lintTask);
const reactEslintJson = createReactEslintJson(
options.appProjectRoot,
options.setParserOptionsProject
);
updateJson(
host,
joinPathFragments(options.appProjectRoot, '.eslintrc.json'),
() => reactEslintJson
extendReactEslintJson
);
const installTask = await addDependenciesToPackageJson(

View File

@ -12,5 +12,6 @@ export async function addCypress(host: Tree, options: NormalizedSchema) {
name: options.e2eProjectName,
directory: options.directory,
project: options.projectName,
rootProject: options.rootProject,
});
}

View File

@ -17,6 +17,15 @@ export const extraEslintDependencies = {
},
};
export const extendReactEslintJson = (json: Linter.Config) => {
const { extends: pluginExtends, ...config } = json;
return {
extends: ['plugin:@nrwl/nx/react', ...(pluginExtends || [])],
...config,
};
};
export const createReactEslintJson = (
projectRoot: string,
setParserOptionsProject: boolean

View File

@ -67,7 +67,7 @@ async function createPreset(tree: Tree, options: Schema) {
await reactApplicationGenerator(tree, {
name: options.name,
style: options.style,
linter: 'none',
linter: options.linter,
unitTestRunner: 'none',
standaloneConfig: options.standaloneConfig,
rootProject: true,

View File

@ -36,6 +36,7 @@
"@nrwl/js": ["packages/js/src"],
"@nrwl/js/*": ["packages/js/*"],
"@nrwl/linter": ["packages/linter"],
"@nrwl/linter/*": ["packages/linter/*"],
"@nrwl/nest": ["packages/nest"],
"@nrwl/next": ["packages/next"],
"@nrwl/node": ["packages/node"],