Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
8fe8691b2b fix(resolver): skip case-insensitive false matches during extension probing
When a directory contains files differing only in case and extension
(e.g. `todos.ts` and `Todos.tsx`), the resolver's case-insensitive
directory lookup would incorrectly match `Todos.tsx` when probing for
`todos.tsx`, preventing the correct `todos.ts` from being found.

Fix by checking that the import stem case-sensitively matches the found
file's stem during extension probing. Also fix path caching in
`loadExtension` to use the entry's actual base name instead of the
query buffer, which could produce wrong-cased paths on case-sensitive
filesystems.

Closes #22686

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 11:09:52 +00:00
2 changed files with 123 additions and 7 deletions

View File

@@ -67,6 +67,7 @@ const bufs = struct {
pub threadlocal var check_browser_map: bun.PathBuffer = undefined;
pub threadlocal var remap_path: bun.PathBuffer = undefined;
pub threadlocal var load_as_file: bun.PathBuffer = undefined;
pub threadlocal var load_as_file2: bun.PathBuffer = undefined;
pub threadlocal var remap_path_trailing_slash: bun.PathBuffer = undefined;
pub threadlocal var path_in_global_disk_cache: bun.PathBuffer = undefined;
pub threadlocal var abs_to_rel: bun.PathBuffer = undefined;
@@ -3896,6 +3897,18 @@ pub const Resolver = struct {
@memcpy(buffer[segment.len..buffer.len][0..ext_to_replace.len], ext_to_replace);
if (entries.get(buffer)) |query| {
// If the case-insensitive lookup matched a differently-cased file,
// verify the stem matches to avoid confusing different files.
if (query.diff_case != null) {
const actual_base = query.entry.base();
const actual_stem_end = std.mem.lastIndexOfScalar(u8, actual_base, '.') orelse actual_base.len;
if (!bun.strings.eqlLong(segment, actual_base[0..actual_stem_end], false)) {
if (r.debug_logs) |*debug| {
debug.addNoteFmt("Skipping \"{s}\" (stem case mismatch during rewrite)", .{actual_base});
}
continue;
}
}
if (query.entry.kind(rfs, r.store_fd) == .file) {
if (r.debug_logs) |*debug| {
debug.addNoteFmt("Rewrote to \"{s}\" ", .{buffer});
@@ -3904,12 +3917,12 @@ pub const Resolver = struct {
return LoadResult{
.path = brk: {
if (query.entry.abs_path.isEmpty()) {
const actual_name = query.entry.base();
if (query.entry.dir.len > 0 and query.entry.dir[query.entry.dir.len - 1] == std.fs.path.sep) {
const parts = [_]string{ query.entry.dir, buffer };
const parts = [_]string{ query.entry.dir, actual_name };
query.entry.abs_path = PathString.init(r.fs.filename_store.append(@TypeOf(parts), parts) catch unreachable);
// the trailing path CAN be missing here
} else {
const parts = [_]string{ query.entry.dir, std.fs.path.sep_str, buffer };
const parts = [_]string{ query.entry.dir, std.fs.path.sep_str, actual_name };
query.entry.abs_path = PathString.init(r.fs.filename_store.append(@TypeOf(parts), parts) catch unreachable);
}
}
@@ -3954,18 +3967,38 @@ pub const Resolver = struct {
}
if (entries.get(file_name)) |query| {
if (query.diff_case != null) {
// The case-insensitive lookup matched a file with different casing.
// Check if the import stem (e.g. "todos") matches the found file's
// stem (e.g. "Todos"). If the stems differ, this is a different file
// that only matched because the directory lookup is case-insensitive.
// Skip it so the resolver can continue trying other extensions.
// (e.g. "todos" + ".tsx" should NOT match "Todos.tsx" when "todos.ts" exists)
const actual_base = query.entry.base();
const stem_end = std.mem.lastIndexOfScalar(u8, actual_base, '.') orelse actual_base.len;
const actual_stem = actual_base[0..stem_end];
if (!bun.strings.eqlLong(base, actual_stem, false)) {
if (r.debug_logs) |*debug| {
debug.addNoteFmt("Skipping \"{s}\" (stem case mismatch: \"{s}\" vs \"{s}\")", .{ actual_base, base, actual_stem });
}
return null;
}
}
if (query.entry.kind(rfs, r.store_fd) == .file) {
if (r.debug_logs) |*debug| {
debug.addNoteFmt("Found file \"{s}\" ", .{buffer});
}
// now that we've found it, we allocate it.
// Use the entry's actual base name to build the absolute path,
// not the query buffer, to ensure correct casing on case-sensitive
// filesystems.
return .{
.path = brk: {
query.entry.abs_path = if (query.entry.abs_path.isEmpty())
PathString.init(r.fs.dirname_store.append(@TypeOf(buffer), buffer) catch unreachable)
else
query.entry.abs_path;
if (query.entry.abs_path.isEmpty()) {
const abs_path_parts = [_]string{ query.entry.dir, query.entry.base() };
query.entry.abs_path = PathString.init(r.fs.dirname_store.append(string, r.fs.absBuf(&abs_path_parts, bufs(.load_as_file2))) catch unreachable);
}
break :brk query.entry.abs_path.slice();
},

View File

@@ -0,0 +1,83 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// https://github.com/oven-sh/bun/issues/22686
// When a directory has files that differ only in case and extension
// (e.g. todos.ts and Todos.tsx), extensionless imports should resolve
// to the correct file based on case-sensitive stem matching.
test("extensionless import resolves correct file when similar names differ by case", async () => {
using dir = tempDir("issue-22686", {
"src/todos.ts": `export const todos = ["todo1", "todo2"];`,
"src/Todos.tsx": `export function Todos() { return "Todos Component"; }`,
"src/index.tsx": `
import { todos } from "./todos";
import { Todos } from "./Todos";
console.log(JSON.stringify(todos));
console.log(Todos());
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "run", "src/index.tsx"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe('["todo1","todo2"]\nTodos Component\n');
expect(exitCode).toBe(0);
});
test("bundler resolves correct file when similar names differ by case", async () => {
using dir = tempDir("issue-22686-bundler", {
"src/todos.ts": `export const todos = ["todo1", "todo2"];`,
"src/Todos.tsx": `export function Todos() { return "Todos Component"; }`,
"src/index.tsx": `
import { todos } from "./todos";
import { Todos } from "./Todos";
console.log(JSON.stringify(todos));
console.log(Todos());
`,
});
// First, bundle
await using buildProc = Bun.spawn({
cmd: [bunExe(), "build", "src/index.tsx", "--outdir=dist"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [buildStdout, buildStderr, buildExitCode] = await Promise.all([
buildProc.stdout.text(),
buildProc.stderr.text(),
buildProc.exited,
]);
expect(buildStderr).toBe("");
expect(buildExitCode).toBe(0);
// Then run the bundled output
await using runProc = Bun.spawn({
cmd: [bunExe(), "run", "dist/index.js"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [runStdout, runStderr, runExitCode] = await Promise.all([
runProc.stdout.text(),
runProc.stderr.text(),
runProc.exited,
]);
expect(runStderr).toBe("");
expect(runStdout).toBe('["todo1","todo2"]\nTodos Component\n');
expect(runExitCode).toBe(0);
});