Skip to content

test --changed: follow bare specifiers that resolve to local files - #44836

Open
robobun wants to merge 2 commits into
robobun/91add4ef/test-changed-forget-scan-cachefrom
robobun/91add4ef/test-changed-select-broken-tests
Open

robobun wants to merge 2 commits into
robobun/91add4ef/test-changed-forget-scan-cachefrom
robobun/91add4ef/test-changed-select-broken-tests

Conversation

@robobun

@robobun robobun commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #44162

Problem

  • bun test --changed skips a test that imports a changed file through a bare specifier (#imports, workspace package, own name, tsconfig baseUrl). It prints --changed: 1 changed file, but no test files are affected and exits 0.
  • The scan sets packages = External (ChangedFilesFilter.rs:149), so the resolver (resolver.rs:942) makes each bare specifier external before it resolves it.

Fix

  • The scan resolves every import. A new resolver switch, installed_packages_are_external, stops the node_modules walk in load_node_modules at an entry that is a directory, or a link that stays under node_modules. The package is not opened.
  • The scan takes the run's target, main-field preference, bun:test aliases and env behaviour, so it follows the file that the run loads.
  • Verified: test/cli/test/test-changed.test.ts (10 new cases fail on the base), bundler_edgecase.test.ts -t External, resolve.test.ts, packagejson.test.ts.
  • Self-reviewed: the review asked for a rework, and this is the reworked shape. See Notes.

Background

  • bun test --changed scans the import graph of each test file and runs the tests that reach a file git reports as changed.
  • Considered a bundler flag that skips node_modules paths after it resolves (it reads every installed package), and a check before the lookup (it copied the lookup order). bun build --packages=external is unchanged.
  • Stacked on test --changed: forget what the module graph scan cached before the tests run #44835. Next PR: deleted files, preloads, snapshots, git failures. Maintainer's call: does a changed package.json, bun.lock, tsconfig, bunfig.toml or .env select every test?

Downsides

  • Scan cost per package on an isolated install: +5 syscalls, +1 allocation. 70 workspaces x 15 dependencies: 32.4 ms to 57.0 ms.
  • Runtime: +28 instructions per bare-specifier resolution. Binary: +1,536 bytes.
Notes

Measurements (Linux x64, release builds of #44835 at e7c347c and of this PR unless stated)

  • scan syscalls per installed package: hoisted +0, isolated +5 (the base 0; ptrace count at N=100: 144 to 148 hoisted, 144 to 648 isolated). Hoisted pays +4 once per node_modules directory (openat, 2 getdents64, close). Isolated pays newfstatat, openat, fstat, readlink and close per link.
  • scan allocations per installed package: hoisted +1, isolated +1 (gdb breakpoint count on the mimalloc entry points: 9,973 to 10,130 at N=1, 10,927 to 11,183 at N=100). The fixed part is +156 per scan.
  • files parsed under node_modules or the cache: 0 across 5 layouts (hoisted, isolated, bun install --backend=symlink, a # alias to an installed package, a baseUrl project). Local files newly parsed on the bun test --changed skips tests that import through package.json "imports" (#subpath) or a workspace package #44162 reproduction: +1 (packages/lib/src/lib.ts).
  • runtime resolution, default mode: +28 instructions per bare-specifier resolution. load_node_modules runs 304 of its own instructions against 287, resolve_without_remapping 58 against 47. resolve_and_auto_install runs 226 in both builds, and a relative import does not reach the changed functions (gdb nexti count from entry to return, warm third call, 3 runs each). valgrind is not installed here.
  • bun build --packages=external: 0 bytes differ across 1 output file, for a fixture with #internal, a baseUrl path, the package's own name, a linked workspace package and an npm package. The default bundle of the same fixture is identical too.
  • release binary: +1,536 bytes text (size, 88,978,875 to 88,980,411).
  • scan time, 70 workspaces x 15 deps isolated: 32.4 ms to 57.0 ms. 300 hoisted packages: 20.5 ms to 22.4 ms (median of 30 interleaved rounds, A-vs-A noise floor 0.9 ms and 1.1 ms).
  • reproduction grid: 11 of 16 rows still skip a broken test (main 16). The 5 bare-specifier rows and both reproductions of bun test --changed skips tests that import through package.json "imports" (#subpath) or a workspace package #44162 are this PR. The rest are the next PR, except the file: row.
  • git processes per run: 3, repository without commits 5, POSIX watch restart 0. All as on main.
  • strace, perf, valgrind and bloaty are not installed here. The syscall counts come from a ptrace counter for the bun process and its threads.

How the walk decides

  • load_node_modules looks in its usual order: tsconfig paths, baseUrl, package.json imports, the package's own name, then each node_modules directory upward. Only the last step has the new check.
  • A directory entry with the package name is installed. A link is installed when its real path holds a node_modules directory. A workspace link and a bun link link point outside, so the lookup continues and the scan enters the package.
  • When no entry has the name, what the lookup then finds in that node_modules is a file of node_modules itself (node_modules/single.js). It is external too.
  • An external match has no path. The caller names it with the specifier that it holds, so nothing is copied. A # alias to an installed package is the one case that copies the target.
  • Resolver::for_worker copies the switch, so the browser transpiler that the scan creates for an .html import has it.

Two fixes that come with the scan settings

  • The scan transpiler was initialized for target browser. It followed browser in package.json and the browser export condition. It now takes the run's target before Transpiler::init.
  • The scan inlined process.env.NODE_ENV, so a require() in a branch that is dead at scan time had no edge. It now takes the run's env behaviour, which does not inline.

The self-review

  • The first version decided before the lookup, in a function that repeated the order of load_node_modules. The review reproduced two wrong answers of that copy: a change to node_modules/single.js selected a test, and a tsconfig paths alias through a linked directory was not selected. The check now sits inside the walk. Both cases are test rows.
  • The first version kept the mode in opts.packages, which the bundler projects again for each derived transpiler. The browser transpiler of an .html import lost it and the scan parsed an installed package. The switch is now a resolver field. This case is a test row.
  • The cache change moved to test --changed: forget what the module graph scan cached before the tests run #44835.
  • One point differs from the first design plan: the scan does not take --preserve-symlinks from the run. With it, an import through a linked source directory stopped selecting under that flag, and it selects on main. git names a file by its real path. A test pins it.

Not covered

  • file: folder dependencies. bun install hardlinks them into node_modules, so the file that the run loads is under node_modules.
  • A package found only through NODE_PATH is resolved and scanned like a local file.
  • Windows: the tests create junctions. I could not run them on Windows.
  • The next PR: deleted and renamed files, preload files, snapshot files, and git exit errors that read as an empty change set.

Other open PRs on these lines


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/test/test-changed.test.ts

@robobun

robobun commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:52 PM PT - Oct 8th, 2026

❌ @autofix-ci[bot], your commit 376d6f6 has 1 failures in Build #124023 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 44836

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

bun-44836 --bun

@github-actions github-actions Bot added the claude label Oct 9, 2026
@robobun

robobun commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced with the script of #44162 and a real bun install, on a debug build of #44835. After a change to packages/lib/src/lib.ts, bun test --changed prints --changed: 1 changed file, but no test files are affected. With this change it runs workspace.test.ts (1 of 3 test files), and a change to src/own.ts runs 2 of 3. This PR is stacked on #44835.

Comment thread src/resolver/resolver.rs
Comment on lines +576 to +579
/// `bun test --changed`: a bare specifier that names a package installed
/// under `node_modules` is external, and the package is not read. A link
/// whose real path is outside `node_modules` (a workspace package) is not
/// an installed package.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/resolver/resolver.rs
Comment on lines +1008 to +1009
/// `out` for a package path that stays as it is written. The path is
/// empty: the caller of `load_node_modules` names it (`name_external`).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/resolver/resolver.rs
Comment on lines +1018 to +1019
/// An external match that `load_node_modules` left without a path is the
/// specifier as the caller holds it.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/resolver/resolver.rs
Comment on lines +1026 to +1029
/// `installed_packages_are_external`: the `<dir>/node_modules/<package_name>`
/// entry, read from the listing of the directory that holds it, so the
/// package is not opened. `None`: there is no such entry. `Some(false)`: a
/// link whose real path is outside `node_modules`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/resolver/resolver.rs
Comment on lines +2898 to +2899
// No `node_modules/<name>` entry: the probe found a file that
// lives in `node_modules` itself.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +5 to +8
//! 2. Run the bundler over every discovered test file. The scan resolves an
//! import as the test run does, and does not enter a package that is
//! installed under node_modules. This produces the full parse graph
//! (transitive imports) without linking or emitting code.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +141 to +143
// The scan must resolve an import to the file that the run loads, so it
// takes the run's target and the run's resolver settings. It does not take
// `preserve_symlinks`: git names a file by its real path.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +159 to +160
// The run reads `process.env` when the code runs. A scan that inlines it
// drops a `require()` in a branch that is dead now and live in a test.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +173 to +174
// A change to an installed package is not a local edit, so the scan does
// not enter one. Every other bare specifier names a local file.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

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

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/resolver/resolver.rs — Users who set NODE_PATH (common for globally installed CLIs such as typescript or eslint) will see bun test --changed parse the global package tree on every scan when a test imports a package not installed locally. With packages=External gone, the walk reaches the NODE_PATH loop at src/resolver/resolver.rs:2919-2941 and returns a real file; the installed-package check only covers the node_modules walk above it. Fix: treat a NODE_PATH hit as external when installed_packages_are_external is set (or when its resolved path contains the node_modules needle), matching the rule applied at line 2691. [also at: src/resolver/resolver.rs:2594 - Projects whose tsconfig paths or baseUrl map a bare specifier into node_modules will have bun test --changed parse that installed package and everything it imports on every scan.]

    Why this was flagged

    NODE_PATH=/usr/lib/node_modules is set in the environment (nvm, Homebrew and many CI images do this). A test file imports typescript which is not in the project's node_modules. The walk at src/resolver/resolver.rs:2675-2916 finds nothing, then the NODE_PATH loop at lines 2925-2941 joins /usr/lib/node_modules/typescript and load_as_file_or_directory succeeds with a file path and is_external false. BundleV2 enqueues and parses lib/typescript.js (about 9 MB) plus whatever it requires, on every bun test --changed run and on each --watch rerun; the base branch never got here because is_external_pattern (resolver.rs:957, 1261) returned external first. The scan also adds those files to module_graph_files (ChangedFilesFilter.rs:57), so --changed --watch registers watchers on the global tree. The author states NODE_PATH packages are scanned like local files; nothing in a global prefix is a local edit git can report, so the cost buys no selection. Remedy: return external_match for NODE_PATH hits when the flag is set.

    Verification: NODE_PATH is set and a test imports a bare specifier absent from enclosing node_modules but under a NODE_PATH directory. The installed_packages_are_external gate covers only the node_modules walk; the NODE_PATH loop at src/resolver/resolver.rs:2919-2950 returns MatchStatus::Success. On the base packages = External made them external, so NODE_PATH packages were never parsed.

Comment on lines +676 to +682

git(String(dir), "checkout", "-q", "--", ".");
appendFileSync(join(String(dir), "node_modules", "dep", "index.js"), "// touched\n");
appendFileSync(join(String(dir), store, "index.js"), "// touched\n");
appendFileSync(join(String(dir), "node_modules", "single.js"), "// touched\n");
{
const { stderr, exitCode } = await runTestChanged(String(dir));

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.

🔴 On Windows CI the new "an installed package is not followed" case fails: git reports 4 changed files, not the asserted "3 changed files". The fixture commits node_modules, and node_modules/linked is a junction created by linkDirectory (test/cli/test/test-changed.test.ts:69-72) before initRepo. Git for Windows treats a junction as a plain directory, so git add -A also tracks node_modules/linked/index.js, and the append to the store file shows up under both paths. Fix: keep the junction out of the index on Windows (add node_modules/linked to .gitignore, or create the link after the initial commit) while still committing node_modules/dep and node_modules/single.js so the "changed file inside node_modules" assertion stays meaningful.

Why this was flagged

The test at test/cli/test/test-changed.test.ts:627-685 writes node_modules/.bun/linked@ 1.0.0/node_modules/linked/{package.json,index.js}, calls linkDirectory(store, node_modules/linked) at line 662, then initRepo at line 663 which runs git add -A and commits. There is no .gitignore in this fixture, so node_modules is tracked on purpose. On POSIX git stores node_modules/linked as a symlink blob; on Windows symlinkSync(..., "junction") creates a junction, which Git for Windows lstat()s as a directory, so git recurses into it and tracks node_modules/linked/index.js and node_modules/linked/package.json as regular files. Line 674 appends to the store's index.js; git then reports it as modified under both node_modules/.bun/linked@ 1.0.0/node_modules/linked/index.js and node_modules/linked/index.js, plus node_modules/dep/index.js and node_modules/single.js, so bun prints "4 changed files, but no test files are affected" and expect(stderr).toContain("3 changed files, but no test files are affected") at line 681 fails. The describe block has no skipIf(isWindows). The base branch has no such test, so this is a new red Windows CI job, not a runtime defect.

Verification: linkDirectory (test/cli/test/test-changed.test.ts:69-72) does symlinkSync(target, link, "junction"). The case creates the link at line 659 and only afterwards calls initRepo at line 660. The output is "4 changed files, but no test files are affected", so expect(stderr).toContain("3 changed files, but no test files are affected") at line 684 fails on Windows.

Comment thread src/resolver/resolver.rs
Comment on lines +5169 to +5176
if status.is_success() && out.is_external && out.path_pair.primary.text.is_empty() {
let Ok(package_path) = self
.fs_ref()
.dirname_store
.append_slice(&esm_resolution.path)
else {
return MatchStatus::NotFound;
};

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.

🟡 nit (optional): an allocation failure here is reported as a module-not-found instead of an OOM, hiding the real cause. When load_node_modules returns an external match for a "#alias" that maps to an installed package, the copy of esm_resolution.path into dirname_store at resolver.rs:5170 maps Err to MatchStatus::NotFound. Fix: route the AllocError through bun_core::handle_oom (or unwrap_or_oom) like the sibling copy at resolver.rs:2157, so a failed allocation is a controlled crash rather than a silent NotFound.

Why this was flagged

Trigger: bun test --changed scans a test whose package.json imports map "#dep" to an installed package name, so load_package_imports at src/resolver/resolver.rs:5161 calls load_node_modules with installed_packages_are_external set and gets an external match with an empty path. At resolver.rs:5170 the code appends esm_resolution.path to dirname_store; the let-else at 5174-5176 turns an allocation error into MatchStatus::NotFound. The caller then treats the alias as unresolvable (the scan has ignore_module_resolution_errors, so the edge is silently dropped) instead of surfacing the OOM. The repository rule is to route allocation failure through bun_core::handle_oom; the neighbouring copy at resolver.rs:2157 uses .expect("unreachable") and the base branch has no such NotFound-on-OOM path. Only reachable under memory exhaustion, hence nit.

Verification: nit. In a bun test --changed scan, an imports alias maps to an installed package and malloc returns null. src/resolver/resolver.rs:5169-5176 maps the AllocError from dirname_store.append_slice to MatchStatus::NotFound; the scan sets ignore_module_resolution_errors = true (ChangedFilesFilter.rs:164), so a genuine OOM is masked as an unresolved import rather than a controlled crash.

Comment thread src/resolver/resolver.rs
Comment on lines +1032 to +1048
fn is_node_modules_entry_installed(&mut self, dir: &[u8], package_name: &[u8]) -> Option<bool> {
// `@scope/name` is the entry `name` of `node_modules/@scope`.
let (scope, base) = match strings::last_index_of_char(package_name, b'/') {
Some(slash) => (&package_name[..slash], &package_name[slash + 1..]),
None => (&b""[..], package_name),
};
let parts: [&[u8]; 3] = [dir, b"node_modules", scope];
let listing_path = self.fs_ref().abs_buf_checked(
&parts[..if scope.is_empty() { 2 } else { 3 }],
bufs!(node_modules_check),
)?;
let listing = self.dir_info_cached(listing_path).ok().flatten()?;
let lookup = listing.get_entry(self.generation, base)?;
// SAFETY: `rfs_ptr` points at the process-global RealFS; the lazy-stat
// rewrite inside `symlink()` is serialized on `Entry.mutex`.
let real_path = unsafe { lookup.entry().symlink(self.rfs_ptr(), self.store_fd) };
Some(real_path.is_empty() || strings::contains(real_path, bun_paths::NODE_MODULES_NEEDLE))

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.

🟣 pre-existing, not blocking: pre-existing gap the fix leaves open: a monorepo that links the whole scope directory (node_modules/@ org -> ../packages) still gets no test selected when a package behind it changes. is_node_modules_entry_installed applies the "link whose real path is outside node_modules" rule only to the final <name> entry (resolver.rs:1044-1048); the listing of node_modules/@ org is read through the link, so lib there is a plain directory, symlink() is empty and it is reported installed. Fix: when scope is non-empty, apply the same real-path test to the @ scope entry of the <dir>/node_modules listing (or to the listing directory itself) before deciding, so a scope link that leaves node_modules is followed like a package link.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Setup: a repo with packages/lib/index.ts and a link node_modules/@ org -> ../packages (no per-package links). a.test.ts imports @ org/lib; the user edits packages/lib/index.ts and runs bun test --changed. The scan resolver has installed_packages_are_external = true (ChangedFilesFilter.rs:175). In load_node_modules the first check (resolver.rs:2691-2698) calls is_node_modules_entry_installed(dir, "@ org/lib"). At resolver.rs:1034-1043 it splits into scope @ org / base lib, builds <dir>/node_modules/@ org and reads that directory's listing with dir_info_cached, which follows the link and lists packages. The lib entry there is a real directory, so symlink() at resolver.rs:1047 returns an empty slice and line 1048 yields Some(true); external_match is returned and the package is never entered. The test is not selected and --changed prints that no test files are affected. The base branch behaves the same (everything bare was external), so this is a leftover of the partial fix rather than a regression; the PR's stated rule is only evaluated on the leaf entry, never on the scope directory the leaf was found in.

Verification: pre-existing (base marks every bare specifier external). Trigger: a scope directory linked as a whole (node_modules/@ org -> ../packages) with a test importing @ org/lib and an edit to packages/lib/index.ts, run under bun test --changed. entry().symlink() (src/resolver/resolver.rs:1047) stays empty and line 1048 returns Some(true), so the scan never follows @ org/lib.

Comment thread src/resolver/resolver.rs
Comment on lines +2691 to +2698
if skips_installed_packages
&& self.is_node_modules_entry_installed(dir_info.abs_path, package_name)
== Some(true)
{
if let Some(d) = self.debug_logs.as_mut() {
d.decrease_indent();
}
return Self::external_match(out);

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.

🟣 pre-existing, not blocking: Monorepo users of bun test --changed get a test silently skipped when a nearer node_modules/<name> entry exists that the run itself cannot load from. The first check at src/resolver/resolver.rs:2691-2698 returns external_match as soon as is_node_modules_entry_installed finds any real directory named <name>, before load_as_file_or_directory has shown it resolves. The run (and the base walk) fails there and continues to the parent node_modules, where a workspace link is followed. Fix: only short-circuit when the entry resolves (run the probe, then classify), or treat an entry that fails to load as absent and keep walking; the same rule applies to the .is_none() check at line 2900. A stale or empty nested directory left by a previous install is enough to trigger it.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A workspace package packages/app has a leftover packages/app/node_modules/lib directory (no package.json, no index) from a previous registry install, while the current install links node_modules/lib -> ../packages/lib. A test in packages/app imports lib. At runtime the walk tries packages/app/node_modules/lib, cannot load it, moves to the parent and loads packages/lib through the link. In the scan, src/resolver/resolver.rs:2691-2698 calls is_node_modules_entry_installed first; the entry is a real directory so symlink() (src/resolver/fs.rs:269) returns empty, line 1048 returns Some(true), and external_match is returned without ever probing the directory. The base branch never had this check and load_as_file_or_directory at line 2891 failed and continued to the parent. Result: --changed: N changed files, but no test files are affected, exit 0, for an edit to packages/lib.

Verification: On the base the test was already skipped. Triggered when a nearer node_modules/<name> is a real directory the run cannot load from while the parent is a workspace link. src/resolver/resolver.rs:2691-2698 returns Self::external_match(out) on Some(true) before any load attempt. The run fails load_as_file_or_directory (2890) and continues to the parent, so the test in packages/app is silently skipped.

@robobun

robobun commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Note for the next PR of this series: #46608 changes how bun test --changed reads the file list from git, and it overlaps one item in the Notes here.

  • "git exit errors that read as an empty change set": test --changed: read NUL-terminated names from git for the whole work tree #46608 makes a failed git ls-files, and a failed git diff after git diff HEAD failed, an error with git's message. It has the truncated .git/index case as a test.
  • It adds -c diff.renames=false to every git call, so git diff lists the old path of a rename too. The deleted and renamed work can build on that.
  • runTestChanged has the same signature as in wip-stage2 (cwd, extra, flag, env).

Deleted files, preload files and snapshot files are not in #46608.

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