@wolfcola/treeshake-check 0.0.0-snapshot-20260512024645

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -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.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
package/dist/index.js ADDED
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ // src/index.ts
3
+ import { Command, Options } from '@effect/cli';
4
+ import { NodeContext, NodeRuntime } from '@effect/platform-node';
5
+ import { Console, Effect, Option, Schema, pipe } from 'effect';
6
+ import { analyzeTreeshakeability, checkPackage } from './lib/treeshake-check.js';
7
+ import { EXPLANATIONS, primaryCause } from './lib/explanations.js';
8
+ import { TreeshakeResult } from './lib/schemas.js';
9
+ const tree = `
10
+ \\\\/// /Thanks
11
+ \\\\//////
12
+ |||||
13
+ |||||
14
+ |||||
15
+ .....//||||\\\\....
16
+ Awesome! Your code is 100% tree-shakeable!
17
+ `;
18
+ // ─── Options ─────────────────────────────────────────────────────────────────
19
+ const cwd = Options.directory('cwd', { exists: 'yes' }).pipe(Options.withAlias('C'), Options.withDescription('Directory containing the package.json to analyze. Defaults to the current working directory.'), Options.optional);
20
+ const entry = Options.file('entry', { exists: 'yes' }).pipe(Options.withAlias('e'), Options.withDescription('Analyze a specific entry file directly, skipping package.json resolution.'), Options.optional);
21
+ const json = Options.boolean('json').pipe(Options.withDescription('Emit machine-readable JSON instead of human-readable output.'));
22
+ const quiet = Options.boolean('quiet').pipe(Options.withAlias('q'), Options.withDescription('Suppress all output; rely on exit code only.'));
23
+ const top = Options.integer('top').pipe(Options.withDescription('Show only the N modules with the largest surviving byte count.'), Options.optional);
24
+ // ─── Rendering helpers ───────────────────────────────────────────────────────
25
+ const indent = (text, prefix = ' ') => text
26
+ .split('\n')
27
+ .map((line) => prefix + line)
28
+ .join('\n');
29
+ const SEPARATOR = ' ───────────────────────────────────────────────';
30
+ // ─── Renderers ───────────────────────────────────────────────────────────────
31
+ const renderJson = (result) => pipe(Schema.encode(TreeshakeResult)(result), Effect.flatMap((encoded) => Console.log(JSON.stringify(encoded, null, 2))));
32
+ const renderHuman = (result, topN) => Effect.gen(function* () {
33
+ if (result._tag === 'FullyTreeshakeable') {
34
+ yield* Console.info(tree);
35
+ if (result.hints.recommendations.length > 0) {
36
+ yield* Console.info('\nRecommendations:');
37
+ for (const rec of result.hints.recommendations) {
38
+ yield* Console.info(` • ${rec}`);
39
+ }
40
+ }
41
+ return;
42
+ }
43
+ // ─── Headline verdict ──────────────────────────────────────────────────
44
+ const survivedPct = (result.totalRenderedBytes / result.totalOriginalBytes) * 100;
45
+ const moduleCount = result.modules.length;
46
+ yield* Console.info('\n This package is not tree-shakeable.\n');
47
+ yield* Console.info(` When a consumer imports anything from this package, ${survivedPct.toFixed(0)}% ` +
48
+ `of its code (${result.totalRenderedBytes} of ${result.totalOriginalBytes} bytes) ` +
49
+ `gets pulled into their bundle, even if they only use a single export.\n`);
50
+ yield* Console.info(` ${moduleCount} ${moduleCount === 1 ? 'file is' : 'files are'} preventing ` +
51
+ `tree-shaking. Details below.\n`);
52
+ // ─── Per-module diagnosis ──────────────────────────────────────────────
53
+ const sorted = [...result.modules].sort((a, b) => b.renderedLength - a.renderedLength);
54
+ const shown = Option.match(topN, {
55
+ onNone: () => sorted,
56
+ onSome: (n) => sorted.slice(0, n),
57
+ });
58
+ yield* Console.info(`${SEPARATOR}\n`);
59
+ for (const [i, m] of shown.entries()) {
60
+ const cause = primaryCause(m.suspectedCauses);
61
+ const explanation = EXPLANATIONS[cause];
62
+ const filePct = (m.renderedLength / m.originalLength) * 100;
63
+ yield* Console.info(` [${i + 1}] ${m.id}\n`);
64
+ yield* Console.info(` Problem: ${explanation.summary}\n`);
65
+ yield* Console.info(` Impact: ${m.renderedLength} of ${m.originalLength} bytes ` +
66
+ `(${filePct.toFixed(0)}%) end up in consumer bundles\n`);
67
+ if (m.renderedExports.length > 0) {
68
+ yield* Console.info(` Exports affected: ${m.renderedExports.join(', ')}\n`);
69
+ }
70
+ yield* Console.info(' Why this happens:');
71
+ yield* Console.info(indent(explanation.why, ' '));
72
+ yield* Console.info('');
73
+ yield* Console.info(' How to fix:');
74
+ for (const f of explanation.fix) {
75
+ yield* Console.info(` • ${f}`);
76
+ }
77
+ if (explanation.example) {
78
+ yield* Console.info('\n Example:');
79
+ yield* Console.info(' Before:');
80
+ yield* Console.info(indent(explanation.example.before, ' '));
81
+ yield* Console.info('\n After:');
82
+ yield* Console.info(indent(explanation.example.after, ' '));
83
+ }
84
+ // Show a snippet of the actual surviving code as evidence
85
+ if (m.survivingCode && m.survivingCode.trim().length > 0) {
86
+ const snippet = m.survivingCode.length > 400
87
+ ? m.survivingCode.slice(0, 400) + '\n... (truncated)'
88
+ : m.survivingCode;
89
+ yield* Console.info('\n Surviving code:');
90
+ yield* Console.info(indent(snippet, ' '));
91
+ }
92
+ yield* Console.info(`\n${SEPARATOR}\n`);
93
+ }
94
+ // ─── Truncation notice ─────────────────────────────────────────────────
95
+ if (Option.isSome(topN) && sorted.length > Option.getOrThrow(topN)) {
96
+ yield* Console.info(` (Showing top ${shown.length} of ${sorted.length}. ` +
97
+ `Run without --top to see all, or with --json for machine-readable output.)\n`);
98
+ }
99
+ // ─── Aggregate summary when all modules share a cause ──────────────────
100
+ const uniqueCauses = new Set(result.modules.map((m) => primaryCause(m.suspectedCauses)));
101
+ if (uniqueCauses.size === 1 && result.modules.length > 1) {
102
+ const [onlyCause] = Array.from(uniqueCauses);
103
+ yield* Console.info(` Summary: All ${result.modules.length} files have the same root cause ` +
104
+ `(${EXPLANATIONS[onlyCause].summary}). Applying the fix above resolves all of them.\n`);
105
+ }
106
+ // ─── Rollup warnings, if any ───────────────────────────────────────────
107
+ if (result.warnings.length > 0) {
108
+ yield* Console.info(' Rollup warnings encountered during analysis:');
109
+ for (const w of result.warnings) {
110
+ const loc = w.loc ? ` (${w.loc.file ?? '?'}:${w.loc.line}:${w.loc.column})` : '';
111
+ yield* Console.info(` [${w.code ?? 'WARN'}] ${w.message}${loc}`);
112
+ }
113
+ yield* Console.info('');
114
+ }
115
+ // ─── Package-level recommendations ─────────────────────────────────────
116
+ if (result.hints.recommendations.length > 0) {
117
+ yield* Console.info(' Package-level recommendations:');
118
+ for (const rec of result.hints.recommendations) {
119
+ yield* Console.info(` • ${rec}`);
120
+ }
121
+ yield* Console.info('');
122
+ }
123
+ });
124
+ // ─── Command ─────────────────────────────────────────────────────────────────
125
+ const command = Command.make('treeshake-check', { cwd, entry, json, quiet, top }, ({ cwd, entry, json, quiet, top }) => Effect.gen(function* () {
126
+ const result = yield* Option.match(entry, {
127
+ onNone: () => checkPackage(Option.getOrUndefined(cwd)),
128
+ onSome: (entryPath) => analyzeTreeshakeability(entryPath),
129
+ });
130
+ if (!quiet) {
131
+ yield* json ? renderJson(result) : renderHuman(result, top);
132
+ }
133
+ // Non-zero exit when shaking failed, so this composes as a CI gate.
134
+ // Use process.exitCode (not process.exit) so any in-flight stdout
135
+ // writes flush before Node exits.
136
+ if (result._tag === 'HasSideEffects') {
137
+ yield* Effect.sync(() => {
138
+ process.exitCode = 1;
139
+ });
140
+ }
141
+ })).pipe(Command.withDescription('Check whether a package can be fully tree-shaken by Rollup.'));
142
+ const cli = Command.run(command, {
143
+ name: 'Treeshake Check',
144
+ version: '1.0.0',
145
+ });
146
+ cli(process.argv).pipe(Effect.catchTags({
147
+ PackageJsonNotFound: (e) => Console.error(`error: package.json not found at ${e.path}`).pipe(Effect.zipRight(Effect.sync(() => {
148
+ process.exitCode = 1;
149
+ }))),
150
+ MissingEntryPoint: (e) => Console.error(`error: package.json at ${e.path} has no "module" or "main" entry`).pipe(Effect.zipRight(Effect.sync(() => {
151
+ process.exitCode = 1;
152
+ }))),
153
+ BundleFailed: (e) => Console.error(`error: bundling failed — ${String(e.cause)}`).pipe(Effect.zipRight(Effect.sync(() => {
154
+ process.exitCode = 1;
155
+ }))),
156
+ ParseError: (e) => Console.error(`error: invalid package.json — ${e.message}`).pipe(Effect.zipRight(Effect.sync(() => {
157
+ process.exitCode = 1;
158
+ }))),
159
+ }), Effect.provide(NodeContext.layer), NodeRuntime.runMain);
@@ -0,0 +1,33 @@
1
+ import type { ModuleAnalysis, PackageJson, PackageJsonHints, SuspectedCause } from './schemas.js';
2
+ /**
3
+ * Resolve the ESM entry point from a package.json.
4
+ *
5
+ * Priority: exports["." | flat] → module → main
6
+ * Within the exports conditions, priority is: import → module → default
7
+ * Returns undefined when no usable ESM entry can be found (e.g. require-only).
8
+ */
9
+ export declare const resolveEntry: (pkg: PackageJson) => string | undefined;
10
+ export declare const detectCauses: (code: string) => ReadonlyArray<SuspectedCause>;
11
+ /**
12
+ * Build a single ModuleAnalysis from rollup's per-module rendered info.
13
+ */
14
+ export declare const buildModuleAnalysis: (id: string, m: {
15
+ originalLength: number;
16
+ renderedLength: number;
17
+ renderedExports: ReadonlyArray<string>;
18
+ removedExports: ReadonlyArray<string>;
19
+ code: string | null;
20
+ }) => ModuleAnalysis;
21
+ /**
22
+ * Inspect a parsed package.json and produce hints + recommendations.
23
+ *
24
+ * The single highest-leverage fix for most real-world libraries is
25
+ * declaring `"sideEffects": false`, so that recommendation comes first.
26
+ */
27
+ export declare const analyzePackageJsonHints: (pkg: PackageJson) => PackageJsonHints;
28
+ /**
29
+ * Default hints for cases where we analyze a raw entry path without a
30
+ * package.json (e.g., when the user passes --entry directly).
31
+ */
32
+ export declare const defaultHints: () => PackageJsonHints;
33
+ //# sourceMappingURL=analysis.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"analysis.d.ts","sourceRoot":"","sources":["../../src/lib/analysis.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AA4BlG;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,GAAI,KAAK,WAAW,KAAG,MAAM,GAAG,SAwBxD,CAAC;AAEF,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,KAAG,aAAa,CAAC,cAAc,CAsCvE,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,mBAAmB,GAC9B,IAAI,MAAM,EACV,GAAG;IACD,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC,cAAc,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACtC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB,KACA,cASD,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GAAI,KAAK,WAAW,KAAG,gBAqC1D,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,YAAY,QAAO,gBAK9B,CAAC"}
@@ -0,0 +1,144 @@
1
+ // src/lib/analysis.ts
2
+ import * as acorn from 'acorn';
3
+ // Walk the AST to find top-level ExpressionStatement → CallExpression nodes
4
+ // that are not preceded by a /*#__PURE__*/ annotation. Falls back to a regex
5
+ // heuristic when acorn cannot parse the code (e.g. unparseable rollup output).
6
+ const detectTopLevelCall = (code) => {
7
+ let ast;
8
+ try {
9
+ ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: 'module' });
10
+ }
11
+ catch {
12
+ const topLevelCall = /^(?!.*\/\*#__PURE__\*\/)\s*[a-zA-Z_$][\w$]*\s*\(/m;
13
+ return topLevelCall.test(code);
14
+ }
15
+ return ast.body.some((node) => {
16
+ if (node.type !== 'ExpressionStatement')
17
+ return false;
18
+ const expr = node.expression;
19
+ if (expr.type !== 'CallExpression')
20
+ return false;
21
+ // Check for a /*#__PURE__*/ annotation immediately before this node
22
+ const preceding = code.slice(0, node.start);
23
+ const annotationIdx = preceding.lastIndexOf('/*#__PURE__*/');
24
+ if (annotationIdx === -1)
25
+ return true;
26
+ const between = preceding.slice(annotationIdx + '/*#__PURE__*/'.length);
27
+ return !/^\s*$/.test(between);
28
+ });
29
+ };
30
+ /**
31
+ * Resolve the ESM entry point from a package.json.
32
+ *
33
+ * Priority: exports["." | flat] → module → main
34
+ * Within the exports conditions, priority is: import → module → default
35
+ * Returns undefined when no usable ESM entry can be found (e.g. require-only).
36
+ */
37
+ export const resolveEntry = (pkg) => {
38
+ const { exports } = pkg;
39
+ if (exports !== undefined) {
40
+ if (typeof exports === 'string')
41
+ return exports;
42
+ // exports is a record — determine whether it's a subpath map or flat conditions
43
+ const dotEntry = exports['.'];
44
+ const conditions = dotEntry !== undefined ? dotEntry : exports;
45
+ if (typeof conditions === 'string')
46
+ return conditions;
47
+ if (conditions !== null && typeof conditions === 'object') {
48
+ const c = conditions;
49
+ const candidate = c['import'] ?? c['module'] ?? c['default'];
50
+ if (typeof candidate === 'string')
51
+ return candidate;
52
+ }
53
+ // No usable ESM condition found in exports — don't fall through to module/main.
54
+ // Falling through would return a CJS path dressed as ESM.
55
+ return undefined;
56
+ }
57
+ return pkg.module ?? pkg.main;
58
+ };
59
+ export const detectCauses = (code) => {
60
+ const causes = new Set();
61
+ // TypeScript enum IIFE: `(function (X) { ... })(X || (X = {}));`
62
+ if (/\(function\s*\([A-Z_$][\w$]*\)\s*\{[\s\S]*?\}\)\s*\(\s*[A-Z_$][\w$]*\s*\|\|\s*\(\s*[A-Z_$][\w$]*\s*=\s*\{\s*\}\s*\)\s*\)/.test(code)) {
63
+ causes.add('EnumPattern');
64
+ }
65
+ // CJS contamination
66
+ if (/\b(require\s*\(|module\.exports|exports\.[a-zA-Z_$]|__esModule)/.test(code)) {
67
+ causes.add('CommonJsContamination');
68
+ }
69
+ // Prototype mutations / Object.defineProperty
70
+ if (/Object\.(defineProperty|defineProperties|assign|setPrototypeOf|freeze)\s*\(/.test(code)) {
71
+ causes.add('PrototypeMutation');
72
+ }
73
+ if (/\.prototype\.[a-zA-Z_$]+\s*=/.test(code)) {
74
+ causes.add('PrototypeMutation');
75
+ }
76
+ // Global assignment
77
+ if (/^\s*(window|globalThis|self|global)\.[a-zA-Z_$]/m.test(code)) {
78
+ causes.add('GlobalAssignment');
79
+ }
80
+ // Bare top-level call without /*#__PURE__*/ — AST-based, regex fallback
81
+ if (!causes.has('EnumPattern') && detectTopLevelCall(code)) {
82
+ causes.add('UnannotatedCall');
83
+ causes.add('TopLevelSideEffect');
84
+ }
85
+ if (causes.size === 0)
86
+ causes.add('Unknown');
87
+ return Array.from(causes);
88
+ };
89
+ /**
90
+ * Build a single ModuleAnalysis from rollup's per-module rendered info.
91
+ */
92
+ export const buildModuleAnalysis = (id, m) => ({
93
+ id,
94
+ originalLength: m.originalLength,
95
+ renderedLength: m.renderedLength,
96
+ shakingRatio: m.originalLength === 0 ? 0 : m.renderedLength / m.originalLength,
97
+ renderedExports: [...m.renderedExports],
98
+ removedExports: [...m.removedExports],
99
+ survivingCode: m.code,
100
+ suspectedCauses: m.code ? detectCauses(m.code) : ['Unknown'],
101
+ });
102
+ /**
103
+ * Inspect a parsed package.json and produce hints + recommendations.
104
+ *
105
+ * The single highest-leverage fix for most real-world libraries is
106
+ * declaring `"sideEffects": false`, so that recommendation comes first.
107
+ */
108
+ export const analyzePackageJsonHints = (pkg) => {
109
+ const hasSideEffectsField = pkg.sideEffects !== undefined;
110
+ const hasModuleField = pkg.module !== undefined;
111
+ const hasTypeModule = pkg.type === 'module';
112
+ const recommendations = [];
113
+ if (!hasSideEffectsField) {
114
+ recommendations.push('Add "sideEffects": false to package.json. Without it, bundlers ' +
115
+ 'conservatively assume every module may have side effects, which ' +
116
+ 'blocks aggressive tree-shaking by consumers.');
117
+ }
118
+ if (!hasModuleField && pkg.main !== undefined) {
119
+ recommendations.push('Add a "module" field pointing to an ESM build. The "main" field ' +
120
+ 'traditionally points to CommonJS, which cannot be statically ' +
121
+ 'analyzed for tree-shaking.');
122
+ }
123
+ if (!hasTypeModule && !hasModuleField) {
124
+ recommendations.push('Add "type": "module" to package.json, or provide a separate ' +
125
+ '"module" entry for ESM consumers.');
126
+ }
127
+ return {
128
+ hasSideEffectsField,
129
+ sideEffectsValue: pkg.sideEffects,
130
+ hasModuleField,
131
+ hasTypeModule,
132
+ recommendations,
133
+ };
134
+ };
135
+ /**
136
+ * Default hints for cases where we analyze a raw entry path without a
137
+ * package.json (e.g., when the user passes --entry directly).
138
+ */
139
+ export const defaultHints = () => ({
140
+ hasSideEffectsField: false,
141
+ hasModuleField: false,
142
+ hasTypeModule: false,
143
+ recommendations: [],
144
+ });
@@ -0,0 +1,22 @@
1
+ import type { SuspectedCause } from './schemas.js';
2
+ export interface CauseExplanation {
3
+ /** One-line description of what was detected. */
4
+ readonly summary: string;
5
+ /** Why this prevents tree-shaking. */
6
+ readonly why: string;
7
+ /** Concrete steps the user can take. */
8
+ readonly fix: ReadonlyArray<string>;
9
+ /** Optional code example showing the fix. */
10
+ readonly example?: {
11
+ before: string;
12
+ after: string;
13
+ };
14
+ }
15
+ export declare const EXPLANATIONS: Record<SuspectedCause, CauseExplanation>;
16
+ /**
17
+ * Pick the most informative cause when a module has multiple labels.
18
+ * EnumPattern wins over UnannotatedCall, etc., so users get the most
19
+ * specific explanation rather than a generic one.
20
+ */
21
+ export declare const primaryCause: (causes: ReadonlyArray<SuspectedCause>) => SuspectedCause;
22
+ //# sourceMappingURL=explanations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"explanations.d.ts","sourceRoot":"","sources":["../../src/lib/explanations.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAEnD,MAAM,WAAW,gBAAgB;IAC/B,iDAAiD;IACjD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,sCAAsC;IACtC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,wCAAwC;IACxC,QAAQ,CAAC,GAAG,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACpC,6CAA6C;IAC7C,QAAQ,CAAC,OAAO,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CACtD;AAED,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,cAAc,EAAE,gBAAgB,CA6GjE,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,YAAY,GAAI,QAAQ,aAAa,CAAC,cAAc,CAAC,KAAG,cAcpE,CAAC"}
@@ -0,0 +1,117 @@
1
+ export const EXPLANATIONS = {
2
+ EnumPattern: {
3
+ summary: 'TypeScript enum',
4
+ why: 'TypeScript compiles `enum` declarations into an IIFE that mutates a ' +
5
+ 'module-scoped variable. Rollup sees the mutation and conservatively ' +
6
+ 'assumes the module has observable side effects, even when no one is ' +
7
+ 'using the enum.',
8
+ fix: [
9
+ 'Replace `enum` with an `as const` object plus a derived type. This ' +
10
+ 'compiles to a plain object literal that Rollup can statically analyze.',
11
+ 'For published packages, also add `"sideEffects": false` to ' +
12
+ 'package.json so consumers benefit from the change.',
13
+ ],
14
+ example: {
15
+ before: 'export enum StepType {\n' + ' LOGIN = "LOGIN",\n' + ' LOGOUT = "LOGOUT",\n' + '}',
16
+ after: 'export const StepType = {\n' +
17
+ ' LOGIN: "LOGIN",\n' +
18
+ ' LOGOUT: "LOGOUT",\n' +
19
+ '} as const;\n' +
20
+ 'export type StepType = typeof StepType[keyof typeof StepType];',
21
+ },
22
+ },
23
+ PrototypeMutation: {
24
+ summary: 'prototype or property mutation at module scope',
25
+ why: 'Calls like `Object.defineProperty`, `Object.assign`, or assignments ' +
26
+ 'to `.prototype` at the top level run when the module is imported, so ' +
27
+ 'Rollup keeps them in the bundle.',
28
+ fix: [
29
+ 'Move the mutation inside a function that callers explicitly invoke.',
30
+ 'If the call is genuinely pure (e.g., defining a property on a ' +
31
+ 'module-local object), wrap it in a `/*#__PURE__*/` annotation.',
32
+ 'For library code that legitimately needs side effects on import ' +
33
+ "(polyfills, registrations), declare the file in `package.json`'s " +
34
+ "`sideEffects` array so it's explicitly opted in.",
35
+ ],
36
+ },
37
+ GlobalAssignment: {
38
+ summary: 'assignment to a global object',
39
+ why: 'Assignments to `window`, `globalThis`, `self`, or `global` at module ' +
40
+ 'scope are observable side effects — they affect state outside the ' +
41
+ 'module — and can never be tree-shaken.',
42
+ fix: [
43
+ 'If this is a polyfill or registration, declare the file in ' +
44
+ "`package.json`'s `sideEffects` array so consumers know it has to run.",
45
+ 'If the global assignment is opportunistic (e.g., exposing a debug API), ' +
46
+ 'consider moving it to a separately-imported entry point so the main ' +
47
+ 'entry stays shakeable.',
48
+ ],
49
+ },
50
+ CommonJsContamination: {
51
+ summary: 'CommonJS code in the bundle',
52
+ why: '`require()`, `module.exports`, and `__esModule` markers indicate ' +
53
+ "CommonJS code, which Rollup can't statically analyze for tree-shaking. " +
54
+ 'This usually means a transitive dependency ships only CJS, or your ' +
55
+ 'build is producing CJS output.',
56
+ fix: [
57
+ 'Verify your build emits ESM. Check `package.json` for a `"module"` ' +
58
+ 'field or `"type": "module"`.',
59
+ 'If a dependency is the source, look for an ESM-only alternative or ' +
60
+ 'check whether the dep has an `exports` field with an `import` condition.',
61
+ 'For unavoidable CJS deps, mark them as external in your build so they ' +
62
+ "don't get bundled into your output.",
63
+ ],
64
+ },
65
+ UnannotatedCall: {
66
+ summary: 'top-level function call',
67
+ why: 'A bare function call at module scope is treated as side-effectful by ' +
68
+ "default — Rollup doesn't know whether the call mutates state, prints " +
69
+ 'to console, or registers something globally.',
70
+ fix: [
71
+ 'If you know the call is pure, prefix it with `/*#__PURE__*/`:\n' +
72
+ ' const result = /*#__PURE__*/ computeOnce();',
73
+ 'If the call genuinely has side effects, move it inside a function ' +
74
+ 'that consumers explicitly invoke.',
75
+ ],
76
+ },
77
+ TopLevelSideEffect: {
78
+ summary: 'top-level statement with side effects',
79
+ why: 'Some statement at the top of the module runs when imported and ' +
80
+ "Rollup can't prove it's safe to eliminate.",
81
+ fix: [
82
+ 'Move side-effecting code into an exported function.',
83
+ 'For pure expressions, wrap them in `/*#__PURE__*/`.',
84
+ ],
85
+ },
86
+ Unknown: {
87
+ summary: 'unknown side effect',
88
+ why: "The heuristic patterns didn't match this code, but Rollup decided to " +
89
+ 'keep it. Look at the surviving code below to identify the cause manually.',
90
+ fix: [
91
+ 'Read the surviving code in the breakdown above to identify the side effect.',
92
+ 'Common causes the heuristic might miss: getters, decorators, ' +
93
+ 'destructuring with defaults, or class field initializers.',
94
+ ],
95
+ },
96
+ };
97
+ /**
98
+ * Pick the most informative cause when a module has multiple labels.
99
+ * EnumPattern wins over UnannotatedCall, etc., so users get the most
100
+ * specific explanation rather than a generic one.
101
+ */
102
+ export const primaryCause = (causes) => {
103
+ const priority = [
104
+ 'EnumPattern',
105
+ 'CommonJsContamination',
106
+ 'GlobalAssignment',
107
+ 'PrototypeMutation',
108
+ 'UnannotatedCall',
109
+ 'TopLevelSideEffect',
110
+ 'Unknown',
111
+ ];
112
+ for (const p of priority) {
113
+ if (causes.includes(p))
114
+ return p;
115
+ }
116
+ return 'Unknown';
117
+ };
@@ -0,0 +1,103 @@
1
+ import { Schema } from 'effect';
2
+ export declare const SideEffectsValue: Schema.Union<[typeof Schema.Boolean, Schema.Array$<typeof Schema.String>]>;
3
+ export declare const ExportsConditions: Schema.Record$<typeof Schema.String, Schema.Union<[typeof Schema.String, typeof Schema.Unknown]>>;
4
+ export declare const ExportsField: Schema.Union<[typeof Schema.String, Schema.Record$<typeof Schema.String, Schema.Union<[typeof Schema.String, typeof Schema.Unknown]>>]>;
5
+ export declare const PackageJson: Schema.Struct<{
6
+ name: Schema.optional<typeof Schema.String>;
7
+ module: Schema.optional<typeof Schema.String>;
8
+ main: Schema.optional<typeof Schema.String>;
9
+ exports: Schema.optional<Schema.Union<[typeof Schema.String, Schema.Record$<typeof Schema.String, Schema.Union<[typeof Schema.String, typeof Schema.Unknown]>>]>>;
10
+ type: Schema.optional<Schema.Literal<["module", "commonjs"]>>;
11
+ sideEffects: Schema.optional<Schema.Union<[typeof Schema.Boolean, Schema.Array$<typeof Schema.String>]>>;
12
+ dependencies: Schema.optional<typeof Schema.Unknown>;
13
+ peerDependencies: Schema.optional<typeof Schema.Unknown>;
14
+ devDependencies: Schema.optional<typeof Schema.Unknown>;
15
+ }>;
16
+ export type PackageJson = typeof PackageJson.Type;
17
+ export declare const PackageJsonFromString: Schema.transform<Schema.SchemaClass<unknown, string, never>, Schema.Struct<{
18
+ name: Schema.optional<typeof Schema.String>;
19
+ module: Schema.optional<typeof Schema.String>;
20
+ main: Schema.optional<typeof Schema.String>;
21
+ exports: Schema.optional<Schema.Union<[typeof Schema.String, Schema.Record$<typeof Schema.String, Schema.Union<[typeof Schema.String, typeof Schema.Unknown]>>]>>;
22
+ type: Schema.optional<Schema.Literal<["module", "commonjs"]>>;
23
+ sideEffects: Schema.optional<Schema.Union<[typeof Schema.Boolean, Schema.Array$<typeof Schema.String>]>>;
24
+ dependencies: Schema.optional<typeof Schema.Unknown>;
25
+ peerDependencies: Schema.optional<typeof Schema.Unknown>;
26
+ devDependencies: Schema.optional<typeof Schema.Unknown>;
27
+ }>>;
28
+ export declare const SuspectedCause: Schema.Literal<["TopLevelSideEffect", "PrototypeMutation", "GlobalAssignment", "CommonJsContamination", "UnannotatedCall", "EnumPattern", "Unknown"]>;
29
+ export type SuspectedCause = typeof SuspectedCause.Type;
30
+ export declare const ModuleAnalysis: Schema.Struct<{
31
+ id: typeof Schema.String;
32
+ originalLength: typeof Schema.Number;
33
+ renderedLength: typeof Schema.Number;
34
+ shakingRatio: typeof Schema.Number;
35
+ renderedExports: Schema.Array$<typeof Schema.String>;
36
+ removedExports: Schema.Array$<typeof Schema.String>;
37
+ survivingCode: Schema.NullOr<typeof Schema.String>;
38
+ suspectedCauses: Schema.Array$<Schema.Literal<["TopLevelSideEffect", "PrototypeMutation", "GlobalAssignment", "CommonJsContamination", "UnannotatedCall", "EnumPattern", "Unknown"]>>;
39
+ }>;
40
+ export type ModuleAnalysis = typeof ModuleAnalysis.Type;
41
+ export declare const RollupWarning: Schema.Struct<{
42
+ code: Schema.optional<typeof Schema.String>;
43
+ message: typeof Schema.String;
44
+ id: Schema.optional<typeof Schema.String>;
45
+ loc: Schema.optional<Schema.Struct<{
46
+ file: Schema.optional<typeof Schema.String>;
47
+ line: typeof Schema.Number;
48
+ column: typeof Schema.Number;
49
+ }>>;
50
+ }>;
51
+ export type RollupWarning = typeof RollupWarning.Type;
52
+ export declare const PackageJsonHints: Schema.Struct<{
53
+ hasSideEffectsField: typeof Schema.Boolean;
54
+ sideEffectsValue: Schema.optional<Schema.Union<[typeof Schema.Boolean, Schema.Array$<typeof Schema.String>]>>;
55
+ hasModuleField: typeof Schema.Boolean;
56
+ hasTypeModule: typeof Schema.Boolean;
57
+ recommendations: Schema.Array$<typeof Schema.String>;
58
+ }>;
59
+ export type PackageJsonHints = typeof PackageJsonHints.Type;
60
+ export declare const TreeshakeResult: Schema.Union<[Schema.Struct<{
61
+ _tag: Schema.Literal<["FullyTreeshakeable"]>;
62
+ hints: Schema.Struct<{
63
+ hasSideEffectsField: typeof Schema.Boolean;
64
+ sideEffectsValue: Schema.optional<Schema.Union<[typeof Schema.Boolean, Schema.Array$<typeof Schema.String>]>>;
65
+ hasModuleField: typeof Schema.Boolean;
66
+ hasTypeModule: typeof Schema.Boolean;
67
+ recommendations: Schema.Array$<typeof Schema.String>;
68
+ }>;
69
+ }>, Schema.Struct<{
70
+ _tag: Schema.Literal<["HasSideEffects"]>;
71
+ totalOriginalBytes: typeof Schema.Number;
72
+ totalRenderedBytes: typeof Schema.Number;
73
+ modules: Schema.Array$<Schema.Struct<{
74
+ id: typeof Schema.String;
75
+ originalLength: typeof Schema.Number;
76
+ renderedLength: typeof Schema.Number;
77
+ shakingRatio: typeof Schema.Number;
78
+ renderedExports: Schema.Array$<typeof Schema.String>;
79
+ removedExports: Schema.Array$<typeof Schema.String>;
80
+ survivingCode: Schema.NullOr<typeof Schema.String>;
81
+ suspectedCauses: Schema.Array$<Schema.Literal<["TopLevelSideEffect", "PrototypeMutation", "GlobalAssignment", "CommonJsContamination", "UnannotatedCall", "EnumPattern", "Unknown"]>>;
82
+ }>>;
83
+ warnings: Schema.Array$<Schema.Struct<{
84
+ code: Schema.optional<typeof Schema.String>;
85
+ message: typeof Schema.String;
86
+ id: Schema.optional<typeof Schema.String>;
87
+ loc: Schema.optional<Schema.Struct<{
88
+ file: Schema.optional<typeof Schema.String>;
89
+ line: typeof Schema.Number;
90
+ column: typeof Schema.Number;
91
+ }>>;
92
+ }>>;
93
+ hints: Schema.Struct<{
94
+ hasSideEffectsField: typeof Schema.Boolean;
95
+ sideEffectsValue: Schema.optional<Schema.Union<[typeof Schema.Boolean, Schema.Array$<typeof Schema.String>]>>;
96
+ hasModuleField: typeof Schema.Boolean;
97
+ hasTypeModule: typeof Schema.Boolean;
98
+ recommendations: Schema.Array$<typeof Schema.String>;
99
+ }>;
100
+ unshakenCode: typeof Schema.String;
101
+ }>]>;
102
+ export type TreeshakeResult = typeof TreeshakeResult.Type;
103
+ //# sourceMappingURL=schemas.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../../src/lib/schemas.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAIhC,eAAO,MAAM,gBAAgB,4EAA4D,CAAC;AAG1F,eAAO,MAAM,iBAAiB,mGAG5B,CAAC;AAGH,eAAO,MAAM,YAAY,yIAAiD,CAAC;AAE3E,eAAO,MAAM,WAAW;;;;;;;;;;EAUtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,OAAO,WAAW,CAAC,IAAI,CAAC;AAGlD,eAAO,MAAM,qBAAqB;;;;;;;;;;GAAgC,CAAC;AAInE,eAAO,MAAM,cAAc,uJAQ1B,CAAC;AACF,MAAM,MAAM,cAAc,GAAG,OAAO,cAAc,CAAC,IAAI,CAAC;AAExD,eAAO,MAAM,cAAc;;;;;;;;;EAUzB,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,OAAO,cAAc,CAAC,IAAI,CAAC;AAIxD,eAAO,MAAM,aAAa;;;;;;;;;EAWxB,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,OAAO,aAAa,CAAC,IAAI,CAAC;AAItD,eAAO,MAAM,gBAAgB;;;;;;EAM3B,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,OAAO,gBAAgB,CAAC,IAAI,CAAC;AAI5D,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAc3B,CAAC;AACF,MAAM,MAAM,eAAe,GAAG,OAAO,eAAe,CAAC,IAAI,CAAC"}
@@ -0,0 +1,69 @@
1
+ // src/lib/schemas.ts
2
+ import { Schema } from 'effect';
3
+ // ─── package.json (the bits we care about) ───────────────────────────────────
4
+ export const SideEffectsValue = Schema.Union(Schema.Boolean, Schema.Array(Schema.String));
5
+ // Conditions object: { import?: string, module?: string, require?: string, default?: string, ... }
6
+ export const ExportsConditions = Schema.Record({
7
+ key: Schema.String,
8
+ value: Schema.Union(Schema.String, Schema.Unknown),
9
+ });
10
+ // exports field: string | conditions | { ".": conditions | string }
11
+ export const ExportsField = Schema.Union(Schema.String, ExportsConditions);
12
+ export const PackageJson = Schema.Struct({
13
+ name: Schema.optional(Schema.String),
14
+ module: Schema.optional(Schema.String),
15
+ main: Schema.optional(Schema.String),
16
+ exports: Schema.optional(ExportsField),
17
+ type: Schema.optional(Schema.Literal('module', 'commonjs')),
18
+ sideEffects: Schema.optional(SideEffectsValue),
19
+ dependencies: Schema.optional(Schema.Unknown),
20
+ peerDependencies: Schema.optional(Schema.Unknown),
21
+ devDependencies: Schema.optional(Schema.Unknown),
22
+ });
23
+ // JSON-string → validated PackageJson in one shot
24
+ export const PackageJsonFromString = Schema.parseJson(PackageJson);
25
+ // ─── Per-module analysis ─────────────────────────────────────────────────────
26
+ export const SuspectedCause = Schema.Literal('TopLevelSideEffect', 'PrototypeMutation', 'GlobalAssignment', 'CommonJsContamination', 'UnannotatedCall', 'EnumPattern', 'Unknown');
27
+ export const ModuleAnalysis = Schema.Struct({
28
+ id: Schema.String,
29
+ originalLength: Schema.Number,
30
+ renderedLength: Schema.Number,
31
+ // 0 = fully shaken, 1 = nothing shaken
32
+ shakingRatio: Schema.Number,
33
+ renderedExports: Schema.Array(Schema.String),
34
+ removedExports: Schema.Array(Schema.String),
35
+ survivingCode: Schema.NullOr(Schema.String),
36
+ suspectedCauses: Schema.Array(SuspectedCause),
37
+ });
38
+ // ─── Rollup warnings (captured during build) ─────────────────────────────────
39
+ export const RollupWarning = Schema.Struct({
40
+ code: Schema.optional(Schema.String),
41
+ message: Schema.String,
42
+ id: Schema.optional(Schema.String),
43
+ loc: Schema.optional(Schema.Struct({
44
+ file: Schema.optional(Schema.String),
45
+ line: Schema.Number,
46
+ column: Schema.Number,
47
+ })),
48
+ });
49
+ // ─── package.json hints ──────────────────────────────────────────────────────
50
+ export const PackageJsonHints = Schema.Struct({
51
+ hasSideEffectsField: Schema.Boolean,
52
+ sideEffectsValue: Schema.optional(SideEffectsValue),
53
+ hasModuleField: Schema.Boolean,
54
+ hasTypeModule: Schema.Boolean,
55
+ recommendations: Schema.Array(Schema.String),
56
+ });
57
+ // ─── Top-level result ────────────────────────────────────────────────────────
58
+ export const TreeshakeResult = Schema.Union(Schema.Struct({
59
+ _tag: Schema.Literal('FullyTreeshakeable'),
60
+ hints: PackageJsonHints,
61
+ }), Schema.Struct({
62
+ _tag: Schema.Literal('HasSideEffects'),
63
+ totalOriginalBytes: Schema.Number,
64
+ totalRenderedBytes: Schema.Number,
65
+ modules: Schema.Array(ModuleAnalysis),
66
+ warnings: Schema.Array(RollupWarning),
67
+ hints: PackageJsonHints,
68
+ unshakenCode: Schema.String,
69
+ }));
@@ -0,0 +1,95 @@
1
+ import { FileSystem, Path } from '@effect/platform';
2
+ import { Effect } from 'effect';
3
+ import { type PackageJson, type TreeshakeResult } from './schemas.js';
4
+ declare const PackageJsonNotFound_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
5
+ readonly _tag: "PackageJsonNotFound";
6
+ } & Readonly<A>;
7
+ export declare class PackageJsonNotFound extends PackageJsonNotFound_base<{
8
+ readonly path: string;
9
+ }> {
10
+ }
11
+ declare const MissingEntryPoint_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
12
+ readonly _tag: "MissingEntryPoint";
13
+ } & Readonly<A>;
14
+ export declare class MissingEntryPoint extends MissingEntryPoint_base<{
15
+ readonly path: string;
16
+ }> {
17
+ }
18
+ declare const BundleFailed_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
19
+ readonly _tag: "BundleFailed";
20
+ } & Readonly<A>;
21
+ export declare class BundleFailed extends BundleFailed_base<{
22
+ readonly cause: unknown;
23
+ }> {
24
+ }
25
+ export declare const getEntryFromPackageJson: (cwd?: string) => Effect.Effect<{
26
+ readonly entry: string;
27
+ readonly pkg: {
28
+ readonly name?: string | undefined;
29
+ readonly module?: string | undefined;
30
+ readonly main?: string | undefined;
31
+ readonly exports?: string | {
32
+ readonly [x: string]: unknown;
33
+ } | undefined;
34
+ readonly type?: "module" | "commonjs" | undefined;
35
+ readonly sideEffects?: boolean | readonly string[] | undefined;
36
+ readonly dependencies?: unknown;
37
+ readonly peerDependencies?: unknown;
38
+ readonly devDependencies?: unknown;
39
+ };
40
+ }, PackageJsonNotFound | MissingEntryPoint | import("effect/ParseResult").ParseError | import("@effect/platform/Error").PlatformError, Path.Path | FileSystem.FileSystem>;
41
+ /**
42
+ * Bundle the entry as the *only* import in a synthetic virtual module.
43
+ * If rollup can statically determine the entry has no observable side
44
+ * effects, every real module renders to zero bytes — that's our "fully
45
+ * shakeable" signal. Anything that survives is what's preventing
46
+ * tree-shaking.
47
+ */
48
+ export declare const analyzeTreeshakeability: (entry: string, pkg?: PackageJson) => Effect.Effect<TreeshakeResult, BundleFailed, Path.Path>;
49
+ /**
50
+ * Full check: read package.json from `cwd`, resolve its entry, then analyze.
51
+ */
52
+ export declare const checkPackage: (cwd?: string) => Effect.Effect<{
53
+ readonly _tag: "FullyTreeshakeable";
54
+ readonly hints: {
55
+ readonly hasSideEffectsField: boolean;
56
+ readonly sideEffectsValue?: boolean | readonly string[] | undefined;
57
+ readonly hasModuleField: boolean;
58
+ readonly hasTypeModule: boolean;
59
+ readonly recommendations: readonly string[];
60
+ };
61
+ } | {
62
+ readonly _tag: "HasSideEffects";
63
+ readonly hints: {
64
+ readonly hasSideEffectsField: boolean;
65
+ readonly sideEffectsValue?: boolean | readonly string[] | undefined;
66
+ readonly hasModuleField: boolean;
67
+ readonly hasTypeModule: boolean;
68
+ readonly recommendations: readonly string[];
69
+ };
70
+ readonly totalOriginalBytes: number;
71
+ readonly totalRenderedBytes: number;
72
+ readonly modules: readonly {
73
+ readonly id: string;
74
+ readonly originalLength: number;
75
+ readonly renderedLength: number;
76
+ readonly shakingRatio: number;
77
+ readonly renderedExports: readonly string[];
78
+ readonly removedExports: readonly string[];
79
+ readonly survivingCode: string | null;
80
+ readonly suspectedCauses: readonly ("TopLevelSideEffect" | "PrototypeMutation" | "GlobalAssignment" | "CommonJsContamination" | "UnannotatedCall" | "EnumPattern" | "Unknown")[];
81
+ }[];
82
+ readonly warnings: readonly {
83
+ readonly id?: string | undefined;
84
+ readonly code?: string | undefined;
85
+ readonly message: string;
86
+ readonly loc?: {
87
+ readonly file?: string | undefined;
88
+ readonly line: number;
89
+ readonly column: number;
90
+ } | undefined;
91
+ }[];
92
+ readonly unshakenCode: string;
93
+ }, PackageJsonNotFound | MissingEntryPoint | BundleFailed | import("effect/ParseResult").ParseError | import("@effect/platform/Error").PlatformError, Path.Path | FileSystem.FileSystem>;
94
+ export {};
95
+ //# sourceMappingURL=treeshake-check.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"treeshake-check.d.ts","sourceRoot":"","sources":["../../src/lib/treeshake-check.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAQ,MAAM,EAAgB,MAAM,QAAQ,CAAC;AASpD,OAAO,EAEL,KAAK,WAAW,EAEhB,KAAK,eAAe,EACrB,MAAM,cAAc,CAAC;;;;AAOtB,qBAAa,mBAAoB,SAAQ,yBAAwC;IAC/E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB,CAAC;CAAG;;;;AAEL,qBAAa,iBAAkB,SAAQ,uBAAsC;IAC3E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB,CAAC;CAAG;;;;AAEL,qBAAa,YAAa,SAAQ,kBAAiC;IACjE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACzB,CAAC;CAAG;AAqBL,eAAO,MAAM,uBAAuB,GAAI,MAAM,MAAM;;;;;;;;;;;;;;;yKASjD,CAAC;AA2BJ;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,GAClC,OAAO,MAAM,EACb,MAAM,WAAW,KAChB,MAAM,CAAC,MAAM,CAAC,eAAe,EAAE,YAAY,EAAE,IAAI,CAAC,IAAI,CAmEjC,CAAC;AAIzB;;GAEG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wLAItC,CAAC"}
@@ -0,0 +1,115 @@
1
+ // src/lib/treeshake-check.ts
2
+ import { FileSystem, Path } from '@effect/platform';
3
+ import { Data, Effect, Schema, pipe } from 'effect';
4
+ import virtualPlugin from '@rollup/plugin-virtual';
5
+ import { rollup } from 'rollup';
6
+ import { analyzePackageJsonHints, buildModuleAnalysis, defaultHints, resolveEntry, } from './analysis.js';
7
+ import { PackageJsonFromString, } from './schemas.js';
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- interop default export
9
+ const virtual = virtualPlugin;
10
+ // ─── Errors ──────────────────────────────────────────────────────────────────
11
+ export class PackageJsonNotFound extends Data.TaggedError('PackageJsonNotFound') {
12
+ }
13
+ export class MissingEntryPoint extends Data.TaggedError('MissingEntryPoint') {
14
+ }
15
+ export class BundleFailed extends Data.TaggedError('BundleFailed') {
16
+ }
17
+ // ─── Read & validate package.json ────────────────────────────────────────────
18
+ const readPackageJson = (cwd) => Effect.gen(function* () {
19
+ const fs = yield* FileSystem.FileSystem;
20
+ const path = yield* Path.Path;
21
+ const pkgPath = path.join(cwd, 'package.json');
22
+ const exists = yield* fs.exists(pkgPath);
23
+ if (!exists) {
24
+ return yield* new PackageJsonNotFound({ path: pkgPath });
25
+ }
26
+ const contents = yield* fs.readFileString(pkgPath, 'utf-8');
27
+ const pkg = yield* Schema.decodeUnknown(PackageJsonFromString)(contents);
28
+ return { pkg, pkgPath };
29
+ });
30
+ export const getEntryFromPackageJson = (cwd) => pipe(readPackageJson(cwd ?? process.cwd()), Effect.flatMap(({ pkg, pkgPath }) => {
31
+ const entry = resolveEntry(pkg);
32
+ return entry !== undefined
33
+ ? Effect.succeed({ entry, pkg })
34
+ : new MissingEntryPoint({ path: pkgPath });
35
+ }));
36
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
37
+ const closeBundle = (bundle) => Effect.tryPromise(() => bundle.close()).pipe(Effect.catchAll((cause) => Effect.logWarning('Failed to close rollup bundle').pipe(Effect.annotateLogs('cause', String(cause)))));
38
+ /**
39
+ * Drop the synthetic virtual entry — `@rollup/plugin-virtual` prefixes its
40
+ * module IDs with `\0virtual:`. The leading null byte is sometimes stripped
41
+ * before the ID surfaces in `chunk.modules`, sometimes not, so we check the
42
+ * realistic forms plus a permissive `:treeshake` suffix as a catch-all.
43
+ */
44
+ const isSyntheticEntry = (id) => id === 'treeshake' ||
45
+ id === 'virtual:treeshake' ||
46
+ id === '\0virtual:treeshake' ||
47
+ id.endsWith(':treeshake');
48
+ // ─── Rollup bundling ─────────────────────────────────────────────────────────
49
+ /**
50
+ * Bundle the entry as the *only* import in a synthetic virtual module.
51
+ * If rollup can statically determine the entry has no observable side
52
+ * effects, every real module renders to zero bytes — that's our "fully
53
+ * shakeable" signal. Anything that survives is what's preventing
54
+ * tree-shaking.
55
+ */
56
+ export const analyzeTreeshakeability = (entry, pkg) => Effect.gen(function* () {
57
+ const path = yield* Path.Path;
58
+ const resolvedEntry = path.resolve(entry);
59
+ const warnings = [];
60
+ const bundle = yield* Effect.acquireRelease(Effect.tryPromise({
61
+ try: () => rollup({
62
+ input: 'treeshake',
63
+ plugins: [
64
+ virtual({
65
+ treeshake: `import ${JSON.stringify(resolvedEntry)}`,
66
+ }),
67
+ ],
68
+ onwarn: (warning) => {
69
+ if (warning.code === 'EMPTY_BUNDLE')
70
+ return;
71
+ warnings.push({
72
+ code: warning.code,
73
+ message: warning.message,
74
+ id: warning.id,
75
+ loc: warning.loc,
76
+ });
77
+ },
78
+ }),
79
+ catch: (cause) => new BundleFailed({ cause }),
80
+ }), closeBundle);
81
+ const output = yield* Effect.tryPromise({
82
+ try: () => bundle.generate({ format: 'esm' }),
83
+ catch: (cause) => new BundleFailed({ cause }),
84
+ });
85
+ const chunk = output.output[0];
86
+ const hints = pkg ? analyzePackageJsonHints(pkg) : defaultHints();
87
+ // Build per-module analyses, skipping the synthetic virtual entry.
88
+ const modules = Object.entries(chunk.modules)
89
+ .filter(([id]) => !isSyntheticEntry(id))
90
+ .map(([id, m]) => buildModuleAnalysis(id, m));
91
+ const totalOriginalBytes = modules.reduce((s, m) => s + m.originalLength, 0);
92
+ const totalRenderedBytes = modules.reduce((s, m) => s + m.renderedLength, 0);
93
+ // The package is fully shakeable when none of the real modules have
94
+ // surviving code. The bundle's `chunk.code` may still contain a tiny
95
+ // bit of rollup glue, but that's not the user's code.
96
+ const isFullyShakeable = modules.length === 0 || totalRenderedBytes === 0;
97
+ if (isFullyShakeable) {
98
+ return { _tag: 'FullyTreeshakeable', hints };
99
+ }
100
+ const offenders = modules.filter((m) => m.renderedLength > 0);
101
+ return {
102
+ _tag: 'HasSideEffects',
103
+ totalOriginalBytes,
104
+ totalRenderedBytes,
105
+ modules: modules.filter((m) => m.renderedLength > 0), // only show actual offenders
106
+ warnings,
107
+ hints,
108
+ unshakenCode: offenders.map((m) => `// ${m.id}\n${m.survivingCode ?? ''}`).join('\n\n'),
109
+ };
110
+ }).pipe(Effect.scoped);
111
+ // ─── Composed pipeline ───────────────────────────────────────────────────────
112
+ /**
113
+ * Full check: read package.json from `cwd`, resolve its entry, then analyze.
114
+ */
115
+ export const checkPackage = (cwd) => pipe(getEntryFromPackageJson(cwd), Effect.flatMap(({ entry, pkg }) => analyzeTreeshakeability(entry, pkg)));
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@wolfcola/treeshake-check",
3
+ "version": "0.0.0-snapshot-20260512024645",
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
+ "dependencies": {
34
+ "@effect/cli": "^0.75.1",
35
+ "@effect/platform": "^0.96.0",
36
+ "@effect/platform-node": "^0.106.0",
37
+ "@rollup/plugin-virtual": "^3.0.2",
38
+ "acorn": "^8.16.0",
39
+ "effect": "^3.21.1",
40
+ "rollup": "^4.59.0"
41
+ },
42
+ "devDependencies": {
43
+ "@effect/vitest": "^0.29.0",
44
+ "vitest": "^3.2.0"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.lib.json",
48
+ "lint": "eslint .",
49
+ "test": "vitest run"
50
+ }
51
+ }