Skip to content

feat: resolve package import wildcard trailers - #604

Merged
styfle merged 1 commit into
mainfrom
fix/package-import-wildcard-trailers
Aug 18, 2026
Merged

feat: resolve package import wildcard trailers#604
styfle merged 1 commit into
mainfrom
fix/package-import-wildcard-trailers

Conversation

@cmpadden

Copy link
Copy Markdown
Contributor

Summary

  • resolve package.json import/export wildcard patterns that include a trailer, such as #*.js
  • match both the pattern prefix and trailer before substituting the captured wildcard
  • use Node-compatible pattern precedence and reject patterns containing multiple wildcards
  • add an imports-wildcard trace fixture covering #internal/marker.js mapped through #*.js

Context

NFT previously recognized wildcard keys only when they ended in *. A valid import map such as:

{
  "imports": {
    "#*.js": "./dist/*.js"
  }
}

therefore failed to resolve #internal/marker.js. Consumers such as Nitro/nf3 would trace the package entry point but omit the internal target, producing an incomplete deployment artifact that failed at runtime with ERR_MODULE_NOT_FOUND.

Related reproduction and downstream workaround:

Regression testing

The new test/unit/imports-wildcard fixture imports #internal/marker.js and expects dist/internal/marker.js in the trace.

Before this change, both the cwd-based and root-based fixture variants failed because the marker file was absent. After the resolver change, both variants pass.

Validation performed:

  • pnpm build
  • pnpm prettier-check
  • pnpm exec jest test/unit.test.js --runInBand -t 'imports|exports-wildcard'
  • pnpm exec jest --runInBand --silent — 1,475 tests passed

@cmpadden
cmpadden requested review from a team, icyJoseph, ijjk and styfle as code owners July 27, 2026 15:16
pi0x pushed a commit to cmpadden/nf3 that referenced this pull request Jul 27, 2026
Replace the `@vercel/nft` pnpm patch with a fallback resolver, so the fix
does not have to be re-applied on every nft release (an exact-version
`patchedDependencies` key hard-fails installs with ERR_PNPM_UNUSED_PATCH
as soon as the version moves).

nft exports its own resolver, so `nft.resolve` can wrap it and only fall
back to exsolve — which implements the Node resolution algorithm — for
specifiers nft throws on. exsolve had the same defect in its `imports`
matching and fixes it in 1.1.1 (unjs/exsolve#56).

This is purely additive: nft stays the primary resolver, so the fallback
only ever turns a "Failed to resolve dependency" warning into a resolved
file, and becomes a no-op once vercel/nft#604 lands.

It also covers a case the patch did not: wildcard imports whose target is
another package (`"#utils/*": "@fixture/nitro-utils/*"`). nft's wildcard
branch only handles targets starting with `./`, so those stayed
unresolved even with the patch applied. The fixture and test now assert
both that and the original `"#*.js": "./runtime/*.js"` trailer case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mischnic added a commit to vercel/next.js that referenced this pull request Aug 14, 2026

styfle commented Aug 18, 2026

Copy link
Copy Markdown
Member

I reviewed this specifically for conformance with Node's resolver implementation (lib/internal/modules/esm/resolve.js, v22.22.0) rather than just for the reported bug. The core of the change is a faithful port of Node's algorithm and a clear improvement. There's one bug on a line this PR rewrites, plus two adjacent gaps worth a decision.

What checks out

patternKeyCompare (src/resolve-dependency.ts:196) is equivalent to Node's patternKeyCompare step for step, and getPatternMatch (:212) reproduces every guard from Node's imports / exports pattern loops:

Node this PR
patternIndex !== -1
startsWith(key.slice(0, patternIndex))
name.length >= key.length subpath.length < pattern.length → undefined
endsWith(patternTrailer) ✅ (guarded on non-empty trailer — equivalent, since endsWith('') is always true)
key.lastIndexOf('*') === patternIndex
slice(patternIndex, name.length - trailer.length)

Node picks a single bestMatch in one pass; this PR sorts every key and takes the first that matches. Those aren't obviously the same thing, so I fuzzed it — ~900k generated (keys, subpath) pairs, ~218k of which actually matched a pattern, with key sets built by construction from the subpath (up to ~65 keys, shuffled insertion order to defeat sort stability): 0 disagreements on either the selected key or the captured wildcard.

End-to-end against real import.meta.resolve, on a 10-case wildcard-focused matrix, this PR moves nft from 2/10 to 7/10 agreeing with Node with no regressions — the 7 is the 2 that already agreed plus 5 newly fixed, and every case still differing was already differing before this PR:

case probes base this PR
imports-wildcard-trailer {"#*.js": "./dist/*.js"} fixed
imports-mid-wildcard {"#lib/*/index.js": "./src/*/main.js"} fixed
imports-precedence-trailer-vs-plain {"#*": ..., "#*.js": ...} + #a.js fixed
exports-mid-wildcard {"./feature/*.js": "./src/feature/*.js"} fixed
imports-empty-wildcard-match-rejected {"#a*": "./dist/*.js"} + #a fixed
imports-multi-wildcard-key-ignored {"#*/*.js": ..., "#*": ...} unchanged
exports-dot-star-vs-exact {"./*": ..., "./special": ...} unchanged
imports-wildcard-to-external-pkg {"#dep/*": "some-pkg/*"} pre-existing → gap 1 below
imports-dollar-ampersand-filename real file dist/a$&b.js pre-existing → fix below
exports-null-blocked-then-catchall {"./internal/*": null, "./*": ...} pre-existing → gap 2 below

Three of the fixes aren't claimed in the description:

  • mid-path wildcards now resolve: {"#lib/*/index.js": "./src/*/main.js"}
  • precedence is now correct: with {"#*": "./generic/*.js", "#*.js": "./specific/*.js"}, #a.js resolved to generic/a.js.js before this PR and specific/a.js after — the latter is what Node does
  • zero-length wildcard matches are now rejected: {"#a*": "./dist/*.js"} + #a used to resolve to dist/.js; Node throws ERR_PACKAGE_IMPORT_NOT_DEFINED, and nft now declines to resolve it too

The repo's own suite agrees on no regressions: test/unit.test.js is 304/304 on main and 306/306 here — the same 304 plus the 2 new fixture tests — and the diff touches no pre-existing test file, so nothing was relaxed to go green.

One thing that should change

src/resolve-dependency.ts:358, and the same pattern in addExportsTargetPath at :241. String.prototype.replace with a string replacement interprets $-sequences in that string, so the captured wildcard is not inserted literally:

'./dist/*.js'.replace(/\*/g, 'a$&b')   // → './dist/a*b.js'   ✗  ($& = the matched '*')

Node avoids exactly this by using a function replacer — RegExpPrototypeSymbolReplace(patternRegEx, target, () => subpath) (also at L398, L414, L438).

Verified against real Node: with {"imports": {"#*.js": "./dist/*.js"}} and a real file dist/a$&b.js, import '#a$&b.js' resolves fine under Node, while nft computes dist/a*b.js and reports Failed to resolve dependency "#a$&b.js". Also confirmed for $` (duplicates the target prefix — #pre$\post.jsdist/pre/dist/post.js), $'(splices the suffix), and$$(collapses to a single$`).

Two-character fix in both places:

         const resolvedPath =
-          pkgPath + target.slice(1).replace(/\*/g, wildcardReplacement);
+          pkgPath + target.slice(1).replace(/\*/g, () => wildcardReplacement);
   const targetPath = wildcardReplacement
-    ? target.slice(1).replace(/\*/g, wildcardReplacement)
+    ? target.slice(1).replace(/\*/g, () => wildcardReplacement)
     : target.slice(1);

This is pre-existing, but this PR rewrites the line, and $ in a filename isn't exotic enough to leave broken.

Two gaps — your call whether they belong in this PR

1. Wildcard imports targets that are bare specifiers never resolve. The exact-match branch has the external-dependency fallback at :341; the wildcard branch has no equivalent — it only handles target.startsWith('./') and otherwise falls through to the next, less-specific key. So this silently under-traces:

{ "imports": { "#dep/*": "some-pkg/*" } }

Node resolves it: resolvePackageTargetString L391-405 substitutes the wildcard and hands the result to packageResolve when the target isn't ./-relative and internal is true. Confirmed: Node resolves #dep/thing.jsnode_modules/depper/lib/thing.js, nft emits Failed to resolve dependency.

That's the same failure mode the description is about — trace omits a real target, deployment fails at runtime with ERR_MODULE_NOT_FOUND — just reached through a different key shape, so it looks in scope to me. Mirroring :341 inside the wildcard branch covers it:

      } else if (isImports && typeof target === 'string') {
        // The imports field additionally allows external dependencies as well
        const resolved = await resolveDependency(
          target.replace(/\*/g, () => wildcardReplacement),
          parent,
          job,
          cjsResolve,
        );
        return Array.isArray(resolved) ? resolved : [resolved];
      }

2. null targets fall through instead of blocking. With {"./internal/*": null, "./*": "./src/*"}, Node throws ERR_PACKAGE_PATH_NOT_EXPORTED for dep/internal/secret.js — a null target is a hard block, and Node never retries a less-specific key. nft's loop continues when the target isn't a ./ string, so it matches ./* instead and traces src/internal/secret.js. Pre-existing and unchanged by this PR, and over-tracing is the safe direction for a tracer, so I'd leave it — noting it only because the wider pattern matching here makes the fall-through reachable for more key shapes than before.

I applied the $ fix and gap 1 locally: the matrix goes 7/10 → 9/10 (gap 2 is the remaining diff, deliberately) and all 306 test/unit.test.js tests still pass.

Nit

patternKeyCompare isn't a consistent comparator across the whole key list: for two wildcard-free keys of equal length it returns 1 in both directions, and compare(a, a) === 1. That's why Node never sorts with it and does a single best-match pass instead. Restricted to *-containing keys it is a proper ordering, and wildcard-free keys can never satisfy getPatternMatch, which is presumably why I couldn't produce a wrong selection in fuzzing — so this is informational, not a request. Worth a comment on the function at most, since Array.prototype.sort with an inconsistent comparator is implementation-defined.

Also, minor: the new fixture only covers the trailing-.js case. {"#lib/*/index.js": "./src/*/main.js"} (mid-path wildcard) and the {"#*": ..., "#*.js": ...} precedence pair are both fixed by this change and both would have failed before — cheap to lock in as fixtures so the precedence ordering doesn't silently regress.

@styfle styfle changed the title fix: resolve package import wildcard trailers feat: resolve package import wildcard trailers Aug 18, 2026

@styfle styfle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved since there is no regression here, just additive support for import wildcards.

I changed from fix to feat since changing the import resolution behavior might break someone and is a bit more like a new feature.

Also, the comment above mentions the remaining bugs for this feature so its not fully implemented yet. We can address in a future PR.

@styfle
styfle merged commit 7d6e6e4 into main Aug 18, 2026
17 checks passed
@styfle
styfle deleted the fix/package-import-wildcard-trailers branch August 18, 2026 15:44
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.11.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants