From 4bbfb23ea549fb9946015b2c93a16af1b001d69e Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 15 Jan 2026 01:14:17 +0000 Subject: [PATCH] fix(resolver): support tsconfig project references path mappings Parse and resolve path mappings from tsconfig.json `references` array. This allows monorepo-style setups where path aliases are defined in referenced configs to work correctly. - Add `references` field parsing in tsconfig_json.zig - Load and merge paths from referenced configs in resolver.zig - Referenced config paths have lower priority than main config paths - Support both file paths and directory paths (with tsconfig.json) Fixes #3617 Co-Authored-By: Claude Opus 4.5 --- src/resolver/resolver.zig | 50 ++++++ src/resolver/tsconfig_json.zig | 23 +++ test/regression/issue/3617.test.ts | 240 +++++++++++++++++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 test/regression/issue/3617.test.ts diff --git a/src/resolver/resolver.zig b/src/resolver/resolver.zig index ec0ae1dc10..2b2fd8379d 100644 --- a/src/resolver/resolver.zig +++ b/src/resolver/resolver.zig @@ -64,6 +64,7 @@ const bufs = struct { pub threadlocal var node_modules_check: bun.PathBuffer = undefined; pub threadlocal var field_abs_path: bun.PathBuffer = undefined; pub threadlocal var tsconfig_path_abs: bun.PathBuffer = undefined; + pub threadlocal var tsconfig_path_abs2: bun.PathBuffer = undefined; 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; @@ -4241,6 +4242,55 @@ pub const Resolver = struct { } // todo deinit these parent configs somehow? } + + // Process "references" array - load configs and merge their paths + // References are lower priority than extends, so we only add paths that don't already exist + // https://www.typescriptlang.org/docs/handbook/project-references.html + for (tsconfig_json.references) |ref_path| { + const ts_dir_name = Dirname.dirname(tsconfig_json.abs_path); + // Reference paths can point to directories or tsconfig files + // If it's a directory, append tsconfig.json + const resolved_ref_path = ResolvePath.joinAbsStringBuf(ts_dir_name, bufs(.tsconfig_path_abs), &[_]string{ ts_dir_name, ref_path }, .auto); + + // Try to load the referenced config (either as file or directory/tsconfig.json) + const ref_config_maybe = r.parseTSConfig(resolved_ref_path, bun.invalid_fd) catch |err| blk: { + // If path doesn't have .json extension, try as directory with tsconfig.json + if (!strings.endsWith(ref_path, ".json")) { + const dir_config_path = ResolvePath.joinAbsStringBuf(resolved_ref_path, bufs(.tsconfig_path_abs2), &[_]string{ resolved_ref_path, "tsconfig.json" }, .auto); + break :blk r.parseTSConfig(dir_config_path, bun.invalid_fd) catch { + r.log.addDebugFmt(null, logger.Loc.Empty, r.allocator, "{s} loading tsconfig.json reference {f}", .{ + @errorName(err), + bun.fmt.QuotedFormatter{ .text = ref_path }, + }) catch {}; + break :blk null; + }; + } + r.log.addDebugFmt(null, logger.Loc.Empty, r.allocator, "{s} loading tsconfig.json reference {f}", .{ + @errorName(err), + bun.fmt.QuotedFormatter{ .text = ref_path }, + }) catch {}; + break :blk null; + }; + + if (ref_config_maybe) |ref_config| { + // Merge paths from referenced config (lower priority - don't overwrite existing paths) + var ref_iter = ref_config.paths.iterator(); + while (ref_iter.next()) |c| { + // Only add if this path pattern doesn't already exist in the merged config + if (!merged_config.paths.contains(c.key_ptr.*)) { + merged_config.paths.put(c.key_ptr.*, c.value_ptr.*) catch unreachable; + } + } + + // If merged config doesn't have baseUrl but ref does, we can use it + // (lower priority, so only if not already set) + if (merged_config.base_url.len == 0 and ref_config.base_url.len > 0) { + merged_config.base_url = ref_config.base_url; + merged_config.base_url_for_paths = ref_config.base_url_for_paths; + } + } + } + info.tsconfig_json = merged_config; } info.enclosing_tsconfig_json = info.tsconfig_json; diff --git a/src/resolver/tsconfig_json.zig b/src/resolver/tsconfig_json.zig index d7510c5363..08cfee23c4 100644 --- a/src/resolver/tsconfig_json.zig +++ b/src/resolver/tsconfig_json.zig @@ -24,6 +24,9 @@ pub const TSConfigJSON = struct { base_url_for_paths: string = "", extends: string = "", + // Array of referenced tsconfig paths for project references + // https://www.typescriptlang.org/docs/handbook/project-references.html + references: []const string = &.{}, // The verbatim values of "compilerOptions.paths". The keys are patterns to // match and the values are arrays of fallback paths to search. Each key and // each fallback path can optionally have a single "*" wildcard character. @@ -157,6 +160,26 @@ pub const TSConfigJSON = struct { } } } + + // Parse "references" array for project references + // https://www.typescriptlang.org/docs/handbook/project-references.html + if (json.asProperty("references")) |references_value| { + if (!source.path.isNodeModule()) parse_references: { + var array = references_value.expr.asArray() orelse break :parse_references; + var refs = std.ArrayListUnmanaged(string){}; + errdefer refs.deinit(allocator); + while (array.next()) |element| { + // Each reference is an object with a "path" property + if (element.asProperty("path")) |path_prop| { + if (path_prop.expr.asString(allocator)) |path_str| { + refs.append(allocator, path_str) catch continue; + } + } + } + result.references = refs.toOwnedSlice(allocator) catch &.{}; + } + } + var has_base_url = false; // Parse "compilerOptions" diff --git a/test/regression/issue/3617.test.ts b/test/regression/issue/3617.test.ts new file mode 100644 index 0000000000..28b8a6bad4 --- /dev/null +++ b/test/regression/issue/3617.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import path from "node:path"; + +describe("issue #3617: tsconfig references path mappings", () => { + test("should resolve paths from referenced tsconfig", async () => { + // Create test structure matching the issue's reproduction case + const dir = tempDirWithFiles("tsconfig-references", { + // Main tsconfig with references pointing to app config + "tsconfig.json": JSON.stringify({ + files: [], + references: [{ path: "./tsconfig.app.json" }], + }), + // App tsconfig with path mappings + "tsconfig.app.json": JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { + "~/*": ["./src/*"], + }, + }, + include: ["src/**/*"], + }), + // Source file + "src/number.ts": ` + export function displayMax(a: number, b: number): number { + return Math.max(a, b); + } + `, + // Main file using the path alias + "index.ts": ` + import { displayMax } from "~/number"; + console.log("Result:", displayMax(1, 2)); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", path.join(dir, "index.ts")], + env: bunEnv, + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("Cannot find module"); + expect(stdout).toContain("Result: 2"); + expect(exitCode).toBe(0); + }); + + test("should resolve paths from referenced tsconfig directory (without .json extension)", async () => { + // Reference can point to a directory containing tsconfig.json + const dir = tempDirWithFiles("tsconfig-references-dir", { + "tsconfig.json": JSON.stringify({ + files: [], + references: [{ path: "./packages/core" }], + }), + "packages/core/tsconfig.json": JSON.stringify({ + compilerOptions: { + baseUrl: "../..", + paths: { + "@core/*": ["./packages/core/src/*"], + }, + }, + }), + "packages/core/src/utils.ts": ` + export function greet(name: string): string { + return \`Hello, \${name}!\`; + } + `, + "app/index.ts": ` + import { greet } from "@core/utils"; + console.log(greet("World")); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", path.join(dir, "app/index.ts")], + env: bunEnv, + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("Cannot find module"); + expect(stdout).toContain("Hello, World!"); + expect(exitCode).toBe(0); + }); + + test("should prefer main config paths over referenced config paths", async () => { + // Main config paths should have higher priority than referenced config paths + const dir = tempDirWithFiles("tsconfig-references-priority", { + "tsconfig.json": JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { + "@lib/*": ["./lib-override/*"], + }, + }, + references: [{ path: "./tsconfig.base.json" }], + }), + "tsconfig.base.json": JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { + "@lib/*": ["./lib-base/*"], + }, + }, + }), + "lib-override/helper.ts": ` + export const value = "from-override"; + `, + "lib-base/helper.ts": ` + export const value = "from-base"; + `, + "index.ts": ` + import { value } from "@lib/helper"; + console.log(value); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", path.join(dir, "index.ts")], + env: bunEnv, + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("Cannot find module"); + expect(stdout).toContain("from-override"); + expect(exitCode).toBe(0); + }); + + test("should merge non-conflicting paths from referenced config", async () => { + // Referenced config should add additional paths that don't conflict with main + const dir = tempDirWithFiles("tsconfig-references-merge", { + "tsconfig.json": JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { + "@app/*": ["./app/*"], + }, + }, + references: [{ path: "./tsconfig.lib.json" }], + }), + "tsconfig.lib.json": JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { + "@lib/*": ["./lib/*"], + }, + }, + }), + "app/main.ts": ` + export const appValue = "app"; + `, + "lib/utils.ts": ` + export const libValue = "lib"; + `, + "index.ts": ` + import { appValue } from "@app/main"; + import { libValue } from "@lib/utils"; + console.log(appValue, libValue); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", path.join(dir, "index.ts")], + env: bunEnv, + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("Cannot find module"); + expect(stdout).toContain("app"); + expect(stdout).toContain("lib"); + expect(exitCode).toBe(0); + }); + + test("should handle multiple references", async () => { + // Multiple references should all be processed + const dir = tempDirWithFiles("tsconfig-references-multiple", { + "tsconfig.json": JSON.stringify({ + files: [], + references: [{ path: "./packages/a/tsconfig.json" }, { path: "./packages/b/tsconfig.json" }], + }), + "packages/a/tsconfig.json": JSON.stringify({ + compilerOptions: { + baseUrl: "../..", + paths: { + "@a/*": ["./packages/a/src/*"], + }, + }, + }), + "packages/b/tsconfig.json": JSON.stringify({ + compilerOptions: { + baseUrl: "../..", + paths: { + "@b/*": ["./packages/b/src/*"], + }, + }, + }), + "packages/a/src/a.ts": ` + export const a = "from-a"; + `, + "packages/b/src/b.ts": ` + export const b = "from-b"; + `, + "index.ts": ` + import { a } from "@a/a"; + import { b } from "@b/b"; + console.log(a, b); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", path.join(dir, "index.ts")], + env: bunEnv, + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("Cannot find module"); + expect(stdout).toContain("from-a"); + expect(stdout).toContain("from-b"); + expect(exitCode).toBe(0); + }); +});