Skip to content
Closed
8 changes: 4 additions & 4 deletions src/bun.js/api/JSTranspiler.zig
Original file line number Diff line number Diff line change
Expand Up @@ -515,8 +515,8 @@ pub const TransformTask = struct {
.path = source.path,
.virtual_source = &source,
.replace_exports = this.replace_exports,
.experimental_decorators = if (this.tsconfig) |ts| ts.experimental_decorators else false,
.emit_decorator_metadata = if (this.tsconfig) |ts| ts.emit_decorator_metadata else false,
.experimental_decorators = if (this.tsconfig) |ts| ts.experimental_decorators orelse false else false,
.emit_decorator_metadata = if (this.tsconfig) |ts| ts.emit_decorator_metadata orelse false else false,
};

const parse_result = this.transpiler.parse(parse_options, null) orelse {
Expand Down Expand Up @@ -813,8 +813,8 @@ fn getParseResult(this: *JSTranspiler, allocator: std.mem.Allocator, code: []con
.virtual_source = source,
.replace_exports = this.config.runtime.replace_exports,
.macro_js_ctx = macro_js_ctx,
.experimental_decorators = if (this.config.tsconfig) |ts| ts.experimental_decorators else false,
.emit_decorator_metadata = if (this.config.tsconfig) |ts| ts.emit_decorator_metadata else false,
.experimental_decorators = if (this.config.tsconfig) |ts| ts.experimental_decorators orelse false else false,
.emit_decorator_metadata = if (this.config.tsconfig) |ts| ts.emit_decorator_metadata orelse false else false,
};

return this.transpiler.parse(parse_options, null);
Expand Down
155 changes: 136 additions & 19 deletions src/resolver/resolver.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1000,8 +1000,8 @@

if (dir.enclosing_tsconfig_json) |tsconfig| {
result.jsx = tsconfig.mergeJSX(result.jsx);
result.flags.emit_decorator_metadata = result.flags.emit_decorator_metadata or tsconfig.emit_decorator_metadata;
result.flags.experimental_decorators = result.flags.experimental_decorators or tsconfig.experimental_decorators;
result.flags.emit_decorator_metadata = result.flags.emit_decorator_metadata or (tsconfig.emit_decorator_metadata orelse false);
result.flags.experimental_decorators = result.flags.experimental_decorators or (tsconfig.experimental_decorators orelse false);
}
Comment thread
robobun marked this conversation as resolved.

// If you use mjs or mts, then you're using esm
Expand Down Expand Up @@ -3982,6 +3982,111 @@
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 {
// Detect whether the extends value points at a subpath inside the
// package (e.g. "@scope/pkg/tsconfig.base.json") or is just the
// package name (e.g. "@scope/pkg" or "pkg"), in which case TypeScript
// resolves "<pkg>/tsconfig.json".
const has_subpath = brk: {
if (strings.startsWithChar(extends, '@')) {
// Scoped: the first '/' separates scope from package name, so a
// subpath requires a second '/'.
const first_slash = strings.indexOfChar(extends, '/') orelse break :brk false;
break :brk strings.indexOfChar(extends[first_slash + 1 ..], '/') != null;
}
break :brk strings.indexOfChar(extends, '/') != null;
};
// If the subpath already ends in ".json" we don't need to try the
// implicit "<path>.json" fallback. TypeScript allows omitting the
// extension (e.g. "extends": "expo/tsconfig.base").
const has_json_ext = strings.hasSuffixComptime(extends, ".json");

const buf = bufs(.tsconfig_path_abs);

// Walk up the directory tree using the already-populated parent chain.
var cur_dir: ?*const DirInfo = starting_info;
while (cur_dir) |dir| : (cur_dir = dir.getParent()) {
if (!dir.hasNodeModules()) continue;

// extends is user-controlled and can be arbitrarily long, so use
// the checked variant to avoid overflowing the path buffer.
const candidate = r.fs.absBufChecked(
&.{ dir.abs_path, "node_modules", extends },
buf,
) orelse continue;
// candidate is a slice of buf starting at offset 0; we can extend
// it in place for the implicit-suffix probes below.
const base_len = candidate.len;
Comment thread
robobun marked this conversation as resolved.

if (has_subpath) {
// 1. Try the path exactly as written: "<node_modules>/<extends>".
// Skipped for bare package names since those always name a
// directory in node_modules (would always EISDIR).
if (r.tryParseTSConfigPath(candidate)) |parent_config| {
return parent_config;
}
if (!has_json_ext) {
// 2. "<node_modules>/<extends>.json" for extensionless subpaths
{
const suffix = ".json";
if (base_len + suffix.len <= buf.len) {
@memcpy(buf[base_len..][0..suffix.len], suffix);
if (r.tryParseTSConfigPath(buf[0 .. base_len + suffix.len])) |parent_config| {
return parent_config;
}
}
}
// 3. "<node_modules>/<extends>/tsconfig.json" in case the subpath
// names a directory inside the package. Skipped when the path
// already ends in ".json" since that is a file, not a directory.
{
const suffix = std.fs.path.sep_str ++ "tsconfig.json";
if (base_len + suffix.len <= buf.len) {
@memcpy(buf[base_len..][0..suffix.len], suffix);
if (r.tryParseTSConfigPath(buf[0 .. base_len + suffix.len])) |parent_config| {
return parent_config;
}
}
}
}
} else {
// "<node_modules>/<extends>/tsconfig.json" when extends is a bare
// package name (matches TypeScript's implicit tsconfig.json lookup).
const suffix = std.fs.path.sep_str ++ "tsconfig.json";
if (base_len + suffix.len <= buf.len) {
@memcpy(buf[base_len..][0..suffix.len], suffix);
if (r.tryParseTSConfigPath(buf[0 .. base_len + suffix.len])) |parent_config| {
return parent_config;
}
}
}
}
return null;
Comment thread
robobun marked this conversation as resolved.
}

fn tryParseTSConfigPath(r: *ThisResolver, candidate: string) ?*TSConfigJSON {
// parseTSConfig makes its own persistent copy of `candidate` on
// success; don't pre-allocate into dirname_store here or every
// failed probe leaves an orphaned entry in the append-only store.
return r.parseTSConfig(candidate, bun.invalid_fd) catch |err| {
switch (err) {
// Expected while probing: keep walking quietly.
error.ENOENT, error.FileNotFound, error.ENOTDIR, error.NotDir, error.IsDir, error.EISDIR => {},
// Unexpected (EACCES, EIO, etc.): surface in debug logs so a
// file that exists but can't be read doesn't vanish silently.
else => r.log.addDebugFmt(null, logger.Loc.Empty, r.allocator, "{s} loading tsconfig.json extends {f}", .{
@errorName(err),
bun.fmt.QuotedFormatter{ .text = candidate },
}) catch {},
}
return null;
};
}
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.

fn dirInfoUncached(
r: *ThisResolver,
info: *DirInfo,
Expand Down Expand Up @@ -4204,31 +4309,43 @@
var parent_configs = try bun.BoundedArray(*TSConfigJSON, 64).init(0);
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 {

Check failure on line 4314 in src/resolver/resolver.zig

View check run for this annotation

Claude / Claude Code Review

Wrong DirInfo starting point for package extends after relative extends hop

In the extends-chain while loop (resolver.zig:4313-4314), resolvePackagePathForTSConfigExtends always passes info — the DirInfo for the directory dirInfoUncached is processing — even after a relative extends hop moves current to a config outside the project directory. Concretely: packages/app/tsconfig.json extends ../../shared/tsconfig.base.json (relative), then shared/tsconfig.base.json extends @scope/config (package); on the second iteration info.abs_path is still packages/app/ while current.a
Comment thread
robobun marked this conversation as resolved.
r.log.addDebugFmt(null, logger.Loc.Empty, r.allocator, "ENOENT loading tsconfig.json extends {f}", .{
bun.fmt.QuotedFormatter{ .text = current.extends },
}) catch {};
break;
Comment thread
robobun marked this conversation as resolved.
}
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| {
Comment thread
robobun marked this conversation as resolved.
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;
Comment thread
robobun marked this conversation as resolved.
};
if (parent_config_maybe) |parent_config| {
try parent_configs.append(parent_config);
current = parent_config;
} else {
break;
}
try parent_configs.append(parent_config);
Comment thread
robobun marked this conversation as resolved.
current = parent_config;

Check notice on line 4334 in src/resolver/resolver.zig

View check run for this annotation

Claude / Claude Code Review

Circular tsconfig extends silently overflows BoundedArray with opaque error

The extends-chain collection loop (lines 4312–4334) has no cycle detection; a circular project-level extends chain (e.g. tsconfig.json → base.json → tsconfig.json) causes each config to be re-parsed into BoundedArray(*TSConfigJSON, 64) on every iteration until the 64th append returns error.Overflow, which propagates as a generic build failure with no user-facing diagnostic. This is a pre-existing issue predating the PR, but since the PR rewrites this exact loop to add package-path handling, addi
Comment thread
robobun marked this conversation as resolved.
}

Check notice on line 4335 in src/resolver/resolver.zig

View check run for this annotation

Claude / Claude Code Review

Circular tsconfig extends causes BoundedArray overflow with opaque error

The `while` loop collecting the tsconfig extends chain (resolver.zig:4312–4335) has no cycle detection; a circular project-level chain (e.g., A `extends` B `extends` A) causes `parseTSConfig` to allocate a fresh `*TSConfigJSON` on every iteration until the `BoundedArray(*TSConfigJSON, 64)` overflows after 64 appends, propagating `error.Overflow` through `dirInfoUncached`'s `anyerror\!void` return as an opaque build failure with no user-visible diagnostic. TypeScript emits `error TS18010: Circula
Comment thread
robobun marked this conversation as resolved.

var merged_config = parent_configs.pop().?;
// starting from the base config (end of the list)
// 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;
// Child overrides parent when explicitly set. These are
// popped child-after-parent, so assignment is the override.
if (parent_config.emit_decorator_metadata) |value| {
merged_config.emit_decorator_metadata = value;
}
if (parent_config.experimental_decorators) |value| {
merged_config.experimental_decorators = value;
}
if (parent_config.base_url.len > 0) {
merged_config.base_url = parent_config.base_url;
}
Expand Down
13 changes: 8 additions & 5 deletions src/resolver/tsconfig_json.zig
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ pub const TSConfigJSON = struct {

use_define_for_class_fields: ?bool = null,

preserve_imports_not_used_as_values: ?bool = false,

emit_decorator_metadata: bool = false,
experimental_decorators: bool = false,
// null means "not specified"; needed so the extends merge can tell a child
// config that explicitly sets `false` apart from one that leaves it unset.
preserve_imports_not_used_as_values: ?bool = null,
emit_decorator_metadata: ?bool = null,
Comment thread
robobun marked this conversation as resolved.
experimental_decorators: ?bool = null,
Comment thread
robobun marked this conversation as resolved.

pub fn hasBaseURL(tsconfig: *const TSConfigJSON) bool {
return tsconfig.base_url.len > 0;
Expand Down Expand Up @@ -249,7 +250,9 @@ pub const TSConfigJSON = struct {
.preserve, .err => {
result.preserve_imports_not_used_as_values = true;
},
.remove => {},
.remove => {
result.preserve_imports_not_used_as_values = false;
},
else => {
log.addRangeWarningFmt(source, source.rangeOfString(jsx_prop.loc), allocator, "Invalid value \"{s}\" for \"importsNotUsedAsValues\"", .{str}) catch {};
},
Expand Down
4 changes: 2 additions & 2 deletions src/transpiler.zig
Original file line number Diff line number Diff line change
Expand Up @@ -470,8 +470,8 @@ pub const Transpiler = struct {
if (transpiler.options.transform_options.jsx == null) {
transpiler.options.jsx = tsconfig.jsx;
}
transpiler.options.emit_decorator_metadata = tsconfig.emit_decorator_metadata;
transpiler.options.experimental_decorators = tsconfig.experimental_decorators;
transpiler.options.emit_decorator_metadata = tsconfig.emit_decorator_metadata orelse false;
transpiler.options.experimental_decorators = tsconfig.experimental_decorators orelse false;
}
}
}
Expand Down
Loading
Loading