From e718cdcd42f6cb0049a970f4b48dff37955c33eb Mon Sep 17 00:00:00 2001 From: Arno V Date: Fri, 30 Jan 2026 19:45:12 -0500 Subject: [PATCH 01/10] feat: add @node-cli/bundlecheck package Introduce a new CLI package to analyze npm package bundle sizes. Adds package metadata and README, TypeScript sources (CLI entry, bundler, parser, defaults), and build/test configs (.swcrc, tsconfig, vitest.config.ts). The tool uses esbuild to produce minified bundles and reports raw and gzip sizes, with support for selecting exports and marking externals. --- packages/bundlecheck/.swcrc | 14 + packages/bundlecheck/README.md | 154 ++++ packages/bundlecheck/package.json | 50 + .../bundlecheck/src/__tests__/bundler.test.ts | 852 ++++++++++++++++++ .../bundlecheck/src/__tests__/trend.test.ts | 347 +++++++ .../src/__tests__/versions.test.ts | 285 ++++++ packages/bundlecheck/src/bundlecheck.ts | 189 ++++ packages/bundlecheck/src/bundler.ts | 598 ++++++++++++ packages/bundlecheck/src/defaults.ts | 13 + packages/bundlecheck/src/parse.ts | 147 +++ packages/bundlecheck/src/trend.ts | 182 ++++ packages/bundlecheck/src/versions.ts | 89 ++ packages/bundlecheck/tsconfig.json | 10 + packages/bundlecheck/vitest.config.ts | 32 + pnpm-lock.yaml | 331 ++++--- 15 files changed, 3152 insertions(+), 141 deletions(-) create mode 100644 packages/bundlecheck/.swcrc create mode 100644 packages/bundlecheck/README.md create mode 100644 packages/bundlecheck/package.json create mode 100644 packages/bundlecheck/src/__tests__/bundler.test.ts create mode 100644 packages/bundlecheck/src/__tests__/trend.test.ts create mode 100644 packages/bundlecheck/src/__tests__/versions.test.ts create mode 100644 packages/bundlecheck/src/bundlecheck.ts create mode 100644 packages/bundlecheck/src/bundler.ts create mode 100644 packages/bundlecheck/src/defaults.ts create mode 100644 packages/bundlecheck/src/parse.ts create mode 100644 packages/bundlecheck/src/trend.ts create mode 100644 packages/bundlecheck/src/versions.ts create mode 100644 packages/bundlecheck/tsconfig.json create mode 100644 packages/bundlecheck/vitest.config.ts diff --git a/packages/bundlecheck/.swcrc b/packages/bundlecheck/.swcrc new file mode 100644 index 00000000..413b6eda --- /dev/null +++ b/packages/bundlecheck/.swcrc @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/swcrc", + "exclude": ["test"], + "jsc": { + "target": "es2022", + "parser": { + "syntax": "typescript" + }, + "externalHelpers": false + }, + "module": { + "type": "es6" + } +} diff --git a/packages/bundlecheck/README.md b/packages/bundlecheck/README.md new file mode 100644 index 00000000..ae68c388 --- /dev/null +++ b/packages/bundlecheck/README.md @@ -0,0 +1,154 @@ +# @node-cli/bundlecheck + +![npm](https://img.shields.io/npm/v/@node-cli/bundlecheck?label=version&logo=npm) + +A CLI tool to check the bundle size of npm packages, similar to [bundlephobia.com](https://bundlephobia.com/). + +## Features + +- Check bundle size of any npm package +- Support for specific package versions (e.g., `lodash@4.17.0`) +- Interactive version selection with `--versions` flag +- Bundle size trend analysis with `--trend` flag (bar graph across versions) +- Support for checking specific exports (tree-shaking) +- Automatic externalization of React and React-DOM +- Raw and gzip sizes with configurable compression level +- Fast bundling using esbuild (with pnpm support) + +## Installation + +```bash +npm install -g @node-cli/bundlecheck +``` + +Or use with npx: + +```bash +npx @node-cli/bundlecheck +``` + +## Usage + +### Check entire package + +```bash +bundlecheck lodash +bundlecheck @mantine/core +``` + +### Check a specific version + +```bash +bundlecheck lodash@4.17.0 +bundlecheck @versini/ui-panel@1.0.0 +``` + +### Choose version interactively + +```bash +bundlecheck lodash --versions +bundlecheck @mantine/core --versions +``` + +This will fetch available versions from npm and let you select one from a list. + +### Show bundle size trend + +```bash +bundlecheck lodash --trend # default: 5 versions +bundlecheck clsx --trend 3 # custom: 3 versions +``` + +This analyzes recent versions and displays a bar graph showing how the bundle size has evolved: + +``` +Bundle Size Trend: clsx +──────────────────────────────────────────────────────────── + +Gzip Size: + 2.1.1 ██████████████████████████████ 331 B + 2.0.0 █████████████████████████████ 321 B + 1.2.0 █████████████████████████████ 318 B + 1.0.4 ████████████████████████████ 308 B + 1.0.1 ██████████████████████████ 285 B + +Raw Size: + 2.1.1 ██████████████████████████████ 527 B + ... + +──────────────────────────────────────────────────────────── +Change from 1.0.1 to 2.1.1: + Gzip: +46 B (+16.1%) + Raw: +108 B (+25.8%) +``` + +### Check specific exports (tree-shaking) + +```bash +bundlecheck @mantine/core "ScrollArea,Button" +bundlecheck lodash "debounce,throttle" +``` + +### Options + +| Flag | Short | Description | +| ------------------- | ----------- | ------------------------------------------------------- | +| `--help` | `-h` | Display help instructions | +| `--version` | `-v` | Output the current version | +| `--versions` | `-V` | Choose from available package versions interactively | +| `--trend [N]` | `-t [N]` | Show bundle size trend for N versions (default: 5) | +| `--boring` | `-b` | Do not use color output | +| `--gzipLevel ` | `-g ` | Gzip compression level (1-9, default: 9) | +| `--external ` | `-e ` | Comma-separated additional packages to mark as external | +| `--noExternal` | `-n` | Do not mark any packages as external | + +### Examples + +```bash +# Check the entire lodash package +bundlecheck lodash + +# Check a specific version +bundlecheck lodash@4.17.0 +bundlecheck @versini/ui-panel@1.0.0 + +# Check specific exports from @mantine/core +bundlecheck @mantine/core "ScrollArea,Button" + +# Check react itself (without marking it as external) +bundlecheck react -n + +# Add vue and svelte as additional externals +bundlecheck some-package --external "vue,svelte" + +# Use a different gzip compression level +bundlecheck lodash --gzipLevel 6 + +# Choose version interactively +bundlecheck lodash --versions + +# Show bundle size trend (default: 5 versions) +bundlecheck lodash --trend + +# Show bundle size trend for 3 versions +bundlecheck lodash --trend 3 +``` + +## How It Works + +1. Creates a temporary directory +2. Installs the specified npm package +3. Creates an entry file importing the package/exports +4. Bundles with esbuild (minified, tree-shaken) +5. Reports raw and gzip sizes +6. Cleans up temporary files + +## Default Externals + +By default, `react` and `react-dom` are marked as external (not included in the bundle size) since most React-based packages expect these as peer dependencies. This matches how these packages would typically be used in a real application. + +To include React/React-DOM in the bundle size calculation, use the `--no-external` flag. + +## License + +MIT - see [LICENSE](./LICENSE) for details. diff --git a/packages/bundlecheck/package.json b/packages/bundlecheck/package.json new file mode 100644 index 00000000..897816b4 --- /dev/null +++ b/packages/bundlecheck/package.json @@ -0,0 +1,50 @@ +{ + "name": "@node-cli/bundlecheck", + "version": "1.0.0", + "license": "MIT", + "author": "Arno Versini", + "description": "CLI tool to check the bundle size of npm packages (like bundlephobia)", + "type": "module", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/bundler.js" + } + }, + "bin": "dist/bundlecheck.js", + "files": [ + "dist", + "README.md" + ], + "node": ">=18", + "scripts": { + "build": "npm-run-all --serial clean build:types build:js build:barrel", + "build:barrel": "barrelsby --delete --directory dist --pattern \"**/*.d.ts\" --name \"index.d\"", + "build:js": "swc --strip-leading-paths --source-maps --out-dir dist src", + "build:types": "tsc", + "clean": "rimraf dist types coverage", + "comments:fix": "comments --merge-line-comments 'src/**/*.ts'", + "lint": "biome lint src", + "lint:fix": "biome check src --write --no-errors-on-unmatched", + "test": "vitest run --globals", + "test:coverage": "vitest run --coverage --globals", + "test:watch": "vitest --globals", + "watch": "swc --strip-leading-paths --watch --out-dir dist src" + }, + "dependencies": { + "@inquirer/select": "5.0.4", + "@node-cli/logger": "workspace:*", + "@node-cli/parser": "workspace:*", + "esbuild": "0.27.2", + "kleur": "4.1.5" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@node-cli/comments": "workspace:*", + "@vitest/coverage-v8": "4.0.16", + "vitest": "4.0.16" + } +} diff --git a/packages/bundlecheck/src/__tests__/bundler.test.ts b/packages/bundlecheck/src/__tests__/bundler.test.ts new file mode 100644 index 00000000..929f075a --- /dev/null +++ b/packages/bundlecheck/src/__tests__/bundler.test.ts @@ -0,0 +1,852 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import * as esbuild from "esbuild"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + checkBundleSize, + formatBytes, + parsePackageSpecifier, +} from "../bundler.js"; + +// Mock modules +vi.mock("node:child_process"); +vi.mock("node:fs"); +vi.mock("node:os"); +vi.mock("esbuild"); + +describe("parsePackageSpecifier", () => { + describe("scoped packages", () => { + it("should parse scoped package without version", () => { + const result = parsePackageSpecifier("@scope/package"); + expect(result).toEqual({ + name: "@scope/package", + version: "latest", + }); + }); + + it("should parse scoped package with version", () => { + const result = parsePackageSpecifier("@scope/package@1.0.0"); + expect(result).toEqual({ + name: "@scope/package", + version: "1.0.0", + }); + }); + + it("should parse scoped package with subpath", () => { + const result = parsePackageSpecifier("@scope/package/subpath"); + expect(result).toEqual({ + name: "@scope/package", + version: "latest", + subpath: "subpath", + }); + }); + + it("should parse scoped package with subpath and version", () => { + const result = parsePackageSpecifier("@scope/package/subpath@2.0.0"); + expect(result).toEqual({ + name: "@scope/package", + version: "2.0.0", + subpath: "subpath", + }); + }); + + it("should parse scoped package with nested subpath", () => { + const result = parsePackageSpecifier("@scope/package/sub/path/deep"); + expect(result).toEqual({ + name: "@scope/package", + version: "latest", + subpath: "sub/path/deep", + }); + }); + + it("should parse scoped package with nested subpath and version", () => { + const result = parsePackageSpecifier("@scope/package/sub/path@3.0.0"); + expect(result).toEqual({ + name: "@scope/package", + version: "3.0.0", + subpath: "sub/path", + }); + }); + }); + + describe("non-scoped packages", () => { + it("should parse simple package without version", () => { + const result = parsePackageSpecifier("lodash"); + expect(result).toEqual({ + name: "lodash", + version: "latest", + }); + }); + + it("should parse simple package with version", () => { + const result = parsePackageSpecifier("lodash@4.17.21"); + expect(result).toEqual({ + name: "lodash", + version: "4.17.21", + }); + }); + + it("should parse package with subpath", () => { + const result = parsePackageSpecifier("lodash/fp"); + expect(result).toEqual({ + name: "lodash", + version: "latest", + subpath: "fp", + }); + }); + + it("should parse package with subpath and version", () => { + const result = parsePackageSpecifier("lodash/fp@4.17.21"); + expect(result).toEqual({ + name: "lodash", + version: "4.17.21", + subpath: "fp", + }); + }); + }); +}); + +describe("formatBytes", () => { + it("should return '0 B' for 0 bytes", () => { + expect(formatBytes(0)).toBe("0 B"); + }); + + it("should format bytes correctly", () => { + expect(formatBytes(500)).toBe("500 B"); + expect(formatBytes(1023)).toBe("1023 B"); + }); + + it("should format kilobytes correctly", () => { + expect(formatBytes(1024)).toBe("1 kB"); + expect(formatBytes(1536)).toBe("1.5 kB"); + expect(formatBytes(10240)).toBe("10 kB"); + }); + + it("should format megabytes correctly", () => { + expect(formatBytes(1048576)).toBe("1 MB"); + expect(formatBytes(1572864)).toBe("1.5 MB"); + }); + + it("should format gigabytes correctly", () => { + expect(formatBytes(1073741824)).toBe("1 GB"); + }); +}); + +describe("checkBundleSize", () => { + const mockPackageJson = { + name: "test-package", + version: "1.0.0", + main: "./dist/index.js", + dependencies: { clsx: "^2.0.0" }, + peerDependencies: {}, + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock os.tmpdir + vi.mocked(os.tmpdir).mockReturnValue("/tmp"); + + // Mock fs functions + vi.mocked(fs.mkdirSync).mockReturnValue(undefined); + vi.mocked(fs.writeFileSync).mockReturnValue(undefined); + vi.mocked(fs.rmSync).mockReturnValue(undefined); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockPackageJson)); + + // Mock execSync - first call checks pnpm, subsequent calls install packages + vi.mocked(execSync).mockImplementation((cmd: string) => { + if (cmd === "pnpm --version") { + return Buffer.from("8.0.0"); + } + return Buffer.from(""); + }); + + // Mock esbuild + vi.mocked(esbuild.build).mockResolvedValue({ + outputFiles: [ + { + contents: new Uint8Array(Buffer.from("minified code")), + path: "entry.js", + text: "minified code", + hash: "abc123", + }, + ], + errors: [], + warnings: [], + metafile: { inputs: {}, outputs: {} }, + mangleCache: {}, + }); + + // Ensure Date.now returns consistent value for temp dir + vi.spyOn(Date, "now").mockReturnValue(12345); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should analyze bundle size for a simple package", async () => { + const result = await checkBundleSize({ + packageName: "test-package", + }); + + expect(result).toMatchObject({ + packageName: "test-package", + packageVersion: "1.0.0", + exports: [], + }); + expect(result.rawSize).toBeGreaterThan(0); + expect(result.gzipSize).toBeGreaterThan(0); + expect(result.externals).toContain("react"); + expect(result.externals).toContain("react-dom"); + }); + + it("should respect noExternal option", async () => { + const result = await checkBundleSize({ + packageName: "test-package", + noExternal: true, + }); + + expect(result.externals).toEqual([]); + }); + + it("should add additional externals", async () => { + const result = await checkBundleSize({ + packageName: "test-package", + additionalExternals: ["lodash", "dayjs"], + }); + + expect(result.externals).toContain("react"); + expect(result.externals).toContain("react-dom"); + expect(result.externals).toContain("lodash"); + expect(result.externals).toContain("dayjs"); + }); + + it("should exclude react from externals when checking react itself", async () => { + const result = await checkBundleSize({ + packageName: "react", + }); + + expect(result.externals).not.toContain("react"); + expect(result.externals).toContain("react-dom"); + }); + + it("should exclude react-dom from externals when checking react-dom itself", async () => { + const result = await checkBundleSize({ + packageName: "react-dom", + }); + + expect(result.externals).toContain("react"); + expect(result.externals).not.toContain("react-dom"); + }); + + it("should handle specific exports", async () => { + const result = await checkBundleSize({ + packageName: "test-package", + exports: ["Button", "Input"], + }); + + expect(result.exports).toEqual(["Button", "Input"]); + }); + + it("should handle package with version", async () => { + const result = await checkBundleSize({ + packageName: "test-package@2.0.0", + }); + + expect(result.packageName).toBe("test-package"); + }); + + it("should handle peer dependencies", async () => { + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + name: "test-package", + version: "1.0.0", + main: "./dist/index.js", + dependencies: {}, + peerDependencies: { react: "^18.0.0" }, + }), + ); + + const result = await checkBundleSize({ + packageName: "test-package", + }); + + expect(result.dependencies).toContain("react"); + }); + + it("should respect custom gzip level", async () => { + const result = await checkBundleSize({ + packageName: "test-package", + gzipLevel: 1, + }); + + expect(result.gzipLevel).toBe(1); + }); + + it("should use default gzip level when not specified", async () => { + const result = await checkBundleSize({ + packageName: "test-package", + }); + + expect(result.gzipLevel).toBe(9); + }); + + it("should handle package with subpath", async () => { + const result = await checkBundleSize({ + packageName: "@scope/package/subpath", + }); + + expect(result.packageName).toBe("@scope/package/subpath"); + }); + + it("should cleanup temp directory on success", async () => { + await checkBundleSize({ packageName: "test-package" }); + + expect(fs.rmSync).toHaveBeenCalledWith( + expect.stringContaining("bundlecheck-"), + expect.objectContaining({ recursive: true, force: true }), + ); + }); + + it("should cleanup temp directory on error", async () => { + vi.mocked(esbuild.build).mockRejectedValue(new Error("Build failed")); + + await expect( + checkBundleSize({ packageName: "test-package" }), + ).rejects.toThrow("Build failed"); + + expect(fs.rmSync).toHaveBeenCalled(); + }); + + it("should fallback to npm when pnpm is not available", async () => { + // Reset the module-level usePnpm cache by clearing all mocks first + vi.resetModules(); + + // Re-import after resetting modules + const { checkBundleSize: freshCheckBundleSize } = await import( + "../bundler.js" + ); + + // Mock execSync to throw for pnpm --version + vi.mocked(execSync).mockImplementation((cmd: string) => { + if (cmd === "pnpm --version") { + throw new Error("pnpm not found"); + } + return Buffer.from(""); + }); + + await freshCheckBundleSize({ packageName: "test-package" }); + + // Should have called npm install + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining("npm install"), + expect.any(Object), + ); + }); + + it("should handle package info read failure gracefully", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = await checkBundleSize({ + packageName: "test-package", + }); + + expect(result.packageVersion).toBe("unknown"); + expect(result.dependencies).toEqual([]); + }); + + it("should handle subpath exports for packages without main entry", async () => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return JSON.stringify({ + name: "@scope/pkg", + version: "1.0.0", + exports: { + "./header": { + types: "./dist/Header/index.d.ts", + import: "./dist/Header/index.js", + }, + "./footer": { + types: "./dist/Footer/index.d.ts", + import: "./dist/Footer/index.js", + }, + }, + }); + } + if (pathStr.includes("index.d.ts")) { + return "export declare const Header: any;"; + } + return ""; + }); + + const result = await checkBundleSize({ + packageName: "@scope/pkg", + }); + + // Should bundle all subpaths when no main entry + expect(result).toBeDefined(); + }); + + it("should find single subpath for exports", async () => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return JSON.stringify({ + name: "@scope/pkg", + version: "1.0.0", + exports: { + "./header": { + types: "./dist/Header/index.d.ts", + import: "./dist/Header/index.js", + }, + "./footer": { + types: "./dist/Footer/index.d.ts", + import: "./dist/Footer/index.js", + }, + }, + }); + } + if (pathStr.includes("Header/index.d.ts")) { + return "export declare const HeaderComponent: any;"; + } + if (pathStr.includes("Footer/index.d.ts")) { + return "export declare const FooterComponent: any;"; + } + return ""; + }); + + const result = await checkBundleSize({ + packageName: "@scope/pkg", + exports: ["HeaderComponent"], + }); + + expect(result.packageName).toBe("@scope/pkg/header"); + }); + + it("should find multiple subpaths for exports from different subpaths", async () => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return JSON.stringify({ + name: "@scope/pkg", + version: "1.0.0", + exports: { + "./header": { + types: "./dist/Header/index.d.ts", + import: "./dist/Header/index.js", + }, + "./footer": { + types: "./dist/Footer/index.d.ts", + import: "./dist/Footer/index.js", + }, + }, + }); + } + if (pathStr.includes("Header/index.d.ts")) { + return "export declare const HeaderComponent: any;"; + } + if (pathStr.includes("Footer/index.d.ts")) { + return "export declare const FooterComponent: any;"; + } + return ""; + }); + + const result = await checkBundleSize({ + packageName: "@scope/pkg", + exports: ["HeaderComponent", "FooterComponent"], + }); + + // Should show multiple subpaths in the name + expect(result.packageName).toContain("{"); + expect(result.packageName).toContain("footer"); + expect(result.packageName).toContain("header"); + }); + + it("should handle string export paths in package exports", async () => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return JSON.stringify({ + name: "@scope/pkg", + version: "1.0.0", + exports: { + "./utils": "./dist/utils.js", + }, + }); + } + if (pathStr.includes("utils.js")) { + return "export const helperFunction = () => {};"; + } + return ""; + }); + + const result = await checkBundleSize({ + packageName: "@scope/pkg", + exports: ["helperFunction"], + }); + + expect(result.packageName).toBe("@scope/pkg/utils"); + }); + + it("should handle exports not found in any subpath", async () => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return JSON.stringify({ + name: "@scope/pkg", + version: "1.0.0", + exports: { + "./header": { + types: "./dist/Header/index.d.ts", + import: "./dist/Header/index.js", + }, + }, + }); + } + if (pathStr.includes("index.d.ts")) { + return "export declare const SomeOtherComponent: any;"; + } + return ""; + }); + + const result = await checkBundleSize({ + packageName: "@scope/pkg", + exports: ["NonExistentComponent"], + }); + + // Should fall back to bundling all subpaths + expect(result).toBeDefined(); + }); + + it("should skip export paths with null value", async () => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return JSON.stringify({ + name: "@scope/pkg", + version: "1.0.0", + exports: { + "./header": { + types: "./dist/Header/index.d.ts", + import: "./dist/Header/index.js", + }, + "./internal": null, + }, + }); + } + if (pathStr.includes("index.d.ts")) { + return "export declare const Header: any;"; + } + return ""; + }); + + const result = await checkBundleSize({ + packageName: "@scope/pkg", + }); + + expect(result).toBeDefined(); + }); + + it("should handle subpath keys without ./ prefix", async () => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return JSON.stringify({ + name: "@scope/pkg", + version: "1.0.0", + exports: { + header: { + types: "./dist/Header/index.d.ts", + import: "./dist/Header/index.js", + }, + }, + }); + } + if (pathStr.includes("index.d.ts")) { + return "export declare const Header: any;"; + } + return ""; + }); + + const result = await checkBundleSize({ + packageName: "@scope/pkg", + exports: ["Header"], + }); + + expect(result).toBeDefined(); + }); + + it("should handle export detection for 'export { Name }' pattern", async () => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return JSON.stringify({ + name: "@scope/pkg", + version: "1.0.0", + exports: { + "./utils": { + types: "./dist/utils.d.ts", + import: "./dist/utils.js", + }, + }, + }); + } + if (pathStr.includes("utils.d.ts")) { + return "export { MyFunction, AnotherThing };"; + } + return ""; + }); + + const result = await checkBundleSize({ + packageName: "@scope/pkg", + exports: ["MyFunction"], + }); + + expect(result.packageName).toBe("@scope/pkg/utils"); + }); + + it("should handle export detection for 'export const Name' pattern", async () => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return JSON.stringify({ + name: "@scope/pkg", + version: "1.0.0", + exports: { + "./utils": { + import: "./dist/utils.js", + }, + }, + }); + } + if (pathStr.includes("utils.js")) { + return "export const myConstant = 42;"; + } + return ""; + }); + + const result = await checkBundleSize({ + packageName: "@scope/pkg", + exports: ["myConstant"], + }); + + expect(result.packageName).toBe("@scope/pkg/utils"); + }); + + it("should handle file read errors gracefully in subpath detection", async () => { + let callCount = 0; + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return JSON.stringify({ + name: "@scope/pkg", + version: "1.0.0", + exports: { + "./header": { + types: "./dist/Header/index.d.ts", + }, + "./footer": { + types: "./dist/Footer/index.d.ts", + }, + }, + }); + } + callCount++; + if (callCount === 1) { + throw new Error("Read error"); + } + return "export declare const FooterComp: any;"; + }); + + const result = await checkBundleSize({ + packageName: "@scope/pkg", + exports: ["FooterComp"], + }); + + expect(result).toBeDefined(); + }); + + it("should skip main entry (.) in exports", async () => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return JSON.stringify({ + name: "@scope/pkg", + version: "1.0.0", + exports: { + ".": "./dist/index.js", + "./package.json": "./package.json", + "./utils": "./dist/utils.js", + }, + }); + } + return ""; + }); + + const result = await checkBundleSize({ + packageName: "@scope/pkg", + }); + + expect(result).toBeDefined(); + }); + + it("should handle package.json parse error in getPackageInfo", async () => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return "invalid json {{{"; + } + return ""; + }); + + const result = await checkBundleSize({ + packageName: "test-package", + }); + + expect(result.packageVersion).toBe("unknown"); + }); + + it("should handle file not existing in subpath detection", async () => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return JSON.stringify({ + name: "@scope/pkg", + version: "1.0.0", + exports: { + "./utils": { + types: "./dist/utils.d.ts", + }, + }, + }); + } + return ""; + }); + vi.mocked(fs.existsSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + // Package.json exists, but utils.d.ts doesn't + return pathStr.includes("package.json"); + }); + + const result = await checkBundleSize({ + packageName: "@scope/pkg", + exports: ["SomeExport"], + }); + + expect(result).toBeDefined(); + }); + + it("should handle already found exports when scanning subpaths", async () => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return JSON.stringify({ + name: "@scope/pkg", + version: "1.0.0", + exports: { + "./header": { + types: "./dist/Header/index.d.ts", + }, + "./header2": { + types: "./dist/Header2/index.d.ts", + }, + }, + }); + } + // Both files export the same component (edge case) + return "export declare const SharedComponent: any;"; + }); + + const result = await checkBundleSize({ + packageName: "@scope/pkg", + exports: ["SharedComponent"], + }); + + // Should find it in the first subpath and skip subsequent ones + expect(result.packageName).toBe("@scope/pkg/header"); + }); + + it("should skip ./package.json in exports when finding subpaths", async () => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return JSON.stringify({ + name: "@scope/pkg", + version: "1.0.0", + // No main entry - only subpaths + exports: { + "./package.json": "./package.json", + "./utils": { + types: "./dist/utils.d.ts", + }, + }, + }); + } + if (pathStr.includes("utils.d.ts")) { + return "export declare const UtilFunc: any;"; + } + return ""; + }); + + const result = await checkBundleSize({ + packageName: "@scope/pkg", + exports: ["UtilFunc"], + }); + + expect(result.packageName).toBe("@scope/pkg/utils"); + }); + + it("should handle export with empty object value", async () => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return JSON.stringify({ + name: "@scope/pkg", + version: "1.0.0", + exports: { + "./empty": {}, + "./utils": { + types: "./dist/utils.d.ts", + }, + }, + }); + } + if (pathStr.includes("utils.d.ts")) { + return "export declare const UtilFunc: any;"; + } + return ""; + }); + + const result = await checkBundleSize({ + packageName: "@scope/pkg", + exports: ["UtilFunc"], + }); + + expect(result.packageName).toBe("@scope/pkg/utils"); + }); + + it("should handle null exports field in package", async () => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes("package.json")) { + return JSON.stringify({ + name: "test-pkg", + version: "1.0.0", + main: "./dist/index.js", + exports: null, + }); + } + return ""; + }); + + const result = await checkBundleSize({ + packageName: "test-pkg", + }); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/bundlecheck/src/__tests__/trend.test.ts b/packages/bundlecheck/src/__tests__/trend.test.ts new file mode 100644 index 00000000..4c7bc1bf --- /dev/null +++ b/packages/bundlecheck/src/__tests__/trend.test.ts @@ -0,0 +1,347 @@ +import kleur from "kleur"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as bundler from "../bundler.js"; +import { + analyzeTrend, + renderTrendGraph, + selectTrendVersions, + type TrendResult, +} from "../trend.js"; + +// Mock the bundler module +vi.mock("../bundler.js", async () => { + const actual = await vi.importActual("../bundler.js"); + return { + ...actual, + checkBundleSize: vi.fn(), + }; +}); + +describe("selectTrendVersions", () => { + it("should return first N versions (most recent)", () => { + const versions = ["3.0.0", "2.0.0", "1.0.0", "0.9.0", "0.8.0", "0.7.0"]; + const result = selectTrendVersions(versions, 3); + expect(result).toEqual(["3.0.0", "2.0.0", "1.0.0"]); + }); + + it("should return all versions if count is greater than available", () => { + const versions = ["2.0.0", "1.0.0"]; + const result = selectTrendVersions(versions, 5); + expect(result).toEqual(["2.0.0", "1.0.0"]); + }); + + it("should use default count of 5", () => { + const versions = [ + "6.0.0", + "5.0.0", + "4.0.0", + "3.0.0", + "2.0.0", + "1.0.0", + "0.9.0", + ]; + const result = selectTrendVersions(versions); + expect(result).toEqual(["6.0.0", "5.0.0", "4.0.0", "3.0.0", "2.0.0"]); + }); + + it("should handle empty array", () => { + const result = selectTrendVersions([]); + expect(result).toEqual([]); + }); +}); + +describe("renderTrendGraph", () => { + beforeEach(() => { + kleur.enabled = false; + }); + + it("should return 'No data to display' for empty results", () => { + const result = renderTrendGraph("test-package", []); + expect(result).toEqual(["No data to display"]); + }); + + it("should render graph with single result", () => { + const results: TrendResult[] = [ + { version: "1.0.0", rawSize: 1024, gzipSize: 512 }, + ]; + + const lines = renderTrendGraph("test-package", results, true); + + expect(lines.some((l) => l.includes("test-package"))).toBe(true); + expect(lines.some((l) => l.includes("1.0.0"))).toBe(true); + expect(lines.some((l) => l.includes("Gzip Size:"))).toBe(true); + expect(lines.some((l) => l.includes("Raw Size:"))).toBe(true); + }); + + it("should render graph with multiple results", () => { + const results: TrendResult[] = [ + { version: "2.0.0", rawSize: 2048, gzipSize: 1024 }, + { version: "1.0.0", rawSize: 1024, gzipSize: 512 }, + ]; + + const lines = renderTrendGraph("test-package", results, true); + + expect(lines.some((l) => l.includes("2.0.0"))).toBe(true); + expect(lines.some((l) => l.includes("1.0.0"))).toBe(true); + // Should show change summary + expect(lines.some((l) => l.includes("Change from"))).toBe(true); + }); + + it("should show correct size change percentages", () => { + const results: TrendResult[] = [ + { version: "2.0.0", rawSize: 2048, gzipSize: 1024 }, + { version: "1.0.0", rawSize: 1024, gzipSize: 512 }, + ]; + + const lines = renderTrendGraph("test-package", results, true); + const changeLines = lines.filter((l) => l.includes("%")); + + // 100% increase (doubled) + expect(changeLines.some((l) => l.includes("+100"))).toBe(true); + }); + + it("should handle negative size changes", () => { + const results: TrendResult[] = [ + { version: "2.0.0", rawSize: 512, gzipSize: 256 }, + { version: "1.0.0", rawSize: 1024, gzipSize: 512 }, + ]; + + const lines = renderTrendGraph("test-package", results, true); + const changeLines = lines.filter((l) => l.includes("%")); + + // 50% decrease + expect(changeLines.some((l) => l.includes("-50"))).toBe(true); + }); + + it("should handle identical sizes (no change)", () => { + const results: TrendResult[] = [ + { version: "2.0.0", rawSize: 1024, gzipSize: 512 }, + { version: "1.0.0", rawSize: 1024, gzipSize: 512 }, + ]; + + const lines = renderTrendGraph("test-package", results, true); + + // Should show 0% change + expect(lines.some((l) => l.includes("+0.0%") || l.includes("0.0%"))).toBe( + true, + ); + }); + + it("should include bar characters", () => { + const results: TrendResult[] = [ + { version: "2.0.0", rawSize: 2048, gzipSize: 1024 }, + { version: "1.0.0", rawSize: 1024, gzipSize: 512 }, + ]; + + const lines = renderTrendGraph("test-package", results, true); + + // Should contain bar character + expect(lines.some((l) => l.includes("▇"))).toBe(true); + }); + + it("should render bars with correct relative lengths (min-max scaling)", () => { + const results: TrendResult[] = [ + { version: "3.0.0", rawSize: 3000, gzipSize: 1500 }, + { version: "2.0.0", rawSize: 2000, gzipSize: 1000 }, + { version: "1.0.0", rawSize: 1000, gzipSize: 500 }, + ]; + + const lines = renderTrendGraph("test-package", results, true); + + // Find the lines with version numbers (they contain the bars) + const barLines = lines.filter( + (l) => l.includes("3.0.0") || l.includes("2.0.0") || l.includes("1.0.0"), + ); + + // Largest version should have more bars than smallest + const v3Line = barLines.find((l) => l.includes("3.0.0")); + const v1Line = barLines.find((l) => l.includes("1.0.0")); + + if (v3Line && v1Line) { + const v3Bars = (v3Line.match(/▇/g) || []).length; + const v1Bars = (v1Line.match(/▇/g) || []).length; + expect(v3Bars).toBeGreaterThan(v1Bars); + } + }); + + it("should align version labels correctly", () => { + const results: TrendResult[] = [ + { version: "10.0.0", rawSize: 2048, gzipSize: 1024 }, + { version: "1.0.0", rawSize: 1024, gzipSize: 512 }, + ]; + + const lines = renderTrendGraph("test-package", results, true); + + // Both version lines should exist and have proper formatting + expect(lines.some((l) => l.includes("10.0.0"))).toBe(true); + expect(lines.some((l) => l.includes("1.0.0"))).toBe(true); + }); + + it("should not show change summary for single result", () => { + const results: TrendResult[] = [ + { version: "1.0.0", rawSize: 1024, gzipSize: 512 }, + ]; + + const lines = renderTrendGraph("test-package", results, true); + + expect(lines.some((l) => l.includes("Change from"))).toBe(false); + }); + + it("should respect boring flag for colors", () => { + const results: TrendResult[] = [ + { version: "1.0.0", rawSize: 1024, gzipSize: 512 }, + ]; + + // With boring=true (no colors) + const boringLines = renderTrendGraph("test-package", results, true); + + // With boring=false (with colors) - would include ANSI codes + kleur.enabled = true; + const colorLines = renderTrendGraph("test-package", results, false); + kleur.enabled = false; + + // Color lines should contain escape codes + expect(colorLines.some((l) => l.includes("\x1b["))).toBe(true); + // Boring lines should not contain escape codes + expect(boringLines.some((l) => l.includes("\x1b["))).toBe(false); + }); +}); + +describe("analyzeTrend", () => { + const mockCheckBundleSize = vi.mocked(bundler.checkBundleSize); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should analyze multiple versions", async () => { + mockCheckBundleSize + .mockResolvedValueOnce({ + packageName: "test@2.0.0", + packageVersion: "2.0.0", + exports: [], + rawSize: 2048, + gzipSize: 1024, + gzipLevel: 9, + externals: [], + dependencies: [], + }) + .mockResolvedValueOnce({ + packageName: "test@1.0.0", + packageVersion: "1.0.0", + exports: [], + rawSize: 1024, + gzipSize: 512, + gzipLevel: 9, + externals: [], + dependencies: [], + }); + + const results = await analyzeTrend({ + packageName: "test", + versions: ["2.0.0", "1.0.0"], + boring: true, + }); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ + version: "2.0.0", + rawSize: 2048, + gzipSize: 1024, + }); + expect(results[1]).toEqual({ + version: "1.0.0", + rawSize: 1024, + gzipSize: 512, + }); + }); + + it("should skip versions that fail to analyze", async () => { + mockCheckBundleSize + .mockResolvedValueOnce({ + packageName: "test@2.0.0", + packageVersion: "2.0.0", + exports: [], + rawSize: 2048, + gzipSize: 1024, + gzipLevel: 9, + externals: [], + dependencies: [], + }) + .mockRejectedValueOnce(new Error("Version not found")) + .mockResolvedValueOnce({ + packageName: "test@1.0.0", + packageVersion: "1.0.0", + exports: [], + rawSize: 1024, + gzipSize: 512, + gzipLevel: 9, + externals: [], + dependencies: [], + }); + + const results = await analyzeTrend({ + packageName: "test", + versions: ["2.0.0", "1.5.0", "1.0.0"], + boring: true, + }); + + // Should only have 2 results (skipped 1.5.0) + expect(results).toHaveLength(2); + expect(results.map((r) => r.version)).toEqual(["2.0.0", "1.0.0"]); + }); + + it("should pass through all options to checkBundleSize", async () => { + mockCheckBundleSize.mockResolvedValue({ + packageName: "test@1.0.0", + packageVersion: "1.0.0", + exports: ["Button"], + rawSize: 1024, + gzipSize: 512, + gzipLevel: 5, + externals: ["lodash"], + dependencies: [], + }); + + await analyzeTrend({ + packageName: "test", + versions: ["1.0.0"], + exports: ["Button"], + additionalExternals: ["lodash"], + noExternal: false, + gzipLevel: 5, + boring: true, + }); + + expect(mockCheckBundleSize).toHaveBeenCalledWith({ + packageName: "test@1.0.0", + exports: ["Button"], + additionalExternals: ["lodash"], + noExternal: false, + gzipLevel: 5, + }); + }); + + it("should return empty array when all versions fail", async () => { + mockCheckBundleSize.mockRejectedValue(new Error("All failed")); + + const results = await analyzeTrend({ + packageName: "test", + versions: ["2.0.0", "1.0.0"], + boring: true, + }); + + expect(results).toEqual([]); + }); + + it("should handle empty versions array", async () => { + const results = await analyzeTrend({ + packageName: "test", + versions: [], + boring: true, + }); + + expect(results).toEqual([]); + expect(mockCheckBundleSize).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/bundlecheck/src/__tests__/versions.test.ts b/packages/bundlecheck/src/__tests__/versions.test.ts new file mode 100644 index 00000000..9cb48df2 --- /dev/null +++ b/packages/bundlecheck/src/__tests__/versions.test.ts @@ -0,0 +1,285 @@ +import select from "@inquirer/select"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchPackageVersions, promptForVersion } from "../versions.js"; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +// Mock @inquirer/select +vi.mock("@inquirer/select", () => ({ + default: vi.fn(), +})); + +describe("fetchPackageVersions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should fetch and return versions for a package", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + versions: { + "1.0.0": {}, + "2.0.0": {}, + "3.0.0": {}, + }, + "dist-tags": { + latest: "3.0.0", + next: "3.1.0-beta", + }, + }), + }); + + const result = await fetchPackageVersions("test-package"); + + expect(mockFetch).toHaveBeenCalledWith( + "https://registry.npmjs.org/test-package", + ); + expect(result.versions).toEqual(["3.0.0", "2.0.0", "1.0.0"]); + expect(result.tags).toEqual({ + latest: "3.0.0", + next: "3.1.0-beta", + }); + }); + + it("should handle scoped packages", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + versions: { "1.0.0": {} }, + "dist-tags": { latest: "1.0.0" }, + }), + }); + + await fetchPackageVersions("@scope/package"); + + expect(mockFetch).toHaveBeenCalledWith( + "https://registry.npmjs.org/@scope/package", + ); + }); + + it("should strip version from package specifier", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + versions: { "1.0.0": {} }, + "dist-tags": { latest: "1.0.0" }, + }), + }); + + await fetchPackageVersions("test-package@2.0.0"); + + // Should call with just the package name + expect(mockFetch).toHaveBeenCalledWith( + "https://registry.npmjs.org/test-package", + ); + }); + + it("should throw error when fetch fails", async () => { + mockFetch.mockResolvedValue({ + ok: false, + statusText: "Not Found", + }); + + await expect(fetchPackageVersions("nonexistent-package")).rejects.toThrow( + "Failed to fetch package info: Not Found", + ); + }); + + it("should handle missing versions gracefully", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + "dist-tags": { latest: "1.0.0" }, + }), + }); + + const result = await fetchPackageVersions("test-package"); + + expect(result.versions).toEqual([]); + }); + + it("should handle missing dist-tags gracefully", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + versions: { "1.0.0": {} }, + }), + }); + + const result = await fetchPackageVersions("test-package"); + + expect(result.tags).toEqual({}); + }); +}); + +describe("promptForVersion", () => { + const mockSelect = vi.mocked(select); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should prompt user with version choices", async () => { + mockSelect.mockResolvedValue("2.0.0"); + + const result = await promptForVersion( + "test-package", + ["3.0.0", "2.0.0", "1.0.0"], + { latest: "3.0.0" }, + ); + + expect(mockSelect).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Select a version for test-package:", + pageSize: 15, + }), + ); + expect(result).toBe("2.0.0"); + }); + + it("should highlight tagged versions", async () => { + mockSelect.mockResolvedValue("3.0.0"); + + await promptForVersion("test-package", ["3.0.0", "2.0.0", "1.0.0"], { + latest: "3.0.0", + beta: "2.0.0", + }); + + // Check that choices include tags + expect(mockSelect).toHaveBeenCalledWith( + expect.objectContaining({ + choices: expect.arrayContaining([ + expect.objectContaining({ name: "3.0.0 (latest)", value: "3.0.0" }), + expect.objectContaining({ name: "2.0.0 (beta)", value: "2.0.0" }), + ]), + }), + ); + }); + + it("should prioritize tagged versions in the list", async () => { + mockSelect.mockResolvedValue("3.0.0"); + + await promptForVersion("test-package", ["3.0.0", "2.0.0", "1.0.0"], { + latest: "3.0.0", + }); + + const call = mockSelect.mock.calls[0][0]; + const choices = call.choices; + + // Latest version should be first + expect(choices[0].value).toBe("3.0.0"); + }); + + it("should limit to 20 recent versions", async () => { + mockSelect.mockResolvedValue("1.0.0"); + + const manyVersions = Array.from({ length: 50 }, (_, i) => `${i + 1}.0.0`); + + await promptForVersion("test-package", manyVersions, {}); + + const call = mockSelect.mock.calls[0][0]; + const choices = call.choices; + + // Should have at most ~20 choices (may include tagged versions) + expect(choices.length).toBeLessThanOrEqual(25); + }); + + it("should include all tagged versions even if not in recent 20", async () => { + mockSelect.mockResolvedValue("0.1.0"); + + const versions = Array.from({ length: 50 }, (_, i) => `${50 - i}.0.0`); + const tags = { legacy: "0.1.0" }; // Old version that wouldn't be in recent 20 + + // Add the legacy version to the versions array + versions.push("0.1.0"); + + await promptForVersion("test-package", versions, tags); + + const call = mockSelect.mock.calls[0][0]; + const choices = call.choices; + + // Should include the tagged legacy version + expect(choices.some((c: { value: string }) => c.value === "0.1.0")).toBe( + true, + ); + }); + + it("should handle empty tags", async () => { + mockSelect.mockResolvedValue("1.0.0"); + + await promptForVersion("test-package", ["2.0.0", "1.0.0"], {}); + + const call = mockSelect.mock.calls[0][0]; + const choices = call.choices; + + // All choices should just be version numbers without tags + expect(choices.every((c: { name: string }) => !c.name.includes("("))).toBe( + true, + ); + }); + + it("should handle multiple tags pointing to same version", async () => { + mockSelect.mockResolvedValue("3.0.0"); + + // This is an edge case - same version can only have one tag in display + await promptForVersion("test-package", ["3.0.0", "2.0.0"], { + latest: "3.0.0", + stable: "3.0.0", + }); + + const call = mockSelect.mock.calls[0][0]; + const choices = call.choices; + + // Should still work, version appears once with one of the tags + const v3Choices = choices.filter( + (c: { value: string }) => c.value === "3.0.0", + ); + expect(v3Choices.length).toBe(1); + }); + + it("should sort tagged versions before untagged in all branches", async () => { + mockSelect.mockResolvedValue("2.0.0"); + + // Create a scenario where we test both sort branches: + // - tagged vs untagged + // - untagged vs tagged + await promptForVersion( + "test-package", + ["3.0.0", "2.0.0", "1.0.0", "0.5.0"], + { + latest: "3.0.0", + beta: "0.5.0", + }, + ); + + const call = mockSelect.mock.calls[0][0]; + const choices = call.choices; + + // Tagged versions should come first + const taggedIndices = choices + .map((c: { name: string }, i: number) => (c.name.includes("(") ? i : -1)) + .filter((i: number) => i >= 0); + const untaggedIndices = choices + .map((c: { name: string }, i: number) => (!c.name.includes("(") ? i : -1)) + .filter((i: number) => i >= 0); + + // All tagged versions should have lower indices than untagged + if (taggedIndices.length > 0 && untaggedIndices.length > 0) { + expect(Math.max(...taggedIndices)).toBeLessThan( + Math.min(...untaggedIndices), + ); + } + }); +}); diff --git a/packages/bundlecheck/src/bundlecheck.ts b/packages/bundlecheck/src/bundlecheck.ts new file mode 100644 index 00000000..1d004e33 --- /dev/null +++ b/packages/bundlecheck/src/bundlecheck.ts @@ -0,0 +1,189 @@ +#!/usr/bin/env node + +/* istanbul ignore file */ + +import { Logger } from "@node-cli/logger"; +import kleur from "kleur"; +import { + checkBundleSize, + formatBytes, + parsePackageSpecifier, +} from "./bundler.js"; +import { TREND_VERSION_COUNT } from "./defaults.js"; +import { config } from "./parse.js"; +import { + analyzeTrend, + renderTrendGraph, + selectTrendVersions, +} from "./trend.js"; +import { fetchPackageVersions, promptForVersion } from "./versions.js"; + +const flags = config.flags; +const parameters = config.parameters; + +const log = new Logger({ + boring: flags?.boring, +}); + +async function main() { + let packageName = parameters?.["0"]; + + if (!packageName) { + log.error("Package name is required"); + config.showHelp?.(); + process.exit(1); + } + + // Parse additional externals if provided (comma-separated) + let additionalExternals: string[] | undefined; + if (flags?.external) { + additionalExternals = flags.external + .split(",") + .map((e) => e.trim()) + .filter(Boolean); + } + + // Parse exports if provided (comma-separated) + let exports: string[] | undefined; + const exportsArg = parameters?.["1"]; + if (exportsArg) { + exports = exportsArg + .split(",") + .map((e) => e.trim()) + .filter(Boolean); + } + + // If --trend flag is set, show bundle size trend across versions + // --trend alone uses default (5), --trend N uses N versions + const trendValue = flags?.trend; + if (trendValue !== undefined) { + const parsedCount = Number.parseInt(trendValue, 10); + const versionCount = + !Number.isNaN(parsedCount) && parsedCount > 0 + ? parsedCount + : TREND_VERSION_COUNT; + + try { + const { name } = parsePackageSpecifier(packageName); + log.info(`\nFetching available versions for ${name}...`); + + const { versions } = await fetchPackageVersions(packageName); + + if (versions.length === 0) { + log.error("No versions found for this package"); + process.exit(1); + } + + // Select versions for trend + const trendVersions = selectTrendVersions(versions, versionCount); + + log.info( + `Analyzing ${trendVersions.length} versions: ${trendVersions.join(", ")}`, + ); + log.info(""); + + const results = await analyzeTrend({ + packageName: name, + versions: trendVersions, + exports, + additionalExternals, + noExternal: flags?.noExternal, + gzipLevel: flags?.gzipLevel, + boring: flags?.boring, + }); + + if (results.length === 0) { + log.error("Failed to analyze any versions"); + process.exit(1); + } + + // Render and display the trend graph + const graphLines = renderTrendGraph(name, results, flags?.boring); + for (const line of graphLines) { + log.log(line); + } + + process.exit(0); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + log.error(`Failed to analyze trend: ${errorMessage}`); + process.exit(1); + } + } + + // If --versions flag is set, fetch and prompt for version selection + if (flags?.versions) { + try { + const { name } = parsePackageSpecifier(packageName); + log.info(`\nFetching available versions for ${name}...`); + + const { versions, tags } = await fetchPackageVersions(packageName); + + if (versions.length === 0) { + log.error("No versions found for this package"); + process.exit(1); + } + + const selectedVersion = await promptForVersion(name, versions, tags); + packageName = `${name}@${selectedVersion}`; + log.info(`\nSelected: ${packageName}`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + log.error(`Failed to fetch versions: ${errorMessage}`); + process.exit(1); + } + } + + log.info(`\nAnalyzing bundle size for: ${packageName}`); + if (exports && exports.length > 0) { + log.info(`Exports: { ${exports.join(", ")} }`); + } + log.info("Please wait, installing and bundling...\n"); + + try { + const result = await checkBundleSize({ + packageName, + exports, + additionalExternals, + noExternal: flags?.noExternal, + gzipLevel: flags?.gzipLevel, + }); + + const blue = kleur.blue; + const green = kleur.green; + + // Display results + log.printBox( + [ + `${blue("Package:")} ${result.packageName} (${blue("version:")} ${result.packageVersion})`, + result.exports.length > 0 + ? `${blue("Exports:")} { ${result.exports.join(", ")} }` + : `${blue("Exports:")} * (entire package)`, + "", + `${blue("Raw size:")} ${formatBytes(result.rawSize)}`, + `${blue("Gzip size:")} ${formatBytes(result.gzipSize)} (level ${result.gzipLevel})`, + "", + result.externals.length > 0 + ? `${blue("Externals:")} ${result.externals.join(", ")}` + : `${blue("Externals:")} ${green("none")}`, + result.dependencies.length > 0 + ? `${blue("Dependencies:")} ${result.dependencies.join(", ")}` + : `${blue("Dependencies:")} ${green("none")}`, + ], + { + borderStyle: "round", + align: "left", + }, + ); + + process.exit(0); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.error(`Failed to analyze bundle size: ${errorMessage}`); + process.exit(1); + } +} + +main(); diff --git a/packages/bundlecheck/src/bundler.ts b/packages/bundlecheck/src/bundler.ts new file mode 100644 index 00000000..a1e8469b --- /dev/null +++ b/packages/bundlecheck/src/bundler.ts @@ -0,0 +1,598 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import zlib from "node:zlib"; +import * as esbuild from "esbuild"; +import { DEFAULT_EXTERNALS } from "./defaults.js"; + +const gzipAsync = promisify(zlib.gzip); + +export type ParsedPackage = { + name: string; + version: string; + subpath?: string; +}; + +/** + * Parse a package specifier to extract name, version, and subpath + * Handles: + * - @scope/package@1.0.0 + * - @scope/package/subpath@1.0.0 + * - @scope/package/subpath + * - package/subpath@1.0.0 + */ +export function parsePackageSpecifier(specifier: string): ParsedPackage { + let workingSpec = specifier; + let version = "latest"; + + // Handle scoped packages (@scope/name...) + if (workingSpec.startsWith("@")) { + // Find the second @ which would separate version + const secondAtIndex = workingSpec.indexOf("@", 1); + if (secondAtIndex !== -1) { + version = workingSpec.substring(secondAtIndex + 1); + workingSpec = workingSpec.substring(0, secondAtIndex); + } + + // Now workingSpec is like @scope/name or @scope/name/subpath + // Split by / and check if there are more than 2 parts + const parts = workingSpec.split("/"); + if (parts.length > 2) { + // Has subpath: @scope/name/subpath/more + const name = `${parts[0]}/${parts[1]}`; + const subpath = parts.slice(2).join("/"); + return { name, version, subpath }; + } + // No subpath: @scope/name + return { name: workingSpec, version }; + } + + // Handle non-scoped packages (name@version or name/subpath@version) + const atIndex = workingSpec.indexOf("@"); + if (atIndex !== -1) { + version = workingSpec.substring(atIndex + 1); + workingSpec = workingSpec.substring(0, atIndex); + } + + // Check for subpath in non-scoped packages + const slashIndex = workingSpec.indexOf("/"); + if (slashIndex !== -1) { + const name = workingSpec.substring(0, slashIndex); + const subpath = workingSpec.substring(slashIndex + 1); + return { name, version, subpath }; + } + + return { name: workingSpec, version }; +} + +export type BundleOptions = { + packageName: string; + exports?: string[]; + additionalExternals?: string[]; + noExternal?: boolean; + gzipLevel?: number; +}; + +export type BundleResult = { + packageName: string; + packageVersion: string; + exports: string[]; + rawSize: number; + gzipSize: number; + gzipLevel: number; + externals: string[]; + dependencies: string[]; +}; + +/** + * Format bytes to human-readable string + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) { + return "0 B"; + } + const k = 1024; + const sizes = ["B", "kB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`; +} + +/** + * Create a temporary directory for bundling + */ +function createTempDir(): string { + const tmpDir = path.join(os.tmpdir(), `bundlecheck-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + return tmpDir; +} + +/** + * Clean up temporary directory + */ +function cleanupTempDir(tmpDir: string): void { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +} + +/** + * Check if pnpm is available + */ +function isPnpmAvailable(): boolean { + try { + execSync("pnpm --version", { stdio: "pipe" }); + return true; + } catch { + return false; + } +} + +// Cache the result of pnpm availability check +let usePnpm: boolean | null = null; + +/** + * Get the install command (pnpm preferred, npm fallback) + */ +function getInstallCommand(): string { + if (usePnpm === null) { + usePnpm = isPnpmAvailable(); + } + + if (usePnpm) { + return "pnpm install --ignore-scripts"; + } + return "npm install --legacy-peer-deps --ignore-scripts"; +} + +export type EntryContentOptions = { + packageName: string; + subpath?: string; + exports?: string[]; + allSubpaths?: string[]; + exportToSubpath?: Map; +}; + +/** + * Generate the entry file content based on package, subpath, and exports + */ +function generateEntryContent(options: EntryContentOptions): string { + const { packageName, subpath, exports, allSubpaths, exportToSubpath } = + options; + + // If we have exports mapped to different subpaths + if (exportToSubpath && exportToSubpath.size > 0) { + // Group exports by subpath + const subpathToExports = new Map(); + for (const [exportName, sp] of exportToSubpath) { + const existing = subpathToExports.get(sp) || []; + existing.push(exportName); + subpathToExports.set(sp, existing); + } + + // Generate imports for each subpath + const lines: string[] = []; + const allExportNames: string[] = []; + + for (const [sp, exportNames] of subpathToExports) { + const importPath = `${packageName}/${sp}`; + const names = exportNames.join(", "); + lines.push(`import { ${names} } from "${importPath}";`); + allExportNames.push(...exportNames); + } + + lines.push(`export { ${allExportNames.join(", ")} };`); + return lines.join("\n") + "\n"; + } + + // If we have specific exports to import + if (exports && exports.length > 0) { + // Determine the import path + const importPath = subpath ? `${packageName}/${subpath}` : packageName; + const importNames = exports.join(", "); + return `import { ${importNames} } from "${importPath}";\nexport { ${importNames} };\n`; + } + + // If we have a specific subpath (but no specific exports) + if (subpath) { + const importPath = `${packageName}/${subpath}`; + return `import * as pkg from "${importPath}";\nexport default pkg;\n`; + } + + // If package has subpath exports only (no main entry), import all subpaths + if (allSubpaths && allSubpaths.length > 0) { + const imports = allSubpaths + .map( + (sp, i) => + `import * as sub${i} from "${packageName}/${sp}";\nexport { sub${i} };`, + ) + .join("\n"); + return imports + "\n"; + } + + // Default: import everything from main entry + return `import * as pkg from "${packageName}";\nexport default pkg;\n`; +} + +/** + * Get externals list based on options + */ +function getExternals( + packageName: string, + externals?: string[], + noExternal?: boolean, +): string[] { + if (noExternal) { + return []; + } + + // Start with default externals (react, react-dom) + let result = [...DEFAULT_EXTERNALS]; + + // If checking react or react-dom themselves, don't mark them as external + if (packageName === "react") { + result = result.filter((e) => e !== "react"); + } else if (packageName === "react-dom") { + result = result.filter((e) => e !== "react-dom"); + } + + // Add any additional externals + if (externals && externals.length > 0) { + result = [...new Set([...result, ...externals])]; + } + + return result; +} + +export type PackageExports = Record< + string, + string | { import?: string; types?: string } +>; + +export type PackageInfo = { + version: string; + dependencies: Record; + peerDependencies: Record; + exports: PackageExports | null; + hasMainEntry: boolean; +}; + +/** + * Get version, dependencies, peer dependencies, and exports from an installed package + */ +function getPackageInfo(tmpDir: string, packageName: string): PackageInfo { + try { + // Handle scoped packages - the package name in node_modules + const pkgJsonPath = path.join( + tmpDir, + "node_modules", + packageName, + "package.json", + ); + if (fs.existsSync(pkgJsonPath)) { + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")); + + // Check if package has a main entry point + const hasMainEntry = Boolean( + pkgJson.main || + pkgJson.module || + pkgJson.exports?.["."] || + pkgJson.exports?.["./index"] || + (!pkgJson.exports && !pkgJson.main && !pkgJson.module), + ); + + return { + version: pkgJson.version || "unknown", + dependencies: pkgJson.dependencies || {}, + peerDependencies: pkgJson.peerDependencies || {}, + exports: pkgJson.exports || null, + hasMainEntry, + }; + } + } catch { + // Ignore errors reading package info + } + return { + version: "unknown", + dependencies: {}, + peerDependencies: {}, + exports: null, + hasMainEntry: true, + }; +} + +/** + * Extract subpath export names from package exports field + * Returns array of subpaths like ["header", "body", "datagrid"] + */ +function getSubpathExports(exports: PackageExports | null): string[] { + if (!exports) { + return []; + } + + const subpaths: string[] = []; + for (const key of Object.keys(exports)) { + // Skip the main entry point and package.json + if (key === "." || key === "./package.json") { + continue; + } + // Remove leading "./" to get subpath name + if (key.startsWith("./")) { + subpaths.push(key.substring(2)); + } + } + return subpaths; +} + +/** + * Result of finding subpaths for exports + */ +type SubpathMapping = { + // Single subpath if all exports are from the same subpath + singleSubpath?: string; + // Map of export name to subpath for multiple subpaths + exportToSubpath?: Map; +}; + +/** + * Find which subpath(s) export the given component names + * Reads type definition files or JS files to find the exports + */ +function findSubpathsForExports( + tmpDir: string, + packageName: string, + exports: PackageExports, + componentNames: string[], +): SubpathMapping { + const packageDir = path.join(tmpDir, "node_modules", packageName); + const exportToSubpath = new Map(); + + for (const [subpathKey, subpathValue] of Object.entries(exports)) { + // Skip main entry and package.json + if (subpathKey === "." || subpathKey === "./package.json") { + continue; + } + + // Get the types or import path + let filePath: string | undefined; + if (typeof subpathValue === "object" && subpathValue !== null) { + // Prefer types file for more accurate export detection + filePath = subpathValue.types || subpathValue.import; + } else if (typeof subpathValue === "string") { + filePath = subpathValue; + } + + if (!filePath) { + continue; + } + + // Resolve the file path + const fullPath = path.join(packageDir, filePath); + + try { + if (fs.existsSync(fullPath)) { + const content = fs.readFileSync(fullPath, "utf-8"); + const subpath = subpathKey.startsWith("./") + ? subpathKey.substring(2) + : subpathKey; + + // Check each component name + for (const name of componentNames) { + // Skip if already found + if (exportToSubpath.has(name)) { + continue; + } + + // Look for various export patterns + const patterns = [ + new RegExp(`export\\s*\\{[^}]*\\b${name}\\b[^}]*\\}`, "m"), + new RegExp( + `export\\s+declare\\s+(?:const|function|class)\\s+${name}\\b`, + "m", + ), + new RegExp(`export\\s+(?:const|function|class)\\s+${name}\\b`, "m"), + ]; + + if (patterns.some((pattern) => pattern.test(content))) { + exportToSubpath.set(name, subpath); + } + } + } + } catch { + // Ignore read errors, continue to next subpath + } + } + + // Check if all exports were found + if (exportToSubpath.size !== componentNames.length) { + return {}; // Not all exports found + } + + // Check if all exports are from the same subpath + const subpaths = new Set(exportToSubpath.values()); + if (subpaths.size === 1) { + return { singleSubpath: [...subpaths][0] }; + } + + // Multiple subpaths needed + return { exportToSubpath }; +} + +/** + * Check the bundle size of an npm package + */ +export async function checkBundleSize( + options: BundleOptions, +): Promise { + const { + packageName: packageSpecifier, + exports, + additionalExternals, + noExternal, + gzipLevel = 9, + } = options; + + // Parse the package specifier to extract name, version, and subpath + const { + name: packageName, + version: requestedVersion, + subpath, + } = parsePackageSpecifier(packageSpecifier); + + const tmpDir = createTempDir(); + + try { + // Create initial package.json + const packageJson: { + name: string; + version: string; + type: string; + dependencies: Record; + } = { + name: "bundlecheck-temp", + version: "1.0.0", + type: "module", + dependencies: { + [packageName]: requestedVersion, + }, + }; + + fs.writeFileSync( + path.join(tmpDir, "package.json"), + JSON.stringify(packageJson, null, 2), + ); + + // Install the main package (try pnpm first, fallback to npm) + const installCmd = getInstallCommand(); + execSync(installCmd, { + cwd: tmpDir, + stdio: "pipe", + }); + + // Get package info (version, dependencies, peer dependencies, exports) + const pkgInfo = getPackageInfo(tmpDir, packageName); + const peerDepKeys = Object.keys(pkgInfo.peerDependencies); + + // Collect all dependency names (prod + peer) + const allDependencies = [ + ...new Set([...Object.keys(pkgInfo.dependencies), ...peerDepKeys]), + ].sort(); + + if (peerDepKeys.length > 0) { + // Add peer dependencies to package.json + for (const dep of peerDepKeys) { + // Use the version range from peer dependencies + packageJson.dependencies[dep] = pkgInfo.peerDependencies[dep]; + } + + // Update package.json and reinstall + fs.writeFileSync( + path.join(tmpDir, "package.json"), + JSON.stringify(packageJson, null, 2), + ); + + execSync(installCmd, { + cwd: tmpDir, + stdio: "pipe", + }); + } + + // Determine if we need to use all subpath exports or find the right subpath(s) + let allSubpaths: string[] | undefined; + let resolvedSubpath = subpath; + let exportToSubpath: Map | undefined; + + if (!subpath && !pkgInfo.hasMainEntry && pkgInfo.exports) { + if (exports && exports.length > 0) { + // User specified exports but no subpath - try to find the right subpath(s) + const mapping = findSubpathsForExports( + tmpDir, + packageName, + pkgInfo.exports, + exports, + ); + + if (mapping.singleSubpath) { + // All exports from the same subpath + resolvedSubpath = mapping.singleSubpath; + } else if (mapping.exportToSubpath) { + // Exports from multiple subpaths + exportToSubpath = mapping.exportToSubpath; + } + } + + // If still no subpath resolved and no mapping, bundle all subpaths + if (!resolvedSubpath && !exportToSubpath) { + allSubpaths = getSubpathExports(pkgInfo.exports); + } + } + + // Create entry file with appropriate content + const entryContent = generateEntryContent({ + packageName, + subpath: resolvedSubpath, + exports, + allSubpaths, + exportToSubpath, + }); + const entryFile = path.join(tmpDir, "entry.js"); + fs.writeFileSync(entryFile, entryContent); + + // Get externals + const externals = getExternals( + packageName, + additionalExternals, + noExternal, + ); + + // Bundle with esbuild + const result = await esbuild.build({ + entryPoints: [entryFile], + bundle: true, + write: false, + format: "esm", + platform: "browser", + target: "es2020", + minify: true, + treeShaking: true, + external: externals, + metafile: true, + }); + + // Get raw size + const bundleContent = result.outputFiles[0].contents; + const rawSize = bundleContent.length; + + // Gzip the bundle + const gzipped = await gzipAsync(Buffer.from(bundleContent), { + level: gzipLevel, + }); + const gzipSize = gzipped.length; + + // Determine the display name + let displayName = packageName; + if (resolvedSubpath) { + displayName = `${packageName}/${resolvedSubpath}`; + } else if (exportToSubpath && exportToSubpath.size > 0) { + // Multiple subpaths - show them all + const uniqueSubpaths = [...new Set(exportToSubpath.values())].sort(); + displayName = `${packageName}/{${uniqueSubpaths.join(", ")}}`; + } + + return { + packageName: displayName, + packageVersion: pkgInfo.version, + exports: exports || [], + rawSize, + gzipSize, + gzipLevel, + externals, + dependencies: allDependencies, + }; + } finally { + cleanupTempDir(tmpDir); + } +} diff --git a/packages/bundlecheck/src/defaults.ts b/packages/bundlecheck/src/defaults.ts new file mode 100644 index 00000000..bfbf01a5 --- /dev/null +++ b/packages/bundlecheck/src/defaults.ts @@ -0,0 +1,13 @@ +/* istanbul ignore file */ + +export const defaultFlags = { + boring: false, + gzipLevel: 5, + external: "", + noExternal: false, + versions: false, +}; + +export const TREND_VERSION_COUNT = 5; + +export const DEFAULT_EXTERNALS = ["react", "react-dom"]; diff --git a/packages/bundlecheck/src/parse.ts b/packages/bundlecheck/src/parse.ts new file mode 100644 index 00000000..4290e90f --- /dev/null +++ b/packages/bundlecheck/src/parse.ts @@ -0,0 +1,147 @@ +/* istanbul ignore file */ +import { parser } from "@node-cli/parser"; +import { DEFAULT_EXTERNALS, defaultFlags } from "./defaults.js"; + +export type Flags = { + boring?: boolean; + help?: boolean; + version?: boolean; + versions?: boolean; + trend?: string; + gzipLevel?: number; + external?: string; + noExternal?: boolean; +}; + +export type Parameters = { + ["0"]?: string; // package name + ["1"]?: string; // exports (comma-separated) +}; + +export type Configuration = { + flags?: Flags; + parameters?: Parameters; + usage?: boolean; + showHelp?: () => void; +}; + +export const config: Configuration = parser({ + meta: import.meta, + examples: [ + { + command: "bundlecheck lodash", + comment: "## Check the bundle size of the entire lodash package", + }, + { + command: "bundlecheck lodash@4.17.0", + comment: "## Check a specific version of a package", + }, + { + command: "bundlecheck @mantine/core", + comment: "## Check the bundle size of the entire @mantine/core package", + }, + { + command: 'bundlecheck @mantine/core "ScrollArea,Button"', + comment: + "## Check the bundle size of specific exports from @mantine/core", + }, + { + command: "bundlecheck react --no-external", + comment: + "## Check the bundle size of react itself (not marked as external)", + }, + { + command: 'bundlecheck some-pkg --external "vue,svelte"', + comment: "## Add vue and svelte as additional external dependencies", + }, + { + command: "bundlecheck lodash --gzip-level 6", + comment: "## Use a different gzip compression level (1-9)", + }, + { + command: "bundlecheck lodash --versions", + comment: "## Choose from available versions interactively", + }, + { + command: "bundlecheck lodash --trend", + comment: "## Show bundle size trend (default: 5 versions)", + }, + { + command: "bundlecheck lodash --trend 3", + comment: "## Show bundle size trend for 3 versions", + }, + ], + flags: { + gzipLevel: { + shortFlag: "g", + default: defaultFlags.gzipLevel, + description: "Gzip compression level (1-9, default: 9)", + type: "number", + }, + external: { + shortFlag: "e", + default: defaultFlags.external, + description: `Comma-separated additional externals (default externals: ${DEFAULT_EXTERNALS.join(", ")})`, + type: "string", + }, + noExternal: { + shortFlag: "n", + default: defaultFlags.noExternal, + description: + "Do not mark any packages as external (useful for checking react/react-dom themselves)", + type: "boolean", + }, + boring: { + shortFlag: "b", + default: defaultFlags.boring, + description: "Do not use color output", + type: "boolean", + }, + help: { + shortFlag: "h", + description: "Display help instructions", + type: "boolean", + }, + version: { + shortFlag: "v", + description: "Output the current version", + type: "boolean", + }, + versions: { + shortFlag: "V", + default: defaultFlags.versions, + description: "Choose from available package versions interactively", + type: "boolean", + }, + trend: { + shortFlag: "t", + description: "Show bundle size trend for N recent versions (default: 5)", + type: "string", + }, + }, + parameters: { + package: { + description: "The npm package to check (e.g., lodash, @mantine/core)", + }, + exports: { + description: + 'Comma-separated list of exports to check (e.g., "ScrollArea,Button")', + }, + }, + restrictions: [ + { + exit: 1, + message: () => "Error: Package name is required", + test: (flags, parameters) => !parameters?.["0"], + }, + { + exit: 1, + message: () => "Error: Gzip level must be between 1 and 9", + test: (flags) => + flags.gzipLevel !== undefined && + (flags.gzipLevel < 1 || flags.gzipLevel > 9), + }, + ], + usage: true, + defaultFlags, +}); diff --git a/packages/bundlecheck/src/trend.ts b/packages/bundlecheck/src/trend.ts new file mode 100644 index 00000000..f00f1221 --- /dev/null +++ b/packages/bundlecheck/src/trend.ts @@ -0,0 +1,182 @@ +import { Logger } from "@node-cli/logger"; +import kleur from "kleur"; +import { checkBundleSize, formatBytes } from "./bundler.js"; +import { TREND_VERSION_COUNT } from "./defaults.js"; + +export type TrendResult = { + version: string; + rawSize: number; + gzipSize: number; +}; + +export type TrendOptions = { + packageName: string; + versions: string[]; + exports?: string[]; + additionalExternals?: string[]; + noExternal?: boolean; + gzipLevel?: number; + boring?: boolean; +}; + +/** + * Select versions for trend analysis + * Returns the most recent versions (newest first) + */ +export function selectTrendVersions( + allVersions: string[], + count: number = TREND_VERSION_COUNT, +): string[] { + return allVersions.slice(0, count); +} + +/** + * Analyze bundle size trend across multiple versions + */ +export async function analyzeTrend( + options: TrendOptions, +): Promise { + const { + packageName, + versions, + exports, + additionalExternals, + noExternal, + gzipLevel, + boring, + } = options; + + const log = new Logger({ boring }); + const results: TrendResult[] = []; + + for (const version of versions) { + const versionedPackage = `${packageName}@${version}`; + log.info(` Checking ${version}...`); + + try { + const result = await checkBundleSize({ + packageName: versionedPackage, + exports, + additionalExternals, + noExternal, + gzipLevel, + }); + + results.push({ + version, + rawSize: result.rawSize, + gzipSize: result.gzipSize, + }); + } catch { + // Skip versions that fail to analyze + log.info(` Skipping ${version} (failed to analyze)`); + } + } + + return results; +} + +/** + * Render a bar graph showing bundle size trend + */ +export function renderTrendGraph( + packageName: string, + results: TrendResult[], + boring: boolean = false, +): string[] { + if (results.length === 0) { + return ["No data to display"]; + } + + const lines: string[] = []; + + // Color helper (respects boring flag) + const blue = (text: string) => (boring ? text : kleur.blue(text)); + + // Find min/max sizes for scaling (use min-max scaling to show differences) + const gzipSizes = results.map((r) => r.gzipSize); + const rawSizes = results.map((r) => r.rawSize); + + const minGzipSize = Math.min(...gzipSizes); + const maxGzipSize = Math.max(...gzipSizes); + const minRawSize = Math.min(...rawSizes); + const maxRawSize = Math.max(...rawSizes); + + // Find max version length for alignment + const maxVersionLen = Math.max(...results.map((r) => r.version.length)); + + // Bar width (characters) + const barWidth = 30; + const minBarWidth = 10; // Minimum bar length for smallest value + + // Helper to calculate bar length with min-max scaling + const calcBarLength = (value: number, min: number, max: number): number => { + if (max === min) { + return barWidth; // All values are the same + } + // Scale from minBarWidth to barWidth based on position between min and max + const ratio = (value - min) / (max - min); + return Math.round(minBarWidth + ratio * (barWidth - minBarWidth)); + }; + + // Header + lines.push(""); + lines.push(`${blue("Bundle Size:")} ${packageName}`); + lines.push("─".repeat(60)); + lines.push(""); + + // Gzip size bars + lines.push(blue("Gzip Size:")); + for (const result of results) { + const barLength = calcBarLength(result.gzipSize, minGzipSize, maxGzipSize); + const bar = "▇".repeat(barLength); + const padding = " ".repeat(maxVersionLen - result.version.length); + const sizeStr = formatBytes(result.gzipSize); + lines.push(` ${result.version}${padding} ${bar} ${sizeStr}`); + } + + lines.push(""); + + // Raw size bars + lines.push(blue("Raw Size:")); + for (const result of results) { + const barLength = calcBarLength(result.rawSize, minRawSize, maxRawSize); + const bar = "▇".repeat(barLength); + const padding = " ".repeat(maxVersionLen - result.version.length); + const sizeStr = formatBytes(result.rawSize); + lines.push(` ${result.version}${padding} ${bar} ${sizeStr}`); + } + + lines.push(""); + + // Summary + const oldestResult = results[results.length - 1]; + const newestResult = results[0]; + + if (results.length > 1) { + const gzipDiff = newestResult.gzipSize - oldestResult.gzipSize; + const gzipPercent = ((gzipDiff / oldestResult.gzipSize) * 100).toFixed(1); + const rawDiff = newestResult.rawSize - oldestResult.rawSize; + const rawPercent = ((rawDiff / oldestResult.rawSize) * 100).toFixed(1); + + const gzipTrend = + gzipDiff > 0 ? `+${formatBytes(gzipDiff)}` : formatBytes(gzipDiff); + const rawTrend = + rawDiff > 0 ? `+${formatBytes(rawDiff)}` : formatBytes(rawDiff); + + lines.push("─".repeat(60)); + lines.push( + `Change from ${oldestResult.version} to ${newestResult.version}:`, + ); + lines.push( + ` ${blue("Gzip:")} ${gzipTrend} (${gzipDiff >= 0 ? "+" : ""}${gzipPercent}%)`, + ); + lines.push( + ` ${blue("Raw:")} ${rawTrend} (${rawDiff >= 0 ? "+" : ""}${rawPercent}%)`, + ); + } + + lines.push(""); + + return lines; +} diff --git a/packages/bundlecheck/src/versions.ts b/packages/bundlecheck/src/versions.ts new file mode 100644 index 00000000..9ffaec11 --- /dev/null +++ b/packages/bundlecheck/src/versions.ts @@ -0,0 +1,89 @@ +import select from "@inquirer/select"; +import { parsePackageSpecifier } from "./bundler.js"; + +export type NpmPackageInfo = { + versions: string[]; + tags: Record; +}; + +/** + * Fetch available versions for an npm package from the registry + */ +type NpmRegistryResponse = { + versions?: Record; + "dist-tags"?: Record; +}; + +export async function fetchPackageVersions( + packageName: string, +): Promise { + // Parse the package specifier to get just the name (without version) + const { name } = parsePackageSpecifier(packageName); + + const url = `https://registry.npmjs.org/${name}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch package info: ${response.statusText}`); + } + + const data = (await response.json()) as NpmRegistryResponse; + + // Get all versions sorted by semver (newest first) + const versions = Object.keys(data.versions || {}).reverse(); + + // Get dist-tags (latest, next, beta, etc.) + const tags = data["dist-tags"] || {}; + + return { versions, tags }; +} + +/** + * Prompt user to select a version from available versions + */ +export async function promptForVersion( + packageName: string, + versions: string[], + tags: Record, +): Promise { + // Build choices with tags highlighted + const taggedVersions = new Set(Object.values(tags)); + const tagByVersion = Object.fromEntries( + Object.entries(tags).map(([tag, ver]) => [ver, tag]), + ); + + // Limit to most recent 20 versions for usability, but include all tagged versions + const recentVersions = versions.slice(0, 20); + const displayVersions = [ + ...new Set([...Object.values(tags), ...recentVersions]), + ]; + + // Sort so tagged versions appear first, then by version order + displayVersions.sort((a, b) => { + const aTagged = taggedVersions.has(a); + const bTagged = taggedVersions.has(b); + if (aTagged && !bTagged) { + return -1; + } + if (!aTagged && bTagged) { + return 1; + } + return versions.indexOf(a) - versions.indexOf(b); + }); + + const choices = displayVersions.map((ver) => { + const tag = tagByVersion[ver]; + return { + name: tag ? `${ver} (${tag})` : ver, + value: ver, + }; + }); + + const selectedVersion = await select({ + message: `Select a version for ${packageName}:`, + choices, + pageSize: 15, + }); + + return selectedVersion; +} diff --git a/packages/bundlecheck/tsconfig.json b/packages/bundlecheck/tsconfig.json new file mode 100644 index 00000000..3aa3852e --- /dev/null +++ b/packages/bundlecheck/tsconfig.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": ["../../configuration/tsconfig.common.json"], + "compilerOptions": { + "declarationDir": "dist", + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["src/__tests__"] +} diff --git a/packages/bundlecheck/vitest.config.ts b/packages/bundlecheck/vitest.config.ts new file mode 100644 index 00000000..efc11ab5 --- /dev/null +++ b/packages/bundlecheck/vitest.config.ts @@ -0,0 +1,32 @@ +/// +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["src/**/*.ts"], + exclude: [ + "node_modules/", + "dist/", + "coverage/", + "**/*.d.ts", + "**/*.config.*", + "src/__tests__/**", + "src/bundlecheck.ts", + "src/parse.ts", + "src/defaults.ts", + ], + thresholds: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, + include: ["src/**/__tests__/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c3cc83f..7b59a2da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,34 @@ importers: specifier: workspace:* version: link:../../packages/run + packages/bundlecheck: + dependencies: + '@inquirer/select': + specifier: 5.0.4 + version: 5.0.4(@types/node@25.0.6) + '@node-cli/logger': + specifier: workspace:* + version: link:../logger + '@node-cli/parser': + specifier: workspace:* + version: link:../parser + esbuild: + specifier: 0.27.2 + version: 0.27.2 + kleur: + specifier: 4.1.5 + version: 4.1.5 + devDependencies: + '@node-cli/comments': + specifier: workspace:* + version: link:../comments + '@vitest/coverage-v8': + specifier: 4.0.16 + version: 4.0.16(vitest@4.0.16(@types/node@25.0.6)(happy-dom@20.1.0)(jsdom@27.4.0)(tsx@4.21.0)(yaml@2.8.1)) + vitest: + specifier: 4.0.16 + version: 4.0.16(@types/node@25.0.6)(happy-dom@20.1.0)(jsdom@27.4.0)(tsx@4.21.0)(yaml@2.8.1) + packages/bundlesize: dependencies: '@node-cli/logger': @@ -518,8 +546,8 @@ packages: '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} - '@esbuild/aix-ppc64@0.25.5': - resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} + '@esbuild/aix-ppc64@0.25.1': + resolution: {integrity: sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -530,8 +558,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.5': - resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} + '@esbuild/android-arm64@0.25.1': + resolution: {integrity: sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -542,8 +570,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.5': - resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} + '@esbuild/android-arm@0.25.1': + resolution: {integrity: sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -554,8 +582,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.5': - resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} + '@esbuild/android-x64@0.25.1': + resolution: {integrity: sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -566,8 +594,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.5': - resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} + '@esbuild/darwin-arm64@0.25.1': + resolution: {integrity: sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -578,8 +606,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.5': - resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} + '@esbuild/darwin-x64@0.25.1': + resolution: {integrity: sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -590,8 +618,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.5': - resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} + '@esbuild/freebsd-arm64@0.25.1': + resolution: {integrity: sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -602,8 +630,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.5': - resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} + '@esbuild/freebsd-x64@0.25.1': + resolution: {integrity: sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -614,8 +642,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.5': - resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} + '@esbuild/linux-arm64@0.25.1': + resolution: {integrity: sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -626,8 +654,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.5': - resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} + '@esbuild/linux-arm@0.25.1': + resolution: {integrity: sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -638,8 +666,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.5': - resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} + '@esbuild/linux-ia32@0.25.1': + resolution: {integrity: sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -650,8 +678,8 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.5': - resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} + '@esbuild/linux-loong64@0.25.1': + resolution: {integrity: sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -662,8 +690,8 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.5': - resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} + '@esbuild/linux-mips64el@0.25.1': + resolution: {integrity: sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -674,8 +702,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.5': - resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} + '@esbuild/linux-ppc64@0.25.1': + resolution: {integrity: sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -686,8 +714,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.5': - resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} + '@esbuild/linux-riscv64@0.25.1': + resolution: {integrity: sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -698,8 +726,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.5': - resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} + '@esbuild/linux-s390x@0.25.1': + resolution: {integrity: sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -710,8 +738,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.5': - resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} + '@esbuild/linux-x64@0.25.1': + resolution: {integrity: sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -722,8 +750,8 @@ packages: cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.5': - resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} + '@esbuild/netbsd-arm64@0.25.1': + resolution: {integrity: sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -734,8 +762,8 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.5': - resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} + '@esbuild/netbsd-x64@0.25.1': + resolution: {integrity: sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -746,8 +774,8 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.5': - resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} + '@esbuild/openbsd-arm64@0.25.1': + resolution: {integrity: sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -758,8 +786,8 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.5': - resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} + '@esbuild/openbsd-x64@0.25.1': + resolution: {integrity: sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -776,8 +804,8 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.5': - resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} + '@esbuild/sunos-x64@0.25.1': + resolution: {integrity: sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -788,8 +816,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.5': - resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} + '@esbuild/win32-arm64@0.25.1': + resolution: {integrity: sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -800,8 +828,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.5': - resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} + '@esbuild/win32-ia32@0.25.1': + resolution: {integrity: sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -812,8 +840,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.5': - resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} + '@esbuild/win32-x64@0.25.1': + resolution: {integrity: sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -881,6 +909,10 @@ packages: resolution: {integrity: sha512-SYLX05PwJVnW+WVegZt1T4Ip1qba1ik+pNJPDiqvk6zS5Y/i8PhRzLpGEtVd7sW0G8cMtkD8t4AZYhQwm8vnww==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/ansi@2.0.3': + resolution: {integrity: sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/checkbox@4.3.2': resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} engines: {node: '>=18'} @@ -935,6 +967,15 @@ packages: '@types/node': optional: true + '@inquirer/core@11.1.1': + resolution: {integrity: sha512-hV9o15UxX46OyQAtaoMqAOxGR8RVl1aZtDx1jHbCtSJy1tBdTfKxLPKf7utsE4cRy4tcmCQ4+vdV+ca+oNxqNA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/editor@4.2.23': resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} engines: {node: '>=18'} @@ -997,6 +1038,10 @@ packages: resolution: {integrity: sha512-qXm6EVvQx/FmnSrCWCIGtMHwqeLgxABP8XgcaAoywsL0NFga9gD5kfG0gXiv80GjK9Hsoz4pgGwF/+CjygyV9A==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/figures@2.0.3': + resolution: {integrity: sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/input@4.3.1': resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} engines: {node: '>=18'} @@ -1114,8 +1159,8 @@ packages: '@types/node': optional: true - '@inquirer/select@5.0.3': - resolution: {integrity: sha512-M+ynbwS0ecQFDYMFrQrybA0qL8DV0snpc4kKevCCNaTpfghsRowRY7SlQBeIYNzHqXtiiz4RG9vTOeb/udew7w==} + '@inquirer/select@5.0.4': + resolution: {integrity: sha512-s8KoGpPYMEQ6WXc0dT9blX2NtIulMdLOO3LA1UKOiv7KFWzlJ6eLkEYTDBIi+JkyKXyn8t/CD6TinxGjyLt57g==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -1141,6 +1186,15 @@ packages: '@types/node': optional: true + '@inquirer/type@4.0.3': + resolution: {integrity: sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -1184,15 +1238,9 @@ packages: resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} - '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.25': - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -2772,8 +2820,8 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild@0.25.5: - resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} + esbuild@0.25.1: + resolution: {integrity: sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==} engines: {node: '>=18'} hasBin: true @@ -2875,14 +2923,6 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - fdir@6.4.6: - resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -5670,127 +5710,127 @@ snapshots: '@epic-web/invariant@1.0.0': {} - '@esbuild/aix-ppc64@0.25.5': + '@esbuild/aix-ppc64@0.25.1': optional: true '@esbuild/aix-ppc64@0.27.2': optional: true - '@esbuild/android-arm64@0.25.5': + '@esbuild/android-arm64@0.25.1': optional: true '@esbuild/android-arm64@0.27.2': optional: true - '@esbuild/android-arm@0.25.5': + '@esbuild/android-arm@0.25.1': optional: true '@esbuild/android-arm@0.27.2': optional: true - '@esbuild/android-x64@0.25.5': + '@esbuild/android-x64@0.25.1': optional: true '@esbuild/android-x64@0.27.2': optional: true - '@esbuild/darwin-arm64@0.25.5': + '@esbuild/darwin-arm64@0.25.1': optional: true '@esbuild/darwin-arm64@0.27.2': optional: true - '@esbuild/darwin-x64@0.25.5': + '@esbuild/darwin-x64@0.25.1': optional: true '@esbuild/darwin-x64@0.27.2': optional: true - '@esbuild/freebsd-arm64@0.25.5': + '@esbuild/freebsd-arm64@0.25.1': optional: true '@esbuild/freebsd-arm64@0.27.2': optional: true - '@esbuild/freebsd-x64@0.25.5': + '@esbuild/freebsd-x64@0.25.1': optional: true '@esbuild/freebsd-x64@0.27.2': optional: true - '@esbuild/linux-arm64@0.25.5': + '@esbuild/linux-arm64@0.25.1': optional: true '@esbuild/linux-arm64@0.27.2': optional: true - '@esbuild/linux-arm@0.25.5': + '@esbuild/linux-arm@0.25.1': optional: true '@esbuild/linux-arm@0.27.2': optional: true - '@esbuild/linux-ia32@0.25.5': + '@esbuild/linux-ia32@0.25.1': optional: true '@esbuild/linux-ia32@0.27.2': optional: true - '@esbuild/linux-loong64@0.25.5': + '@esbuild/linux-loong64@0.25.1': optional: true '@esbuild/linux-loong64@0.27.2': optional: true - '@esbuild/linux-mips64el@0.25.5': + '@esbuild/linux-mips64el@0.25.1': optional: true '@esbuild/linux-mips64el@0.27.2': optional: true - '@esbuild/linux-ppc64@0.25.5': + '@esbuild/linux-ppc64@0.25.1': optional: true '@esbuild/linux-ppc64@0.27.2': optional: true - '@esbuild/linux-riscv64@0.25.5': + '@esbuild/linux-riscv64@0.25.1': optional: true '@esbuild/linux-riscv64@0.27.2': optional: true - '@esbuild/linux-s390x@0.25.5': + '@esbuild/linux-s390x@0.25.1': optional: true '@esbuild/linux-s390x@0.27.2': optional: true - '@esbuild/linux-x64@0.25.5': + '@esbuild/linux-x64@0.25.1': optional: true '@esbuild/linux-x64@0.27.2': optional: true - '@esbuild/netbsd-arm64@0.25.5': + '@esbuild/netbsd-arm64@0.25.1': optional: true '@esbuild/netbsd-arm64@0.27.2': optional: true - '@esbuild/netbsd-x64@0.25.5': + '@esbuild/netbsd-x64@0.25.1': optional: true '@esbuild/netbsd-x64@0.27.2': optional: true - '@esbuild/openbsd-arm64@0.25.5': + '@esbuild/openbsd-arm64@0.25.1': optional: true '@esbuild/openbsd-arm64@0.27.2': optional: true - '@esbuild/openbsd-x64@0.25.5': + '@esbuild/openbsd-x64@0.25.1': optional: true '@esbuild/openbsd-x64@0.27.2': @@ -5799,25 +5839,25 @@ snapshots: '@esbuild/openharmony-arm64@0.27.2': optional: true - '@esbuild/sunos-x64@0.25.5': + '@esbuild/sunos-x64@0.25.1': optional: true '@esbuild/sunos-x64@0.27.2': optional: true - '@esbuild/win32-arm64@0.25.5': + '@esbuild/win32-arm64@0.25.1': optional: true '@esbuild/win32-arm64@0.27.2': optional: true - '@esbuild/win32-ia32@0.25.5': + '@esbuild/win32-ia32@0.25.1': optional: true '@esbuild/win32-ia32@0.27.2': optional: true - '@esbuild/win32-x64@0.25.5': + '@esbuild/win32-x64@0.25.1': optional: true '@esbuild/win32-x64@0.27.2': @@ -5895,6 +5935,8 @@ snapshots: '@inquirer/ansi@2.0.2': {} + '@inquirer/ansi@2.0.3': {} + '@inquirer/checkbox@4.3.2(@types/node@25.0.6)': dependencies: '@inquirer/ansi': 1.0.2 @@ -5953,6 +5995,18 @@ snapshots: optionalDependencies: '@types/node': 25.0.6 + '@inquirer/core@11.1.1(@types/node@25.0.6)': + dependencies: + '@inquirer/ansi': 2.0.3 + '@inquirer/figures': 2.0.3 + '@inquirer/type': 4.0.3(@types/node@25.0.6) + cli-width: 4.1.0 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + wrap-ansi: 9.0.2 + optionalDependencies: + '@types/node': 25.0.6 + '@inquirer/editor@4.2.23(@types/node@25.0.6)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.0.6) @@ -6002,6 +6056,8 @@ snapshots: '@inquirer/figures@2.0.2': {} + '@inquirer/figures@2.0.3': {} + '@inquirer/input@4.3.1(@types/node@25.0.6)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.0.6) @@ -6072,7 +6128,7 @@ snapshots: '@inquirer/password': 5.0.3(@types/node@25.0.6) '@inquirer/rawlist': 5.1.0(@types/node@25.0.6) '@inquirer/search': 4.0.3(@types/node@25.0.6) - '@inquirer/select': 5.0.3(@types/node@25.0.6) + '@inquirer/select': 5.0.4(@types/node@25.0.6) optionalDependencies: '@types/node': 25.0.6 @@ -6118,12 +6174,12 @@ snapshots: optionalDependencies: '@types/node': 25.0.6 - '@inquirer/select@5.0.3(@types/node@25.0.6)': + '@inquirer/select@5.0.4(@types/node@25.0.6)': dependencies: - '@inquirer/ansi': 2.0.2 - '@inquirer/core': 11.1.0(@types/node@25.0.6) - '@inquirer/figures': 2.0.2 - '@inquirer/type': 4.0.2(@types/node@25.0.6) + '@inquirer/ansi': 2.0.3 + '@inquirer/core': 11.1.1(@types/node@25.0.6) + '@inquirer/figures': 2.0.3 + '@inquirer/type': 4.0.3(@types/node@25.0.6) optionalDependencies: '@types/node': 25.0.6 @@ -6135,6 +6191,10 @@ snapshots: optionalDependencies: '@types/node': 25.0.6 + '@inquirer/type@4.0.3(@types/node@25.0.6)': + optionalDependencies: + '@types/node': 25.0.6 + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -6174,19 +6234,12 @@ snapshots: '@jridgewell/set-array@1.2.1': {} - '@jridgewell/sourcemap-codec@1.5.0': {} - '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.25': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@lerna/create@9.0.3(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@25.0.6)(typescript@5.9.3)': dependencies: @@ -7936,33 +7989,33 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild@0.25.5: + esbuild@0.25.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.5 - '@esbuild/android-arm': 0.25.5 - '@esbuild/android-arm64': 0.25.5 - '@esbuild/android-x64': 0.25.5 - '@esbuild/darwin-arm64': 0.25.5 - '@esbuild/darwin-x64': 0.25.5 - '@esbuild/freebsd-arm64': 0.25.5 - '@esbuild/freebsd-x64': 0.25.5 - '@esbuild/linux-arm': 0.25.5 - '@esbuild/linux-arm64': 0.25.5 - '@esbuild/linux-ia32': 0.25.5 - '@esbuild/linux-loong64': 0.25.5 - '@esbuild/linux-mips64el': 0.25.5 - '@esbuild/linux-ppc64': 0.25.5 - '@esbuild/linux-riscv64': 0.25.5 - '@esbuild/linux-s390x': 0.25.5 - '@esbuild/linux-x64': 0.25.5 - '@esbuild/netbsd-arm64': 0.25.5 - '@esbuild/netbsd-x64': 0.25.5 - '@esbuild/openbsd-arm64': 0.25.5 - '@esbuild/openbsd-x64': 0.25.5 - '@esbuild/sunos-x64': 0.25.5 - '@esbuild/win32-arm64': 0.25.5 - '@esbuild/win32-ia32': 0.25.5 - '@esbuild/win32-x64': 0.25.5 + '@esbuild/aix-ppc64': 0.25.1 + '@esbuild/android-arm': 0.25.1 + '@esbuild/android-arm64': 0.25.1 + '@esbuild/android-x64': 0.25.1 + '@esbuild/darwin-arm64': 0.25.1 + '@esbuild/darwin-x64': 0.25.1 + '@esbuild/freebsd-arm64': 0.25.1 + '@esbuild/freebsd-x64': 0.25.1 + '@esbuild/linux-arm': 0.25.1 + '@esbuild/linux-arm64': 0.25.1 + '@esbuild/linux-ia32': 0.25.1 + '@esbuild/linux-loong64': 0.25.1 + '@esbuild/linux-mips64el': 0.25.1 + '@esbuild/linux-ppc64': 0.25.1 + '@esbuild/linux-riscv64': 0.25.1 + '@esbuild/linux-s390x': 0.25.1 + '@esbuild/linux-x64': 0.25.1 + '@esbuild/netbsd-arm64': 0.25.1 + '@esbuild/netbsd-x64': 0.25.1 + '@esbuild/openbsd-arm64': 0.25.1 + '@esbuild/openbsd-x64': 0.25.1 + '@esbuild/sunos-x64': 0.25.1 + '@esbuild/win32-arm64': 0.25.1 + '@esbuild/win32-ia32': 0.25.1 + '@esbuild/win32-x64': 0.25.1 esbuild@0.27.2: optionalDependencies: @@ -8114,10 +8167,6 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.4.6(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -8771,8 +8820,8 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: - '@jridgewell/trace-mapping': 0.3.25 - debug: 4.4.1 + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -10809,8 +10858,8 @@ snapshots: vite@6.3.5(@types/node@25.0.6)(tsx@4.21.0)(yaml@2.8.1): dependencies: - esbuild: 0.25.5 - fdir: 6.4.6(picomatch@4.0.3) + esbuild: 0.25.1 + fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 rollup: 4.44.0 From 026a0be89be8bb99813aa5d2fa041fc4aab4ad89 Mon Sep 17 00:00:00 2001 From: Arno V Date: Fri, 30 Jan 2026 20:02:01 -0500 Subject: [PATCH 02/10] code review --- packages/bundlecheck/src/bundler.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/bundlecheck/src/bundler.ts b/packages/bundlecheck/src/bundler.ts index a1e8469b..320177cc 100644 --- a/packages/bundlecheck/src/bundler.ts +++ b/packages/bundlecheck/src/bundler.ts @@ -9,6 +9,13 @@ import { DEFAULT_EXTERNALS } from "./defaults.js"; const gzipAsync = promisify(zlib.gzip); +/** + * Escape special regex characters in a string + */ +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + export type ParsedPackage = { name: string; version: string; @@ -386,14 +393,20 @@ function findSubpathsForExports( continue; } + // Escape regex special characters in the name to prevent injection + const escapedName = escapeRegExp(name); + // Look for various export patterns const patterns = [ - new RegExp(`export\\s*\\{[^}]*\\b${name}\\b[^}]*\\}`, "m"), + new RegExp(`export\\s*\\{[^}]*\\b${escapedName}\\b[^}]*\\}`, "m"), + new RegExp( + `export\\s+declare\\s+(?:const|function|class)\\s+${escapedName}\\b`, + "m", + ), new RegExp( - `export\\s+declare\\s+(?:const|function|class)\\s+${name}\\b`, + `export\\s+(?:const|function|class)\\s+${escapedName}\\b`, "m", ), - new RegExp(`export\\s+(?:const|function|class)\\s+${name}\\b`, "m"), ]; if (patterns.some((pattern) => pattern.test(content))) { From 441609d9a65dc408159d6dbe4b39f220e8202a05 Mon Sep 17 00:00:00 2001 From: Arno V Date: Fri, 30 Jan 2026 20:07:50 -0500 Subject: [PATCH 03/10] Preserve package subpath in analysis and output Parse and preserve package subpaths from the package specifier. Construct a fullPackagePath (name[/subpath]) and use it for trend analysis and graph rendering so subpaths are not lost. Also rebuild the package specifier when a version is selected to keep any subpath (e.g. name/subpath@version). --- packages/bundlecheck/src/bundlecheck.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/bundlecheck/src/bundlecheck.ts b/packages/bundlecheck/src/bundlecheck.ts index 1d004e33..68d343d8 100644 --- a/packages/bundlecheck/src/bundlecheck.ts +++ b/packages/bundlecheck/src/bundlecheck.ts @@ -64,7 +64,10 @@ async function main() { : TREND_VERSION_COUNT; try { - const { name } = parsePackageSpecifier(packageName); + const { name, subpath } = parsePackageSpecifier(packageName); + // Construct the full package path including subpath if present + const fullPackagePath = subpath ? `${name}/${subpath}` : name; + log.info(`\nFetching available versions for ${name}...`); const { versions } = await fetchPackageVersions(packageName); @@ -83,7 +86,7 @@ async function main() { log.info(""); const results = await analyzeTrend({ - packageName: name, + packageName: fullPackagePath, versions: trendVersions, exports, additionalExternals, @@ -98,7 +101,11 @@ async function main() { } // Render and display the trend graph - const graphLines = renderTrendGraph(name, results, flags?.boring); + const graphLines = renderTrendGraph( + fullPackagePath, + results, + flags?.boring, + ); for (const line of graphLines) { log.log(line); } @@ -115,7 +122,7 @@ async function main() { // If --versions flag is set, fetch and prompt for version selection if (flags?.versions) { try { - const { name } = parsePackageSpecifier(packageName); + const { name, subpath } = parsePackageSpecifier(packageName); log.info(`\nFetching available versions for ${name}...`); const { versions, tags } = await fetchPackageVersions(packageName); @@ -126,7 +133,10 @@ async function main() { } const selectedVersion = await promptForVersion(name, versions, tags); - packageName = `${name}@${selectedVersion}`; + // Rebuild specifier preserving any subpath + packageName = subpath + ? `${name}/${subpath}@${selectedVersion}` + : `${name}@${selectedVersion}`; log.info(`\nSelected: ${packageName}`); } catch (error) { const errorMessage = From d1aa2ba32cd1a55abe61b6100aba1d68624d8386 Mon Sep 17 00:00:00 2001 From: Arno V Date: Fri, 30 Jan 2026 20:08:44 -0500 Subject: [PATCH 04/10] Bump bundlecheck Node engine to >=20 Update packages/bundlecheck/package.json to require Node.js >=20 (previously >=18). This raises the minimum Node version for the bundlecheck package to align with updated runtime requirements and ensure compatibility with newer language/runtime features. --- packages/bundlecheck/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bundlecheck/package.json b/packages/bundlecheck/package.json index 897816b4..c60ea449 100644 --- a/packages/bundlecheck/package.json +++ b/packages/bundlecheck/package.json @@ -17,7 +17,7 @@ "dist", "README.md" ], - "node": ">=18", + "node": ">=20", "scripts": { "build": "npm-run-all --serial clean build:types build:js build:barrel", "build:barrel": "barrelsby --delete --directory dist --pattern \"**/*.d.ts\" --name \"index.d\"", From 492a31cf1ebd8c090f61684d8001f5f6a57921b7 Mon Sep 17 00:00:00 2001 From: Arno V Date: Fri, 30 Jan 2026 20:12:21 -0500 Subject: [PATCH 05/10] code review --- packages/bundlecheck/README.md | 2 +- packages/bundlecheck/package.json | 3 ++- packages/bundlecheck/src/__tests__/bundler.test.ts | 2 +- packages/bundlecheck/src/bundler.ts | 2 +- packages/bundlecheck/src/parse.ts | 2 +- packages/bundlecheck/src/versions.ts | 3 ++- pnpm-lock.yaml | 10 ++++++++++ 7 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/bundlecheck/README.md b/packages/bundlecheck/README.md index ae68c388..d2ceb0af 100644 --- a/packages/bundlecheck/README.md +++ b/packages/bundlecheck/README.md @@ -98,7 +98,7 @@ bundlecheck lodash "debounce,throttle" | `--versions` | `-V` | Choose from available package versions interactively | | `--trend [N]` | `-t [N]` | Show bundle size trend for N versions (default: 5) | | `--boring` | `-b` | Do not use color output | -| `--gzipLevel ` | `-g ` | Gzip compression level (1-9, default: 9) | +| `--gzipLevel ` | `-g ` | Gzip compression level (1-9, default: 5) | | `--external ` | `-e ` | Comma-separated additional packages to mark as external | | `--noExternal` | `-n` | Do not mark any packages as external | diff --git a/packages/bundlecheck/package.json b/packages/bundlecheck/package.json index c60ea449..38a9ac4f 100644 --- a/packages/bundlecheck/package.json +++ b/packages/bundlecheck/package.json @@ -37,7 +37,8 @@ "@node-cli/logger": "workspace:*", "@node-cli/parser": "workspace:*", "esbuild": "0.27.2", - "kleur": "4.1.5" + "kleur": "4.1.5", + "semver": "7.7.1" }, "publishConfig": { "access": "public" diff --git a/packages/bundlecheck/src/__tests__/bundler.test.ts b/packages/bundlecheck/src/__tests__/bundler.test.ts index 929f075a..216065a6 100644 --- a/packages/bundlecheck/src/__tests__/bundler.test.ts +++ b/packages/bundlecheck/src/__tests__/bundler.test.ts @@ -291,7 +291,7 @@ describe("checkBundleSize", () => { packageName: "test-package", }); - expect(result.gzipLevel).toBe(9); + expect(result.gzipLevel).toBe(5); }); it("should handle package with subpath", async () => { diff --git a/packages/bundlecheck/src/bundler.ts b/packages/bundlecheck/src/bundler.ts index 320177cc..4740ef40 100644 --- a/packages/bundlecheck/src/bundler.ts +++ b/packages/bundlecheck/src/bundler.ts @@ -445,7 +445,7 @@ export async function checkBundleSize( exports, additionalExternals, noExternal, - gzipLevel = 9, + gzipLevel = 5, } = options; // Parse the package specifier to extract name, version, and subpath diff --git a/packages/bundlecheck/src/parse.ts b/packages/bundlecheck/src/parse.ts index 4290e90f..988000d4 100644 --- a/packages/bundlecheck/src/parse.ts +++ b/packages/bundlecheck/src/parse.ts @@ -75,7 +75,7 @@ export const config: Configuration = parser({ gzipLevel: { shortFlag: "g", default: defaultFlags.gzipLevel, - description: "Gzip compression level (1-9, default: 9)", + description: "Gzip compression level (1-9, default: 5)", type: "number", }, external: { diff --git a/packages/bundlecheck/src/versions.ts b/packages/bundlecheck/src/versions.ts index 9ffaec11..1217d29d 100644 --- a/packages/bundlecheck/src/versions.ts +++ b/packages/bundlecheck/src/versions.ts @@ -1,4 +1,5 @@ import select from "@inquirer/select"; +import { rsort } from "semver"; import { parsePackageSpecifier } from "./bundler.js"; export type NpmPackageInfo = { @@ -30,7 +31,7 @@ export async function fetchPackageVersions( const data = (await response.json()) as NpmRegistryResponse; // Get all versions sorted by semver (newest first) - const versions = Object.keys(data.versions || {}).reverse(); + const versions = rsort(Object.keys(data.versions || {})); // Get dist-tags (latest, next, beta, etc.) const tags = data["dist-tags"] || {}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b59a2da..531f0206 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: kleur: specifier: 4.1.5 version: 4.1.5 + semver: + specifier: 7.7.1 + version: 7.7.1 devDependencies: '@node-cli/comments': specifier: workspace:* @@ -4745,6 +4748,11 @@ packages: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true + semver@7.7.1: + resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} + engines: {node: '>=10'} + hasBin: true + semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -10250,6 +10258,8 @@ snapshots: semver@5.7.2: {} + semver@7.7.1: {} + semver@7.7.2: {} semver@7.7.3: {} From e420864f2321a7457dde5864b32994a3447f881d Mon Sep 17 00:00:00 2001 From: Arno V Date: Fri, 30 Jan 2026 20:19:28 -0500 Subject: [PATCH 06/10] code review --- .release-please-manifest.json | 1 + release-please-config.json | 1 + 2 files changed, 2 insertions(+) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 451f4e6f..b80e5ca2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,5 @@ { + "packages/bundlecheck": "1.0.0", "packages/bundlesize": "4.6.0", "packages/envtools": "4.2.2", "packages/logger": "1.3.3", diff --git a/release-please-config.json b/release-please-config.json index c01f8579..4a4dfdb2 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,5 +1,6 @@ { "packages": { + "packages/bundlecheck": {}, "packages/bundlesize": {}, "packages/envtools": {}, "packages/logger": {}, From da7b13a74faa2eef83ec03d6ff89fc1011accd21 Mon Sep 17 00:00:00 2001 From: Arno V Date: Fri, 30 Jan 2026 20:32:21 -0500 Subject: [PATCH 07/10] Update bundler.ts --- packages/bundlecheck/src/bundler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bundlecheck/src/bundler.ts b/packages/bundlecheck/src/bundler.ts index 4740ef40..9bd4e039 100644 --- a/packages/bundlecheck/src/bundler.ts +++ b/packages/bundlecheck/src/bundler.ts @@ -150,7 +150,7 @@ function getInstallCommand(): string { } if (usePnpm) { - return "pnpm install --ignore-scripts"; + return "pnpm install --ignore-scripts --no-frozen-lockfile"; } return "npm install --legacy-peer-deps --ignore-scripts"; } From d9d3fba4ab67a4533eef3371693e8a29b1504b18 Mon Sep 17 00:00:00 2001 From: Arno V Date: Fri, 30 Jan 2026 20:47:36 -0500 Subject: [PATCH 08/10] code review --- .../src/__tests__/versions.test.ts | 48 ++++++++++++------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/packages/bundlecheck/src/__tests__/versions.test.ts b/packages/bundlecheck/src/__tests__/versions.test.ts index 9cb48df2..8909e683 100644 --- a/packages/bundlecheck/src/__tests__/versions.test.ts +++ b/packages/bundlecheck/src/__tests__/versions.test.ts @@ -1,11 +1,24 @@ import select from "@inquirer/select"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import { fetchPackageVersions, promptForVersion } from "../versions.js"; -// Mock fetch globally +// Save original fetch and restore after all tests +const originalFetch = global.fetch; const mockFetch = vi.fn(); global.fetch = mockFetch; +afterAll(() => { + global.fetch = originalFetch; +}); + // Mock @inquirer/select vi.mock("@inquirer/select", () => ({ default: vi.fn(), @@ -179,7 +192,7 @@ describe("promptForVersion", () => { const choices = call.choices; // Latest version should be first - expect(choices[0].value).toBe("3.0.0"); + expect((choices as Array<{ value: string }>)[0].value).toBe("3.0.0"); }); it("should limit to 20 recent versions", async () => { @@ -211,9 +224,9 @@ describe("promptForVersion", () => { const choices = call.choices; // Should include the tagged legacy version - expect(choices.some((c: { value: string }) => c.value === "0.1.0")).toBe( - true, - ); + expect( + (choices as Array<{ value: string }>).some((c) => c.value === "0.1.0"), + ).toBe(true); }); it("should handle empty tags", async () => { @@ -225,9 +238,9 @@ describe("promptForVersion", () => { const choices = call.choices; // All choices should just be version numbers without tags - expect(choices.every((c: { name: string }) => !c.name.includes("("))).toBe( - true, - ); + expect( + (choices as Array<{ name: string }>).every((c) => !c.name.includes("(")), + ).toBe(true); }); it("should handle multiple tags pointing to same version", async () => { @@ -243,8 +256,8 @@ describe("promptForVersion", () => { const choices = call.choices; // Should still work, version appears once with one of the tags - const v3Choices = choices.filter( - (c: { value: string }) => c.value === "3.0.0", + const v3Choices = (choices as Array<{ value: string }>).filter( + (c) => c.value === "3.0.0", ); expect(v3Choices.length).toBe(1); }); @@ -268,12 +281,13 @@ describe("promptForVersion", () => { const choices = call.choices; // Tagged versions should come first - const taggedIndices = choices - .map((c: { name: string }, i: number) => (c.name.includes("(") ? i : -1)) - .filter((i: number) => i >= 0); - const untaggedIndices = choices - .map((c: { name: string }, i: number) => (!c.name.includes("(") ? i : -1)) - .filter((i: number) => i >= 0); + const typedChoices = choices as Array<{ name: string }>; + const taggedIndices = typedChoices + .map((c, i) => (c.name.includes("(") ? i : -1)) + .filter((i) => i >= 0); + const untaggedIndices = typedChoices + .map((c, i) => (!c.name.includes("(") ? i : -1)) + .filter((i) => i >= 0); // All tagged versions should have lower indices than untagged if (taggedIndices.length > 0 && untaggedIndices.length > 0) { From 03ee92429e733bb60b1a66cad86f77ff9da82845 Mon Sep 17 00:00:00 2001 From: Arno V Date: Fri, 30 Jan 2026 20:57:49 -0500 Subject: [PATCH 09/10] Disable kleur when --boring flag is set Set kleur.enabled = !flags?.boring so ANSI colors are disabled when the --boring flag is used. This change is applied before Logger is constructed to ensure color output is consistently turned off for subsequent logging and CLI output. --- packages/bundlecheck/src/bundlecheck.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/bundlecheck/src/bundlecheck.ts b/packages/bundlecheck/src/bundlecheck.ts index 68d343d8..fdff5d5e 100644 --- a/packages/bundlecheck/src/bundlecheck.ts +++ b/packages/bundlecheck/src/bundlecheck.ts @@ -21,6 +21,9 @@ import { fetchPackageVersions, promptForVersion } from "./versions.js"; const flags = config.flags; const parameters = config.parameters; +// Disable kleur colors when --boring flag is set +kleur.enabled = !flags?.boring; + const log = new Logger({ boring: flags?.boring, }); From 8fc58e3235e29cb2b83d656f40bb232f821a6d3e Mon Sep 17 00:00:00 2001 From: Arno V Date: Fri, 30 Jan 2026 20:59:07 -0500 Subject: [PATCH 10/10] Always show +/- sign for trend sizes Ensure gzip and raw trend values always display an explicit sign. Change uses >= 0 to prefix a '+' for zero/positive values and formats negative values with '-' plus the absolute byte value to avoid double-negative output and keep sign presentation consistent for both gzip and raw sizes. --- packages/bundlecheck/src/trend.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/bundlecheck/src/trend.ts b/packages/bundlecheck/src/trend.ts index f00f1221..5fa771a3 100644 --- a/packages/bundlecheck/src/trend.ts +++ b/packages/bundlecheck/src/trend.ts @@ -160,9 +160,13 @@ export function renderTrendGraph( const rawPercent = ((rawDiff / oldestResult.rawSize) * 100).toFixed(1); const gzipTrend = - gzipDiff > 0 ? `+${formatBytes(gzipDiff)}` : formatBytes(gzipDiff); + gzipDiff >= 0 + ? `+${formatBytes(gzipDiff)}` + : `-${formatBytes(Math.abs(gzipDiff))}`; const rawTrend = - rawDiff > 0 ? `+${formatBytes(rawDiff)}` : formatBytes(rawDiff); + rawDiff >= 0 + ? `+${formatBytes(rawDiff)}` + : `-${formatBytes(Math.abs(rawDiff))}`; lines.push("─".repeat(60)); lines.push(