Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
d9e9e3643e fix(resolver): resolve tsconfig extends with package specifiers via node_modules
When tsconfig.json uses `"extends": "@acme/configuration/tsconfig.base.json"`
(a bare package specifier), Bun was naively joining the path against the
tsconfig's directory instead of resolving through node_modules. This caused
inherited settings like `emitDecoratorMetadata` and `experimentalDecorators`
to silently fail.

This change:
- Adds node_modules resolution for bare package specifiers in tsconfig extends
  by walking up parent directories (matching TypeScript's behavior)
- Merges `experimentalDecorators` in the extends chain (was previously missing)

Closes #6326

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 04:54:12 +00:00
2 changed files with 188 additions and 16 deletions

View File

@@ -3979,6 +3979,27 @@ pub const Resolver = struct {
return null;
}
/// Resolve a tsconfig.json "extends" value that is a bare package specifier
/// (e.g. "@acme/configuration/tsconfig.base.json") by walking up parent
/// directories to find it in node_modules.
/// Returns the resolved *TSConfigJSON if found.
fn resolvePackagePathForTSConfigExtends(r: *ThisResolver, starting_info: *const DirInfo, extends: string) ?*TSConfigJSON {
// Walk up the directory tree using the already-populated parent chain.
var cur_dir: ?*const DirInfo = starting_info;
while (cur_dir) |dir| {
if (dir.hasNodeModules()) {
var parts = [_]string{ dir.abs_path, "node_modules", extends };
const candidate = r.fs.absBuf(&parts, bufs(.tsconfig_path_abs));
const persistent_path = r.fs.dirname_store.append(string, candidate) catch return null;
if (r.parseTSConfig(persistent_path, bun.invalid_fd) catch null) |parent_config| {
return parent_config;
}
}
cur_dir = dir.getParent();
}
return null;
}
fn dirInfoUncached(
r: *ThisResolver,
info: *DirInfo,
@@ -4202,23 +4223,23 @@ pub const Resolver = struct {
try parent_configs.append(tsconfig_json);
var current = tsconfig_json;
while (current.extends.len > 0) {
const ts_dir_name = Dirname.dirname(current.abs_path);
const abs_path = ResolvePath.joinAbsStringBuf(ts_dir_name, bufs(.tsconfig_path_abs), &[_]string{ ts_dir_name, current.extends }, .auto);
const parent_config_maybe = r.parseTSConfig(abs_path, bun.invalid_fd) catch |err| {
r.log.addDebugFmt(null, logger.Loc.Empty, r.allocator, "{s} loading tsconfig.json extends {f}", .{
@errorName(err),
bun.fmt.QuotedFormatter{
.text = abs_path,
},
}) catch {};
break;
const parent_config = if (isPackagePath(current.extends))
r.resolvePackagePathForTSConfigExtends(info, current.extends) orelse break
else brk: {
const ts_dir_name = Dirname.dirname(current.abs_path);
const abs_path = ResolvePath.joinAbsStringBuf(ts_dir_name, bufs(.tsconfig_path_abs), &[_]string{ ts_dir_name, current.extends }, .auto);
break :brk r.parseTSConfig(abs_path, bun.invalid_fd) catch |err| {
r.log.addDebugFmt(null, logger.Loc.Empty, r.allocator, "{s} loading tsconfig.json extends {f}", .{
@errorName(err),
bun.fmt.QuotedFormatter{
.text = abs_path,
},
}) catch {};
break;
} orelse break;
};
if (parent_config_maybe) |parent_config| {
try parent_configs.append(parent_config);
current = parent_config;
} else {
break;
}
try parent_configs.append(parent_config);
current = parent_config;
}
var merged_config = parent_configs.pop().?;
@@ -4226,6 +4247,7 @@ pub const Resolver = struct {
// successively apply the inheritable attributes to the next config
while (parent_configs.pop()) |parent_config| {
merged_config.emit_decorator_metadata = merged_config.emit_decorator_metadata or parent_config.emit_decorator_metadata;
merged_config.experimental_decorators = merged_config.experimental_decorators or parent_config.experimental_decorators;
if (parent_config.base_url.len > 0) {
merged_config.base_url = parent_config.base_url;
merged_config.base_url_for_paths = parent_config.base_url_for_paths;

View File

@@ -0,0 +1,150 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import path from "node:path";
describe("tsconfig extends with package specifiers", () => {
test("resolves extends from node_modules", async () => {
using dir = tempDir("issue-6326", {
"node_modules/@acme/configuration/tsconfig.base.json": JSON.stringify({
compilerOptions: {
jsxFactory: "h",
},
}),
"tsconfig.json": JSON.stringify({
extends: "@acme/configuration/tsconfig.base.json",
compilerOptions: {
jsx: "react",
},
}),
// h() is only used as jsxFactory if the extends is properly resolved
"index.tsx": `
function h(tag: string, props: any, ...children: any[]) {
return { tag, props, children };
}
console.log(JSON.stringify(<div id="test" />));
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "run", path.join(String(dir), "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]);
// If jsxFactory "h" was inherited, we get our custom element object.
// If not inherited, React.createElement is used and fails.
expect(stdout).toContain('"tag":"div"');
expect(exitCode).toBe(0);
});
test("resolves extends for scoped package", async () => {
using dir = tempDir("issue-6326-scoped", {
"node_modules/@acme/configuration/tsconfig.base.json": JSON.stringify({
compilerOptions: {
jsxFactory: "h",
jsxFragmentFactory: "Fragment",
},
}),
"tsconfig.json": JSON.stringify({
extends: "@acme/configuration/tsconfig.base.json",
compilerOptions: {
jsx: "react",
},
}),
"index.tsx": `
function h(tag: any, props: any, ...children: any[]) {
return { tag: typeof tag === 'function' ? 'fragment' : tag, props, children };
}
function Fragment(props: any) { return props; }
console.log(JSON.stringify(<><span /></>));
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "run", path.join(String(dir), "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(stdout).toContain('"tag":"fragment"');
expect(exitCode).toBe(0);
});
test("resolves extends for unscoped package", async () => {
using dir = tempDir("issue-6326-unscoped", {
"node_modules/my-config/tsconfig.json": JSON.stringify({
compilerOptions: {
jsxFactory: "h",
},
}),
"tsconfig.json": JSON.stringify({
extends: "my-config/tsconfig.json",
compilerOptions: {
jsx: "react",
},
}),
"index.tsx": `
function h(tag: string, props: any, ...children: any[]) {
return { tag, props, children };
}
console.log(JSON.stringify(<div />));
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "run", path.join(String(dir), "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(stdout).toContain('"tag":"div"');
expect(exitCode).toBe(0);
});
test("relative extends still works", async () => {
using dir = tempDir("issue-6326-relative", {
"base/tsconfig.base.json": JSON.stringify({
compilerOptions: {
jsxFactory: "h",
},
}),
"tsconfig.json": JSON.stringify({
extends: "./base/tsconfig.base.json",
compilerOptions: {
jsx: "react",
},
}),
"index.tsx": `
function h(tag: string, props: any, ...children: any[]) {
return { tag, props, children };
}
console.log(JSON.stringify(<div />));
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "run", path.join(String(dir), "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(stdout).toContain('"tag":"div"');
expect(exitCode).toBe(0);
});
});