Skip to content

fix(resolver): resolve tsconfig extends with package specifiers via node_modules - #27248

Closed
robobun wants to merge 9 commits into
mainfrom
claude/fix-tsconfig-extends-package-resolution
Closed

robobun wants to merge 9 commits into
mainfrom
claude/fix-tsconfig-extends-package-resolution

Conversation

@robobun

@robobun robobun commented Feb 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes tsconfig.json extends resolution for bare package specifiers so that settings like experimentalDecorators/emitDecoratorMetadata are correctly inherited from shared configs in node_modules.

Previously, Bun naively joined the extends value against the tsconfig's directory. For "extends": "@repo/typescript-config/tsconfig.json" this produced <project>/@repo/typescript-config/tsconfig.json instead of looking in node_modules, so the extended config was silently ignored. Since ce715b5 (standard decorators by default), this meant frameworks like TypeORM / MikroORM / NestJS would crash with undefined is not an object (evaluating 'target.constructor') because TC39 standard decorator semantics were applied instead of legacy semantics.

The resolver now detects package specifiers with isPackagePath() and walks up through node_modules (matching TypeScript's lookup), handling:

  • @scope/pkg/tsconfig.base.json — explicit subpath
  • @scope/pkg / pkg — bare package name → implicit <pkg>/tsconfig.json
  • @scope/pkg/base — extensionless subpath → <subpath>.json
  • @scope/pkg/configs — subpath directory → <subpath>/tsconfig.json

Also adds the missing experimentalDecorators merge in the extends chain (only emitDecoratorMetadata was merged before).

Test plan

test/regression/issue/06326.test.ts covers each extends form with a property decorator that reports whether it was called with legacy (target = prototype) or standard (target = undefined) semantics:

  • Scoped/unscoped package with explicit file path
  • Bare scoped/unscoped package name (implicit tsconfig.json)
  • Extensionless subpath
  • Subpath directory
  • node_modules in a parent directory (monorepo layout)
  • JSX factory inheritance (existing tests, unchanged behavior)

All tests fail with the released bun and pass with this build. Existing decorator/tsconfig bundler tests (es-decorators.test.ts, decorators.test.ts, decorator-metadata.test.ts, bundler_decorator_metadata.test.ts, esbuild/tsconfig.test.ts) pass unchanged.

Closes #6326

@coderabbitai

coderabbitai Bot commented Feb 20, 2026 •

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Resolve package-style tsconfig "extends" by probing parent node_modules and implicit tsconfig candidates; make decorator-related tsconfig fields tri-state (null = unset) and change merge semantics so parent-only values apply when explicitly present. Add regression tests for package extends, JSX factory inheritance, and decorator metadata/semantics.

Changes

Cohort / File(s) Summary
Resolver & TSConfig parsing
src/resolver/resolver.zig, src/resolver/tsconfig_json.zig
Added resolvePackagePathForTSConfigExtends and tryParseTSConfigPath to resolve package-style "extends" by walking parent directories, probing node_modules, and testing implicit candidates (e.g., .json, /tsconfig.json). Updated the "extends" resolution loop to use the new resolver and emit ENOENT-style debug when unresolved. Made preserve_imports_not_used_as_values, emit_decorator_metadata, and experimental_decorators nullable and changed merge semantics so parent decorator flags apply only when explicitly present.
Transpiler / JS API
src/bun.js/api/JSTranspiler.zig, src/transpiler.zig
Updated construction of transpiler/linker options to coalesce nullable tsconfig fields with orelse false, ensuring experimental_decorators and emit_decorator_metadata default to false when unset; adjusted conditional expressions accordingly.
Tests
test/regression/issue/06326.test.ts
Added regression test that creates synthetic projects and node_modules layouts (scoped/unscoped, explicit subpaths, implicit tsconfig.json, parent node_modules) to validate package-style tsconfig.extends resolution, JSX factory inheritance, decorator metadata emission, and override behavior when child tsconfig disables decorator flags.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the primary change: fixing tsconfig extends resolution for package specifiers via node_modules lookup.
Description check ✅ Passed The description covers the problem, solution approach, and test plan, though the template sections ('What does this PR do?' and 'How did you verify your code works?') are not explicitly labeled.
Linked Issues check ✅ Passed All code changes directly address issue #6326 requirements: tsconfig extends resolution for package specifiers, decorator metadata inheritance, and proper node_modules lookup matching TypeScript behavior.
Out of Scope Changes check ✅ Passed All changes are scoped to resolving tsconfig extends with package specifiers and related decorator flag handling; no unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented Feb 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:58 AM PT - Apr 18th, 2026

❌ @robobun, your commit 2177316 has 3 failures in Build #46179 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 27248

That installs a local version of the PR into your bun-27248 executable, so you can run:

bun-27248 --bun

@claude

claude Bot commented Feb 20, 2026

Copy link
Copy Markdown
Contributor

✅ d9e9e — Looks good!

Reviewed 2 files across src/resolver/ and test/regression/issue/: Fixes tsconfig.json extends resolution for bare package specifiers by detecting them with isPackagePath() and walking up parent directories to resolve through node_modules, and adds the missing experimentalDecorators merge in the tsconfig extends chain.

@SirBernardPhilip

Copy link
Copy Markdown

Hey! Resolving this would be quite helpful for our team, any plans to merge it?

@alii

alii commented Apr 17, 2026

Copy link
Copy Markdown
Member

@robobun investigate #6326 (comment)

@robobun

robobun commented Apr 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

✅ Reproduced & fixed.

The target.constructor TypeError happens because experimentalDecorators from an extended tsconfig in node_modules never reached the transpiler, so Bun fell back to TC39 standard decorator lowering (where field decorators receive undefined as the first arg).

Two things were wrong:

  1. The PR build users tested was 377 commits stale — it predated fix(transpiler): treat emitDecoratorMetadata as implying legacy decorators #27527 which landed in 1.3.11.
  2. The original fix only handled "extends": "@pkg/path/file.json"; bare package names ("@tsconfig/node20"), extensionless subpaths, and subpath directories still failed to resolve.

Rebased onto main and extended the resolver to probe <pkg>/tsconfig.json, <subpath>.json, and <subpath>/tsconfig.json to match TypeScript's lookup. Also switched the decorator flags on TSConfigJSON to ?bool so a child config can explicitly override an inherited true back to false. Added decorator-specific regression tests for each form — all fail on released bun, all pass on this build.

Pushed → #27248

@robobun
robobun force-pushed the claude/fix-tsconfig-extends-package-resolution branch from d9e9e36 to e9df616 Compare April 17, 2026 18:47

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/resolver/resolver.zig`:
- Around line 4289-4303: The package-extends resolution is using the original
info for every hop, causing package extends to be resolved from the wrong root;
update the call to resolvePackagePathForTSConfigExtends so it uses a DirInfo
derived from the current config's directory (the same directory used for
relative extends via Dirname.dirname(current.abs_path)), i.e. compute a DirInfo
for current.abs_path and pass that instead of the original info when calling
resolvePackagePathForTSConfigExtends(info, current.extends) inside the while
loop so each extends hop resolves from the directory of the config that declared
it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: beebca82-6280-4f0c-872f-dcaca18231ef

📥 Commits

Reviewing files that changed from the base of the PR and between bc7da9e and e9df61694ac9d4701c025bc3a68f0f185cd546f0.

📒 Files selected for processing (2)
  • src/resolver/resolver.zig
  • test/regression/issue/06326.test.ts

Comment thread src/resolver/resolver.zig
Comment thread src/resolver/resolver.zig Outdated
Comment thread src/resolver/resolver.zig
Comment thread src/resolver/resolver.zig Outdated
@coderabbitai

coderabbitai Bot commented Apr 17, 2026 •

Copy link
Copy Markdown
Contributor

✅ No merge conflicts detected when merging into main.

Your branch is good to go!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/resolver/resolver.zig`:
- Around line 4065-4067: tryParseTSConfigPath currently swallows all
parseTSConfig errors into null, allowing malformed/unreadable configs to be
treated like “not found”; change tryParseTSConfigPath so it only converts ENOENT
(file-not-found) into null and propagates any other errors returned by
parseTSConfig (i.e., return the error instead of null), and update
resolvePackagePathForTSConfigExtends callers to stop probing and
propagate/handle non-ENOENT errors from tryParseTSConfigPath rather than
continuing to the next candidate; use the existing symbols tryParseTSConfigPath,
parseTSConfig, and resolvePackagePathForTSConfigExtends to locate and apply this
change.

In `@test/regression/issue/06326.test.ts`:
- Around line 150-264: Add a new test in the "inherits experimentalDecorators
from extended config" suite that specifically verifies emitDecoratorMetadata is
inherited: create a test (e.g. "inherits emitDecoratorMetadata from extended
config") that uses the same tempDir setup as the other cases, ensures
reflect-metadata is imported (either in the test entry file or the existing
decoratorFixture), defines a class (e.g. Entity with property name) and asserts
Reflect.getMetadata("design:type", Entity.prototype, "name") is the expected
type (e.g. String) after running run(..., "index.ts"); reference symbols:
decoratorFixture, run, and Reflect.getMetadata to locate where to add the
assertion and import.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c302be4f-7495-4b15-bf63-0356e192b76f

📥 Commits

Reviewing files that changed from the base of the PR and between e9df61694ac9d4701c025bc3a68f0f185cd546f0 and b84f72db4caa3e2aa7332821c17de775df95886d.

📒 Files selected for processing (5)
  • src/bun.js/api/JSTranspiler.zig
  • src/resolver/resolver.zig
  • src/resolver/tsconfig_json.zig
  • src/transpiler.zig
  • test/regression/issue/06326.test.ts

Comment thread src/resolver/resolver.zig Outdated
Comment thread test/regression/issue/06326.test.ts
Comment thread src/resolver/tsconfig_json.zig Outdated
Comment thread src/resolver/resolver.zig

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/resolver/resolver.zig`:
- Around line 3985-4062: The resolver skips checking a package's package.json
"tsconfig" field for bare package extends; in
resolvePackagePathForTSConfigExtends add a probe in the bare-package branch
(where has_subpath is false) that reads "<node_modules>/<extends>/package.json",
parses the JSON, and if a "tsconfig" string exists build its resolved path (join
dir.abs_path + "node_modules" + extends + "/" + tsconfigValue, normalizing
relative paths and appending ".json" if needed) and call r.tryParseTSConfigPath
on that resolved path before falling back to the existing
"<node_modules>/<extends>/tsconfig.json" probe; use existing buf, base_len,
DirInfo.abs_path, tryParseTSConfigPath, has_json_ext, and ensure to add a
regression test exercising an extends that uses package.json "tsconfig".
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 27c4a277-44eb-42b5-9644-782c3e37586e

📥 Commits

Reviewing files that changed from the base of the PR and between b84f72db4caa3e2aa7332821c17de775df95886d and e8f2548b0b2ba086aae3b8095163d35202aee25f.

📒 Files selected for processing (3)
  • src/resolver/resolver.zig
  • src/resolver/tsconfig_json.zig
  • test/regression/issue/06326.test.ts

Comment thread src/resolver/resolver.zig
Comment thread src/resolver/tsconfig_json.zig

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
src/resolver/resolver.zig (2)

3985-4062: ⚠️ Potential issue | 🟠 Major

Probe package.json#tsconfig for bare package extends.

Bare package specifiers still only fall back to <pkg>/tsconfig.json. Packages that publish their base config through a "tsconfig" field in package.json will keep failing here even though TypeScript resolves them.

Does TypeScript resolve `tsconfig.json` `"extends": "@scope/pkg"` by reading the package's `package.json` `"tsconfig"` field before falling back to `<pkg>/tsconfig.json`?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/resolver/resolver.zig` around lines 3985 - 4062, The resolver currently
skips checking package.json for a "tsconfig" export when extends is a bare
package; update resolvePackagePathForTSConfigExtends to, for the bare-package
branch (when has_subpath is false), after constructing node_modules/<extends>
and before/alongside trying "<pkg>/tsconfig.json", load and parse
node_modules/<pkg>/package.json and, if it contains a "tsconfig" string, resolve
that value relative to the package root (using the same buf/base_len approach
and r.fs.absBuf buffer) and pass the resolved path to r.tryParseTSConfigPath;
ensure you try the literal value, the value+".json" if extension missing, and
value+"/tsconfig.json" if it points at a directory, and keep using
DirInfo.hasNodeModules and tryParseTSConfigPath to validate results.

4065-4080: ⚠️ Potential issue | 🟠 Major

Don't treat non-ENOENT tsconfig failures as "not found".

tryParseTSConfigPath() logs EACCES/EIO/parse failures but still returns null, so a broken nearer config can be skipped and replaced by a farther ancestor match. That changes an actual config error into different resolution behavior.

Suggested direction
-fn tryParseTSConfigPath(r: *ThisResolver, candidate: string) ?*TSConfigJSON {
+fn tryParseTSConfigPath(r: *ThisResolver, candidate: string) !?*TSConfigJSON {
     const persistent_path = r.fs.dirname_store.append(string, candidate) catch return null;
-    return r.parseTSConfig(persistent_path, 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 = persistent_path },
-            }) catch {},
-        }
-        return null;
-    };
+    return r.parseTSConfig(persistent_path, bun.invalid_fd) catch |err| switch (err) {
+        error.ENOENT, error.FileNotFound, error.ENOTDIR, error.NotDir, error.IsDir, error.EISDIR => null,
+        else => err,
+    };
 }

The callers in resolvePackagePathForTSConfigExtends() should then stop probing on catch instead of continuing to the next ancestor.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/resolver/resolver.zig` around lines 4065 - 4080, tryParseTSConfigPath
currently swallows unexpected errors (EACCES/EIO/parse failures) and returns
null, letting callers continue probing ancestors; change it to propagate
non-ENOENT/non-NotFound/non-IsDir errors instead of returning null so callers
can stop probing. Concretely: in fn tryParseTSConfigPath (the catch after
r.parseTSConfig), only swallow the expected errors (error.ENOENT,
error.FileNotFound, error.ENOTDIR, error.NotDir, error.IsDir, error.EISDIR) and
return null for those; for any other err, return that error (or change the
function signature to return an error union) so callers like
resolvePackagePathForTSConfigExtends can catch and stop probing on error rather
than skipping to the next ancestor.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/resolver/resolver.zig`:
- Around line 3985-4062: The resolver currently skips checking package.json for
a "tsconfig" export when extends is a bare package; update
resolvePackagePathForTSConfigExtends to, for the bare-package branch (when
has_subpath is false), after constructing node_modules/<extends> and
before/alongside trying "<pkg>/tsconfig.json", load and parse
node_modules/<pkg>/package.json and, if it contains a "tsconfig" string, resolve
that value relative to the package root (using the same buf/base_len approach
and r.fs.absBuf buffer) and pass the resolved path to r.tryParseTSConfigPath;
ensure you try the literal value, the value+".json" if extension missing, and
value+"/tsconfig.json" if it points at a directory, and keep using
DirInfo.hasNodeModules and tryParseTSConfigPath to validate results.
- Around line 4065-4080: tryParseTSConfigPath currently swallows unexpected
errors (EACCES/EIO/parse failures) and returns null, letting callers continue
probing ancestors; change it to propagate non-ENOENT/non-NotFound/non-IsDir
errors instead of returning null so callers can stop probing. Concretely: in fn
tryParseTSConfigPath (the catch after r.parseTSConfig), only swallow the
expected errors (error.ENOENT, error.FileNotFound, error.ENOTDIR, error.NotDir,
error.IsDir, error.EISDIR) and return null for those; for any other err, return
that error (or change the function signature to return an error union) so
callers like resolvePackagePathForTSConfigExtends can catch and stop probing on
error rather than skipping to the next ancestor.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 88d9429e-b1e5-4469-b367-3304ee73db9e

📥 Commits

Reviewing files that changed from the base of the PR and between e8f2548b0b2ba086aae3b8095163d35202aee25f and eb142540f37e192e5e6401dfb614dc91a5f0db8b.

📒 Files selected for processing (1)
  • src/resolver/resolver.zig

Comment thread src/resolver/resolver.zig
Comment thread src/resolver/resolver.zig
Comment thread src/resolver/resolver.zig
@robobun
robobun force-pushed the claude/fix-tsconfig-extends-package-resolution branch from b6cc47e to c110a8b Compare April 17, 2026 21:43
Comment thread src/resolver/resolver.zig Outdated
Comment thread src/resolver/tsconfig_json.zig
@robobun
robobun force-pushed the claude/fix-tsconfig-extends-package-resolution branch from c110a8b to 1297396 Compare April 17, 2026 22:51
Comment thread src/resolver/resolver.zig
@robobun
robobun force-pushed the claude/fix-tsconfig-extends-package-resolution branch from 1297396 to 2941f5e Compare April 18, 2026 01:09
Comment thread src/resolver/resolver.zig
Claude Bot and others added 5 commits April 18, 2026 01:47
…ode_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>
… extends

When 'extends' is a bare package name (e.g. '@tsconfig/node20'), probe
<pkg>/tsconfig.json. When the subpath has no .json extension
(e.g. 'expo/tsconfig.base'), probe <subpath>.json. When the subpath names
a directory, probe <subpath>/tsconfig.json. Matches TypeScript's lookup
behavior so experimentalDecorators/emitDecoratorMetadata inherited from
a shared config actually reach the transpiler.

Add decorator-specific regression tests covering each extends form.
…e parent

Switch TSConfigJSON.emit_decorator_metadata and .experimental_decorators
to ?bool so 'not specified' is distinguishable from 'explicitly false',
and change the extends merge to child-overrides-parent instead of OR.
Skip the '<subpath>/tsconfig.json' probe when the subpath already ends
in .json.
Keeps parity with the relative-extends branch so invalid specifiers like
'!!!bad!!!' still surface under logLevel=debug (fixes bun-run.test.ts).
Also default preserve_imports_not_used_as_values to null so the extends
merge treats 'unset' distinctly, and add a test that directly observes
design:type metadata emission through an inherited emitDecoratorMetadata.
ENOENT/ENOTDIR/EISDIR stay silent (expected while probing candidate
suffixes); anything else (EACCES, EIO, etc.) is surfaced as a debug log
with the specific path, so a file that exists but can't be read doesn't
silently fall through to a different node_modules ancestor.
robobun added 4 commits April 18, 2026 01:47
…'remove'

Now that the field defaults to null (unset) instead of false, the
'.remove' parser arm needs to assign false explicitly so a child config
with importsNotUsedAsValues:'remove' can override an inherited
'preserve'/'error'.
A bare package name (no subpath) always names a directory in
node_modules, so trying to parse it as a file is a guaranteed EISDIR.
Only run probe 1 when there is a subpath.
The extends value comes straight from tsconfig.json and can be
arbitrarily long; joining it into the fixed 4K path buffer with absBuf
could overflow. Use absBufChecked (same pattern as the node_modules
import-path lookup) and skip to the parent directory on overflow.
parseTSConfig already dupes the path into dirname_store on success,
so the pre-emptive append in tryParseTSConfigPath left an orphaned
copy for every failed probe (and a redundant one on success). Pass
the buffer-backed candidate directly; it stays valid for the
synchronous parseTSConfig call and the debug-log format.
@robobun
robobun force-pushed the claude/fix-tsconfig-extends-package-resolution branch from 2941f5e to 2177316 Compare April 18, 2026 02:07
Comment thread src/resolver/resolver.zig
Comment thread src/resolver/resolver.zig
Comment thread src/resolver/resolver.zig
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bun does not emit decorator metadata if tsconfig inherits the configuration from another file

3 participants