diff --git a/tools/generators/normalize-package-dependencies/README.md b/tools/generators/normalize-package-dependencies/README.md new file mode 100644 index 00000000000000..2def291f05d98b --- /dev/null +++ b/tools/generators/normalize-package-dependencies/README.md @@ -0,0 +1,55 @@ +# normalize-package-dependencies + +Workspace Generator for package.json dependencies normalization. + +> it applies for all dependencies, devDependencies, peerDependencies + +- update mode: updates projects package.json to use \* as version for inner monorepo dependencies +- verify mode: verifies projects package.json to use \* as version for inner monorepo dependencies + + + +- [Usage](#usage) + - [Examples](#examples) +- [Options](#options) + - [`verify`](#verify) + - [`projectType`](#projecttype) + - [`tag`](#tag) + + + +## Usage + +```sh +yarn nx workspace-generator normalize-package-dependencies ... +``` + +Show what will be generated without writing to disk: + +```sh +yarn nx workspace-generator normalize-package-dependencies --dry-run +``` + +### Examples + +```sh +yarn nx workspace-generator normalize-package-dependencies +``` + +## Options + +#### `verify` + +Run generator in check(verification mode). Verify package.json dependencies for all projects or filtered projects (if filters are applied) + +#### `projectType` + +Type: `application | library | any` + +Filter flag. Use to apply generator execution only on projects that contain provided `projectType` within their `project.json#projectType`. + +#### `tag` + +Type: `string` + +Filter flag. Use to apply generator execution only on projects that contain provided tag within their `project.json#tags`. diff --git a/tools/generators/normalize-package-dependencies/index.spec.ts b/tools/generators/normalize-package-dependencies/index.spec.ts new file mode 100644 index 00000000000000..b0fd867dbf9b34 --- /dev/null +++ b/tools/generators/normalize-package-dependencies/index.spec.ts @@ -0,0 +1,314 @@ +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { + Tree, + addProjectConfiguration, + writeJson, + getProjects, + readJson, + joinPathFragments, + ProjectGraph, + readProjectConfiguration, + updateJson, +} from '@nrwl/devkit'; +import * as chalk from 'chalk'; + +import generator from './index'; +import { PackageJson } from '../../types'; +import { disableChalk } from '../../utils-testing'; + +const graphMock: ProjectGraph = { + dependencies: {}, + nodes: {}, + externalNodes: {}, +}; + +jest.mock('@nrwl/devkit', () => { + async function createProjectGraphAsyncMock(): Promise { + return graphMock; + } + + return { + ...jest.requireActual('@nrwl/devkit'), + createProjectGraphAsync: createProjectGraphAsyncMock, + }; +}); + +describe('normalize-package-dependencies generator', () => { + let tree: Tree; + // eslint-disable-next-line @typescript-eslint/no-empty-function + const noop = () => {}; + disableChalk(chalk); + + const logLogSpy = jest.spyOn(console, 'log').mockImplementation(noop); + const infoLogSpy = jest.spyOn(console, 'info').mockImplementation(noop); + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + tree = createProject(tree, { + projectName: 'react-one', + deps: { dev: {}, prod: { react: '17.x.x' }, peer: {} }, + tags: ['platform:any'], + }); + tree = createProject(tree, { + projectName: 'react-two', + deps: { dev: {}, prod: { react: '17.x.x', '@proj/react-one': '^1.0.0' }, peer: {} }, + tags: ['platform:any', 'scope:two'], + }); + tree = createProject(tree, { + projectName: 'react-three', + deps: { dev: {}, prod: { react: '17.x.x', '@proj/react-two': '^1.0.0' }, peer: {} }, + tags: ['platform:any', 'scope:two'], + }); + tree = createProject(tree, { + projectName: 'react-app', + projectType: 'application', + deps: { dev: {}, prod: { react: '17.x.x', '@proj/react-one': '^1.0.0', '@proj/react-two': '^1.0.0' }, peer: {} }, + tags: ['platform:web', 'scope:two', 'type:app'], + }); + }); + + it(`should update workspace dependencies to "*" for version`, async () => { + // this tests that dependency which has different major version (thus is installed from npm) will not be updated to '*' + addNonWorkspaceDependency(tree, '@proj/react-three', { pkgName: '@proj/react-one', pkgVersion: '^0.1.0' }); + + await generator(tree, {}); + + const { reactApp, reactOne, reactTwo, reactThree } = getPackageJsonForAllProjects(tree); + + expect(reactOne.dependencies).toEqual({ react: '17.x.x' }); + expect(reactTwo.dependencies).toEqual({ + react: '17.x.x', + '@proj/react-one': '*', + }); + expect(reactThree.dependencies).toEqual({ + react: '17.x.x', + '@proj/react-one': '^0.1.0', + '@proj/react-two': '*', + }); + expect(reactApp.dependencies).toEqual({ + react: '17.x.x', + '@proj/react-one': '*', + '@proj/react-two': '*', + }); + }); + + describe(`filters`, () => { + describe(`multiple active`, () => { + it(`should update workspace dependencies that match provided filter options`, async () => { + await generator(tree, { projectType: 'library', tag: 'type:app' }); + + const { reactApp, reactOne, reactTwo } = getPackageJsonForAllProjects(tree); + + expect(reactOne.dependencies).toEqual({ + react: '17.x.x', + }); + expect(reactTwo.dependencies).toEqual({ + react: '17.x.x', + '@proj/react-one': '^1.0.0', + }); + expect(reactApp.dependencies).toEqual({ + react: '17.x.x', + '@proj/react-one': '^1.0.0', + '@proj/react-two': '^1.0.0', + }); + }); + }); + + describe(`--tag`, () => { + it(`should update workspace dependencies that have specified tag`, async () => { + await generator(tree, { tag: 'scope:two' }); + + const { reactApp, reactOne, reactTwo } = getPackageJsonForAllProjects(tree); + + expect(reactOne.dependencies).toEqual({ + react: '17.x.x', + }); + expect(reactTwo.dependencies).toEqual({ + react: '17.x.x', + '@proj/react-one': '*', + }); + expect(reactApp.dependencies).toEqual({ + react: '17.x.x', + '@proj/react-one': '*', + '@proj/react-two': '*', + }); + }); + }); + + describe(`--projectType`, () => { + it(`should update workspace dependencies only for application`, async () => { + await generator(tree, { projectType: 'application' }); + + const { reactApp, reactOne, reactTwo } = getPackageJsonForAllProjects(tree); + + expect(reactOne.dependencies).toEqual({ + react: '17.x.x', + }); + expect(reactTwo.dependencies).toEqual({ + react: '17.x.x', + '@proj/react-one': '^1.0.0', + }); + expect(reactApp.dependencies).toEqual({ + react: '17.x.x', + '@proj/react-one': '*', + '@proj/react-two': '*', + }); + }); + }); + }); + + describe(`--verify`, () => { + it(`should not update any package.json`, async () => { + try { + await generator(tree, { verify: true }); + } catch { + const { reactApp, reactOne, reactTwo } = getPackageJsonForAllProjects(tree); + + expect(reactOne.dependencies).toEqual({ + react: '17.x.x', + }); + expect(reactTwo.dependencies).toEqual({ + react: '17.x.x', + '@proj/react-one': '^1.0.0', + }); + expect(reactApp.dependencies).toEqual({ + react: '17.x.x', + '@proj/react-one': '^1.0.0', + '@proj/react-two': '^1.0.0', + }); + } + }); + + it(`should fail if there are any violations`, async () => { + await expect(generator(tree, { verify: true })).rejects.toThrowErrorMatchingInlineSnapshot( + `"package dependency violations found"`, + ); + + expect(logLogSpy.mock.calls.flat()).toMatchInlineSnapshot(` + Array [ + "@proj/react-two has following dependency version issues:", + " - @proj/react-one", + "@proj/react-three has following dependency version issues:", + " - @proj/react-two", + "@proj/react-app has following dependency version issues:", + " - @proj/react-one", + " - @proj/react-two", + ] + `); + expect(infoLogSpy.mock.calls.flat()).toMatchInlineSnapshot(` + Array [ + "All these dependencies version should be specified as '*'", + "Fix this by running 'nx workspace-generator normalize-package-dependencies'", + ] + `); + }); + + it(`should fail if there are any violations and filters are used`, async () => { + await expect( + generator(tree, { verify: true, projectType: 'application' }), + ).rejects.toThrowErrorMatchingInlineSnapshot(`"package dependency violations found"`); + + expect(logLogSpy.mock.calls.flat()).toMatchInlineSnapshot(` + Array [ + "@proj/react-app has following dependency version issues:", + " - @proj/react-one", + " - @proj/react-two", + ] + `); + expect(infoLogSpy.mock.calls.flat()).toMatchInlineSnapshot(` + Array [ + "All these dependencies version should be specified as '*'", + "Fix this by running 'nx workspace-generator normalize-package-dependencies'", + ] + `); + + jest.resetAllMocks(); + + await expect(generator(tree, { verify: true, projectType: 'library', tag: 'type:app' })).resolves.toEqual( + undefined, + ); + + expect(logLogSpy.mock.calls.flat()).toMatchInlineSnapshot(`Array []`); + expect(infoLogSpy.mock.calls.flat()).toMatchInlineSnapshot(`Array []`); + }); + }); +}); + +function getPackageJsonForAllProjects(tree: Tree) { + const projects = getProjects(tree); + + const reactOne = projects.get('@proj/react-one'); + const reactTwo = projects.get('@proj/react-two'); + const reactThree = projects.get('@proj/react-three'); + const reactApp = projects.get('@proj/react-app'); + + return { + reactOne: readJson(tree, joinPathFragments(reactOne!.root, 'package.json')), + reactTwo: readJson(tree, joinPathFragments(reactTwo!.root, 'package.json')), + reactThree: readJson(tree, joinPathFragments(reactThree!.root, 'package.json')), + reactApp: readJson(tree, joinPathFragments(reactApp!.root, 'package.json')), + }; +} + +function createProject( + tree: Tree, + options: { + projectName: string; + projectType?: 'application' | 'library'; + tags?: string[]; + deps: { prod: Record; dev: Record; peer: Record }; + }, +) { + const { projectName, deps, tags, projectType = 'library' } = options; + const packageName = `@proj/${projectName}`; + + const rootPath = `packages/${projectName}`; + + writeJson(tree, `packages/${projectName}/package.json`, { + name: packageName, + dependencies: { ...deps.prod }, + devDependencies: { ...deps.dev }, + peerDependencies: { ...deps.peer }, + }); + + addProjectConfiguration(tree, packageName, { + root: rootPath, + projectType, + ...(tags ? { tags } : null), + }); + + const depKeys = [...Object.keys(deps.prod), ...Object.keys(deps.dev), ...Object.keys(deps.peer)]; + + graphMock.dependencies[packageName] = depKeys.map(value => { + return { source: packageName, target: value, type: 'static' }; + }); + graphMock.nodes[packageName] = { + name: packageName, + type: projectType === 'library' ? 'lib' : 'app', + data: { name: packageName, root: rootPath, files: [] }, + }; + + return tree; +} + +function addNonWorkspaceDependency( + tree: Tree, + projectName: string, + dependency: { pkgName: string; pkgVersion: string }, +) { + const project = readProjectConfiguration(tree, projectName); + updateJson(tree, joinPathFragments(project.root, 'package.json'), json => { + json.dependencies = json.dependencies ?? {}; + json.dependencies[dependency.pkgName] = dependency.pkgVersion; + + return json; + }); + + graphMock.dependencies[projectName].push({ + source: projectName, + target: `npm:${dependency.pkgName}`, + type: 'static', + }); + + return tree; +} diff --git a/tools/generators/normalize-package-dependencies/index.ts b/tools/generators/normalize-package-dependencies/index.ts new file mode 100644 index 00000000000000..bffd5217903420 --- /dev/null +++ b/tools/generators/normalize-package-dependencies/index.ts @@ -0,0 +1,194 @@ +import { + Tree, + formatFiles, + getProjects, + ProjectConfiguration, + updateJson, + joinPathFragments, + readJson, + logger, + createProjectGraphAsync, + ProjectGraph, +} from '@nrwl/devkit'; + +import { NormalizePackageDependenciesGeneratorSchema } from './schema'; +import { PackageJson } from '../../types'; +import * as chalk from 'chalk'; + +type ProjectIssues = { [projectName: string]: { [depName: string]: string } }; + +type NormalizedSchema = ReturnType; + +export default async function (tree: Tree, schema: NormalizePackageDependenciesGeneratorSchema) { + const normalizedOptions = normalizeOptions(tree, schema); + + const graph = await createProjectGraphAsync(); + + const filters = getActiveFilters(normalizedOptions); + const projects = getProjects(tree); + const issues: ProjectIssues = {}; + + projects.forEach(projectConfig => { + if (!shouldBeProjectProcessed(projectConfig, filters)) { + return; + } + + if (normalizedOptions.verify) { + const foundIssues = getPackageJsonDependenciesIssues(tree, projectConfig, graph); + + if (foundIssues) { + issues[projectConfig.name!] = foundIssues; + } + return; + } + + normalizePackageJsonDependencies(tree, projectConfig, graph); + }); + + reportPackageJsonDependenciesIssues(issues); + + await formatFiles(tree); +} + +function getActiveFilters(options: NormalizedSchema) { + const active: Partial< + Record, (projectConfig: ProjectConfiguration) => boolean> + > = {}; + + if (options.projectType !== 'any') { + active.projectType = (projectConfig: ProjectConfiguration) => { + return projectConfig.projectType === options.projectType; + }; + } + if (options.tag) { + active.tag = (projectConfig: ProjectConfiguration) => { + return Array.isArray(projectConfig.tags) && projectConfig.tags.includes(options.tag!); + }; + } + + const activeCount = Object.keys(active).length; + + return { + hasActive: activeCount > 0, + filters: active, + }; +} + +function shouldBeProjectProcessed(projectConfig: ProjectConfiguration, filters: ReturnType) { + const filterPredicates = Object.values(filters.filters); + + if (filters.hasActive) { + return filterPredicates.every(predicate => predicate(projectConfig)); + } + + return true; +} + +function normalizePackageJsonDependencies(tree: Tree, projectConfig: ProjectConfiguration, graph: ProjectGraph) { + const projectDependencies = getProjectDependenciesFromGraph(projectConfig.name!, graph); + const packageJsonPath = joinPathFragments(projectConfig.root, 'package.json'); + + updateJson(tree, packageJsonPath, json => { + updateDepType(json, 'dependencies'); + updateDepType(json, 'devDependencies'); + updateDepType(json, 'peerDependencies'); + + return json; + }); + + return tree; + + function updateDepType(json: PackageJson, depType: 'dependencies' | 'devDependencies' | 'peerDependencies') { + const deps = json[depType]; + if (!deps) { + return; + } + + for (const packageName in deps) { + if (isProjectDependencyAnWorkspaceProject(graph, packageName, projectDependencies)) { + deps[packageName] = '*'; + } + } + } +} + +function reportPackageJsonDependenciesIssues(issues: ProjectIssues) { + const issueEntries = Object.entries(issues); + + if (issueEntries.length === 0) { + return; + } + + issueEntries.forEach(([projectName, dependencyIssues]) => { + logger.log(chalk.bold(chalk.red(`${projectName} has following dependency version issues:`))); + // eslint-disable-next-line guard-for-in + for (const dep in dependencyIssues) { + logger.log(chalk.red(` - ${dep}`)); + } + }); + + logger.info(`All these dependencies version should be specified as '*'`); + logger.info(`Fix this by running 'nx workspace-generator normalize-package-dependencies'`); + + throw new Error('package dependency violations found'); +} + +function getPackageJsonDependenciesIssues( + tree: Tree, + projectConfig: ProjectConfiguration, + graph: ProjectGraph, +): Record | null { + const projectDependencies = getProjectDependenciesFromGraph(projectConfig.name!, graph); + const packageJsonPath = joinPathFragments(projectConfig.root, 'package.json'); + const packageJson = readJson(tree, packageJsonPath); + + let issues: Record | null = null; + checkDepType(packageJson, 'dependencies'); + checkDepType(packageJson, 'devDependencies'); + checkDepType(packageJson, 'peerDependencies'); + + return issues; + + function checkDepType(json: PackageJson, depType: 'dependencies' | 'devDependencies' | 'peerDependencies') { + const deps = json[depType]; + if (!deps) { + return null; + } + + for (const packageName in deps) { + if (isProjectDependencyAnWorkspaceProject(graph, packageName, projectDependencies) && deps[packageName] !== '*') { + issues = issues ?? {}; + issues[packageName] = deps[packageName]; + } + } + + return issues; + } +} + +function getProjectDependenciesFromGraph(projectName: string, graph: ProjectGraph): string[] { + const projectDeps = graph.dependencies[projectName]; + return projectDeps.map(value => value.target); +} + +function isProjectDependencyAnWorkspaceProject( + graph: ProjectGraph, + dependencyName: string, + projectDependenciesFromGraph: string[], +) { + const projectDeps = graph.dependencies[dependencyName]; + + if (!projectDeps) { + return false; + } + + return projectDependenciesFromGraph.indexOf(dependencyName) !== -1; +} + +function normalizeOptions(tree: Tree, schema: NormalizePackageDependenciesGeneratorSchema) { + const options = { projectType: 'any', verify: false, ...schema } as const; + + return { + ...options, + }; +} diff --git a/tools/generators/normalize-package-dependencies/lib/.gitkeep b/tools/generators/normalize-package-dependencies/lib/.gitkeep new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tools/generators/normalize-package-dependencies/schema.json b/tools/generators/normalize-package-dependencies/schema.json new file mode 100644 index 00000000000000..414d61e4421627 --- /dev/null +++ b/tools/generators/normalize-package-dependencies/schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/schema", + "cli": "nx", + "id": "normalize-package-dependencies", + "type": "object", + "properties": { + "projectType": { + "type": "string", + "description": "Filter flag. Use to apply generator execution only on projects that contain provided `projectType` within their `project.json#projectType`.", + "enum": ["application", "library", "any"], + "default": "any" + }, + "tag": { + "type": "string", + "description": "Filter flag. Use to apply generator execution only on projects that contain provided tag within their `project.json#tags`." + }, + "verify": { + "type": "boolean", + "description": "Run generator in check(verification mode). Verify package.json dependencies for all projects or filtered projects (if filters are applied)" + } + }, + "required": [] +} diff --git a/tools/generators/normalize-package-dependencies/schema.ts b/tools/generators/normalize-package-dependencies/schema.ts new file mode 100644 index 00000000000000..5029e146bca798 --- /dev/null +++ b/tools/generators/normalize-package-dependencies/schema.ts @@ -0,0 +1,14 @@ +export interface NormalizePackageDependenciesGeneratorSchema { + /** + * Filter flag. Use to apply generator execution only on projects that contain provided `projectType` within their `project.json#projectType`. + */ + projectType?: 'application' | 'library' | 'any'; + /** + * Filter flag. Use to apply generator execution only on projects that contain provided tag within their `project.json#tags`. + */ + tag?: string; + /** + * Run generator in check(verification mode). Verify package.json dependencies for all projects or filtered projects (if filters are applied) + */ + verify?: boolean; +}