From 3eca5f3a9599a7878a6d9016d1c2c16b66cc3588 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Mon, 22 Jul 2024 22:07:29 -0600 Subject: [PATCH] Refactor --- yarn.config.cjs | 799 ++++++++++++++++++++++++++++-------------------- 1 file changed, 467 insertions(+), 332 deletions(-) diff --git a/yarn.config.cjs b/yarn.config.cjs index e23d23d9850..548f434d4b4 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -9,159 +9,33 @@ 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 */ -const REPO_URI = 'https://github.com/MetaMask/core'; - -/** - * 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 {any} [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 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.`, - ); - } +module.exports = defineConfig({ + async constraints({ Yarn }) { + const rootWorkspace = Yarn.workspace({ cwd: '.' }); + if (rootWorkspace === null) { + throw new Error('Could not find root workspace'); + } - 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.`, + const repositoryUri = rootWorkspace.manifest.repository.url.replace( + /\.git$/u, + '', ); - } -} -module.exports = defineConfig({ - async constraints({ Yarn }) { 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 workspaceDependencies = Yarn.dependencies({ workspace }); - - const dependenciesByIdent = new Map(); - for (const dependency of workspaceDependencies) { - const dependenciesForIdent = dependenciesByIdent.get(dependency.ident); - - if (dependenciesForIdent === undefined) { - dependenciesByIdent.set( - dependency.ident, - new Map([[dependency.type, dependency]]), - ); - } else { - dependenciesForIdent.set(dependency.type, dependency); - } - } + const dependenciesByIdentAndType = getDependenciesByIdentAndType( + workspaceDependencies, + ); // All packages must have a name. expectWorkspaceField(workspace, 'name'); @@ -189,17 +63,21 @@ module.exports = defineConfig({ expectWorkspaceField( workspace, 'homepage', - `${REPO_URI}/tree/main/packages/${workspaceBasename}#readme`, + `${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', `${REPO_URI}/issues`); + // 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. + // All non-root packages must specify a Git repository within the + // MetaMask GitHub organization. expectWorkspaceField(workspace, 'repository.type', 'git'); - expectWorkspaceField(workspace, 'repository.url', `${REPO_URI}.git`); + expectWorkspaceField( + workspace, + 'repository.url', + `${repositoryUri}.git`, + ); // If not specified, the license for all non-root packages must be MIT. if ( @@ -215,52 +93,10 @@ module.exports = defineConfig({ expectWorkspaceField(workspace, 'sideEffects', false); } - // 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 - // 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 set up ESM- and CommonJS-compatible + // exports correctly. + expectCorrectWorkspaceExports(workspace); - // All non-root packages must export a `package.json` file. - expectWorkspaceField( - workspace, - 'exports["./package.json"]', - './package.json', - ); - } - - 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 (isChildWorkspace) { // All non-root packages must have the same "build" script. expectWorkspaceField( workspace, @@ -285,25 +121,11 @@ module.exports = defineConfig({ ); } - // All non-root packages must not have a "prepack" script. + // No non-root packages may have a "prepack" script. workspace.unset('scripts.prepack'); - } - if (isChildWorkspace) { - // The "changelog:validate" script for each published package must run a - // common script with the name of the package as the first argument. - 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}".`, - ); - } + // All non-root package must have a valid "changelog:validate" script. + expectCorrectWorkspaceChangelogValidationScript(workspace); // All non-root packages must have the same "test" script. expectWorkspaceField( @@ -334,104 +156,44 @@ module.exports = defineConfig({ ); } + 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. - for (const dependency of workspaceDependencies) { - const dependencyWorkspace = Yarn.workspace({ ident: dependency.ident }); + expectUpToDateWorkspaceDependenciesAndDevDependencies(Yarn, workspace); - if ( - dependencyWorkspace !== null && - dependency.type !== 'peerDependencies' - ) { - dependency.update(`^${dependencyWorkspace.manifest.version}`); - } - } + // 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`. - for (const [ - dependencyIdent, - dependenciesByType, - ] of dependenciesByIdent.entries()) { - if ( - dependenciesByType.size > 1 && - !dependenciesByType.has('peerDependencies') - ) { - workspace.error( - `\`${dependencyIdent}\` cannot be listed in 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. - // - // 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 controller singletons but actually represent abstract - // classes. - for (const dependenciesByType of dependenciesByIdent.values()) { - const dependency = dependenciesByType.get('dependencies'); - - if (dependency === undefined) { - continue; - } - - const dependencyWorkspace = Yarn.workspace({ ident: dependency.ident }); - - if ( - dependencyWorkspace !== null && - dependency.ident.endsWith('-controller') && - dependency.ident !== '@metamask/base-controller' && - dependency.ident !== '@metamask/polling-controller' && - !dependenciesByType.has('peerDependencies') - ) { - const dependencyWorkspaceVersion = new semver.SemVer( - dependencyWorkspace.manifest.version, - ); - expectWorkspaceField( - workspace, - `peerDependencies["${dependency.ident}"]`, - `^${dependencyWorkspaceVersion.major}.0.0`, - ); - } - } - - // If one workspace package lists another workspace package within - // `peerDependencies`, the dependency range must satisfy the current - // version of B. - for (const dependency of workspaceDependencies) { - 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`, - ); - } - } - } + expectControllerDependenciesListedAsPeerDependencies( + Yarn, + workspace, + dependenciesByIdentAndType, + ); // The root workspace (and only the root workspace) must specify the Yarn // version required for development. @@ -464,52 +226,425 @@ module.exports = defineConfig({ } // All version ranges in `dependencies` and `devDependencies` for the same - // dependency across the monorepo must be 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.) - const nonPeerDependenciesByIdent = new Map(); - for (const dependency of Yarn.dependencies()) { - if (dependency.type === 'peerDependencies') { - continue; - } + // dependency across the monorepo must be the same. + expectConsistentDependenciesAndDevDependencies(Yarn); + }, +}); - const dependencyRangesForIdent = nonPeerDependenciesByIdent.get( +/** + * Construct a nested map of dependencies where the first layer is keyed by + * dependency ident and the second layer is keyed by dependency type. + * + * @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); + } + } - if (dependencyRangesForIdent === undefined) { - nonPeerDependenciesByIdent.set( - dependency.ident, - new Map([[dependency.range, [dependency]]]), - ); + return dependenciesByIdentAndType; +} + +/** + * Construct a nested map of dependencies where the first layer is keyed by + * dependency ident and the second layer is keyed by dependency range. + * + * @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 { - const dependenciesForDependencyRange = dependencyRangesForIdent.get( + 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`, ); - - if (dependenciesForDependencyRange === undefined) { - dependencyRangesForIdent.set(dependency.range, [dependency]); - } else { - dependenciesForDependencyRange.push(dependency); - } } } - for (const dependencyRangesForIdent of nonPeerDependenciesByIdent.values()) { - const dependencyRanges = [...dependencyRangesForIdent.keys()].sort(); - if (dependencyRangesForIdent.size > 1) { - for (const dependencies of dependencyRangesForIdent.values()) { - for (const dependency of dependencies) { - dependency.error( - `Expected version range for ${dependency.ident} (in ${ - dependency.type - }) to be consistent across monorepo. Pick one: ${inspect( - dependencyRanges, - )}`, - ); - } + } +} + +/** + * 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, + dependenciesByType, + ] of dependenciesByIdentAndType.entries()) { + if ( + dependenciesByType.size > 1 && + !dependenciesByType.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 controller singletons 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 dependenciesByType of dependenciesByIdentAndType.values()) { + const dependency = dependenciesByType.get('dependencies'); + + if (dependency === undefined) { + continue; + } + + const dependencyWorkspace = Yarn.workspace({ ident: dependency.ident }); + + if ( + dependencyWorkspace !== null && + dependency.ident.endsWith('-controller') && + dependency.ident !== '@metamask/base-controller' && + dependency.ident !== '@metamask/polling-controller' && + !dependenciesByType.has('peerDependencies') + ) { + const dependencyWorkspaceVersion = new semver.SemVer( + dependencyWorkspace.manifest.version, + ); + expectWorkspaceField( + workspace, + `peerDependencies["${dependency.ident}"]`, + `^${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 dependencyRangesForIdent of nonPeerDependenciesByIdent.values()) { + const dependencyRanges = [...dependencyRangesForIdent.keys()].sort(); + if (dependencyRangesForIdent.size > 1) { + for (const dependencies of dependencyRangesForIdent.values()) { + for (const dependency of dependencies) { + dependency.error( + `Expected version range for ${dependency.ident} (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.`, + ); + } +}