Skip to content

feat: complete wildcard imports resolution - #608

Open
cmpadden wants to merge 3 commits into
mainfrom
fix/imports-wildcard-resolution-followups
Open

feat: complete wildcard imports resolution#608
cmpadden wants to merge 3 commits into
mainfrom
fix/imports-wildcard-resolution-followups

Conversation

@cmpadden

@cmpadden cmpadden commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • preserve wildcard captures literally when substituting package imports and exports targets
  • resolve wildcard imports targets that point to external packages
  • stop at the selected wildcard key when its external target cannot resolve, matching Node instead of falling through to a less-specific pattern
  • add regression coverage for dollar replacement sequences, mid-path patterns, pattern precedence, external targets, and module-sync catchall paths

Context

Follow-up to #604 and the resolver conformance review in #604 (comment).

Three resolver gaps remained after wildcard trailers were added:

  1. Passing a wildcard capture directly as the replacement string caused JavaScript replacement sequences such as $&, $$, $` , and $' to be interpreted instead of inserted literally.
  2. Wildcard imports mappings only handled ./-relative targets, even though Node also permits package targets such as "#dep/*": "depper/*".
  3. A matched wildcard key with an unresolved external target fell through to a less-specific key. Node selects one best match and reports the external resolution failure instead of retrying another pattern. Consumers relying on that accidental fallback may now receive a trace warning, matching Node's behavior.

This uses function replacers at all three target-substitution sites and mirrors the exact imports branch's external dependency resolution after wildcard substitution.

The null target fallthrough behavior noted in the review is intentionally not changed here because blocking a target can remove files from traces and warrants a separate compatibility decision. The existing patternKeyCompare implementation is also intentionally left unchanged; its comparator-consistency concern is informational and no incorrect wildcard selection has been demonstrated.

Regression coverage

The fixtures now cover:

  • a literal $& wildcard capture for package imports
  • a literal $& capture in both module-sync and fallback export targets
  • a literal $& capture in a wildcard import targeting an external package
  • wildcard imports targeting an external package
  • failure of a selected external target without fallthrough to an existing less-specific target
  • a mid-path pattern (#lib/*/index.js)
  • precedence of #*.js over #*, with both candidate files present

Mutation checks confirmed that the tests fail independently when each resolver fix is reverted, including the external-target function replacer and no-fallthrough behavior.

Validation

  • pnpm build
  • pnpm prettier-check
  • git diff --check
  • pnpm exec jest test/unit.test.js --runInBand --silent — 304/304 passed locally on macOS (the platform skips two additional cwd/root cases compared with Linux)
  • targeted wildcard tests — cwd and root variants passed
  • direct Node execution of both modified fixtures
  • CI matrix passed on the previous revision across Node 20, 22, 24, and 26 on Linux, macOS, and Windows; CI is rerunning for the added regression fixtures

@cmpadden
cmpadden marked this pull request as ready for review August 18, 2026 16:06
@cmpadden
cmpadden requested review from a team, icyJoseph, ijjk and styfle as code owners August 18, 2026 16:06

styfle commented Aug 18, 2026

Copy link
Copy Markdown
Member

Reviewed as the follow-up to my conformance review on #604. Both resolver fixes are exactly right and land where I'd hoped — the src diff matches the patch I validated there, including the function replacer on the new substitution site, which is the easy thing to forget. Fixtures execute correctly under real Node (I ran both), so they're pinned to real semantics rather than just to nft's own expectations.

One real coverage gap, one undocumented behavior change, and some nits.

Verification I ran

I mutation-tested each fix independently — reverted it, rebuilt, ran test/unit.test.js:

reverted change detected by tests?
addExportsTargetPath replacer (:241) ✅ caught — module-sync-catchall ×2
wildcard resolvedPath replacer (:358) ✅ caught — imports-wildcard ×2, module-sync-catchall ×2
external-target branch removed entirely (:403) ✅ caught — imports-wildcard ×2
external-target replacer (:406) missed — 306/306 still green

So the module-sync/fallback pair in module-sync-catchall is doing real work (it's the only thing covering addExportsTargetPath), and the external-target branch is covered as a branch. But:

The gap: the new substitution site has no $-literal test

target.replace(/\*/g, () => wildcardReplacement) in the new else if is the third substitution site, and it's the one this PR introduces. Reverting just that arrow function to the string form (replace(/\*/g, wildcardReplacement)) leaves all 306 tests passing — the #dep/thing.js fixture has no $ in its capture, so it can't tell the two apart. The whole premise of this PR is that captures must be inserted literally at every site, so the site it adds should be pinned too.

Two lines closes it. Add the file:

// test/unit/imports-wildcard/node_modules/depper/a$&b.js
export const externalDollar = 'external literal dollar';

and in input.js / output.js:

 import { external } from '#dep/thing.js';
+import { externalDollar } from '#dep/a$&b.js';
+  "test/unit/imports-wildcard/node_modules/depper/a$&b.js",
   "test/unit/imports-wildcard/node_modules/depper/package.json",

I applied exactly that and re-ran the mutation: passes with the fix, fails without it (imports-wildcard from cwd + root). Confirmed against real Node too — import.meta.resolve('#dep/a$&b.js')node_modules/depper/a$&b.js.

While you're there, the Array.isArray(resolved) ? resolved : [resolved] normalization on the next line only ever takes the non-array path in the current fixtures. Reachable if the external target is itself a multi-path resolve (e.g. a depper with module-sync catchall exports). Low priority — mirrors :341 — but it's the other uncovered sub-path in the new block.

Undocumented behavior change worth calling out

Adding the else if means the wildcard loop now stops at the first key whose target is a bare specifier, instead of falling through to a less-specific key. That's a real, observable change beyond "external targets now resolve":

{ "imports": { "#dep/*": "not-installed/*", "#*": "./fallback/*" } }

with fallback/dep/thing.js on disk, importing #dep/thing.js:

result
Node ERR_MODULE_NOT_FOUND
main resolves fallback/dep/thing.js ← diverges from Node
this PR unresolved + trace warning ← matches Node

So this is another conformance win, not a regression — Node commits to one bestMatch and never retries a less-specific key (packageImportsResolve). But it's worth (a) a line in the description, since a consumer whose package leaned on that accidental fallback will start seeing new trace warnings after upgrading, and (b) a fixture, since nothing currently locks in "don't fall through" and it'd be easy to reintroduce.

Nits

  • The precedence fixture has no generic/ directory. {"#*": "./generic/*.js"} can never resolve, so if precedence regressed and #* won, the test fails because generic/precedence.js.js is missing rather than because the wrong existing file was picked. Adding generic/precedence.js.js makes the assertion direct and doesn't change expected output (#*.js still wins, so it's never traced).
  • 304/304 in the description is 306/306 on this branch — and on main, which already has the two imports-wildcard cases from feat: resolve package import wildcard trailers #604. Looks like the numbers were captured pre-rebase. Worth double-checking the "integration suite excluding socket.io.js — 88/88" line came from the rebased branch too.
  • All five new behaviors ride on the same two test cases (imports-wildcard from cwd + root), since the fixture was extended rather than split. Fine and consistent with how the suite is organized, but it does mean a single failure won't tell you which of the five regressed. Not worth restructuring for; just noting it as the tradeoff.
  • The patternKeyCompare comparator-consistency point from the last review is untouched, which is the right call — I flagged it as informational and couldn't produce a wrong result from it. Mentioning only so it reads as a decision rather than an oversight.
  • Deferring the null-target fallthrough with that rationale is the right call too, and the reasoning in the description is better than what I wrote — "blocking can remove files from traces" is exactly the compatibility risk.

Nothing here blocks except the coverage gap, which is two lines.

@cmpadden

Copy link
Copy Markdown
Contributor Author

Addressed in 748f505. Added coverage for a literal $& capture through the new external-target substitution site and mutation-tested that reverting its function replacer now fails both cwd/root cases. I also added existing decoy files for the precedence and less-specific fallback patterns, so those assertions now directly prove that the selected pattern wins and that an unresolved external target does not fall through. Updated the PR description to document the no-fallthrough behavior, clarify the platform-specific unit count, and record the intentional comparator/null-target deferrals.

styfle commented Aug 18, 2026

Copy link
Copy Markdown
Member

Re-reviewed at 748f505. The coverage gap I flagged is closed and the two nits are addressed — I re-ran the mutation matrix and the external-target replacer is now caught. One item is still outstanding, though: the no-fallthrough fixture doesn't actually exercise no-fallthrough.

Now covered ✅

reverted change detected?
addExportsTargetPath replacer (:241) module-sync-catchall ×2
wildcard resolvedPath replacer (:358) imports-wildcard ×2 + module-sync-catchall ×2
external-target replacer (:406) now caughtimports-wildcard ×2
external-target branch removed (:403) imports-wildcard ×2
patternKeyCompare length tiebreak inverted imports-wildcard ×2 — and with generic/precedence.js.js present it now fails by picking the wrong existing file, which is the point

Fixture still executes correctly under real Node, prettier --check is clean, and the 304 vs 306 discrepancy checks out — skipOnMac pushes microtime-node-gyp on darwin/arm64, which is exactly the two cwd/root cases. Good call documenting the behavior change and the patternKeyCompare decision in the description.

Outstanding: the no-fallthrough fixture is inert

#missing/thing.js ends in .js, so it can never reach #*. After #missing/* fails, the loop continues to #*.js — which sorts ahead of #* (same base length, longer key) and matches, because the specifier ends with the .js trailer. That resolves to ./dist/missing/thing.js, which doesn't exist, so validateAndResolvePaths throws and resolution ends there. #*./generic/*.js is never consulted, and generic/missing/thing.js.js is unreachable regardless of whether fallthrough exists.

Verified by reintroducing fallthrough — wrapping the new resolveDependency call so a failure falls through to the next key:

-        const resolved = await resolveDependency(...);
-        return Array.isArray(resolved) ? resolved : [resolved];
+        try {
+          const resolved = await resolveDependency(...);
+          return Array.isArray(resolved) ? resolved : [resolved];
+        } catch {}

306/306 still pass, and the emitted trace is byte-identical — same nine files, same single Failed to resolve dependency "#missing/thing.js" warning. So this fixture currently pins nothing. (The :403 mutation above is caught, but via the #dep/* cases, not this one.)

The fix is to pick a specifier #*.js can't intercept, so #* is the only remaining candidate. Rename both sides to .mjs:

-import('#missing/thing.js').catch(() => {});
+import('#missing/thing.mjs').catch(() => {});
-test/unit/imports-wildcard/generic/missing/thing.js.js
+test/unit/imports-wildcard/generic/missing/thing.mjs.js

#missing/thing.mjs doesn't end in .js, so #*.js is skipped and #*./generic/missing/thing.mjs.js becomes reachable — meaning it lands in the trace if and only if fallthrough regresses. I applied exactly that and re-ran:

  • unmutated → 306/306 pass, output.js unchanged, fixture still runs under real Node (import('#missing/thing.mjs') rejects with ERR_MODULE_NOT_FOUND and is caught, same as today)
  • fallthrough reintroduced → fails, imports-wildcard from cwd + root, with generic/missing/thing.mjs.js appearing in the trace

Worth also softening the description line — "Mutation checks confirmed that the tests fail independently when each resolver fix is reverted, including … no-fallthrough behavior" isn't true for the no-fallthrough case as currently written.

Optional, and not this PR

Dropping the trailer check in getPatternMatch(patternTrailer && !subpath.endsWith(patternTrailer)), so #*.js would match #foo.mjs — also leaves all 306 green. That's #604 code rather than anything introduced here, and I fuzzed the trailer logic against Node pretty hard last time so I'm confident it's correct; it's just unpinned. Given this PR is otherwise tightening exactly this area, a specifier that matches a pattern's base but not its trailer would be a cheap addition — happy for it to be a separate follow-up.

Everything else looks good to me. Re-ping me once the .mjs rename is in and I'll approve.

@styfle styfle changed the title fix: complete wildcard imports resolution feat: complete wildcard imports resolution Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants