fix(importer): reach package-main selection for trailing-slash directory references (#4219)

* fix(importer): reach package-main selection for trailing-slash directory references

posix.normalize preserves a trailing slash in the specifier, so normalized was sub/ while dirname(match) was sub; the resolvedThroughIndex comparison failed and selectPackageMain never ran for require("./sub/"). Compare against the slash-stripped directory so trailing-slash directory references take the same package-main path as the plain spelling. Boundary rows pair each trailing-slash request with the existing manifest fixtures and are validated against Node's own resolver in the same table.

Fixes #4189

* fix(importer): derive trailing-slash directory identity with path semantics

The lookbehind strip kept a trailing slash when the final component ended in a line terminator (require("./pkg\n/")), so dirname(match) still never matched and the trailing-slash path skipped package-main selection for those directories. Join the normalized path with "." instead: path semantics collapse any trailing slash run regardless of the preceding character, and behave identically to the strip on every other spelling. Boundary fixture gains a newline directory with a package.json, an index and a declared main so the index path (not the no-match branch) exercises the comparison.

Addresses Codex P1 on #4219

* fix(importer): normalize package-main targets with path semantics

---------

Co-authored-by: Hemang Varshney <218273197+hemv-857@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-24 23:14:19 +09:00
1 parent c6a2576aba
commit 791524c2fa
2 files changed
+27 -4

No files matched your search

@@ -269,9 +269,9 @@ const selectPackageMain = (
// package, and a trailing separator does not make the entry a directory.
if (posix.isAbsolute(manifest.main))
return { to: posix.normalize(manifest.main), resolution: "unresolved" };
const target = posix
.normalize(posix.join(packageDirectory, manifest.main))
.replace(/(?<=.)\/+$/u, "");
// Joining "." strips the trailing separator run with path semantics; a
// lookbehind regex kept separators behind line terminators (main "lib\n/").
const target = posix.join(posix.join(packageDirectory, manifest.main), ".");
// Node would load a declared entry outside the imported root.
if (logicalPathEscapesRoot(target))
return { to: target, resolution: "unresolved" };
@@ -395,9 +395,15 @@ const resolveInternalSpecifier = (
return packageMainForDirectory(normalized, context) !== undefined
? selectPackageMain(normalized, undefined, context, mode)
: { to: normalized, resolution: "unresolved" };
// posix.normalize preserves the specifier's trailing slash while
// dirname(match) never carries one; derive the directory identity with path
// semantics (joining "." collapses trailing slashes, including ones a regex
// would keep behind a line terminator) so a trailing-slash reference still
// reaches package-main selection the same way the plain spelling does.
const directory = posix.join(normalized, ".");
const resolvedThroughIndex =
posix.basename(match).startsWith("index.") &&
posix.dirname(match).normalize("NFC") === normalized.normalize("NFC");
posix.dirname(match).normalize("NFC") === directory.normalize("NFC");
return resolvedThroughIndex
? selectPackageMain(posix.dirname(match), match, context, mode)
: { to: match, resolution: "internal" };
@@ -37,10 +37,14 @@ const prepareReferenceSourceDirectoryFixture =
["legacy-order.cjs", "./dep", "dep/index.js"],
["bare-index.cjs", "./bare", "bare.js"],
["package-main.cjs", "./pkg", "pkg/actual.js"],
["package-main-slash-specifier.cjs", "./pkg/", "pkg/actual.js"],
["package-main-newline-slash.cjs", "./pkg\n/", "pkg\n/actual.js"],
["package-main-no-index.cjs", "./no-index-pkg", "no-index-pkg/actual.js"],
["package-main-fallback.cjs", "./stale", "stale/index.js"],
["package-main-fallback-slash.cjs", "./stale/", "stale/index.js"],
["typed-index-collision.cjs", "./typed", "typed.js"],
["package-main-slash.cjs", "./slashed", "slashed/lib.js"],
["package-main-manifest-newline-slash.cjs", "./nlpkg/", "nlpkg/lib\n.js"],
["json.cjs", "./data", "data.json"],
["json-explicit.cjs", "./data.json", "data.json"],
["json-before-index.cjs", "./conf", "conf.json"],
@@ -70,6 +74,12 @@ const prepareReferenceSourceDirectoryFixture =
["bare/index", 'module.exports = "extensionless index";\n'],
["only/index", 'module.exports = "extensionless index";\n'],
["pkg/package.json", '{"main":"actual.js"}\n'],
["pkg\n/package.json", '{"main":"actual.js"}\n'],
[
"pkg\n/actual.js",
'module.exports = "declared main behind line terminator";\n',
],
["pkg\n/index.js", 'module.exports = "index not selected";\n'],
["pkg/actual.js", 'module.exports = "declared main";\n'],
["pkg/index.js", 'module.exports = "ignored index";\n'],
["no-index-pkg/package.json", '{"main":"actual.js"}\n'],
@@ -87,6 +97,13 @@ const prepareReferenceSourceDirectoryFixture =
["slashed/lib.js", 'module.exports = "file main";\n'],
["slashed/lib/index.js", 'module.exports = "not selected";\n'],
["slashed/index.js", 'module.exports = "not selected";\n'],
["nlpkg/package.json", '{"main":"lib\\n/"}\n'],
[
"nlpkg/lib\n.js",
'module.exports = "file main behind line terminator";\n',
],
["nlpkg/lib\n/index.js", 'module.exports = "not selected";\n'],
["nlpkg/index.js", 'module.exports = "entry index";\n'],
["absolute/package.json", `${JSON.stringify({ main: outside })}\n`],
["absolute/index.js", 'module.exports = "not selected";\n'],
["data.json", '{"value":1}\n'],