diff --git a/packages/docs/generate-docs.js b/packages/docs/generate-docs.js index fb65512c1..ec338b836 100755 --- a/packages/docs/generate-docs.js +++ b/packages/docs/generate-docs.js @@ -46,11 +46,16 @@ const makeType = ({ type, typeName, multiple }) => { return multiple ? `${typeString}[]` : typeString; }; +/** Determine supported scope for option */ +const makeScope = ({ scope }) => { + return scope || 'Global' +} + /** Generate a table for an array of options */ function generateOptionTable(options) { let text = dedent`\n - | Name | Type | Description | - | ---- | ---- | ----------- | + | Name | Type | Scope | Description | + | ---- | ---- | ----- | ----------- | `; Object.entries(options).forEach(([option, value]) => { @@ -58,7 +63,7 @@ function generateOptionTable(options) { return; } - text += `\n| ${option} | ${makeType(value)} | ${value.description} |`; + text += `\n| ${option} | ${makeType(value)} | ${makeScope(value)} | ${value.description} |`; }); return text; @@ -82,7 +87,7 @@ async function generateConfigDocs() { \`@design-systems/cli\` supports a wide array of configuration files. - Add one of the following to to the root of the project: + Add one of the following to to the root of the project and/or the root of a given submodule: - a \`ds\` key in the \`package.json\` - \`.dsrc\` @@ -93,6 +98,8 @@ async function generateConfigDocs() { - \`ds.config.js\` - \`ds.config.json\` + !> The package-specific configuration feature is in very early stages, and only supports options with the **Local** scope. + ## Structure The config is structured with each key being a command name and the value being an object configuring options. diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index ed4b833e7..ab6bd901e 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -9,6 +9,8 @@ import { Overwrite } from 'utility-types'; export type Option = AppOption & { /** Whether the Option should be configurable via ds.config.json */ config?: boolean; + /** Whether or not the option is available in the global or local scope */ + scope?: string; }; interface Configurable { diff --git a/plugins/size/package.json b/plugins/size/package.json index 3bae024c6..3a91acb1e 100644 --- a/plugins/size/package.json +++ b/plugins/size/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@design-systems/cli-utils": "link:../../packages/cli-utils", + "cosmiconfig": "7.0.0", "@design-systems/plugin": "link:../../packages/plugin", "@royriojas/get-exports-from-file": "https://github.com/hipstersmoothie/get-exports-from-file#all", "change-case": "4.1.1", @@ -42,6 +43,7 @@ "table": "6.0.7", "terser-webpack-plugin": "4.1.0", "tslib": "2.0.1", + "utility-types": "3.10.0", "webpack": "4.44.1", "webpack-bundle-analyzer": "3.8.0", "webpack-inject-plugin": "1.5.5", diff --git a/plugins/size/src/command.ts b/plugins/size/src/command.ts index ff561caa2..5b8c156f0 100644 --- a/plugins/size/src/command.ts +++ b/plugins/size/src/command.ts @@ -89,6 +89,13 @@ const command: CliCommand = { description: 'Failure Threshold for Size', config: true }, + { + name: 'sizeLimit', + type: Number, + description: 'Size limit failure threshold', + config: true, + scope: 'Local' + }, { name: 'merge-base', type: String, diff --git a/plugins/size/src/index.ts b/plugins/size/src/index.ts index 159f06e64..87c127eed 100644 --- a/plugins/size/src/index.ts +++ b/plugins/size/src/index.ts @@ -8,7 +8,7 @@ import { SizeResult} from "./interfaces" import { formatLine, formatExports } from "./utils/formatUtils"; import { buildPackages } from "./utils/BuildUtils"; -import { calcSizeForAllPackages, reportResults, table, diffSizeForPackage } from "./utils/CalcSizeUtils"; +import { calcSizeForAllPackages, reportResults, table, diffSizeForPackage, sizePassesMuster } from "./utils/CalcSizeUtils"; import { startAnalyze } from "./utils/WebpackUtils"; import { createDiff } from "./utils/DiffUtils"; @@ -86,10 +86,11 @@ export default class SizePlugin implements Plugin { local }); const header = args.css ? cssHeader : defaultHeader; + const success = sizePassesMuster(size, FAILURE_THRESHOLD); await reportResults( name, - size.percent <= FAILURE_THRESHOLD || size.percent === Infinity, + success, Boolean(args.comment), table( args.detailed @@ -107,7 +108,7 @@ export default class SizePlugin implements Plugin { createDiff(); } - if (size && size.percent > FAILURE_THRESHOLD && size.percent !== Infinity) { + if (!success) { process.exit(1); } } diff --git a/plugins/size/src/interfaces.ts b/plugins/size/src/interfaces.ts index e62262cee..2475eac36 100644 --- a/plugins/size/src/interfaces.ts +++ b/plugins/size/src/interfaces.ts @@ -19,6 +19,8 @@ export interface SizeArgs { ignore?: string[] /** The registry to install packages from */ registry?: string + /** Size limit failure threshold */ + sizeLimit?: number /** Size Failure Threshold */ failureThreshold?: number /** Run the plugin against merge base. (Will be slower due to additional build process) */ @@ -43,6 +45,8 @@ export interface Size { js: number /** Top level exports of package */ exported?: Export[] + /** Maximum bundle size as defined by the package */ + limit?: number } export interface SizeResult { @@ -52,6 +56,8 @@ export interface SizeResult { pr: Size /** The difference between sizes */ percent: number + /** The total number of bytes allowed as defined in the local changeset */ + localBudget?: number } export interface ConfigOptions { @@ -88,6 +94,15 @@ export interface GetSizesOptions extends CommonCalcSizeOptions { analyze?: boolean /** What port to start the analyzer on */ analyzerPort?: number + /** Working directory to execute analysis from */ + dir: string +} + +export interface LoadPackageOptions { + /** The name of the package to get size for */ + name: string + /** The registry to install packages from */ + registry?: string } type Scope = 'pr' | 'master' diff --git a/plugins/size/src/utils/BuildUtils.ts b/plugins/size/src/utils/BuildUtils.ts index 1e5425ae6..52dafd629 100644 --- a/plugins/size/src/utils/BuildUtils.ts +++ b/plugins/size/src/utils/BuildUtils.ts @@ -1,7 +1,10 @@ -import { execSync } from 'child_process'; +import { execSync, ExecSyncOptions } from 'child_process'; import os from 'os'; import path from 'path'; -import { getMonorepoRoot, createLogger } from '@design-systems/cli-utils'; +import fs from 'fs-extra'; +import { getMonorepoRoot, createLogger, getLogLevel } from '@design-systems/cli-utils'; +import { mockPackage } from './CalcSizeUtils'; +import { LoadPackageOptions } from '../interfaces'; const logger = createLogger({ scope: 'size' }); @@ -11,7 +14,7 @@ export function buildPackages(args: { mergeBase: string /** Build command for merge base */ buildCommand: string -}) { +}): string { const id = Math.random().toString(36).substring(7); const dir = path.join(os.tmpdir(), `commit-build-${id}`); const root = getMonorepoRoot(); @@ -54,3 +57,39 @@ export function getLocalPackage( return path.join(local, path.relative(getMonorepoRoot(), pkg.location)); } + +/** Install package to tmp dir */ +export async function loadPackage(options: LoadPackageOptions): Promise { + const dir = mockPackage(); + const execOptions: ExecSyncOptions = { + cwd: dir, + stdio: getLogLevel() === 'trace' ? 'inherit' : 'ignore' + }; + try { + const browsersList = path.join(getMonorepoRoot(), '.browserslistrc'); + if (fs.existsSync(browsersList)) { + fs.copyFileSync(browsersList, path.join(dir, '.browserslistrc')); + } + + const npmrc = path.join(getMonorepoRoot(), '.npmrc'); + if (options.registry && fs.existsSync(npmrc)) { + fs.copyFileSync(npmrc, path.join(dir, '.npmrc')); + } + + logger.debug(`Installing: ${options.name}`); + if (options.registry) { + execSync( + `yarn add ${options.name} --registry ${options.registry}`, + execOptions + ); + } else { + execSync(`yarn add ${options.name}`, execOptions); + } + } catch (error) { + logger.debug(error); + logger.warn(`Could not find package ${options.name}...`); + return './'; + } + + return dir; +} diff --git a/plugins/size/src/utils/CalcSizeUtils.ts b/plugins/size/src/utils/CalcSizeUtils.ts index adb0f21b5..3b21ae13e 100644 --- a/plugins/size/src/utils/CalcSizeUtils.ts +++ b/plugins/size/src/utils/CalcSizeUtils.ts @@ -1,4 +1,5 @@ import { monorepoName, createLogger } from '@design-systems/cli-utils'; +import { cosmiconfigSync as load } from 'cosmiconfig'; import path from 'path'; import fs from 'fs-extra'; import os from 'os'; @@ -20,7 +21,7 @@ import { DiffSizeForPackageOptions } from '../interfaces'; import { getSizes } from './WebpackUtils'; -import { getLocalPackage } from './BuildUtils'; +import { getLocalPackage, loadPackage } from './BuildUtils'; const RUNTIME_SIZE = 537; @@ -39,6 +40,22 @@ const cssHeader = [ const defaultHeader = ['master', 'pr', '+/-', '%']; +/** Load package-specific configuration options. */ +function loadConfig(cwd: string) { + return load('ds', { + searchPlaces: [ + 'package.json', + `.dsrc`, + `.dsrc.json`, + `.dsrc.yaml`, + `.dsrc.yml`, + `.dsrc.js`, + `ds.config.js`, + `ds.config.json`, + ] + }).search(cwd)?.config; +} + /** Calculate the bundled CSS and JS size. */ async function calcSizeForPackage({ name, @@ -50,15 +67,24 @@ async function calcSizeForPackage({ registry, local }: CommonOptions & CommonCalcSizeOptions): Promise { + const packageName = local ? getLocalPackage(importName, local) : name; + const dir = await loadPackage({ + name: packageName, + registry + }); const sizes = await getSizes({ - name: local ? getLocalPackage(importName, local) : name, + name: packageName, importName, scope, persist, chunkByExport, diff, - registry + registry, + dir }); + const packageDir = local ? path.join(dir, 'node_modules', packageName) : name; + const packageConfig = loadConfig(packageDir); + fs.removeSync(dir); const js = sizes.filter((size) => !size.chunkNames.includes('css')); const css = sizes.filter((size) => size.chunkNames.includes('css')); @@ -76,6 +102,7 @@ async function calcSizeForPackage({ js: js.length ? js.reduce((acc, i) => i.size + acc, 0) - RUNTIME_SIZE : 0, // Minus webpack runtime size; css: css.length ? css.reduce((acc, i) => i.size + acc, 0) : 0, exported: sizes, + limit: packageConfig?.size?.sizeLimit }; } @@ -129,11 +156,12 @@ async function diffSizeForPackage({ master, pr, percent, + localBudget: pr.limit }; } /** Create a mock npm package in a tmp dir on the system. */ -export function mockPackage() { +export function mockPackage(): string { const id = Math.random().toString(36).substring(7); const dir = path.join(os.tmpdir(), `package-size-${id}`); @@ -267,6 +295,15 @@ function table(data: (string | number)[][], isCi?: boolean) { return cliTable(data); } +/** Analyzes a SizeResult to determine if it passes or fails */ +export function sizePassesMuster(size: SizeResult, failureThreshold: number) { + const underFailureThreshold = size && + size.percent <= failureThreshold || + size.percent === Infinity; + const underSizeLimit = size.localBudget ? size.pr.js + size.pr.css <= size.localBudget : true; + return underFailureThreshold && underSizeLimit; +} + /** Generate diff for all changed packages in the monorepo. */ async function calcSizeForAllPackages(args: SizeArgs & CommonCalcSizeOptions) { const ignore = args.ignore || []; @@ -317,11 +354,13 @@ async function calcSizeForAllPackages(args: SizeArgs & CommonCalcSizeOptions) { results.push(size); const FAILURE_THRESHOLD = args.failureThreshold || 5; - if (size.percent > FAILURE_THRESHOLD && size.percent !== Infinity) { - success = false; - logger.error(`${packageJson.package.name} failed bundle size check :(`); - } else { + + success = sizePassesMuster(size, FAILURE_THRESHOLD); + + if (success) { logger.success(`${packageJson.package.name} passed bundle size check!`); + } else { + logger.error(`${packageJson.package.name} failed bundle size check :(`); } return args.detailed diff --git a/plugins/size/src/utils/WebpackUtils.ts b/plugins/size/src/utils/WebpackUtils.ts index 1b8f4bb7c..4c35b9493 100644 --- a/plugins/size/src/utils/WebpackUtils.ts +++ b/plugins/size/src/utils/WebpackUtils.ts @@ -9,13 +9,11 @@ import Terser from 'terser-webpack-plugin'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import OptimizeCSSAssetsPlugin from 'optimize-css-assets-webpack-plugin'; import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; -import { getMonorepoRoot, getLogLevel } from '@design-systems/cli-utils'; -import { execSync, ExecSyncOptions } from 'child_process'; +import { execSync } from 'child_process'; import RelativeCommentsPlugin from '../RelativeCommentsPlugin'; import { fromEntries } from './formatUtils'; import { ConfigOptions, GetSizesOptions, CommonOptions } from '../interfaces'; -import { mockPackage } from './CalcSizeUtils'; -import { getLocalPackage } from './BuildUtils'; +import { getLocalPackage, loadPackage } from './BuildUtils'; const logger = createLogger({ scope: 'size' }); @@ -161,52 +159,18 @@ async function runWebpack(config: webpack.Configuration): Promise }); } -/** Install package to tmp dir and run webpack on it to calculate size. */ +/** Run webpack on package directory to calculate size. */ async function getSizes(options: GetSizesOptions & CommonOptions) { - const dir = mockPackage(); - const execOptions: ExecSyncOptions = { - cwd: dir, - stdio: getLogLevel() === 'trace' ? 'inherit' : 'ignore' - }; - try { - const browsersList = path.join(getMonorepoRoot(), '.browserslistrc'); - if (fs.existsSync(browsersList)) { - fs.copyFileSync(browsersList, path.join(dir, '.browserslistrc')); - } - - const npmrc = path.join(getMonorepoRoot(), '.npmrc'); - if (options.registry && fs.existsSync(npmrc)) { - fs.copyFileSync(npmrc, path.join(dir, '.npmrc')); - } - - logger.debug(`Installing: ${options.name}`); - if (options.registry) { - execSync( - `yarn add ${options.name} --registry ${options.registry}`, - execOptions - ); - } else { - execSync(`yarn add ${options.name}`, execOptions); - } - } catch (error) { - logger.debug(error); - logger.warn(`Could not find package ${options.name}...`); - return []; - } - const result = await runWebpack( - await config({ - dir, - ...options - }) + await config(options) ); - logger.debug(`Completed building: ${dir}`); + logger.debug(`Completed building: ${options.dir}`); if (options.persist) { const folder = `bundle-${options.scope}-${options.importName}`; const out = path.join(process.cwd(), folder); logger.info(`Persisting output to: ${folder}`); await fs.remove(out); - await fs.copy(dir, out); + await fs.copy(options.dir, out); await fs.writeFile(`${out}/stats.json`, JSON.stringify(result.toJson())); await fs.writeFile( `${out}/.gitignore`, @@ -218,7 +182,6 @@ async function getSizes(options: GetSizesOptions & CommonOptions) { execSync('git commit -m "init"', { cwd: out }); } - fs.removeSync(dir); if (result.hasErrors()) { throw new Error(result.toString('errors-only')); } @@ -234,13 +197,19 @@ async function getSizes(options: GetSizesOptions & CommonOptions) { /** Start the webpack bundle analyzer for both of the bundles. */ async function startAnalyze(name: string, registry?: string, local?: string) { logger.start('Analyzing build output...'); + const packageName = local ? getLocalPackage(name, local) : name; + const dir = await loadPackage({ + name: packageName, + registry + }); await Promise.all([ getSizes({ - name: local ? getLocalPackage(name, local) : name, + name: packageName, importName: name, scope: 'master', analyze: true, - registry + registry, + dir }), getSizes({ name: process.cwd(), @@ -248,9 +217,11 @@ async function startAnalyze(name: string, registry?: string, local?: string) { scope: 'pr', analyze: true, analyzerPort: 9000, - registry + registry, + dir }) ]); + fs.removeSync(dir); } export { startAnalyze, runWebpack, config, getSizes };