Skip to content

bundler: make metafile import paths deterministic and match input keys - #34534

Merged
dylan-conway merged 4 commits into
mainfrom
farm/a19d1ff5/metafile-import-path-determinism
Aug 5, 2026
Merged

dylan-conway merged 4 commits into
mainfrom
farm/a19d1ff5/metafile-import-path-determinism

Conversation

@robobun

@robobun robobun commented Jul 18, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

// src/b/shared.js
export const s = 1;
// src/mid{0..29}.js  (each: import { s } from "./b/shared.js"; export const m = s;)
// src/entry.js       (export * from each mid)
$ bun build src/entry.js --outdir=o --metafile=m.json
$ grep -o '"path": "[^"]*shared[^"]*"' m.json | sort | uniq -c
     29 "path": "./b/shared.js"
      1 "path": "/tmp/nd/src/b/shared.js"

Whichever mid*.js reaches the resolver first gets the absolute path; every other importer keeps the raw specifier. Which one wins varies with parse-task completion order, so two consecutive builds produce different metafiles. Neither form matches the string used as the key in "inputs" (src/b/shared.js), so metafile.inputs[importPath] never resolves and the --metafile-md reverse-dependency map misses most edges (it shows 1 importer of shared.js instead of 30).

Cause

resolve_import_records has a dedup path that, on a path_to_source_index_map hit, sets record.source_index and continues without rewriting record.path. The "first" importer falls through to the fresh-resolve branch and does get its path rewritten; every subsequent one keeps the original specifier. The metafile emitter then writes record.path.text unconditionally.

Fix

For bundled imports (record.source_index valid), emit sources[source_index].path.pretty, which is exactly the string used as the "inputs" key. Externals and chunk references (source_index invalid) keep record.path.text.

After:

$ grep -o '"path": "[^"]*shared[^"]*"' m.json | sort | uniq -c
     30 "path": "src/b/shared.js"

This matches esbuild's metafile format, is stable across runs, and lets the markdown report's reverse-dependency lookup hit the exact-match path for every edge.

Tests

Two new cases in test/bundler/metafile.test.ts:

  • 30 files import the same module; every import path must equal the "inputs" key and carry "original": "./b/shared.js".
  • 5 consecutive builds of that graph produce identical import-path sets.

Both fail on current main and pass with this change. All 43 existing metafile tests and the 12 esbuild-compat metafile tests pass.


[review] gate passed · iteration 2 · 2 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/metafile.test.ts
bun test v1.4.0 (4960cf3cc)

test/bundler/metafile.test.ts:
(pass) bundler metafile > metafile option returns metafile object [124.77ms]
(pass) bundler metafile > metafile inputs contain file metadata [91.33ms]
(pass) bundler metafile > metafile outputs contain chunk metadata [52.21ms]
(pass) bundler metafile > metafile tracks import relationships [49.26ms]
(pass) bundler metafile > metafile imports have resolved path and original specifier [58.55ms]
(pass) bundler metafile > metafile without option returns undefined [44.39ms]
(pass) bundler metafile > metafile tracks exports [46.90ms]
(pass) bundler metafile > metafile includes entryPoint for entry chunks [47.26ms]
(pass) bundler metafile > metafile includes format for JS inputs [47.81ms]
(pass) bundler metafile > metafile detects cjs format for CommonJS files [52.18ms]
(pass) bundler metafile > metafile marks external imports [51.58ms]
(pass) bundler metafile > metafile with code splitting [47.66ms]
(pass) bundler metafile > metafile includes with clause
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (b66764ff3)

test/bundler/metafile.test.ts:
(pass) bundler metafile > metafile option returns metafile object [4.99ms]
(pass) bundler metafile > metafile inputs contain file metadata [2.21ms]
(pass) bundler metafile > metafile outputs contain chunk metadata [1.54ms]
(pass) bundler metafile > metafile tracks import relationships [2.82ms]
(pass) bundler metafile > metafile imports have resolved path and original specifier [1.90ms]
(pass) bundler metafile > metafile without option returns undefined [2.30ms]
(pass) bundler metafile > metafile tracks exports [1.96ms]
(pass) bundler metafile > metafile includes entryPoint for entry chunks [2.20ms]
(pass) bundler metafile > metafile includes format for JS inputs [1.23ms]
(pass) bundler metafile > metafile detects cjs format for CommonJS files [3.10ms]
(pass) bundler metafile > metafile marks external imports [1.70ms]
(pass) bundler metafile > metafile with code splitting [2.68ms]
(pass) bundler metafile > metafile includes with clause for JSON imports [2.10ms]
(pass) bundler metafile > metafile tracks require-call imports [6.77ms]
(pass) bundler metafile > metafile tracks dynamic-import imports [7.
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/metafile.test.ts
bun test v1.4.0 (4960cf3cc)

test/bundler/metafile.test.ts:
(pass) bundler metafile > metafile option returns metafile object [146.16ms]
(pass) bundler metafile > metafile inputs contain file metadata [91.63ms]
(pass) bundler metafile > metafile outputs contain chunk metadata [73.81ms]
(pass) bundler metafile > metafile tracks import relationships [79.73ms]
(pass) bundler metafile > metafile imports have resolved path and original specifier [61.99ms]
(pass) bundler metafile > metafile without option returns undefined [49.82ms]
(pass) bundler metafile > metafile tracks exports [51.79ms]
(pass) bundler metafile > metafile includes entryPoint for entry chunks [56.47ms]
(pass) bundler metafile > metafile includes format for JS inputs [54.89ms]
(pass) bundler metafile > metafile detects cjs format for CommonJS files [54.24ms]
(pass) bundler metafile > metafile marks external imports [62.14ms]
(pass) bundler metafile > metafile with code splitting [52.06ms]
(pass) bundler metafile > metafile includes with clause
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     4960cf3cc4
  features     baseline

22 deps, 106 codegen, 1175 objects in 847ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] install /workspace/bun
bun install v1.4.0-canary.1 (b66764ff3)

