Skip to content

Commit f9d203f

Browse files
fix: resolve bare package imports against the "." exports subpath (#1276)
`resolveExportPath('')` — the call made for bare package imports like `import x from 'foo'` — used to blindly prepend './' to the lookup key, turning '' into './'. That never matched the '.' key stored in the resolved exports map, so the export lookup silently returned zero paths. In practice the bug was masked for packages that also declared a `main` field, because `resolveSourceFile` falls back to `mainPaths` when the exports lookup returns nothing and `exportPath === ''`. Packages that only declared `exports` (no `main`) would not resolve their bare entry point and the parser would fail to walk their dependency graph. Normalize both '' and '.' to '.' so the lookup matches the canonical '.' subpath key used by both the string-shorthand `exports: "./foo.js"` form and the object form `exports: { ".": "./foo.js" }`. Subpath imports (either bare 'sub' or './sub') continue to work unchanged. Tests added: - Plain string exports resolved via bare import - Object exports with '.' key resolved via bare import - Single-level conditional exports under '.' resolved via bare import - Explicit '.' exportPath accepted - Regression guard for non-bare subpath imports Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8213010 commit f9d203f

File tree

2 files changed

+88
-3
lines changed

2 files changed

+88
-3
lines changed

packages/cli/src/services/check-parser/package-files/__tests__/package-json-file.spec.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,5 +278,80 @@ describe('package.json file', () => {
278278
expect(paths).toHaveLength(1)
279279
expect(paths[0].target.path).toBe('./esm.mjs')
280280
})
281+
282+
// The resolver is called with exportPath = '' for bare package imports
283+
// (e.g. `import x from 'foo'`), which should map to the "." subpath in
284+
// the package's exports field. Before the fix, the prefix-prepending
285+
// logic turned '' into './', which never matched the '.' key and made
286+
// the export lookup silently return zero paths — masked at runtime by
287+
// the main-field fallback for packages that also declared `main`.
288+
289+
it('resolves a plain string exports value on a bare import', () => {
290+
const testFile = PackageJsonFile.make('/pkg/package.json', {
291+
name: 'foo',
292+
version: '1.0.0',
293+
exports: './index.js',
294+
})
295+
296+
const { paths } = testFile.resolveExportPath('', importConditions)
297+
298+
expect(paths).toHaveLength(1)
299+
expect(paths[0].target.path).toBe('./index.js')
300+
})
301+
302+
it('resolves the "." subpath on a bare import', () => {
303+
const testFile = PackageJsonFile.make('/pkg/package.json', {
304+
name: 'foo',
305+
version: '1.0.0',
306+
exports: {
307+
'.': './lib/main.js',
308+
},
309+
})
310+
311+
const { paths } = testFile.resolveExportPath('', importConditions)
312+
313+
expect(paths).toHaveLength(1)
314+
expect(paths[0].target.path).toBe('./lib/main.js')
315+
})
316+
317+
it('resolves the "." subpath with single-level conditions on a bare import', () => {
318+
const testFile = PackageJsonFile.make('/pkg/package.json', {
319+
name: 'foo',
320+
version: '1.0.0',
321+
exports: {
322+
'.': {
323+
import: './lib/main.mjs',
324+
require: './lib/main.cjs',
325+
default: './lib/main.js',
326+
},
327+
} as any,
328+
})
329+
330+
const importResult = testFile.resolveExportPath('', importConditions)
331+
expect(importResult.paths).toHaveLength(1)
332+
expect(importResult.paths[0].target.path).toBe('./lib/main.mjs')
333+
334+
const requireResult = testFile.resolveExportPath('', requireConditions)
335+
expect(requireResult.paths).toHaveLength(1)
336+
expect(requireResult.paths[0].target.path).toBe('./lib/main.cjs')
337+
})
338+
339+
it('accepts "." as an explicit bare-import path', () => {
340+
// Callers sometimes pass '.' directly instead of ''. Both should
341+
// resolve the same root export without introducing a spurious './.'
342+
// lookup.
343+
const testFile = PackageJsonFile.make('/pkg/package.json', {
344+
name: 'foo',
345+
version: '1.0.0',
346+
exports: {
347+
'.': './index.js',
348+
},
349+
})
350+
351+
const { paths } = testFile.resolveExportPath('.', importConditions)
352+
353+
expect(paths).toHaveLength(1)
354+
expect(paths[0].target.path).toBe('./index.js')
355+
})
281356
})
282357
})

packages/cli/src/services/check-parser/package-files/package-json-file.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,19 @@ export class PackageJsonFile {
8484
this.#resolveExports(this.jsonFile.data.exports ?? {}, conditions),
8585
)
8686

87-
// Exports must always start with "./" - make sure that the path we're
88-
// matching against also starts with that prefix.
89-
if (!exportPath.startsWith('./')) {
87+
// Normalize the export path to the canonical subpath form used by
88+
// `exports` keys. Per the Node.js spec, the root subpath is "." and
89+
// all other subpaths start with "./". An empty string means the bare
90+
// package import (`import x from 'foo'`) and maps to the root subpath.
91+
//
92+
// Previously the code blindly prepended "./" to everything, turning
93+
// "" into "./", which never matched the "." key and made bare imports
94+
// silently resolve to zero paths. Packages that also declared `main`
95+
// worked anyway because of the fallback in `resolveSourceFile`; other
96+
// packages did not.
97+
if (exportPath === '' || exportPath === '.') {
98+
exportPath = '.'
99+
} else if (!exportPath.startsWith('./')) {
90100
exportPath = `./${exportPath}`
91101
}
92102

0 commit comments

Comments
 (0)