diff --git a/.eslintrc.js b/.eslintrc.js index 3b53171107a..f71ff43b3ed 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 babf5729a6d..00000000000 --- 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 07fb2a6cdd9..787636a1c28 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 00000000000..7786e1cea9f --- /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 3e148e6272a..725e1a96ba7 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: