What
extractEsmShSubpath in src/transforms/import-rewriter/strategies/import-map-strategy.ts returns "" for every scoped package.
if (pathname.startsWith("@")) {
const parts = pathname.split("/");
if (parts.length <= 2) return "";
const packageParts = parts.slice(0, 2).join("/");
const afterPackage = pathname.slice(packageParts.length);
const versionMatch = afterPackage.match(/^@[^/]+(.*)$/);
return versionMatch?.[1] ?? "";
}
Trace https://esm.sh/@scope/pkg@1.0/sub:
| step |
value |
pathname |
@scope/pkg@1.0/sub |
parts |
["@scope", "pkg@1.0", "sub"] |
packageParts |
@scope/pkg@1.0 — already contains the version |
afterPackage |
/sub |
versionMatch |
null, because /sub does not start with @ |
| return |
"" |
The regex expects the version to still be ahead of it, but parts.slice(0, 2) consumed it. The unscoped branch does not have this problem, which is why unscoped subpaths work.
Impact
In resolveImportWithMap, an empty subpath skips the esmShPackage + subpath lookup:
const subpath = extractEsmShSubpath(specifier);
if (subpath) {
const fullKey = esmShPackage + subpath;
...
}
So for a scoped package, an import map entry keyed on @scope/pkg/sub never matches, and the specifier resolves to the package root instead — or to null when only a package-plus-subpath key exists. Silent wrong resolution rather than an error.
Suggested fix
Split the version off before computing the remainder, so scoped and unscoped paths share one shape. Something like matching ^(@[^/]+/[^/@]+)(?:@[^/]+)?(.*)$ and returning group 2 covers both, but the exact parse should be chosen by someone who owns esm.sh specifier handling.
Provenance
Found during a test-quality audit of src/transforms (PR #4097). That PR adds three cases covering the unscoped subpath tail, which pass. The scoped case was deliberately omitted — the only assertion that passes today is one pinning the dropped subpath as expected, which is the behavior this audit exists to remove.
What
extractEsmShSubpathinsrc/transforms/import-rewriter/strategies/import-map-strategy.tsreturns""for every scoped package.Trace
https://esm.sh/@scope/pkg@1.0/sub:pathname@scope/pkg@1.0/subparts["@scope", "pkg@1.0", "sub"]packageParts@scope/pkg@1.0— already contains the versionafterPackage/subversionMatchnull, because/subdoes not start with@""The regex expects the version to still be ahead of it, but
parts.slice(0, 2)consumed it. The unscoped branch does not have this problem, which is why unscoped subpaths work.Impact
In
resolveImportWithMap, an empty subpath skips theesmShPackage + subpathlookup:So for a scoped package, an import map entry keyed on
@scope/pkg/subnever matches, and the specifier resolves to the package root instead — or tonullwhen only a package-plus-subpath key exists. Silent wrong resolution rather than an error.Suggested fix
Split the version off before computing the remainder, so scoped and unscoped paths share one shape. Something like matching
^(@[^/]+/[^/@]+)(?:@[^/]+)?(.*)$and returning group 2 covers both, but the exact parse should be chosen by someone who owns esm.sh specifier handling.Provenance
Found during a test-quality audit of
src/transforms(PR #4097). That PR adds three cases covering the unscoped subpath tail, which pass. The scoped case was deliberately omitted — the only assertion that passes today is one pinning the dropped subpath as expected, which is the behavior this audit exists to remove.