Skip to content

bun check: resolve a page's scripts with the bundler's resolver - #46223

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/01ecf66e/check-page-scripts-resolve
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/01ecf66e/check-page-scripts-resolve

Conversation

@robobun

@robobun robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun check <file>, bun --check and bun test --check join a page's src text onto its folder (imports_of_page, src/runtime/cli/check_command.rs:814).
  • ./main for main.ts is not checked. ./main.js is a false TS6053. bun build --check is right.

Fix

  • The check asks the bundler's resolver (browser target, the run's --tsconfig-override). A name that does not resolve is named as before. For a bundle's page, only the graph names scripts (TypeChecked::pages).
  • A process that goes on after the check forgets what the check's resolver cached (DirectoryCacheScope, on test --changed: forget what the module graph scan cached before the tests run #44835's map marks).
  • Verified: test/cli/check/check.test.ts (46 new tests, 15 fail on main), entry-points.test.ts.
  • Self-reviewed: 102 concerns raised in three rounds, 7 still open (Notes).

Background

  • The HTML scanner makes an import record per <script src>. The type checker gets an imported page's scripts from the hook scripts_of_page.
  • All resolvers share one directory cache. An entry keeps its first reader's set-up (a package.json's main fields).
  • Weighed: the type checker's own resolver needs no cache care, but takes both.ts where the bundle holds both.tsx.

Downsides

  • Cost: one resolver per page (184 allocations), 40 more file system calls for a check's first page at 9 folders deep, binary +12 KB.
  • v1.4.3 results change: ./main is now checked. A page's script in node_modules, or one a build leaves external, is not.
  • Under bun test --check and --hot, three cache cases still differ from a run without --check (Notes, "The cache").
Notes

A question for a maintainer. The check's resolver shares the directory cache of the process. See "The cache" below: the forgetting is exact when the check runs before the program's resolver exists. It leaves three cases open when something read the cache first. An owned cache for the check's resolver would close them. That is a larger change in bun_resolver.

Open after the self-review (7).

  1. to 3. The three cache cases under "The cache".
  2. Which import is a page is still decided by name (first limit below).
  3. One set-up of the resolver for every command (second limit below).
  4. The map marks are in two pull requests, this one and test --changed: forget what the module graph scan cached before the tests run #44835. They need to land once.
  5. Tests that are not written: --watch, the scope of bun --check page.html, a page that a plugin provides.

Not taken: the type checker's own resolver for the scripts (the ask is the bundler's resolution).

Why this change. A maintainer asked for it in chat, with a script that runs one page through each command. No user reported it: the tracker has no issue about a page in bun check. bun check is in v1.4.3 (#44361), so each changed result below changes released behaviour.

What each command does now. The commands that ask the resolver: bun check <file>, bun --check <file>, bun --check page.html, bun test --check, bun build --no-bundle --check, a bun build --check whose page is not in the bundle, and a check of the whole project when no tsconfig.json is found (it then names every file below the working directory). Plain bun check in a project with a tsconfig.json does not ask about pages and does not change.

the page writes v1.4.3 and main now
./main for main.ts, ./app for app/index.ts not checked, exit 0 the file is checked
./main.js for main.ts TS6053: File '<dir>/main.js' not found. main.ts is checked
./both with both.ts and both.tsx not checked both.tsx, the order of the bundler
@/aliased with paths in tsconfig.json or in --tsconfig-override not checked src/aliased.ts is checked
#client with imports in package.json, keyed on browser not checked the browser file
./server-only.ts that the browser field of package.json maps to another file, or to false server-only.ts is checked the other file is checked, or none
./node_modules/dep/index.ts checked not checked, as in a build
./client.script under --loader .script:ts, through a page that a file imports not checked checked, as through bun --check page.html
./missing.ts or missing.ts, no such file TS6053 TS6053
./missing, ./folder, a package that is not installed, app://x/main.js not checked not checked
a data: URL or a relative value that is longer than a path buffer v1.4.3: panic: range end index 4133 out of range for slice of length 4095. main since #46021: no panic it names no file
./main in a template that a file imports with { type: "text" } not checked main.ts is checked: see the limits

bun build --check --target=bun server.ts: for a page of the bundle, the hook no longer names a second file beside the graph.

the script v1.4.3 and main now
a plugin's onResolve maps ./generated.ts to src/real.ts TS6053 for generated.ts src/real.ts is checked, no TS6053
a plugin provides ./virtual.ts under a namespace, and a file of that name is on the disk the file on the disk is checked not checked
--external ./main.ts, or a plugin answers external: true, and the file has a type error TS2322, exit 1 not checked, exit 0
the same, no such file TS6053, exit 1 exit 0

The resolver. bun_bundler::transpiler::resolver_with_options makes the resolver that Transpiler::init makes, without the transpiler. It loads no environment and sets up no parser store, because the hook can run on a thread of the type checker. The set-up is the browser side of bun build --target=bun: browser main fields and conditions, the tsconfig override of the run, no --conditions, no development condition.

The cache. Every resolver of a process reads one cache of directories (DirInfo::hash_map_instance(), and the listings of RealFS). What an entry keeps depends on its first reader: the main fields of a package.json, the real path of a link (not with --preserve-symlinks), the warnings (logged once), the listing, a directory that was not there. In every process that goes on after the check (Provided::ends_the_process is false: bun --check, bun test --check, a script, Bun.build), the first resolver of the check enters a DirectoryCacheScope. The scope is dropped when the check ends, once, and forgets every directory that was read since it was entered. BSSMapInner::mark and forget_since below it are the hunk of open #44835, which needs the same for bun test --changed. A first version forgot once per page: 800 imported pages then took 506 MB under bun --check. Now 40 MB, as on main.

Eleven tests run a program with and without --check and compare the output: --main-fields, --preserve-symlinks, NODE_PRESERVE_SYMLINKS, a package.json warning, a package and a file that the program writes before it imports them, an auto-install range, bun test, bun run --sequential and --parallel without a tsconfig.json, and an import after Bun.build({ check: true }). With the drop of the scope made a no-op in a copy of the release binary, all eleven fail.

Three cases still differ from a run without --check, where something read the cache before the check (bun test --check, --hot, --watch). The check's resolver then writes into entries of listings that are older than its scope, and the scope cannot reach them.

  • A link that the program points elsewhere before it imports through it, when a page names a file through that link: the program gets the old target.
  • bun test --preserve-symlinks --check without a path, when a test imports the module that a page names through a linked folder: the real path.
  • A page that names ./DATA beside data.json: error: ENOENT reading ".../DATA.json" when the program imports ./data.json.
    An owned cache for the check's resolver would close the class. The two maps are process-wide statics today.

Measured. Release builds of main b1d4686174 and of that commit plus this diff, on one machine. strace, perf and bloaty are not in the container. A ptrace syscall counter and a perf_event_open counter of user instructions stand in.

  • A check that reaches no page: the file system calls are equal at the median of 7 runs, for bun check main.ts and bun --check main.ts. Only newfstatat moves, between 13 and 20 in both builds. Instructions: 29,169,145 and 29,510,836 at the median of 20 interleaved runs, inside the spread of 28.7M to 31.3M.
  • First page of a check, 9 folders deep: +10 openat, +18 getdents64, +10 close, +2 read, for bun check server.ts (136 to 178) and for bun --check server.ts (211 to 245). Instructions of bun check server.ts: 29,659,247 and 29,862,528, inside the spread.
  • Five pages in one folder, 5 folders deep: +27 file system calls in total for bun check, +24 for bun --check.
  • 800 pages that one file imports: bun --check 40 MB in both builds. bun check 33 MB and 35 MB.
  • Resolvers: one per distinct page asked outside a bundle, 0 for a page of a bundle. 184 allocator calls each. The whole answer for a page: 709 allocator calls (gdb counter, ASAN debug build).
  • 20 modules import one page: index.html is opened 1 time (main: 20), and the check makes 57 fewer file system calls (317 to 260).
  • Bun.build({ check: true, target: "bun" }): index.html is opened 1 time per build (main: 2 for 1 importer, 11 for 10 importers).
  • 100,000 Bun.resolveSync("./x"): 7,237 and 7,239 instructions per call at the median of 30 interleaved runs. The diff adds no code on that path.
  • Leaks (LSan, ASAN debug build): bun check server.ts in a project with references, 10 runs, no report. bun --check server.ts there, 10 runs: the report of bun server.ts (678 bytes in 5 allocations), nothing more. A forgotten directory keeps its slot, as after bust_dir_cache.
  • Binary: text 107,417,579 to 107,429,355 bytes, +11,776 (size).
  • New public items: bun_bundler::transpiler::resolver_with_options, the field TypeChecked::pages, Resolver::directory_cache_scope and DirectoryCacheScope, and from test --changed: forget what the module graph scan cached before the tests run #44835 BSSMapInner::mark, BSSMapInner::forget_since, BSSMapMark. No host function, no codegen entry.
  • bun run rust:check-all x86_64-pc-windows-msvc aarch64-apple-darwin: 2 ok.

Limits that stay.

  • Which import is a page is decided by name: a relative specifier that ends in .html (src/sema/driver/lib.rs). A page that a file imports by a paths alias, by #imports or by a workspace name is not followed. A page behind a link is read from the folder of the link. An .html file that is imported as text is read as a page: for ./main in such a template, the check now reports the errors of main.ts, as it did before for ./main.ts.
  • One set-up for every command. bun build page.html without --production and the dev server add the development condition. The check does not. A <script src> that needs a condition can name another file there.
  • What a script of a page imports is resolved by the type checker, as TypeScript does (both.ts before both.tsx, no browser field).
  • A <script src> by a bare name or an alias that does not resolve is skipped. A build says Could not resolve.
  • A script behind a link is named by its real path. The tsconfig.json nearest to the real file applies. If a checked file also imports it by the name of the link, it is checked under both names.
  • A page of a bundle is matched by the path that the bundler resolved. A page behind a link misses that, and the check's resolver answers for it.
  • There is no test under --watch, and none for the scope of bun --check page.html.

Open pull requests on the same lines. #44835 has the map marks. #45060 rewrites the head of pages_of. #45693 adds a field to TypeChecked. #46021 and #46001 are on main, and this branch is rebased on them: the path bounds of #46021 end the aborts that an earlier version of this diff had on a src of 4,092 to 4,095 joined bytes and on a 4 KB tsconfig.json value.

@robobun

robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:05 AM PT - Oct 22nd, 2026

❌ @robobun, your commit b06bc9e has some failures in Build #130775 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 46223

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

bun-46223 --bun

@robobun

robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduction on main (release build of b1d4686174):

  1. index.html has <script type="module" src="./main"></script>. main.ts has export const c: number = "1";. server.ts has import page from "./index.html";.
  2. bun check server.ts prints No type errors in 1 file and exits 0. bun build --check index.html reports TS2322 in main.ts.
  3. With src="./main.js", bun check server.ts prints TS6053: File '<dir>/main.js' not found.

With this branch, steps 2 and 3 report TS2322 in main.ts, for bun check server.ts, bun --check server.ts, bun --check index.html and bun test --check.

The branch is rebased on main, which now has #46021 and #46001. The Notes of the description list what is open.

`bun check <file>`, `bun --check` and `bun test --check` named the scripts
of an HTML page by a join of each `src` text onto the folder of the page.
A script that the bundler resolves (`./main` for main.ts, a folder, a
tsconfig path) was not checked, and `./main.js` for main.ts was reported
as not found.

The check now asks the bundler's resolver, for the browser, with the
tsconfig override of the run. A name that does not resolve is named as
before. `bun build --check` passes the pages of its bundle, and only its
graph names the scripts of those.

Every resolver of a process reads one cache of directories, and an entry
keeps what its first reader made of it. So a process that goes on after
the check forgets what the check's resolvers put there
(`DirectoryCacheScope`): the program reads those directories as it does
without `--check`.

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.

2 participants