@mui/internal-bundle-size-checker 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintrc.cjs ADDED
@@ -0,0 +1,14 @@
1
+ module.exports = {
2
+ rules: {
3
+ 'import/prefer-default-export': 'off',
4
+ // Allow .js file extensions in import statements for ESM compatibility
5
+ 'import/extensions': [
6
+ 'error',
7
+ 'ignorePackages',
8
+ {
9
+ js: 'always',
10
+ mjs: 'always',
11
+ },
12
+ ],
13
+ },
14
+ };
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2019 Material-UI SAS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # Bundle Size Checker
2
+
3
+ A tool to check and track the bundle size of MUI packages.
4
+
5
+ ## Features
6
+
7
+ - Measures minified and gzipped bundle sizes of packages and components
8
+ - Compares bundle sizes between versions
9
+ - Generates markdown reports
10
+ - Uploads snapshots to S3 for persistent storage and comparison
11
+
12
+ ## Usage
13
+
14
+ ### CLI
15
+
16
+ ```bash
17
+ bundle-size-checker [options]
18
+ ```
19
+
20
+ Options:
21
+
22
+ - `--analyze`: Creates a webpack-bundle-analyzer report for each bundle
23
+ - `--accurateBundles`: Displays used bundles accurately at the cost of more CPU cycles
24
+ - `--output`, `-o`: Path to output the size snapshot JSON file
25
+
26
+ ### Configuration
27
+
28
+ Create a `bundle-size-checker.config.js` or `bundle-size-checker.config.mjs` file:
29
+
30
+ ```js
31
+ import { defineConfig } from '@mui/internal-bundle-size-checker';
32
+
33
+ export default defineConfig(async () => {
34
+ return {
35
+ entrypoints: [
36
+ // String entries (simple format)
37
+ '@mui/material', // Will bundle `import * as ... from '@mui/material'`
38
+ '@mui/material/Button', // Will bundle `import * as ... from '@mui/material/Button'`
39
+ '@mui/material#Button', // Will bundle `import { Button } from '@mui/material'`
40
+
41
+ // Object entries (advanced format)
42
+ {
43
+ id: 'custom-button',
44
+ code: `import Button from '@mui/material/Button'; console.log(Button);`,
45
+ },
46
+ // Object entries with import and importedNames
47
+ {
48
+ id: 'material-button-icons',
49
+ import: '@mui/material',
50
+ importedNames: ['Button', 'IconButton'],
51
+ },
52
+ // Object entry with custom externals
53
+ {
54
+ id: 'custom-externals',
55
+ import: '@mui/material',
56
+ importedNames: ['Button'],
57
+ externals: ['react', 'react-dom', '@emotion/styled'],
58
+ },
59
+ // Object entry that automatically extracts externals from package.json peer dependencies
60
+ {
61
+ id: 'auto-externals',
62
+ import: '@mui/material',
63
+ importedNames: ['Button'],
64
+ // When externals is not specified, peer dependencies will be automatically excluded
65
+ },
66
+ // ...
67
+ ],
68
+ // Optional upload configuration
69
+ upload: {
70
+ project: 'organization/repository',
71
+ branch: 'main', // Optional, defaults to current git branch
72
+ isPullRequest: false, // Optional, defaults to false
73
+ },
74
+ };
75
+ });
76
+ ```
77
+
78
+ ### S3 Upload
79
+
80
+ When the `upload` configuration is provided, the snapshot will be uploaded to S3 after generation.
81
+
82
+ The snapshot will be uploaded to:
83
+
84
+ ```bash
85
+ s3://mui-org-ci/artifacts/{project}/{commit-sha}/size-snapshot.json
86
+ ```
87
+
88
+ The following tags will be applied:
89
+
90
+ - `isPullRequest`: 'yes' or 'no'
91
+ - `branch`: The branch name
92
+
93
+ Required AWS environment variables:
94
+
95
+ - `AWS_ACCESS_KEY_ID` or `AWS_ACCESS_KEY_ID_ARTIFACTS`
96
+ - `AWS_SECRET_ACCESS_KEY` or `AWS_SECRET_ACCESS_KEY_ARTIFACTS`
97
+ - `AWS_REGION` or `AWS_REGION_ARTIFACTS` (defaults to 'eu-central-1')
98
+
99
+ If the upload fails, the CLI will exit with an error code.
100
+
101
+ ## API
102
+
103
+ The library exports the following functions:
104
+
105
+ - `defineConfig`: Helper for defining configuration with TypeScript support
106
+ - `loadConfig`: Loads configuration from file
107
+ - `calculateSizeDiff`: Calculates size differences between snapshots
108
+ - `renderMarkdownReport`: Generates markdown reports from size comparisons
109
+ - `fetchSnapshot`: Fetches size snapshots from S3
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ import '../src/cli.js';
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@mui/internal-bundle-size-checker",
3
+ "version": "1.0.0",
4
+ "description": "Bundle size checker for MUI packages.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "bin": {
8
+ "bundle-size-checker": "./bin/bundle-size-checker.js"
9
+ },
10
+ "sideEffects": false,
11
+ "exports": {
12
+ ".": "./src/index.js",
13
+ "./package.json": "./package.json",
14
+ "./browser": "./src/browser.js"
15
+ },
16
+ "dependencies": {
17
+ "@aws-sdk/client-s3": "^3.515.0",
18
+ "@aws-sdk/credential-providers": "^3.787.0",
19
+ "chalk": "^5.4.1",
20
+ "compression-webpack-plugin": "^10.0.0",
21
+ "css-loader": "^7.1.2",
22
+ "env-ci": "^11.1.0",
23
+ "execa": "^7.2.0",
24
+ "fast-glob": "^3.3.2",
25
+ "file-loader": "^6.2.0",
26
+ "fs-extra": "^11.2.0",
27
+ "micromatch": "^4.0.8",
28
+ "piscina": "^4.2.1",
29
+ "terser-webpack-plugin": "^5.3.10",
30
+ "webpack": "^5.90.3",
31
+ "webpack-bundle-analyzer": "^4.10.1",
32
+ "yargs": "^17.7.2"
33
+ },
34
+ "devDependencies": {
35
+ "@types/env-ci": "^3.1.4",
36
+ "@types/fs-extra": "^11.0.4",
37
+ "@types/micromatch": "^4.0.9",
38
+ "@types/webpack": "^5.28.5",
39
+ "@types/webpack-bundle-analyzer": "^4.7.0",
40
+ "@types/yargs": "^17.0.33"
41
+ },
42
+ "scripts": {
43
+ "typescript": "tsc -p tsconfig.json"
44
+ }
45
+ }
@@ -0,0 +1,2 @@
1
+ export * from './sizeDiff.js';
2
+ export * from './fetchSnapshot.js';
package/src/browser.js ADDED
@@ -0,0 +1,2 @@
1
+ export { calculateSizeDiff } from './sizeDiff.js';
2
+ export { fetchSnapshot } from './fetchSnapshot.js';
package/src/cli.js ADDED
@@ -0,0 +1,448 @@
1
+ // @ts-check
2
+
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import fse from 'fs-extra';
6
+ import yargs from 'yargs';
7
+ import Piscina from 'piscina';
8
+ import micromatch from 'micromatch';
9
+ import { loadConfig } from './configLoader.js';
10
+ import { uploadSnapshot } from './uploadSnapshot.js';
11
+ import { calculateSizeDiff } from './sizeDiff.js';
12
+ import { renderMarkdownReportContent, renderMarkdownReport } from './renderMarkdownReport.js';
13
+ import { fetchSnapshot } from './fetchSnapshot.js';
14
+
15
+ /**
16
+ * @typedef {import('./sizeDiff.js').SizeSnapshot} SizeSnapshot
17
+ */
18
+
19
+ const MAX_CONCURRENCY = Math.min(8, os.cpus().length);
20
+
21
+ const rootDir = process.cwd();
22
+
23
+ /**
24
+ * Normalizes entries to ensure they have a consistent format and ids are unique
25
+ * @param {ObjectEntry[]} entries - The array of entries from the config
26
+ * @returns {ObjectEntry[]} - Normalized entries with uniqueness enforced
27
+ */
28
+ function normalizeEntries(entries) {
29
+ const usedIds = new Set();
30
+
31
+ return entries.map((entry) => {
32
+ if (!entry.id) {
33
+ throw new Error('Object entries must have an id property');
34
+ }
35
+
36
+ if (!entry.code && !entry.import) {
37
+ throw new Error(`Entry "${entry.id}" must have either code or import property defined`);
38
+ }
39
+
40
+ if (usedIds.has(entry.id)) {
41
+ throw new Error(`Duplicate entry id found: "${entry.id}". Entry ids must be unique.`);
42
+ }
43
+
44
+ usedIds.add(entry.id);
45
+
46
+ return entry;
47
+ });
48
+ }
49
+
50
+ /**
51
+ * creates size snapshot for every bundle that built with webpack
52
+ * @param {CommandLineArgs} args
53
+ * @param {NormalizedBundleSizeCheckerConfig} config - The loaded configuration
54
+ * @returns {Promise<Array<[string, { parsed: number, gzip: number }]>>}
55
+ */
56
+ async function getWebpackSizes(args, config) {
57
+ const worker = new Piscina({
58
+ filename: new URL('./worker.js', import.meta.url).href,
59
+ maxThreads: MAX_CONCURRENCY,
60
+ });
61
+ // Clean and recreate the build directory
62
+ const buildDir = path.join(rootDir, 'build');
63
+ await fse.emptyDir(buildDir);
64
+
65
+ if (
66
+ !config ||
67
+ !config.entrypoints ||
68
+ !Array.isArray(config.entrypoints) ||
69
+ config.entrypoints.length === 0
70
+ ) {
71
+ throw new Error(
72
+ 'No valid configuration found. Create a bundle-size-checker.config.js or bundle-size-checker.config.mjs file with entrypoints array.',
73
+ );
74
+ }
75
+
76
+ // Normalize and validate entries
77
+ const entries = normalizeEntries(config.entrypoints);
78
+
79
+ // Apply filters if provided
80
+ let validEntries = entries;
81
+ const filter = args.filter;
82
+ if (filter && filter.length > 0) {
83
+ validEntries = entries.filter((entry) => {
84
+ return filter.some((pattern) => {
85
+ if (pattern.includes('*') || pattern.includes('?') || pattern.includes('[')) {
86
+ return micromatch.isMatch(entry.id, pattern, { nocase: true });
87
+ }
88
+ return entry.id.toLowerCase().includes(pattern.toLowerCase());
89
+ });
90
+ });
91
+
92
+ if (validEntries.length === 0) {
93
+ console.warn('Warning: No entries match the provided filter pattern(s).');
94
+ }
95
+ }
96
+
97
+ const sizeArrays = await Promise.all(
98
+ validEntries.map((entry, index) =>
99
+ worker.run({ entry, args, index, total: validEntries.length }),
100
+ ),
101
+ );
102
+
103
+ return sizeArrays.flat();
104
+ }
105
+
106
+ /**
107
+ * Main runner function
108
+ * @param {CommandLineArgs} argv - Command line arguments
109
+ */
110
+ async function run(argv) {
111
+ const { analyze, accurateBundles, output, verbose, filter } = argv;
112
+
113
+ const snapshotDestPath = output ? path.resolve(output) : path.join(rootDir, 'size-snapshot.json');
114
+
115
+ const config = await loadConfig(rootDir);
116
+
117
+ // Pass the filter patterns to getWebpackSizes if provided
118
+ const webpackSizes = await getWebpackSizes({ analyze, accurateBundles, verbose, filter }, config);
119
+ const bundleSizes = Object.fromEntries(webpackSizes.sort((a, b) => a[0].localeCompare(b[0])));
120
+
121
+ // Ensure output directory exists
122
+ await fse.mkdirp(path.dirname(snapshotDestPath));
123
+ await fse.writeJSON(snapshotDestPath, bundleSizes, { spaces: 2 });
124
+
125
+ // eslint-disable-next-line no-console
126
+ console.log(`Bundle size snapshot written to ${snapshotDestPath}`);
127
+
128
+ // Upload the snapshot if upload configuration is provided and not null
129
+ if (config && config.upload) {
130
+ try {
131
+ // eslint-disable-next-line no-console
132
+ console.log('Uploading bundle size snapshot to S3...');
133
+ const { key } = await uploadSnapshot(snapshotDestPath, config.upload);
134
+ // eslint-disable-next-line no-console
135
+ console.log(`Bundle size snapshot uploaded to S3 with key: ${key}`);
136
+ } catch (/** @type {any} */ error) {
137
+ console.error('Failed to upload bundle size snapshot:', error.message);
138
+ // Exit with error code to indicate failure
139
+ process.exit(1);
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Resolves a file path that can be relative or absolute
146
+ * @param {string} filePath - The file path to resolve
147
+ * @returns {string} The resolved absolute path
148
+ */
149
+ function resolveFilePath(filePath) {
150
+ if (path.isAbsolute(filePath)) {
151
+ return filePath;
152
+ }
153
+ return path.resolve(rootDir, filePath);
154
+ }
155
+
156
+ /**
157
+ * Checks if a string is a URL
158
+ * @param {string} str - The string to check
159
+ * @returns {boolean} Whether the string is a URL
160
+ */
161
+ function isUrl(str) {
162
+ try {
163
+ // eslint-disable-next-line no-new
164
+ new URL(str);
165
+ return true;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Loads a snapshot from a URL (http:, https:, or file: scheme)
173
+ * @param {string} source - The source URL
174
+ * @returns {Promise<SizeSnapshot>} The loaded snapshot
175
+ */
176
+ async function loadSnapshot(source) {
177
+ // Check if it's a valid URL
178
+ if (!isUrl(source)) {
179
+ throw new Error(`Invalid URL: ${source}. Use file:, http:, or https: schemes.`);
180
+ }
181
+
182
+ if (source.startsWith('file:')) {
183
+ // Handle file: URL
184
+ // Remove file: prefix and handle the rest as a file path
185
+ // For file:///absolute/path
186
+ let filePath = source.substring(source.indexOf('file:') + 5);
187
+
188
+ // Remove leading slashes for absolute paths on this machine
189
+ while (
190
+ filePath.startsWith('/') &&
191
+ !path.isAbsolute(filePath.substring(1)) &&
192
+ filePath.length > 1
193
+ ) {
194
+ filePath = filePath.substring(1);
195
+ }
196
+
197
+ // Now resolve the path
198
+ filePath = resolveFilePath(filePath);
199
+
200
+ try {
201
+ return await fse.readJSON(filePath);
202
+ } catch (/** @type {any} */ error) {
203
+ throw new Error(`Failed to read snapshot from ${filePath}: ${error.message}`);
204
+ }
205
+ }
206
+
207
+ // HTTP/HTTPS URL - fetch directly
208
+ const response = await fetch(source);
209
+ if (!response.ok) {
210
+ throw new Error(`Failed to fetch snapshot from ${source}: ${response.statusText}`);
211
+ }
212
+ const body = await response.json();
213
+ return body;
214
+ }
215
+
216
+ /**
217
+ * Handler for the diff command
218
+ * @param {DiffCommandArgs} argv - Command line arguments
219
+ */
220
+ async function diffHandler(argv) {
221
+ const { base, head = 'file:./size-snapshot.json', output, reportUrl } = argv;
222
+
223
+ if (!base) {
224
+ console.error('The --base option is required');
225
+ process.exit(1);
226
+ }
227
+
228
+ try {
229
+ // Load snapshots
230
+ // eslint-disable-next-line no-console
231
+ console.log(`Loading base snapshot from ${base}...`);
232
+ const baseSnapshot = await loadSnapshot(base);
233
+
234
+ // eslint-disable-next-line no-console
235
+ console.log(`Loading head snapshot from ${head}...`);
236
+ const headSnapshot = await loadSnapshot(head);
237
+
238
+ // Calculate diff
239
+ const comparison = calculateSizeDiff(baseSnapshot, headSnapshot);
240
+
241
+ // Output
242
+ if (output === 'markdown') {
243
+ // Generate markdown with optional report URL
244
+ let markdownContent = renderMarkdownReportContent(comparison);
245
+
246
+ // Add report URL if provided
247
+ if (reportUrl) {
248
+ markdownContent += `\n\n[Details of bundle changes](${reportUrl})`;
249
+ }
250
+
251
+ // eslint-disable-next-line no-console
252
+ console.log(markdownContent);
253
+ } else {
254
+ // Default JSON output
255
+ // eslint-disable-next-line no-console
256
+ console.log(JSON.stringify(comparison, null, 2));
257
+ }
258
+ } catch (/** @type {any} */ error) {
259
+ console.error(`Error: ${error.message}`);
260
+ process.exit(1);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Fetches GitHub PR information
266
+ * @param {string} owner - Repository owner
267
+ * @param {string} repo - Repository name
268
+ * @param {number} prNumber - Pull request number
269
+ * @returns {Promise<PrInfo>} PR information
270
+ */
271
+ async function fetchPrInfo(owner, repo, prNumber) {
272
+ const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`;
273
+
274
+ try {
275
+ // eslint-disable-next-line no-console
276
+ console.log(`Fetching PR info from ${url}...`);
277
+ const response = await fetch(url);
278
+
279
+ if (!response.ok) {
280
+ throw new Error(`GitHub API request failed: ${response.statusText} (${response.status})`);
281
+ }
282
+
283
+ return await response.json();
284
+ } catch (/** @type {any} */ error) {
285
+ console.error(`Failed to fetch PR info: ${error.message}`);
286
+ throw error;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Handler for the pr command
292
+ * @param {PrCommandArgs} argv - Command line arguments
293
+ */
294
+ async function prHandler(argv) {
295
+ const { prNumber, circleci, output } = argv;
296
+
297
+ try {
298
+ // Load the config to get the repository information
299
+ const config = await loadConfig(rootDir);
300
+
301
+ if (!config.upload) {
302
+ throw new Error(
303
+ 'Upload is not configured. Please enable it in your bundle-size-checker config.',
304
+ );
305
+ }
306
+
307
+ // Extract owner and repo from repository config
308
+ const [owner, repo] = config.upload.repo.split('/');
309
+
310
+ if (!owner || !repo) {
311
+ throw new Error(
312
+ `Invalid repository format in config: ${config.upload.repo}. Expected format: "owner/repo"`,
313
+ );
314
+ }
315
+
316
+ // Fetch PR information from GitHub
317
+ const prInfo = await fetchPrInfo(owner, repo, prNumber);
318
+
319
+ // Generate the report
320
+ // eslint-disable-next-line no-console
321
+ console.log('Generating bundle size report...');
322
+ const report = await renderMarkdownReport(prInfo, circleci);
323
+
324
+ // Output
325
+ if (output === 'markdown') {
326
+ // eslint-disable-next-line no-console
327
+ console.log(report);
328
+ } else {
329
+ // For JSON we need to load the snapshots and calculate differences
330
+ const baseCommit = prInfo.base.sha;
331
+ const prCommit = prInfo.head.sha;
332
+
333
+ // eslint-disable-next-line no-console
334
+ console.log(`Fetching base snapshot for commit ${baseCommit}...`);
335
+ // eslint-disable-next-line no-console
336
+ console.log(`Fetching PR snapshot for commit ${prCommit}...`);
337
+
338
+ const [baseSnapshot, prSnapshot] = await Promise.all([
339
+ fetchSnapshot(config.upload.repo, baseCommit).catch(() => ({})),
340
+ fetchSnapshot(config.upload.repo, prCommit).catch(() => ({})),
341
+ ]);
342
+
343
+ const comparison = calculateSizeDiff(baseSnapshot, prSnapshot);
344
+ // eslint-disable-next-line no-console
345
+ console.log(JSON.stringify(comparison, null, 2));
346
+ }
347
+ } catch (/** @type {any} */ error) {
348
+ console.error(`Error: ${error.message}`);
349
+ process.exit(1);
350
+ }
351
+ }
352
+
353
+ yargs(process.argv.slice(2))
354
+ // @ts-expect-error
355
+ .command({
356
+ command: '$0',
357
+ describe: 'Saves a size snapshot in size-snapshot.json',
358
+ builder: (cmdYargs) => {
359
+ return cmdYargs
360
+ .option('analyze', {
361
+ default: false,
362
+ describe: 'Creates a webpack-bundle-analyzer report for each bundle.',
363
+ type: 'boolean',
364
+ })
365
+ .option('accurateBundles', {
366
+ default: false,
367
+ describe: 'Displays used bundles accurately at the cost of more CPU cycles.',
368
+ type: 'boolean',
369
+ })
370
+ .option('verbose', {
371
+ default: false,
372
+ describe: 'Show more detailed information during compilation.',
373
+ type: 'boolean',
374
+ })
375
+ .option('output', {
376
+ alias: 'o',
377
+ describe:
378
+ 'Path to output the size snapshot JSON file (defaults to size-snapshot.json in current directory).',
379
+ type: 'string',
380
+ })
381
+ .option('filter', {
382
+ alias: 'F',
383
+ describe: 'Filter entry points by glob pattern(s) applied to their IDs',
384
+ type: 'array',
385
+ });
386
+ },
387
+ handler: run,
388
+ })
389
+ // @ts-expect-error
390
+ .command({
391
+ command: 'diff',
392
+ describe: 'Compare two bundle size snapshots',
393
+ builder: (cmdYargs) => {
394
+ return cmdYargs
395
+ .option('base', {
396
+ describe: 'Base snapshot URL (file:, http:, or https: scheme)',
397
+ type: 'string',
398
+ demandOption: true,
399
+ })
400
+ .option('head', {
401
+ describe:
402
+ 'Head snapshot URL (file:, http:, or https: scheme), defaults to file:./size-snapshot.json',
403
+ type: 'string',
404
+ default: 'file:./size-snapshot.json',
405
+ })
406
+ .option('output', {
407
+ alias: 'o',
408
+ describe: 'Output format (json or markdown)',
409
+ type: 'string',
410
+ choices: ['json', 'markdown'],
411
+ default: 'json',
412
+ })
413
+ .option('reportUrl', {
414
+ describe: 'URL to the detailed report (optional)',
415
+ type: 'string',
416
+ });
417
+ },
418
+ handler: diffHandler,
419
+ })
420
+ // @ts-expect-error
421
+ .command({
422
+ command: 'pr <prNumber>',
423
+ describe: 'Generate a bundle size report for a GitHub pull request',
424
+ builder: (cmdYargs) => {
425
+ return cmdYargs
426
+ .positional('prNumber', {
427
+ describe: 'GitHub pull request number',
428
+ type: 'number',
429
+ demandOption: true,
430
+ })
431
+ .option('output', {
432
+ alias: 'o',
433
+ describe: 'Output format (json or markdown)',
434
+ type: 'string',
435
+ choices: ['json', 'markdown'],
436
+ default: 'markdown', // Default to markdown for PR reports
437
+ })
438
+ .option('circleci', {
439
+ describe: 'CircleCI build number for the report URL (optional)',
440
+ type: 'string',
441
+ });
442
+ },
443
+ handler: prHandler,
444
+ })
445
+ .help()
446
+ .strict(true)
447
+ .version(false)
448
+ .parse();