From 88bdf450f6f45f96c29af0eee9350015778b1fec Mon Sep 17 00:00:00 2001 From: Arno V Date: Sat, 31 Jan 2026 15:57:03 -0500 Subject: [PATCH 1/5] feat(bundlecheck): Add library API to access data programmatically Expose a programmatic Library API for @node-cli/bundlecheck: adds src/index.ts which implements getBundleStats, getBundleTrend, getPackageVersions and re-exports utilities (formatBytes, parsePackageSpecifier, clearCache, getCacheCount). Update package.json to include main and correct exports to point at dist/index.js. Expand README with detailed Programmatic Usage and API reference. Add comprehensive unit tests at src/__tests__/index.test.ts that mock underlying modules and cover caching, options, trend analysis, and utility helpers. --- packages/bundlecheck/README.md | 202 ++++++ packages/bundlecheck/package.json | 3 +- .../bundlecheck/src/__tests__/index.test.ts | 576 ++++++++++++++++++ packages/bundlecheck/src/index.ts | 455 ++++++++++++++ 4 files changed, 1235 insertions(+), 1 deletion(-) create mode 100644 packages/bundlecheck/src/__tests__/index.test.ts create mode 100644 packages/bundlecheck/src/index.ts diff --git a/packages/bundlecheck/README.md b/packages/bundlecheck/README.md index 5fc10b27..e22827f0 100644 --- a/packages/bundlecheck/README.md +++ b/packages/bundlecheck/README.md @@ -17,6 +17,7 @@ A CLI tool to check the bundle size of npm packages, similar to [bundlephobia.co - Custom npm registry support (for private registries) - Fast bundling using esbuild (with pnpm support) - **Local caching** for faster repeated lookups (SQLite-based, max 100 entries) +- **Library API** for programmatic usage in Node.js applications ## Installation @@ -152,6 +153,207 @@ bundlecheck express # auto-detects "node" from package.json engines bundlecheck lodash --force ``` +## Programmatic Usage (Library API) + +In addition to the CLI, `@node-cli/bundlecheck` can be used as a library in your Node.js code. + +### Installation + +```bash +npm install @node-cli/bundlecheck +``` + +### Basic Usage + +```js +import { getBundleStats, getBundleTrend, getPackageVersions } from "@node-cli/bundlecheck"; + +// Get bundle stats for a single package +const stats = await getBundleStats({ + package: "@mantine/core@7.0.0", +}); +console.log(stats); +// { +// packageName: "@mantine/core", +// packageVersion: "7.0.0", +// rawSize: 234567, +// gzipSize: 45678, +// rawSizeFormatted: "229.07 kB", +// gzipSizeFormatted: "44.61 kB", +// exports: [], +// externals: ["react", "react-dom"], +// dependencies: ["@floating-ui/react", ...], +// platform: "browser", +// gzipLevel: 5, +// fromCache: false +// } + +// Check specific exports (tree-shaking) +const buttonStats = await getBundleStats({ + package: "@mantine/core", + exports: ["Button", "Input"], +}); +console.log(buttonStats.gzipSizeFormatted); // "12.3 kB" + +// Get bundle size trend across versions +const trend = await getBundleTrend({ + package: "@mantine/core", + versionCount: 5, +}); +console.log(trend); +// { +// packageName: "@mantine/core", +// versions: [ +// { version: "7.0.0", rawSize: 234567, gzipSize: 45678, rawSizeFormatted: "229.07 kB", ... }, +// { version: "6.0.0", rawSize: 220000, gzipSize: 42000, ... }, +// ... +// ], +// change: { +// fromVersion: "5.0.0", +// toVersion: "7.0.0", +// rawDiff: 14567, +// rawPercent: 6.6, +// rawDiffFormatted: "+14.23 kB", +// gzipDiff: 3678, +// gzipPercent: 8.7, +// gzipDiffFormatted: "+3.59 kB" +// } +// } + +// Get available versions for a package +const versions = await getPackageVersions({ + package: "@mantine/core", +}); +console.log(versions.tags.latest); // "7.0.0" +console.log(versions.versions.slice(0, 5)); // ["7.0.0", "6.0.21", "6.0.20", ...] +``` + +### API Reference + +#### `getBundleStats(options)` + +Get bundle size statistics for a single package. + +**Options:** + +| Option | Type | Default | Description | +| ------------ | ----------------------------- | ---------- | -------------------------------------------------------- | +| `package` | `string` | (required) | Package name with optional version (e.g., `lodash@4.17.0`) | +| `exports` | `string[]` | `undefined`| Specific exports to measure (tree-shaking) | +| `external` | `string[]` | `undefined`| Additional packages to mark as external | +| `noExternal` | `boolean` | `false` | Bundle everything including default externals | +| `gzipLevel` | `number` | `5` | Gzip compression level (1-9) | +| `registry` | `string` | `undefined`| Custom npm registry URL | +| `platform` | `"browser" \| "node" \| "auto"` | `"auto"` | Target platform | +| `force` | `boolean` | `false` | Bypass cache | + +**Returns:** `Promise` + +```ts +type BundleStats = { + packageName: string; // Display name (may include subpath) + packageVersion: string; // Resolved version + exports: string[]; // Exports analyzed + rawSize: number; // Raw size in bytes + gzipSize: number | null; // Gzip size in bytes (null for node platform) + gzipLevel: number; // Compression level used + externals: string[]; // External packages + dependencies: string[]; // Package dependencies + platform: "browser" | "node"; + rawSizeFormatted: string; // Human-readable (e.g., "45.2 kB") + gzipSizeFormatted: string | null; + fromCache: boolean; // Whether result was from cache +}; +``` + +#### `getBundleTrend(options)` + +Get bundle size trend across multiple versions. + +**Options:** + +| Option | Type | Default | Description | +| -------------- | ----------------------------- | ---------- | -------------------------------------------------------- | +| `package` | `string` | (required) | Package name (version ignored if provided) | +| `versionCount` | `number` | `5` | Number of versions to analyze | +| `exports` | `string[]` | `undefined`| Specific exports to measure | +| `external` | `string[]` | `undefined`| Additional packages to mark as external | +| `noExternal` | `boolean` | `false` | Bundle everything including default externals | +| `gzipLevel` | `number` | `5` | Gzip compression level (1-9) | +| `registry` | `string` | `undefined`| Custom npm registry URL | +| `platform` | `"browser" \| "node" \| "auto"` | `"auto"` | Target platform | +| `force` | `boolean` | `false` | Bypass cache | + +**Returns:** `Promise` + +```ts +type BundleTrend = { + packageName: string; + versions: TrendVersionResult[]; // Results for each version (newest first) + change: TrendChange | null; // Change between oldest and newest +}; + +type TrendVersionResult = { + version: string; + rawSize: number; + gzipSize: number | null; + rawSizeFormatted: string; + gzipSizeFormatted: string | null; +}; + +type TrendChange = { + fromVersion: string; + toVersion: string; + rawDiff: number; // Positive = increase, negative = decrease + rawPercent: number; + rawDiffFormatted: string; // e.g., "+5.2 kB" or "-1.3 kB" + gzipDiff: number | null; + gzipPercent: number | null; + gzipDiffFormatted: string | null; +}; +``` + +#### `getPackageVersions(options)` + +Get available versions for an npm package. + +**Options:** + +| Option | Type | Default | Description | +| ---------- | -------- | ---------- | ---------------------------- | +| `package` | `string` | (required) | Package name | +| `registry` | `string` | `undefined`| Custom npm registry URL | + +**Returns:** `Promise` + +```ts +type PackageVersions = { + versions: string[]; // All versions (newest first) + tags: Record; // Dist tags (e.g., { latest: "7.0.0" }) +}; +``` + +### Utility Functions + +```js +import { formatBytes, parsePackageSpecifier, clearCache, getCacheCount } from "@node-cli/bundlecheck"; + +// Format bytes to human-readable string +formatBytes(1024); // "1 kB" +formatBytes(1536); // "1.5 kB" + +// Parse a package specifier +parsePackageSpecifier("@scope/name@1.0.0"); +// { name: "@scope/name", version: "1.0.0" } + +parsePackageSpecifier("@scope/name/subpath@2.0.0"); +// { name: "@scope/name", version: "2.0.0", subpath: "subpath" } + +// Cache management +getCacheCount(); // Returns number of cached entries +clearCache(); // Clears all cached results +``` + ## How It Works 1. Creates a temporary directory diff --git a/packages/bundlecheck/package.json b/packages/bundlecheck/package.json index ad71f4ec..116c8683 100644 --- a/packages/bundlecheck/package.json +++ b/packages/bundlecheck/package.json @@ -6,10 +6,11 @@ "description": "CLI tool to check the bundle size of npm packages (like bundlephobia)", "type": "module", "types": "./dist/index.d.ts", + "main": "./dist/index.js", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/bundler.js" + "import": "./dist/index.js" } }, "bin": "dist/bundlecheck.js", diff --git a/packages/bundlecheck/src/__tests__/index.test.ts b/packages/bundlecheck/src/__tests__/index.test.ts new file mode 100644 index 00000000..967b29fe --- /dev/null +++ b/packages/bundlecheck/src/__tests__/index.test.ts @@ -0,0 +1,576 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the underlying modules before importing +vi.mock("../bundler.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + checkBundleSize: vi.fn(), + }; +}); + +vi.mock("../cache.js", () => ({ + getCachedResult: vi.fn(), + setCachedResult: vi.fn(), + normalizeCacheKey: vi.fn((opts) => ({ + packageName: opts.packageName, + version: opts.version, + exports: (opts.exports || []).join(","), + platform: opts.platform ?? "auto", + gzipLevel: opts.gzipLevel, + externals: opts.externals.join(","), + noExternal: opts.noExternal ? 1 : 0, + })), + clearCache: vi.fn(), + getCacheCount: vi.fn(), +})); + +vi.mock("../versions.js", () => ({ + fetchPackageVersions: vi.fn(), +})); + +vi.mock("../trend.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + analyzeTrend: vi.fn(), + }; +}); + +import { + checkBundleSize, + formatBytes, + parsePackageSpecifier, +} from "../bundler.js"; +import { + clearCache, + getCacheCount, + getCachedResult, + setCachedResult, +} from "../cache.js"; +import { + getBundleStats, + getBundleTrend, + getPackageVersions, +} from "../index.js"; +import { analyzeTrend } from "../trend.js"; +import { fetchPackageVersions } from "../versions.js"; + +describe("Library API", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getBundleStats", () => { + const mockBundleResult = { + packageName: "@mantine/core", + packageVersion: "7.0.0", + exports: [], + rawSize: 234567, + gzipSize: 45678, + gzipLevel: 5, + externals: ["react", "react-dom"], + dependencies: ["@floating-ui/react", "clsx"], + platform: "browser" as const, + }; + + const mockVersionsResponse = { + versions: ["7.0.0", "6.0.0"], + tags: { latest: "7.0.0" }, + }; + + it("should return bundle stats for a package", async () => { + vi.mocked(getCachedResult).mockReturnValue(null); + vi.mocked(checkBundleSize).mockResolvedValue(mockBundleResult); + + const result = await getBundleStats({ + package: "@mantine/core@7.0.0", + }); + + expect(result).toMatchObject({ + packageName: "@mantine/core", + packageVersion: "7.0.0", + rawSize: 234567, + gzipSize: 45678, + platform: "browser", + fromCache: false, + }); + expect(result.rawSizeFormatted).toBe("229.07 kB"); + expect(result.gzipSizeFormatted).toBe("44.61 kB"); + }); + + it("should use cached result when available", async () => { + vi.mocked(getCachedResult).mockReturnValue(mockBundleResult); + + const result = await getBundleStats({ + package: "@mantine/core@7.0.0", + }); + + expect(result.fromCache).toBe(true); + expect(checkBundleSize).not.toHaveBeenCalled(); + }); + + it("should bypass cache when force is true", async () => { + vi.mocked(getCachedResult).mockReturnValue(mockBundleResult); + vi.mocked(checkBundleSize).mockResolvedValue(mockBundleResult); + + const result = await getBundleStats({ + package: "@mantine/core@7.0.0", + force: true, + }); + + expect(result.fromCache).toBe(false); + expect(checkBundleSize).toHaveBeenCalled(); + }); + + it("should pass exports to checkBundleSize", async () => { + vi.mocked(fetchPackageVersions).mockResolvedValue(mockVersionsResponse); + vi.mocked(getCachedResult).mockReturnValue(null); + vi.mocked(checkBundleSize).mockResolvedValue({ + ...mockBundleResult, + exports: ["Button", "Input"], + }); + + await getBundleStats({ + package: "@mantine/core", + exports: ["Button", "Input"], + }); + + expect(checkBundleSize).toHaveBeenCalledWith( + expect.objectContaining({ + exports: ["Button", "Input"], + }), + ); + }); + + it("should pass external packages", async () => { + vi.mocked(fetchPackageVersions).mockResolvedValue(mockVersionsResponse); + vi.mocked(getCachedResult).mockReturnValue(null); + vi.mocked(checkBundleSize).mockResolvedValue(mockBundleResult); + + await getBundleStats({ + package: "@mantine/core", + external: ["lodash", "dayjs"], + }); + + expect(checkBundleSize).toHaveBeenCalledWith( + expect.objectContaining({ + additionalExternals: ["lodash", "dayjs"], + }), + ); + }); + + it("should pass noExternal option", async () => { + vi.mocked(fetchPackageVersions).mockResolvedValue(mockVersionsResponse); + vi.mocked(getCachedResult).mockReturnValue(null); + vi.mocked(checkBundleSize).mockResolvedValue({ + ...mockBundleResult, + externals: [], + }); + + await getBundleStats({ + package: "@mantine/core", + noExternal: true, + }); + + expect(checkBundleSize).toHaveBeenCalledWith( + expect.objectContaining({ + noExternal: true, + }), + ); + }); + + it("should pass custom gzipLevel", async () => { + vi.mocked(fetchPackageVersions).mockResolvedValue(mockVersionsResponse); + vi.mocked(getCachedResult).mockReturnValue(null); + vi.mocked(checkBundleSize).mockResolvedValue({ + ...mockBundleResult, + gzipLevel: 9, + }); + + await getBundleStats({ + package: "@mantine/core", + gzipLevel: 9, + }); + + expect(checkBundleSize).toHaveBeenCalledWith( + expect.objectContaining({ + gzipLevel: 9, + }), + ); + }); + + it("should pass custom registry", async () => { + vi.mocked(fetchPackageVersions).mockResolvedValue(mockVersionsResponse); + vi.mocked(getCachedResult).mockReturnValue(null); + vi.mocked(checkBundleSize).mockResolvedValue(mockBundleResult); + + await getBundleStats({ + package: "@mantine/core", + registry: "https://npm.mycompany.com", + }); + + expect(checkBundleSize).toHaveBeenCalledWith( + expect.objectContaining({ + registry: "https://npm.mycompany.com", + }), + ); + }); + + it("should handle platform option 'node'", async () => { + vi.mocked(fetchPackageVersions).mockResolvedValue({ + versions: ["4.18.0"], + tags: { latest: "4.18.0" }, + }); + vi.mocked(getCachedResult).mockReturnValue(null); + vi.mocked(checkBundleSize).mockResolvedValue({ + ...mockBundleResult, + packageName: "express", + platform: "node", + gzipSize: null, + }); + + const result = await getBundleStats({ + package: "express", + platform: "node", + }); + + expect(result.platform).toBe("node"); + expect(result.gzipSize).toBeNull(); + expect(result.gzipSizeFormatted).toBeNull(); + }); + + it("should handle platform option 'auto'", async () => { + vi.mocked(fetchPackageVersions).mockResolvedValue(mockVersionsResponse); + vi.mocked(getCachedResult).mockReturnValue(null); + vi.mocked(checkBundleSize).mockResolvedValue(mockBundleResult); + + await getBundleStats({ + package: "@mantine/core", + platform: "auto", + }); + + expect(checkBundleSize).toHaveBeenCalledWith( + expect.objectContaining({ + platform: undefined, + }), + ); + }); + + it("should resolve 'latest' version before caching", async () => { + vi.mocked(fetchPackageVersions).mockResolvedValue({ + versions: ["7.0.0", "6.0.0"], + tags: { latest: "7.0.0" }, + }); + vi.mocked(getCachedResult).mockReturnValue(null); + vi.mocked(checkBundleSize).mockResolvedValue(mockBundleResult); + + await getBundleStats({ + package: "@mantine/core", + }); + + expect(fetchPackageVersions).toHaveBeenCalled(); + }); + + it("should store result in cache after fetching", async () => { + vi.mocked(getCachedResult).mockReturnValue(null); + vi.mocked(checkBundleSize).mockResolvedValue(mockBundleResult); + + await getBundleStats({ + package: "@mantine/core@7.0.0", + }); + + expect(setCachedResult).toHaveBeenCalled(); + }); + }); + + describe("getBundleTrend", () => { + const mockTrendResults = [ + { version: "7.0.0", rawSize: 234567, gzipSize: 45678 }, + { version: "6.0.0", rawSize: 220000, gzipSize: 42000 }, + { version: "5.0.0", rawSize: 200000, gzipSize: 38000 }, + ]; + + beforeEach(() => { + vi.mocked(fetchPackageVersions).mockResolvedValue({ + versions: ["7.0.0", "6.0.0", "5.0.0", "4.0.0", "3.0.0"], + tags: { latest: "7.0.0" }, + }); + vi.mocked(analyzeTrend).mockResolvedValue(mockTrendResults); + }); + + it("should return trend data for a package", async () => { + const result = await getBundleTrend({ + package: "@mantine/core", + }); + + expect(result.packageName).toBe("@mantine/core"); + expect(result.versions).toHaveLength(3); + expect(result.versions[0].version).toBe("7.0.0"); + }); + + it("should include formatted sizes in version results", async () => { + const result = await getBundleTrend({ + package: "@mantine/core", + }); + + expect(result.versions[0].rawSizeFormatted).toBe("229.07 kB"); + expect(result.versions[0].gzipSizeFormatted).toBe("44.61 kB"); + }); + + it("should calculate change between oldest and newest", async () => { + const result = await getBundleTrend({ + package: "@mantine/core", + }); + + expect(result.change).not.toBeNull(); + expect(result.change?.fromVersion).toBe("5.0.0"); + expect(result.change?.toVersion).toBe("7.0.0"); + expect(result.change?.rawDiff).toBe(34567); + expect(result.change?.rawPercent).toBeCloseTo(17.3, 0); + expect(result.change?.rawDiffFormatted).toBe("+33.76 kB"); + }); + + it("should calculate gzip change", async () => { + const result = await getBundleTrend({ + package: "@mantine/core", + }); + + expect(result.change?.gzipDiff).toBe(7678); + expect(result.change?.gzipPercent).toBeCloseTo(20.2, 0); + expect(result.change?.gzipDiffFormatted).toBe("+7.5 kB"); + }); + + it("should handle negative change (size decrease)", async () => { + vi.mocked(analyzeTrend).mockResolvedValue([ + { version: "2.0.0", rawSize: 50000, gzipSize: 10000 }, + { version: "1.0.0", rawSize: 100000, gzipSize: 20000 }, + ]); + + const result = await getBundleTrend({ + package: "some-package", + }); + + expect(result.change?.rawDiff).toBe(-50000); + expect(result.change?.rawDiffFormatted).toBe("-48.83 kB"); + }); + + it("should return null change for single version", async () => { + vi.mocked(analyzeTrend).mockResolvedValue([ + { version: "1.0.0", rawSize: 100000, gzipSize: 20000 }, + ]); + + const result = await getBundleTrend({ + package: "some-package", + }); + + expect(result.change).toBeNull(); + }); + + it("should pass versionCount to selectTrendVersions", async () => { + await getBundleTrend({ + package: "@mantine/core", + versionCount: 10, + }); + + expect(analyzeTrend).toHaveBeenCalledWith( + expect.objectContaining({ + versions: expect.any(Array), + }), + ); + }); + + it("should pass exports to analyzeTrend", async () => { + await getBundleTrend({ + package: "@mantine/core", + exports: ["Button"], + }); + + expect(analyzeTrend).toHaveBeenCalledWith( + expect.objectContaining({ + exports: ["Button"], + }), + ); + }); + + it("should pass external packages to analyzeTrend", async () => { + await getBundleTrend({ + package: "@mantine/core", + external: ["lodash"], + }); + + expect(analyzeTrend).toHaveBeenCalledWith( + expect.objectContaining({ + additionalExternals: ["lodash"], + }), + ); + }); + + it("should pass force option to analyzeTrend", async () => { + await getBundleTrend({ + package: "@mantine/core", + force: true, + }); + + expect(analyzeTrend).toHaveBeenCalledWith( + expect.objectContaining({ + force: true, + }), + ); + }); + + it("should throw error when no versions found", async () => { + vi.mocked(fetchPackageVersions).mockResolvedValue({ + versions: [], + tags: {}, + }); + + await expect( + getBundleTrend({ + package: "non-existent-package", + }), + ).rejects.toThrow("No versions found for package"); + }); + + it("should throw error when analyzeTrend returns empty results", async () => { + vi.mocked(analyzeTrend).mockResolvedValue([]); + + await expect( + getBundleTrend({ + package: "@mantine/core", + }), + ).rejects.toThrow("Failed to analyze any versions"); + }); + + it("should handle null gzipSize for node platform", async () => { + vi.mocked(analyzeTrend).mockResolvedValue([ + { version: "2.0.0", rawSize: 100000, gzipSize: null }, + { version: "1.0.0", rawSize: 80000, gzipSize: null }, + ]); + + const result = await getBundleTrend({ + package: "express", + platform: "node", + }); + + expect(result.versions[0].gzipSize).toBeNull(); + expect(result.versions[0].gzipSizeFormatted).toBeNull(); + expect(result.change?.gzipDiff).toBeNull(); + expect(result.change?.gzipPercent).toBeNull(); + expect(result.change?.gzipDiffFormatted).toBeNull(); + }); + + it("should handle subpath in package name", async () => { + await getBundleTrend({ + package: "@mantine/core/Button", + }); + + expect(analyzeTrend).toHaveBeenCalledWith( + expect.objectContaining({ + packageName: "@mantine/core/Button", + }), + ); + }); + + it("should suppress logging with boring: true", async () => { + await getBundleTrend({ + package: "@mantine/core", + }); + + expect(analyzeTrend).toHaveBeenCalledWith( + expect.objectContaining({ + boring: true, + }), + ); + }); + }); + + describe("getPackageVersions", () => { + it("should return versions and tags for a package", async () => { + vi.mocked(fetchPackageVersions).mockResolvedValue({ + versions: ["7.0.0", "6.0.0", "5.0.0"], + tags: { latest: "7.0.0", next: "8.0.0-beta.1" }, + }); + + const result = await getPackageVersions({ + package: "@mantine/core", + }); + + expect(result.versions).toEqual(["7.0.0", "6.0.0", "5.0.0"]); + expect(result.tags).toEqual({ latest: "7.0.0", next: "8.0.0-beta.1" }); + }); + + it("should pass registry option", async () => { + vi.mocked(fetchPackageVersions).mockResolvedValue({ + versions: ["1.0.0"], + tags: { latest: "1.0.0" }, + }); + + await getPackageVersions({ + package: "@myorg/private-pkg", + registry: "https://npm.mycompany.com", + }); + + expect(fetchPackageVersions).toHaveBeenCalledWith({ + packageName: "@myorg/private-pkg", + registry: "https://npm.mycompany.com", + }); + }); + }); + + describe("Re-exported utilities", () => { + describe("formatBytes", () => { + it("should format bytes correctly", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(500)).toBe("500 B"); + expect(formatBytes(1024)).toBe("1 kB"); + expect(formatBytes(1536)).toBe("1.5 kB"); + expect(formatBytes(1048576)).toBe("1 MB"); + }); + }); + + describe("parsePackageSpecifier", () => { + it("should parse package specifiers correctly", () => { + expect(parsePackageSpecifier("lodash")).toEqual({ + name: "lodash", + version: "latest", + }); + + expect(parsePackageSpecifier("lodash@4.17.21")).toEqual({ + name: "lodash", + version: "4.17.21", + }); + + expect(parsePackageSpecifier("@scope/package@1.0.0")).toEqual({ + name: "@scope/package", + version: "1.0.0", + }); + + expect(parsePackageSpecifier("@scope/package/subpath@2.0.0")).toEqual({ + name: "@scope/package", + version: "2.0.0", + subpath: "subpath", + }); + }); + }); + + describe("clearCache", () => { + it("should call the underlying clearCache function", () => { + clearCache(); + expect(clearCache).toHaveBeenCalled(); + }); + }); + + describe("getCacheCount", () => { + it("should call the underlying getCacheCount function", () => { + vi.mocked(getCacheCount).mockReturnValue(42); + const count = getCacheCount(); + expect(count).toBe(42); + }); + }); + }); +}); diff --git a/packages/bundlecheck/src/index.ts b/packages/bundlecheck/src/index.ts new file mode 100644 index 00000000..ce9f3b6f --- /dev/null +++ b/packages/bundlecheck/src/index.ts @@ -0,0 +1,455 @@ +/** + * @node-cli/bundlecheck - Library API + * + * Programmatic interface for analyzing npm package bundle sizes. + */ + +import { + type BundleResult, + checkBundleSize, + formatBytes, + getExternals, + parsePackageSpecifier, +} from "./bundler.js"; +import { + getCachedResult, + normalizeCacheKey, + setCachedResult, +} from "./cache.js"; +import { normalizePlatform, TREND_VERSION_COUNT } from "./defaults.js"; +import { analyzeTrend, selectTrendVersions } from "./trend.js"; +import { fetchPackageVersions as fetchVersions } from "./versions.js"; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Options for getting bundle stats of a single package + */ +export type GetBundleStatsOptions = { + /** Package name with optional version (e.g., "@mantine/core" or "@mantine/core@7.0.0") */ + package: string; + /** Specific exports to measure (e.g., ["Button", "Input"]) */ + exports?: string[]; + /** Additional packages to mark as external (not bundled) */ + external?: string[]; + /** Bundle everything including default externals (react, react-dom) */ + noExternal?: boolean; + /** Gzip compression level (1-9, default 5) */ + gzipLevel?: number; + /** Custom npm registry URL */ + registry?: string; + /** Target platform: "browser", "node", or "auto" (default: "auto" - auto-detect from package.json) */ + platform?: "browser" | "node" | "auto"; + /** Bypass cache and force re-analysis */ + force?: boolean; +}; + +/** + * Result from getBundleStats + */ +export type BundleStats = { + /** Display name of the package (may include subpath) */ + packageName: string; + /** Resolved package version */ + packageVersion: string; + /** Exports that were analyzed */ + exports: string[]; + /** Raw (minified) bundle size in bytes */ + rawSize: number; + /** Gzipped bundle size in bytes (null for node platform) */ + gzipSize: number | null; + /** Gzip compression level used */ + gzipLevel: number; + /** Packages marked as external (not included in bundle) */ + externals: string[]; + /** Package dependencies */ + dependencies: string[]; + /** Target platform used for bundling */ + platform: "browser" | "node"; + /** Human-readable raw size (e.g., "45.2 kB") */ + rawSizeFormatted: string; + /** Human-readable gzip size (e.g., "12.3 kB") or null */ + gzipSizeFormatted: string | null; + /** Whether the result was retrieved from cache */ + fromCache: boolean; +}; + +/** + * Options for getting bundle size trend across versions + */ +export type GetBundleTrendOptions = { + /** Package name (e.g., "@mantine/core") - version is ignored if provided */ + package: string; + /** Number of versions to analyze (default 5) */ + versionCount?: number; + /** Specific exports to measure (e.g., ["Button", "Input"]) */ + exports?: string[]; + /** Additional packages to mark as external (not bundled) */ + external?: string[]; + /** Bundle everything including default externals (react, react-dom) */ + noExternal?: boolean; + /** Gzip compression level (1-9, default 5) */ + gzipLevel?: number; + /** Custom npm registry URL */ + registry?: string; + /** Target platform: "browser", "node", or "auto" (default: "auto" - auto-detect from package.json) */ + platform?: "browser" | "node" | "auto"; + /** Bypass cache and force re-analysis */ + force?: boolean; +}; + +/** + * Single version result in trend analysis + */ +export type TrendVersionResult = { + /** Package version */ + version: string; + /** Raw (minified) bundle size in bytes */ + rawSize: number; + /** Gzipped bundle size in bytes (null for node platform) */ + gzipSize: number | null; + /** Human-readable raw size (e.g., "45.2 kB") */ + rawSizeFormatted: string; + /** Human-readable gzip size (e.g., "12.3 kB") or null */ + gzipSizeFormatted: string | null; +}; + +/** + * Size change information between oldest and newest versions + */ +export type TrendChange = { + /** Oldest version analyzed */ + fromVersion: string; + /** Newest version analyzed */ + toVersion: string; + /** Raw size difference in bytes (positive = increase, negative = decrease) */ + rawDiff: number; + /** Raw size percentage change */ + rawPercent: number; + /** Human-readable raw size change (e.g., "+5.2 kB" or "-1.3 kB") */ + rawDiffFormatted: string; + /** Gzip size difference in bytes (null if not applicable) */ + gzipDiff: number | null; + /** Gzip size percentage change (null if not applicable) */ + gzipPercent: number | null; + /** Human-readable gzip size change (e.g., "+1.5 kB" or "-0.8 kB") or null */ + gzipDiffFormatted: string | null; +}; + +/** + * Result from getBundleTrend + */ +export type BundleTrend = { + /** Package name */ + packageName: string; + /** Results for each version analyzed (newest first) */ + versions: TrendVersionResult[]; + /** Size change between oldest and newest versions (null if only one version) */ + change: TrendChange | null; +}; + +/** + * Options for fetching package versions + */ +export type GetPackageVersionsOptions = { + /** Package name (e.g., "@mantine/core") */ + package: string; + /** Custom npm registry URL */ + registry?: string; +}; + +/** + * Result from getPackageVersions + */ +export type PackageVersions = { + /** All available versions (sorted newest first) */ + versions: string[]; + /** Distribution tags (e.g., { latest: "7.0.0", next: "8.0.0-beta.1" }) */ + tags: Record; +}; + +// ============================================================================= +// Library Functions +// ============================================================================= + +/** + * Get bundle size statistics for an npm package. + * + * @example + * ```js + * import { getBundleStats } from "@node-cli/bundlecheck"; + * + * const stats = await getBundleStats({ + * package: "@mantine/core@7.0.0", + * exports: ["Button", "Input"], + * }); + * + * console.log(stats.gzipSizeFormatted); // "12.3 kB" + * ``` + */ +export async function getBundleStats( + options: GetBundleStatsOptions, +): Promise { + const { + package: packageName, + exports: exportsList, + external: additionalExternals, + noExternal, + gzipLevel = 5, + registry, + platform: platformOption = "auto", + force = false, + } = options; + + // Normalize platform + const platform = normalizePlatform( + platformOption === "auto" ? undefined : platformOption, + ); + + // Parse package specifier + const { name: baseName, version: requestedVersion } = + parsePackageSpecifier(packageName); + + // Resolve "latest" to actual version for cache key + let resolvedVersion = requestedVersion; + if (requestedVersion === "latest") { + const { tags } = await fetchVersions({ + packageName: baseName, + registry, + }); + resolvedVersion = tags.latest || requestedVersion; + } + + // Compute externals for cache key + const externals = getExternals(baseName, additionalExternals, noExternal); + + // Build cache key + const cacheKey = normalizeCacheKey({ + packageName: baseName, + version: resolvedVersion, + exports: exportsList, + platform, + gzipLevel, + externals, + noExternal: noExternal ?? false, + }); + + // Check cache (unless force is set) + if (!force) { + const cached = getCachedResult(cacheKey); + if (cached) { + return formatBundleStats(cached, true); + } + } + + // Perform the analysis + const result = await checkBundleSize({ + packageName, + exports: exportsList, + additionalExternals, + noExternal, + gzipLevel, + registry, + platform, + }); + + // Store in cache + setCachedResult(cacheKey, result); + + return formatBundleStats(result, false); +} + +/** + * Get bundle size trend across multiple versions of a package. + * + * @example + * ```js + * import { getBundleTrend } from "@node-cli/bundlecheck"; + * + * const trend = await getBundleTrend({ + * package: "@mantine/core", + * versionCount: 5, + * }); + * + * console.log(trend.change?.rawDiffFormatted); // "+5.2 kB" + * ``` + */ +export async function getBundleTrend( + options: GetBundleTrendOptions, +): Promise { + const { + package: packageName, + versionCount = TREND_VERSION_COUNT, + exports: exportsList, + external: additionalExternals, + noExternal, + gzipLevel, + registry, + platform: platformOption = "auto", + force = false, + } = options; + + // Normalize platform + const platform = normalizePlatform( + platformOption === "auto" ? undefined : platformOption, + ); + + // Parse package name (ignore version if provided) + const { name: baseName, subpath } = parsePackageSpecifier(packageName); + const fullPackagePath = subpath ? `${baseName}/${subpath}` : baseName; + + // Fetch available versions + const { versions } = await fetchVersions({ + packageName: baseName, + registry, + }); + + if (versions.length === 0) { + throw new Error(`No versions found for package: ${baseName}`); + } + + // Select versions for trend + const trendVersions = selectTrendVersions(versions, versionCount); + + // Analyze all versions (silently - no console output) + const results = await analyzeTrend({ + packageName: fullPackagePath, + versions: trendVersions, + exports: exportsList, + additionalExternals, + noExternal, + gzipLevel, + boring: true, // Suppress logging + registry, + platform, + force, + }); + + if (results.length === 0) { + throw new Error(`Failed to analyze any versions for package: ${baseName}`); + } + + // Format results + const formattedVersions: TrendVersionResult[] = results.map((r) => ({ + version: r.version, + rawSize: r.rawSize, + gzipSize: r.gzipSize, + rawSizeFormatted: formatBytes(r.rawSize), + gzipSizeFormatted: r.gzipSize !== null ? formatBytes(r.gzipSize) : null, + })); + + // Calculate change between oldest and newest + let change: TrendChange | null = null; + if (results.length > 1) { + const newest = results[0]; + const oldest = results[results.length - 1]; + + const rawDiff = newest.rawSize - oldest.rawSize; + const rawPercent = (rawDiff / oldest.rawSize) * 100; + + let gzipDiff: number | null = null; + let gzipPercent: number | null = null; + let gzipDiffFormatted: string | null = null; + + if (newest.gzipSize !== null && oldest.gzipSize !== null) { + gzipDiff = newest.gzipSize - oldest.gzipSize; + gzipPercent = (gzipDiff / oldest.gzipSize) * 100; + gzipDiffFormatted = + gzipDiff >= 0 + ? `+${formatBytes(gzipDiff)}` + : `-${formatBytes(Math.abs(gzipDiff))}`; + } + + change = { + fromVersion: oldest.version, + toVersion: newest.version, + rawDiff, + rawPercent: Number.parseFloat(rawPercent.toFixed(1)), + rawDiffFormatted: + rawDiff >= 0 + ? `+${formatBytes(rawDiff)}` + : `-${formatBytes(Math.abs(rawDiff))}`, + gzipDiff, + gzipPercent: + gzipPercent !== null ? Number.parseFloat(gzipPercent.toFixed(1)) : null, + gzipDiffFormatted, + }; + } + + return { + packageName: fullPackagePath, + versions: formattedVersions, + change, + }; +} + +/** + * Get available versions for an npm package. + * + * @example + * ```js + * import { getPackageVersions } from "@node-cli/bundlecheck"; + * + * const { versions, tags } = await getPackageVersions({ + * package: "@mantine/core", + * }); + * + * console.log(tags.latest); // "7.0.0" + * ``` + */ +export async function getPackageVersions( + options: GetPackageVersionsOptions, +): Promise { + const { package: packageName, registry } = options; + + const result = await fetchVersions({ + packageName, + registry, + }); + + return { + versions: result.versions, + tags: result.tags, + }; +} + +// ============================================================================= +// Re-exports for advanced usage +// ============================================================================= + +/** Format bytes to human-readable string (e.g., 1024 → "1 kB") */ +/** Parse a package specifier (e.g., "@scope/name@1.0.0" → { name, version, subpath }) */ +export { formatBytes, parsePackageSpecifier } from "./bundler.js"; +/** Clear the bundle cache */ +/** Get the number of cached entries */ +export { clearCache, getCacheCount } from "./cache.js"; + +// ============================================================================= +// Internal Helpers +// ============================================================================= + +/** + * Format a BundleResult into a BundleStats object + */ +function formatBundleStats( + result: BundleResult, + fromCache: boolean, +): BundleStats { + return { + packageName: result.packageName, + packageVersion: result.packageVersion, + exports: result.exports, + rawSize: result.rawSize, + gzipSize: result.gzipSize, + gzipLevel: result.gzipLevel, + externals: result.externals, + dependencies: result.dependencies, + platform: result.platform, + rawSizeFormatted: formatBytes(result.rawSize), + gzipSizeFormatted: + result.gzipSize !== null ? formatBytes(result.gzipSize) : null, + fromCache, + }; +} From c560d213bada652c38f08aaeec403aaf94241ef3 Mon Sep 17 00:00:00 2001 From: Arno V Date: Sat, 31 Jan 2026 16:10:28 -0500 Subject: [PATCH 2/5] Polish comments and JSDoc across bundlecheck Standardize and polish inline comments and JSDoc-style documentation across the bundlecheck package. Files updated: bundlecheck.ts, bundler.ts, cache.ts, defaults.ts, index.ts, trend.ts, versions.ts. Changes are primarily non-functional: add punctuation, clarify intent in comments, expand docblocks, improve error/message text clarity, and make cache/platform/registry notes more explicit. No behavioral changes intended. --- packages/bundlecheck/src/bundlecheck.ts | 42 ++-- packages/bundlecheck/src/bundler.ts | 186 ++++++++------- packages/bundlecheck/src/cache.ts | 77 +++--- packages/bundlecheck/src/defaults.ts | 20 +- packages/bundlecheck/src/index.ts | 300 +++++++++++++++++------- packages/bundlecheck/src/trend.ts | 80 ++++--- packages/bundlecheck/src/versions.ts | 23 +- 7 files changed, 450 insertions(+), 278 deletions(-) diff --git a/packages/bundlecheck/src/bundlecheck.ts b/packages/bundlecheck/src/bundlecheck.ts index 3e638a96..71e083d3 100644 --- a/packages/bundlecheck/src/bundlecheck.ts +++ b/packages/bundlecheck/src/bundlecheck.ts @@ -27,7 +27,7 @@ import { fetchPackageVersions, promptForVersion } from "./versions.js"; const flags = config.flags; const parameters = config.parameters; -// Disable kleur colors when --boring flag is set +// Disable kleur colors when --boring flag is set. kleur.enabled = !flags?.boring; const log = new Logger({ @@ -35,7 +35,7 @@ const log = new Logger({ }); /** - * Display bundle result in a formatted box + * Display bundle result in a formatted box. */ function displayResult( result: { @@ -93,7 +93,7 @@ async function main() { process.exit(1); } - // Parse additional externals if provided (comma-separated) + // Parse additional externals if provided (comma-separated). let additionalExternals: string[] | undefined; if (flags?.external) { additionalExternals = flags.external @@ -102,7 +102,7 @@ async function main() { .filter(Boolean); } - // Parse exports if provided (comma-separated) + // Parse exports if provided (comma-separated). let exports: string[] | undefined; const exportsArg = parameters?.["1"]; if (exportsArg) { @@ -112,11 +112,13 @@ async function main() { .filter(Boolean); } - // Normalize platform from flag (handles aliases like "web" → "browser") + // Normalize platform from flag (handles aliases like "web" → "browser"). const platform = normalizePlatform(flags?.platform); - // If --trend flag is set, show bundle size trend across versions - // --trend alone uses default (5), --trend N uses N versions + /** + * 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); @@ -127,7 +129,7 @@ async function main() { try { const { name, subpath } = parsePackageSpecifier(packageName); - // Construct the full package path including subpath if present + // Construct the full package path including subpath if present. const fullPackagePath = subpath ? `${name}/${subpath}` : name; log.info(`\nFetching available versions for ${name}...`); @@ -142,7 +144,7 @@ async function main() { process.exit(1); } - // Select versions for trend + // Select versions for trend. const trendVersions = selectTrendVersions(versions, versionCount); log.info( @@ -168,7 +170,7 @@ async function main() { process.exit(1); } - // Render and display the trend graph + // Render and display the trend graph. const graphLines = renderTrendGraph( fullPackagePath, results, @@ -187,7 +189,7 @@ async function main() { } } - // If --versions flag is set, fetch and prompt for version selection + // If --versions flag is set, fetch and prompt for version selection. if (flags?.versions) { try { const { name, subpath } = parsePackageSpecifier(packageName); @@ -204,7 +206,7 @@ async function main() { } const selectedVersion = await promptForVersion(name, versions, tags); - // Rebuild specifier preserving any subpath + // Rebuild specifier preserving any subpath. packageName = subpath ? `${name}/${subpath}@${selectedVersion}` : `${name}@${selectedVersion}`; @@ -223,11 +225,11 @@ async function main() { } try { - // Parse package specifier to get name and version + // Parse package specifier to get name and version. const { name: baseName, version: requestedVersion } = parsePackageSpecifier(packageName); - // Resolve "latest" to actual version for cache key + // Resolve "latest" to actual version for cache key. let resolvedVersion = requestedVersion; if (requestedVersion === "latest") { const { tags } = await fetchPackageVersions({ @@ -237,15 +239,17 @@ async function main() { resolvedVersion = tags.latest || requestedVersion; } - // Compute externals for cache key (same logic as bundler) + // Compute externals for cache key (same logic as bundler). const externals = getExternals( baseName, additionalExternals, flags?.noExternal, ); - // Build cache key - // Note: platform can be undefined (auto-detect), which is stored as "auto" in cache + /** + * Build cache key. + * NOTE: platform can be undefined (auto-detect), which is stored as "auto" in cache. + */ const cacheKey = normalizeCacheKey({ packageName: baseName, version: resolvedVersion, @@ -256,7 +260,7 @@ async function main() { noExternal: flags?.noExternal ?? false, }); - // Check cache (unless --force flag is set) + // Check cache (unless --force flag is set). if (!flags?.force) { const cached = getCachedResult(cacheKey); if (cached) { @@ -278,7 +282,7 @@ async function main() { platform, }); - // Store result in cache + // Store result in cache. setCachedResult(cacheKey, result); displayResult(result, platform === undefined); diff --git a/packages/bundlecheck/src/bundler.ts b/packages/bundlecheck/src/bundler.ts index 5da79876..2ca2e2b6 100644 --- a/packages/bundlecheck/src/bundler.ts +++ b/packages/bundlecheck/src/bundler.ts @@ -10,7 +10,7 @@ import { DEFAULT_EXTERNALS } from "./defaults.js"; const gzipAsync = promisify(zlib.gzip); /** - * Escape special regex characters in a string + * Escape special regex characters in a string. */ function escapeRegExp(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -23,7 +23,7 @@ export type ParsedPackage = { }; /** - * Parse a package specifier to extract name, version, and subpath + * Parse a package specifier to extract name, version, and subpath. * Handles: * - @scope/package@1.0.0 * - @scope/package/subpath@1.0.0 @@ -36,34 +36,36 @@ export function parsePackageSpecifier(specifier: string): ParsedPackage { // Handle scoped packages (@scope/name...) if (workingSpec.startsWith("@")) { - // Find the second @ which would separate version + // 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 + /** + * 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 + // 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 + // No subpath: @scope/name. return { name: workingSpec, version }; } - // Handle non-scoped packages (name@version or name/subpath@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 + // Check for subpath in non-scoped packages. const slashIndex = workingSpec.indexOf("/"); if (slashIndex !== -1) { const name = workingSpec.substring(0, slashIndex); @@ -81,7 +83,9 @@ export type BundleOptions = { noExternal?: boolean; gzipLevel?: number; registry?: string; - /** Target platform. If undefined, auto-detects from package.json engines */ + /** + * Target platform. If undefined, auto-detects from package.json engines. + */ platform?: "browser" | "node"; }; @@ -90,7 +94,9 @@ export type BundleResult = { packageVersion: string; exports: string[]; rawSize: number; - /** Gzip size in bytes, or null for node platform (gzip not applicable) */ + /** + * Gzip size in bytes, or null for node platform (gzip not applicable). + */ gzipSize: number | null; gzipLevel: number; externals: string[]; @@ -99,7 +105,7 @@ export type BundleResult = { }; /** - * Format bytes to human-readable string + * Format bytes to human-readable string. */ export function formatBytes(bytes: number): string { if (bytes === 0) { @@ -112,7 +118,7 @@ export function formatBytes(bytes: number): string { } /** - * Create a temporary directory for bundling + * Create a temporary directory for bundling. */ function createTempDir(): string { const tmpDir = path.join(os.tmpdir(), `bundlecheck-${Date.now()}`); @@ -121,18 +127,18 @@ function createTempDir(): string { } /** - * Clean up temporary directory + * Clean up temporary directory. */ function cleanupTempDir(tmpDir: string): void { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { - // Ignore cleanup errors + // Ignore cleanup errors. } } /** - * Check if pnpm is available + * Check if pnpm is available. */ function isPnpmAvailable(): boolean { try { @@ -143,17 +149,17 @@ function isPnpmAvailable(): boolean { } } -// Cache the result of pnpm availability check +// Cache the result of pnpm availability check. let usePnpm: boolean | null = null; /** - * Validate and sanitize a registry URL to prevent command injection + * Validate and sanitize a registry URL to prevent command injection. * @param registry - The registry URL to validate * @returns The sanitized URL or undefined if invalid * @throws Error if the URL is invalid or contains potentially malicious characters */ function validateRegistryUrl(registry: string): string { - // Parse as URL to validate format + // Parse as URL to validate format. let url: URL; try { url = new URL(registry); @@ -163,19 +169,19 @@ function validateRegistryUrl(registry: string): string { ); } - // Only allow http and https protocols + // Only allow http and https protocols. if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error( `Invalid registry URL protocol: ${url.protocol}. Only http: and https: are allowed`, ); } - // Return the sanitized URL (URL constructor normalizes it) + // Return the sanitized URL (URL constructor normalizes it). return url.toString(); } /** - * Get the install command (pnpm preferred, npm fallback) + * Get the install command (pnpm preferred, npm fallback). * @param registry - Optional custom npm registry URL */ function getInstallCommand(registry?: string): string { @@ -185,9 +191,9 @@ function getInstallCommand(registry?: string): string { let registryArg = ""; if (registry) { - // Validate and sanitize the registry URL to prevent command injection + // Validate and sanitize the registry URL to prevent command injection. const sanitizedRegistry = validateRegistryUrl(registry); - // Quote the URL to handle any special characters safely + // Quote the URL to handle any special characters safely. registryArg = ` --registry "${sanitizedRegistry}"`; } @@ -206,15 +212,15 @@ export type EntryContentOptions = { }; /** - * Generate the entry file content based on package, subpath, and exports + * 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 we have exports mapped to different subpaths. if (exportToSubpath && exportToSubpath.size > 0) { - // Group exports by subpath + // Group exports by subpath. const subpathToExports = new Map(); for (const [exportName, sp] of exportToSubpath) { const existing = subpathToExports.get(sp) || []; @@ -222,7 +228,7 @@ function generateEntryContent(options: EntryContentOptions): string { subpathToExports.set(sp, existing); } - // Generate imports for each subpath + // Generate imports for each subpath. const lines: string[] = []; const allExportNames: string[] = []; @@ -237,21 +243,21 @@ function generateEntryContent(options: EntryContentOptions): string { return lines.join("\n") + "\n"; } - // If we have specific exports to import + // If we have specific exports to import. if (exports && exports.length > 0) { - // Determine the import path + // 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 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 package has subpath exports only (no main entry), import all subpaths. if (allSubpaths && allSubpaths.length > 0) { const imports = allSubpaths .map( @@ -262,12 +268,12 @@ function generateEntryContent(options: EntryContentOptions): string { return imports + "\n"; } - // Default: import everything from main entry + // Default: import everything from main entry. return `import * as pkg from "${packageName}";\nexport default pkg;\n`; } /** - * Get externals list based on options + * Get externals list based on options. */ export function getExternals( packageName: string, @@ -278,17 +284,17 @@ export function getExternals( return []; } - // Start with default externals (react, react-dom) + // 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 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 + // Add any additional externals. if (externals && externals.length > 0) { result = [...new Set([...result, ...externals])]; } @@ -311,11 +317,12 @@ export type PackageInfo = { }; /** - * Get version, dependencies, peer dependencies, and exports from an installed package + * 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 + // Handle scoped packages - the package name in node_modules. const pkgJsonPath = path.join( tmpDir, "node_modules", @@ -325,7 +332,7 @@ function getPackageInfo(tmpDir: string, packageName: string): PackageInfo { if (fs.existsSync(pkgJsonPath)) { const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")); - // Check if package has a main entry point + // Check if package has a main entry point. const hasMainEntry = Boolean( pkgJson.main || pkgJson.module || @@ -344,7 +351,7 @@ function getPackageInfo(tmpDir: string, packageName: string): PackageInfo { }; } } catch { - // Ignore errors reading package info + // Ignore errors reading package info. } return { version: "unknown", @@ -357,8 +364,8 @@ function getPackageInfo(tmpDir: string, packageName: string): PackageInfo { } /** - * Extract subpath export names from package exports field - * Returns array of subpaths like ["header", "body", "datagrid"] + * Extract subpath export names from package exports field Returns array of + * subpaths like ["header", "body", "datagrid"]. */ function getSubpathExports(exports: PackageExports | null): string[] { if (!exports) { @@ -367,11 +374,11 @@ function getSubpathExports(exports: PackageExports | null): string[] { const subpaths: string[] = []; for (const key of Object.keys(exports)) { - // Skip the main entry point and package.json + // Skip the main entry point and package.json. if (key === "." || key === "./package.json") { continue; } - // Remove leading "./" to get subpath name + // Remove leading "./" to get subpath name. if (key.startsWith("./")) { subpaths.push(key.substring(2)); } @@ -380,18 +387,18 @@ function getSubpathExports(exports: PackageExports | null): string[] { } /** - * Result of finding subpaths for exports + * Result of finding subpaths for exports. */ type SubpathMapping = { - // Single subpath if all exports are from the same subpath + // Single subpath if all exports are from the same subpath. singleSubpath?: string; - // Map of export name to subpath for multiple subpaths + // 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 + * Find which subpath(s) export the given component names Reads type definition + * files or JS files to find the exports. */ function findSubpathsForExports( tmpDir: string, @@ -403,15 +410,15 @@ function findSubpathsForExports( const exportToSubpath = new Map(); for (const [subpathKey, subpathValue] of Object.entries(exports)) { - // Skip main entry and package.json + // Skip main entry and package.json. if (subpathKey === "." || subpathKey === "./package.json") { continue; } - // Get the types or import path + // Get the types or import path. let filePath: string | undefined; if (typeof subpathValue === "object" && subpathValue !== null) { - // Prefer types file for more accurate export detection + // Prefer types file for more accurate export detection. filePath = subpathValue.types || subpathValue.import; } else if (typeof subpathValue === "string") { filePath = subpathValue; @@ -421,7 +428,7 @@ function findSubpathsForExports( continue; } - // Resolve the file path + // Resolve the file path. const fullPath = path.join(packageDir, filePath); try { @@ -431,17 +438,17 @@ function findSubpathsForExports( ? subpathKey.substring(2) : subpathKey; - // Check each component name + // Check each component name. for (const name of componentNames) { - // Skip if already found + // Skip if already found. if (exportToSubpath.has(name)) { continue; } - // Escape regex special characters in the name to prevent injection + // Escape regex special characters in the name to prevent injection. const escapedName = escapeRegExp(name); - // Look for various export patterns + // Look for various export patterns. const patterns = [ new RegExp(`export\\s*\\{[^}]*\\b${escapedName}\\b[^}]*\\}`, "m"), new RegExp( @@ -460,27 +467,27 @@ function findSubpathsForExports( } } } catch { - // Ignore read errors, continue to next subpath + // Ignore read errors, continue to next subpath. } } - // Check if all exports were found + // 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 + // 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 + // Multiple subpaths needed. return { exportToSubpath }; } /** - * Check the bundle size of an npm package + * Check the bundle size of an npm package. */ export async function checkBundleSize( options: BundleOptions, @@ -495,7 +502,7 @@ export async function checkBundleSize( platform: explicitPlatform, } = options; - // Parse the package specifier to extract name, version, and subpath + // Parse the package specifier to extract name, version, and subpath. const { name: packageName, version: requestedVersion, @@ -505,7 +512,7 @@ export async function checkBundleSize( const tmpDir = createTempDir(); try { - // Create initial package.json + // Create initial package.json. const packageJson: { name: string; version: string; @@ -525,39 +532,45 @@ export async function checkBundleSize( JSON.stringify(packageJson, null, 2), ); - // Install the main package (try pnpm first, fallback to npm) + // Install the main package (try pnpm first, fallback to npm). const installCmd = getInstallCommand(registry); execSync(installCmd, { cwd: tmpDir, stdio: "pipe", }); - // Get package info (version, dependencies, peer dependencies, exports, engines) + /** + * Get package info (version, dependencies, peer dependencies, exports, + * engines). + */ const pkgInfo = getPackageInfo(tmpDir, packageName); const peerDepKeys = Object.keys(pkgInfo.peerDependencies); - // Determine platform: use explicit value if provided, otherwise auto-detect from engines + /** + * Determine platform: use explicit value if provided, otherwise auto-detect + * from engines. + */ let platform: "browser" | "node" = "browser"; if (explicitPlatform) { platform = explicitPlatform; } else if (pkgInfo.engines?.node && !pkgInfo.engines?.browser) { - // Package specifies node engine without browser - likely a Node.js package + // Package specifies node engine without browser - likely a Node.js package. platform = "node"; } - // Collect all dependency names (prod + peer) + // 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 + // Add peer dependencies to package.json. for (const dep of peerDepKeys) { - // Use the version range from peer dependencies + // Use the version range from peer dependencies. packageJson.dependencies[dep] = pkgInfo.peerDependencies[dep]; } - // Update package.json and reinstall + // Update package.json and reinstall. fs.writeFileSync( path.join(tmpDir, "package.json"), JSON.stringify(packageJson, null, 2), @@ -569,14 +582,17 @@ export async function checkBundleSize( }); } - // Determine if we need to use all subpath exports or find the right subpath(s) + /** + * 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) + // User specified exports but no subpath - try to find the right subpath(s). const mapping = findSubpathsForExports( tmpDir, packageName, @@ -585,21 +601,21 @@ export async function checkBundleSize( ); if (mapping.singleSubpath) { - // All exports from the same subpath + // All exports from the same subpath. resolvedSubpath = mapping.singleSubpath; } else if (mapping.exportToSubpath) { - // Exports from multiple subpaths + // Exports from multiple subpaths. exportToSubpath = mapping.exportToSubpath; } } - // If still no subpath resolved and no mapping, bundle all subpaths + // If still no subpath resolved and no mapping, bundle all subpaths. if (!resolvedSubpath && !exportToSubpath) { allSubpaths = getSubpathExports(pkgInfo.exports); } } - // Create entry file with appropriate content + // Create entry file with appropriate content. const entryContent = generateEntryContent({ packageName, subpath: resolvedSubpath, @@ -610,14 +626,14 @@ export async function checkBundleSize( const entryFile = path.join(tmpDir, "entry.js"); fs.writeFileSync(entryFile, entryContent); - // Get externals + // Get externals. const externals = getExternals( packageName, additionalExternals, noExternal, ); - // Bundle with esbuild + // Bundle with esbuild. const result = await esbuild.build({ entryPoints: [entryFile], bundle: true, @@ -631,11 +647,11 @@ export async function checkBundleSize( metafile: true, }); - // Get raw size + // Get raw size. const bundleContent = result.outputFiles[0].contents; const rawSize = bundleContent.length; - // Gzip the bundle (only for browser platform - not relevant for Node.js) + // Gzip the bundle (only for browser platform - not relevant for Node.js). let gzipSize: number | null = null; if (platform === "browser") { const gzipped = await gzipAsync(Buffer.from(bundleContent), { @@ -644,12 +660,12 @@ export async function checkBundleSize( gzipSize = gzipped.length; } - // Determine the display name + // Determine the display name. let displayName = packageName; if (resolvedSubpath) { displayName = `${packageName}/${resolvedSubpath}`; } else if (exportToSubpath && exportToSubpath.size > 0) { - // Multiple subpaths - show them all + // Multiple subpaths - show them all. const uniqueSubpaths = [...new Set(exportToSubpath.values())].sort(); displayName = `${packageName}/{${uniqueSubpaths.join(", ")}}`; } diff --git a/packages/bundlecheck/src/cache.ts b/packages/bundlecheck/src/cache.ts index e0d7d46d..77be6dd8 100644 --- a/packages/bundlecheck/src/cache.ts +++ b/packages/bundlecheck/src/cache.ts @@ -7,14 +7,14 @@ import type { BundleResult } from "./bundler.js"; const MAX_CACHE_ENTRIES = 100; /** - * Get the cache directory path (computed lazily to support testing) + * Get the cache directory path (computed lazily to support testing). */ function getCacheDir(): string { return path.join(os.homedir(), ".bundlecheck"); } /** - * Get the cache database path (computed lazily to support testing) + * Get the cache database path (computed lazily to support testing). */ function getCacheDbPath(): string { return path.join(getCacheDir(), "cache.db"); @@ -24,7 +24,9 @@ export type CacheKey = { packageName: string; version: string; exports: string; - /** Platform: "browser", "node", or "auto" (for auto-detection) */ + /** + * Platform: "browser", "node", or "auto" (for auto-detection). + */ platform: "browser" | "node" | "auto"; gzipLevel: number; externals: string; @@ -52,14 +54,14 @@ type CacheRow = { let db: Database.Database | null = null; /** - * Initialize the cache database with proper pragmas and schema + * Initialize the cache database with proper pragmas and schema. */ export function initCache(): Database.Database { if (db) { return db; } - // Ensure cache directory exists + // Ensure cache directory exists. const cacheDir = getCacheDir(); if (!fs.existsSync(cacheDir)) { fs.mkdirSync(cacheDir, { recursive: true }); @@ -67,11 +69,11 @@ export function initCache(): Database.Database { db = new Database(getCacheDbPath()); - // Apply pragmas for better CLI performance + // Apply pragmas for better CLI performance. db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); - // Create table if not exists + // Create table if not exists. db.exec(` CREATE TABLE IF NOT EXISTS bundle_cache ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -97,18 +99,21 @@ export function initCache(): Database.Database { } /** - * Normalize cache key options to a consistent format - * Sorts exports and externals to ensure consistent cache hits + * Normalize cache key options to a consistent format Sorts exports and + * externals to ensure consistent cache hits. * - * Note: platform can be "browser", "node", or "auto" (when user doesn't specify). + * NOTE: platform can be "browser", "node", or "auto" (when user doesn't specify). * Using "auto" in the cache key ensures that auto-detected results are cached * separately from explicitly specified platform results. + * */ export function normalizeCacheKey(options: { packageName: string; version: string; exports?: string[]; - /** Platform: "browser", "node", or undefined (treated as "auto") */ + /** + * Platform: "browser", "node", or undefined (treated as "auto"). + */ platform: "browser" | "node" | undefined; gzipLevel: number; externals: string[]; @@ -118,7 +123,10 @@ export function normalizeCacheKey(options: { packageName: options.packageName, version: options.version, exports: (options.exports || []).slice().sort().join(","), - // Use "auto" when platform is undefined to distinguish from explicit platform + /** + * Use "auto" when platform is undefined to distinguish from explicit + * platform. + */ platform: options.platform ?? "auto", gzipLevel: options.gzipLevel, externals: options.externals.slice().sort().join(","), @@ -127,8 +135,8 @@ export function normalizeCacheKey(options: { } /** - * Get a cached result if it exists - * Returns null if not found or if an error occurs (graceful degradation) + * Get a cached result if it exists Returns null if not found or if an error + * occurs (graceful degradation). */ export function getCachedResult(key: CacheKey): CachedBundleResult | null { try { @@ -161,16 +169,16 @@ export function getCachedResult(key: CacheKey): CachedBundleResult | null { return null; } - // Parse dependencies with error handling for corrupted data + // Parse dependencies with error handling for corrupted data. let dependencies: string[] = []; try { dependencies = JSON.parse(row.dependencies) as string[]; } catch { - // If JSON is corrupted, use empty array + // If JSON is corrupted, use empty array. dependencies = []; } - // Convert row to BundleResult + // Convert row to BundleResult. return { packageName: row.display_name, packageVersion: row.version, @@ -183,21 +191,26 @@ export function getCachedResult(key: CacheKey): CachedBundleResult | null { platform: row.platform as "browser" | "node", }; } catch { - // On any database error, return null (graceful degradation - continue without cache) + /** + * On any database error, return null (graceful degradation - continue without + * cache). + */ return null; } } /** - * Store a result in the cache - * Silently fails on errors (graceful degradation - CLI continues without caching) + * Store a result in the cache Silently fails on errors (graceful degradation - + * CLI continues without caching). */ export function setCachedResult(key: CacheKey, result: BundleResult): void { try { const database = initCache(); - // Use INSERT OR REPLACE to handle duplicates - // Note: This updates created_at on replace, making this LRU-style eviction + /** + * Use INSERT OR REPLACE to handle duplicates. + * NOTE: This updates created_at on replace, making this LRU-style eviction. + */ const stmt = database.prepare(` INSERT OR REPLACE INTO bundle_cache ( package_name, version, exports, platform, gzip_level, externals, no_external, @@ -220,17 +233,17 @@ export function setCachedResult(key: CacheKey, result: BundleResult): void { Date.now(), ); - // Enforce max entries (LRU-style eviction based on created_at) + // Enforce max entries (LRU-style eviction based on created_at). enforceMaxEntries(database); } catch { - // On any database error, silently continue (graceful degradation) + // On any database error, silently continue (graceful degradation). } } /** - * Enforce maximum cache entries by removing entries with oldest timestamps - * Uses LRU-style eviction: entries that are re-checked get updated timestamps - * and move to the end of the eviction queue + * Enforce maximum cache entries by removing entries with oldest timestamps Uses + * LRU-style eviction: entries that are re-checked get updated timestamps and + * move to the end of the eviction queue. */ function enforceMaxEntries(database: Database.Database): void { const countResult = database @@ -255,21 +268,19 @@ function enforceMaxEntries(database: Database.Database): void { } /** - * Clear all cache entries - * Silently fails on errors + * Clear all cache entries Silently fails on errors. */ export function clearCache(): void { try { const database = initCache(); database.prepare("DELETE FROM bundle_cache").run(); } catch { - // Silently ignore errors + // Silently ignore errors. } } /** - * Get the number of cached entries - * Returns 0 on error + * Get the number of cached entries Returns 0 on error. */ export function getCacheCount(): number { try { @@ -284,7 +295,7 @@ export function getCacheCount(): number { } /** - * Close the database connection (useful for testing) + * Close the database connection (useful for testing). */ export function closeCache(): void { if (db) { diff --git a/packages/bundlecheck/src/defaults.ts b/packages/bundlecheck/src/defaults.ts index 2bc3530c..07b4c22c 100644 --- a/packages/bundlecheck/src/defaults.ts +++ b/packages/bundlecheck/src/defaults.ts @@ -12,7 +12,7 @@ export const defaultFlags = { }; /** - * Normalize platform aliases to canonical esbuild platform values + * Normalize platform aliases to canonical esbuild platform values. * - "auto" → undefined (triggers auto-detection based on package engines) * - Browser aliases: "browser", "web", "desktop", "client" → "browser" * - Node aliases: "node", "server", "nodejs", "backend" → "node" @@ -26,27 +26,27 @@ export function normalizePlatform( const normalized = platform.toLowerCase().trim(); - // Auto-detect from package.json engines + // Auto-detect from package.json engines. if (normalized === "auto") { return undefined; } - // Node aliases + // Node aliases. if (["node", "server", "nodejs", "backend"].includes(normalized)) { return "node"; } - // Browser aliases + // Browser aliases. if (["browser", "web", "desktop", "client"].includes(normalized)) { return "browser"; } - // Invalid value - will be caught by validation + // Invalid value - will be caught by validation. return normalized as "browser" | "node"; } /** - * Check if a platform value is valid (either canonical or alias) + * Check if a platform value is valid (either canonical or alias). */ export function isValidPlatform(platform: string | undefined): boolean { if (platform === undefined) { @@ -55,19 +55,19 @@ export function isValidPlatform(platform: string | undefined): boolean { const normalized = platform.toLowerCase().trim(); - // Empty string after trim is invalid + // Empty string after trim is invalid. if (normalized === "") { return false; } const validValues = [ - // Auto-detect + // Auto-detect. "auto", - // Browser + // Browser. "browser", "web", "desktop", "client", - // Node + // Node. "node", "server", "nodejs", diff --git a/packages/bundlecheck/src/index.ts b/packages/bundlecheck/src/index.ts index ce9f3b6f..9838a740 100644 --- a/packages/bundlecheck/src/index.ts +++ b/packages/bundlecheck/src/index.ts @@ -2,6 +2,7 @@ * @node-cli/bundlecheck - Library API * * Programmatic interface for analyzing npm package bundle sizes. + * */ import { @@ -20,159 +21,264 @@ import { normalizePlatform, TREND_VERSION_COUNT } from "./defaults.js"; import { analyzeTrend, selectTrendVersions } from "./trend.js"; import { fetchPackageVersions as fetchVersions } from "./versions.js"; -// ============================================================================= -// Types -// ============================================================================= +/** + * ============================================================================= + * Types + * ============================================================================= + */ /** - * Options for getting bundle stats of a single package + * Options for getting bundle stats of a single package. */ export type GetBundleStatsOptions = { - /** Package name with optional version (e.g., "@mantine/core" or "@mantine/core@7.0.0") */ + /** + * Package name with optional version (e.g., "@mantine/core" or + * "@mantine/core@7.0.0"). + */ package: string; - /** Specific exports to measure (e.g., ["Button", "Input"]) */ + /** + * Specific exports to measure (e.g., ["Button", "Input"]). + */ exports?: string[]; - /** Additional packages to mark as external (not bundled) */ + /** + * Additional packages to mark as external (not bundled). + */ external?: string[]; - /** Bundle everything including default externals (react, react-dom) */ + /** + * Bundle everything including default externals (react, react-dom). + */ noExternal?: boolean; - /** Gzip compression level (1-9, default 5) */ + /** + * Gzip compression level (1-9, default 5). + */ gzipLevel?: number; - /** Custom npm registry URL */ + /** + * Custom npm registry URL. + */ registry?: string; - /** Target platform: "browser", "node", or "auto" (default: "auto" - auto-detect from package.json) */ + /** + * Target platform: "browser", "node", or "auto" (default: "auto" - auto-detect + * from package.json). + */ platform?: "browser" | "node" | "auto"; - /** Bypass cache and force re-analysis */ + /** + * Bypass cache and force re-analysis. + */ force?: boolean; }; /** - * Result from getBundleStats + * Result from getBundleStats. */ export type BundleStats = { - /** Display name of the package (may include subpath) */ + /** + * Display name of the package (may include subpath). + */ packageName: string; - /** Resolved package version */ + /** + * Resolved package version. + */ packageVersion: string; - /** Exports that were analyzed */ + /** + * Exports that were analyzed. + */ exports: string[]; - /** Raw (minified) bundle size in bytes */ + /** + * Raw (minified) bundle size in bytes. + */ rawSize: number; - /** Gzipped bundle size in bytes (null for node platform) */ + /** + * Gzipped bundle size in bytes (null for node platform). + */ gzipSize: number | null; - /** Gzip compression level used */ + /** + * Gzip compression level used. + */ gzipLevel: number; - /** Packages marked as external (not included in bundle) */ + /** + * Packages marked as external (not included in bundle). + */ externals: string[]; - /** Package dependencies */ + /** + * Package dependencies. + */ dependencies: string[]; - /** Target platform used for bundling */ + /** + * Target platform used for bundling. + */ platform: "browser" | "node"; - /** Human-readable raw size (e.g., "45.2 kB") */ + /** + * Human-readable raw size (e.g., "45.2 kB"). + */ rawSizeFormatted: string; - /** Human-readable gzip size (e.g., "12.3 kB") or null */ + /** + * Human-readable gzip size (e.g., "12.3 kB") or null. + */ gzipSizeFormatted: string | null; - /** Whether the result was retrieved from cache */ + /** + * Whether the result was retrieved from cache. + */ fromCache: boolean; }; /** - * Options for getting bundle size trend across versions + * Options for getting bundle size trend across versions. */ export type GetBundleTrendOptions = { - /** Package name (e.g., "@mantine/core") - version is ignored if provided */ + /** + * Package name (e.g., "@mantine/core") - version is ignored if provided. + */ package: string; - /** Number of versions to analyze (default 5) */ + /** + * Number of versions to analyze (default 5). + */ versionCount?: number; - /** Specific exports to measure (e.g., ["Button", "Input"]) */ + /** + * Specific exports to measure (e.g., ["Button", "Input"]). + */ exports?: string[]; - /** Additional packages to mark as external (not bundled) */ + /** + * Additional packages to mark as external (not bundled). + */ external?: string[]; - /** Bundle everything including default externals (react, react-dom) */ + /** + * Bundle everything including default externals (react, react-dom). + */ noExternal?: boolean; - /** Gzip compression level (1-9, default 5) */ + /** + * Gzip compression level (1-9, default 5). + */ gzipLevel?: number; - /** Custom npm registry URL */ + /** + * Custom npm registry URL. + */ registry?: string; - /** Target platform: "browser", "node", or "auto" (default: "auto" - auto-detect from package.json) */ + /** + * Target platform: "browser", "node", or "auto" (default: "auto" - auto-detect + * from package.json). + */ platform?: "browser" | "node" | "auto"; - /** Bypass cache and force re-analysis */ + /** + * Bypass cache and force re-analysis. + */ force?: boolean; }; /** - * Single version result in trend analysis + * Single version result in trend analysis. */ export type TrendVersionResult = { - /** Package version */ + /** + * Package version. + */ version: string; - /** Raw (minified) bundle size in bytes */ + /** + * Raw (minified) bundle size in bytes. + */ rawSize: number; - /** Gzipped bundle size in bytes (null for node platform) */ + /** + * Gzipped bundle size in bytes (null for node platform). + */ gzipSize: number | null; - /** Human-readable raw size (e.g., "45.2 kB") */ + /** + * Human-readable raw size (e.g., "45.2 kB"). + */ rawSizeFormatted: string; - /** Human-readable gzip size (e.g., "12.3 kB") or null */ + /** + * Human-readable gzip size (e.g., "12.3 kB") or null. + */ gzipSizeFormatted: string | null; }; /** - * Size change information between oldest and newest versions + * Size change information between oldest and newest versions. */ export type TrendChange = { - /** Oldest version analyzed */ + /** + * Oldest version analyzed. + */ fromVersion: string; - /** Newest version analyzed */ + /** + * Newest version analyzed. + */ toVersion: string; - /** Raw size difference in bytes (positive = increase, negative = decrease) */ + /** + * Raw size difference in bytes (positive = increase, negative = decrease). + */ rawDiff: number; - /** Raw size percentage change */ + /** + * Raw size percentage change. + */ rawPercent: number; - /** Human-readable raw size change (e.g., "+5.2 kB" or "-1.3 kB") */ + /** + * Human-readable raw size change (e.g., "+5.2 kB" or "-1.3 kB"). + */ rawDiffFormatted: string; - /** Gzip size difference in bytes (null if not applicable) */ + /** + * Gzip size difference in bytes (null if not applicable). + */ gzipDiff: number | null; - /** Gzip size percentage change (null if not applicable) */ + /** + * Gzip size percentage change (null if not applicable). + */ gzipPercent: number | null; - /** Human-readable gzip size change (e.g., "+1.5 kB" or "-0.8 kB") or null */ + /** + * Human-readable gzip size change (e.g., "+1.5 kB" or "-0.8 kB") or null. + */ gzipDiffFormatted: string | null; }; /** - * Result from getBundleTrend + * Result from getBundleTrend. */ export type BundleTrend = { - /** Package name */ + /** + * Package name. + */ packageName: string; - /** Results for each version analyzed (newest first) */ + /** + * Results for each version analyzed (newest first). + */ versions: TrendVersionResult[]; - /** Size change between oldest and newest versions (null if only one version) */ + /** + * Size change between oldest and newest versions (null if only one version). + */ change: TrendChange | null; }; /** - * Options for fetching package versions + * Options for fetching package versions. */ export type GetPackageVersionsOptions = { - /** Package name (e.g., "@mantine/core") */ + /** + * Package name (e.g., "@mantine/core"). + */ package: string; - /** Custom npm registry URL */ + /** + * Custom npm registry URL. + */ registry?: string; }; /** - * Result from getPackageVersions + * Result from getPackageVersions. */ export type PackageVersions = { - /** All available versions (sorted newest first) */ + /** + * All available versions (sorted newest first). + */ versions: string[]; - /** Distribution tags (e.g., { latest: "7.0.0", next: "8.0.0-beta.1" }) */ + /** + * Distribution tags (e.g., { latest: "7.0.0", next: "8.0.0-beta.1" }). + */ tags: Record; }; -// ============================================================================= -// Library Functions -// ============================================================================= +/** + * ============================================================================= + * Library Functions + * ============================================================================= + */ /** * Get bundle size statistics for an npm package. @@ -188,6 +294,7 @@ export type PackageVersions = { * * console.log(stats.gzipSizeFormatted); // "12.3 kB" * ``` + * */ export async function getBundleStats( options: GetBundleStatsOptions, @@ -203,16 +310,16 @@ export async function getBundleStats( force = false, } = options; - // Normalize platform + // Normalize platform. const platform = normalizePlatform( platformOption === "auto" ? undefined : platformOption, ); - // Parse package specifier + // Parse package specifier. const { name: baseName, version: requestedVersion } = parsePackageSpecifier(packageName); - // Resolve "latest" to actual version for cache key + // Resolve "latest" to actual version for cache key. let resolvedVersion = requestedVersion; if (requestedVersion === "latest") { const { tags } = await fetchVersions({ @@ -222,10 +329,10 @@ export async function getBundleStats( resolvedVersion = tags.latest || requestedVersion; } - // Compute externals for cache key + // Compute externals for cache key. const externals = getExternals(baseName, additionalExternals, noExternal); - // Build cache key + // Build cache key. const cacheKey = normalizeCacheKey({ packageName: baseName, version: resolvedVersion, @@ -236,7 +343,7 @@ export async function getBundleStats( noExternal: noExternal ?? false, }); - // Check cache (unless force is set) + // Check cache (unless force is set). if (!force) { const cached = getCachedResult(cacheKey); if (cached) { @@ -244,7 +351,7 @@ export async function getBundleStats( } } - // Perform the analysis + // Perform the analysis. const result = await checkBundleSize({ packageName, exports: exportsList, @@ -255,7 +362,7 @@ export async function getBundleStats( platform, }); - // Store in cache + // Store in cache. setCachedResult(cacheKey, result); return formatBundleStats(result, false); @@ -275,6 +382,7 @@ export async function getBundleStats( * * console.log(trend.change?.rawDiffFormatted); // "+5.2 kB" * ``` + * */ export async function getBundleTrend( options: GetBundleTrendOptions, @@ -291,16 +399,16 @@ export async function getBundleTrend( force = false, } = options; - // Normalize platform + // Normalize platform. const platform = normalizePlatform( platformOption === "auto" ? undefined : platformOption, ); - // Parse package name (ignore version if provided) + // Parse package name (ignore version if provided). const { name: baseName, subpath } = parsePackageSpecifier(packageName); const fullPackagePath = subpath ? `${baseName}/${subpath}` : baseName; - // Fetch available versions + // Fetch available versions. const { versions } = await fetchVersions({ packageName: baseName, registry, @@ -310,10 +418,10 @@ export async function getBundleTrend( throw new Error(`No versions found for package: ${baseName}`); } - // Select versions for trend + // Select versions for trend. const trendVersions = selectTrendVersions(versions, versionCount); - // Analyze all versions (silently - no console output) + // Analyze all versions (silently - no console output). const results = await analyzeTrend({ packageName: fullPackagePath, versions: trendVersions, @@ -331,7 +439,7 @@ export async function getBundleTrend( throw new Error(`Failed to analyze any versions for package: ${baseName}`); } - // Format results + // Format results. const formattedVersions: TrendVersionResult[] = results.map((r) => ({ version: r.version, rawSize: r.rawSize, @@ -340,7 +448,7 @@ export async function getBundleTrend( gzipSizeFormatted: r.gzipSize !== null ? formatBytes(r.gzipSize) : null, })); - // Calculate change between oldest and newest + // Calculate change between oldest and newest. let change: TrendChange | null = null; if (results.length > 1) { const newest = results[0]; @@ -398,6 +506,7 @@ export async function getBundleTrend( * * console.log(tags.latest); // "7.0.0" * ``` + * */ export async function getPackageVersions( options: GetPackageVersionsOptions, @@ -415,23 +524,36 @@ export async function getPackageVersions( }; } -// ============================================================================= -// Re-exports for advanced usage -// ============================================================================= +/** + * ============================================================================= + * Re-exports for advanced usage + * ============================================================================= + */ -/** Format bytes to human-readable string (e.g., 1024 → "1 kB") */ -/** Parse a package specifier (e.g., "@scope/name@1.0.0" → { name, version, subpath }) */ +/** + * Format bytes to human-readable string (e.g., 1024 → "1 kB"). + */ +/** + * Parse a package specifier (e.g., "@scope/name@1.0.0" → { name, version, + * subpath }). + */ export { formatBytes, parsePackageSpecifier } from "./bundler.js"; -/** Clear the bundle cache */ -/** Get the number of cached entries */ +/** + * Clear the bundle cache. + */ +/** + * Get the number of cached entries. + */ export { clearCache, getCacheCount } from "./cache.js"; -// ============================================================================= -// Internal Helpers -// ============================================================================= +/** + * ============================================================================= + * Internal Helpers + * ============================================================================= + */ /** - * Format a BundleResult into a BundleStats object + * Format a BundleResult into a BundleStats object. */ function formatBundleStats( result: BundleResult, diff --git a/packages/bundlecheck/src/trend.ts b/packages/bundlecheck/src/trend.ts index 45b84450..c8304ce0 100644 --- a/packages/bundlecheck/src/trend.ts +++ b/packages/bundlecheck/src/trend.ts @@ -17,7 +17,9 @@ import { TREND_VERSION_COUNT } from "./defaults.js"; export type TrendResult = { version: string; rawSize: number; - /** Gzip size in bytes, or null for node platform */ + /** + * Gzip size in bytes, or null for node platform. + */ gzipSize: number | null; }; @@ -30,16 +32,20 @@ export type TrendOptions = { gzipLevel?: number; boring?: boolean; registry?: string; - /** Target platform. If undefined, auto-detects from package.json engines */ + /** + * Target platform. If undefined, auto-detects from package.json engines. + */ platform?: "browser" | "node"; - /** Bypass cache and force re-fetch/re-calculation */ + /** + * Bypass cache and force re-fetch/re-calculation. + */ force?: boolean; }; /** - * Select versions for trend analysis - * Returns the most recent stable versions (newest first) - * Filters out prerelease versions (canary, alpha, beta, rc, etc.) + * Select versions for trend analysis Returns the most recent stable versions + * (newest first) Filters out prerelease versions (canary, alpha, beta, rc, + * etc.) */ export function selectTrendVersions( allVersions: string[], @@ -51,7 +57,7 @@ export function selectTrendVersions( } /** - * Analyze bundle size trend across multiple versions + * Analyze bundle size trend across multiple versions. */ export async function analyzeTrend( options: TrendOptions, @@ -72,17 +78,19 @@ export async function analyzeTrend( const log = new Logger({ boring }); const results: TrendResult[] = []; - // Parse base package name (without version) + // Parse base package name (without version). const { name: baseName } = parsePackageSpecifier(packageName); - // Compute externals for cache key (same logic as bundler) + // Compute externals for cache key (same logic as bundler). const externals = getExternals(baseName, additionalExternals, noExternal); for (const version of versions) { const versionedPackage = `${packageName}@${version}`; - // Build cache key for this version - // Note: platform can be undefined (auto-detect), which is stored as "auto" in cache + /** + * Build cache key for this version. + * NOTE: platform can be undefined (auto-detect), which is stored as "auto" in cache. + */ const cacheKey = normalizeCacheKey({ packageName: baseName, version, @@ -93,7 +101,7 @@ export async function analyzeTrend( noExternal: noExternal ?? false, }); - // Check cache first (unless --force flag is set) + // Check cache first (unless --force flag is set). if (!force) { const cached = getCachedResult(cacheKey); if (cached) { @@ -120,7 +128,7 @@ export async function analyzeTrend( platform, }); - // Store result in cache + // Store result in cache. setCachedResult(cacheKey, result); results.push({ @@ -129,7 +137,7 @@ export async function analyzeTrend( gzipSize: result.gzipSize, }); } catch { - // Skip versions that fail to analyze + // Skip versions that fail to analyze. log.info(` Skipping ${version} (failed to analyze)`); } } @@ -138,7 +146,7 @@ export async function analyzeTrend( } /** - * Render a bar graph showing bundle size trend + * Render a bar graph showing bundle size trend. */ export function renderTrendGraph( packageName: string, @@ -151,21 +159,23 @@ export function renderTrendGraph( const lines: string[] = []; - // Color helper (respects boring flag) + // Color helper (respects boring flag). const blue = (text: string) => (boring ? text : kleur.blue(text)); - // Check if gzip data is available (null for node platform) + // Check if gzip data is available (null for node platform). const hasGzipData = results.some((r) => r.gzipSize !== null); - // Create maps from formatted string to representative value - // This ensures values that display the same get the same bar length + /** + * Create maps from formatted string to representative value This ensures + * values that display the same get the same bar length. + */ const gzipFormattedToValue = new Map(); const rawFormattedToValue = new Map(); for (const result of results) { if (hasGzipData && result.gzipSize !== null) { const gzipFormatted = formatBytes(result.gzipSize); - // Use first occurrence as representative value for each formatted string + // Use first occurrence as representative value for each formatted string. if (!gzipFormattedToValue.has(gzipFormatted)) { gzipFormattedToValue.set(gzipFormatted, result.gzipSize); } @@ -176,7 +186,7 @@ export function renderTrendGraph( } } - // Get unique representative values for min/max calculation + // Get unique representative values for min/max calculation. const uniqueGzipValues = [...gzipFormattedToValue.values()]; const uniqueRawValues = [...rawFormattedToValue.values()]; @@ -185,30 +195,30 @@ export function renderTrendGraph( const minRawSize = Math.min(...uniqueRawValues); const maxRawSize = Math.max(...uniqueRawValues); - // Find max version length for alignment + // Find max version length for alignment. const maxVersionLen = Math.max(...results.map((r) => r.version.length)); - // Bar width (characters) + // Bar width (characters). const barWidth = 30; const minBarWidth = 10; // Minimum bar length for smallest value - // Helper to calculate bar length with min-max scaling + // 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 + // 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 + // Header. lines.push(""); lines.push(`${blue("Bundle Size:")} ${packageName}`); lines.push("─".repeat(60)); lines.push(""); - // Gzip size bars (only for browser platform) + // Gzip size bars (only for browser platform). if (hasGzipData) { lines.push(blue("Gzip Size:")); for (const result of results) { @@ -216,7 +226,10 @@ export function renderTrendGraph( continue; } const sizeStr = formatBytes(result.gzipSize); - // Use representative value for this formatted string to ensure consistent bar length + /** + * Use representative value for this formatted string to ensure consistent + * bar length. + */ const representativeValue = gzipFormattedToValue.get(sizeStr) as number; const barLength = calcBarLength( representativeValue, @@ -231,11 +244,14 @@ export function renderTrendGraph( lines.push(""); } - // Raw size bars + // Raw size bars. lines.push(blue("Raw Size:")); for (const result of results) { const sizeStr = formatBytes(result.rawSize); - // Use representative value for this formatted string to ensure consistent bar length + /** + * Use representative value for this formatted string to ensure consistent bar + * length. + */ const representativeValue = rawFormattedToValue.get(sizeStr) as number; const barLength = calcBarLength( representativeValue, @@ -249,7 +265,7 @@ export function renderTrendGraph( lines.push(""); - // Summary + // Summary. const oldestResult = results[results.length - 1]; const newestResult = results[0]; @@ -266,7 +282,7 @@ export function renderTrendGraph( `Change from ${oldestResult.version} to ${newestResult.version}:`, ); - // Gzip summary (only for browser platform) + // Gzip summary (only for browser platform). if ( hasGzipData && newestResult.gzipSize !== null && diff --git a/packages/bundlecheck/src/versions.ts b/packages/bundlecheck/src/versions.ts index fe43b271..8304b186 100644 --- a/packages/bundlecheck/src/versions.ts +++ b/packages/bundlecheck/src/versions.ts @@ -9,7 +9,7 @@ export type NpmPackageInfo = { }; /** - * Fetch available versions for an npm package from the registry + * Fetch available versions for an npm package from the registry. */ type NpmRegistryResponse = { versions?: Record; @@ -24,18 +24,18 @@ export type FetchVersionsOptions = { export async function fetchPackageVersions( packageNameOrOptions: string | FetchVersionsOptions, ): Promise { - // Support both string (legacy) and options object + // Support both string (legacy) and options object. const { packageName, registry } = typeof packageNameOrOptions === "string" ? { packageName: packageNameOrOptions, registry: undefined } : packageNameOrOptions; - // Parse the package specifier to get just the name (without version) + // Parse the package specifier to get just the name (without version). const { name } = parsePackageSpecifier(packageName); - // Use custom registry or default + // Use custom registry or default. const registryUrl = registry || DEFAULT_REGISTRY; - // Ensure no trailing slash + // Ensure no trailing slash. const baseUrl = registryUrl.replace(/\/$/, ""); const url = `${baseUrl}/${name}`; const response = await fetch(url); @@ -46,7 +46,7 @@ export async function fetchPackageVersions( const data = (await response.json()) as NpmRegistryResponse; - // Get all versions sorted by semver (newest first) + // Get all versions sorted by semver (newest first). const versions = rsort(Object.keys(data.versions || {})); // Get dist-tags (latest, next, beta, etc.) @@ -56,26 +56,29 @@ export async function fetchPackageVersions( } /** - * Prompt user to select a version from available versions + * 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 + // 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 + /** + * 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 + // Sort so tagged versions appear first, then by version order. displayVersions.sort((a, b) => { const aTagged = taggedVersions.has(a); const bTagged = taggedVersions.has(b); From 43c460136bc3dd4250cae37a6901555bf1ec86b9 Mon Sep 17 00:00:00 2001 From: Arno V Date: Sat, 31 Jan 2026 16:13:54 -0500 Subject: [PATCH 3/5] Handle division-by-zero in trend percent Treat percent changes as null when the oldest size is zero. Update TrendChange types and JSDoc to allow rawPercent as number | null and clarify gzipPercent behavior. Add runtime guards in getBundleTrend to avoid dividing by zero and only call toFixed when percent is not null. Add a unit test covering the division-by-zero case and update README comments to match the new nullable semantics. Also include minor comment/export cleanup. --- packages/bundlecheck/README.md | 4 ++-- .../bundlecheck/src/__tests__/index.test.ts | 16 ++++++++++++++ packages/bundlecheck/src/index.ts | 21 ++++++++++--------- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/bundlecheck/README.md b/packages/bundlecheck/README.md index e22827f0..987ba2eb 100644 --- a/packages/bundlecheck/README.md +++ b/packages/bundlecheck/README.md @@ -305,10 +305,10 @@ type TrendChange = { fromVersion: string; toVersion: string; rawDiff: number; // Positive = increase, negative = decrease - rawPercent: number; + rawPercent: number | null; // null if oldest size was 0 rawDiffFormatted: string; // e.g., "+5.2 kB" or "-1.3 kB" gzipDiff: number | null; - gzipPercent: number | null; + gzipPercent: number | null; // null if not applicable or oldest size was 0 gzipDiffFormatted: string | null; }; ``` diff --git a/packages/bundlecheck/src/__tests__/index.test.ts b/packages/bundlecheck/src/__tests__/index.test.ts index 967b29fe..129ab4b4 100644 --- a/packages/bundlecheck/src/__tests__/index.test.ts +++ b/packages/bundlecheck/src/__tests__/index.test.ts @@ -464,6 +464,22 @@ describe("Library API", () => { expect(result.change?.gzipDiffFormatted).toBeNull(); }); + it("should handle division by zero when oldest rawSize is 0", async () => { + vi.mocked(analyzeTrend).mockResolvedValue([ + { version: "2.0.0", rawSize: 50000, gzipSize: 10000 }, + { version: "1.0.0", rawSize: 0, gzipSize: 0 }, + ]); + + const result = await getBundleTrend({ + package: "some-package", + }); + + expect(result.change?.rawDiff).toBe(50000); + expect(result.change?.rawPercent).toBeNull(); + expect(result.change?.gzipDiff).toBe(10000); + expect(result.change?.gzipPercent).toBeNull(); + }); + it("should handle subpath in package name", async () => { await getBundleTrend({ package: "@mantine/core/Button", diff --git a/packages/bundlecheck/src/index.ts b/packages/bundlecheck/src/index.ts index 9838a740..016ecf1e 100644 --- a/packages/bundlecheck/src/index.ts +++ b/packages/bundlecheck/src/index.ts @@ -207,9 +207,9 @@ export type TrendChange = { */ rawDiff: number; /** - * Raw size percentage change. + * Raw size percentage change (null if oldest size was 0). */ - rawPercent: number; + rawPercent: number | null; /** * Human-readable raw size change (e.g., "+5.2 kB" or "-1.3 kB"). */ @@ -219,7 +219,7 @@ export type TrendChange = { */ gzipDiff: number | null; /** - * Gzip size percentage change (null if not applicable). + * Gzip size percentage change (null if not applicable or oldest size was 0). */ gzipPercent: number | null; /** @@ -455,7 +455,9 @@ export async function getBundleTrend( const oldest = results[results.length - 1]; const rawDiff = newest.rawSize - oldest.rawSize; - const rawPercent = (rawDiff / oldest.rawSize) * 100; + // Handle division by zero: if oldest size is 0, percent is null + const rawPercent = + oldest.rawSize === 0 ? null : (rawDiff / oldest.rawSize) * 100; let gzipDiff: number | null = null; let gzipPercent: number | null = null; @@ -463,7 +465,9 @@ export async function getBundleTrend( if (newest.gzipSize !== null && oldest.gzipSize !== null) { gzipDiff = newest.gzipSize - oldest.gzipSize; - gzipPercent = (gzipDiff / oldest.gzipSize) * 100; + // Handle division by zero: if oldest size is 0, percent is null + gzipPercent = + oldest.gzipSize === 0 ? null : (gzipDiff / oldest.gzipSize) * 100; gzipDiffFormatted = gzipDiff >= 0 ? `+${formatBytes(gzipDiff)}` @@ -474,7 +478,8 @@ export async function getBundleTrend( fromVersion: oldest.version, toVersion: newest.version, rawDiff, - rawPercent: Number.parseFloat(rawPercent.toFixed(1)), + rawPercent: + rawPercent !== null ? Number.parseFloat(rawPercent.toFixed(1)) : null, rawDiffFormatted: rawDiff >= 0 ? `+${formatBytes(rawDiff)}` @@ -532,16 +537,12 @@ export async function getPackageVersions( /** * Format bytes to human-readable string (e.g., 1024 → "1 kB"). - */ -/** * Parse a package specifier (e.g., "@scope/name@1.0.0" → { name, version, * subpath }). */ export { formatBytes, parsePackageSpecifier } from "./bundler.js"; /** * Clear the bundle cache. - */ -/** * Get the number of cached entries. */ export { clearCache, getCacheCount } from "./cache.js"; From 66486da15d314258eec92b3d99334fc92642945c Mon Sep 17 00:00:00 2001 From: Arno V Date: Sat, 31 Jan 2026 16:15:43 -0500 Subject: [PATCH 4/5] Test: verify index re-exports delegate to cache Update unit tests to assert that index.ts re-exports delegate to the underlying cache module. Rename imported cache functions to clearCacheMock/getCacheCountMock, import the re-exported clearCache and getCacheCount from index, and adjust tests to call the re-exported functions while verifying the underlying mocked functions are invoked (and return expected values). Also reorder a checkBundleSize import and add clarifying comments in the tests. --- .../bundlecheck/src/__tests__/index.test.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/bundlecheck/src/__tests__/index.test.ts b/packages/bundlecheck/src/__tests__/index.test.ts index 129ab4b4..c65d8e24 100644 --- a/packages/bundlecheck/src/__tests__/index.test.ts +++ b/packages/bundlecheck/src/__tests__/index.test.ts @@ -37,21 +37,21 @@ vi.mock("../trend.js", async (importOriginal) => { }; }); +import { checkBundleSize } from "../bundler.js"; import { - checkBundleSize, - formatBytes, - parsePackageSpecifier, -} from "../bundler.js"; -import { - clearCache, - getCacheCount, + clearCache as clearCacheMock, + getCacheCount as getCacheCountMock, getCachedResult, setCachedResult, } from "../cache.js"; import { + clearCache, + formatBytes, getBundleStats, getBundleTrend, + getCacheCount, getPackageVersions, + parsePackageSpecifier, } from "../index.js"; import { analyzeTrend } from "../trend.js"; import { fetchPackageVersions } from "../versions.js"; @@ -575,16 +575,22 @@ describe("Library API", () => { }); describe("clearCache", () => { - it("should call the underlying clearCache function", () => { + it("should delegate to the underlying cache module", () => { + // Call the re-exported function from index.ts clearCache(); - expect(clearCache).toHaveBeenCalled(); + // Verify it delegated to the mocked cache module function + expect(clearCacheMock).toHaveBeenCalled(); }); }); describe("getCacheCount", () => { - it("should call the underlying getCacheCount function", () => { - vi.mocked(getCacheCount).mockReturnValue(42); + it("should delegate to the underlying cache module", () => { + // Setup mock return value on the underlying cache module + vi.mocked(getCacheCountMock).mockReturnValue(42); + // Call the re-exported function from index.ts const count = getCacheCount(); + // Verify it delegated and returned the expected value + expect(getCacheCountMock).toHaveBeenCalled(); expect(count).toBe(42); }); }); From cfb78a93ba54d2f9aec9d6510f82ac1a9d29ec82 Mon Sep 17 00:00:00 2001 From: Arno V Date: Sat, 31 Jan 2026 16:16:56 -0500 Subject: [PATCH 5/5] Update index.ts --- packages/bundlecheck/src/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/bundlecheck/src/index.ts b/packages/bundlecheck/src/index.ts index 016ecf1e..e8a92e25 100644 --- a/packages/bundlecheck/src/index.ts +++ b/packages/bundlecheck/src/index.ts @@ -455,7 +455,7 @@ export async function getBundleTrend( const oldest = results[results.length - 1]; const rawDiff = newest.rawSize - oldest.rawSize; - // Handle division by zero: if oldest size is 0, percent is null + // Handle division by zero: if oldest size is 0, percent is null. const rawPercent = oldest.rawSize === 0 ? null : (rawDiff / oldest.rawSize) * 100; @@ -465,7 +465,7 @@ export async function getBundleTrend( if (newest.gzipSize !== null && oldest.gzipSize !== null) { gzipDiff = newest.gzipSize - oldest.gzipSize; - // Handle division by zero: if oldest size is 0, percent is null + // Handle division by zero: if oldest size is 0, percent is null. gzipPercent = oldest.gzipSize === 0 ? null : (gzipDiff / oldest.gzipSize) * 100; gzipDiffFormatted = @@ -536,14 +536,14 @@ export async function getPackageVersions( */ /** - * Format bytes to human-readable string (e.g., 1024 → "1 kB"). - * Parse a package specifier (e.g., "@scope/name@1.0.0" → { name, version, + * - Format bytes to human-readable string (e.g., 1024 → "1 kB"). + * - Parse a package specifier (e.g., "@scope/name@1.0.0" → { name, version, * subpath }). */ export { formatBytes, parsePackageSpecifier } from "./bundler.js"; /** - * Clear the bundle cache. - * Get the number of cached entries. + * - Clear the bundle cache. + * - Get the number of cached entries. */ export { clearCache, getCacheCount } from "./cache.js";