From aa6fa927bbda8042c7174dab68fd7a72f0bb022a Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 26 Jun 2024 17:03:07 -0600 Subject: [PATCH] Convert constraints from Prolog to JavaScript The existing Yarn constraints have been difficult to maintain because they are written in Prolog. More recent versions of Yarn support JavaScript-based constraints, so this commit rewrites the existing constraints in JavaScript. Instead of `constraints.pro`, all constraints are now kept in `yarn.config.cjs`. This file can be used for other things in the future besides constraints. (And constraints can be used in the future to check more than just dependencies!) Note that we have had discussions in the past around the peer dependency constraints, and we know that they are not quite correct. This will be fixed in another PR. --- .eslintrc.js | 7 +- constraints.pro | 430 ------------------------------- package.json | 7 +- yarn.config.cjs | 654 ++++++++++++++++++++++++++++++++++++++++++++++++ yarn.lock | 18 +- 5 files changed, 681 insertions(+), 435 deletions(-) delete mode 100644 constraints.pro create mode 100644 yarn.config.cjs diff --git a/.eslintrc.js b/.eslintrc.js index 3b53171107..f71ff43b3e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -28,10 +28,10 @@ module.exports = { }, }, { - files: ['*.js'], + files: ['*.js', '*.cjs'], parserOptions: { sourceType: 'script', - ecmaVersion: '2018', + ecmaVersion: '2020', }, }, { @@ -121,5 +121,8 @@ module.exports = { 'import/resolver': { typescript: {}, }, + jsdoc: { + mode: 'typescript', + }, }, }; diff --git a/constraints.pro b/constraints.pro deleted file mode 100644 index babf5729a6..0000000000 --- a/constraints.pro +++ /dev/null @@ -1,430 +0,0 @@ -%=============================================================================== -% Utility predicates -%=============================================================================== - -% True if and only if VersionRange is a value that we would expect to see -% following a package in a "*dependencies" field within a `package.json`. -is_valid_version_range(VersionRange) :- - VersionRange = 'workspace:^'; - VersionRange = 'workspace:~'; - parse_version_range(VersionRange, _, _, _, _). - -% Succeeds if Number can be unified with Atom converted to a number; throws if -% not. -atom_to_number(Atom, Number) :- - atom_chars(Atom, Chars), - number_chars(Number, Chars). - -% True if and only if Atom can be converted to a number. -is_atom_number(Atom) :- - catch(atom_to_number(Atom, _), _, false). - -% True if and only if Modifier can be unified with the leading character of the -% version range ("^" or "~" if present, or "" if not present), Major can be -% unified with the major part of the version string, Minor with the minor, and -% Patch with the patch. -parse_version_range(VersionRange, Modifier, Major, Minor, Patch) :- - % Identify and extract the modifier (^ or ~) from the version string - atom_chars(VersionRange, Chars), - Chars = [PossibleModifier | CharsWithoutPossibleModifier], - ( - ( - PossibleModifier = '^'; - PossibleModifier = '~' - ) -> - ( - Modifier = PossibleModifier, - CharsWithoutModifier = CharsWithoutPossibleModifier - ) ; - ( - is_atom_number(PossibleModifier) -> - ( - Modifier = '', - CharsWithoutModifier = Chars - ) ; - false - ) - ), - atomic_list_concat(CharsWithoutModifier, '', VersionRangeWithoutModifier), - atomic_list_concat(VersionParts, '.', VersionRangeWithoutModifier), - % Validate version string while extracting each part - length(VersionParts, 3), - nth0(0, VersionParts, MajorAtom), - nth0(1, VersionParts, MinorAtom), - nth0(2, VersionParts, PatchAtom), - atom_to_number(MajorAtom, Major), - atom_to_number(MinorAtom, Minor), - atom_to_number(PatchAtom, Patch). - -% True if and only if the first SemVer version range is greater than the second -% SemVer version range. Such a range must match "^MAJOR.MINOR.PATCH", -% "~MAJOR.MINOR.PATCH", "MAJOR.MINOR.PATCH". If two ranges do not have the same -% modifier ("^" or "~"), then they cannot be compared and the first cannot be -% considered as less than the second. -% -% Borrowed from: -npm_version_range_out_of_sync(VersionRange1, VersionRange2) :- - parse_version_range(VersionRange1, VersionRange1Modifier, VersionRange1Major, VersionRange1Minor, VersionRange1Patch), - parse_version_range(VersionRange2, VersionRange2Modifier, VersionRange2Major, VersionRange2Minor, VersionRange2Patch), - VersionRange1Modifier == VersionRange2Modifier, - ( - % 2.0.0 > 1.0.0 - % 2.0.0 > 1.1.0 - % 2.0.0 > 1.0.1 - VersionRange1Major @> VersionRange2Major ; - ( - VersionRange1Major == VersionRange2Major , - ( - % 1.1.0 > 1.0.0 - % 1.1.0 > 1.0.1 - VersionRange1Minor @> VersionRange2Minor ; - ( - VersionRange1Minor == VersionRange2Minor , - % 1.0.1 > 1.0.0 - VersionRange1Patch @> VersionRange2Patch - ) - ) - ) - ). - -% True if and only if WorkspaceBasename can unify with the part of the given -% workspace directory name that results from removing all leading directories. -workspace_basename(WorkspaceCwd, WorkspaceBasename) :- - atomic_list_concat(Parts, '/', WorkspaceCwd), - last(Parts, WorkspaceBasename). - -% True if and only if WorkspacePackageName can unify with the name of the -% package which the workspace represents (which comes from the directory where -% the package is located). Assumes that the package is not in a sub-workspace -% and is not private. -workspace_package_name(WorkspaceCwd, WorkspacePackageName) :- - workspace_basename(WorkspaceCwd, WorkspaceBasename), - atom_concat('@metamask/', WorkspaceBasename, WorkspacePackageName). - -% True if RepoName can be unified with the repository name part of RepoUrl, a -% complete URL for a repository on GitHub. This URL must include the ".git" -% extension. -repo_name(RepoUrl, RepoName) :- - Prefix = 'https://github.com/MetaMask/', - atom_length(Prefix, PrefixLength), - Suffix = '.git', - atom_length(Suffix, SuffixLength), - atom_length(RepoUrl, RepoUrlLength), - sub_atom(RepoUrl, 0, PrefixLength, After, Prefix), - sub_atom(RepoUrl, Before, SuffixLength, 0, Suffix), - Start is RepoUrlLength - After + 1, - End is Before + 1, - RepoNameLength is End - Start, - sub_atom(RepoUrl, PrefixLength, RepoNameLength, SuffixLength, RepoName). - -% True if DependencyIdent starts with '@metamask' and ends with '-controller'. -is_controller(DependencyIdent) :- - Prefix = '@metamask/', - atom_length(Prefix, PrefixLength), - Suffix = '-controller', - atom_length(Suffix, SuffixLength), - atom_length(DependencyIdent, DependencyIdentLength), - sub_atom(DependencyIdent, 0, PrefixLength, After, Prefix), - sub_atom(DependencyIdent, Before, SuffixLength, 0, Suffix), - Start is DependencyIdentLength - After + 1, - End is Before + 1, - ControllerNameLength is End - Start, - sub_atom(DependencyIdent, PrefixLength, ControllerNameLength, SuffixLength, _). - -%=============================================================================== -% Constraints -%=============================================================================== - -% All packages, published or otherwise, must have a name. -\+ gen_enforced_field(WorkspaceCwd, 'name', null). - -% The name of the root package can be anything, but the name of a non-root -% package must match its directory (e.g., a package located in "packages/foo" -% must be called "@metamask/foo"). -% -% NOTE: This assumes that the set of non-root workspaces is flat. Nested -% workspaces will be added in a future change. -gen_enforced_field(WorkspaceCwd, 'name', WorkspacePackageName) :- - WorkspaceCwd \= '.', - workspace_package_name(WorkspaceCwd, WorkspacePackageName). - -% All packages, published or otherwise, must have a description. -\+ gen_enforced_field(WorkspaceCwd, 'description', null). -% The description cannot end with a period. -gen_enforced_field(WorkspaceCwd, 'description', DescriptionWithoutTrailingPeriod) :- - workspace_field(WorkspaceCwd, 'description', Description), - atom_length(Description, Length), - LengthLessOne is Length - 1, - sub_atom(Description, LengthLessOne, 1, 0, LastCharacter), - sub_atom(Description, 0, LengthLessOne, 1, DescriptionWithoutPossibleTrailingPeriod), - ( - LastCharacter == '.' -> - DescriptionWithoutTrailingPeriod = DescriptionWithoutPossibleTrailingPeriod ; - DescriptionWithoutTrailingPeriod = Description - ). - -% All published packages must have the same set of NPM keywords. -gen_enforced_field(WorkspaceCwd, 'keywords', ['MetaMask', 'Ethereum']) :- - \+ workspace_field(WorkspaceCwd, 'private', true). -% Non-published packages do not have any NPM keywords. -gen_enforced_field(WorkspaceCwd, 'keywords', null) :- - workspace_field(WorkspaceCwd, 'private', true). - -% The homepage of a published package must match its name (which is in turn -% based on its workspace directory name). -gen_enforced_field(WorkspaceCwd, 'homepage', CorrectHomepageUrl) :- - \+ workspace_field(WorkspaceCwd, 'private', true), - workspace_basename(WorkspaceCwd, WorkspaceBasename), - workspace_field(WorkspaceCwd, 'repository.url', RepoUrl), - repo_name(RepoUrl, RepoName), - atomic_list_concat(['https://github.com/MetaMask/', RepoName, '/tree/main/packages/', WorkspaceBasename, '#readme'], CorrectHomepageUrl). -% Non-published packages do not have a homepage. -gen_enforced_field(WorkspaceCwd, 'homepage', null) :- - workspace_field(WorkspaceCwd, 'private', true). - -% The bugs URL of a published package must point to the Issues page for the -% repository. -gen_enforced_field(WorkspaceCwd, 'bugs.url', CorrectBugsUrl) :- - \+ workspace_field(WorkspaceCwd, 'private', true), - workspace_field(WorkspaceCwd, 'repository.url', RepoUrl), - repo_name(RepoUrl, RepoName), - atomic_list_concat(['https://github.com/MetaMask/', RepoName, '/issues'], CorrectBugsUrl). -% Non-published packages must not have a bugs section. -gen_enforced_field(WorkspaceCwd, 'bugs', null) :- - workspace_field(WorkspaceCwd, 'private', true). - -% All packages must specify Git as the repository type. -gen_enforced_field(WorkspaceCwd, 'repository.type', 'git'). - -% All packages must match the URL of a repo within the MetaMask organization. -gen_enforced_field(WorkspaceCwd, 'repository.url', 'https://github.com/MetaMask/.git') :- - workspace_field(WorkspaceCwd, 'repository.url', RepoUrl), - \+ repo_name(RepoUrl, _). -% The repository URL for non-root packages must match the same URL used for the -% root package. -gen_enforced_field(WorkspaceCwd, 'repository.url', RepoUrl) :- - workspace_field('.', 'repository.url', RepoUrl), - repo_name(RepoUrl, _). - WorkspaceCwd \= '.'. - -% The license for all published packages must be MIT unless otherwise specified. -gen_enforced_field(WorkspaceCwd, 'license', 'MIT') :- - \+ workspace_field(WorkspaceCwd, 'private', true), - WorkspaceCwd \= 'packages/json-rpc-engine', - WorkspaceCwd \= 'packages/json-rpc-middleware-stream', - WorkspaceCwd \= 'packages/permission-log-controller', - WorkspaceCwd \= 'packages/eth-json-rpc-provider'. -% The following published packages use an ISC license instead of MIT. -gen_enforced_field(WorkspaceCwd, 'license', 'ISC') :- - \+ workspace_field(WorkspaceCwd, 'private', true), - ( - WorkspaceCwd == 'packages/json-rpc-engine' ; - WorkspaceCwd == 'packages/json-rpc-middleware-stream' ; - WorkspaceCwd == 'packages/eth-json-rpc-provider' - ). -% The following published packages use a custom license instead of MIT. -gen_enforced_field(WorkspaceCwd, 'license', 'SEE LICENSE IN LICENSE') :- - \+ workspace_field(WorkspaceCwd, 'private', true), - WorkspaceCwd == 'packages/permission-log-controller'. -% Non-published packages do not have a license. -gen_enforced_field(WorkspaceCwd, 'license', null) :- - workspace_field(WorkspaceCwd, 'private', true). - -% The entrypoint for all published packages must be the same. -gen_enforced_field(WorkspaceCwd, 'main', './dist/index.js') :- - \+ workspace_field(WorkspaceCwd, 'private', true). -% Non-published packages must not specify an entrypoint. -gen_enforced_field(WorkspaceCwd, 'main', null) :- - workspace_field(WorkspaceCwd, 'private', true). - -% The type definitions entrypoint for all publishable packages must be the same. -gen_enforced_field(WorkspaceCwd, 'types', './dist/types/index.d.ts') :- - \+ workspace_field(WorkspaceCwd, 'private', true). -% Non-published packages must not specify a type definitions entrypoint. -gen_enforced_field(WorkspaceCwd, 'types', null) :- - workspace_field(WorkspaceCwd, 'private', true). - -% The exports for all published packages must be the same. -gen_enforced_field(WorkspaceCwd, 'exports["."].import', './dist/index.mjs') :- - \+ workspace_field(WorkspaceCwd, 'private', true). -gen_enforced_field(WorkspaceCwd, 'exports["."].require', './dist/index.js') :- - \+ workspace_field(WorkspaceCwd, 'private', true). -gen_enforced_field(WorkspaceCwd, 'exports["."].types', './dist/types/index.d.ts') :- - \+ workspace_field(WorkspaceCwd, 'private', true). -gen_enforced_field(WorkspaceCwd, 'exports["./package.json"]', './package.json') :- - \+ workspace_field(WorkspaceCwd, 'private', true). -% Non-published packages must not specify exports. -gen_enforced_field(WorkspaceCwd, 'exports', null) :- - workspace_field(WorkspaceCwd, 'private', true). - -% Published packages must not have side effects. -gen_enforced_field(WorkspaceCwd, 'sideEffects', false) :- - \+ workspace_field(WorkspaceCwd, 'private', true), - WorkspaceCwd \= 'packages/base-controller'. -% Non-published packages must not specify side effects. -gen_enforced_field(WorkspaceCwd, 'sideEffects', null) :- - workspace_field(WorkspaceCwd, 'private', true). - -% The list of files included in published packages must only include files -% generated during the build step. -gen_enforced_field(WorkspaceCwd, 'files', ['dist/']) :- - \+ workspace_field(WorkspaceCwd, 'private', true). -% The root package must specify an empty set of published files. (This is -% required in order to be able to import anything in development-only scripts, -% as otherwise the `node/no-unpublished-require` ESLint rule will disallow it.) -gen_enforced_field(WorkspaceCwd, 'files', []) :- - WorkspaceCwd = '.'. - -% All non-root packages must have the same "build" script. -gen_enforced_field(WorkspaceCwd, 'scripts.build', 'tsup --config ../../tsup.config.ts --tsconfig ./tsconfig.build.json --clean') :- - WorkspaceCwd \= '.'. - -% All non-root packages must have the same "build:docs" script. -gen_enforced_field(WorkspaceCwd, 'scripts.build:docs', 'typedoc') :- - WorkspaceCwd \= '.'. - -% All published packages must have the same "publish:preview" script. -gen_enforced_field(WorkspaceCwd, 'scripts.publish:preview', 'yarn npm publish --tag preview') :- - \+ workspace_field(WorkspaceCwd, 'private', true). - -% All published packages must not have a "prepack" script. -gen_enforced_field(WorkspaceCwd, 'scripts.prepack', null) :- - \+ workspace_field(WorkspaceCwd, 'private', true). - -% The "changelog:validate" script for each published package must run a common -% script with the name of the package as the first argument. -gen_enforced_field(WorkspaceCwd, 'scripts.changelog:validate', CorrectChangelogValidationCommand) :- - \+ workspace_field(WorkspaceCwd, 'private', true), - workspace_field(WorkspaceCwd, 'scripts.changelog:validate', ChangelogValidationCommand), - workspace_package_name(WorkspaceCwd, WorkspacePackageName), - atomic_list_concat(['../../scripts/validate-changelog.sh ', WorkspacePackageName, ' [...]'], CorrectChangelogValidationCommand), - atom_concat('../../scripts/validate-changelog.sh ', WorkspacePackageName, ExpectedPrefix), - \+ atom_concat(ExpectedPrefix, _, ChangelogValidationCommand). - -% The "changelog:update" script for each published package must run a common -% script with the name of the package as the first argument. -gen_enforced_field(WorkspaceCwd, 'scripts.changelog:update', CorrectChangelogUpdateCommand) :- - \+ workspace_field(WorkspaceCwd, 'private', true), - workspace_field(WorkspaceCwd, 'scripts.changelog:update', ChangelogUpdateCommand), - workspace_package_name(WorkspaceCwd, WorkspacePackageName), - atomic_list_concat(['../../scripts/update-changelog.sh ', WorkspacePackageName, ' [...]'], CorrectChangelogUpdateCommand), - atom_concat('../../scripts/update-changelog.sh ', WorkspacePackageName, ExpectedPrefix), - \+ atom_concat(ExpectedPrefix, _, ChangelogUpdateCommand). - -% All non-root packages must have the same "test" script. -gen_enforced_field(WorkspaceCwd, 'scripts.test', 'NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter') :- - WorkspaceCwd \= '.'. - -% All non-root packages must have the same "test:clean" script. -gen_enforced_field(WorkspaceCwd, 'scripts.test:clean', 'NODE_OPTIONS=--experimental-vm-modules jest --clearCache') :- - WorkspaceCwd \= '.'. - -% All non-root packages must have the same "test:verbose" script. -gen_enforced_field(WorkspaceCwd, 'scripts.test:verbose', 'NODE_OPTIONS=--experimental-vm-modules jest --verbose') :- - WorkspaceCwd \= '.'. - -% All non-root packages must have the same "test:watch" script. -gen_enforced_field(WorkspaceCwd, 'scripts.test:watch', 'NODE_OPTIONS=--experimental-vm-modules jest --watch') :- - WorkspaceCwd \= '.'. - -% All dependency ranges must be recognizable (this makes it possible to apply -% the next two rules effectively). -gen_enforced_dependency(WorkspaceCwd, DependencyIdent, 'a range optionally starting with ^ or ~', DependencyType) :- - workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType), - \+ is_valid_version_range(DependencyRange). - -% All version ranges used to reference one workspace package in another -% workspace package's `dependencies` or `devDependencies` must be the same. -% Among all references to the same dependency across the monorepo, the one with -% the smallest version range will win. (We handle `peerDependencies` in another -% constraint, as it has slightly different logic.) -gen_enforced_dependency(WorkspaceCwd, DependencyIdent, OtherDependencyRange, DependencyType) :- - workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType), - workspace_has_dependency(OtherWorkspaceCwd, DependencyIdent, OtherDependencyRange, OtherDependencyType), - WorkspaceCwd \= OtherWorkspaceCwd, - DependencyRange \= OtherDependencyRange, - npm_version_range_out_of_sync(DependencyRange, OtherDependencyRange), - DependencyType \= 'peerDependencies', - OtherDependencyType \= 'peerDependencies'. - -% All version ranges used to reference one workspace package in another -% workspace package's `dependencies` or `devDependencies` must match the current -% version of that package. (We handle `peerDependencies` in another rule.) -gen_enforced_dependency(WorkspaceCwd, DependencyIdent, CorrectDependencyRange, DependencyType) :- - DependencyType \= 'peerDependencies', - workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType), - workspace_ident(OtherWorkspaceCwd, DependencyIdent), - workspace_version(OtherWorkspaceCwd, OtherWorkspaceVersion), - atomic_list_concat(['^', OtherWorkspaceVersion], CorrectDependencyRange). - -% If a workspace package is listed under another workspace package's -% `dependencies`, it should not also be listed under its `devDependencies`. -gen_enforced_dependency(WorkspaceCwd, DependencyIdent, null, 'devDependencies') :- - workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, 'dependencies'). - -% Each controller is a singleton, so we need to ensure the versions -% used match expectations. To accomplish this, if a controller (other than -% `base-controller`, `eth-keyring-controller` and `polling-controller`) is -% listed under a workspace package's `dependencies`, it should also be listed -% under its `peerDependencies`, and the major version of the peer dependency -% should match the major part of the current version dependency, with the minor -% and patch parts set to 0. If it is already listed there, then the major -% version should match the current version of the package and the minor and -% patch parts should be <= the corresponding parts. -gen_enforced_dependency(WorkspaceCwd, DependencyIdent, CorrectPeerDependencyRange, 'peerDependencies') :- - workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, 'dependencies'), - \+ workspace_has_dependency(WorkspaceCwd, DependencyIdent, _, 'peerDependencies'), - is_controller(DependencyIdent), - DependencyIdent \= '@metamask/base-controller', - DependencyIdent \= '@metamask/eth-keyring-controller', - DependencyIdent \= '@metamask/polling-controller', - workspace_ident(DependencyWorkspaceCwd, DependencyIdent), - workspace_version(DependencyWorkspaceCwd, CurrentDependencyWorkspaceVersion), - parse_version_range(CurrentDependencyWorkspaceVersion, _, CurrentDependencyVersionMajor, _, _), - atomic_list_concat([CurrentDependencyVersionMajor, 0, 0], '.', CorrectPeerDependencyVersion), - atom_concat('^', CorrectPeerDependencyVersion, CorrectPeerDependencyRange). -gen_enforced_dependency(WorkspaceCwd, DependencyIdent, CorrectPeerDependencyRange, 'peerDependencies') :- - workspace_has_dependency(WorkspaceCwd, DependencyIdent, SpecifiedPeerDependencyRange, 'peerDependencies'), - is_controller(DependencyIdent), - DependencyIdent \= '@metamask/base-controller', - DependencyIdent \= '@metamask/eth-keyring-controller', - DependencyIdent \= '@metamask/polling-controller', - workspace_ident(DependencyWorkspaceCwd, DependencyIdent), - workspace_version(DependencyWorkspaceCwd, CurrentDependencyVersion), - parse_version_range(CurrentDependencyVersion, _, CurrentDependencyVersionMajor, CurrentDependencyVersionMinor, CurrentDependencyVersionPatch), - parse_version_range(SpecifiedPeerDependencyRange, _, SpecifiedPeerDependencyVersionMajor, SpecifiedPeerDependencyVersionMinor, SpecifiedPeerDependencyVersionPatch), - ( - ( - SpecifiedPeerDependencyVersionMajor == CurrentDependencyVersionMajor, - ( - SpecifiedPeerDependencyVersionMinor @< CurrentDependencyVersionMinor ; - ( - SpecifiedPeerDependencyVersionMinor == CurrentDependencyVersionMinor, - SpecifiedPeerDependencyVersionPatch @=< CurrentDependencyVersionPatch - ) - ) - ) -> - CorrectPeerDependencyRange = SpecifiedPeerDependencyRange ; - atom_concat('^', CurrentDependencyVersion, CorrectPeerDependencyRange) - ). - -% The root workspace (and only the root workspace) needs to specify the Yarn -% version required for development. -gen_enforced_field(WorkspaceCwd, 'packageManager', 'yarn@4.2.2') :- - WorkspaceCwd == '.'. -gen_enforced_field(WorkspaceCwd, 'packageManager', null) :- - WorkspaceCwd \= '.'. - -% All packages must specify a minimum Node version of 18. -gen_enforced_field(WorkspaceCwd, 'engines.node', '^18.18 || >=20'). - -% All published packages are public. -gen_enforced_field(WorkspaceCwd, 'publishConfig.access', 'public') :- - \+ workspace_field(WorkspaceCwd, 'private', true). -% All published packages are available on the NPM registry. -gen_enforced_field(WorkspaceCwd, 'publishConfig.registry', 'https://registry.npmjs.org/') :- - \+ workspace_field(WorkspaceCwd, 'private', true). -% Non-published packages do not need to specify any publishing settings -% whatsoever. -gen_enforced_field(WorkspaceCwd, 'publishConfig', null) :- - workspace_field(WorkspaceCwd, 'private', true). diff --git a/package.json b/package.json index 07fb2a6cdd..787636a1c2 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck && yarn dedupe --check", "lint:dependencies:fix": "depcheck && yarn dedupe", - "lint:eslint": "eslint . --cache --ext js,ts", + "lint:eslint": "eslint . --cache --ext js,cjs,mjs,ts", "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies:fix", "lint:misc": "prettier '**/*.json' '**/*.md' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path .gitignore", "prepack": "./scripts/prepack.sh", @@ -61,9 +61,12 @@ "@metamask/json-rpc-engine": "^9.0.2", "@metamask/utils": "^9.1.0", "@types/jest": "^27.4.1", + "@types/lodash": "^4.14.191", "@types/node": "^16.18.54", + "@types/semver": "^7", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", + "@yarnpkg/types": "^4.0.0", "babel-jest": "^27.5.1", "depcheck": "^1.4.7", "eslint": "^8.44.0", @@ -80,10 +83,12 @@ "isomorphic-fetch": "^3.0.0", "jest": "^27.5.1", "jest-silent-reporter": "^0.5.0", + "lodash": "^4.17.21", "nock": "^13.3.1", "prettier": "^2.7.1", "prettier-plugin-packagejson": "^2.4.5", "rimraf": "^5.0.5", + "semver": "^7.6.3", "simple-git-hooks": "^2.8.0", "ts-node": "^10.9.1", "tsup": "^8.0.2", diff --git a/yarn.config.cjs b/yarn.config.cjs new file mode 100644 index 0000000000..7786e1cea9 --- /dev/null +++ b/yarn.config.cjs @@ -0,0 +1,654 @@ +/** @type {import('@yarnpkg/types')} */ +const { defineConfig } = require('@yarnpkg/types'); +const { readFile } = require('fs/promises'); +const { get } = require('lodash'); +const { basename, resolve } = require('path'); +const semver = require('semver'); +const { inspect } = require('util'); + +/** + * Aliases for the Yarn type definitions, to make the code more readable. + * + * @typedef {import('@yarnpkg/types').Yarn} Yarn + * @typedef {import('@yarnpkg/types').Yarn.Constraints.Workspace} Workspace + * @typedef {import('@yarnpkg/types').Yarn.Constraints.Dependency} Dependency + * @typedef {import('@yarnpkg/types').Yarn.Constraints.DependencyType} DependencyType + */ + +module.exports = defineConfig({ + async constraints({ Yarn }) { + const rootWorkspace = Yarn.workspace({ cwd: '.' }); + if (rootWorkspace === null) { + throw new Error('Could not find root workspace'); + } + + const repositoryUri = rootWorkspace.manifest.repository.url.replace( + /\.git$/u, + '', + ); + + for (const workspace of Yarn.workspaces()) { + const workspaceBasename = getWorkspaceBasename(workspace); + const isChildWorkspace = workspace.cwd !== '.'; + const isPrivate = + 'private' in workspace.manifest && workspace.manifest.private === true; + const dependenciesByIdentAndType = getDependenciesByIdentAndType( + Yarn.dependencies({ workspace }), + ); + + // All packages must have a name. + expectWorkspaceField(workspace, 'name'); + + if (isChildWorkspace) { + // All non-root packages must have a name that matches its directory + // (e.g., a package in a workspace directory called `foo` must be called + // `@metamask/foo`). + expectWorkspaceField( + workspace, + 'name', + `@metamask/${workspaceBasename}`, + ); + + // All non-root packages must have a version. + expectWorkspaceField(workspace, 'version'); + + // All non-root packages must have a description that ends in a period. + expectWorkspaceDescription(workspace); + + // All non-root packages must have the same set of NPM keywords. + expectWorkspaceField(workspace, 'keywords', ['MetaMask', 'Ethereum']); + + // All non-root packages must have a homepage URL that includes its name. + expectWorkspaceField( + workspace, + 'homepage', + `${repositoryUri}/tree/main/packages/${workspaceBasename}#readme`, + ); + + // All non-root packages must have a URL for reporting bugs that points + // to the Issues page for the repository. + expectWorkspaceField(workspace, 'bugs.url', `${repositoryUri}/issues`); + + // All non-root packages must specify a Git repository within the + // MetaMask GitHub organization. + expectWorkspaceField(workspace, 'repository.type', 'git'); + expectWorkspaceField( + workspace, + 'repository.url', + `${repositoryUri}.git`, + ); + + // If not specified, the license for all non-root packages must be MIT. + if ( + workspace.manifest.license === null || + workspace.manifest.license === undefined + ) { + expectWorkspaceField(workspace, 'license', 'MIT'); + } + + // All non-root packages must not have side effects. (An exception is + // made for `@metamask/base-controller`). + if (workspace.ident !== '@metamask/base-controller') { + expectWorkspaceField(workspace, 'sideEffects', false); + } + + // All non-root packages must set up ESM- and CommonJS-compatible + // exports correctly. + expectCorrectWorkspaceExports(workspace); + + // All non-root packages must have the same "build" script. + expectWorkspaceField( + workspace, + 'scripts.build', + 'tsup --config ../../tsup.config.ts --tsconfig ./tsconfig.build.json --clean', + ); + + // All non-root packages must have the same "build:docs" script. + expectWorkspaceField(workspace, 'scripts.build:docs', 'typedoc'); + + if (isPrivate) { + // All private, non-root packages must not have a "publish:preview" + // script. + workspace.unset('scripts.publish:preview'); + } else { + // All non-private, non-root packages must have the same + // "publish:preview" script. + expectWorkspaceField( + workspace, + 'scripts.publish:preview', + 'yarn npm publish --tag preview', + ); + } + + // No non-root packages may have a "prepack" script. + workspace.unset('scripts.prepack'); + + // All non-root package must have a valid "changelog:validate" script. + expectCorrectWorkspaceChangelogValidationScript(workspace); + + // All non-root packages must have the same "test" script. + expectWorkspaceField( + workspace, + 'scripts.test', + 'NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter', + ); + + // All non-root packages must have the same "test:clean" script. + expectWorkspaceField( + workspace, + 'scripts.test:clean', + 'NODE_OPTIONS=--experimental-vm-modules jest --clearCache', + ); + + // All non-root packages must have the same "test:verbose" script. + expectWorkspaceField( + workspace, + 'scripts.test:verbose', + 'NODE_OPTIONS=--experimental-vm-modules jest --verbose', + ); + + // All non-root packages must have the same "test:watch" script. + expectWorkspaceField( + workspace, + 'scripts.test:watch', + 'NODE_OPTIONS=--experimental-vm-modules jest --watch', + ); + } + + if (isChildWorkspace) { + // The list of files included in all non-root packages must only include + // files generated during the build process. + expectWorkspaceField(workspace, 'files', ['dist/']); + } else { + // The root package must specify an empty set of published files. (This + // is required in order to be able to import anything in + // development-only scripts, as otherwise the + // `node/no-unpublished-require` ESLint rule will disallow it.) + expectWorkspaceField(workspace, 'files', []); + } + + // If one workspace package lists another workspace package within + // `dependencies` or `devDependencies`, the version used within the + // dependency range must match the current version of the dependency. + expectUpToDateWorkspaceDependenciesAndDevDependencies(Yarn, workspace); + + // If one workspace package lists another workspace package within + // `peerDependencies`, the dependency range must satisfy the current + // version of that package. + expectUpToDateWorkspacePeerDependencies(Yarn, workspace); + + // No dependency may be listed under both `dependencies` and + // `devDependencies`. + expectDependenciesNotInBothProdAndDev( + workspace, + dependenciesByIdentAndType, + ); + + // If one workspace package (A) lists another workspace package (B) in its + // `dependencies`, and B is a controller package, then we need to ensure + // that B is also listed in A's `peerDependencies` and that the version + // range satisfies the current version of B. + expectControllerDependenciesListedAsPeerDependencies( + Yarn, + workspace, + dependenciesByIdentAndType, + ); + + // The root workspace (and only the root workspace) must specify the Yarn + // version required for development. + if (isChildWorkspace) { + workspace.unset('packageManager'); + } else { + expectWorkspaceField(workspace, 'packageManager', 'yarn@4.2.2'); + } + + // All packages must specify a minimum Node.js version of 18.18. + expectWorkspaceField(workspace, 'engines.node', '^18.18 || >=20'); + + // All non-root public packages should be published to the NPM registry; + // all non-root private packages should not. + if (isPrivate) { + workspace.unset('publishConfig'); + } else { + expectWorkspaceField(workspace, 'publishConfig.access', 'public'); + expectWorkspaceField( + workspace, + 'publishConfig.registry', + 'https://registry.npmjs.org/', + ); + } + + if (isChildWorkspace) { + // All non-root packages must have a valid README.md file. + await expectReadme(workspace, workspaceBasename); + } + } + + // All version ranges in `dependencies` and `devDependencies` for the same + // dependency across the monorepo must be the same. + expectConsistentDependenciesAndDevDependencies(Yarn); + }, +}); + +/** + * Construct a nested map of dependencies. The inner layer categorizes + * instances of the same dependency by its location in the manifest; the outer + * layer categorizes the inner layer by the name of the dependency. + * + * @param {Dependency[]} dependencies - The list of dependencies to transform. + * @returns {Map>} The resulting map. + */ +function getDependenciesByIdentAndType(dependencies) { + const dependenciesByIdentAndType = new Map(); + + for (const dependency of dependencies) { + const dependenciesForIdent = dependenciesByIdentAndType.get( + dependency.ident, + ); + + if (dependenciesForIdent === undefined) { + dependenciesByIdentAndType.set( + dependency.ident, + new Map([[dependency.type, dependency]]), + ); + } else { + dependenciesForIdent.set(dependency.type, dependency); + } + } + + return dependenciesByIdentAndType; +} + +/** + * Construct a nested map of non-peer dependencies (`dependencies` and + * `devDependencies`). The inner layer categorizes instances of the same + * dependency by the version range specified; the outer layer categorizes the + * inner layer by the name of the dependency itself. + * + * @param {Dependency[]} dependencies - The list of dependencies to transform. + * @returns {Map>} The resulting map. + */ +function getNonPeerDependenciesByIdent(dependencies) { + const nonPeerDependenciesByIdent = new Map(); + + for (const dependency of dependencies) { + if (dependency.type === 'peerDependencies') { + continue; + } + + const dependencyRangesForIdent = nonPeerDependenciesByIdent.get( + dependency.ident, + ); + + if (dependencyRangesForIdent === undefined) { + nonPeerDependenciesByIdent.set( + dependency.ident, + new Map([[dependency.range, [dependency]]]), + ); + } else { + const dependenciesForDependencyRange = dependencyRangesForIdent.get( + dependency.range, + ); + + if (dependenciesForDependencyRange === undefined) { + dependencyRangesForIdent.set(dependency.range, [dependency]); + } else { + dependenciesForDependencyRange.push(dependency); + } + } + } + + return nonPeerDependenciesByIdent; +} + +/** + * Get the basename of the workspace's directory. The workspace directory is + * expected to be in the form `/`, and this function + * will extract ``. + * + * @param {Workspace} workspace - The workspace. + * @returns {string} The name of the workspace. + */ +function getWorkspaceBasename(workspace) { + return basename(workspace.cwd); +} + +/** + * Get the absolute path to a file within the workspace. + * + * @param {Workspace} workspace - The workspace. + * @param {string} path - The path to the file, relative to the workspace root. + * @returns {string} The absolute path to the file. + */ +function getWorkspacePath(workspace, path) { + return resolve(__dirname, workspace.cwd, path); +} + +/** + * Get the contents of a file within the workspace. The file is expected to be + * encoded as UTF-8. + * + * @param {Workspace} workspace - The workspace. + * @param {string} path - The path to the file, relative to the workspace root. + * @returns {Promise} The contents of the file. + */ +async function getWorkspaceFile(workspace, path) { + return await readFile(getWorkspacePath(workspace, path), 'utf8'); +} + +/** + * Expect that the workspace has the given field, and that it is a non-null + * value. If the field is not present, or is null, this will log an error, and + * cause the constraint to fail. + * + * If a value is provided, this will also verify that the field is equal to the + * given value. + * + * @param {Workspace} workspace - The workspace to check. + * @param {string} fieldName - The field to check. + * @param {unknown} [expectedValue] - The value to check. + */ +function expectWorkspaceField(workspace, fieldName, expectedValue = undefined) { + const fieldValue = get(workspace.manifest, fieldName); + + if (expectedValue) { + workspace.set(fieldName, expectedValue); + } else if (fieldValue === undefined || fieldValue === null) { + workspace.error(`Missing required field "${fieldName}".`); + } +} + +/** + * Expect that the workspace has a description, and that it is a non-empty + * string. If the description is not present, or is null, this will log an + * error, and cause the constraint to fail. + * + * This will also verify that the description does not end with a period. + * + * @param {Workspace} workspace - The workspace to check. + */ +function expectWorkspaceDescription(workspace) { + expectWorkspaceField(workspace, 'description'); + + const { description } = workspace.manifest; + + if (typeof description !== 'string') { + workspace.error( + `Expected description to be a string, but got ${typeof description}.`, + ); + return; + } + + if (description === '') { + workspace.error(`Expected description not to be an empty string.`); + return; + } + + if (description.endsWith('.')) { + workspace.set('description', description.slice(0, -1)); + } +} + +/** + * Expect that the workspace has exports set up correctly. + * + * @param {Workspace} workspace - The workspace to check. + */ +function expectCorrectWorkspaceExports(workspace) { + // All non-root packages must provide the location of the ESM-compatible + // JavaScript entrypoint and its matching type declaration file. + expectWorkspaceField(workspace, 'exports["."].import', './dist/index.mjs'); + expectWorkspaceField( + workspace, + 'exports["."].types', + './dist/types/index.d.ts', + ); + // TODO: This was copied from the module template: enable when ready + // expectWorkspaceField(workspace, 'module', './dist/index.mjs'); + + // All non-root package must provide the location of the CommonJS-compatible + // entrypoint and its matching type declaration file. + expectWorkspaceField(workspace, 'exports["."].require', './dist/index.js'); + expectWorkspaceField(workspace, 'main', './dist/index.js'); + expectWorkspaceField(workspace, 'types', './dist/types/index.d.ts'); + + // All non-root packages must export a `package.json` file. + expectWorkspaceField( + workspace, + 'exports["./package.json"]', + './package.json', + ); +} + +/** + * Expect that the workspace has a "changelog:validate" script which runs a + * common script with the name of the package as the first argument. + * + * @param {Workspace} workspace - The workspace to check. + */ +function expectCorrectWorkspaceChangelogValidationScript(workspace) { + const expectedStartString = `../../scripts/validate-changelog.sh ${workspace.manifest.name}`; + const changelogValidationScript = + workspace.manifest.scripts['changelog:validate'] ?? ''; + expectWorkspaceField(workspace, 'scripts.changelog:validate'); + if ( + changelogValidationScript !== '' && + !changelogValidationScript.startsWith(expectedStartString) + ) { + workspace.error( + `Expected package's "changelog:validate" script to be or start with "${expectedStartString}", but it was "${changelogValidationScript}".`, + ); + } +} + +/** + * Expect that if the workspace package lists another workspace package within + * `dependencies` or `devDependencies`, the version used within the dependency + * range is exactly equal to the current version of the dependency (and the + * range uses the `^` modifier). + * + * @param {Yarn} Yarn - The Yarn "global". + * @param {Workspace} workspace - The workspace to check. + */ +function expectUpToDateWorkspaceDependenciesAndDevDependencies( + Yarn, + workspace, +) { + for (const dependency of Yarn.dependencies({ workspace })) { + const dependencyWorkspace = Yarn.workspace({ ident: dependency.ident }); + + if ( + dependencyWorkspace !== null && + dependency.type !== 'peerDependencies' + ) { + dependency.update(`^${dependencyWorkspace.manifest.version}`); + } + } +} + +/** + * Expect that if the workspace package lists another workspace package within + * `peerDependencies`, the dependency range satisfies the current version of + * that package. + * + * @param {Yarn} Yarn - The Yarn "global". + * @param {Workspace} workspace - The workspace to check. + */ +function expectUpToDateWorkspacePeerDependencies(Yarn, workspace) { + for (const dependency of Yarn.dependencies({ workspace })) { + const dependencyWorkspace = Yarn.workspace({ ident: dependency.ident }); + + if ( + dependencyWorkspace !== null && + dependency.type === 'peerDependencies' + ) { + const dependencyWorkspaceVersion = new semver.SemVer( + dependencyWorkspace.manifest.version, + ); + if ( + !semver.satisfies( + dependencyWorkspace.manifest.version, + dependency.range, + ) + ) { + expectWorkspaceField( + workspace, + `peerDependencies["${dependency.ident}"]`, + `^${dependencyWorkspaceVersion.major}.0.0`, + ); + } + } + } +} + +/** + * Expect that a workspace package does not list a dependency in both + * `dependencies` and `devDependencies`. + * + * @param {Workspace} workspace - The workspace to check. + * @param {Map} dependenciesByIdentAndType - Map of + * dependency ident to dependency type and dependency. + */ +function expectDependenciesNotInBothProdAndDev( + workspace, + dependenciesByIdentAndType, +) { + for (const [ + dependencyIdent, + dependencyInstancesByType, + ] of dependenciesByIdentAndType.entries()) { + if ( + dependencyInstancesByType.size > 1 && + !dependencyInstancesByType.has('peerDependencies') + ) { + workspace.error( + `\`${dependencyIdent}\` cannot be listed in both \`dependencies\` and \`devDependencies\``, + ); + } + } +} + +/** + * Expect that if the workspace package lists another workspace package in its + * dependencies, and it is a controller package, that the controller package is + * listed in the workspace's `peerDependencies` and the version range satisfies + * the current version of the controller package. + * + * The expectation in this case is that the client will instantiate B in order + * to pass it into A. Therefore, it needs to list not only A as a dependency, + * but also B. Additionally, the version of B that the client is using with A + * needs to match the version that A itself is expecting internally. + * + * Note that this constraint does not apply for packages that seem to represent + * instantiable controllers but actually represent abstract classes. + * + * @param {Yarn} Yarn - The Yarn "global". + * @param {Workspace} workspace - The workspace to check. + * @param {Map} dependenciesByIdentAndType - Map of + * dependency ident to dependency type and dependency. + */ +function expectControllerDependenciesListedAsPeerDependencies( + Yarn, + workspace, + dependenciesByIdentAndType, +) { + for (const [ + dependencyIdent, + dependencyInstancesByType, + ] of dependenciesByIdentAndType.entries()) { + if (!dependencyInstancesByType.has('dependencies')) { + continue; + } + + const dependencyWorkspace = Yarn.workspace({ ident: dependencyIdent }); + + if ( + dependencyWorkspace !== null && + dependencyIdent.endsWith('-controller') && + dependencyIdent !== '@metamask/base-controller' && + dependencyIdent !== '@metamask/polling-controller' && + !dependencyInstancesByType.has('peerDependencies') + ) { + const dependencyWorkspaceVersion = new semver.SemVer( + dependencyWorkspace.manifest.version, + ); + expectWorkspaceField( + workspace, + `peerDependencies["${dependencyIdent}"]`, + `^${dependencyWorkspaceVersion.major}.0.0`, + ); + } + } +} + +/** + * Expect that all version ranges in `dependencies` and `devDependencies` for + * the same dependency across the entire monorepo are the same. As it is + * impossible to compare NPM version ranges, let the user decide if there are + * conflicts. (`peerDependencies` is a special case, and we handle that + * particularly for workspace packages elsewhere.) + * + * @param {Yarn} Yarn - The Yarn "global". + */ +function expectConsistentDependenciesAndDevDependencies(Yarn) { + const nonPeerDependenciesByIdent = getNonPeerDependenciesByIdent( + Yarn.dependencies(), + ); + + for (const [ + dependencyIdent, + dependenciesByRange, + ] of nonPeerDependenciesByIdent.entries()) { + const dependencyRanges = [...dependenciesByRange.keys()].sort(); + if (dependenciesByRange.size > 1) { + for (const dependencies of dependenciesByRange.values()) { + for (const dependency of dependencies) { + dependency.error( + `Expected version range for ${dependencyIdent} (in ${ + dependency.type + }) to be consistent across monorepo. Pick one: ${inspect( + dependencyRanges, + )}`, + ); + } + } + } + } +} + +/** + * Expect that the workspace has a README.md file, and that it is a non-empty + * string. The README.md is expected to: + * + * - Not contain template instructions (unless the workspace is the module + * template itself). + * - Match the version of Node.js specified in the `.nvmrc` file. + * + * @param {Workspace} workspace - The workspace to check. + * @param {string} workspaceBasename - The name of the workspace. + * @returns {Promise} + */ +async function expectReadme(workspace, workspaceBasename) { + const readme = await getWorkspaceFile(workspace, 'README.md'); + + if ( + workspaceBasename !== 'metamask-module-template' && + readme.includes('## Template Instructions') + ) { + workspace.error( + 'The README.md contains template instructions. These instructions should be removed.', + ); + } + + if (!readme.includes(`yarn add @metamask/${workspaceBasename}`)) { + workspace.error( + `The README.md does not contain an example of how to install the package using Yarn (\`yarn add @metamask/${workspaceBasename}\`). Please add an example.`, + ); + } + + if (!readme.includes(`npm install @metamask/${workspaceBasename}`)) { + workspace.error( + `The README.md does not contain an example of how to install the package using npm (\`npm install @metamask/${workspaceBasename}\`). Please add an example.`, + ); + } +} diff --git a/yarn.lock b/yarn.lock index 3e148e6272..725e1a96ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2566,9 +2566,12 @@ __metadata: "@metamask/json-rpc-engine": "npm:^9.0.2" "@metamask/utils": "npm:^9.1.0" "@types/jest": "npm:^27.4.1" + "@types/lodash": "npm:^4.14.191" "@types/node": "npm:^16.18.54" + "@types/semver": "npm:^7" "@typescript-eslint/eslint-plugin": "npm:^5.62.0" "@typescript-eslint/parser": "npm:^5.62.0" + "@yarnpkg/types": "npm:^4.0.0" babel-jest: "npm:^27.5.1" depcheck: "npm:^1.4.7" eslint: "npm:^8.44.0" @@ -2585,10 +2588,12 @@ __metadata: isomorphic-fetch: "npm:^3.0.0" jest: "npm:^27.5.1" jest-silent-reporter: "npm:^0.5.0" + lodash: "npm:^4.17.21" nock: "npm:^13.3.1" prettier: "npm:^2.7.1" prettier-plugin-packagejson: "npm:^2.4.5" rimraf: "npm:^5.0.5" + semver: "npm:^7.6.3" simple-git-hooks: "npm:^2.8.0" ts-node: "npm:^10.9.1" tsup: "npm:^8.0.2" @@ -4837,7 +4842,7 @@ __metadata: languageName: node linkType: hard -"@types/semver@npm:^7.3.12, @types/semver@npm:^7.3.6": +"@types/semver@npm:^7, @types/semver@npm:^7.3.12, @types/semver@npm:^7.3.6": version: 7.5.8 resolution: "@types/semver@npm:7.5.8" checksum: 10/3496808818ddb36deabfe4974fd343a78101fa242c4690044ccdc3b95dcf8785b494f5d628f2f47f38a702f8db9c53c67f47d7818f2be1b79f2efb09692e1178 @@ -5114,6 +5119,15 @@ __metadata: languageName: node linkType: hard +"@yarnpkg/types@npm:^4.0.0": + version: 4.0.0 + resolution: "@yarnpkg/types@npm:4.0.0" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10/f9670e120761a4d17461df2f01aa4b92213fbdd063501a36145d11ea01bd87ba01d44615cba3d6bc8f9bfc39a03a9a6452bf0436c7fb0c9c5311352b975349e6 + languageName: node + linkType: hard + "abab@npm:^2.0.3, abab@npm:^2.0.5": version: 2.0.6 resolution: "abab@npm:2.0.6" @@ -11826,7 +11840,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.x, semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0": +"semver@npm:7.x, semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3": version: 7.6.3 resolution: "semver@npm:7.6.3" bin: