Skip to content

bundler: type-check the files text of an entry that a plugin loads or that only types use - #46320

Open
robobun wants to merge 2 commits into
mainfrom
robobun/57c572db/check-files-text-behind-plugin
Open

robobun wants to merge 2 commits into
mainfrom
robobun/57c572db/check-files-text-behind-plugin

Conversation

@robobun

@robobun robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A files entry that only types use (import type) is not in the bundle. Bun.build({ check: true }) reports a false TS2307 for it.
  • For a files entry that an onLoad plugin loads, the checker gets the plugin's output or the disk file. A files text with TS2322 passes.
  • Cause: BundleV2::type_check (src/bundler/bundle_v2.rs:2586) takes each text from the graph and never reads self.file_map.

Fix

  • type_check hands over each TS or JS files entry that the bundle does not load as code, if it is not on disk. Its key must be a plain absolute path outside node_modules.
  • For a plugin-loaded files key with such a name, it takes the files text and the name's loader.
  • Verified: test/bundler/bundler_files.test.ts (41 new tests, 20 fail on main). Also check.test.ts, entry-points.test.ts.
  • Self-reviewed: 65 concerns raised, 59 addressed (Notes).

Background

  • files maps paths to in-memory texts that override the disk (ParseTask.rs:1477).
  • type_check fills already_read, which the checker reads before the disk.
  • Considered: only the plugin clause (the false TS2307 stays), and a TypeChecked::files field (a second files-before-disk rule).

Downsides

  • Question for a maintainer: is the files text the source when an onLoad plugin loads the path? This PR says yes, so a plugin-filled placeholder fails (TS2305), as on disk.
  • 1,000 unloaded 1 KB entries cost 990 access() calls, 1,013,760 copied bytes, and 990 more loaded files under include. Binary: +2,560 bytes of text.
  • Not fixed: a files entry that is on disk too and that only types use. The check reads the disk (Notes).
Notes

How it was found. By a probe during the work on #45693. There is no user report and no issue.

The rule in this PR.

  • A file of the bundle that an onLoad plugin loads, and whose path is a key of files: if the name is TypeScript or JavaScript (Path::loader, which also reads the loader option), the check reads the text of files, with the loader of the name. The loader in the graph is the plugin's. For every other name (App.svelte, config.yaml, data.json, a name without an extension), the rule of main applies: the check reads what the plugin returns if the file is not on disk.
  • A key of files that no file of the bundle gives to the checker (the bundle does not load it, or loads it as text): the checker gets it if its name is TypeScript or JavaScript, its key is an absolute path with no ., .. or empty part, it is not in node_modules, and no file is on disk at the key.
  • Everything else is as on main.

Results that change. Each one has a test.

  • No plugin. A file that is only in files and that only types use: no TS2307 (import type, an import that only a type uses, export type, with { type: "text" } of a TypeScript file) and no TS2304 (an ambient .d.ts). Such a file is now a file of the program if include has it: an error in it is reported when a checked file imports it, and its ambient declarations apply.
  • onLoad for a TypeScript or JavaScript key of files: the files text is checked. Main checks the plugin's output (file not on disk) or the disk file (file on disk too). So:
    • A type error in the files text is reported.
    • An error in the plugin's output is not reported.
    • A placeholder in files that the plugin fills fails with the errors of the placeholder (TS2305 in the test). The same placeholder on disk fails on main too.

Not changed. These are as on main. The first five have a test.

  • A files entry that the bundle does not load and that is on disk too (an override of a file that only types use): the check reads the disk text. A rule in type_check is not safe there, for two reasons. Where names fold case (macOS, Windows), the resolver names a file by the directory as the importer spelled it. So the bundle can hold the disk text of that file under another spelling than the key, and the checker, which folds case, keeps one of the two texts. And a module that only a macro import reaches is not in the graph, but the macro runs the text on disk.
  • A key with a ., .. or empty part (/app/missing/../lib.ts, /app/lib.ts/). A relative import reaches a key only in joined form. access() on the other spellings can fail while the joined path is a file on disk.
  • A key in node_modules. The checker takes the real path of a file that it finds there through a package name. Under a linked package directory, a key that is only in files then fails with TS5083.
  • A files entry with a relative key that the bundle does not load: TS2307. What a relative key names is the subject of bundler: resolve relative Bun.build files keys against the cwd #38650.
  • A plugin-loaded key of files whose name is not TypeScript or JavaScript: the check reads what the plugin returns if the file is not on disk, and nothing of the plugin if it is.
  • JSON in files that the bundle does not load, which includes tsconfig.json and a file that it extends: the bundler's resolver reads configuration from disk, and so does the check. A file that tsconfig.json extends can have any .json name, so no rule by name separates it from a JSON module. The false TS2307 for a JSON module that only types use stays.
  • A page that is on disk and in files, and that a server module imports: the scripts of the page on disk are checked too.
  • A native onBeforeParse plugin: the check reads what the plugin returns, on disk and in files.
  • An entry point that onLoad returns with a loader that is not code is not checked.
  • For an entry point that a plugin loads, what the plugin's output imports is checked too, on disk and in files.
  • A key on a UNC path (//server/share/...) has an empty part, so the checker does not get it.

A difference that stays. The check finds a file that is only in files the way TypeScript finds a module: also without its extension, as a directory index, and as .ts for .js. The bundler finds a files entry only by its exact path. So a type-only import can pass the check where the same import of a value fails in the bundler.

The question, in full. onLoad does not get the files text. A plugin that answers for a path replaces the source before the bundler reads files, so the bundle never has the files text of that path. Main checks the source text of a file, not what a plugin returns for it, and it takes the source text from disk. This PR takes it from files first. That is worse than main in two cases: a placeholder in files that a plugin fills, and a plugin that reads the file on disk while files overrides it (the check reads files, the bundle has the disk text). If the answer is no, the plugin clause goes (the if is_of_plugin block of checked_source, with its tests), and the rest stays.

Measured. Linux x64. strace and perf are not installed. The tools are an LD_PRELOAD counter of access() and realpath(), an inotify IN_OPEN watcher, a temporary log line in check_for_build (not in this PR), and size.

  • Builds without check: type_check returns on its first line, and the load path has no hunk. access() calls that name the project in an unchecked build with 8 plugin-loaded files: 0 on main, 0 here.
  • access() calls in a checked build with 8 plugin-loaded files, 4 of them keys of files: 8 on main, 4 here. With no files: 8 and 8. With files that the bundle loads and no plugin: 0 and 0. bun build --check: 0 and 0. realpath() calls: no difference in any of these.
  • 1,000 keys of files of 1 KB each, none on disk, 10 of them imported: access() calls 0 on main, 990 here. The checker gets 11 texts (10,666 bytes) on main, and 1,001 texts (1,024,426 bytes) here. It loads 12 files on main. Here it loads 1,002 files if include has the 1,000, and 12 if not.
  • The same 1,000 keys with each file on disk too: access() calls 0 on main, 990 here. The checker gets the same texts as on main.
  • Wall clock, 21 interleaved runs of release builds, 1,000 files under include. Keys not on disk: median 67 and 60 ms on main (two series), 92 ms here. Keys on disk too: 141 and 132 ms on main, 149 ms here.
  • Opens of the disk file that a plugin-loaded key of files overrides: 1 on main, 0 here.
  • Binary, release builds of ea6a2c7 with and without this change: 107,095,081 to 107,097,641 bytes of text (+2,560). Data and bss are equal.
  • Windows, from the code and not measured: a key that is on disk, and that the bundle loads through the disk resolver (an import without the extension), costs one exists call. The path of that file in the graph has backslashes, and the lookup of the key compares bytes. The result does not change.
  • New tests: 41 tests. At 5566c61, the whole file took 335 ms (debian x64), 832 ms (windows x64), 1,023 ms (x64-asan) and 1,041 ms (darwin aarch64) in CI build 130049. In a debug build on this machine the new tests take about 16 s. The file passes under scripts/runner.node.ts with the debug build (67 pass, 2 skip, exit 0).
  • bun check, bun --check and bun test --check do not reach the changed code: check_command.rs and the checker host have no hunk.

Other pull requests.

Tests. The tests are in test/bundler/bundler_files.test.ts, the file of the files option. The other build tests of check are in test/cli/check/check.test.ts. That file does not pass in a debug build on this machine: on unchanged main, 220 of its 641 tests reach the 5 s limit. The new tests run Bun.build in the test process with noLib. Two tests run a build in a child process, because a relative key needs the project as the working directory. Two tests run only on a case-insensitive file system. I did not run them. They passed in CI at 5566c61 on macOS and Windows.

The new tests run one at a time. Each type check uses every core, so checks that run at once in one process slow each other down: on a release build here, 20 of these builds at once take about 5 s, and one alone takes 30 ms. With describe.concurrent, 30 to 36 of the tests reach the 5 s limit on that build.

Also run with this change: bun run rust:check-all x86_64-pc-windows-msvc aarch64-apple-darwin (2 ok), cargo clippy -p bun_bundler (no warning), and test/integration/bun-types/bun-types.test.ts (24 pass). On a release build of this change on ea6a2c7: bundler_files.test.ts (67 pass, 2 skip), entry-points.test.ts (473 pass), and check.test.ts with the same failures as the release build of ea6a2c7 without the change (those tests are newer than that base).

Self-review. Two reviews, 65 concerns, 59 addressed.

The first revision also gave the checker a page of files, plain .json entries, and a rule for the spelling of a key. The first review found that the page rule turned three green builds red (TS6053), that a .json entry that tsconfig.json extends changed the options of the check, and that the key rule contradicted #38650. All three are removed. It also found a test that leaked under LeakSanitizer (a native plugin), which is removed with the native case. Not taken: one classification of a graph row that entry_points uses too (that is the change of #45693), a stack on #38650, and a disk-as-oracle test helper.

The second review read the answer to the code review of 5566c61. That answer gave the checker an unloaded key that is on disk too, if the key was the real path of the file. The review showed by experiment the two cases under "Not changed" where the check then reads a text that the bundle did not use, so that rule is removed. It also found the keys with a .. part, the keys in a linked package, and the names like config.yaml that the plugin clause took from files although the checker cannot read them. Each has a condition and a test now. Not taken:

  • A return from type_check before it reads files, for a build in which no entry point has types. Such a build with check is rare, and the pass costs about 1 ms for 1,000 keys.
  • A lookup that also finds a graph path with backslashes (the Windows line under "Measured"). It changes no result.
  • The difference under "A difference that stays". It needs the bundler to find a files entry the way it finds a file on disk.

[human-review] gate passed · iteration 0 · 5 files touched

fails on main (without fix)
ASAN without fix: 20 failed, 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_files.test.ts
bun test v1.4.4 (367d939d9)

test/bundler/bundler_files.test.ts:
(pass) bundler files option > basic in-memory file bundling [164.96ms]
(pass) bundler files option > in-memory file with imports [77.09ms]
(pass) bundler files option > in-memory file with relative imports (same directory) [82.79ms]
(pass) bundler files option > in-memory file with relative imports (subdirectory) [82.96ms]
(pass) bundler files option > in-memory file with relative imports (parent directory) [65.22ms]
(pass) bundler files option > in-memory file with relative imports between multiple files [125.23ms]
(pass) bundler files option > in-memory file with nested imports [166.57ms]
(pass) bundler files option > in-memory file with TypeScript [71.49ms]
(pass) bundler files option > in-memory file with JSX [448.88ms]
(pass) bundler files option > in-memory file with Blob content [159.25ms]
(pass) bundler files option > in-memory file with a file-backed Blob is rejected [29.96ms]
(pass) bundler files option > in-memory file with Uin
... (truncated)

release without fix: 38 failed, 2 skipped
bun test v1.4.3-canary.1 (367d939d9)

test/bundler/bundler_files.test.ts:
(pass) bundler files option > basic in-memory file bundling [181.05ms]
(pass) bundler files option > in-memory file with imports [5.43ms]
(pass) bundler files option > in-memory file with relative imports (same directory) [4.63ms]
(pass) bundler files option > in-memory file with relative imports (subdirectory) [2.84ms]
(pass) bundler files option > in-memory file with relative imports (parent directory) [4.23ms]
(pass) bundler files option > in-memory file with relative imports between multiple files [3.58ms]
(pass) bundler files option > in-memory file with nested imports [3.95ms]
(pass) bundler files option > in-memory file with TypeScript [2.51ms]
(pass) bundler files option > in-memory file with JSX [19.64ms]
(pass) bundler files option > in-memory file with Blob content [4.51ms]
(pass) bundler files option > in-memory file with a file-backed Blob is rejected [1.07ms]
(pass) bundler files option > in-memory file with Uint8Array content [30.20ms]
(pass) bundler files option > in-memory file with ArrayBuffer content [22.69ms]
(pass) bundler files option > in-memory file with re-exports [7.6
... (truncated)
passes on PR (with fix)
ASAN with fix: 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_files.test.ts
rustup spent 9s installing the pinned toolchain (nightly-2026-09-15); it was missing or incomplete on this machine
bun test v1.4.4 (367d939d9)

test/bundler/bundler_files.test.ts:
(pass) bundler files option > basic in-memory file bundling [112.58ms]
(pass) bundler files option > in-memory file with imports [77.93ms]
(pass) bundler files option > in-memory file with relative imports (same directory) [91.76ms]
(pass) bundler files option > in-memory file with relative imports (subdirectory) [78.97ms]
(pass) bundler files option > in-memory file with relative imports (parent directory) [46.48ms]
(pass) bundler files option > in-memory file with relative imports between multiple files [70.17ms]
(pass) bundler files option > in-memory file with nested imports [86.48ms]
(pass) bundler files option > in-memory file with TypeScript [60.55ms]
(pass) bundler files option > in-memory file with JSX [481.46ms]
(pass) bundler files option > in-memory file with Blob content [78.21ms]
(pass) bundler files option > in
... (truncated)

release with fix: 2 skipped
$ bun scripts/build.ts --profile=release
rustup spent 12s installing the pinned toolchain (nightly-2026-09-15); it was missing or incomplete on this machine
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     191fd684c8
  features     lto, baseline

23 deps, 213 codegen, 1211 objects in 14888ms

ninja: Entering directory `/workspace/bun/build/release'
[1/4] fetch lolhtml
[lolhtml] up to date
[2/4] fetch rust-argon2
[rust-argon2] up to date
[2/4] cargo plan → /workspace/bun/build/release/rust-target/plan.json
268 units: 194 lib, 16 proc-macro (host), 20 custom-build (host), 16 run custom-build, 17 lib (host), 4 run custom-build (host), 1 rlib
[3/4] reconfigure
[1/1568] mkdir codegen
[2/1568] mkdir stamps
[3/1568] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[4/1568] install /workspace/bun
bun install v1.4.3-canary.1 (367d939d9)

Checked 22 installs across 44 packages (no changes) [6.15s]
[5/1568] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (367d939d9)

Checked 1 install across 2 packages (no changes) [1250.00ms]
[
... (truncated)
diff hotspot
docs/runtime/check.mdx             |   2 +
 packages/bun-types/bun.d.ts        |   5 +
 src/bundler/bundle_v2.rs           |  51 ++++-
 src/bundler/options.rs             |   3 +-
 test/bundler/bundler_files.test.ts | 409 +++++++++++++++++++++++++++++++++++++
 5 files changed, 460 insertions(+), 10 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                reads  edits  tests
docs/runtime/check.mdx                  1      2    116
packages/bun-types/bun.d.ts             1      2    116
src/bundler/bundle_v2.rs                9      6    116
src/bundler/options.rs                  2      1    116
test/bundler/bundler_files.test.ts      6      6    116

…ugin loads or that only types use

The type checker got the text of a `files` entry from the bundle graph.
For an entry that an onLoad plugin loads, that is the plugin's output,
or the file on disk that the entry overrides. An entry that the bundle
does not load, such as the target of `import type`, was missing.

`BundleV2::type_check` now takes the text of a plugin-loaded entry from
the file map, with the loader of its name. It also hands over the
entries that the bundle does not load and that are not on disk.
@robobun
robobun requested a review from alii as a code owner October 21, 2026 09:50
@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:01 PM PT - Oct 21st, 2026

✅ @robobun, your commit 191fd684c8c1ab91e4cb758074394c0b3930da46 passed in Build #130281! 🎉


🧪   To try this PR locally:

bunx bun-pr 46320

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

bun-46320 --bun

@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main with a debug build. The layout:

  • main.ts imports ./lib.ts.
  • lib.ts is only in files. Its text there has a type error (TS2322).
  • An onLoad plugin loads lib.ts and returns a text with no error.

Bun.build({ entrypoints: ["main.ts"], check: true, files, plugins }) passes. Without the plugin, the same build fails with TS2322.

A second layout needs no plugin: main.ts has import type { value } from "./lib.ts", and lib.ts is only in files. The build fails with a false TS2307.

test/bundler/bundler_files.test.ts has both layouts. On main, 20 of the 41 new tests fail. With this change, all pass in a debug build (2 run only on a case-insensitive file system).

PR: #46320

@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: e96ae76b-a80e-4c74-90f1-923d0a603ab3
📥 Commits

Reviewing files that changed from the base of the PR and between 5566c61 and 191fd68.

📒 Files selected for processing: 5
  • docs/runtime/check.mdx
  • packages/bun-types/bun.d.ts
  • src/bundler/bundle_v2.rs
  • src/bundler/options.rs
  • test/bundler/bundler_files.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Walkthrough

Type checking now selects source contents from the graph, file_map, or disk. Plugin-loaded files use mapped or on-disk contents when available and otherwise use plugin-returned contents. Documentation and tests cover this behavior.

Changes

Build type checking

Layer / File(s) Summary
Source selection and documented behavior
src/bundler/bundle_v2.rs, src/bundler/options.rs, packages/bun-types/bun.d.ts, docs/runtime/check.mdx
Type checking now considers graph and file_map contents. Plugin-loaded files use mapped contents, disk contents, or plugin-returned contents according to source availability. Documentation describes the precedence and TypeChecked.sources behavior.
Source precedence and plugin import tests
test/bundler/bundler_files.test.ts
Tests compare type-checking results for disk, files, and plugin sources. They cover loaders, resolution, entry points, placeholder files, plugin-loaded imports, and imported type errors.
Additional file and component tests
test/bundler/bundler_files.test.ts
Tests cover type-only imports, ambient declarations, configuration files, case-insensitive paths, and plugin-compiled Svelte, extensionless, YAML, and JSON files.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 191fd

No actionable issue remains; the change is mergeable after normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title is concise, specific, and accurately summarizes the primary change: type-checking files text for plugin-loaded entries and type-only dependencies.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The description is complete and clearly explains the problem, fix, scope, known limitations, and verification results. It does not use the template headings “What does this PR do?” and “How did you ve…
Full details: Description check

Explanation

The description is complete and clearly explains the problem, fix, scope, known limitations, and verification results. It does not use the template headings “What does this PR do?” and “How did you verify your code works?”, but it provides equivalent information under “Problem”, “Fix”, and detailed test sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

The review left one case unverified: a type-only import through a relative files key. I ran it.

  • Layout: the project is the working directory, main.ts has import type { value } from "./lib.ts", files is { "./lib.ts": ... }, and lib.ts is not on disk.
  • Result: check: true reports TS2307 on main and with this change. The build passes without check.

This PR does not change that case. The Notes list it under "Not changed", and #38650 is about what a relative key names.

@claude claude 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.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread test/bundler/bundler_files.test.ts Outdated
Comment thread test/bundler/bundler_files.test.ts
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs Outdated
…hat isn't TypeScript or JavaScript by name

A plugin-loaded key of `files` like `virtual`, `App.svelte` or `config.yaml` has no source text that the type checker can read. The check takes the plugin's output for it, as for a file that is in neither place.

A key of `files` that the bundle does not load is not given to the type checker if it has a `.`, `..` or empty part, or if it is in `node_modules`. For such a key, the bundler and the type checker can name different files.

@claude claude 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.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
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.

1 participant