diff --git a/.gitignore b/.gitignore index 6d8b537589..89315c8e80 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ __build__ .env.test .tmp/ .pnpm-store/ +.transpile-diff/ tsconfig.build.tsbuildinfo tsconfig.node.build.tsbuildinfo diff --git a/docs/contributor-docs/transpile-diff.md b/docs/contributor-docs/transpile-diff.md new file mode 100644 index 0000000000..bd735c6521 --- /dev/null +++ b/docs/contributor-docs/transpile-diff.md @@ -0,0 +1,66 @@ +--- +title: Comparing Transpiled Output +category: Contributor Guides +--- + +# Comparing Transpiled Output + +When migrating build tooling — Babel → SWC, `tsc` → `tsgo`, a Babel preset +bump, a TypeScript upgrade — you want to know whether the change actually +altered the emitted code. `transpile-diff` builds two git refs in isolation +and diffs their `es/`, `lib/` and `types/` output. + +```bash +pnpm run transpile-diff [refA] [refB] +``` + +- `refA` defaults to `master`, `refB` to the current branch (`HEAD`). +- **Any git ref works** — a branch, a tag (`v9.0.0`), or a commit SHA. + +```bash +pnpm run transpile-diff # current branch vs master +pnpm run transpile-diff master my-swc-branch # two branches +pnpm run transpile-diff v9.0.0 HEAD # a release vs now +``` + +## How it works + +For each ref the command: + +1. checks it out into a temporary git worktree under `.transpile-diff/`, +2. runs `pnpm install --frozen-lockfile` then `pnpm run bootstrap` (a clean, + full build — so `types/` declarations are included), +3. snapshots the built output, passing each file through a normalization step + that strips the license banner, sourcemap comments and whitespace noise. + +It then diffs the two snapshots, prints a `changed / added / removed` summary, +writes an HTML report to `.transpile-diff/report--.html` (opened +automatically), and exits non-zero if anything differs. + +Snapshots are cached by commit SHA, so re-running against an unchanged ref +(e.g. `master`) skips its rebuild. + +## Options + +| Option | Default | Description | +| ------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--modules` | `es,lib,types` | Which output dirs to compare. | +| `--semantic` | `false` | Reprint each file with the repo's `dprint` config before diffing, so pure-formatting differences are ignored. **Recommended for Babel→SWC / tsc→tsgo**, where output is never byte-identical but should be semantically equivalent. | +| `--raw` | `false` | Skip normalization entirely — compare raw bytes (banner, whitespace included). | +| `--no-frozen` | _(off)_ | Allow lockfile changes when installing. Useful for old refs whose lockfile no longer resolves under the current toolchain. | +| `--no-open` | _(off)_ | Don't open the HTML report automatically. | + +### Which mode to use + +- **Same compiler, config bump** (e.g. a TypeScript version upgrade): the default normalization is enough — output formatting is stable, so any diff is a real change. +- **Different compiler** (Babel→SWC, tsc→tsgo): add `--semantic`. Babel and SWC will never produce byte-identical output, but after reprinting through `dprint` the diff reduces to genuine code differences. + +`--semantic` uses `dprint`, which is already a ui-scripts dependency — it adds no new packages. + +## Notes + +- A full `bootstrap` runs per ref, so a cold run takes several minutes — this + is meant to be run occasionally, not in CI. +- Comparing very old commits can fail at the **build** step (lockfile or Node + version drift), not the diff step. The command aborts with a clear message + identifying which ref failed to build rather than reporting a misleading diff. diff --git a/package.json b/package.json index 28168475db..e519c6372b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ ], "scripts": { "build:themes": "ui-scripts build-themes", + "transpile-diff": "ui-scripts transpile-diff", "prestart": "pnpm run bootstrap", "start": "pnpm --filter docs-app start", "start:watch": "pnpm --filter docs-app start:watch", diff --git a/packages/ui-scripts/lib/commands/index.js b/packages/ui-scripts/lib/commands/index.js index 03793cfb69..3ebf153446 100644 --- a/packages/ui-scripts/lib/commands/index.js +++ b/packages/ui-scripts/lib/commands/index.js @@ -29,6 +29,7 @@ import deprecate from './deprecate.js' import publish from './publish.js' import publishPrivate from './publish-private.js' import visualDiff from './visual-diff.ts' +import transpileDiff from './transpile-diff.ts' import lint from '../test/lint.js' import bundle from '../build/webpack.js' import clean from '../build/clean.js' @@ -46,6 +47,7 @@ export const yargCommands = [ publish, publishPrivate, visualDiff, + transpileDiff, lint, bundle, clean, diff --git a/packages/ui-scripts/lib/commands/transpile-diff.ts b/packages/ui-scripts/lib/commands/transpile-diff.ts new file mode 100644 index 0000000000..c64520821c --- /dev/null +++ b/packages/ui-scripts/lib/commands/transpile-diff.ts @@ -0,0 +1,469 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * 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 + * SOFTWARE. + */ + +import { execFileSync, execSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { dirname, join, relative } from 'node:path' + +type Status = 'changed' | 'added' | 'removed' +type ModuleDir = 'es' | 'lib' | 'types' + +type Snapshot = { + ref: string + sha: string + /** normalized output tree, mirrors packages///... */ + dir: string + fileCount: number + cached: boolean +} + +// --------------------------------------------------------------------------- +// git helpers +// --------------------------------------------------------------------------- + +function git(args: string[], cwd: string): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() +} + +/** Run `git diff --no-index`, which exits 1 when files differ — capture stdout anyway. */ +function gitDiff(args: string[], cwd: string): string { + try { + return execFileSync('git', ['diff', '--no-index', ...args], { + cwd, + encoding: 'utf8', + maxBuffer: 1024 * 1024 * 512 + }) + } catch (err: any) { + // exit code 1 simply means "differences found" + if (err && typeof err.stdout === 'string') return err.stdout + throw err + } +} + +// --------------------------------------------------------------------------- +// normalization — the pluggable noise-reduction layer +// +// Each normalizer is a pure (content) => content. They strip everything that +// changes between builds without reflecting a real transpiler difference. +// To add AST-level normalization later (e.g. reprint through dprint so pure +// formatting collapses), append a normalizer here — nothing else changes. +// --------------------------------------------------------------------------- + +const LICENSE_BANNER = + /^\/\*[\s\S]*?Permission is hereby granted[\s\S]*?\*\/\n?/ + +const normalizers: Array<(content: string) => string> = [ + // drop the fixed MIT license banner that prefixes every emitted file + (c) => c.replace(LICENSE_BANNER, ''), + // sourcemap references change every build and are not semantic + (c) => c.replace(/^\/\/# sourceMappingURL=.*$/gm, ''), + (c) => c.replace(/\/\*# sourceMappingURL=.*?\*\//g, ''), + // normalize line endings + trailing whitespace + (c) => c.replace(/\r\n/g, '\n').replace(/[ \t]+$/gm, ''), + // collapse runs of blank lines and trim file edges + (c) => c.replace(/\n{3,}/g, '\n\n').trim() + '\n' +] + +function normalize(content: string, raw: boolean): string { + if (raw) return content + return normalizers.reduce((acc, fn) => fn(acc), content) +} + +// --------------------------------------------------------------------------- +// snapshotting +// --------------------------------------------------------------------------- + +function walk(dir: string): string[] { + if (!existsSync(dir)) return [] + const out: string[] = [] + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + if (statSync(full).isDirectory()) out.push(...walk(full)) + else out.push(full) + } + return out +} + +/** Copy every package's built output through the normalizers into `destRoot`. */ +function collect( + worktree: string, + destRoot: string, + modules: ModuleDir[], + raw: boolean +): number { + const packagesDir = join(worktree, 'packages') + let count = 0 + for (const pkg of readdirSync(packagesDir)) { + for (const mod of modules) { + const srcDir = join(packagesDir, pkg, mod) + for (const file of walk(srcDir)) { + if (file.endsWith('.map')) continue // sourcemaps are not semantic + const rel = relative(worktree, file) + const dest = join(destRoot, rel) + const isText = /\.(js|jsx|ts|tsx|mjs|cjs|d\.ts)$/.test(file) + const content = readFileSync(file, isText ? 'utf8' : null) + mkdirSync(dirname(dest), { recursive: true }) + writeFileSync( + dest, + isText ? normalize(content as string, raw) : (content as Buffer) + ) + count++ + } + } + } + return count +} + +/** + * Reprint the whole snapshot tree with the repo's dprint config (already a + * ui-scripts dependency — no new dep). This collapses pure-formatting + * differences so that swapping transpilers (Babel → SWC) or compilers + * (tsc → tsgo) shows only *real* code changes, not whitespace/quote churn. + * + * IMPORTANT: dprint resolves its file globs relative to `cwd`, NOT to the + * config location — so we always run it *inside* the snapshot dir. Running it + * from the repo root would reformat the actual source tree. + */ +function dprintFormat(dir: string, repoRoot: string): void { + try { + execFileSync( + 'npx', + [ + 'dprint', + 'fmt', + '--config', + join(repoRoot, 'dprint.json'), + '--allow-no-files', + '**/*.js', + '**/*.jsx', + '**/*.ts', + '**/*.tsx', + '**/*.mjs', + '**/*.cjs' + ], + { cwd: dir, stdio: 'ignore' } + ) + } catch { + // a file SWC/tsgo emits may use syntax dprint's parser rejects — don't + // fail the whole run, just leave those files in their regex-normalized form + console.warn( + ' dprint reprint reported issues; some files left un-reprinted' + ) + } +} + +function buildSnapshot( + ref: string, + repoRoot: string, + workRoot: string, + modules: ModuleDir[], + raw: boolean, + semantic: boolean, + frozen: boolean +): Snapshot { + const sha = git(['rev-parse', ref], repoRoot) + const mode = raw ? 'raw' : semantic ? 'semantic' : 'norm' + const cacheKey = `${sha}-${mode}-${modules.join('+')}` + const cacheDir = join(workRoot, 'cache', cacheKey) + + // SHA-keyed cache: an unchanged ref (e.g. a tag, or master) is reused. + if (existsSync(cacheDir) && walk(cacheDir).length > 0) { + console.info(`✓ reusing cached snapshot for ${ref} (${sha.slice(0, 9)})`) + return { + ref, + sha, + dir: cacheDir, + fileCount: walk(cacheDir).length, + cached: true + } + } + + const worktree = join(workRoot, 'worktrees', sha) + rmSync(worktree, { recursive: true, force: true }) + + console.info(`\n▸ ${ref} (${sha.slice(0, 9)}) — creating worktree`) + git(['worktree', 'add', '--force', '--detach', worktree, sha], repoRoot) + + try { + const opts = { cwd: worktree, stdio: 'inherit' as const } + + console.info(`▸ ${ref} — installing dependencies`) + try { + execSync('pnpm install --frozen-lockfile', opts) + } catch (err) { + if (frozen) throw err + console.warn( + ' frozen install failed, retrying without --frozen-lockfile' + ) + execSync('pnpm install', opts) + } + + console.info(`▸ ${ref} — running bootstrap (clean + full build)`) + try { + execSync('pnpm run bootstrap', opts) + } catch { + throw new Error( + `ref "${ref}" (${sha.slice( + 0, + 9 + )}) failed to build — "pnpm run bootstrap" exited non-zero (see output above).\n` + + ` This is the ref's own build, not transpile-diff. Common causes: the ref does not type-check under the\n` + + ` current toolchain (bootstrap runs a strict "build:types"), or its lockfile/Node version has drifted.` + ) + } + + rmSync(cacheDir, { recursive: true, force: true }) + const fileCount = collect(worktree, cacheDir, modules, raw) + if (fileCount === 0) { + throw new Error( + `ref "${ref}" produced no output in ${modules.join( + '/' + )} — its build likely failed` + ) + } + if (semantic && !raw) { + console.info(`▸ ${ref} — reprinting with dprint (semantic compare)`) + dprintFormat(cacheDir, repoRoot) + } + console.info(`✓ ${ref} — captured ${fileCount} files`) + return { ref, sha, dir: cacheDir, fileCount, cached: false } + } finally { + git(['worktree', 'remove', '--force', worktree], repoRoot) + } +} + +// --------------------------------------------------------------------------- +// reporting +// --------------------------------------------------------------------------- + +function bucketStatus( + repoRoot: string, + a: Snapshot, + b: Snapshot +): Record { + const out: Record = { changed: 0, added: 0, removed: 0 } + const nameStatus = gitDiff(['--name-status', a.dir, b.dir], repoRoot) + for (const line of nameStatus.split('\n')) { + const code = line.trim()[0] + if (code === 'M') out.changed++ + else if (code === 'A') out.added++ + else if (code === 'D') out.removed++ + } + return out +} + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>') +} + +function renderHtml(a: Snapshot, b: Snapshot, diff: string): string { + const files = diff + .split(/^diff --git /m) + .slice(1) + .map((chunk) => { + const header = chunk.split('\n', 1)[0] + const body = chunk + .split('\n') + .map((l) => { + const cls = + l.startsWith('+') && !l.startsWith('+++') + ? 'add' + : l.startsWith('-') && !l.startsWith('---') + ? 'del' + : l.startsWith('@@') + ? 'hunk' + : '' + return `${esc(l)}` + }) + .join('\n') + return `
${esc( + header + )}
${body}
` + }) + .join('\n') + + return ` +Transpile diff + + +
+

