From c5c10bef9df8eafbab42bd5af7c291cbbc65947b Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Mon, 27 Jul 2026 16:29:15 +0000 Subject: [PATCH] fix: match wildcard `imports` keys with a trailer `packageImportsResolve` computed the pattern base as `key.slice(0, -1)` instead of `key.slice(0, patternIndex)`. For a key like `#*.js` this tested `name.startsWith("#*.")`, which never matches, so the key was skipped. Usually that meant resolution threw, but when a less specific key also matched the specifier it silently resolved the wrong file: given `#feat*.js` and `#feat*`, `#featx.js` should pick the former, yet the former was skipped and the latter won by default. Keys without a trailer (`#/*`) were unaffected, which hid the bug, and `packageExportsResolve` already used the correct base. Aligns with the Node.js upstream implementation. Fixes #56 Co-Authored-By: Claude Opus 5 --- src/internal/resolve.ts | 2 +- test/fixture/imports-pkg/feat-any/x.js | 1 + test/fixture/imports-pkg/feat-js/x.js | 1 + test/fixture/imports-pkg/lib/deep/thing.js | 1 + test/fixture/imports-pkg/package.json | 6 +++- .../imports-pkg/runtime/internal/marker.js | 1 + test/resolve-imports-exports.test.ts | 32 +++++++++++++++++++ 7 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 test/fixture/imports-pkg/feat-any/x.js create mode 100644 test/fixture/imports-pkg/feat-js/x.js create mode 100644 test/fixture/imports-pkg/lib/deep/thing.js create mode 100644 test/fixture/imports-pkg/runtime/internal/marker.js diff --git a/src/internal/resolve.ts b/src/internal/resolve.ts index 24d75d5..6b42bcc 100644 --- a/src/internal/resolve.ts +++ b/src/internal/resolve.ts @@ -699,7 +699,7 @@ function packageImportsResolve(name: string, base: URL, conditions?: Set const key = keys[i]!; const patternIndex = key.indexOf("*"); - if (patternIndex !== -1 && name.startsWith(key.slice(0, -1))) { + if (patternIndex !== -1 && name.startsWith(key.slice(0, patternIndex))) { const patternTrailer = key.slice(patternIndex + 1); if ( name.length >= key.length && diff --git a/test/fixture/imports-pkg/feat-any/x.js b/test/fixture/imports-pkg/feat-any/x.js new file mode 100644 index 0000000..8ff4cf3 --- /dev/null +++ b/test/fixture/imports-pkg/feat-any/x.js @@ -0,0 +1 @@ +export const via = "feat-any"; diff --git a/test/fixture/imports-pkg/feat-js/x.js b/test/fixture/imports-pkg/feat-js/x.js new file mode 100644 index 0000000..4be6617 --- /dev/null +++ b/test/fixture/imports-pkg/feat-js/x.js @@ -0,0 +1 @@ +export const via = "feat-js"; diff --git a/test/fixture/imports-pkg/lib/deep/thing.js b/test/fixture/imports-pkg/lib/deep/thing.js new file mode 100644 index 0000000..b2577e1 --- /dev/null +++ b/test/fixture/imports-pkg/lib/deep/thing.js @@ -0,0 +1 @@ +export const thing = "lib/deep/thing.js"; diff --git a/test/fixture/imports-pkg/package.json b/test/fixture/imports-pkg/package.json index aeb189c..1925a02 100644 --- a/test/fixture/imports-pkg/package.json +++ b/test/fixture/imports-pkg/package.json @@ -2,6 +2,10 @@ "name": "imports-pkg", "imports": { "#/*": "./src/*", - "#internal": "./internal.js" + "#internal": "./internal.js", + "#*.js": "./runtime/*.js", + "#lib/*.js": "./lib/*.js", + "#feat*.js": "./feat-js/*.js", + "#feat*": "./feat-any/*" } } diff --git a/test/fixture/imports-pkg/runtime/internal/marker.js b/test/fixture/imports-pkg/runtime/internal/marker.js new file mode 100644 index 0000000..266f2ce --- /dev/null +++ b/test/fixture/imports-pkg/runtime/internal/marker.js @@ -0,0 +1 @@ +export const marker = "runtime/internal/marker.js"; diff --git a/test/resolve-imports-exports.test.ts b/test/resolve-imports-exports.test.ts index fdc6d88..54880ce 100644 --- a/test/resolve-imports-exports.test.ts +++ b/test/resolve-imports-exports.test.ts @@ -24,6 +24,38 @@ describe("package.json imports field", () => { expect(resolved).toMatch(/imports-pkg\/internal\.js$/); expect(existsSync(fileURLToPath(resolved))).toBe(true); }); + + // Regression (#56): the pattern base was computed as `key.slice(0, -1)` + // instead of `key.slice(0, patternIndex)`, so any wildcard key with a + // trailer (`#*.js`) was tested against `name.startsWith("#*.")` and could + // never match. Keys without a trailer (`#/*`) happened to work, which hid + // the bug. `exports` resolution already used the correct base. + it("resolves a wildcard import key with a trailer", () => { + const resolved = resolveModuleURL("#internal/marker.js", { from }); + expect(resolved).toMatch(/imports-pkg\/runtime\/internal\/marker\.js$/); + expect(existsSync(fileURLToPath(resolved))).toBe(true); + }); + + it("resolves a nested wildcard import key with a trailer", () => { + const resolved = resolveModuleURL("#lib/deep/thing.js", { from }); + expect(resolved).toMatch(/imports-pkg\/lib\/deep\/thing\.js$/); + expect(existsSync(fileURLToPath(resolved))).toBe(true); + }); + + // The same bug could also resolve the *wrong* file rather than throw: when + // `#feat*.js` was skipped, the less specific `#feat*` won by default and + // `feat-any/x.js` was loaded instead. Both targets exist, so this pins key + // selection (`patternKeyCompare`) and not mere existence. + it("prefers the more specific key when wildcard keys compete", () => { + const resolved = resolveModuleURL("#featx.js", { from }); + expect(resolved).toMatch(/imports-pkg\/feat-js\/x\.js$/); + expect(resolved).not.toMatch(/feat-any/); + }); + + // Boundary: `name.length >= key.length` fails, so `#*.js` must not match. + it("does not match a wildcard key when the specifier is too short", () => { + expect(() => resolveModuleURL("#.js", { from })).toThrow(); + }); }); describe("package.json conditional exports fall-through", () => {