diff --git a/README.md b/README.md index 95e0b7d6f94..4ced46cc2c5 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ linkStyle default opacity:0.5 approval_controller(["@metamask/approval-controller"]); assets_controllers(["@metamask/assets-controllers"]); base_controller(["@metamask/base-controller"]); + build_utils(["@metamask/build-utils"]); composable_controller(["@metamask/composable-controller"]); controller_utils(["@metamask/controller-utils"]); ens_controller(["@metamask/ens-controller"]); @@ -134,9 +135,9 @@ linkStyle default opacity:0.5 signature_controller --> approval_controller; signature_controller --> base_controller; signature_controller --> controller_utils; + signature_controller --> keyring_controller; signature_controller --> logging_controller; signature_controller --> message_manager; - signature_controller --> keyring_controller; transaction_controller --> approval_controller; transaction_controller --> base_controller; transaction_controller --> controller_utils; diff --git a/packages/build-utils/CHANGELOG.md b/packages/build-utils/CHANGELOG.md new file mode 100644 index 00000000000..27eb830b8c5 --- /dev/null +++ b/packages/build-utils/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/build-utils/LICENSE b/packages/build-utils/LICENSE new file mode 100644 index 00000000000..b703d6a4a23 --- /dev/null +++ b/packages/build-utils/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2023 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/build-utils/README.md b/packages/build-utils/README.md new file mode 100644 index 00000000000..ace187c42d6 --- /dev/null +++ b/packages/build-utils/README.md @@ -0,0 +1,21 @@ +# `@metamask/build-utils` + +Utilities for building MetaMask applications. + +## Installation + +`yarn add @metamask/build-utils` + +or + +`npm install @metamask/build-utils` + +## Usage + +### `/transforms` + +See [the transforms readme](https://github.com/MetaMask/core/packages/build-utils/src/transforms/README.md). + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/build-utils/jest.config.js b/packages/build-utils/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/build-utils/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/build-utils/package.json b/packages/build-utils/package.json new file mode 100644 index 00000000000..a232ac43e6d --- /dev/null +++ b/packages/build-utils/package.json @@ -0,0 +1,54 @@ +{ + "name": "@metamask/build-utils", + "version": "0.0.0", + "description": "Utilities for building MetaMask applications", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/build-utils#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/" + ], + "scripts": { + "build:docs": "typedoc", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/build-utils", + "publish:preview": "yarn npm publish --tag preview", + "test": "jest --reporters=jest-silent-reporter", + "test:clean": "jest --clearCache", + "test:verbose": "jest --verbose", + "test:watch": "jest --watch" + }, + "dependencies": { + "@metamask/utils": "^8.2.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.3", + "@types/eslint": "^8.44.7", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "eslint": "^8.44.0", + "jest": "^27.5.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~4.8.4" + }, + "engines": { + "node": ">=16.0.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/build-utils/src/index.ts b/packages/build-utils/src/index.ts new file mode 100644 index 00000000000..0e5dc2315ed --- /dev/null +++ b/packages/build-utils/src/index.ts @@ -0,0 +1,3 @@ +export type { FeatureLabels } from './transforms/remove-fenced-code'; +export { removeFencedCode } from './transforms/remove-fenced-code'; +export { lintTransformedFile } from './transforms/utils'; diff --git a/packages/build-utils/src/transforms/README.md b/packages/build-utils/src/transforms/README.md new file mode 100644 index 00000000000..b7f10d60069 --- /dev/null +++ b/packages/build-utils/src/transforms/README.md @@ -0,0 +1,198 @@ +# Source file transforms + +This directory contains home-grown transforms for the build systems of the MetaMask applications. + +## Remove Fenced Code + +> `./remove-fenced-code.ts` + +### Usage + +Let's imagine you've added some fences to your source code. + +```typescript +this.store.updateStructure({ + /** ..., */ + GasFeeController: this.gasFeeController, + TokenListController: this.tokenListController, + ///: BEGIN:ONLY_INCLUDE_IF(snaps) + SnapController: this.snapController, + ///: END:ONLY_INCLUDE_IF +}); +``` + +The transform should be applied on your raw source files as they are committed to +your repository, before anything else (e.g. Babel, `tsc`, etc.) parses or modifies them. + +```typescript +import { + FeatureLabels, + removeFencedCode, + lintTransformedFile, +} from '@metamask/build-utils'; + +// Let's imagine this function exists in your build system and is called immediately +// after your source files are read from disk. +async function applyTransforms( + filePath: string, + fileContent: string, + features: FeatureLabels, + shouldLintTransformedFiles: boolean = true, +): string { + const [newFileContent, wasModified] = removeFencedCode( + filePath, + fileContent, + features, + ); + + // You may choose to disable linting during e.g. dev builds since lint failures cause + // an error to be thrown. + if (wasModified && shouldLintTransformedFiles) { + // You probably only need a singleton ESLint instance for your linting purposes. + // See the lintTransformedFile documentation for important notes about usage. + const eslintInstance = getESLintInstance(); + await lintTransformedFile(eslintInstance, filePath, newFileContent); + } + return newFileContent; +} + +// Then, in the relevant part of your build process... + +const features: FeatureLabels = { + active: new Set(['foo']), // Fences with these features will be included. + all: new Set(['snaps', 'foo' /** etc. */]), // All extant features must be listed here. +}; + +const transformedFile = await applyTransforms( + filePath, + fileContent, + features, + shouldLintTransformedFiles, +); + +// Do something with the results. +// continueBuildProcess(transformedFile); +``` + +After the transform has been applied as above, the example source code will look like this: + +```typescript +this.store.updateStructure({ + /** ..., */ + GasFeeController: this.gasFeeController, + TokenListController: this.tokenListController, +}); +``` + +### Overview + +When creating builds that support different features, it is desirable to exclude +unsupported features, files, and dependencies at build time. Undesired files and +dependencies can be excluded wholesale, but the _use_ of undesired modules in +files that should otherwise be included – i.e. import statements and references +to those imports – cannot. + +To support the exclusion of the use of undesired modules at build time, we +introduce the concept of code fencing to our build system. Our code fencing +syntax amounts to a tiny DSL, which is specified below. + +The transform expects to receive the contents of individual files as a single string, +which it will parse in order to identify any code fences. If any fences that should not +be included in the current build are found, the fences and the lines that they wrap +are deleted. An error is thrown if a malformed fence is identified. + +For example, the following fenced code: + +```javascript +this.store.updateStructure({ + ..., + GasFeeController: this.gasFeeController, + TokenListController: this.tokenListController, + ///: BEGIN:ONLY_INCLUDE_IF(snaps) + SnapController: this.snapController, + ///: END:ONLY_INCLUDE_IF +}); +``` + +Is transformed as follows if the current build should not include the `snaps` feature: + +```javascript +this.store.updateStructure({ + ..., + GasFeeController: this.gasFeeController, + TokenListController: this.tokenListController, +}); +``` + +Note that multiple features can be specified by separating them with +commands inside the parameter parentheses: + +```javascript +///: BEGIN:ONLY_INCLUDE_IF(build-beta,build-flask) +``` + +### Code Fencing Syntax + +> In the specification, angle brackets, `< >`, indicate required tokens, while +> straight brackets, `[ ]`, indicate optional tokens. +> +> Alphabetical characters identify the name and purpose of a token. All other +> characters, including parentheses, `( )`, are literals. + +A fence line is a single-line JavaScript comment, optionally surrounded by +whitespace, in the following format: + +```text +///: :[(parameters)] + +|__| |________________________________| + | | + | | +sentinel directive +``` + +The first part of a fence line is the **sentinel** which is always the string +"`///:`". If the first four non-whitespace characters of a line are not exactly the +**sentinel** the line will be ignored by the parser. The **sentinel** must be +succeeded by a single space character, or parsing will fail. + +The remainder of the fence line is called the **directive** +The directive consists of a **terminus** **command** and **parameters** + +- The **terminus** is one of the strings `BEGIN` and `END`. It must be followed by + a single colon, `:`. +- The **command** is a string of uppercase alphabetical characters, optionally + including underscores, `_`. The possible commands are listed later in this + specification. +- The **parameters** are a string of comma-separated RegEx `\w` strings. The parameters + string must be parenthesized, only specified for `BEGIN` directives, and valid for its + command. + +A valid code fence consists of two fence lines surrounding one or more lines of +non-fence lines. The first fence line must consist of a `BEGIN` directive, and +the second an `END` directive. The command of both directives must be the same, +and the parameters (if any) must be valid for the command. Nesting is not intended +to be supported, and may produce undefined behavior. + +If an invalid fence is detected, parsing will fail, and the transform will throw +an error. + +### Commands + +#### `ONLY_INCLUDE_IF` + +This, the only command defined so far, is used to exclude lines of code depending +on flags provided to the current build process. If a particular set of lines should +only be included in e.g. the beta build type, they should be wrapped as follows: + +```javascript +///: BEGIN:ONLY_INCLUDE_IF(build-beta) +console.log('I am only included in beta builds.'); +///: END:ONLY_INCLUDE_IF +``` + +At build time, the fences and the fenced lines will be removed if the `build-beta` +flag is not provided to the transform. + +The parameters must be provided as a comma-separated list of features that are +valid per the consumer's build system. diff --git a/packages/build-utils/src/transforms/remove-fenced-code.test.ts b/packages/build-utils/src/transforms/remove-fenced-code.test.ts new file mode 100644 index 00000000000..74022db2c1e --- /dev/null +++ b/packages/build-utils/src/transforms/remove-fenced-code.test.ts @@ -0,0 +1,709 @@ +import { removeFencedCode } from '..'; +import type { FeatureLabels } from '..'; +import { + DirectiveCommand, + multiSplice, + validateCommand, +} from './remove-fenced-code'; + +const FEATURE_A = 'feature-a'; +const FEATURE_B = 'feature-b'; +const FEATURE_C = 'feature-c'; + +const getFeatures = ({ all, active }: FeatureLabels) => ({ + all: new Set(all), + active: new Set(active), +}); + +const getFencedCode = (...params: string[]) => + `///: BEGIN:ONLY_INCLUDE_IF(${params.join(',')}) +Conditionally_Included +///: END:ONLY_INCLUDE_IF +`; + +const getUnfencedCode = () => ` +Always included +Always included +Always included +`; + +const join = (...args: string[]) => args.join('\n'); + +describe('build transforms', () => { + describe('removeFencedCode', () => { + const mockFileName = 'file.js'; + + it('transforms file consisting of single fence pair', () => { + expect( + removeFencedCode( + mockFileName, + getFencedCode(FEATURE_A), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B, FEATURE_A]), + }), + ), + ).toStrictEqual(['', true]); + }); + + ( + [ + [ + join( + getFencedCode(FEATURE_A), + getUnfencedCode(), + getFencedCode(FEATURE_C), + ), + join('', getUnfencedCode(), ''), + ], + + [ + join( + getFencedCode(FEATURE_A), + getFencedCode(FEATURE_B), + getFencedCode(FEATURE_C), + ), + join('', getFencedCode(FEATURE_B), ''), + ], + + [ + join( + getFencedCode(FEATURE_A), + getUnfencedCode(), + getFencedCode(FEATURE_B), + getFencedCode(FEATURE_C), + getUnfencedCode(), + getFencedCode(FEATURE_A), + getFencedCode(FEATURE_B), + ), + join( + '', + getUnfencedCode(), + getFencedCode(FEATURE_B), + '', + getUnfencedCode(), + '', + getFencedCode(FEATURE_B), + ), + ], + + [ + join( + getUnfencedCode(), + getFencedCode(FEATURE_A), + getFencedCode(FEATURE_B), + getFencedCode(FEATURE_B), + getFencedCode(FEATURE_C), + getFencedCode(FEATURE_C), + getUnfencedCode(), + getFencedCode(FEATURE_A), + getFencedCode(FEATURE_A), + getFencedCode(FEATURE_B), + ), + join( + getUnfencedCode(), + '', + getFencedCode(FEATURE_B), + getFencedCode(FEATURE_B), + '', + '', + getUnfencedCode(), + '', + '', + getFencedCode(FEATURE_B), + ), + ], + ] as const + ).forEach(([input, expected], i) => { + it(`removes multiple fences from file ${i}`, () => { + expect( + removeFencedCode( + mockFileName, + input, + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_A, FEATURE_B, FEATURE_C]), + }), + ), + ).toStrictEqual([expected, true]); + }); + }); + + ( + [ + [ + [FEATURE_A], + join( + getFencedCode(FEATURE_A, FEATURE_B), + getUnfencedCode(), + getFencedCode(FEATURE_C), + ), + join(getFencedCode(FEATURE_A, FEATURE_B), getUnfencedCode(), ''), + true, + ], + + [ + [FEATURE_A], + join( + getFencedCode(FEATURE_A, FEATURE_B), + getUnfencedCode(), + getFencedCode(FEATURE_C, FEATURE_B), + ), + join(getFencedCode(FEATURE_A, FEATURE_B), getUnfencedCode(), ''), + true, + ], + + [ + [FEATURE_B], + join( + getFencedCode(FEATURE_A, FEATURE_B, FEATURE_C), + getUnfencedCode(), + getFencedCode(FEATURE_C), + ), + join( + getFencedCode(FEATURE_A, FEATURE_B, FEATURE_C), + getUnfencedCode(), + '', + ), + true, + ], + + [ + [FEATURE_B], + join( + getFencedCode(FEATURE_A, FEATURE_B, FEATURE_C), + getUnfencedCode(), + getFencedCode(FEATURE_C, FEATURE_A), + ), + join( + getFencedCode(FEATURE_A, FEATURE_B, FEATURE_C), + getUnfencedCode(), + '', + ), + true, + ], + + [ + [FEATURE_A, FEATURE_B], + join( + getFencedCode(FEATURE_A, FEATURE_B, FEATURE_C), + getUnfencedCode(), + getFencedCode(FEATURE_C), + ), + join( + getFencedCode(FEATURE_A, FEATURE_B, FEATURE_C), + getUnfencedCode(), + '', + ), + true, + ], + + [ + [FEATURE_A, FEATURE_B, FEATURE_C], + join( + getFencedCode(FEATURE_A, FEATURE_B, FEATURE_C), + getUnfencedCode(), + getFencedCode(FEATURE_C), + ), + join( + getFencedCode(FEATURE_A, FEATURE_B, FEATURE_C), + getUnfencedCode(), + getFencedCode(FEATURE_C), + ), + false, + ], + ] as const + ).forEach(([activeFeatures, input, expected, modified], i) => { + it(`removes or keeps multi-parameter fences ${i}`, () => { + expect( + removeFencedCode( + mockFileName, + input, + getFeatures({ + active: new Set(activeFeatures), + all: new Set([FEATURE_A, FEATURE_B, FEATURE_C]), + }), + ), + ).toStrictEqual([expected, modified]); + }); + }); + + [ + getFencedCode(FEATURE_A), + + join( + getFencedCode(FEATURE_A), + getUnfencedCode(), + getFencedCode(FEATURE_C), + ), + + join(getUnfencedCode(), getFencedCode(FEATURE_C)), + ].forEach((input, i) => { + it(`does not transform files with only inactive fences ${i}`, () => { + expect( + removeFencedCode( + mockFileName, + input, + getFeatures({ + active: new Set([FEATURE_A, FEATURE_C]), + all: new Set([FEATURE_A, FEATURE_C]), + }), + ), + ).toStrictEqual([input, false]); + }); + }); + + it('ignores sentinels preceded by non-whitespace', () => { + const validBeginDirective = '///: BEGIN:ONLY_INCLUDE_IF(feature-b)\n'; + const ignoredLines = [ + `a ${validBeginDirective}`, + `2 ${validBeginDirective}`, + `@ ${validBeginDirective}`, + ]; + + ignoredLines.forEach((ignoredLine) => { + // These inputs will be transformed + expect( + removeFencedCode( + mockFileName, + getFencedCode(FEATURE_A).concat(ignoredLine), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B, FEATURE_A]), + }), + ), + ).toStrictEqual([ignoredLine, true]); + + const modifiedInputWithoutFences = + getUnfencedCode().concat(ignoredLine); + + // These inputs will not be transformed + expect( + removeFencedCode( + mockFileName, + modifiedInputWithoutFences, + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toStrictEqual([modifiedInputWithoutFences, false]); + }); + }); + + // Invalid inputs + it('rejects empty fences', () => { + const jsComment = '// A comment\n'; + + const emptyFence = getFencedCode(FEATURE_B) + .split('\n') + .filter((line) => line.startsWith('///:')) + .map((line) => `${line}\n`) + .join(''); + + const emptyFenceWithPrefix = jsComment.concat(emptyFence); + const emptyFenceWithSuffix = emptyFence.concat(jsComment); + const emptyFenceSurrounded = emptyFenceWithPrefix.concat(jsComment); + + const inputs = [ + emptyFence, + emptyFenceWithPrefix, + emptyFenceWithSuffix, + emptyFenceSurrounded, + ]; + + inputs.forEach((input) => { + expect(() => + removeFencedCode( + mockFileName, + input, + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow( + `Empty fence found in file "${mockFileName}":\n${emptyFence}`, + ); + }); + }); + + it('rejects sentinels not followed by a single space and a multi-character alphabetical string', () => { + // Matches the sentinel and terminus component of the first line + // beginning with "///: TERMINUS" + const fenceSentinelAndTerminusRegex = /^\/\/\/: \w+/mu; + + const replacements = [ + '///:BEGIN', + '///:XBEGIN', + '///:_BEGIN', + '///:B', + '///:_', + '///: ', + '///: B', + '///:', + ]; + + replacements.forEach((replacement) => { + expect(() => + removeFencedCode( + mockFileName, + getFencedCode(FEATURE_B).replace( + fenceSentinelAndTerminusRegex, + replacement, + ), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow( + /Fence sentinel must be followed by a single space and an alphabetical string of two or more characters.$/u, + ); + }); + }); + + it('rejects malformed BEGIN directives', () => { + // This is the first line of the minimal input template + const directiveString = '///: BEGIN:ONLY_INCLUDE_IF(feature-b)'; + + const replacements = [ + // Invalid terminus + '///: BE_GIN:BEGIN:ONLY_INCLUDE_IF(feature-b)', + '///: BE6IN:BEGIN:ONLY_INCLUDE_IF(feature-b)', + '///: BEGIN7:BEGIN:ONLY_INCLUDE_IF(feature-b)', + '///: BeGIN:ONLY_INCLUDE_IF(feature-b)', + '///: BE3:BEGIN:ONLY_INCLUDE_IF(feature-b)', + '///: BEG-IN:BEGIN:ONLY_INCLUDE_IF(feature-b)', + '///: BEG N:BEGIN:ONLY_INCLUDE_IF(feature-b)', + + // Invalid commands + '///: BEGIN:ONLY-INCLUDE_IF(flask)', + '///: BEGIN:ONLY_INCLUDE:IF(flask)', + '///: BEGIN:ONL6_INCLUDE_IF(flask)', + '///: BEGIN:ONLY_IN@LUDE_IF(flask)', + '///: BEGIN:ONLy_INCLUDE_IF(feature-b)', + '///: BEGIN:ONLY INCLUDE_IF(flask)', + + // Invalid parameters + '///: BEGIN:ONLY_INCLUDE_IF(,flask)', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b,)', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b,,main)', + '///: BEGIN:ONLY_INCLUDE_IF(,)', + '///: BEGIN:ONLY_INCLUDE_IF()', + '///: BEGIN:ONLY_INCLUDE_IF( )', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b]', + '///: BEGIN:ONLY_INCLUDE_IF[flask)', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b.main)', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b,@)', + '///: BEGIN:ONLY_INCLUDE_IF(fla k)', + + // Stuff after the directive + '///: BEGIN:ONLY_INCLUDE_IF(feature-b) A', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b) 9', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b)A', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b)9', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b)_', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b))', + ]; + + replacements.forEach((replacement) => { + expect(() => + removeFencedCode( + mockFileName, + getFencedCode(FEATURE_B).replace(directiveString, replacement), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow( + new RegExp( + `${replacement.replace( + /([()[\]])/gu, + '\\$1', + )}":\nFailed to parse fence directive.$`, + 'u', + ), + ); + }); + }); + + it('rejects malformed END directives', () => { + // This is the last line of the minimal input template + const directiveString = '///: END:ONLY_INCLUDE_IF'; + + const replacements = [ + // Invalid terminus + '///: ENx:ONLY_INCLUDE_IF', + '///: EN3:ONLY_INCLUDE_IF', + '///: EN_:ONLY_INCLUDE_IF', + '///: EN :ONLY_INCLUDE_IF', + '///: EN::ONLY_INCLUDE_IF', + + // Invalid commands + '///: END:ONLY-INCLUDE_IF', + '///: END::ONLY_INCLUDE_IN', + '///: END:ONLY_INCLUDE:IF', + '///: END:ONL6_INCLUDE_IF', + '///: END:ONLY_IN@LUDE_IF', + '///: END:ONLy_INCLUDE_IF', + '///: END:ONLY INCLUDE_IF', + + // Stuff after the directive + '///: END:ONLY_INCLUDE_IF A', + '///: END:ONLY_INCLUDE_IF 9', + '///: END:ONLY_INCLUDE_IF _', + ]; + + replacements.forEach((replacement) => { + expect(() => + removeFencedCode( + mockFileName, + getFencedCode(FEATURE_B).replace(directiveString, replacement), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow( + new RegExp( + `${replacement}":\nFailed to parse fence directive.$`, + 'u', + ), + ); + }); + }); + + it('rejects files with uneven number of fence lines', () => { + const additions = [ + '///: BEGIN:ONLY_INCLUDE_IF(feature-b)', + '///: END:ONLY_INCLUDE_IF', + ]; + + additions.forEach((addition) => { + expect(() => + removeFencedCode( + mockFileName, + getFencedCode(FEATURE_B).concat(addition), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow( + /A valid fence consists of two fence lines, but the file contains an uneven number, "3", of fence lines.$/u, + ); + }); + }); + + it('rejects invalid terminuses', () => { + const testCases = [ + ['BEGIN', ['KAPLAR', 'FLASK', 'FOO']], + ['END', ['KAPLAR', 'FOO', 'BAR']], + ] as const; + + testCases.forEach(([validTerminus, replacements]) => { + replacements.forEach((replacement) => { + expect(() => + removeFencedCode( + mockFileName, + getFencedCode(FEATURE_B).replace(validTerminus, replacement), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow( + new RegExp( + `Line contains invalid directive terminus "${replacement}".$`, + 'u', + ), + ); + }); + }); + }); + + it('rejects invalid commands', () => { + const testCases = [ + [/ONLY_INCLUDE_IF\(/mu, ['ONLY_KEEP_IF(', 'FLASK(', 'FOO(']], + [/ONLY_INCLUDE_IF$/mu, ['ONLY_KEEP_IF', 'FLASK', 'FOO']], + ] as const; + + testCases.forEach(([validCommand, replacements]) => { + replacements.forEach((replacement) => { + expect(() => + removeFencedCode( + mockFileName, + getFencedCode(FEATURE_B).replace(validCommand, replacement), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow( + new RegExp( + `Line contains invalid directive command "${replacement.replace( + '(', + '', + )}".$`, + 'u', + ), + ); + }); + }); + }); + + it('rejects invalid command parameters', () => { + const testCases = [ + ['bar', ['bar', 'feature-b,bar', 'feature-b,feature-c,feature-a,bar']], + ['Foo', ['Foo', 'feature-b,Foo', 'feature-b,feature-c,feature-a,Foo']], + [ + 'b3ta', + ['b3ta', 'feature-b,b3ta', 'feature-b,feature-c,feature-a,b3ta'], + ], + [ + 'bEta', + ['bEta', 'feature-b,bEta', 'feature-b,feature-c,feature-a,bEta'], + ], + ] as const; + + testCases.forEach(([invalidParam, replacements]) => { + replacements.forEach((replacement) => { + expect(() => + removeFencedCode( + mockFileName, + getFencedCode(replacement), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B, FEATURE_A, FEATURE_C]), + }), + ), + ).toThrow( + new RegExp( + `"${invalidParam}" is not a declared build feature.$`, + 'u', + ), + ); + }); + }); + + // Should fail for empty params + expect(() => + removeFencedCode( + mockFileName, + getFencedCode('').replace('()', ''), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow( + 'Invalid code fence parameters in file "file.js":\nNo parameters specified.', + ); + }); + + it('rejects directive pairs with wrong terminus order', () => { + // We need more than one directive pair for this test + const input = getFencedCode(FEATURE_B).concat(getFencedCode(FEATURE_C)); + + const expectedBeginError = + 'The first directive of a pair must be a "BEGIN" directive.'; + const expectedEndError = + 'The second directive of a pair must be an "END" directive.'; + const testCases = [ + [ + 'BEGIN:ONLY_INCLUDE_IF(feature-b)', + 'END:ONLY_INCLUDE_IF', + expectedBeginError, + ], + [ + /END:ONLY_INCLUDE_IF/mu, + 'BEGIN:ONLY_INCLUDE_IF(feature-a)', + expectedEndError, + ], + [ + 'BEGIN:ONLY_INCLUDE_IF(feature-c)', + 'END:ONLY_INCLUDE_IF', + expectedBeginError, + ], + ] as const; + + testCases.forEach(([target, replacement, expectedError]) => { + expect(() => + removeFencedCode( + mockFileName, + input.replace(target, replacement), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow(expectedError); + }); + }); + + it('ignores files with inline source maps', () => { + // This is so that there isn't an unnecessary second execution of + // removeFencedCode with a transpiled version of the same file + const input = getFencedCode('foo').concat( + '\n//# sourceMappingURL=as32e32wcwc2234f2ew32cnin4243f4nv9nsdoivnxzoivnd', + ); + expect( + removeFencedCode( + mockFileName, + input, + getFeatures({ + active: new Set([FEATURE_A]), + all: new Set([FEATURE_A]), + }), + ), + ).toStrictEqual([input, false]); + }); + + // We can't do this until there's more than one command + it.todo('rejects directive pairs with mismatched commands'); + }); + + describe('multiSplice', () => { + it('throws if the indices array is empty or of odd length', () => { + [[], [1], [1, 2, 3]].forEach((invalidInput) => { + expect(() => multiSplice('foobar', invalidInput)).toThrow( + 'Expected non-empty, even-length array.', + ); + }); + }); + + it('throws if the indices array contains non-integer or negative numbers', () => { + [ + [1.2, 2], + [3, -1], + ].forEach((invalidInput) => { + expect(() => multiSplice('foobar', invalidInput)).toThrow( + 'Expected array of non-negative integers.', + ); + }); + }); + }); + + describe('validateCommand', () => { + it('throws if the parameters are invalid', () => { + [null, undefined, []].forEach((invalidInput) => { + expect(() => + validateCommand( + DirectiveCommand.ONLY_INCLUDE_IF, + invalidInput as any, + 'file.js', + {} as any, + ), + ).toThrow('No parameters specified.'); + }); + }); + + it('throws if the command is unrecognized', () => { + expect(() => validateCommand('foobar', [], 'file.js', {} as any)).toThrow( + 'Unrecognized command "foobar".', + ); + }); + }); +}); diff --git a/packages/build-utils/src/transforms/remove-fenced-code.ts b/packages/build-utils/src/transforms/remove-fenced-code.ts new file mode 100644 index 00000000000..b0732155b47 --- /dev/null +++ b/packages/build-utils/src/transforms/remove-fenced-code.ts @@ -0,0 +1,482 @@ +import { hasProperty } from '@metamask/utils'; + +/** + * Two sets of feature labels, where: + * - `active` is the set of labels that are active for the current build. + * - `all` is the set of all labels that are declared in the codebase. + * + * For `ONLY_INCLUDE_IF` fences, the code fence removal transform will + * include the fenced code if any of the specified labels are active. See + * {@link removeFencedCode} for details. + */ +export type FeatureLabels = { + active: ReadonlySet; + all: ReadonlySet; +}; + +enum DirectiveTerminus { + BEGIN = 'BEGIN', + END = 'END', +} + +export enum DirectiveCommand { + // eslint-disable-next-line @typescript-eslint/naming-convention + ONLY_INCLUDE_IF = 'ONLY_INCLUDE_IF', +} + +// Matches lines starting with "///:", and any preceding whitespace, except +// newlines. We except newlines to avoid eating blank lines preceding a fenced +// line. +// Double-negative RegEx credit: https://stackoverflow.com/a/3469155 +const linesWithFenceRegex = /^[^\S\r\n]*\/\/\/:.*$/gmu; + +// Matches the first "///:" in a string, and any preceding whitespace +const fenceSentinelRegex = /^\s*\/\/\/:/u; + +// Breaks a fence directive into its constituent components. +// At this stage of parsing, we are looking for one of: +// - TERMINUS:COMMAND(PARAMS) +// - TERMINUS:COMMAND +const directiveParsingRegex = + /^([A-Z]+):([A-Z_]+)(?:\(((?:\w[-\w]*,)*\w[-\w]*)\))?$/u; + +/** + * Removes fenced code from the given JavaScript source string. "Fenced code" + * includes the entire fence lines, including their trailing newlines, and the + * lines that they surround. + * + * A valid fence consists of two well-formed fence lines, separated by one or + * more lines that should be excluded. The first line must contain a `BEGIN` + * directive, and the second most contain an `END` directive. Both directives + * must specify the same command. + * + * Here's an example of a valid fence: + * + * ```javascript + * ///: BEGIN:ONLY_INCLUDE_IF(build-flask) + * console.log('I am Flask.'); + * ///: END:ONLY_INCLUDE_IF + * ``` + * + * For details, please see the documentation. + * + * @param filePath - The path to the file being transformed. + * @param fileContent - The contents of the file being transformed. + * @param featureLabels - FeatureLabels that are currently active. + * @returns A tuple of the post-transform file contents and a boolean indicating + * whether they were modified. + */ +export function removeFencedCode( + filePath: string, + fileContent: string, + featureLabels: FeatureLabels, +): [string, boolean] { + // Do not modify the file if we detect an inline sourcemap. For reasons + // yet to be determined, the transform receives every file twice while in + // watch mode, the second after Babel has transpiled the file. Babel adds + // inline source maps to the file, something we will never do in our own + // source files, so we use the existence of inline source maps to determine + // whether we should ignore the file. + if (/^\/\/# sourceMappingURL=/gmu.test(fileContent)) { + return [fileContent, false]; + } + + // If we didn't match any lines, return the unmodified file contents. + const matchedLines = [...fileContent.matchAll(linesWithFenceRegex)]; + + if (matchedLines.length === 0) { + return [fileContent, false]; + } + + // Parse fence lines + const parsedDirectives = matchedLines.map((matchArray) => { + const line = matchArray[0]; + + /* istanbul ignore next: should be impossible */ + if ( + matchArray.index === undefined || + !line || + !fenceSentinelRegex.test(line) + ) { + throw new Error( + getInvalidFenceLineMessage( + filePath, + line ?? '', + `Fence sentinel may only appear at the start of a line, optionally preceded by whitespace.`, + ), + ); + } + + // Store the start and end indices of each line + // Increment the end index by 1 to including the trailing newline when + // performing string operations. + const indices: [number, number] = [ + matchArray.index, + matchArray.index + line.length + 1, + ]; + + const lineWithoutSentinel = line.replace(fenceSentinelRegex, ''); + if (!/^ \w\w+/u.test(lineWithoutSentinel)) { + throw new Error( + getInvalidFenceLineMessage( + filePath, + line, + `Fence sentinel must be followed by a single space and an alphabetical string of two or more characters.`, + ), + ); + } + + const directiveMatches = lineWithoutSentinel + .trim() + .match(directiveParsingRegex); + + if (!directiveMatches) { + throw new Error( + getInvalidFenceLineMessage( + filePath, + line, + `Failed to parse fence directive.`, + ), + ); + } + + // The first element of a RegEx match array is the input. + // Typecast: If there's a match, the expected elements must exist. + const [, terminus, command, parameters] = directiveMatches as [ + string, + string, + string, + string, + ]; + + if (!isValidTerminus(terminus)) { + throw new Error( + getInvalidFenceLineMessage( + filePath, + line, + `Line contains invalid directive terminus "${terminus}".`, + ), + ); + } + + if (!isValidCommand(command)) { + throw new Error( + getInvalidFenceLineMessage( + filePath, + line, + `Line contains invalid directive command "${command}".`, + ), + ); + } + + if (terminus === DirectiveTerminus.BEGIN) { + if (!parameters) { + throw new Error( + getInvalidParamsMessage(filePath, `No parameters specified.`), + ); + } + + return { + command, + indices, + line, + parameters: parameters.split(','), + terminus, + }; + } + return { command, indices, line, terminus }; + }); + + if (parsedDirectives.length % 2 !== 0) { + throw new Error( + getInvalidFenceStructureMessage( + filePath, + `A valid fence consists of two fence lines, but the file contains an uneven number, "${parsedDirectives.length}", of fence lines.`, + ), + ); + } + + // The below for-loop iterates over the parsed fence directives and performs + // the following work: + // - Ensures that the array of parsed directives consists of valid directive + // pairs, as specified in the documentation. + // - For each directive pair, determines whether their fenced lines should be + // removed for the current build, and if so, stores the indices we will use + // to splice the file content string. + + const splicingIndices: number[] = []; + let shouldSplice = false; + let currentCommand: string; + + parsedDirectives.forEach((directive, i) => { + const { line, indices, terminus, command } = directive; + + if (i % 2 === 0) { + if (terminus !== DirectiveTerminus.BEGIN) { + throw new Error( + getInvalidFencePairMessage( + filePath, + line, + `The first directive of a pair must be a "BEGIN" directive.`, + ), + ); + } + + const { parameters } = directive; + currentCommand = command; + validateCommand(command, parameters, filePath, featureLabels); + + const blockIsActive = parameters.some((param) => + featureLabels.active.has(param), + ); + + if (blockIsActive) { + shouldSplice = false; + } else { + shouldSplice = true; + + // Add start index of BEGIN directive line to splicing indices + splicingIndices.push(indices[0]); + } + } else { + if (terminus !== DirectiveTerminus.END) { + throw new Error( + getInvalidFencePairMessage( + filePath, + line, + `The second directive of a pair must be an "END" directive.`, + ), + ); + } + + /* istanbul ignore next: impossible until there's more than one command */ + if (command !== currentCommand) { + throw new Error( + getInvalidFencePairMessage( + filePath, + line, + `Expected "END" directive to have command "${currentCommand}" but found "${command}".`, + ), + ); + } + + // Forbid empty fences + const { line: previousLine, indices: previousIndices } = + // We're only in this case if i > 0, so this will always be defined. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + parsedDirectives[i - 1]!; + if (fileContent.substring(previousIndices[1], indices[0]).trim() === '') { + throw new Error( + `Empty fence found in file "${filePath}":\n${previousLine}\n${line}\n`, + ); + } + + if (shouldSplice) { + // Add end index of END directive line to splicing indices + splicingIndices.push(indices[1]); + } + } + }); + + // This indicates that the present build type should include all fenced code, + // and so we just returned the unmodified file contents. + if (splicingIndices.length === 0) { + return [fileContent, false]; + } + + /* istanbul ignore next: should be impossible */ + if (splicingIndices.length % 2 !== 0) { + throw new Error( + `Internal error while transforming file "${filePath}":\nCollected an uneven number of splicing indices: "${splicingIndices.length}"`, + ); + } + + return [multiSplice(fileContent, splicingIndices), true]; +} + +/** + * Returns a copy of the given string, without the character ranges specified + * by the splicing indices array. + * + * The splicing indices must be a non-empty, even-length array of non-negative + * integers, specifying the character ranges to remove from the given string, as + * follows: + * + * `[ start, end, start, end, start, end, ... ]` + * + * Throws if the array is not an even-length array of non-negative integers. + * + * @param toSplice - The string to splice. + * @param splicingIndices - Indices to splice at. + * @returns The spliced string. + */ +export function multiSplice( + toSplice: string, + splicingIndices: number[], +): string { + if (splicingIndices.length === 0 || splicingIndices.length % 2 !== 0) { + throw new Error('Expected non-empty, even-length array.'); + } + if (splicingIndices.some((index) => !Number.isInteger(index) || index < 0)) { + throw new Error('Expected array of non-negative integers.'); + } + + const retainedSubstrings = []; + + // Get the first part to be included + // The substring() call returns an empty string if splicingIndices[0] is 0, + // which is exactly what we want in that case. + retainedSubstrings.push(toSplice.substring(0, splicingIndices[0])); + + // This loop gets us all parts of the string that should be retained, except + // the first and the last. + // It iterates over all "end" indices of the array except the last one, and + // pushes the substring between each "end" index and the next "begin" index + // to the array of retained substrings. + if (splicingIndices.length > 2) { + // Note the boundary index of "splicingIndices.length - 1". This loop must + // not iterate over the last element of the array, which is handled outside + // of this loop. + for (let i = 1; i < splicingIndices.length - 1; i += 2) { + retainedSubstrings.push( + // splicingIndices[i] refers to an element between the first and last + // elements of the array, and will always be defined. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + toSplice.substring(splicingIndices[i]!, splicingIndices[i + 1]), + ); + } + } + + // Get the last part to be included + retainedSubstrings.push( + // The last element of a non-empty array will always be defined. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + toSplice.substring(splicingIndices[splicingIndices.length - 1]!), + ); + return retainedSubstrings.join(''); +} + +/** + * Gets an invalid fence line error message. + * + * @param filePath - The path to the file that caused the error. + * @param line - The contents of the line with the error. + * @param details - An explanation of the error. + * @returns The error message. + */ +function getInvalidFenceLineMessage( + filePath: string, + line: string, + details: string, +) { + return `Invalid fence line in file "${filePath}": "${line}":\n${details}`; +} + +/** + * Gets an invalid fence structure error message. + * + * @param filePath - The path to the file that caused the error. + * @param details - An explanation of the error. + * @returns The error message. + */ +function getInvalidFenceStructureMessage(filePath: string, details: string) { + return `Invalid fence structure in file "${filePath}":\n${details}`; +} + +/** + * Gets an invalid fence pair error message. + * + * @param filePath - The path to the file that caused the error. + * @param line - The contents of the line with the error. + * @param details - An explanation of the error. + * @returns The error message. + */ +function getInvalidFencePairMessage( + filePath: string, + line: string, + details: string, +) { + return `Invalid fence pair in file "${filePath}" due to line "${line}":\n${details}`; +} + +/** + * Gets an invalid command params error message. + * + * @param filePath - The path to the file that caused the error. + * @param details - An explanation of the error. + * @param command - The command of the directive with the invalid parameters, if known. + * @returns The error message. + */ +function getInvalidParamsMessage( + filePath: string, + details: string, + command?: string, +) { + return `Invalid code fence parameters in file "${filePath}"${ + command ? `for command "${command}"` : '' + }:\n${details}`; +} + +/** + * Checks whether the given terminus string is valid, i.e. one of `BEGIN` or `END`. + * + * @param terminus - The terminus string to validate. + * @returns Whether the string is a valid terminus string. + */ +function isValidTerminus(terminus: string): terminus is DirectiveTerminus { + return hasProperty(DirectiveTerminus, terminus); +} + +/** + * Checks whether the given command string is valid. + * + * @param command - The command string to validate. + * @returns Whether the string is a valid command string. + */ +function isValidCommand(command: string): command is DirectiveCommand { + return hasProperty(DirectiveCommand, command); +} + +/** + * Validates the specified command. Throws if validation fails. + * + * @param command - The command to validate. + * @param params - The parameters of the command. + * @param filePath - The path of the current file. + * @param featureLabels - The possible feature labels. + */ +export function validateCommand( + command: unknown, + params: string[], + filePath: string, + featureLabels: FeatureLabels, +): asserts command is DirectiveCommand { + switch (command) { + case DirectiveCommand.ONLY_INCLUDE_IF: + if (!params || params.length === 0) { + throw new Error( + getInvalidParamsMessage( + filePath, + `No parameters specified.`, + DirectiveCommand.ONLY_INCLUDE_IF, + ), + ); + } + + for (const param of params) { + if (!featureLabels.all.has(param)) { + throw new Error( + getInvalidParamsMessage( + filePath, + `"${param}" is not a declared build feature.`, + DirectiveCommand.ONLY_INCLUDE_IF, + ), + ); + } + } + break; + + default: + throw new Error(`Unrecognized command "${String(command)}".`); + } +} diff --git a/packages/build-utils/src/transforms/utils.test.ts b/packages/build-utils/src/transforms/utils.test.ts new file mode 100644 index 00000000000..1ab2cbc8638 --- /dev/null +++ b/packages/build-utils/src/transforms/utils.test.ts @@ -0,0 +1,66 @@ +import { lintTransformedFile } from '..'; + +describe('transform utils', () => { + describe('lintTransformedFile', () => { + const mockESLint: any = { + lintText: jest.fn(), + }; + + it('returns if linting passes with no errors', async () => { + mockESLint.lintText.mockImplementationOnce(async () => + Promise.resolve([{ errorCount: 0 }]), + ); + + expect( + await lintTransformedFile(mockESLint, 'file.js', '/* JavaScript */'), + ).toBeUndefined(); + }); + + it('throws if the file is ignored by ESLint', async () => { + mockESLint.lintText.mockImplementationOnce(async () => + Promise.resolve([]), + ); + + await expect(async () => + lintTransformedFile(mockESLint, 'file.js', '/* JavaScript */'), + ).rejects.toThrow( + /Transformed file "file\.js" appears to be ignored by ESLint\.$/u, + ); + }); + + it('throws if linting produced any errors', async () => { + const ruleId = 'some-eslint-rule'; + const message = 'You violated the rule!'; + + mockESLint.lintText.mockImplementationOnce(async () => + Promise.resolve([ + { errorCount: 1, messages: [{ message, ruleId, severity: 2 }] }, + ]), + ); + + await expect(async () => + lintTransformedFile(mockESLint, 'file.js', '/* JavaScript */'), + ).rejects.toThrow( + /Lint errors encountered for transformed file "file\.js":\n\n {4}some-eslint-rule\n {4}You violated the rule!\n\n$/u, + ); + }); + + // Contrived case for coverage purposes + it('handles missing rule ids', async () => { + const ruleId = null; + const message = 'You violated the rule!'; + + mockESLint.lintText.mockImplementationOnce(async () => + Promise.resolve([ + { errorCount: 1, messages: [{ message, ruleId, severity: 2 }] }, + ]), + ); + + await expect(async () => + lintTransformedFile(mockESLint, 'file.js', '/* JavaScript */'), + ).rejects.toThrow( + /Lint errors encountered for transformed file "file\.js":\n\n {4}\n {4}You violated the rule!\n\n$/u, + ); + }); + }); +}); diff --git a/packages/build-utils/src/transforms/utils.ts b/packages/build-utils/src/transforms/utils.ts new file mode 100644 index 00000000000..1a4f2439ffb --- /dev/null +++ b/packages/build-utils/src/transforms/utils.ts @@ -0,0 +1,62 @@ +import type { ESLint } from 'eslint'; + +// Four spaces +const TAB = ' '; + +/** + * Lints a transformed file by invoking ESLint programmatically on the string + * file contents. The path to the file must be specified so that the repository + * ESLint config can be applied properly. + * + * **ATTN:** See the `eslintInstance` parameter documentation for important usage + * information. + * + * An error is thrown if linting produced any errors, or if the file is ignored + * by ESLint. Files linted by this function must not be ignored by ESLint. + * + * @param eslintInstance - The ESLint instance to use for linting. This instance + * needs to be initialized with the options `{ baseConfig, useEslintrc: false}`, + * where `baseConfig` is the desired ESLint configuration for linting. If using + * your project's regular `.eslintrc` file, you may need to modify certain rules + * for linting to pass after code fences are removed. Stylistic rules are + * particularly likely to cause problems. + * @param filePath - The path to the file. + * @param fileContent - The file content. + * @returns Returns `undefined` or throws an error if linting produced + * any errors, or if the linted file is ignored. + */ +export async function lintTransformedFile( + eslintInstance: ESLint, + filePath: string, + fileContent: string, +): Promise { + const lintResult = ( + await eslintInstance.lintText(fileContent, { filePath, warnIgnored: false }) + )[0]; + + // This indicates that the file is ignored, which should never be the case for + // a transformed file. + if (lintResult === undefined) { + throw new Error( + `MetaMask build: Transformed file "${filePath}" appears to be ignored by ESLint.`, + ); + } + + // This is the success case + if (lintResult.errorCount === 0) { + return; + } + + // Errors are stored in the messages array, and their "severity" is 2 + const errorsString = lintResult.messages + .filter(({ severity }) => severity === 2) + .reduce((allErrors, { message, ruleId }) => { + return allErrors.concat( + `${TAB}${ruleId ?? ''}\n${TAB}${message}\n\n`, + ); + }, ''); + + throw new Error( + `MetaMask build: Lint errors encountered for transformed file "${filePath}":\n\n${errorsString}`, + ); +} diff --git a/packages/build-utils/tsconfig.build.json b/packages/build-utils/tsconfig.build.json new file mode 100644 index 00000000000..0df910b2151 --- /dev/null +++ b/packages/build-utils/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["../../types", "./src"] +} diff --git a/packages/build-utils/tsconfig.json b/packages/build-utils/tsconfig.json new file mode 100644 index 00000000000..ee9de925a21 --- /dev/null +++ b/packages/build-utils/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "include": ["../../types", "./src"] +} diff --git a/packages/build-utils/typedoc.json b/packages/build-utils/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/build-utils/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/tsconfig.build.json b/tsconfig.build.json index ae7873512d3..b553d02e41d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -18,6 +18,9 @@ { "path": "./packages/base-controller/tsconfig.build.json" }, + { + "path": "./packages/build-utils/tsconfig.build.json" + }, { "path": "./packages/composable-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 1e14c95c882..8cab9caa8d9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,9 @@ { "path": "./packages/base-controller" }, + { + "path": "./packages/build-utils" + }, { "path": "./packages/composable-controller" }, diff --git a/yarn.lock b/yarn.lock index 9ec61104bed..3f3664b1165 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1570,6 +1570,24 @@ __metadata: languageName: node linkType: hard +"@metamask/build-utils@workspace:packages/build-utils": + version: 0.0.0-use.local + resolution: "@metamask/build-utils@workspace:packages/build-utils" + dependencies: + "@metamask/auto-changelog": ^3.4.3 + "@metamask/utils": ^8.2.0 + "@types/eslint": ^8.44.7 + "@types/jest": ^27.4.1 + deepmerge: ^4.2.2 + eslint: ^8.44.0 + jest: ^27.5.1 + ts-jest: ^27.1.4 + typedoc: ^0.24.8 + typedoc-plugin-missing-exports: ^2.0.0 + typescript: ~4.8.4 + languageName: unknown + linkType: soft + "@metamask/composable-controller@workspace:packages/composable-controller": version: 0.0.0-use.local resolution: "@metamask/composable-controller@workspace:packages/composable-controller" @@ -3059,6 +3077,23 @@ __metadata: languageName: node linkType: hard +"@types/eslint@npm:^8.44.7": + version: 8.44.7 + resolution: "@types/eslint@npm:8.44.7" + dependencies: + "@types/estree": "*" + "@types/json-schema": "*" + checksum: 72a52f74477fbe7cc95ad290b491f51f0bc547cb7ea3672c68da3ffd3fb21ba86145bc36823a37d0a186caedeaee15b2d2a6b4c02c6c55819ff746053bd28310 + languageName: node + linkType: hard + +"@types/estree@npm:*": + version: 1.0.5 + resolution: "@types/estree@npm:1.0.5" + checksum: dd8b5bed28e6213b7acd0fb665a84e693554d850b0df423ac8076cc3ad5823a6bc26b0251d080bdc545af83179ede51dd3f6fa78cad2c46ed1f29624ddf3e41a + languageName: node + linkType: hard + "@types/graceful-fs@npm:^4.1.2": version: 4.1.6 resolution: "@types/graceful-fs@npm:4.1.6" @@ -3122,6 +3157,13 @@ __metadata: languageName: node linkType: hard +"@types/json-schema@npm:*": + version: 7.0.15 + resolution: "@types/json-schema@npm:7.0.15" + checksum: 97ed0cb44d4070aecea772b7b2e2ed971e10c81ec87dd4ecc160322ffa55ff330dace1793489540e3e318d90942064bb697cc0f8989391797792d919737b3b98 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.9": version: 7.0.12 resolution: "@types/json-schema@npm:7.0.12"