|
| 1 | +# @wolfcola/treeshake-check |
| 2 | + |
| 3 | +A tree-shakeability analyzer for npm packages. Tells you whether your package can be fully tree-shaken by Rollup, and when it can't, points at the specific files, exports, and likely causes preventing it. |
| 4 | + |
| 5 | +Built on [Effect](https://effect.website), [@effect/cli](https://www.npmjs.com/package/@effect/cli), and [Rollup](https://rollupjs.org). |
| 6 | + |
| 7 | +## Why this exists |
| 8 | + |
| 9 | +When you publish a library, consumers' bundlers (webpack, Rollup, Vite, esbuild) try to eliminate unused exports from your package — that's tree-shaking. If your package isn't shakeable, every consumer who imports a single function pulls in your entire library, inflating their bundle size. |
| 10 | + |
| 11 | +Tree-shakeability isn't visible from the outside. You can ship what looks like a clean ESM library and still have it be unshakeable due to a single `Object.defineProperty` call at module scope, a missing `"sideEffects": false` in `package.json`, or a transitive CJS dependency. This tool surfaces those problems. |
| 12 | + |
| 13 | +The technique is the same one used by Rich Harris's [agadoo](https://www.npmjs.com/package/agadoo): bundle your package as a side-effect-only import (`import "your-package"` with no bindings used) and see what Rollup couldn't eliminate. Anything that survives is what's preventing tree-shaking. |
| 14 | + |
| 15 | +## Installation |
| 16 | + |
| 17 | +```bash |
| 18 | +pnpm add -D @wolfcola/treeshake-check |
| 19 | +``` |
| 20 | + |
| 21 | +## Usage |
| 22 | + |
| 23 | +### Quickstart |
| 24 | + |
| 25 | +From any package directory: |
| 26 | + |
| 27 | +```bash |
| 28 | +pnpm treeshake-check |
| 29 | +``` |
| 30 | + |
| 31 | +You'll get one of two outcomes: |
| 32 | + |
| 33 | +- **Fully tree-shakeable** — ASCII tree celebration plus any recommendations for `package.json` improvements. |
| 34 | +- **Has side effects** — a per-module breakdown of what survived, with diagnostic info for each file. |
| 35 | + |
| 36 | +### Flags |
| 37 | + |
| 38 | +| Flag | Alias | Description | |
| 39 | +| ---------------- | ----- | ------------------------------------------------------------------------------- | |
| 40 | +| `--cwd <path>` | `-C` | Directory containing `package.json`. Defaults to the current working directory. | |
| 41 | +| `--entry <path>` | `-e` | Analyze a specific entry file directly, skipping `package.json` resolution. | |
| 42 | +| `--json` | | Emit machine-readable JSON instead of human output. | |
| 43 | +| `--quiet` | `-q` | Suppress all output; rely on the exit code only. | |
| 44 | +| `--top <n>` | | Show only the N modules with the largest surviving byte count. | |
| 45 | + |
| 46 | +Plus the standard `--help`, `--version`, `--wizard`, and `--completions <shell>` flags from `@effect/cli`. |
| 47 | + |
| 48 | +### Examples |
| 49 | + |
| 50 | +Check the current package: |
| 51 | + |
| 52 | +```bash |
| 53 | +pnpm treeshake-check |
| 54 | +``` |
| 55 | + |
| 56 | +Check a different package in the workspace: |
| 57 | + |
| 58 | +```bash |
| 59 | +pnpm treeshake-check --cwd packages/my-sdk |
| 60 | +``` |
| 61 | + |
| 62 | +Check a specific built file directly: |
| 63 | + |
| 64 | +```bash |
| 65 | +pnpm treeshake-check --entry dist/index.js |
| 66 | +``` |
| 67 | + |
| 68 | +Show only the worst 5 offenders: |
| 69 | + |
| 70 | +```bash |
| 71 | +pnpm treeshake-check --top 5 |
| 72 | +``` |
| 73 | + |
| 74 | +JSON output for CI tooling: |
| 75 | + |
| 76 | +```bash |
| 77 | +pnpm treeshake-check --json | jq '.modules[] | {id, renderedLength, suspectedCauses}' |
| 78 | +``` |
| 79 | + |
| 80 | +## Detected causes |
| 81 | + |
| 82 | +The analyzer uses AST-based heuristics to classify surviving code: |
| 83 | + |
| 84 | +| Cause | Description | |
| 85 | +| ----------------------- | --------------------------------------------------------- | |
| 86 | +| `EnumPattern` | TypeScript enum compiled to IIFE | |
| 87 | +| `CommonJsContamination` | `require()`, `module.exports`, `__esModule` markers | |
| 88 | +| `PrototypeMutation` | `Object.defineProperty`, `.prototype.x = ...` | |
| 89 | +| `GlobalAssignment` | Assignment to `window`, `globalThis`, `self`, or `global` | |
| 90 | +| `UnannotatedCall` | Top-level function call without `/*#__PURE__*/` | |
| 91 | +| `TopLevelSideEffect` | Top-level statement with observable effects | |
| 92 | +| `Unknown` | None of the above patterns matched | |
| 93 | + |
| 94 | +Labels are heuristic — a starting point for investigation, not a verdict. |
| 95 | + |
| 96 | +## Programmatic usage |
| 97 | + |
| 98 | +```typescript |
| 99 | +import { Effect } from 'effect'; |
| 100 | +import { NodeContext } from '@effect/platform-node'; |
| 101 | +import { checkPackage } from '@wolfcola/treeshake-check'; |
| 102 | + |
| 103 | +const program = Effect.gen(function* () { |
| 104 | + const result = yield* checkPackage('./packages/sdk'); |
| 105 | + |
| 106 | + if (result._tag === 'FullyTreeshakeable') { |
| 107 | + return true; |
| 108 | + } |
| 109 | + |
| 110 | + for (const m of result.modules) { |
| 111 | + console.log(`${m.id}: ${m.renderedLength}/${m.originalLength} bytes`); |
| 112 | + console.log(` causes: ${m.suspectedCauses.join(', ')}`); |
| 113 | + } |
| 114 | + return false; |
| 115 | +}); |
| 116 | + |
| 117 | +const isShakeable = await Effect.runPromise(program.pipe(Effect.provide(NodeContext.layer))); |
| 118 | +``` |
| 119 | + |
| 120 | +## CI integration |
| 121 | + |
| 122 | +`treeshake-check` exits with code 1 when a package isn't fully shakeable, so it composes naturally as a quality gate. |
| 123 | + |
| 124 | +### GitHub Actions |
| 125 | + |
| 126 | +```yaml |
| 127 | +- name: Tree-shake check |
| 128 | + run: pnpm -r --filter "./packages/*" exec treeshake-check --top 5 |
| 129 | +``` |
| 130 | +
|
| 131 | +### As a pre-publish hook |
| 132 | +
|
| 133 | +```json |
| 134 | +{ |
| 135 | + "scripts": { |
| 136 | + "prepublishOnly": "treeshake-check --quiet" |
| 137 | + } |
| 138 | +} |
| 139 | +``` |
| 140 | + |
| 141 | +### Across a monorepo |
| 142 | + |
| 143 | +```bash |
| 144 | +pnpm -r --parallel exec treeshake-check --top 3 |
| 145 | +``` |
| 146 | + |
| 147 | +## How it works |
| 148 | + |
| 149 | +1. Reads `package.json` from the target directory and resolves the entry point (`exports` → `module` → `main`). |
| 150 | +2. Constructs a synthetic Rollup entry that imports the target as a side-effect-only import: `import "/absolute/path/to/entry.js"`. |
| 151 | +3. Runs Rollup with default tree-shaking enabled. |
| 152 | +4. Inspects `chunk.modules` for per-module `renderedLength`, `renderedExports`, and `removedExports`. |
| 153 | +5. Classifies surviving code by AST analysis (with regex fallback for unparseable output). |
| 154 | +6. Reports per-module statistics, surviving code, and `package.json` recommendations. |
| 155 | + |
| 156 | +## Prior art |
| 157 | + |
| 158 | +- [agadoo](https://www.npmjs.com/package/agadoo) by Rich Harris — same technique, the original implementation. This package adds richer diagnostics, structured output, and Effect-based composition. |
| 159 | +- [bundlephobia](https://bundlephobia.com) — measures the post-shake size from a consumer's perspective rather than analyzing why shaking succeeds or fails. |
0 commit comments