Transpile diff

+
${esc(a.ref)} (${a.sha.slice(0, 9)}, ${ + a.fileCount + } files) → ${esc(b.ref)} (${b.sha.slice(0, 9)}, ${b.fileCount} files)
+
+
${ + files || '
No differences — output is identical.
' + }
+` +} + +// --------------------------------------------------------------------------- +// command +// --------------------------------------------------------------------------- + +export default { + command: 'transpile-diff [refA] [refB]', + desc: 'Compare the transpiled output (es/lib/types) of two git refs', + builder: (yargs: any) => { + yargs.positional('refA', { + describe: 'baseline ref (branch, tag or commit)', + default: 'master' + }) + yargs.positional('refB', { + describe: 'ref to compare against the baseline', + default: 'HEAD' + }) + yargs.option('modules', { + string: true, + desc: 'Which output dirs to compare', + default: 'es,lib,types', + coerce: (v: string) => v.split(',') as ModuleDir[] + }) + yargs.option('raw', { + boolean: true, + default: false, + desc: 'Skip normalization (compare raw bytes incl. banner/whitespace)' + }) + yargs.option('semantic', { + boolean: true, + default: false, + desc: 'Reprint with dprint before diffing so formatting differs are ignored (recommended for Babel→SWC / tsc→tsgo)' + }) + yargs.option('no-frozen', { + boolean: true, + desc: 'Allow lockfile changes when installing (useful for old refs)' + }) + yargs.option('open', { + boolean: true, + default: true, + desc: 'Open the HTML report when done' + }) + yargs.strictOptions(true) + }, + handler: async (argv: any) => { + const repoRoot = git(['rev-parse', '--show-toplevel'], process.cwd()) + const workRoot = join(repoRoot, '.transpile-diff') + const modules: ModuleDir[] = argv.modules + const raw: boolean = argv.raw + const semantic: boolean = argv.semantic + const frozen: boolean = argv.frozen !== false + + if (argv.refA === argv.refB) { + console.error(`Nothing to compare: refA and refB are both "${argv.refA}"`) + process.exit(1) + } + + mkdirSync(workRoot, { recursive: true }) + + let a: Snapshot + let b: Snapshot + try { + a = buildSnapshot( + argv.refA, + repoRoot, + workRoot, + modules, + raw, + semantic, + frozen + ) + b = buildSnapshot( + argv.refB, + repoRoot, + workRoot, + modules, + raw, + semantic, + frozen + ) + } catch (err: any) { + console.error(`\n✗ ${err && err.message ? err.message : String(err)}`) + process.exit(1) + } + + const counts = bucketStatus(repoRoot, a, b) + const total = counts.changed + counts.added + counts.removed + + console.info('\n──────────────────────────────────────────') + console.info(` ${a.ref} → ${b.ref}`) + console.info( + ` changed: ${counts.changed} added: ${counts.added} removed: ${counts.removed}` + ) + console.info('──────────────────────────────────────────\n') + + // git prints the absolute snapshot paths (a//packages/...); strip + // the cache-dir prefixes so the report shows clean packages/... paths. + const fullDiff = gitDiff([a.dir, b.dir], repoRoot) + .split(`${a.dir.replace(/^\//, '')}/`) + .join('') + .split(`${b.dir.replace(/^\//, '')}/`) + .join('') + const reportPath = join( + workRoot, + `report-${a.sha.slice(0, 9)}-${b.sha.slice(0, 9)}.html` + ) + writeFileSync(reportPath, renderHtml(a, b, fullDiff)) + console.info(`Report: ${reportPath}`) + + if (total > 0 && argv.open) { + try { + const opener = + process.platform === 'darwin' + ? 'open' + : process.platform === 'win32' + ? 'start' + : 'xdg-open' + execFileSync(opener, [reportPath], { stdio: 'ignore' }) + } catch { + /* opening is best-effort */ + } + } + + // non-zero exit when anything differs, so it is usable in scripts + process.exit(total > 0 ? 1 : 0) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8429b3949..5c158f8abb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3703,7 +3703,7 @@ importers: version: link:../command-utils '@instructure/instructure-design-tokens': specifier: github:instructure/instructure-design-tokens - version: https://codeload.github.com/instructure/instructure-design-tokens/tar.gz/9916a91be6b27a8c2d15136d2865e6f69a2d1c08 + version: https://codeload.github.com/instructure/instructure-design-tokens/tar.gz/6085eab6b704bbcaf27a1bf6626ae8d092fce4df '@instructure/pkg-utils': specifier: workspace:* version: link:../pkg-utils @@ -6711,8 +6711,8 @@ packages: '@types/node': optional: true - '@instructure/instructure-design-tokens@https://codeload.github.com/instructure/instructure-design-tokens/tar.gz/9916a91be6b27a8c2d15136d2865e6f69a2d1c08': - resolution: {tarball: https://codeload.github.com/instructure/instructure-design-tokens/tar.gz/9916a91be6b27a8c2d15136d2865e6f69a2d1c08} + '@instructure/instructure-design-tokens@https://codeload.github.com/instructure/instructure-design-tokens/tar.gz/6085eab6b704bbcaf27a1bf6626ae8d092fce4df': + resolution: {tarball: https://codeload.github.com/instructure/instructure-design-tokens/tar.gz/6085eab6b704bbcaf27a1bf6626ae8d092fce4df} version: 1.0.0 '@isaacs/cliui@8.0.2': @@ -9205,8 +9205,8 @@ packages: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} - enhanced-resolve@5.22.0: - resolution: {integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==} + enhanced-resolve@5.22.1: + resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==} engines: {node: '>=10.13.0'} enquirer@2.3.6: @@ -12922,8 +12922,8 @@ packages: uglify-js: optional: true - terser-webpack-plugin@5.6.0: - resolution: {integrity: sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==} + terser-webpack-plugin@5.6.1: + resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==} engines: {node: '>= 10.13.0'} peerDependencies: '@minify-html/node': '*' @@ -15459,7 +15459,7 @@ snapshots: optionalDependencies: '@types/node': 22.19.15 - '@instructure/instructure-design-tokens@https://codeload.github.com/instructure/instructure-design-tokens/tar.gz/9916a91be6b27a8c2d15136d2865e6f69a2d1c08': + '@instructure/instructure-design-tokens@https://codeload.github.com/instructure/instructure-design-tokens/tar.gz/6085eab6b704bbcaf27a1bf6626ae8d092fce4df': dependencies: glob: 13.0.6 @@ -18328,7 +18328,7 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 - enhanced-resolve@5.22.0: + enhanced-resolve@5.22.1: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -22912,7 +22912,7 @@ snapshots: optionalDependencies: esbuild: 0.28.0 - terser-webpack-plugin@5.6.0(esbuild@0.28.0)(webpack@5.107.2(esbuild@0.28.0)): + terser-webpack-plugin@5.6.1(esbuild@0.28.0)(webpack@5.107.2(esbuild@0.28.0)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 @@ -22922,7 +22922,7 @@ snapshots: optionalDependencies: esbuild: 0.28.0 - terser-webpack-plugin@5.6.0(esbuild@0.28.0)(webpack@5.107.2): + terser-webpack-plugin@5.6.1(esbuild@0.28.0)(webpack@5.107.2): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 @@ -23553,7 +23553,7 @@ snapshots: acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.2 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.22.0 + enhanced-resolve: 5.22.1 es-module-lexer: 2.1.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -23564,7 +23564,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 - terser-webpack-plugin: 5.6.0(esbuild@0.28.0)(webpack@5.107.2(esbuild@0.28.0)) + terser-webpack-plugin: 5.6.1(esbuild@0.28.0)(webpack@5.107.2(esbuild@0.28.0)) watchpack: 2.5.1 webpack-sources: 3.5.0 transitivePeerDependencies: @@ -23592,7 +23592,7 @@ snapshots: acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.2 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.22.0 + enhanced-resolve: 5.22.1 es-module-lexer: 2.1.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -23603,7 +23603,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 - terser-webpack-plugin: 5.6.0(esbuild@0.28.0)(webpack@5.107.2) + terser-webpack-plugin: 5.6.1(esbuild@0.28.0)(webpack@5.107.2) watchpack: 2.5.1 webpack-sources: 3.5.0 optionalDependencies: