From 68a6b4080fe829af33ddcbbebed78eac854f131a Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 3 Jul 2026 07:28:31 -0400 Subject: [PATCH 1/2] fix: Node-resolving rules for glob exports --- src/lib/exports.ts | 31 ++++++++++++++++++++++++++++--- src/test/exports.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/lib/exports.ts b/src/lib/exports.ts index 2ce05a6..9ef6017 100644 --- a/src/lib/exports.ts +++ b/src/lib/exports.ts @@ -337,6 +337,29 @@ 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 at a path-segment boundary + * (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. A mid-segment wildcard + * (`prefix-*.ts`) has no faithful globstar translation and is left 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 === '' || before.endsWith('/')) return `${before}**/*${after}`; + return pattern; +}; + /** * Expand a wildcard export pattern to matching source files. */ @@ -348,10 +371,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..a7dea39 100644 --- a/src/test/exports.test.ts +++ b/src/test/exports.test.ts @@ -358,6 +358,38 @@ 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('deduplicates across .js and .ts patterns', async () => { await withTestDir(async (dir) => { await mkdir(join(dir, 'src/lib'), {recursive: true}); From 34d8a3e95d067d31682b8eec429566fd3f1b6b6a Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 3 Jul 2026 07:37:37 -0400 Subject: [PATCH 2/2] fix and test --- src/lib/exports.ts | 19 ++++++++++++---- src/test/exports.test.ts | 49 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/lib/exports.ts b/src/lib/exports.ts index 9ef6017..d06fde2 100644 --- a/src/lib/exports.ts +++ b/src/lib/exports.ts @@ -344,19 +344,28 @@ const resolveConcreteExport = async ( * `./*.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 at a path-segment boundary + * 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. A mid-segment wildcard - * (`prefix-*.ts`) has no faithful globstar translation and is left matching - * same-directory files only. + * 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 === '' || before.endsWith('/')) return `${before}**/*${after}`; + if (before.endsWith('/')) return `${before}**/*${after}`; return pattern; }; diff --git a/src/test/exports.test.ts b/src/test/exports.test.ts index a7dea39..eac304c 100644 --- a/src/test/exports.test.ts +++ b/src/test/exports.test.ts @@ -390,6 +390,55 @@ describe('discoverFromExports', () => { }); }); + 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});