Skip to content

Commit 1a5f974

Browse files
authored
Merge pull request #18 from ryanbas21/feat/treeshake-check-syncpack
feat: add treeshake-check package, syncpack, and lefthook
2 parents 668e660 + 00a05cd commit 1a5f974

32 files changed

Lines changed: 6121 additions & 1226 deletions

.changeset/add-treeshake-check.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@wolfcola/treeshake-check': minor
3+
---
4+
5+
Add treeshake-check package — a tree-shakeability analyzer for npm packages built on Effect and Rollup

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ elm-stuff/
99
*.crx.zip
1010
*.zip
1111
packged/
12+
out-tsc/

.prettierignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
dist
2+
node_modules
3+
coverage
4+
elm-stuff
5+
out-tsc
6+
pnpm-lock.yaml
7+
**/__fixtures__/bad-syntax/**

.syncpackrc.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// @ts-check
2+
3+
/** @type {import("syncpack").RcFile} */
4+
const config = {
5+
sortFirst: [
6+
'name',
7+
'version',
8+
'private',
9+
'description',
10+
'license',
11+
'type',
12+
'exports',
13+
'main',
14+
'types',
15+
'bin',
16+
'files',
17+
'scripts',
18+
'dependencies',
19+
'devDependencies',
20+
'peerDependencies',
21+
],
22+
versionGroups: [
23+
// ─── Ignore catalog source definitions ─────────────────────────────
24+
// pnpm-workspace.yaml defines the actual versions — these are the
25+
// source of truth for catalogs and should not be pinned.
26+
{
27+
label: 'Catalog source definitions are ignored',
28+
packages: ['pnpm-workspace.yaml'],
29+
isIgnored: true,
30+
},
31+
// ─── Ignore peer dependencies ──────────────────────────────────────
32+
// Peer deps use semver ranges set by the consumer, not catalogs.
33+
{
34+
label: 'Peer dependencies are ignored',
35+
dependencyTypes: ['peer'],
36+
isIgnored: true,
37+
},
38+
// ─── Catalog enforcement: effect ecosystem ─────────────────────────
39+
{
40+
label: 'Effect packages must use catalog:effect',
41+
dependencies: [
42+
'effect',
43+
'@effect/cli',
44+
'@effect/platform',
45+
'@effect/platform-node',
46+
'@effect/vitest',
47+
],
48+
pinVersion: 'catalog:effect',
49+
},
50+
// ─── Catalog enforcement: vitest ───────────────────────────────────
51+
{
52+
label: 'Vitest must use catalog:vitest',
53+
dependencies: ['vitest'],
54+
pinVersion: 'catalog:vitest',
55+
},
56+
// ─── Catalog enforcement: vite ─────────────────────────────────────
57+
{
58+
label: 'Vite must use catalog:vite',
59+
dependencies: ['vite'],
60+
pinVersion: 'catalog:vite',
61+
},
62+
// ─── Internal workspace deps ───────────────────────────────────────
63+
{
64+
label: 'Internal @wolfcola/* packages must use workspace:*',
65+
dependencies: ['@wolfcola/*'],
66+
pinVersion: 'workspace:*',
67+
},
68+
// ─── Banned packages ───────────────────────────────────────────────
69+
{
70+
label: '@effect/schema is deprecated — use effect/Schema',
71+
dependencies: ['@effect/schema'],
72+
isBanned: true,
73+
},
74+
],
75+
};
76+
77+
export default config;

