Skip to content

Commit 99ffed1

Browse files
committed
feat(treeshake-check): resolve entry from exports field
Adds resolveEntry() which reads the exports field (subpath map, flat conditions, or bare string) before falling back to module/main. Conditions priority: import > module > default. require-only exports correctly return MissingEntryPoint rather than silently using a CJS path.
1 parent 46f48cf commit 99ffed1

5 files changed

Lines changed: 148 additions & 3 deletions

File tree

tools/treeshake-check/src/lib/analysis.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// tools/treeshake-check/src/lib/analysis.test.ts
22
import { describe, it, expect } from '@effect/vitest';
3-
import { detectCauses, buildModuleAnalysis, analyzePackageJsonHints } from './analysis.js';
3+
import {
4+
detectCauses,
5+
buildModuleAnalysis,
6+
analyzePackageJsonHints,
7+
resolveEntry,
8+
} from './analysis.js';
49

510
// ─── detectCauses ─────────────────────────────────────────────────────────────
611

@@ -210,3 +215,73 @@ describe('analyzePackageJsonHints', () => {
210215
expect(hints.recommendations).toHaveLength(0);
211216
});
212217
});
218+
219+
// ─── resolveEntry ─────────────────────────────────────────────────────────────
220+
221+
describe('resolveEntry', () => {
222+
// exports field — subpath map with conditions
223+
it('reads import condition from exports["."]]', () => {
224+
expect(
225+
resolveEntry({
226+
exports: { '.': { import: './dist/esm/index.js', require: './dist/cjs/index.js' } },
227+
}),
228+
).toBe('./dist/esm/index.js');
229+
});
230+
231+
it('reads module condition from exports["."] when import is absent', () => {
232+
expect(
233+
resolveEntry({
234+
exports: { '.': { module: './dist/esm/index.js', require: './dist/cjs/index.js' } },
235+
}),
236+
).toBe('./dist/esm/index.js');
237+
});
238+
239+
it('reads default condition from exports["."] when no esm-specific condition', () => {
240+
expect(resolveEntry({ exports: { '.': { default: './dist/index.js' } } })).toBe(
241+
'./dist/index.js',
242+
);
243+
});
244+
245+
// exports field — flat conditions (no subpath key)
246+
it('reads import condition from flat exports object', () => {
247+
expect(
248+
resolveEntry({ exports: { import: './dist/esm/index.js', require: './dist/cjs/index.js' } }),
249+
).toBe('./dist/esm/index.js');
250+
});
251+
252+
// exports field — bare string shorthand
253+
it('reads bare string exports field', () => {
254+
expect(resolveEntry({ exports: './dist/index.js' })).toBe('./dist/index.js');
255+
});
256+
257+
// exports field takes priority over module/main
258+
it('prefers exports over module and main', () => {
259+
expect(
260+
resolveEntry({
261+
exports: { '.': { import: './esm/index.js' } },
262+
module: './module/index.js',
263+
main: './main/index.js',
264+
}),
265+
).toBe('./esm/index.js');
266+
});
267+
268+
// fallback chain
269+
it('falls back to module when no exports field', () => {
270+
expect(resolveEntry({ module: './dist/index.js', main: './dist/cjs/index.js' })).toBe(
271+
'./dist/index.js',
272+
);
273+
});
274+
275+
it('falls back to main when no exports or module field', () => {
276+
expect(resolveEntry({ main: './dist/index.js' })).toBe('./dist/index.js');
277+
});
278+
279+
it('returns undefined when no entry can be resolved', () => {
280+
expect(resolveEntry({ name: 'no-entry' })).toBeUndefined();
281+
});
282+
283+
// CJS-only exports should be skipped (require-only map with no default)
284+
it('returns undefined for require-only exports map with no default', () => {
285+
expect(resolveEntry({ exports: { '.': { require: './dist/cjs/index.js' } } })).toBeUndefined();
286+
});
287+
});

tools/treeshake-check/src/lib/analysis.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,39 @@ const detectTopLevelCall = (code: string): boolean => {
2828
});
2929
};
3030

