From f204d33b81d6c1087805cf9530371ec79023cfcf Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Sun, 12 Nov 2023 15:30:26 -0700 Subject: [PATCH] Add remaining code to complete MVP All of the commits up to now have been adding code to support an MVP version of this tool. This commit ties up the MVP by adding a first rule, which merely verifies that a project has a `src/` directory, the entrypoint function to the tool, and the executable which kicks off the whole thing. This commit also adds a package script, `run-tool`, which can be used in development to quickly smoke test new changes. --- .eslintrc.js | 12 + .gitignore | 3 + jest.config.js | 2 +- package.json | 5 +- src/cli.ts | 22 ++ src/constants.ts | 124 ++++++++ src/establish-metamask-repository.ts | 3 + src/main.test.ts | 416 ++++++++++++++++++++++++++ src/main.ts | 245 +++++++++++++++ src/misc-utils.test.ts | 48 ++- src/misc-utils.ts | 24 ++ src/output-logger.ts | 2 +- src/rules/helpers.ts | 54 ++++ src/rules/index.ts | 3 + src/rules/require-source-directory.ts | 19 ++ src/rules/types.ts | 6 + yarn.lock | 5 +- 17 files changed, 988 insertions(+), 5 deletions(-) create mode 100644 src/cli.ts create mode 100644 src/main.test.ts create mode 100644 src/main.ts create mode 100644 src/rules/helpers.ts create mode 100644 src/rules/index.ts create mode 100644 src/rules/require-source-directory.ts create mode 100644 src/rules/types.ts diff --git a/.eslintrc.js b/.eslintrc.js index 497457a..09ee86a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -27,6 +27,18 @@ module.exports = { '@metamask/eslint-config-nodejs', ], }, + + { + files: ['src/cli.ts'], + parserOptions: { + sourceType: 'script', + }, + rules: { + // It's okay if this file has a shebang; it's meant to be executed + // directly. + 'n/shebang': 'off', + }, + }, ], ignorePatterns: [ diff --git a/.gitignore b/.gitignore index d54c2ba..7fe836a 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,6 @@ node_modules/ !.yarn/releases !.yarn/sdks !.yarn/versions + +# Repositories that this tool clones, and other temporary data +tmp diff --git a/jest.config.js b/jest.config.js index 83e1504..5e5a01e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -22,7 +22,7 @@ module.exports = { collectCoverage: true, // An array of glob patterns indicating a set of files for which coverage information should be collected - collectCoverageFrom: ['./src/**/*.ts'], + collectCoverageFrom: ['./src/**/*.ts', '!./src/cli.ts'], // The directory where Jest should output its coverage files coverageDirectory: 'coverage', diff --git a/package.json b/package.json index 5ff1802..ef71683 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/types/index.d.ts", + "bin": "./dist/cli.js", "files": [ "dist/cjs/**", "dist/esm/**", @@ -44,6 +45,7 @@ "lint:fix": "yarn lint:eslint --fix && yarn lint:constraints --fix && yarn lint:misc --write && yarn lint:dependencies && yarn lint:changelog", "lint:misc": "prettier '**/*.json' '**/*.md' '!CHANGELOG.md' '**/*.yml' '!.yarnrc.yml' --ignore-path .gitignore --no-error-on-unmatched-pattern", "prepack": "./scripts/prepack.sh", + "run-tool": "ts-node --swc src/cli.ts", "test": "jest && jest-it-up", "test:watch": "jest --watch" }, @@ -51,7 +53,8 @@ "@metamask/utils": "^8.2.0", "chalk": "^4.1.2", "dependency-graph": "^0.11.0", - "execa": "^5.1.1" + "execa": "^5.1.1", + "yargs": "^17.7.2" }, "devDependencies": { "@lavamoat/allow-scripts": "^2.3.1", diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..53f1d67 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { + DEFAULT_PROJECT_NAMES, + DEFAULT_CACHED_REPOSITORIES_DIRECTORY_PATH, +} from './constants'; +import { main } from './main'; + +main({ + argv: process.argv, + stdout: process.stdout, + stderr: process.stderr, + config: { + cachedRepositoriesDirectoryPath: DEFAULT_CACHED_REPOSITORIES_DIRECTORY_PATH, + defaultProjectNames: DEFAULT_PROJECT_NAMES, + }, +}).catch((error) => { + console.error(error); + process.exitCode = 1; +}); + +// vi: ft=typescript diff --git a/src/constants.ts b/src/constants.ts index 9f2fe14..70f5ed7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,129 @@ +import path from 'path'; + /** * The number of milliseconds in an hour, used to determine when to pull the * latest changes for previously cached repositories. */ export const ONE_HOUR = 60 * 60 * 1000; + +/** + * The name of this project. Used to exclude this repo as a lintable project. + */ +export const THIS_PROJECT_NAME = 'module-lint'; + +/** + * The root directory of this project. + */ +export const THIS_PROJECT_DIRECTORY_PATH = path.resolve(__dirname, '..'); + +/** + * Wherever the tool was run. + */ +export const WORKING_DIRECTORY_PATH = process.cwd(); + +/** + * The usage text printed when the user requests help or provides invalid input. + */ +export const USAGE_TEXT = ` +Analyzes one or more repos for divergence from a template repo. + +${THIS_PROJECT_NAME} OPTIONS [ARGUMENTS...] + +Pass the names of one or more MetaMask repositories to lint them, or pass +nothing to lint all MetaMask repositories. +`.trim(); + +/** + * In order to lint a remote repository, that repository must be cloned first. + * This is the temporary directory where the clone lives. + */ +export const DEFAULT_CACHED_REPOSITORIES_DIRECTORY_PATH = path.join( + THIS_PROJECT_DIRECTORY_PATH, + 'tmp/repositories', +); + +/** + * The name of the template repository that project repositories will be + * compared to. The only such repository we have is the module template. + */ +export const DEFAULT_TEMPLATE_REPOSITORY_NAME = 'metamask-module-template'; + +/** + * All of the remote MetaMask repositories that will be linted if a list is not + * explicitly provided. + * + * Derived from: + */ +export const DEFAULT_PROJECT_NAMES = [ + 'KeyringController', + 'abi-utils', + 'action-create-release-pr', + 'action-is-release', + 'action-npm-publish', + 'action-publish-gh-pages', + 'action-publish-release', + 'action-tech-challenge-setup', + 'action-utils', + 'actions-test-repo', + 'api-playground', + 'api-specs', + 'auto-changelog', + 'bify-module-groups', + 'browser-passworder', + 'contract-metadata', + 'create-release-branch', + 'design-tokens', + 'detect-provider', + 'docusaurus-openrpc', + 'eth-block-tracker', + 'eth-hd-keyring', + 'eth-json-rpc-filters', + 'eth-json-rpc-infura', + 'eth-json-rpc-middleware', + 'eth-json-rpc-provider', + 'eth-ledger-bridge-keyring', + 'eth-method-registry', + 'eth-phishing-detect', + 'eth-sig-util', + 'eth-simple-keyring', + 'eth-snap-keyring', + 'eth-token-tracker', + 'eth-trezor-keyring', + 'ethereum-provider-openrpc-generator', + 'etherscan-link', + 'extension-port-stream', + 'extension-provider', + 'iframe-ee-openrpc-inspector-transport', + 'json-rpc-engine', + 'json-rpc-middleware-stream', + 'key-tree', + 'keyring-api', + 'keyring-snaps-registry', + 'logo', + 'metamask-eth-abis', + 'metamask-onboarding', + 'mobile-provider', + 'noble-secp256k1-compat-wrapper', + 'nonce-tracker', + 'object-multiplex', + 'obs-store', + 'open-rpc-docs-react', + 'openrpc-inspector-transport', + 'phishing-warning', + 'post-message-stream', + 'ppom-validator', + 'providers', + 'rpc-errors', + 'safe-event-emitter', + 'smart-transactions-controller', + 'snap-simple-keyring', + 'snaps-registry', + 'state-log-explorer', + 'swappable-obj-proxy', + 'template-sync', + 'test-dapp', + 'types', + 'utils', + 'vault-decryptor', + 'web3-stream-provider', +]; diff --git a/src/establish-metamask-repository.ts b/src/establish-metamask-repository.ts index bf3cf7e..6688ec4 100644 --- a/src/establish-metamask-repository.ts +++ b/src/establish-metamask-repository.ts @@ -84,6 +84,9 @@ export async function establishMetaMaskRepository({ if (existingRepository.isKnownMetaMaskRepository) { await requireDefaultBranchSelected(existingRepository); + log( + `(${existingRepository.shortname}) Ensuring default branch is up to date...`, + ); const updatedLastFetchedDate = await ensureDefaultBranchIsUpToDate( existingRepository.directoryPath, existingRepository.lastFetchedDate, diff --git a/src/main.test.ts b/src/main.test.ts new file mode 100644 index 0000000..0202db0 --- /dev/null +++ b/src/main.test.ts @@ -0,0 +1,416 @@ +import { ensureDirectoryStructureExists } from '@metamask/utils/node'; +import type { ExecaChildProcess } from 'execa'; +import execa from 'execa'; +import path from 'path'; +import { MockWritable } from 'stdio-mock'; +import stripAnsi from 'strip-ansi'; + +import { main } from './main'; +import type { PrimaryExecaFunction } from '../tests/helpers'; +import { + buildExecaResult, + fakeDateOnly, + withinSandbox, +} from '../tests/helpers'; +import { setupToolWithMockRepositories } from '../tests/setup-tool-with-mock-repositories'; + +jest.mock('execa'); + +const execaMock = jest.mocked(execa); + +describe('main', () => { + beforeEach(() => { + fakeDateOnly(); + execaMock.mockImplementation((): ExecaChildProcess => { + return buildExecaResult({ stdout: '' }); + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('given a list of project references', () => { + it('lists the rules executed against the default repositories which pass', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const projectNames = ['repo-1', 'repo-2']; + const { cachedRepositoriesDirectoryPath, repositories } = + await setupToolWithMockRepositories({ + execaMock, + sandboxDirectoryPath, + repositories: [ + { name: 'metamask-module-template', create: true }, + ...projectNames.map((projectName) => ({ + name: projectName, + create: true, + })), + ], + }); + const projects = repositories.filter( + (repository) => repository.name !== 'metamask-module-template', + ); + for (const project of projects) { + await ensureDirectoryStructureExists( + path.join(project.directoryPath, 'src'), + ); + } + const stdout = new MockWritable(); + const stderr = new MockWritable(); + + await main({ + argv: ['node', 'module-lint', ...projectNames], + stdout, + stderr, + config: { + cachedRepositoriesDirectoryPath, + defaultProjectNames: [], + }, + }); + + const output = stdout.data().map(stripAnsi).join(''); + + expect(output).toBe( + ` +repo-1 +------ + +Linted project in 0 ms. + +- Does the \`src/\` directory exist? ✅ + + + +repo-2 +------ + +Linted project in 0 ms. + +- Does the \`src/\` directory exist? ✅ + + +`, + ); + }); + }); + + it('lists the rules executed against the default repositories which fail', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const projectNames = ['repo-1', 'repo-2']; + const { cachedRepositoriesDirectoryPath } = + await setupToolWithMockRepositories({ + execaMock, + sandboxDirectoryPath, + repositories: [ + { name: 'metamask-module-template', create: true }, + ...projectNames.map((projectName) => ({ + name: projectName, + create: true, + })), + ], + }); + const stdout = new MockWritable(); + const stderr = new MockWritable(); + + await main({ + argv: ['node', 'module-lint', ...projectNames], + stdout, + stderr, + config: { + cachedRepositoriesDirectoryPath, + defaultProjectNames: [], + }, + }); + + const output = stdout.data().map(stripAnsi).join(''); + + expect(output).toBe( + ` +repo-1 +------ + +Linted project in 0 ms. + +- Does the \`src/\` directory exist? ❌ + - \`src\` exists in the module template, but not in this repo. + + + +repo-2 +------ + +Linted project in 0 ms. + +- Does the \`src/\` directory exist? ❌ + - \`src\` exists in the module template, but not in this repo. + + +`, + ); + }); + }); + + it('does not exit immediately if a project fails to lint for any reason, but shows the reason and continues', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const projectNames = ['repo-1', 'repo-2']; + const { cachedRepositoriesDirectoryPath } = + await setupToolWithMockRepositories({ + execaMock, + sandboxDirectoryPath, + repositories: [ + { name: 'metamask-module-template', create: true }, + ...projectNames.map((projectName) => ({ + name: projectName, + create: false, + })), + ], + validRepositories: [ + { + name: 'repo-1', + fork: false, + archived: false, + }, + ], + }); + const stdout = new MockWritable(); + const stderr = new MockWritable(); + + await main({ + argv: ['node', 'module-lint', ...projectNames], + stdout, + stderr, + config: { + cachedRepositoriesDirectoryPath, + defaultProjectNames: [], + }, + }); + + expect(stdout.data().map(stripAnsi).join('')).toBe( + `Cloning repository MetaMask/repo-1, please wait... + +repo-1 +------ + +Linted project in 0 ms. + +- Does the \`src/\` directory exist? ❌ + - \`src\` exists in the module template, but not in this repo. + + +`, + ); + + expect(stderr.data().map(stripAnsi).join('')).toContain( + `Could not resolve 'repo-2' as it is neither a reference to a directory nor the name of a known MetaMask repository.`, + ); + }); + }); + }); + + describe('given no project references', () => { + it('lists the rules executed against the default repositories which pass', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const projectNames = ['repo-1', 'repo-2']; + const { cachedRepositoriesDirectoryPath, repositories } = + await setupToolWithMockRepositories({ + execaMock, + sandboxDirectoryPath, + repositories: [ + { name: 'metamask-module-template', create: true }, + ...projectNames.map((projectName) => ({ + name: projectName, + create: true, + })), + ], + }); + const projects = repositories.filter( + (repository) => repository.name !== 'metamask-module-template', + ); + for (const project of projects) { + await ensureDirectoryStructureExists( + path.join(project.directoryPath, 'src'), + ); + } + const stdout = new MockWritable(); + const stderr = new MockWritable(); + + await main({ + argv: ['node', 'module-lint'], + stdout, + stderr, + config: { + cachedRepositoriesDirectoryPath, + defaultProjectNames: projectNames, + }, + }); + + const output = stdout.data().map(stripAnsi).join(''); + + expect(output).toBe( + ` +repo-1 +------ + +Linted project in 0 ms. + +- Does the \`src/\` directory exist? ✅ + + + +repo-2 +------ + +Linted project in 0 ms. + +- Does the \`src/\` directory exist? ✅ + + +`, + ); + }); + }); + + it('lists the rules executed against the default repositories which fail', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const projectNames = ['repo-1', 'repo-2']; + const { cachedRepositoriesDirectoryPath } = + await setupToolWithMockRepositories({ + execaMock, + sandboxDirectoryPath, + repositories: [ + { name: 'metamask-module-template', create: true }, + ...projectNames.map((projectName) => ({ + name: projectName, + create: true, + })), + ], + }); + const stdout = new MockWritable(); + const stderr = new MockWritable(); + + await main({ + argv: ['node', 'module-lint'], + stdout, + stderr, + config: { + cachedRepositoriesDirectoryPath, + defaultProjectNames: projectNames, + }, + }); + + const output = stdout.data().map(stripAnsi).join(''); + + expect(output).toBe( + ` +repo-1 +------ + +Linted project in 0 ms. + +- Does the \`src/\` directory exist? ❌ + - \`src\` exists in the module template, but not in this repo. + + + +repo-2 +------ + +Linted project in 0 ms. + +- Does the \`src/\` directory exist? ❌ + - \`src\` exists in the module template, but not in this repo. + + +`, + ); + }); + }); + + it('does not exit immediately if a project fails to lint for any reason, but shows the reason and continues', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const projectNames = ['repo-1', 'repo-2']; + const { cachedRepositoriesDirectoryPath } = + await setupToolWithMockRepositories({ + execaMock, + sandboxDirectoryPath, + repositories: [ + { name: 'metamask-module-template', create: true }, + ...projectNames.map((projectName) => ({ + name: projectName, + create: false, + })), + ], + validRepositories: [ + { + name: 'repo-1', + fork: false, + archived: false, + }, + ], + }); + const stdout = new MockWritable(); + const stderr = new MockWritable(); + + await main({ + argv: ['node', 'module-lint'], + stdout, + stderr, + config: { + cachedRepositoriesDirectoryPath, + defaultProjectNames: projectNames, + }, + }); + + expect(stdout.data().map(stripAnsi).join('')).toBe( + `Cloning repository MetaMask/repo-1, please wait... + +repo-1 +------ + +Linted project in 0 ms. + +- Does the \`src/\` directory exist? ❌ + - \`src\` exists in the module template, but not in this repo. + + +`, + ); + + expect(stderr.data().map(stripAnsi).join('')).toContain( + `Could not resolve 'repo-2' as it is neither a reference to a directory nor the name of a known MetaMask repository.`, + ); + }); + }); + }); + + describe('given --help', () => { + it('shows the usage message and exits', async () => { + const stdout = new MockWritable(); + const stderr = new MockWritable(); + + await main({ + argv: ['node', 'module-lint', '--help'], + stdout, + stderr, + config: { + cachedRepositoriesDirectoryPath: '', + defaultProjectNames: [], + }, + }); + + const output = stderr.data().map(stripAnsi).join(''); + + expect(output).toBe( + `Analyzes one or more repos for divergence from a template repo. + +module-lint OPTIONS [ARGUMENTS...] + +Pass the names of one or more MetaMask repositories to lint them, or pass +nothing to lint all MetaMask repositories. + +Options: + --version Show version number [boolean] +`, + ); + }); + }); +}); diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..ac5d0ef --- /dev/null +++ b/src/main.ts @@ -0,0 +1,245 @@ +import { hideBin } from 'yargs/helpers'; +import createYargs from 'yargs/yargs'; + +import { + DEFAULT_TEMPLATE_REPOSITORY_NAME, + USAGE_TEXT, + WORKING_DIRECTORY_PATH, +} from './constants'; +import type { MetaMaskRepository } from './establish-metamask-repository'; +import { establishMetaMaskRepository } from './establish-metamask-repository'; +import { lintProject } from './lint-project'; +import { + isPromiseFulfilledResult, + isPromiseRejectedResult, +} from './misc-utils'; +import type { SimpleWriteStream } from './output-logger'; +import { OutputLogger } from './output-logger'; +import { reportProjectLintResult } from './report-project-lint-result'; +import { rules } from './rules'; + +/** + * Data created from command-line input used to configure this tool. + */ +export type Inputs = { + templateRepositoryName: string; + projectReferences: string[]; +}; + +/** + * The entrypoint for this tool. Designed to not access `process.argv`, + * `process.stdout`, or `process.stdout` directly so as to be more easily + * testable. + * + * @param args - The arguments to this function. + * @param args.argv - The name of this executable and its arguments (as obtained + * via `process.argv`). + * @param args.stdout - The standard out stream. + * @param args.stderr - The standard error stream. + * @param args.config - Extra configuration. + * @param args.config.cachedRepositoriesDirectoryPath - The directory where + * MetaMask repositories will be (or have been) cloned. + * @param args.config.defaultProjectNames - The set of MetaMask repositories + * that will be linted. + */ +export async function main({ + argv, + stdout, + stderr, + config: { cachedRepositoriesDirectoryPath, defaultProjectNames }, +}: { + argv: string[]; + stdout: SimpleWriteStream; + stderr: SimpleWriteStream; + config: { + cachedRepositoriesDirectoryPath: string; + defaultProjectNames: string[]; + }; +}) { + const outputLogger = new OutputLogger({ stdout, stderr }); + const workingDirectoryPath = WORKING_DIRECTORY_PATH; + + const inputs = await parseInputs({ argv, outputLogger, defaultProjectNames }); + /* istanbul ignore next: At the moment, there is no real way that Yargs could fail */ + if (!inputs) { + process.exitCode = 1; + return; + } + const { templateRepositoryName, projectReferences } = inputs; + + const template = await establishMetaMaskRepository({ + repositoryReference: templateRepositoryName, + workingDirectoryPath, + cachedRepositoriesDirectoryPath, + outputLogger, + }); + + await lintProjects({ + projectReferences, + template, + workingDirectoryPath, + cachedRepositoriesDirectoryPath, + outputLogger, + }); +} + +/** + * Parses command-line arguments and establishes parameters for the remainder of + * this tool. + * + * @param args - The arguments to this function. + * @param args.argv - The name of this executable and its arguments (as obtained + * via `process.argv`). + * @param args.outputLogger - Writable streams for output messages. + * @param args.defaultProjectNames - The set of MetaMask repositories + * that will be linted. + * @returns The parsed command-line arguments if parsing was successful, or null + * otherwise. + */ +async function parseInputs({ + argv, + outputLogger, + defaultProjectNames, +}: { + argv: string[]; + outputLogger: OutputLogger; + defaultProjectNames: string[]; +}): Promise { + const args = await parseCommandLineArguments({ argv, outputLogger }); + + if (args) { + const projectReferences = args._.length > 0 ? args._ : defaultProjectNames; + return { + templateRepositoryName: DEFAULT_TEMPLATE_REPOSITORY_NAME, + projectReferences, + }; + } + + /* istanbul ignore next: At the moment, there is no real way that Yargs could fail */ + return null; +} + +/** + * Parses the command line using `yargs`. + * + * @param args - The arguments to this function. + * @param args.argv - The name of this executable and its arguments (as obtained + * via `process.argv`). + * @param args.outputLogger - Writable streams for output messages. + */ +async function parseCommandLineArguments({ + argv, + outputLogger, +}: { + argv: string[]; + outputLogger: OutputLogger; +}) { + let yargsFailure: { message: string; error: Error } | null = null; + /* istanbul ignore next: At the moment, there is no real way that Yargs could fail */ + const onFail = (message: string, error: Error) => { + if (error) { + throw error; + } + yargsFailure = { message, error }; + }; + + const yargs = createYargs(hideBin(argv)) + .usage(USAGE_TEXT) + .help(false) + .string('_') + .wrap(null) + .exitProcess(false) + .fail(onFail); + + const options = await yargs.parse(); + + if (options.help) { + outputLogger.logToStderr(await yargs.getHelp()); + return null; + } + + /* istanbul ignore next: At the moment, there is no real way that Yargs could fail */ + if (yargsFailure) { + outputLogger.logToStderr('%s\n\n%s', yargsFailure, await yargs.getHelp()); + return null; + } + + return { + ...options, + // This is the name that Yargs gives to the arguments that aren't options. + // eslint-disable-next-line @typescript-eslint/naming-convention + _: options._.map(String), + }; +} + +/** + * Runs all of the rules on the given projects. + * + * This is a bit complicated because linting may fail for a particular project. + * In that case, we don't want to crash the whole tool, but fail gracefully. + * + * @param args - The arguments to this function. + * @param args.projectReferences - References to the projects (either the name + * of a MetaMask repository, such as "utils", or the path to a local Git + * repository). + * @param args.template - The repository to which the project should be + * compared. + * @param args.workingDirectoryPath - The directory where this tool was run. + * @param args.cachedRepositoriesDirectoryPath - The directory where MetaMask + * repositories will be (or have been) cloned. + * @param args.outputLogger - Writable streams for output messages. + */ +async function lintProjects({ + projectReferences, + template, + workingDirectoryPath, + cachedRepositoriesDirectoryPath, + outputLogger, +}: { + projectReferences: string[]; + template: MetaMaskRepository; + workingDirectoryPath: string; + cachedRepositoriesDirectoryPath: string; + outputLogger: OutputLogger; +}) { + const projectLintResultPromiseOutcomes = await Promise.allSettled( + projectReferences.map(async (projectReference) => { + return await lintProject({ + projectReference, + template, + rules, + workingDirectoryPath, + cachedRepositoriesDirectoryPath, + outputLogger, + }); + }), + ); + + const fulfilledProjectLintResultPromiseOutcomes = + projectLintResultPromiseOutcomes.filter(isPromiseFulfilledResult); + const rejectedProjectLintResultPromiseOutcomes = + projectLintResultPromiseOutcomes.filter(isPromiseRejectedResult); + + fulfilledProjectLintResultPromiseOutcomes + .sort((a, b) => { + return a.value.projectName.localeCompare(b.value.projectName); + }) + .forEach((fulfilledProjectLintResultPromiseOutcome) => { + reportProjectLintResult({ + projectLintResult: fulfilledProjectLintResultPromiseOutcome.value, + outputLogger, + }); + }); + + rejectedProjectLintResultPromiseOutcomes.forEach( + (rejectedProjectLintResultPromiseOutcome) => { + outputLogger.logToStderr( + 'stack' in rejectedProjectLintResultPromiseOutcome.reason + ? rejectedProjectLintResultPromiseOutcome.reason.stack + : /* istanbul ignore next: There's no real way to reproduce this. */ String( + rejectedProjectLintResultPromiseOutcome.reason, + ), + ); + }, + ); +} diff --git a/src/misc-utils.test.ts b/src/misc-utils.test.ts index 2ad975a..722d448 100644 --- a/src/misc-utils.test.ts +++ b/src/misc-utils.test.ts @@ -2,7 +2,13 @@ import { writeFile } from '@metamask/utils/node'; import fs from 'fs'; import path from 'path'; -import { getEntryStats, indent, repeat } from './misc-utils'; +import { + getEntryStats, + indent, + isPromiseFulfilledResult, + isPromiseRejectedResult, + repeat, +} from './misc-utils'; import { withinSandbox } from '../tests/helpers'; describe('getEntryStats', () => { @@ -74,3 +80,43 @@ describe('indent', () => { expect(indent('hello', 4)).toBe(' hello'); }); }); + +describe('isPromiseFulfilledResult', () => { + it('returns true if given a fulfilled promise settled result', () => { + const promiseSettledResult = { + status: 'fulfilled', + value: 'whatever', + } as const; + + expect(isPromiseFulfilledResult(promiseSettledResult)).toBe(true); + }); + + it('returns false if given a rejected promise settled result', () => { + const promiseSettledResult = { + status: 'rejected', + reason: 'whatever', + } as const; + + expect(isPromiseFulfilledResult(promiseSettledResult)).toBe(false); + }); +}); + +describe('isPromiseRejectedResult', () => { + it('returns true if given a rejected promise settled result', () => { + const promiseSettledResult = { + status: 'rejected', + reason: 'whatever', + } as const; + + expect(isPromiseRejectedResult(promiseSettledResult)).toBe(true); + }); + + it('returns false if given a fulfilled promise settled result', () => { + const promiseSettledResult = { + status: 'fulfilled', + value: 'whatever', + } as const; + + expect(isPromiseRejectedResult(promiseSettledResult)).toBe(false); + }); +}); diff --git a/src/misc-utils.ts b/src/misc-utils.ts index 2f0d7df..ee09bee 100644 --- a/src/misc-utils.ts +++ b/src/misc-utils.ts @@ -52,3 +52,27 @@ export function indent(text: string, level: number) { const indentation = repeat(' ', level * 2); return `${indentation}${text}`; } + +/** + * Type guard for a fulfilled promise result obtained via `Promise.allSettled`. + * + * @param promiseSettledResult - The promise settled result. + * @returns True if the result is fulfilled, false otherwise. + */ +export function isPromiseFulfilledResult( + promiseSettledResult: PromiseSettledResult, +): promiseSettledResult is PromiseFulfilledResult { + return promiseSettledResult.status === 'fulfilled'; +} + +/** + * Type guard for a rejected promise result obtained via `Promise.allSettled`. + * + * @param promiseSettledResult - The promise settled result. + * @returns True if the result is rejected, false otherwise. + */ +export function isPromiseRejectedResult( + promiseSettledResult: PromiseSettledResult, +): promiseSettledResult is PromiseRejectedResult { + return promiseSettledResult.status === 'rejected'; +} diff --git a/src/output-logger.ts b/src/output-logger.ts index ce3c7c9..172f4d5 100644 --- a/src/output-logger.ts +++ b/src/output-logger.ts @@ -7,7 +7,7 @@ import { format } from 'util'; * type exists so that we can designate that a function takes a writable stream * without enforcing that it must be a Stream object. */ -type SimpleWriteStream = Pick; +export type SimpleWriteStream = Pick; /** * The minimal interface that an output logger is expected to have. diff --git a/src/rules/helpers.ts b/src/rules/helpers.ts new file mode 100644 index 0000000..fc55e9e --- /dev/null +++ b/src/rules/helpers.ts @@ -0,0 +1,54 @@ +import type { RuleName } from './types'; +import type { MetaMaskRepository } from '../establish-metamask-repository'; +import type { + FailedPartialRuleExecutionResult, + PartialRuleExecutionResult, + Rule, + SuccessfulPartialRuleExecutionResult, +} from '../execute-rules'; + +/** + * Rule objects are fairly abstract: the name of a rule and the dependencies of + * a rule can be anything; and unfortunately, we cannot really enforce this or + * else it would mean we'd have to have a `RuleName` type everywhere. + * + * This function exists to bridge that gap at the point where the rule is + * actually defined by validating the name and dependencies against a known set + * of rules. + * + * @param args - The arguments to this function. + * @param args.name - The name of a rule. This function assumes that all rule + * names are predefined in an enum and that this is one of the values in that + * enum. + * @param args.description - The description of the rule. This will show up when + * listing rules as a part of the lint report for a project. + * @param args.dependencies - The names of rules that must be executed first + * before executing this one. + * @param args.execute - The "body" of the rule. + * @returns The (validated) rule. + */ +export function buildRule({ + name, + description, + dependencies, + execute, +}: { + name: Name; + description: string; + dependencies: Exclude[]; + execute(args: { + project: MetaMaskRepository; + template: MetaMaskRepository; + pass: () => SuccessfulPartialRuleExecutionResult; + fail: ( + failures: FailedPartialRuleExecutionResult['failures'], + ) => FailedPartialRuleExecutionResult; + }): Promise; +}): Rule { + return { + name, + description, + dependencies, + execute, + }; +} diff --git a/src/rules/index.ts b/src/rules/index.ts new file mode 100644 index 0000000..77c1f4e --- /dev/null +++ b/src/rules/index.ts @@ -0,0 +1,3 @@ +import requireSourceDirectory from './require-source-directory'; + +export const rules = [requireSourceDirectory] as const; diff --git a/src/rules/require-source-directory.ts b/src/rules/require-source-directory.ts new file mode 100644 index 0000000..6801a8b --- /dev/null +++ b/src/rules/require-source-directory.ts @@ -0,0 +1,19 @@ +import { buildRule } from './helpers'; +import { RuleName } from './types'; + +export default buildRule({ + name: RuleName.RequireSourceDirectory, + description: 'Does the `src/` directory exist?', + dependencies: [], + execute: async ({ project, pass, fail }) => { + const entryPath = 'src'; + const stats = await project.fs.getEntryStats(entryPath); + const passed = stats?.isDirectory(); + const message = stats + ? `\`${entryPath}\` is not a directory when it should be.` + : `\`${entryPath}\` exists in the module template, but not in this repo.`; + const failures = [{ message }]; + + return passed ? pass() : fail(failures); + }, +}); diff --git a/src/rules/types.ts b/src/rules/types.ts new file mode 100644 index 0000000..ebe527c --- /dev/null +++ b/src/rules/types.ts @@ -0,0 +1,6 @@ +/** + * All of the known rules. + */ +export enum RuleName { + RequireSourceDirectory = 'require-source-directory', +} diff --git a/yarn.lock b/yarn.lock index 8da7617..63c109a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1027,6 +1027,9 @@ __metadata: ts-node: ^10.7.0 typedoc: ^0.23.15 typescript: ~4.8.4 + yargs: ^17.7.2 + bin: + module-lint: ./dist/cli.js languageName: unknown linkType: soft @@ -7227,7 +7230,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^17.0.1, yargs@npm:^17.3.1": +"yargs@npm:^17.0.1, yargs@npm:^17.3.1, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: