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 <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2026-01-15 01:14:17 +00:00
parent 22bebfc467
commit 4bbfb23ea5
3 changed files with 313 additions and 0 deletions

View File

@@ -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;

View File

@@ -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"

View File

@@ -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);
});
});