fix(reference): never resolve a directory to an extensionless index (#3789)

resolveInternalSpecifier offered posix.join(dir, "index") as the
second candidate, ahead of every extension. With both dep.js and an
extensionless dep/index present, require("./dep") therefore resolved to
dep/index, a file that neither Node nor TypeScript ever selects.

Directory candidates now carry an extension. File and index ordering is
otherwise unchanged, so Node-validated cases such as dep/index.js
winning over dep.cjs keep their meaning.

Fixes #3381

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-14 19:12:36 -07:00
1 parent d69acc3455
commit 3ded799bc7
2 files changed
+11 -1

No files matched your search

@@ -110,9 +110,11 @@ const resolveInternalSpecifier = (
const directoryReference =
specifier.endsWith("/") || [".", ".."].includes(posix.basename(specifier));
const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"];
// A directory resolves only to an index with an extension; neither Node nor
// TypeScript selects an extensionless `index` file.
const candidates = suffixes.flatMap((suffix) => [
...(directoryReference ? [] : [`${normalized}${suffix}`]),
posix.join(normalized, `index${suffix}`),
...(suffix === "" ? [] : [posix.join(normalized, `index${suffix}`)]),
]);
// Retain present runtime files first; substitute only a missing counterpart.
// Counterparts cover explicit filenames, not directory/index resolution.
@@ -29,11 +29,13 @@ describe.skipIf(process.platform === "win32")(
["direct.cjs", "./lib.js", "lib.js"],
["fallback.cjs", "./fallback", "fallback/index.js"],
["legacy-order.cjs", "./dep", "dep/index.js"],
["bare-index.cjs", "./bare", "bare.js"],
] as const;
const unresolved = [
["missing.cjs", "./missing/", "missing/"],
["escape.cjs", "../outside/", "../outside/"],
["main.ts", "./folder.js/.", "folder.js"],
["only-bare.cjs", "./only/", "only/"],
] as const;
const files = new Map<string, string>([
["index.js", 'module.exports = "root index";\n'],
@@ -48,6 +50,9 @@ describe.skipIf(process.platform === "win32")(
'export const value = "emitted filename counterpart";\n',
],
["missing.js", 'module.exports = "not a directory";\n'],
["bare.js", 'module.exports = "file before directory";\n'],
["bare/index", 'module.exports = "extensionless index";\n'],
["only/index", 'module.exports = "extensionless index";\n'],
...[...imports, ...unresolved].map(
([path, specifier]) =>
[path, `require(${JSON.stringify(specifier)});\n`] as const,
@@ -75,6 +80,9 @@ describe.skipIf(process.platform === "win32")(
expect(() =>
createRequire(join(root, "main.ts")).resolve("./folder.js/."),
).toThrow();
expect(() =>
createRequire(join(root, "only-bare.cjs")).resolve("./only/"),
).toThrow();
expect(
createRequire(join(root, "escape.cjs")).resolve("../outside/"),
).toBe(outside);