eslint.config.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export default [
1919
'**/coverage',
2020
'**/elm-stuff',
2121
'**/vite.config.*.timestamp*',
22+
'**/__fixtures__/**',
23+
'**/out-tsc/**',
2224
],
2325
},
2426
...compat.extends('plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'),
@@ -44,7 +46,6 @@ export default [
4446
'@typescript-eslint/no-use-before-define': 'warn',
4547
'@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }],
4648
'max-len': 'off',
47-
quotes: ['error', 'single', { allowTemplateLiterals: true }],
4849
},
4950
},
5051
{

lefthook.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
pre-commit:
2+
commands:
3+
syncpack-lint:
4+
run: pnpm syncpack lint
5+
stage_fixed: true
6+
eslint:
7+
glob: '*.{js,mjs,ts,mts,tsx}'
8+
exclude: '(out-tsc|__fixtures__|dist|node_modules)/'
9+
run: pnpm eslint --no-warn-ignored --fix {staged_files}
10+
stage_fixed: true
11+
prettier:
12+
glob: '*.{js,mjs,ts,mts,tsx,json,yaml,yml,md,css,html}'
13+
exclude: '(out-tsc|__fixtures__/bad-syntax|dist|node_modules)/'
14+
run: pnpm prettier --write {staged_files}
15+
stage_fixed: true

package.json

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,28 @@
2020
"test": "vitest run",
2121
"typecheck": "tsc --build",
2222
"changeset": "changeset",
23-
"version": "changeset version",
24-
"release": "pnpm build && changeset publish"
23+
"version": "changeset version && prettier --write '**/package.json' pnpm-workspace.yaml",
24+
"release": "pnpm build && changeset publish",
25+
"syncpack:lint": "syncpack lint",
26+
"syncpack:fix": "syncpack fix-mismatches",
27+
"prepare": "test \"$CI\" = 'true' || lefthook install"
2528
},
2629
"devDependencies": {
2730
"@changesets/changelog-github": "^0.6.0",
2831
"@changesets/cli": "^2.27.9",
2932
"@eslint/eslintrc": "^3.0.0",
3033
"@eslint/js": "~9.39.0",
34+
"@types/node": "^22.0.0",
3135
"@typescript-eslint/eslint-plugin": "^8.45.0",
3236
"@typescript-eslint/parser": "^8.45.0",
3337
"eslint": "^9.8.0",
3438
"eslint-config-prettier": "10.1.8",
3539
"eslint-plugin-import": "2.31.0",
3640
"eslint-plugin-prettier": "^5.2.3",
37-
"@types/node": "^22.0.0",
3841
"jsdom": "^26.1.0",
42+
"lefthook": "^2.1.6",
3943
"prettier": "^3.2.5",
44+
"syncpack": "^15.1.2",
4045
"typescript": "5.8.3",
4146
"vite": "catalog:vite",
4247
"vitest": "catalog:vitest"

packages/treeshake-check/README.md

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"name": "@wolfcola/treeshake-check",
3+
"version": "0.0.0",
4+
"description": "Check whether a package can be fully tree-shaken by Rollup",
5+
"license": "MIT",
6+
"type": "module",
7+
"repository": {
8+
"type": "git",
9+
"url": "https://github.com/ryanbas21/devtools.git",
10+
"directory": "packages/treeshake-check"
11+
},
12+
"publishConfig": {
13+
"access": "public"
14+
},
15+
"exports": {
16+
".": {
17+
"types": "./dist/index.d.ts",
18+
"import": "./dist/index.js",
19+
"default": "./dist/index.js"
20+
},
21+
"./package.json": "./package.json"
22+
},
23+
"main": "./dist/index.js",
24+
"types": "./dist/index.d.ts",
25+
"bin": {
26+
"treeshake-check": "./dist/index.js"
27+
},
28+
"files": [
29+
"dist",
30+
"!dist/test-setup.*",
31+
"!dist/*.tsbuildinfo"
32+
],
33+
"scripts": {
34+
"build": "tsc -p tsconfig.lib.json",
35+
"lint": "eslint .",
36+
"test": "vitest run"
37+
},
38+
"dependencies": {
39+
"@effect/cli": "catalog:effect",
40+
"@effect/platform": "catalog:effect",
41+
"@effect/platform-node": "catalog:effect",
42+
"@rollup/plugin-virtual": "^3.0.2",
43+
"acorn": "^8.16.0",
44+
"effect": "catalog:effect",
45+
"rollup": "^4.59.0"
46+
},
47+
"devDependencies": {
48+
"@effect/vitest": "catalog:effect",
49+
"vitest": "catalog:vitest"
50+
}
51+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
this is not valid javascript !!!###

0 commit comments

Comments
 (0)