diff --git a/packages/bundlecheck/README.md b/packages/bundlecheck/README.md index b57fa8ff..a6d2652c 100644 --- a/packages/bundlecheck/README.md +++ b/packages/bundlecheck/README.md @@ -166,7 +166,7 @@ npm install @node-cli/bundlecheck ### Basic Usage ```js -import { getBundleStats, getBundleTrend, getPackageVersions } from "@node-cli/bundlecheck"; +import { getBundleStats, getBundleTrend, getPackageVersions, getPackageExports } from "@node-cli/bundlecheck"; // Get bundle stats for a single package const stats = await getBundleStats({ @@ -226,6 +226,13 @@ const versions = await getPackageVersions({ }); console.log(versions.tags.latest); // "7.0.0" console.log(versions.versions.slice(0, 5)); // ["7.0.0", "6.0.21", "6.0.20", ...] + +// Get named exports from a package +const exports = await getPackageExports({ + package: "date-fns@3.6.0", +}); +console.log(`Found ${exports.count} exports`); +console.log(exports.exports.slice(0, 5).map(e => e.name)); // ["add", "addBusinessDays", "addDays", ...] ``` ### API Reference @@ -263,6 +270,7 @@ type BundleStats = { rawSizeFormatted: string; // Human-readable (e.g., "45.2 kB") gzipSizeFormatted: string | null; fromCache: boolean; // Whether result was from cache + namedExportCount: number; // Total named exports in package }; ``` @@ -333,6 +341,47 @@ type PackageVersions = { }; ``` +#### `getPackageExports(options)` + +Get the named exports of an npm package by analyzing its TypeScript declarations. + +**Options:** + +| Option | Type | Default | Description | +| ---------- | -------- | ---------- | ------------------------------------------------ | +| `package` | `string` | (required) | Package name with optional version | +| `registry` | `string` | `undefined`| Custom npm registry URL | + +**Returns:** `Promise` + +```ts +type PackageExports = { + packageName: string; // Package name + packageVersion: string; // Resolved version + exports: PackageExport[]; // All named exports (including types) + count: number; // Total count (including types) + runtimeExports: PackageExport[]; // Runtime exports only (no types/interfaces) + runtimeCount: number; // Count of runtime exports +}; + +type PackageExport = { + name: string; // Export name (e.g., "Button") + kind: "function" | "class" | "const" | "type" | "interface" | "enum" | "unknown"; +}; +``` + +**Example:** + +```js +const result = await getPackageExports({ + package: "@mantine/core", +}); + +console.log(result.count); // 1056 (all exports including types) +console.log(result.runtimeCount); // 365 (only importable exports) +console.log(result.runtimeExports[0]); // { name: "Accordion", kind: "unknown" } +``` + ### Utility Functions ```js diff --git a/packages/bundlecheck/src/__tests__/exports-installer.test.ts b/packages/bundlecheck/src/__tests__/exports-installer.test.ts new file mode 100644 index 00000000..4824d894 --- /dev/null +++ b/packages/bundlecheck/src/__tests__/exports-installer.test.ts @@ -0,0 +1,306 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as exports from "../exports.js"; + +// Mock modules +vi.mock("node:child_process"); +vi.mock("node:fs"); +vi.mock("node:os"); +vi.mock("../exports.js"); + +describe("exports-installer", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset module to clear cached usePnpm value + vi.resetModules(); + + // Default mocks + vi.mocked(os.tmpdir).mockReturnValue("/tmp"); + vi.mocked(fs.mkdirSync).mockReturnValue(undefined); + vi.mocked(fs.writeFileSync).mockReturnValue(undefined); + vi.mocked(fs.rmSync).mockReturnValue(undefined); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ version: "1.0.0" }), + ); + vi.mocked(execSync).mockReturnValue(Buffer.from("")); + vi.mocked(exports.getNamedExports).mockReturnValue({ + exports: [{ name: "TestExport", kind: "function" }], + count: 1, + runtimeExports: [{ name: "TestExport", kind: "function" }], + runtimeCount: 1, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("installPackage", () => { + it("should install a package and return its exports", async () => { + const { installPackage } = await import("../exports-installer.js"); + const result = await installPackage({ + packageName: "test-package", + version: "1.0.0", + }); + + expect(result.version).toBe("1.0.0"); + expect(result.exports).toEqual([ + { name: "TestExport", kind: "function" }, + ]); + expect(result.runtimeExports).toEqual([ + { name: "TestExport", kind: "function" }, + ]); + }); + + it("should create a temporary directory", async () => { + const { installPackage } = await import("../exports-installer.js"); + await installPackage({ + packageName: "test-package", + version: "1.0.0", + }); + + expect(fs.mkdirSync).toHaveBeenCalledWith( + expect.stringContaining("bundlecheck-exports-"), + { recursive: true }, + ); + }); + + it("should write package.json with the correct dependencies", async () => { + const { installPackage } = await import("../exports-installer.js"); + await installPackage({ + packageName: "@scope/test-package", + version: "2.0.0", + }); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining("package.json"), + expect.stringContaining('"@scope/test-package": "2.0.0"'), + ); + }); + + it("should use pnpm when available", async () => { + // pnpm is available (no error thrown) + vi.mocked(execSync).mockImplementation((cmd) => { + if (String(cmd) === "pnpm --version") { + return Buffer.from("8.0.0"); + } + return Buffer.from(""); + }); + + const { installPackage } = await import("../exports-installer.js"); + await installPackage({ + packageName: "test-package", + version: "1.0.0", + }); + + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining("pnpm install"), + expect.any(Object), + ); + }); + + it("should fall back to npm when pnpm is not available", async () => { + // pnpm is not available + vi.mocked(execSync).mockImplementation((cmd) => { + if (String(cmd) === "pnpm --version") { + throw new Error("pnpm not found"); + } + return Buffer.from(""); + }); + + const { installPackage } = await import("../exports-installer.js"); + await installPackage({ + packageName: "test-package", + version: "1.0.0", + }); + + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining("npm install"), + expect.any(Object), + ); + }); + + it("should include registry flag when registry is provided", async () => { + vi.mocked(execSync).mockImplementation((cmd) => { + if (String(cmd) === "pnpm --version") { + return Buffer.from("8.0.0"); + } + return Buffer.from(""); + }); + + const { installPackage } = await import("../exports-installer.js"); + await installPackage({ + packageName: "test-package", + version: "1.0.0", + registry: "https://registry.example.com", + }); + + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('--registry "https://registry.example.com/"'), + expect.any(Object), + ); + }); + + it("should reject invalid registry URLs", async () => { + const { installPackage } = await import("../exports-installer.js"); + await expect( + installPackage({ + packageName: "test-package", + version: "1.0.0", + registry: "not-a-valid-url", + }), + ).rejects.toThrow("Invalid registry URL"); + }); + + it("should reject non-http/https registry URLs", async () => { + const { installPackage } = await import("../exports-installer.js"); + await expect( + installPackage({ + packageName: "test-package", + version: "1.0.0", + registry: "ftp://registry.example.com", + }), + ).rejects.toThrow("Invalid registry URL protocol"); + }); + + it("should read the installed package version from node_modules", async () => { + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ version: "1.2.3" }), + ); + + const { installPackage } = await import("../exports-installer.js"); + const result = await installPackage({ + packageName: "test-package", + version: "^1.0.0", + }); + + expect(result.version).toBe("1.2.3"); + }); + + it("should use provided version when package.json read fails", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const { installPackage } = await import("../exports-installer.js"); + const result = await installPackage({ + packageName: "test-package", + version: "1.0.0", + }); + + expect(result.version).toBe("1.0.0"); + }); + + it("should clean up temporary directory after success", async () => { + const { installPackage } = await import("../exports-installer.js"); + await installPackage({ + packageName: "test-package", + version: "1.0.0", + }); + + expect(fs.rmSync).toHaveBeenCalledWith( + expect.stringContaining("bundlecheck-exports-"), + { recursive: true, force: true }, + ); + }); + + it("should clean up temporary directory after failure", async () => { + vi.mocked(execSync).mockImplementation((cmd) => { + if (String(cmd) === "pnpm --version") { + return Buffer.from("8.0.0"); + } + throw new Error("Install failed"); + }); + + const { installPackage } = await import("../exports-installer.js"); + await expect( + installPackage({ + packageName: "test-package", + version: "1.0.0", + }), + ).rejects.toThrow("Install failed"); + + expect(fs.rmSync).toHaveBeenCalledWith( + expect.stringContaining("bundlecheck-exports-"), + { recursive: true, force: true }, + ); + }); + + it("should ignore cleanup errors", async () => { + vi.mocked(fs.rmSync).mockImplementation(() => { + throw new Error("Cleanup failed"); + }); + + const { installPackage } = await import("../exports-installer.js"); + // Should not throw + const result = await installPackage({ + packageName: "test-package", + version: "1.0.0", + }); + + expect(result.exports).toBeDefined(); + }); + + it("should call getNamedExports with correct arguments", async () => { + const { installPackage } = await import("../exports-installer.js"); + await installPackage({ + packageName: "@scope/test-package", + version: "1.0.0", + }); + + expect(exports.getNamedExports).toHaveBeenCalledWith( + expect.stringContaining("bundlecheck-exports-"), + "@scope/test-package", + ); + }); + + it("should handle npm install with --legacy-peer-deps and --ignore-scripts", async () => { + vi.mocked(execSync).mockImplementation((cmd) => { + if (String(cmd) === "pnpm --version") { + throw new Error("pnpm not found"); + } + return Buffer.from(""); + }); + + const { installPackage } = await import("../exports-installer.js"); + await installPackage({ + packageName: "test-package", + version: "1.0.0", + }); + + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining("--legacy-peer-deps"), + expect.any(Object), + ); + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining("--ignore-scripts"), + expect.any(Object), + ); + }); + + it("should handle pnpm install with --ignore-scripts and --no-frozen-lockfile", async () => { + vi.mocked(execSync).mockImplementation((cmd) => { + if (String(cmd) === "pnpm --version") { + return Buffer.from("8.0.0"); + } + return Buffer.from(""); + }); + + const { installPackage } = await import("../exports-installer.js"); + await installPackage({ + packageName: "test-package", + version: "1.0.0", + }); + + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining("--ignore-scripts"), + expect.any(Object), + ); + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining("--no-frozen-lockfile"), + expect.any(Object), + ); + }); + }); +}); diff --git a/packages/bundlecheck/src/__tests__/exports.test.ts b/packages/bundlecheck/src/__tests__/exports.test.ts new file mode 100644 index 00000000..63733229 --- /dev/null +++ b/packages/bundlecheck/src/__tests__/exports.test.ts @@ -0,0 +1,618 @@ +import fs from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getNamedExports } from "../exports.js"; + +// Mock the fs module +vi.mock("node:fs"); + +describe("getNamedExports", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should return empty result when package.json does not exist", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result).toEqual({ + exports: [], + count: 0, + runtimeExports: [], + runtimeCount: 0, + }); + }); + + it("should return empty result when types entry is not found", () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = String(p); + return pathStr.endsWith("package.json"); + }); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({})); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result).toEqual({ + exports: [], + count: 0, + runtimeExports: [], + runtimeCount: 0, + }); + }); + + it("should return empty result when types file does not exist", () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = String(p); + // package.json exists, but types file doesn't + return pathStr.endsWith("package.json"); + }); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + types: "dist/index.d.ts", + }), + ); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result).toEqual({ + exports: [], + count: 0, + runtimeExports: [], + runtimeCount: 0, + }); + }); + + it("should parse exported functions", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + return ` +export declare function myFunction(): void; +export declare function anotherFunction(arg: string): number; +`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toContainEqual({ + name: "anotherFunction", + kind: "function", + }); + expect(result.exports).toContainEqual({ + name: "myFunction", + kind: "function", + }); + expect(result.count).toBe(2); + expect(result.runtimeCount).toBe(2); + }); + + it("should parse exported classes", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + return ` +export declare class MyClass {} +export class AnotherClass {} +`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toContainEqual({ + name: "AnotherClass", + kind: "class", + }); + expect(result.exports).toContainEqual({ name: "MyClass", kind: "class" }); + expect(result.count).toBe(2); + }); + + it("should parse exported constants", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + return ` +export declare const MY_CONST: string; +export const ANOTHER_CONST = 42; +export declare const Button: React.FC; +`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toContainEqual({ + name: "ANOTHER_CONST", + kind: "const", + }); + expect(result.exports).toContainEqual({ name: "Button", kind: "const" }); + expect(result.exports).toContainEqual({ name: "MY_CONST", kind: "const" }); + }); + + it("should parse exported types and interfaces", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + return ` +export declare type MyType = string; +export type AnotherType = number; +export type GenericType = T[]; +export interface MyInterface {} +export declare interface AnotherInterface {} +`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toContainEqual({ + name: "AnotherInterface", + kind: "interface", + }); + expect(result.exports).toContainEqual({ + name: "AnotherType", + kind: "type", + }); + expect(result.exports).toContainEqual({ + name: "GenericType", + kind: "type", + }); + expect(result.exports).toContainEqual({ + name: "MyInterface", + kind: "interface", + }); + expect(result.exports).toContainEqual({ name: "MyType", kind: "type" }); + // Types and interfaces should NOT be in runtimeExports + expect(result.runtimeExports).toEqual([]); + expect(result.runtimeCount).toBe(0); + }); + + it("should parse exported enums", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + return ` +export declare enum MyEnum { A, B, C } +`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toContainEqual({ name: "MyEnum", kind: "enum" }); + expect(result.runtimeCount).toBe(1); + }); + + it("should parse named exports with local re-exports", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + return ` +export { foo, bar }; +`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toContainEqual({ name: "bar", kind: "unknown" }); + expect(result.exports).toContainEqual({ name: "foo", kind: "unknown" }); + }); + + it("should parse export type syntax", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + return ` +export type { MyType, AnotherType }; +`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toContainEqual({ + name: "AnotherType", + kind: "type", + }); + expect(result.exports).toContainEqual({ name: "MyType", kind: "type" }); + }); + + it("should handle type re-exports from other modules", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + return ` +export type { SomeType } from './types'; +export { SomeValue } from './values'; +`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toContainEqual({ name: "SomeType", kind: "type" }); + expect(result.exports).toContainEqual({ + name: "SomeValue", + kind: "unknown", + }); + }); + + it("should handle aliased exports", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + return ` +export { original as aliased } from './module'; +export type { OrigType as AliasType } from './types'; +`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toContainEqual({ name: "AliasType", kind: "type" }); + expect(result.exports).toContainEqual({ name: "aliased", kind: "unknown" }); + }); + + it("should skip private/internal exports starting with underscore", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + return ` +export declare function publicFn(): void; +export declare function _privateFn(): void; +export declare const _internalConst: string; +`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toHaveLength(1); + expect(result.exports).toContainEqual({ + name: "publicFn", + kind: "function", + }); + }); + + it("should skip default exports", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + return ` +export { default } from './module'; +export declare function namedExport(): void; +`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toHaveLength(1); + expect(result.exports).toContainEqual({ + name: "namedExport", + kind: "function", + }); + }); + + it("should handle typings field in package.json", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ typings: "types/index.d.ts" }); + } + return `export declare function foo(): void;`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toContainEqual({ name: "foo", kind: "function" }); + }); + + it("should handle exports field types in package.json", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ + exports: { + ".": { + types: "dist/index.d.ts", + }, + }, + }); + } + return `export declare function bar(): void;`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toContainEqual({ name: "bar", kind: "function" }); + }); + + it("should try common type file paths when no types field exists", () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = String(p); + return ( + pathStr.endsWith("package.json") || pathStr.endsWith("dist/index.d.ts") + ); + }); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({}); + } + return `export declare function fromCommonPath(): void;`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toContainEqual({ + name: "fromCommonPath", + kind: "function", + }); + }); + + it("should handle JSON parse errors gracefully", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return "invalid json {{{"; + } + return ""; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result).toEqual({ + exports: [], + count: 0, + runtimeExports: [], + runtimeCount: 0, + }); + }); + + it("should handle type file read errors gracefully", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + throw new Error("Read error"); + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result).toEqual({ + exports: [], + count: 0, + runtimeExports: [], + runtimeCount: 0, + }); + }); + + it("should follow re-exports from other files", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as fs.Stats); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + if (pathStr.endsWith("index.d.ts")) { + return `export * from './utils';`; + } + if (pathStr.includes("utils")) { + return `export declare function utilFn(): void;`; + } + return ""; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toContainEqual({ name: "utilFn", kind: "function" }); + }); + + it("should not follow non-relative imports", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + return ` +export * from 'external-package'; +export declare function localFn(): void; +`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + // Should only have the local export, not try to follow external package + expect(result.exports).toHaveLength(1); + expect(result.exports).toContainEqual({ + name: "localFn", + kind: "function", + }); + }); + + it("should handle .js extension in imports (TypeScript convention)", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as fs.Stats); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + if (pathStr.endsWith("index.d.ts")) { + return `export * from './helper.js';`; + } + if (pathStr.includes("helper")) { + return `export declare function helperFn(): void;`; + } + return ""; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toContainEqual({ + name: "helperFn", + kind: "function", + }); + }); + + it("should avoid circular imports", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as fs.Stats); + + let callCount = 0; + vi.mocked(fs.readFileSync).mockImplementation((p) => { + callCount++; + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + if (pathStr.endsWith("index.d.ts")) { + return ` +export * from './a'; +export declare function indexFn(): void; +`; + } + if (pathStr.includes("/a")) { + return ` +export * from './b'; +export declare function aFn(): void; +`; + } + if (pathStr.includes("/b")) { + return ` +export * from './a'; +export declare function bFn(): void; +`; + } + return ""; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + // Should not infinitely loop + expect(result.exports.length).toBeGreaterThan(0); + expect(callCount).toBeLessThan(20); // Sanity check for no infinite loop + }); + + it("should handle local aliased exports", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + return ` +export { internalName as publicName }; +`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toContainEqual({ + name: "publicName", + kind: "unknown", + }); + expect(result.exports).not.toContainEqual( + expect.objectContaining({ name: "internalName" }), + ); + }); + + it("should handle type keyword in named exports", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + return ` +export { type MyType, MyValue }; +`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports).toContainEqual({ name: "MyType", kind: "unknown" }); + expect(result.exports).toContainEqual({ name: "MyValue", kind: "unknown" }); + }); + + it("should sort exports alphabetically", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + return ` +export declare function zebra(): void; +export declare function apple(): void; +export declare function mango(): void; +`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + expect(result.exports.map((e) => e.name)).toEqual([ + "apple", + "mango", + "zebra", + ]); + }); + + it("should deduplicate exports", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("package.json")) { + return JSON.stringify({ types: "index.d.ts" }); + } + return ` +export declare function foo(): void; +export { foo }; +export declare const foo: () => void; +`; + }); + + const result = getNamedExports("/tmp/test", "test-package"); + + // Should only have one foo entry + const fooExports = result.exports.filter((e) => e.name === "foo"); + expect(fooExports).toHaveLength(1); + }); +}); diff --git a/packages/bundlecheck/src/__tests__/index.test.ts b/packages/bundlecheck/src/__tests__/index.test.ts index c65d8e24..a4879608 100644 --- a/packages/bundlecheck/src/__tests__/index.test.ts +++ b/packages/bundlecheck/src/__tests__/index.test.ts @@ -37,6 +37,10 @@ vi.mock("../trend.js", async (importOriginal) => { }; }); +vi.mock("../exports-installer.js", () => ({ + installPackage: vi.fn(), +})); + import { checkBundleSize } from "../bundler.js"; import { clearCache as clearCacheMock, @@ -44,12 +48,14 @@ import { getCachedResult, setCachedResult, } from "../cache.js"; +import { installPackage } from "../exports-installer.js"; import { clearCache, formatBytes, getBundleStats, getBundleTrend, getCacheCount, + getPackageExports, getPackageVersions, parsePackageSpecifier, } from "../index.js"; @@ -538,6 +544,117 @@ describe("Library API", () => { }); }); + describe("getPackageExports", () => { + const mockExportsResult = { + version: "7.0.0", + exports: [ + { name: "Button", kind: "const" as const }, + { name: "Input", kind: "const" as const }, + { name: "ButtonProps", kind: "interface" as const }, + ], + runtimeExports: [ + { name: "Button", kind: "const" as const }, + { name: "Input", kind: "const" as const }, + ], + }; + + beforeEach(() => { + vi.mocked(installPackage).mockResolvedValue(mockExportsResult); + }); + + it("should return exports for a package", async () => { + const result = await getPackageExports({ + package: "@mantine/core@7.0.0", + }); + + expect(result.packageName).toBe("@mantine/core"); + expect(result.packageVersion).toBe("7.0.0"); + expect(result.count).toBe(3); + expect(result.runtimeCount).toBe(2); + expect(result.exports).toHaveLength(3); + expect(result.runtimeExports).toHaveLength(2); + }); + + it("should resolve latest version when not specified", async () => { + vi.mocked(fetchPackageVersions).mockResolvedValue({ + versions: ["7.0.0", "6.0.0"], + tags: { latest: "7.0.0" }, + }); + + await getPackageExports({ + package: "@mantine/core", + }); + + expect(fetchPackageVersions).toHaveBeenCalledWith({ + packageName: "@mantine/core", + registry: undefined, + }); + expect(installPackage).toHaveBeenCalledWith({ + packageName: "@mantine/core", + version: "7.0.0", + registry: undefined, + }); + }); + + it("should pass registry option", async () => { + vi.mocked(fetchPackageVersions).mockResolvedValue({ + versions: ["1.0.0"], + tags: { latest: "1.0.0" }, + }); + + await getPackageExports({ + package: "@myorg/private-pkg", + registry: "https://npm.mycompany.com", + }); + + expect(installPackage).toHaveBeenCalledWith({ + packageName: "@myorg/private-pkg", + version: "1.0.0", + registry: "https://npm.mycompany.com", + }); + }); + + it("should use specified version directly", async () => { + await getPackageExports({ + package: "lodash@4.17.21", + }); + + expect(installPackage).toHaveBeenCalledWith({ + packageName: "lodash", + version: "4.17.21", + registry: undefined, + }); + }); + + it("should map exports correctly", async () => { + const result = await getPackageExports({ + package: "@mantine/core@7.0.0", + }); + + expect(result.exports[0]).toEqual({ name: "Button", kind: "const" }); + expect(result.exports[1]).toEqual({ name: "Input", kind: "const" }); + expect(result.exports[2]).toEqual({ + name: "ButtonProps", + kind: "interface", + }); + }); + + it("should map runtimeExports correctly", async () => { + const result = await getPackageExports({ + package: "@mantine/core@7.0.0", + }); + + expect(result.runtimeExports[0]).toEqual({ + name: "Button", + kind: "const", + }); + expect(result.runtimeExports[1]).toEqual({ + name: "Input", + kind: "const", + }); + }); + }); + describe("Re-exported utilities", () => { describe("formatBytes", () => { it("should format bytes correctly", () => { diff --git a/packages/bundlecheck/src/bundlecheck.ts b/packages/bundlecheck/src/bundlecheck.ts index 71e083d3..a4d6d784 100644 --- a/packages/bundlecheck/src/bundlecheck.ts +++ b/packages/bundlecheck/src/bundlecheck.ts @@ -48,6 +48,7 @@ function displayResult( externals: string[]; dependencies: string[]; platform: "browser" | "node"; + namedExportCount: number; }, isAutoDetected: boolean, ): void { @@ -57,12 +58,20 @@ function displayResult( const platformLabel = result.platform === "node" ? "node" : "browser"; const platformNote = isAutoDetected ? " (auto-detected)" : ""; + // Format exports display. + let exportsDisplay: string; + if (result.exports.length > 0) { + exportsDisplay = `{ ${result.exports.join(", ")} }`; + } else if (result.namedExportCount > 0) { + exportsDisplay = `${result.namedExportCount} named exports (entire package)`; + } else { + exportsDisplay = "* (entire package)"; + } + log.printBox( [ `${blue("Package:")} ${result.packageName} (${blue("version:")} ${result.packageVersion})`, - result.exports.length > 0 - ? `${blue("Exports:")} { ${result.exports.join(", ")} }` - : `${blue("Exports:")} * (entire package)`, + `${blue("Exports:")} ${exportsDisplay}`, "", `${blue("Raw size:")} ${formatBytes(result.rawSize)}`, result.gzipSize !== null diff --git a/packages/bundlecheck/src/bundler.ts b/packages/bundlecheck/src/bundler.ts index 20890296..7005d277 100644 --- a/packages/bundlecheck/src/bundler.ts +++ b/packages/bundlecheck/src/bundler.ts @@ -6,6 +6,7 @@ import { promisify } from "node:util"; import zlib from "node:zlib"; import * as esbuild from "esbuild"; import { DEFAULT_EXTERNALS, EXTERNAL_SUBPATHS } from "./defaults.js"; +import { getNamedExports } from "./exports.js"; const gzipAsync = promisify(zlib.gzip); @@ -102,6 +103,11 @@ export type BundleResult = { externals: string[]; dependencies: string[]; platform: "browser" | "node"; + /** + * Total number of named exports in the package (when analyzing entire + * package). + */ + namedExportCount: number; }; /** @@ -273,8 +279,8 @@ function generateEntryContent(options: EntryContentOptions): string { } /** - * Check if an error is an esbuild BuildFailure. - * BuildFailure has an `errors` array with structured error objects. + * Check if an error is an esbuild BuildFailure. BuildFailure has an `errors` + * array with structured error objects. */ function isEsbuildBuildFailure( error: unknown, @@ -288,13 +294,14 @@ function isEsbuildBuildFailure( } /** - * Extract unresolved module paths from an esbuild BuildFailure error. - * Returns an object indicating which react-related modules failed to resolve. + * Extract unresolved module paths from an esbuild BuildFailure error. Returns + * an object indicating which react-related modules failed to resolve. * * Uses esbuild's structured error objects (errors array) for reliable parsing. * Falls back to string matching if the error format is unexpected. * * Tested against esbuild 0.27.x error format. + * */ function parseUnresolvedModules(error: unknown): { hasUnresolvedReact: boolean; @@ -302,8 +309,10 @@ function parseUnresolvedModules(error: unknown): { } { const result = { hasUnresolvedReact: false, hasUnresolvedReactDom: false }; - // Pattern to extract module path from "Could not resolve X" errors. - // Matches: Could not resolve "module-name" or Could not resolve 'module-name' + /** + * Pattern to extract module path from "Could not resolve X" errors. Matches: + * Could not resolve "module-name" or Could not resolve 'module-name'. + */ const resolveErrorPattern = /Could not resolve ["']([^"']+)["']/; if (isEsbuildBuildFailure(error)) { @@ -346,7 +355,9 @@ function parseUnresolvedModules(error: unknown): { * react-dom are only marked as external if they are declared in the package's * dependencies or peerDependencies. * - * Returns the base package names (e.g., ["react", "react-dom"]) for display purposes. + * Returns the base package names (e.g., ["react", "react-dom"]) for display + * purposes. + * */ export function getExternals( packageName: string, @@ -389,9 +400,9 @@ export function getExternals( } /** - * Expand externals to include subpaths for esbuild. - * For example, "react" expands to ["react", "react/jsx-runtime", "react/jsx-dev-runtime"]. - * This is needed because esbuild doesn't automatically externalize subpaths. + * Expand externals to include subpaths for esbuild. For example, "react" + * expands to ["react", "react/jsx-runtime", "react/jsx-dev-runtime"]. This is + * needed because esbuild doesn't automatically externalize subpaths. */ export function expandExternalsForEsbuild(externals: string[]): string[] { const expanded: string[] = []; @@ -753,7 +764,8 @@ export async function checkBundleSize( * Bundle with esbuild. We use a two-phase approach: * 1. First attempt with logLevel: "silent" to suppress errors * 2. If it fails due to unresolved react imports, auto-add react to externals and retry - * This handles packages that don't properly declare react as a peer dependency. + * This handles packages that don't properly declare react as a peer + * dependency. */ let result: esbuild.BuildResult<{ write: false; metafile: true }>; try { @@ -773,7 +785,8 @@ export async function checkBundleSize( } catch (error) { /** * Parse unresolved module errors using esbuild's structured error format. - * This handles packages that don't properly declare react as a peer dependency. + * This handles packages that don't properly declare react as a peer + * dependency. */ const { hasUnresolvedReact, hasUnresolvedReactDom } = parseUnresolvedModules(error); @@ -835,6 +848,15 @@ export async function checkBundleSize( displayName = `${packageName}/{${uniqueSubpaths.join(", ")}}`; } + /** + * Get named export count from the package's type definitions. Use + * runtimeCount to exclude type-only exports (types, interfaces). + */ + const { runtimeCount: namedExportCount } = getNamedExports( + tmpDir, + packageName, + ); + return { packageName: displayName, packageVersion: pkgInfo.version, @@ -845,6 +867,7 @@ export async function checkBundleSize( externals, dependencies: allDependencies, platform, + namedExportCount, }; } finally { cleanupTempDir(tmpDir); diff --git a/packages/bundlecheck/src/cache.ts b/packages/bundlecheck/src/cache.ts index 1ab6e17a..63a9b333 100644 --- a/packages/bundlecheck/src/cache.ts +++ b/packages/bundlecheck/src/cache.ts @@ -49,6 +49,7 @@ type CacheRow = { dependencies: string; display_name: string; created_at: number; + named_export_count: number | null; actual_externals: string; }; @@ -90,6 +91,7 @@ export function initCache(): Database.Database { dependencies TEXT NOT NULL DEFAULT '[]', display_name TEXT NOT NULL, created_at INTEGER NOT NULL, + named_export_count INTEGER DEFAULT 0, actual_externals TEXT NOT NULL DEFAULT '', UNIQUE(package_name, version, exports, platform, gzip_level, externals, no_external) ); @@ -97,7 +99,22 @@ export function initCache(): Database.Database { CREATE INDEX IF NOT EXISTS idx_created_at ON bundle_cache(created_at); `); - // Migration: Add actual_externals column if it doesn't exist (for existing databases). + /** + * Migration: Add named_export_count column if it doesn't exist (for existing + * databases). + */ + try { + db.exec( + `ALTER TABLE bundle_cache ADD COLUMN named_export_count INTEGER DEFAULT 0`, + ); + } catch { + // Column already exists, ignore error. + } + + /** + * Migration: Add actual_externals column if it doesn't exist (for existing + * databases). + */ try { db.exec( `ALTER TABLE bundle_cache ADD COLUMN actual_externals TEXT NOT NULL DEFAULT ''`, @@ -190,9 +207,9 @@ export function getCachedResult(key: CacheKey): CachedBundleResult | null { } /** - * Convert row to BundleResult. - * Use actual_externals (computed externals including auto-detected react/react-dom) - * if available, otherwise fall back to externals (for old cache entries). + * Convert row to BundleResult. Use actual_externals (computed externals + * including auto-detected react/react-dom) if available, otherwise fall back + * to externals (for old cache entries). */ const externalsStr = row.actual_externals || row.externals; return { @@ -205,6 +222,7 @@ export function getCachedResult(key: CacheKey): CachedBundleResult | null { externals: externalsStr ? externalsStr.split(",").filter(Boolean) : [], dependencies, platform: row.platform as "browser" | "node", + namedExportCount: row.named_export_count ?? 0, }; } catch { /** @@ -230,8 +248,8 @@ export function setCachedResult(key: CacheKey, result: BundleResult): void { const stmt = database.prepare(` INSERT OR REPLACE INTO bundle_cache ( package_name, version, exports, platform, gzip_level, externals, no_external, - raw_size, gzip_size, dependencies, display_name, created_at, actual_externals - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + raw_size, gzip_size, dependencies, display_name, created_at, named_export_count, actual_externals + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( @@ -247,6 +265,7 @@ export function setCachedResult(key: CacheKey, result: BundleResult): void { JSON.stringify(result.dependencies), result.packageName, Date.now(), + result.namedExportCount, result.externals.slice().sort().join(","), ); diff --git a/packages/bundlecheck/src/defaults.ts b/packages/bundlecheck/src/defaults.ts index 33b86a35..7d4eb04c 100644 --- a/packages/bundlecheck/src/defaults.ts +++ b/packages/bundlecheck/src/defaults.ts @@ -80,15 +80,15 @@ export function isValidPlatform(platform: string | undefined): boolean { export const TREND_VERSION_COUNT = 5; /** - * Base packages to auto-detect for externalization. - * These are checked against the package's dependencies/peerDependencies. + * Base packages to auto-detect for externalization. These are checked against + * the package's dependencies/peerDependencies. */ export const DEFAULT_EXTERNALS = ["react", "react-dom"]; /** - * Subpath externals to add when a base package is detected. - * When "react" is in dependencies, we also need to externalize "react/jsx-runtime" etc. - * because esbuild doesn't automatically externalize subpaths. + * Subpath externals to add when a base package is detected. When "react" is in + * dependencies, we also need to externalize "react/jsx-runtime" etc. because + * esbuild doesn't automatically externalize subpaths. */ export const EXTERNAL_SUBPATHS: Record = { react: ["react/jsx-runtime", "react/jsx-dev-runtime"], diff --git a/packages/bundlecheck/src/exports-installer.ts b/packages/bundlecheck/src/exports-installer.ts new file mode 100644 index 00000000..1c2bff51 --- /dev/null +++ b/packages/bundlecheck/src/exports-installer.ts @@ -0,0 +1,157 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { getNamedExports, type NamedExport } from "./exports.js"; + +/** + * Check if pnpm is available. + */ +function isPnpmAvailable(): boolean { + try { + execSync("pnpm --version", { stdio: "pipe" }); + return true; + } catch { + return false; + } +} + +// Cache the result of pnpm availability check. +let usePnpm: boolean | null = null; + +/** + * Validate and sanitize a registry URL to prevent command injection. + */ +function validateRegistryUrl(registry: string): string { + let url: URL; + try { + url = new URL(registry); + } catch { + throw new Error( + `Invalid registry URL: ${registry}. Must be a valid URL (e.g., https://registry.example.com)`, + ); + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error( + `Invalid registry URL protocol: ${url.protocol}. Only http: and https: are allowed`, + ); + } + + return url.toString(); +} + +/** + * Get the install command (pnpm preferred, npm fallback). + */ +function getInstallCommand(registry?: string): string { + if (usePnpm === null) { + usePnpm = isPnpmAvailable(); + } + + let registryArg = ""; + if (registry) { + const sanitizedRegistry = validateRegistryUrl(registry); + registryArg = ` --registry "${sanitizedRegistry}"`; + } + + if (usePnpm) { + return `pnpm install --ignore-scripts --no-frozen-lockfile${registryArg}`; + } + return `npm install --legacy-peer-deps --ignore-scripts${registryArg}`; +} + +/** + * Create a temporary directory for installation. + */ +function createTempDir(): string { + const tmpDir = path.join(os.tmpdir(), `bundlecheck-exports-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + return tmpDir; +} + +/** + * Clean up temporary directory. + */ +function cleanupTempDir(tmpDir: string): void { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors. + } +} + +export type InstallPackageOptions = { + packageName: string; + version: string; + registry?: string; +}; + +export type InstallPackageResult = { + version: string; + exports: NamedExport[]; + runtimeExports: NamedExport[]; +}; + +/** + * Install a package temporarily and extract its named exports. + */ +export async function installPackage( + options: InstallPackageOptions, +): Promise { + const { packageName, version, registry } = options; + const tmpDir = createTempDir(); + + try { + // Create minimal package.json. + const packageJson = { + name: "bundlecheck-exports-temp", + version: "1.0.0", + type: "module", + dependencies: { + [packageName]: version, + }, + }; + + fs.writeFileSync( + path.join(tmpDir, "package.json"), + JSON.stringify(packageJson, null, 2), + ); + + // Install the package. + const installCmd = getInstallCommand(registry); + execSync(installCmd, { + cwd: tmpDir, + stdio: "pipe", + }); + + // Read the installed version. + const pkgJsonPath = path.join( + tmpDir, + "node_modules", + packageName, + "package.json", + ); + let installedVersion = version; + if (fs.existsSync(pkgJsonPath)) { + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")); + installedVersion = pkgJson.version || version; + } + + // Get named exports. + const { exports, runtimeExports } = getNamedExports(tmpDir, packageName); + + return { + version: installedVersion, + exports, + runtimeExports, + }; + } finally { + cleanupTempDir(tmpDir); + } +} + +/** + * Alias for backward compatibility. + */ +export const getPackageInfoAndExports = installPackage; diff --git a/packages/bundlecheck/src/exports.ts b/packages/bundlecheck/src/exports.ts new file mode 100644 index 00000000..08d717b8 --- /dev/null +++ b/packages/bundlecheck/src/exports.ts @@ -0,0 +1,438 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Represents a named export from a package. + */ +export type NamedExport = { + /** + * The export name (e.g., "Button", "useState"). + */ + name: string; + /** + * The type of export. + */ + kind: + | "function" + | "class" + | "const" + | "type" + | "interface" + | "enum" + | "unknown"; +}; + +/** + * Result from parsing package exports. + */ +export type ParsedExports = { + /** + * Array of all named exports found in the package (including types). + */ + exports: NamedExport[]; + /** + * Total count of all named exports (including types). + */ + count: number; + /** + * Array of runtime exports only (excluding types and interfaces). + */ + runtimeExports: NamedExport[]; + /** + * Count of runtime exports only (functions, classes, const, enums). + */ + runtimeCount: number; +}; + +/** + * Resolve a relative import path to an absolute file path. + */ +function resolveImportPath( + basePath: string, + importPath: string, +): string | null { + // Remove quotes and trim. + let cleanPath = importPath.replace(/['"]/g, "").trim(); + + // Only handle relative imports. + if (!cleanPath.startsWith(".")) { + return null; + } + + /** + * Strip .js/.mjs extension if present (TypeScript uses .js in imports but + * .d.ts for types). + */ + cleanPath = cleanPath.replace(/\.(m?js)$/, ""); + + const baseDir = path.dirname(basePath); + const resolved = path.resolve(baseDir, cleanPath); + + // Try different extensions for the resolved path. + const extensions = [".d.ts", ".d.mts", ".ts", ""]; + for (const ext of extensions) { + const tryPath = resolved + ext; + if (fs.existsSync(tryPath) && fs.statSync(tryPath).isFile()) { + return tryPath; + } + } + + // Try index files in directory. + for (const ext of [".d.ts", ".d.mts", ".ts"]) { + const indexPath = path.join(resolved, `index${ext}`); + if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) { + return indexPath; + } + } + + return null; +} + +/** + * Parse a TypeScript declaration file (.d.ts) to extract named exports. + * Recursively follows re-exports up to a maximum depth. + */ +function parseDeclarationFile( + filePath: string, + content: string, + visited: Set = new Set(), + depth = 0, +): NamedExport[] { + const MAX_DEPTH = 10; + const MAX_FILES = 500; + + if (depth > MAX_DEPTH || visited.size > MAX_FILES) { + return []; + } + + // Avoid circular imports. + const normalizedPath = path.resolve(filePath); + if (visited.has(normalizedPath)) { + return []; + } + visited.add(normalizedPath); + + const exports: NamedExport[] = []; + const seenNames = new Set(); + + const addExport = (name: string, kind: NamedExport["kind"]) => { + // Skip internal/private exports and TypeScript utility types. + if (name.startsWith("_") || name === "default" || seenNames.has(name)) { + return; + } + seenNames.add(name); + exports.push({ name, kind }); + }; + + // Pattern for: export * from './path'. + const reExportAllPattern = /export\s+\*\s+from\s+['"]([^'"]+)['"]/g; + + // Pattern for: export type { Name, Name2 } from './path' (type-only exports). + const reExportTypePattern = + /export\s+type\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g; + + /** + * Pattern for: export { Name, Name2 } from './path' (value exports - NOT + * type). + */ + const reExportValuePattern = + /export\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g; + + // Pattern for: export declare function Name. + const functionPattern = /export\s+declare\s+function\s+(\w+)/g; + // Pattern for: export declare class Name. + const classPattern = /export\s+declare\s+class\s+(\w+)/g; + // Pattern for: export declare const Name. + const constPattern = /export\s+declare\s+const\s+(\w+)/g; + // Pattern for: export declare type Name. + const typePattern = /export\s+declare\s+type\s+(\w+)/g; + // Pattern for: export type Name = ... (without declare). + const typePattern2 = /export\s+type\s+(\w+)\s*[=<]/g; + // Pattern for: export type { Name } (local re-export without from). + const typeExportPattern = /export\s+type\s+\{([^}]+)\}\s*;/g; + // Pattern for: export interface Name. + const interfacePattern = /export\s+(?:declare\s+)?interface\s+(\w+)/g; + // Pattern for: export declare enum Name. + const enumPattern = /export\s+declare\s+enum\s+(\w+)/g; + // Pattern for: export { Name, Name2 } (without from - local exports). + const namedExportPattern = /export\s+\{([^}]+)\}\s*;/g; + // Pattern for: export declare const Name: ... (component style). + const componentPattern = /export\s+declare\s+const\s+(\w+)\s*:/g; + // Pattern for: export function Name (without declare). + const functionPattern2 = /export\s+function\s+(\w+)/g; + // Pattern for: export class Name (without declare). + const classPattern2 = /export\s+class\s+(\w+)/g; + // Pattern for: export const Name (without declare). + const constPattern2 = /export\s+const\s+(\w+)/g; + + // Helper to extract all matches from a regex. + const extractMatches = (pattern: RegExp, text: string): RegExpExecArray[] => { + const matches: RegExpExecArray[] = []; + let result = pattern.exec(text); + while (result !== null) { + matches.push(result); + result = pattern.exec(text); + } + return matches; + }; + + // Handle: export * from './path' - recursively parse. + for (const match of extractMatches(reExportAllPattern, content)) { + const importPath = match[1]; + const resolvedPath = resolveImportPath(filePath, importPath); + if (resolvedPath) { + try { + const importedContent = fs.readFileSync(resolvedPath, "utf-8"); + const importedExports = parseDeclarationFile( + resolvedPath, + importedContent, + visited, + depth + 1, + ); + for (const exp of importedExports) { + addExport(exp.name, exp.kind); + } + } catch { + // Ignore read errors. + } + } + } + + // Handle: export type { Name } from './path' (type-only exports). + for (const match of extractMatches(reExportTypePattern, content)) { + const exportList = match[1]; + const names = exportList.split(",").map((s) => { + const trimmed = s.trim(); + // Handle "Name as Alias". + const asMatch = trimmed.match(/(\w+)\s+as\s+(\w+)/); + if (asMatch) { + return asMatch[2]; + } + return trimmed; + }); + + for (const name of names) { + if (name && /^\w+$/.test(name)) { + addExport(name, "type"); + } + } + } + + // Handle: export { Name } from './path' (value exports). + for (const match of extractMatches(reExportValuePattern, content)) { + // Skip if this is actually a type export (already handled above). + const fullMatch = match[0]; + if (fullMatch.includes("export type")) { + continue; + } + + const exportList = match[1]; + const names = exportList.split(",").map((s) => { + const trimmed = s.trim(); + // Handle "Name as Alias". + const asMatch = trimmed.match(/(\w+)\s+as\s+(\w+)/); + if (asMatch) { + return asMatch[2]; + } + // Handle "type Name" syntax within the braces. + const typeMatch = trimmed.match(/type\s+(\w+)/); + if (typeMatch) { + /** + * This is a type being re-exported, skip it (will be handled by type + * pattern). + */ + return null; + } + return trimmed; + }); + + for (const name of names) { + if (name && /^\w+$/.test(name)) { + addExport(name, "unknown"); + } + } + } + + // Extract functions. + for (const match of extractMatches(functionPattern, content)) { + addExport(match[1], "function"); + } + for (const match of extractMatches(functionPattern2, content)) { + addExport(match[1], "function"); + } + + // Extract classes. + for (const match of extractMatches(classPattern, content)) { + addExport(match[1], "class"); + } + for (const match of extractMatches(classPattern2, content)) { + addExport(match[1], "class"); + } + + // Extract const (includes React components). + for (const match of extractMatches(constPattern, content)) { + addExport(match[1], "const"); + } + for (const match of extractMatches(constPattern2, content)) { + addExport(match[1], "const"); + } + + // Also catch component-style declarations. + for (const match of extractMatches(componentPattern, content)) { + addExport(match[1], "const"); + } + + // Extract types (declare type). + for (const match of extractMatches(typePattern, content)) { + addExport(match[1], "type"); + } + + // Extract types (export type X =). + for (const match of extractMatches(typePattern2, content)) { + addExport(match[1], "type"); + } + + // Extract type exports (export type { X }). + for (const match of extractMatches(typeExportPattern, content)) { + const exportList = match[1]; + const names = exportList.split(",").map((s) => s.trim()); + for (const name of names) { + if (name && /^\w+$/.test(name)) { + addExport(name, "type"); + } + } + } + + // Extract interfaces. + for (const match of extractMatches(interfacePattern, content)) { + addExport(match[1], "interface"); + } + + // Extract enums. + for (const match of extractMatches(enumPattern, content)) { + addExport(match[1], "enum"); + } + + /** + * Extract named exports from export { ... } (without from - these are local + * exports). + */ + for (const match of extractMatches(namedExportPattern, content)) { + const exportList = match[1]; + // Skip if this looks like a type export (already handled above). + if (content.substring(match.index - 5, match.index).includes("type")) { + continue; + } + const names = exportList.split(",").map((s) => { + const trimmed = s.trim(); + const asMatch = trimmed.match(/(\w+)\s+as\s+(\w+)/); + if (asMatch) { + return asMatch[2]; + } + const typeMatch = trimmed.match(/type\s+(\w+)/); + if (typeMatch) { + return typeMatch[1]; + } + return trimmed; + }); + + for (const name of names) { + if (name && /^\w+$/.test(name)) { + addExport(name, "unknown"); + } + } + } + + return exports; +} + +/** + * Get named exports from an installed package by parsing its type definitions. + * + * @param tmpDir - The temporary directory where the package is installed + * @param packageName - The package name (e.g., "@mantine/core") + * @returns ParsedExports containing the list of exports and count + * + */ +export function getNamedExports( + tmpDir: string, + packageName: string, +): ParsedExports { + const packageDir = path.join(tmpDir, "node_modules", packageName); + + const emptyResult: ParsedExports = { + exports: [], + count: 0, + runtimeExports: [], + runtimeCount: 0, + }; + + // Try to read package.json to find the types entry. + const pkgJsonPath = path.join(packageDir, "package.json"); + if (!fs.existsSync(pkgJsonPath)) { + return emptyResult; + } + + let typesEntry: string | undefined; + try { + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")); + + // Look for types in order of preference. + typesEntry = + pkgJson.types || + pkgJson.typings || + pkgJson.exports?.["."]?.types || + (typeof pkgJson.exports?.["."] === "object" && + pkgJson.exports["."].types); + + // If no types field, try common patterns. + if (!typesEntry) { + const commonPaths = [ + "dist/index.d.ts", + "lib/index.d.ts", + "index.d.ts", + "types/index.d.ts", + ]; + for (const tryPath of commonPaths) { + if (fs.existsSync(path.join(packageDir, tryPath))) { + typesEntry = tryPath; + break; + } + } + } + } catch { + return emptyResult; + } + + if (!typesEntry) { + return emptyResult; + } + + // Read and parse the types file. + const typesPath = path.join(packageDir, typesEntry); + if (!fs.existsSync(typesPath)) { + return emptyResult; + } + + try { + const content = fs.readFileSync(typesPath, "utf-8"); + const exports = parseDeclarationFile(typesPath, content); + + // Sort exports by name for consistent output. + exports.sort((a, b) => a.name.localeCompare(b.name)); + + // Filter to runtime-only exports (exclude types and interfaces). + const runtimeExports = exports.filter( + (e) => e.kind !== "type" && e.kind !== "interface", + ); + + return { + exports, + count: exports.length, + runtimeExports, + runtimeCount: runtimeExports.length, + }; + } catch { + return emptyResult; + } +} diff --git a/packages/bundlecheck/src/index.ts b/packages/bundlecheck/src/index.ts index 8dd8b10a..e9078b0b 100644 --- a/packages/bundlecheck/src/index.ts +++ b/packages/bundlecheck/src/index.ts @@ -17,6 +17,7 @@ import { setCachedResult, } from "./cache.js"; import { normalizePlatform, TREND_VERSION_COUNT } from "./defaults.js"; +import type { NamedExport } from "./exports.js"; import { analyzeTrend, selectTrendVersions } from "./trend.js"; import { fetchPackageVersions as fetchVersions } from "./versions.js"; @@ -118,6 +119,11 @@ export type BundleStats = { * Whether the result was retrieved from cache. */ fromCache: boolean; + /** + * Total number of named exports in the package (when analyzing entire + * package). + */ + namedExportCount: number; }; /** @@ -530,6 +536,139 @@ export async function getPackageVersions( }; } +/** + * Options for getting package exports. + */ +export type GetPackageExportsOptions = { + /** + * Package name with optional version (e.g., "@mantine/core" or + * "@mantine/core@7.0.0"). + */ + package: string; + /** + * Custom npm registry URL. + */ + registry?: string; +}; + +/** + * A named export from a package. + */ +export type PackageExport = { + /** + * The export name (e.g., "Button", "useState"). + */ + name: string; + /** + * The type of export. + */ + kind: + | "function" + | "class" + | "const" + | "type" + | "interface" + | "enum" + | "unknown"; +}; + +/** + * Result from getPackageExports. + */ +export type PackageExports = { + /** + * Package name. + */ + packageName: string; + /** + * Resolved package version. + */ + packageVersion: string; + /** + * Array of all named exports found in the package (including types). + */ + exports: PackageExport[]; + /** + * Total count of all named exports (including types). + */ + count: number; + /** + * Array of runtime exports only (excluding types and interfaces). These are + * the exports that can actually be imported at runtime. + */ + runtimeExports: PackageExport[]; + /** + * Count of runtime exports only (functions, classes, const, enums). + */ + runtimeCount: number; +}; + +/** + * Get the named exports of an npm package by analyzing its type definitions. + * + * @example + * ```js + * import { getPackageExports } from "@node-cli/bundlecheck"; + * + * const { exports, count } = await getPackageExports({ + * package: "@mantine/core@7.0.0", + * }); + * + * console.log(`Found ${count} exports`); + * console.log(exports.map(e => e.name)); // ["Accordion", "ActionIcon", "Alert", ...] + * ``` + * + */ +export async function getPackageExports( + options: GetPackageExportsOptions, +): Promise { + const { package: packageName, registry } = options; + + /** + * Import the install utilities from bundler (we'll use a minimal bundle + * check). + */ + const { name: baseName, version: requestedVersion } = + parsePackageSpecifier(packageName); + + // Resolve "latest" to actual version. + let resolvedVersion = requestedVersion; + if (requestedVersion === "latest") { + const { tags } = await fetchVersions({ + packageName: baseName, + registry, + }); + resolvedVersion = tags.latest || requestedVersion; + } + + /** + * Use a minimal bundle check to install the package and get exports We'll + * leverage the existing infrastructure. + */ + const { installPackage } = await import("./exports-installer.js"); + + const { version, exports, runtimeExports } = await installPackage({ + packageName: baseName, + version: resolvedVersion, + registry, + }); + + return { + packageName: baseName, + packageVersion: version, + exports: exports.map((e: NamedExport) => ({ + name: e.name, + kind: e.kind, + })), + count: exports.length, + runtimeExports: runtimeExports.map((e: NamedExport) => ({ + name: e.name, + kind: e.kind, + })), + runtimeCount: runtimeExports.length, + }; +} + /** * ============================================================================= * Re-exports for advanced usage @@ -575,5 +714,6 @@ function formatBundleStats( gzipSizeFormatted: result.gzipSize !== null ? formatBytes(result.gzipSize) : null, fromCache, + namedExportCount: result.namedExportCount, }; }