From 3ded799bc7b9a588404913b4064aff8c1412a045 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 19:12:36 -0700 Subject: [PATCH] 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 --- src/application/ReferenceSourceImportEntries.ts | 4 +++- .../boundary/cli/referenceSourceDirectoryImports.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index d2602df044..c53df6a9d1 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -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. diff --git a/tests/boundary/cli/referenceSourceDirectoryImports.test.ts b/tests/boundary/cli/referenceSourceDirectoryImports.test.ts index 6270855cbc..8fbd8a421c 100644 --- a/tests/boundary/cli/referenceSourceDirectoryImports.test.ts +++ b/tests/boundary/cli/referenceSourceDirectoryImports.test.ts @@ -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([ ["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);