31+
/**
32+
* Resolve the ESM entry point from a package.json.
33+
*
34+
* Priority: exports["." | flat] → module → main
35+
* Within the exports conditions, priority is: import → module → default
36+
* Returns undefined when no usable ESM entry can be found (e.g. require-only).
37+
*/
38+
export const resolveEntry = (pkg: PackageJson): string | undefined => {
39+
const { exports } = pkg;
40+
41+
if (exports !== undefined) {
42+
if (typeof exports === 'string') return exports;
43+
44+
// exports is a record — determine whether it's a subpath map or flat conditions
45+
const dotEntry = (exports as Record<string, unknown>)['.'];
46+
const conditions: unknown = dotEntry !== undefined ? dotEntry : exports;
47+
48+
if (typeof conditions === 'string') return conditions;
49+
50+
if (conditions !== null && typeof conditions === 'object') {
51+
const c = conditions as Record<string, unknown>;
52+
const candidate = c['import'] ?? c['module'] ?? c['default'];
53+
if (typeof candidate === 'string') return candidate;
54+
}
55+
56+
// No usable ESM condition found in exports — don't fall through to module/main.
57+
// Falling through would return a CJS path dressed as ESM.
58+
return undefined;
59+
}
60+
61+
return pkg.module ?? pkg.main;
62+
};
63+
3164
export const detectCauses = (code: string): ReadonlyArray<SuspectedCause> => {
3265
const causes = new Set<SuspectedCause>();
3366

tools/treeshake-check/src/lib/schemas.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,20 @@ import { Schema } from 'effect';
55

66
export const SideEffectsValue = Schema.Union(Schema.Boolean, Schema.Array(Schema.String));
77

8+
// Conditions object: { import?: string, module?: string, require?: string, default?: string, ... }
9+
export const ExportsConditions = Schema.Record({
10+
key: Schema.String,
11+
value: Schema.Union(Schema.String, Schema.Unknown),
12+
});
13+
14+
// exports field: string | conditions | { ".": conditions | string }
15+
export const ExportsField = Schema.Union(Schema.String, ExportsConditions);
16+
817
export const PackageJson = Schema.Struct({
918
name: Schema.optional(Schema.String),
1019
module: Schema.optional(Schema.String),
1120
main: Schema.optional(Schema.String),
21+
exports: Schema.optional(ExportsField),
1222
type: Schema.optional(Schema.Literal('module', 'commonjs')),
1323
sideEffects: Schema.optional(SideEffectsValue),
1424
dependencies: Schema.optional(Schema.Unknown),

tools/treeshake-check/src/lib/treeshake-check.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,33 @@ layer(NodeContext.layer)('getEntryFromPackageJson', (it) => {
6565
}),
6666
);
6767

68+
it.scoped('reads import condition from exports field', () =>
69+
Effect.gen(function* () {
70+
const dir = yield* writeTempPackage({
71+
name: 'exports-pkg',
72+
exports: { '.': { import: './dist/esm/index.js', require: './dist/cjs/index.js' } },
73+
});
74+
const result = yield* getEntryFromPackageJson(dir);
75+
expect(result.entry).toBe('./dist/esm/index.js');
76+
}),
77+
);
78+
6879
it.scoped('fails with MissingEntryPoint when neither module nor main are present', () =>
6980
Effect.gen(function* () {
7081
const dir = yield* writeTempPackage({ name: 'no-entry' });
7182
const error = yield* Effect.flip(getEntryFromPackageJson(dir));
7283
expect(error).toBeInstanceOf(MissingEntryPoint);
7384
}),
7485
);
86+
87+
it.scoped('fails with MissingEntryPoint when exports has only require condition', () =>
88+
Effect.gen(function* () {
89+
const dir = yield* writeTempPackage({
90+
name: 'cjs-only',
91+
exports: { '.': { require: './dist/cjs/index.js' } },
92+
});
93+
const error = yield* Effect.flip(getEntryFromPackageJson(dir));
94+
expect(error).toBeInstanceOf(MissingEntryPoint);
95+
}),
96+
);
7597
});

tools/treeshake-check/src/lib/treeshake-check.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import { FileSystem, Path } from '@effect/platform';
33
import { Data, Effect, Schema, pipe } from 'effect';
44
import virtualPlugin from '@rollup/plugin-virtual';
55
import { rollup, type RollupBuild } from 'rollup';
6-
import { analyzePackageJsonHints, buildModuleAnalysis, defaultHints } from './analysis.js';
6+
import {
7+
analyzePackageJsonHints,
8+
buildModuleAnalysis,
9+
defaultHints,
10+
resolveEntry,
11+
} from './analysis.js';
712
import {
813
PackageJsonFromString,
914
type PackageJson,
@@ -51,7 +56,7 @@ export const getEntryFromPackageJson = (cwd?: string) =>
5156
pipe(
5257
readPackageJson(cwd ?? process.cwd()),
5358
Effect.flatMap(({ pkg, pkgPath }) => {
54-
const entry = pkg.module ?? pkg.main;
59+
const entry = resolveEntry(pkg);
5560
return entry !== undefined
5661
? Effect.succeed({ entry, pkg } as const)
5762
: new MissingEntryPoint({ path: pkgPath });

0 commit comments

Comments
 (0)