diff --git a/src/lib/exports.ts b/src/lib/exports.ts index 2ce05a6..d06fde2 100644 --- a/src/lib/exports.ts +++ b/src/lib/exports.ts @@ -337,6 +337,38 @@ const resolveConcreteExport = async ( } }; +/** + * Widen an exports-derived source pattern into a directory-crossing glob. + * + * A package.json `exports` wildcard matches subpaths *including* `/` — the + * `./*.js` key resolves `./auth/session.js` with `*` capturing `auth/session`. + * A plain glob `*`, by contrast, stops at a directory separator, so globbing + * the mapped `src/lib/*.ts` would match only top-level modules and silently + * drop every nested one. When the wildcard sits after a directory separator + * (the ubiquitous `/*.ext` shape), splice in a globstar segment so the + * glob crosses directories the way Node's resolver does; the globstar matches + * zero-or-more segments, so top-level files still match. + * + * Two shapes are deliberately left un-widened: + * - A mid-segment wildcard (`prefix-*.ts`) has no faithful globstar translation. + * - A bare-root wildcard (`*.ts`, from an empty `sourceDir`) would widen to a + * project-root `**` that rakes in `node_modules`/`dist`. Empty `sourceDir` + * only arises from the multi-`sourcePaths` no-common-prefix layout (which + * `discoverSourceFiles` short-circuits before reaching here) or an explicit + * `sourceRoot: ''`; leaving it non-recursive matches the prior behavior + * without the explosion risk. + * + * Both fall through to matching same-directory files only. + */ +const toRecursiveExportGlob = (pattern: string): string => { + const star = pattern.indexOf('*'); + if (star === -1) return pattern; + const before = pattern.slice(0, star); + const after = pattern.slice(star + 1); + if (before.endsWith('/')) return `${before}**/*${after}`; + return pattern; +}; + /** * Expand a wildcard export pattern to matching source files. */ @@ -348,10 +380,12 @@ const expandWildcardExport = async ( exclude: Array | undefined, discovered: Map, ): Promise => { - const sourcePattern = mapDistToSource(distPath, condition, mappingOptions); - if (!sourcePattern) return; + const mappedPattern = mapDistToSource(distPath, condition, mappingOptions); + if (!mappedPattern) return; - // Convert mapped pattern to glob: "src/lib/*.ts" is already a valid glob + // The exports `*` crosses directory separators; a glob `*` does not — widen + // it so nested modules aren't silently dropped. + const sourcePattern = toRecursiveExportGlob(mappedPattern); const patterns = [sourcePattern]; // For .ts patterns, also try .svelte and .js diff --git a/src/test/exports.test.ts b/src/test/exports.test.ts index 27e9b42..eac304c 100644 --- a/src/test/exports.test.ts +++ b/src/test/exports.test.ts @@ -358,6 +358,87 @@ describe('discoverFromExports', () => { }); }); + test('discovers wildcard exports in nested directories', async () => { + // A package.json `exports` wildcard (`./*.js`) matches subpaths including + // `/` — `@scope/pkg/auth/session.js` resolves through it — so discovery + // must recurse into subdirectories, not just the top-level source dir. + await withTestDir(async (dir) => { + await mkdir(join(dir, 'src/lib/auth'), {recursive: true}); + await mkdir(join(dir, 'src/lib/db/deep'), {recursive: true}); + await writeFile(join(dir, 'src/lib/root.ts'), 'export const r = 0;'); + await writeFile(join(dir, 'src/lib/auth/session.ts'), 'export const s = 1;'); + await writeFile(join(dir, 'src/lib/auth/Login.svelte'), '
login
'); + await writeFile(join(dir, 'src/lib/db/deep/nested.ts'), 'export const n = 2;'); + await writeFile( + join(dir, 'package.json'), + JSON.stringify({ + exports: { + './*.js': {types: './dist/*.d.ts', default: './dist/*.js'}, + './*.svelte': {svelte: './dist/*.svelte', default: './dist/*.svelte'}, + }, + }), + ); + + const {files} = await discoverFromExports({projectRoot: dir}); + assert.ok(files); + const paths = files.map((f) => f.id).sort(); + assert.strictEqual(files.length, 4); + assert.ok(paths.some((p) => p.endsWith('src/lib/root.ts'))); + assert.ok(paths.some((p) => p.endsWith('src/lib/auth/session.ts'))); + assert.ok(paths.some((p) => p.endsWith('src/lib/auth/Login.svelte'))); + assert.ok(paths.some((p) => p.endsWith('src/lib/db/deep/nested.ts'))); + }); + }); + + test('honors exclude for nested wildcard matches', async () => { + // Recursive wildcard expansion means `exclude` now governs nested files + // that a non-recursive `src/lib/*.ts` glob never reached. A co-located + // test file (or any excluded subtree) must still be dropped. + await withTestDir(async (dir) => { + await mkdir(join(dir, 'src/lib/auth'), {recursive: true}); + await writeFile(join(dir, 'src/lib/auth/session.ts'), 'export const s = 1;'); + await writeFile(join(dir, 'src/lib/auth/session.test.ts'), 'export const t = 1;'); + await writeFile( + join(dir, 'package.json'), + JSON.stringify({ + exports: {'./*.js': {types: './dist/*.d.ts', default: './dist/*.js'}}, + }), + ); + + const {files} = await discoverFromExports({ + projectRoot: dir, + exclude: ['**/*.test.ts'], + }); + assert.ok(files); + assert.strictEqual(files.length, 1); + assert.ok(files[0]!.id.endsWith('src/lib/auth/session.ts')); + }); + }); + + test('bare-root wildcard (empty sourceDir) does not recurse into subdirectories', async () => { + // Guard the deliberate non-widening: an empty `sourceDir` maps `./dist/*.js` + // to a bare `*.ts`. Widening that to a project-root `**` would rake in + // `node_modules`/`dist`, so it must stay a single-segment match. Only the + // root-level file is found; the nested one is not. + await withTestDir(async (dir) => { + await mkdir(join(dir, 'nested'), {recursive: true}); + await writeFile(join(dir, 'root.ts'), 'export const r = 0;'); + await writeFile(join(dir, 'nested/deep.ts'), 'export const d = 1;'); + await writeFile( + join(dir, 'package.json'), + JSON.stringify({ + exports: {'./*.js': {types: './dist/*.d.ts', default: './dist/*.js'}}, + }), + ); + + const {files} = await discoverFromExports({projectRoot: dir, sourceDir: ''}); + assert.ok(files); + assert.strictEqual(files.length, 1); + assert.ok(files[0]!.id.endsWith('root.ts')); + assert.ok(!files.some((f) => f.id.endsWith('deep.ts'))); + }); + }); + test('deduplicates across .js and .ts patterns', async () => { await withTestDir(async (dir) => { await mkdir(join(dir, 'src/lib'), {recursive: true});