Checked 124 installs across 170 packages (no changes) [10.00ms]
[2/1238] gen bindgenv2
[3/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (b66764ff3)

Checked 1 install across 2 packages (no changes) [1.00ms]
[4/1238] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (b66764ff3)

Checked 129 installs across 147 packages (no changes) [5.00ms]
[5/1238] gen .bind.ts → GeneratedBindings.cpp
[6/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1238] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[8/1238] fetch picohttpparser
[picohttpparser] up to date
[9/1238] gen ProcessBindingHTTPParser.lut.h
Genera
... (truncated)
diff hotspot
src/bundler/linker_context/MetafileBuilder.rs | 23 +++++--
 test/bundler/metafile.test.ts                 | 89 +++++++++++++++++++++++++++
 2 files changed, 108 insertions(+), 4 deletions(-)

gate history · 2 passed · 0 rejected · iteration 2

evidence per changed file
file                                           reads  edits  tests
src/bundler/linker_context/MetafileBuilder.rs      3      3      0
test/bundler/metafile.test.ts                      1      3      0

When the same module is imported from multiple files, the resolve dedup
path sets record.source_index without rewriting record.path, so the
metafile emitted the original specifier ("./b/shared.js") for most
importers and the resolved absolute path for whichever importer reached
the resolver first. The winner varied with parse-task completion order,
so the JSON differed between runs and the markdown reverse-dependency
map missed most edges.

Emit sources[record.source_index].path.pretty for bundled imports so the
path is always the same string used as the key in "inputs", matching
esbuild's format. Externals and chunk references keep record.path.text.
@robobun

robobun commented Jul 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced with a 30-importer fan-in; 29/30 import paths held the raw ./b/shared.js specifier and which one resolved to the absolute path flipped between runs. With the fix, all 30 emit src/b/shared.js (the inputs key), output is byte-identical across runs, and the markdown IMPORTED_BY section shows all 30 importers instead of 1.

Self-review caught that runtime-helper imports (parser-injected bun:wrap) would have leaked the internal "runtime" source name; 8b8c093 adds a guard that keeps those on record.path.text so they emit "bun:wrap" as before, and 93ef252 locks that in with a positive assertion.

Related: #34531 works around the path mismatch in the markdown generator; this PR removes the mismatch at the JSON source.

After the catch-up merge with main (4960cf3), all 44 metafile tests and the 12 esbuild-compat metafile tests still pass locally. On CI the only hard failure is a pre-existing ASAN leak in RSA keygen (AsyncLocalStorage-tracking.test.ts on debian x64-asan), unrelated to the bundler and reported separately; remaining reds are retry-passed flakes. Diff is green and approved.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Metafile import generation now resolves bundled source indices to pretty input paths, preserves fallback paths, and compares original against the resolved path. Tests cover shared modules, repeated-build determinism, and runtime-helper imports.

Changes

Metafile import path resolution

Layer / File(s) Summary
Resolve and emit metafile paths
src/bundler/linker_context/MetafileBuilder.rs
Valid non-runtime source indices now emit their referenced pretty input paths. External and chunk references retain recorded paths.
Validate metafile path behavior
test/bundler/metafile.test.ts
Tests verify shared-module input matching, original specifier preservation, deterministic repeated builds, and bun:wrap runtime-helper imports.

Possibly related PRs

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the main change: deterministic metafile import paths that match input keys.
Description check ✅ Passed The description explains the problem, cause, fix, regression risks, and verification results in sufficient detail.

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

@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced with a 30-importer fan-in; 29/30 import paths held the raw ./b/shared.js specifier and which one resolved to the absolute path flipped between runs. With the fix, all 30 emit src/b/shared.js (the inputs key), output is byte-identical across runs, and the markdown IMPORTED_BY section shows all 30 importers instead of 1.

Related: #34531 works around this mismatch in the markdown generator; this PR removes the mismatch at the JSON source.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. bundler: index input keys in metafile markdown to avoid O(N^2) scans #34531 - Also modifies MetafileBuilder.rs to address the same root cause of metafile import paths not matching input keys; uses a basename index workaround in markdown generation rather than fixing the path at the source

🤖 Generated with Claude 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.

LGTM — small, well-scoped fix that makes metafile import paths use the same path.pretty string as the "inputs" keys.

What was reviewed:

  • Verified sources[idx].path.pretty is exactly what the "inputs" key generation uses a few lines up, so lookups now align.
  • Confirmed dynamic-import chunk refs are unaffected: computeCrossChunkDependencies.rs:178 sets source_index = INVALID when rewriting to a unique_key, so they still fall through to record.path.text and get resolved by breakOutputIntoPieces.
  • Checked import_path lifetime — it is copied into an owned Vec<u8> via format_json_string_utf8 before push_owned, so no new borrowed data enters the joiner.
  • The "original" comparison now correctly uses the emitted path, so the field appears consistently for every importer (covered by the new test).
Extended reasoning...

Overview

Changes ~15 lines in src/bundler/linker_context/MetafileBuilder.rs (the generate() inputs loop) and adds two tests to test/bundler/metafile.test.ts. For bundled import records, the metafile "path" field now emits sources[record.source_index].path.pretty — the same string used as the top-level "inputs" key — instead of record.path.text, which the resolver dedup path leaves un-rewritten. Externals and chunk references (source_index invalid) keep the old behavior. The "original" field comparison is updated to compare against the actually-emitted path.

Security risks

None. This only affects the shape of the --metafile JSON output (developer tooling metadata), not bundled code, module resolution, or any runtime behavior. No untrusted input parsing, no unsafe blocks, no FFI.

Level of scrutiny

Low-to-medium. The change is mechanical: it reuses the exact same path.pretty value the surrounding code already writes as the "inputs" object key, guarded by the same is_valid() / bounds / non-empty checks used elsewhere in the file. I traced the one interaction that could regress (dynamic imports with --splitting, whose path.text holds a unique_key placeholder resolved later) and confirmed computeCrossChunkDependencies.rs invalidates source_index when rewriting those, so they hit the fallback branch unchanged — the existing "metafile tracks dynamic-import imports" test still exercises this. The import_path slice is consumed synchronously by format_json_string_utf8 into an owned buffer, so the existing detach_lifetime() SAFETY invariant is unaffected.

Other factors

The two new tests are well-constructed: 30 fan-in importers with strong assertions (bad filter must be [], every original must be "./b/shared.js", every entry import must be an inputs key), plus a 5-build determinism check. The PR description states both fail on main and all 43 existing metafile tests + 12 esbuild-compat tests pass. No CODEOWNERS on this path, no prior reviewer comments to address.

Index::RUNTIME has pretty path "runtime", which is an internal name and
never appears as an inputs key. Fall through to record.path.text for
these so the existing "bun:wrap" output is preserved.

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

Beyond the inline nit: I checked that dynamic-import chunk references still emit the resolved chunk path — computeCrossChunkDependencies.rs sets source_index = INVALID when rewriting path.text to the chunk unique_key, so the new pretty-path lookup falls through and breakOutputIntoPieces still resolves it (the existing "metafile tracks dynamic-import imports" test covers this). Also confirmed externals fall through (source_index invalid) and the Index::RUNTIME guard matches the top-level inputs filter.

Note there is an overlapping PR #34531 that works around the same mismatch in the markdown generator — worth deciding which lands / whether that workaround becomes dead code.

Extended reasoning...

The one correctness risk I wanted to rule out was code-split dynamic imports: with splitting: true, an import record targeting another chunk has its path.text rewritten to a unique_key that breakOutputIntoPieces later resolves to ./chunk-xxx.js. If such a record still had a valid source_index, the new lookup would emit the source pretty path instead of the chunk path and break the existing dynamic-import test. computeCrossChunkDependencies.rs:178 sets import_record.source_index = Index::INVALID at the same site it writes the unique_key, so the new block's is_valid() check is false and it falls through to record.path.text as before. Externals likewise have invalid source_index, and the RUNTIME guard mirrors the existing top-level inputs skip. Deferring rather than approving because of the #34531 overlap and the user-visible change to when "original" is emitted (now compared against the pretty path, so it appears on essentially every relative bundled import).

Comment thread test/bundler/metafile.test.ts Resolved

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

LGTM — the previous nit on the runtime-helper test was addressed in 93ef252 (importPaths now asserts ["bun:wrap"] exactly).

What was reviewed

  • Verified bundled imports now emit sources[idx].path.pretty, the exact string used for top-level "inputs" keys, with defensive fallbacks (RUNTIME, out-of-range, empty pretty) to record.path.text.
  • Confirmed split-chunk dynamic imports are unaffected: computeCrossChunkDependencies.rs:178 sets source_index = Index::INVALID when rewriting to a unique_key, so those records fall through and the existing ./chunk-*.js test still holds.
  • Checked the "original" comparison update — externals and chunk refs behave identically to before; deduped bundled imports now correctly emit "original" since path ≠ specifier.
  • Tests guard against vacuous loops (midImports.length === 30, toEqual(["bun:wrap"])).
Extended reasoning...

Overview

Two files touched. In src/bundler/linker_context/MetafileBuilder.rs, the metafile "inputs[*].imports[*].path" emitter now looks up sources[record.source_index].path.pretty for bundled imports instead of trusting record.path.text, which the resolver's dedup path leaves un-rewritten. The "original" field comparison is updated to use the same computed path. test/bundler/metafile.test.ts gains three tests: 30-way fan-in path consistency + original preservation, 5-build determinism, and a positive assertion that runtime-helper imports emit bun:wrap.

Security risks

None. This is output formatting for a diagnostic JSON file (the bundler metafile). No user-controlled data reaches new code paths; the new lookup indexes into sources with a bounds check and falls back to the previous value on any miss.

Level of scrutiny

Low-medium. The fix is a ~15-line read-only lookup in a reporting path, mirroring exactly how the top-level "inputs" keys are already computed a few lines above (source.path.pretty). I traced the one non-obvious interaction — dynamic imports rewritten to chunk unique_keys — and confirmed computeCrossChunkDependencies.rs invalidates source_index at the same time it rewrites path.text, so those records take the fallback branch and continue to be resolved by breakOutputIntoPieces. Externals have invalid source_index by construction. Runtime-helper imports are explicitly excluded so they keep emitting bun:wrap rather than the internal source name.

Other factors

  • The prior review's only finding (vacuous loop in the runtime-helper test) was fixed in 93ef252 and the thread is resolved.
  • The PR description reports all 43 existing metafile tests and 12 esbuild-compat metafile tests pass; the existing dynamic-import chunk-path test in this file would catch the chunk-ref regression I checked for.
  • #34531 is noted as a related workaround at the markdown layer; this PR fixes the JSON source, which is the right layer per the repo's "fix at the layer that owns the invariant" rule. The two don't conflict — they touch different code paths.
  • No CODEOWNERS entry for src/bundler/.

@dylan-conway
dylan-conway enabled auto-merge (squash) August 5, 2026 18:35

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

LGTM — the runtime-helper test is now a positive toEqual(["bun:wrap"]) assertion, which closes out the earlier nit.

Checked that dynamic-import chunk references still fall through to record.path.text: computeCrossChunkDependencies.rs:178 sets source_index = Index::INVALID when rewriting the record to a chunk unique_key, so the new is_valid() guard skips them and breakOutputIntoPieces resolves the placeholder as before (covered by the existing dynamic-import test). Externals likewise have no valid source_index and keep their specifier. The original comparison was updated in lockstep so the specifier is now emitted for every deduped importer.

On the comment-cop flag at line 298: the 3-line comment explains why record.path.text is unreliable rather than what the code does, and the fix isn't a workaround — it reads the import path from the same sources[idx].path.pretty that produces the top-level inputs keys, i.e. the single source of truth. Trimming it wouldn't hurt, but it's not blocking.

Extended reasoning...

Overview

Two files touched: ~19 lines of logic in src/bundler/linker_context/MetafileBuilder.rs (the generate inputs-section emitter) and three new tests in test/bundler/metafile.test.ts. The change replaces record.path.text with sources[record.source_index].path.pretty for bundled imports so every metafile "path" string is byte-identical to its corresponding "inputs" key, and updates the "original" comparison to use the same value.

Security risks

None. This is output-formatting for a build-time diagnostic JSON; no untrusted input, no auth, no I/O beyond what the bundler already does.

Level of scrutiny

Low-medium. The metafile is diagnostic output, not on the runtime hot path, and the change is a read-only lookup into an array that's already indexed elsewhere in the same function with the same bounds pattern. The main correctness question was whether the new lookup would clobber chunk-reference unique_keys for code-split dynamic imports; I verified computeCrossChunkDependencies sets source_index = Index::INVALID on those records before this code runs, so they fall through unchanged, and the pre-existing metafile tracks dynamic-import imports test (which asserts ^\.\/chunk-[a-z0-9]+\.js$) still passes per the PR evidence.

Other factors

  • My prior review flagged the runtime-helper test as vacuous; 93ef252 tightened it to expect(importPaths).toEqual(["bun:wrap"]), which is exactly what was asked. That thread is resolved.
  • The RUNTIME guard is correct: parser-injected bun:wrap records have source_index == Index::RUNTIME and fall through to record.path.text, preserving the existing output. The runtime source's pretty path ("runtime") is never leaked.
  • The bounds check (idx < sources.len()) and empty-pretty fallback mirror the defensive pattern used a few lines earlier for the top-level input keys.
  • The comment-cop bot flagged the 3-line explanatory comment as a "paragraph-long workaround justification". I read it as a legitimate why-comment: it names the specific resolver behavior (dedup sets source_index without rewriting path) that makes record.path.text the wrong field here. The fix itself is not a workaround — it derives the import path from the exact same field (path.pretty) that produces the input keys, which is one-source-of-truth, not symptom-patching. Could be one line shorter, but not a blocker.
  • Evidence shows all 43 existing metafile tests + 12 esbuild-compat tests pass; the two new tests fail on the unfixed ASAN build (where parse ordering is perturbed) and pass with the fix.
  • Coordination with #34531 was addressed: that PR patches generate_markdown downstream; this one removes the mismatch at the JSON source. No file-level conflict.

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