Skip to content

Rewrite Bun in Rust#30412

Merged
Jarred-Sumner merged 6755 commits into
mainfrom
claude/phase-a-port
May 14, 2026
Merged

Rewrite Bun in Rust#30412
Jarred-Sumner merged 6755 commits into
mainfrom
claude/phase-a-port

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented May 8, 2026

Copy link
Copy Markdown
Collaborator

Blog post with details coming soon.

It passes Bun's pre-existing test suite on all platforms (and fixes several memory leaks and flaky tests), the binary size shrinks by 3 MB - 8 MB, the benchmarks are between neutral and faster - and most importantly, we now have compiler-assisted tools for catching & preventing memory bugs, which have costed the team an enormous amount of development & debugging time over the years.

The codebase is otherwise largely the same. The same architecture, the same data structures. Bun still uses few 3rd party libraries. No async rust.

To try this, run:

bun upgrade --canary

Please do file issues if you run into any. If this thread gets crazy I will lock it.

Note:

  • Still some optimization work to do before this lands in non-canary version.
  • Still some cleanup work to do (which will come in a series of follow-up PRs)

@Jarred-Sumner Jarred-Sumner requested a review from alii as a code owner May 8, 2026 22:14
@robobun

robobun commented May 8, 2026

Copy link
Copy Markdown
Collaborator
Updated 1:06 AM PT - May 14th, 2026

@Jarred-Sumner, your commit ed1a70f81708d7d137de8de057d11668c5f4e220 passed in Build #54202! 🎉


🧪   To try this PR locally:

bunx bun-pr 30412

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

bun-30412 --bun

@coderabbitai

coderabbitai Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 40c8a58d-7518-4deb-ac2c-6a6ba2272aec

📥 Commits

Reviewing files that changed from the base of the PR and between 1e306f9 and ed1a70f.

⛔ Files ignored due to path filters (2)
  • Cargo.lock is excluded by !**/*.lock
  • bench/bundle/bun.lockb is excluded by !**/bun.lockb
📒 Files selected for processing (218)
  • .apply-lock
  • .apply-lock-guard.sh
  • .claude/workflows/phase-h-ci-tasks.workflow.js
  • .claude/workflows/phase-h-classify-issues.workflow.js
  • .claude/workflows/phase-h-dedup.workflow.js
  • .claude/workflows/phase-h-deep-dive.workflow.js
  • .claude/workflows/phase-h-diff-review.workflow.js
  • .claude/workflows/phase-h-libuv-audit.workflow.js
  • .claude/workflows/phase-h-main-parity.workflow.js
  • .claude/workflows/phase-h-portnotes-survey.workflow.js
  • .claude/workflows/phase-h-unsafe-wrap.workflow.js
  • .claude/workflows/phase-h-windows-bughunt-wt.workflow.js
  • .claude/workflows/phase-h-windows-bughunt.workflow.js
  • .claude/workflows/phase-h-windows-errors.workflow.js
  • .claude/workflows/phase-h-windows-singlefix.workflow.js
  • .claude/workflows/phase-h-windows-testfix.workflow.js
  • .github/workflows/format.yml
  • .gitignore
  • .prettierignore
  • CLAUDE.md
  • Cargo.toml
  • bench/bundle/package.json
  • package.json
  • packages/bun-types/bun.d.ts
  • packages/bun-usockets/src/loop.c
  • patches/libuv/win-poll-rearm-before-callback.patch
  • scripts/build.ts
  • scripts/build/bun.ts
  • scripts/build/codegen.ts
  • scripts/build/compile.ts
  • scripts/build/config.ts
  • scripts/build/configure.ts
  • scripts/build/deps/libuv.ts
  • scripts/build/deps/mimalloc.ts
  • scripts/build/deps/webkit.ts
  • scripts/build/flags.ts
  • scripts/build/profiles.ts
  • scripts/build/rust.ts
  • scripts/build/shims.ts
  • scripts/build/source.ts
  • scripts/build/tools.ts
  • scripts/build/workarounds.ts
  • scripts/categorize-ci-failures.ts
  • scripts/ci-errors-to-tasks.ts
  • scripts/ci-log-to-results-json.ts
  • scripts/ci-monitor-check.ts
  • scripts/classify-crash-leak-issues.ts
  • scripts/fetch-issues-to-sqlite.ts
  • scripts/find-build.ts
  • scripts/glob-sources.ts
  • scripts/runner.node.mjs
  • scripts/utils.mjs
  • scripts/verify-baseline-static/Cargo.toml
  • scripts/verify-baseline-static/allowlist-aarch64.txt
  • scripts/verify-baseline-static/allowlist-x64-windows.txt
  • scripts/verify-baseline-static/allowlist-x64.txt
  • src/CLAUDE.md
  • src/analytics/Cargo.toml
  • src/analytics/lib.rs
  • src/analytics/schema.rs
  • src/api/Cargo.toml
  • src/api/lib.rs
  • src/ast/Cargo.toml
  • src/ast/ast.zig
  • src/ast/ast_memory_allocator.rs
  • src/ast/ast_memory_allocator.zig
  • src/ast/ast_result.rs
  • src/ast/b.rs
  • src/ast/b.zig
  • src/ast/base.rs
  • src/ast/base.zig
  • src/ast/binding.rs
  • src/ast/binding.zig
  • src/ast/char_freq.rs
  • src/ast/char_freq.zig
  • src/ast/e.rs
  • src/ast/e.zig
  • src/ast/expr.rs
  • src/ast/expr.zig
  • src/ast/fold_string_addition.rs
  • src/ast/fold_string_addition.zig
  • src/ast/g.rs
  • src/ast/g.zig
  • src/ast/import_record.rs
  • src/ast/known_global.rs
  • src/ast/known_global.zig
  • src/ast/lexer_log.rs
  • src/ast/lexer_tables.rs
  • src/ast/lib.rs
  • src/ast/loader.rs
  • src/ast/logger.zig
  • src/ast/new_store.rs
  • src/ast/new_store.zig
  • src/ast/nodes.rs
  • src/ast/op.rs
  • src/ast/op.zig
  • src/ast/runtime.rs
  • src/ast/s.rs
  • src/ast/s.zig
  • src/ast/scope.rs
  • src/ast/scope.zig
  • src/ast/server_component_boundary.rs
  • src/ast/server_component_boundary.zig
  • src/ast/stmt.rs
  • src/ast/stmt.zig
  • src/ast/symbol.rs
  • src/ast/symbol.zig
  • src/ast/target.rs
  • src/ast/transpiler_cache.rs
  • src/ast/ts.rs
  • src/ast/ts.zig
  • src/ast/use_directive.rs
  • src/ast/use_directive.zig
  • src/ast_jsc/Cargo.toml
  • src/ast_jsc/lib.rs
  • src/ast_jsc/logger_jsc.zig
  • src/base64/Cargo.toml
  • src/base64/lib.rs
  • src/boringssl/Cargo.toml
  • src/boringssl/lib.rs
  • src/boringssl_sys/Cargo.toml
  • src/boringssl_sys/boringssl.rs
  • src/boringssl_sys/lib.rs
  • src/brotli/Cargo.toml
  • src/brotli/lib.rs
  • src/brotli_sys/Cargo.toml
  • src/brotli_sys/brotli_c.rs
  • src/brotli_sys/lib.rs
  • src/bun.js.rs
  • src/bun.rs
  • src/bun_alloc/BufferFallbackAllocator.rs
  • src/bun_alloc/Cargo.toml
  • src/bun_alloc/MaxHeapAllocator.rs
  • src/bun_alloc/MimallocArena.rs
  • src/bun_alloc/NullableAllocator.rs
  • src/bun_alloc/ast_alloc.rs
  • src/bun_alloc/basic.rs
  • src/bun_alloc/c_thunks.rs
  • src/bun_alloc/fallback.rs
  • src/bun_alloc/hashbrown_bridge.rs
  • src/bun_alloc/heap_breakdown.rs
  • src/bun_alloc/lib.rs
  • src/bun_alloc/maybe_owned.rs
  • src/bun_alloc/memory.rs
  • src/bun_alloc/stack_fallback.rs
  • src/bun_bin/Cargo.toml
  • src/bun_bin/lib.rs
  • src/bun_bin/phase_c_exports.rs
  • src/bun_core/Cargo.toml
  • src/bun_core/Global.rs
  • src/bun_core/Progress.rs
  • src/bun_core/atomic_cell.rs
  • src/bun_core/bounded_array.rs
  • src/bun_core/bounded_array.zig
  • src/bun_core/deprecated.rs
  • src/bun_core/env.rs
  • src/bun_core/env_var.rs
  • src/bun_core/external_shared.rs
  • src/bun_core/fmt.rs
  • src/bun_core/heap.rs
  • src/bun_core/hint.rs
  • src/bun_core/lib.rs
  • src/bun_core/output.rs
  • src/bun_core/result.rs
  • src/bun_core/string/HashedString.rs
  • src/bun_core/string/MutableString.rs
  • src/bun_core/string/PathString.rs
  • src/bun_core/string/SmolStr.rs
  • src/bun_core/string/StringBuilder.rs
  • src/bun_core/string/StringJoiner.rs
  • src/bun_core/string/escapeRegExp.rs
  • src/bun_core/string/identifier.rs
  • src/bun_core/string/immutable.rs
  • src/bun_core/string/immutable/escapeHTML.rs
  • src/bun_core/string/immutable/escapeHTML.zig
  • src/bun_core/string/immutable/exact_size_matcher.rs
  • src/bun_core/string/immutable/exact_size_matcher.zig
  • src/bun_core/string/immutable/grapheme.rs
  • src/bun_core/string/immutable/grapheme.zig
  • src/bun_core/string/immutable/grapheme_tables.rs
  • src/bun_core/string/immutable/grapheme_tables.zig
  • src/bun_core/string/immutable/unicode.rs
  • src/bun_core/string/immutable/unicode.zig
  • src/bun_core/string/immutable/visible.rs
  • src/bun_core/string/immutable/visible.zig
  • src/bun_core/string/mod.rs
  • src/bun_core/string/write.rs
  • src/bun_core/string/wtf.rs
  • src/bun_core/thread_id.rs
  • src/bun_core/tty.rs
  • src/bun_core/util.rs
  • src/bun_core/windows_sys.rs
  • src/bun_core/wtf.rs
  • src/bun_core_macros/Cargo.toml
  • src/bun_core_macros/lib.rs
  • src/bun_output_tags/Cargo.toml
  • src/bun_output_tags/lib.rs
  • src/bundler/AstBuilder.rs
  • src/bundler/BundleThread.rs
  • src/bundler/Cargo.toml
  • src/bundler/Chunk.rs
  • src/bundler/DeferredBatchTask.rs
  • src/bundler/Graph.rs
  • src/bundler/HTMLImportManifest.rs
  • src/bundler/HTMLScanner.rs
  • src/bundler/IndexStringMap.rs
  • src/bundler/LinkerContext.rs
  • src/bundler/LinkerGraph.rs
  • src/bundler/OutputFile.rs
  • src/bundler/ParseTask.rs
  • src/bundler/PathToSourceIndexMap.rs
  • src/bundler/ServerComponentParseTask.rs
  • src/bundler/ThreadPool.rs
  • src/bundler/analyze_transpiled_module.rs
  • src/bundler/barrel_imports.rs
  • src/bundler/bundle_v2.rs
  • src/bundler/bundled_ast.rs
  • src/bundler/bundled_ast.zig

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.


Walkthrough

The repository migrates from Zig to a Rust-first build: adds a Cargo workspace, replaces Zig build/CI with Rust cargo build integration and artifacts, rewires codegen for Rust, adjusts dependencies and flags, removes Zig tooling, adds numerous Claude automation workflows, and introduces new scripts and docs.

Changes

Rust-first build and automation stack

Layer / File(s) Summary
Build/CI switch to Rust
.buildkite/ci.mjs, scripts/build/*, scripts/build/rust.ts, Cargo.toml, rust-toolchain.toml
Replaces zig-only with rust-only in CI; adds cargo staticlib integration, profiles, toolchain pinning, and artifact wiring.
Codegen and sources
scripts/build/codegen.ts, scripts/glob-sources.ts, .github/workflows/format.yml
Routes generated artifacts to Rust consumers, adds string maps and host exports, and verifies checked-in generated Rust.
Dependency/tool flags and shims
scripts/build/deps/*, scripts/build/flags.ts, scripts/build/shims.ts
Tweaks libuv/mimalloc/webkit builds, linker flags across platforms, and musl CRT decompression shim when using rust-lld.
Zig removal and docs
build.zig, scripts/build/zig.ts, docs and ignores
Removes Zig build/tooling, updates docs to Rust-first, and adjusts ignore patterns.
Automation workflows
.claude/workflows/*.workflow.js
Adds extensive multi-phase workflows for porting, gating, verification, fixing, audits, and test/probe swarms.
Utilities and scripts
scripts/*, packages/*, bench/*
Adds helper scripts for analysis, CRUD on manifests, CI parsing, test orchestration, and small API/type additions.

Possibly related PRs

  • oven-sh/bun#30412: Both PRs modify Buildkite CI to replace zig-only builds with rust-only and update step dependencies accordingly.
✨ Finishing Touches
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch claude/phase-a-port

Comment thread docs/CI_RUST_MIGRATION.md Outdated
Comment thread CLAUDE.md
Comment thread Cargo.toml
Comment on lines +147 to +152
inherits = "release"
lto = "thin"
codegen-units = 16

# Release with debuginfo retained for `perf record` / flamegraphs. Same opt
# level as release; binary is large but profilable.

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: [profile.release-profiling] sets debug = "line-tables-only" and strip = "none", which are exactly the values [profile.release] already defines (lines 141-142), so this profile is a no-op alias. The comment "binary is large but profilable" suggests the intent was debug = true (full DWARF) — and note that cargoProfile() in scripts/build/rust.ts:67-68 only ever selects dev or release, so this profile is currently unreachable from the build system anyway. Consider setting debug = true or dropping the profile.

Extended reasoning...

What the bug is

Cargo.toml defines a custom [profile.release-profiling] profile at lines 153-156:

[profile.release-profiling]
inherits = "release"
debug = "line-tables-only"
strip = "none"

But the base [profile.release] profile at lines 138-142 already sets exactly these values:

[profile.release]
lto = "fat"
codegen-units = 1
debug = "line-tables-only"
strip = "none"

Since release-profiling inherits from release and then "overrides" two keys with the identical values they already have, the resulting profile is byte-for-byte identical to release. It changes nothing.

Why the comment contradicts the config

The doc comment above the profile (lines 151-152) reads:

Release with debuginfo retained for perf record / flamegraphs. Same opt level as release; binary is large but profilable.

"Binary is large" implies the profile was intended to emit more debuginfo than the regular release build — i.e., debug = true (full DWARF, level 2). With debug = "line-tables-only" the binary is the same size as release because it is release. Compare with the sibling [profile.release-dev] at lines 146-149, which actually overrides lto and codegen-units to differ from its parent — that's the pattern release-profiling was presumably meant to follow.

Step-by-step proof

  1. Cargo resolves release-profiling by starting from release: {lto: "fat", codegen-units: 1, debug: "line-tables-only", strip: "none", opt-level: 3, ...}.
  2. It then applies the two explicit keys from release-profiling: debug = "line-tables-only" (unchanged) and strip = "none" (unchanged).
  3. Final resolved profile: identical to step 1. cargo build --profile=release-profiling produces the same artifact as cargo build --release (modulo the output directory name).

It's also unreachable

scripts/build/rust.ts:67-68 defines:

function cargoProfile(cfg: Config): { name: string; subdir: string } {
  return cfg.buildType === "Debug" ? { name: "dev", subdir: "debug" } : { name: "release", subdir: "release" };
}

This only ever returns dev or release, and a repo-wide grep for release-profiling finds only the definition site in Cargo.toml. So even if the profile did differ, nothing in the build system can select it.

Impact

None — this is purely redundant/misleading config. Nothing breaks: line-tables-only is actually sufficient for basic perf record/flamegraph symbolication, so anyone who manually ran cargo build --profile=release-profiling would get a working (if not ideally detailed) profiling build. The only harm is that the comment misdescribes what the profile does, and a future reader might waste time figuring out why "the profiling build" is no different from release.

How to fix

Either:

  • Set debug = true (or debug = 2) to match the comment's intent and get full DWARF for richer profiling (inlined frames, local variables), or
  • Delete the [profile.release-profiling] section entirely since [profile.release] already retains line tables and is unstripped.

If kept, also consider wiring it into cargoProfile() so it's actually selectable.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 78

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
docs/RUST_PATTERNS.md (1)

486-488: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Minor: Missing trailing newline.

The file ends at line 488 without a trailing newline, which can cause issues with some tools and git diff output.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/RUST_PATTERNS.md` around lines 486 - 488, The file ends without a
trailing newline; add a single newline character at EOF to terminate the file.
Open docs/RUST_PATTERNS.md, locate the table rows such as the line containing
"`ptr::read(self) to clone`" (near the `*mut c_void` ctx callback /
`ptr::read(self)` entries) and ensure the file ends with a newline, then save
and commit the change.
src/aio/Cargo.toml (1)

1-28: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add workspace lint opt-in to bun_aio.

Workspace-defined lints from [workspace.lints.clippy] require explicit opt-in in each member crate. Without adding [lints] workspace = true to this manifest, the ptr_as_ptr = "warn" audit configured in the root workspace will not run on this crate.

Suggested fix
 [package]
 name = "bun_aio"
 version.workspace = true
 edition.workspace = true
 
+[lints]
+workspace = true
+
 [lib]
 path = "lib.rs"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/aio/Cargo.toml` around lines 1 - 28, The crate manifest for package
"bun_aio" (Cargo.toml) is missing the workspace lints opt-in so
workspace-defined Clippy lints like ptr_as_ptr won’t apply; update the
Cargo.toml for the bun_aio crate by adding a top-level [lints] table with
workspace = true (i.e., add `[lints]` followed by `workspace = true`) so the
workspace lints declared in the root are opt‑in for this member crate.
scripts/build/bun.ts (1)

470-474: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve Rust-only exports at link time.

libbun_rust.a is linked as a plain archive in both full and link-only modes, so any #[no_mangle] member that is only surfaced through the dynamic-list/N-API export path and has no static ref from the C++ objects can still be discarded. scripts/build/rust.ts already provides rustLinkFlags() for the required whole-archive/force-load handling; use that helper here and track the Rust archive as a dependency instead of feeding it as a plain $in archive.

💡 Suggested wiring
-import { emitRust, rustLibPath } from "./rust.ts";
+import { emitRust, rustLibPath, rustLinkFlags } from "./rust.ts";
-  const exe = link(n, cfg, exeName, [...allObjects, ...rustObjects, ...windowsRes], {
+  const exe = link(n, cfg, exeName, [...allObjects, ...windowsRes], {
     libs: depLibs,
-    flags: [...flags.ldflags, ...systemLibs(cfg), ...manifestLinkFlags(cfg), ...shims.ldflags],
-    implicitInputs: [...linkImplicitInputs(cfg), ...shims.implicitInputs],
+    flags: [
+      ...flags.ldflags,
+      ...rustLinkFlags(cfg, rustObjects),
+      ...systemLibs(cfg),
+      ...manifestLinkFlags(cfg),
+      ...shims.ldflags,
+    ],
+    implicitInputs: [...linkImplicitInputs(cfg), ...rustObjects, ...shims.implicitInputs],
   });
-  const exe = link(n, cfg, exeName, [archive, ...rustObjects, ...windowsRes], {
+  const exe = link(n, cfg, exeName, [archive, ...windowsRes], {
     libs: depLibs,
-    flags: [...flags.ldflags, ...systemLibs(cfg), ...manifestLinkFlags(cfg), ...shims.ldflags],
-    implicitInputs: [...linkImplicitInputs(cfg), ...shims.implicitInputs],
+    flags: [
+      ...flags.ldflags,
+      ...rustLinkFlags(cfg, rustObjects),
+      ...systemLibs(cfg),
+      ...manifestLinkFlags(cfg),
+      ...shims.ldflags,
+    ],
+    implicitInputs: [...linkImplicitInputs(cfg), ...rustObjects, ...shims.implicitInputs],
   });

Also applies to: 610-614

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.claude/workflows/lifetime-classify.workflow.js:
- Line 11: The REPO constant is hardcoded to "/root/bun-5" making the workflow
environment-specific; change the code that defines REPO so it reads from a
configurable source (e.g., process.env.REPO_PATH or a workflow input like
inputs.repo) with a sensible fallback and a clear warning/error if neither is
provided. Specifically replace the literal const REPO = "/root/bun-5"; with
logic that checks process.env.REPO_PATH || workflow input (e.g., inputs.repo) ||
a default, and ensure any code that uses REPO still works; add
validation/logging so missing configuration fails fast or logs the chosen path.
- Around line 139-141: The consensus selection can become undefined when
votes.find(... )?.correct_class returns undefined; update the logic that
computes consensus so it falls back to the original f.class when no valid
correct_class is found. Concretely, inside the block that computes const refutes
= votes.filter(...).length and const consensus = ..., replace the ternary branch
that uses votes.find(v => v && v.refuted)?.correct_class with a safe selection
(e.g., assign foundCorrect = votes.find(v => v && v.refuted)?.correct_class and
then set consensus = refutes >= 2 ? (foundCorrect ?? f.class) : f.class) so
final_class is never undefined; ensure you update usages of consensus and return
{ ...f, refutes, final_class: consensus, verified: true } unchanged.
- Line 106: The current then handler assumes r.fields exists and calls
r.fields.map(...), which can throw if r is an object without fields; update the
handler that returns fields for the promise (the arrow function using r and
f.zig/f.crate) to guard/normalize r.fields first — e.g., check
Array.isArray(r.fields) or use optional chaining/defaulting so you only call
.map when r.fields is an array and otherwise return an empty array; keep the
mapping that spreads each field and adds file: f.zig and crate: f.crate.
- Around line 157-165: The return block can produce NaN when final.length === 0
and repeats the same unsafe toFixed usage noted earlier; change the computation
of unknown_rate to guard against division-by-zero by computing unknown_rate =
final.length === 0 ? "0.000" : ((byClass.UNKNOWN || 0) /
final.length).toFixed(3), and ensure the returned value is a string/number
consistently; keep the rest of the fields (total_fields, by_class,
verified_count, overturned, tsv_preview, tsv) unchanged and reference the
variables final, byClass, unknown_rate, verified and tsv_preview when applying
the fix.

In @.claude/workflows/phase-b0-movein.workflow.js:
- Line 37: The workflow currently references hardcoded line ranges in PORTING.md
which can become stale; update the workflow that reads PORTING.md to locate
sections by header names instead of fixed line ranges (search for the
"§Dispatch" and "§Concurrency" headers), or add a validation step that confirms
the text found at the referenced ranges contains the expected "§Dispatch" and
"§Concurrency" headers and fails with a clear error if not, or alternatively
document that the PORTING.md line numbers must remain stable—implement one of
these fixes and ensure the code uses the "§Dispatch" and "§Concurrency" markers
to locate content reliably.
- Line 36: The workflow references the grep against movein-skipped.txt but
there's no producer for that file; update the phase-b0 move-in workflow to
either (A) add a setup step that materializes the move-out `skipped` array into
the expected movein-skipped file (so the grep -iE
'\b(${t.name}|${t.cyclebreak_key}|bun_${t.cyclebreak_key})\b' has real data), or
(B) add robust error-handling before the grep (ensure the file exists or create
an empty placeholder from the move-out `skipped` data, or short-circuit the grep
with a safe fallback) so explicit move-out requests aren’t silently missed.
Ensure you modify the step that runs the grep command and/or the move-out
producer that exposes the `skipped` array to produce the expected artifact.

In @.claude/workflows/phase-b1-tier.workflow.js:
- Line 10: The code assumes all input crates share the same tier by using
CRATES[0].tier for the batch log and final output; add a validation in the
beginning of the workflow to verify homogeneity (e.g., compute a set of tiers
from args.crates/CRATES and if its size > 1 throw or normalize), and then use a
safe value (the single tier) only after validation; update references where
CRATES[0].tier is used (the initial log call and the final batch summary around
line 53) to either compute the tier from the validated set or to report that
multiple tiers were received if you choose to error instead.

In @.claude/workflows/phase-b2-fill.workflow.js:
- Line 21: The dir_for helper currently maps a few special crate names and
otherwise strips a bun_ prefix, which can produce incorrect directories for
unexpected crate names; update dir_for to validate inputs and document the
mapping: add a short comment explaining the special cases (bun_str→"string",
bun_core, bun_alloc) and the fallback behavior, and implement explicit
validation in the dir_for function to either (a) check the crate name against an
allowed set of known crate keys and throw or log an error when unknown, or (b)
return a clearly marked fallback only after confirming the name begins with
"bun_"—use the existing dir_for identifier to locate and change the mapping
logic and add the comment nearby.

In @.claude/workflows/phase-b2-fix-bugs.workflow.js:
- Line 7: The comment referencing a possible input "bugs_path: \"/tmp/...\"" is
inconsistent with the actual code which only reads args.bugs; either remove that
misleading comment or implement support for loading bugs from a file: add
handling for args.bugs_path in the workflow's argument parsing (check
args.bugs_path, read and parse the file into the same data structure as
args.bugs), or delete the alternate example from the comment so it matches the
existing behavior (update the comment where "// args: { bugs_path: \"/tmp/...\",
or bugs: [{module,fn,what,fix}] }" appears to only show supported inputs).
- Around line 38-39: The template currently injects the full bugs array via
"${JSON.stringify(bugs, null, 2)}", which can exceed token limits for large
lists; modify the workflow to check bugs.length and either truncate or summarize
before embedding: e.g., if bugs.length > MAX_BUGS include a short summary ("N
bugs, showing first M") plus JSON.stringify of the first M entries, or call a
helper like summarizeBugs(bugs) to produce a condensed text; update the template
insertion to use the truncated/summarized string instead of the full JSON and
ensure MAX_BUGS and summarizeBugs are defined/adjustable.
- Line 23: Validate b.module before using b.module.split("/") to compute crate:
ensure b.module is a non-empty string and contains the expected "/" delimiter
(or match a regex like /^[^\/]+\/.+$/) and handle failures (e.g., log a warning
and skip grouping, throw, or default to a safe value) instead of blindly doing
const crate = b.module.split("/")[0]; update the code around the crate
extraction to perform the check, use the validated part when present, and add a
clear log message documenting the expected "crate/..." format when invalid.

In @.claude/workflows/phase-b2-keystone.workflow.js:
- Line 17: The file contains top-level return statements (e.g., the check "if
(!TASK) return { error: 'args.task required' }") which are invalid in ES
modules; wrap the executable workflow code in a function or immediately-invoked
async function (IIFE) that the workflow runtime expects, move all top-level
logic (including checks at TASK and the other return sites referenced) into that
function, and replace those top-level "return" statements with returns from that
function (or throw errors/resolve values from the function) so no return appears
at module top-level; update the checks around the TASK variable and the other
early-exit points so they return from the newly created function scope.

In @.claude/workflows/phase-d-blocked-on-resolve.workflow.js:
- Around line 81-85: The workflow contains literal placeholders in the
PortUpstream command steps (e.g., '<method_name>', '<crate>', '<caller_crate>')
which make automated grep and cargo checks unusable; update the commands in the
PortUpstream steps to interpolate the actual variables (use ${g.symbol} for the
method name, ${g.upstream_crate} or a fallback for the upstream crate instead of
'<crate>', and a computed ${g.caller_crate} or caller list instead of
'<caller_crate>'), so step 1 runs grep -rn "${g.symbol}" src/**/*.zig, step 3
runs cargo check -p ${g.upstream_crate} (or resolved crate name), and step 4
runs cargo check -p for each resolved caller crate; ensure all placeholder
tokens in those commands are replaced with the corresponding ${g.*} variables
used elsewhere in the workflow.

In @.claude/workflows/phase-d-build-queue.workflow.js:
- Line 65: The workflow string in the HARD constant currently stages all of
'src/' and uses git merge with -X ours which can silently discard concurrent
agents' changes; update the commit/push command in
.claude/workflows/phase-d-build-queue.workflow.js (HARD) to stage and commit
only the exact touched paths (not "src/") and remove the `-X ours` merge flag so
merges are not force-resolved locally; instead have the orchestrator perform the
final merge or require each agent to operate on an isolated branch/worktree
before pushing.

In @.claude/workflows/phase-d-bundler-shard.workflow.js:
- Line 102: The prompt currently returns a key named blocks_on which mismatches
the FIX_S schema's blocked_on field; update the template string "Return
{file:\"${u.file}\", before:${u.n}, fns_touched:[...], blocks_on:[...], notes}."
to use blocked_on instead of blocks_on so agents produce the recognized key
(referencing FIX_S).
- Around line 51-114: The agent prompt returns a field named "blocks_on" but the
schema expects "blocked_on": update the return template inside the inner agent
call (the agent invoked for each unit with label starting with "fix:")—replace
"blocks_on:[...]" with "blocked_on:[...]" in the final Return {...} string (the
template that currently reads Return {file:"${u.file}", before:${u.n},
fns_touched:[...], blocks_on:[...], notes}) so the returned object matches the
FIX_S schema.

In @.claude/workflows/phase-d-recursive-ungate.workflow.js:
- Line 100: The HARD rule string currently contains an embedded "Commit+push
with retry" shell snippet that runs git pull -X ours and pushes, which allows
concurrent workers to merge/clobber siblings; update the HARD constant (the
template string assigned to HARD in
.claude/workflows/phase-d-recursive-ungate.workflow.js) to remove the
pull/merge/push steps and only include commit-only instructions (e.g.,
add/commit retry loop without git pull or git push), or otherwise document that
workers must not perform pull/merge/push and that a single orchestrator will
serialize branch integration; keep the rest of the HARD text intact and ensure
the offending git pull -X ours / push sequence is not present.

In @.claude/workflows/phase-e-body-port.workflow.js:
- Line 63: The workflow's HARD constant contains a commit command that does git
-c core.hooksPath=/dev/null add -A 'src/' which can accidentally stage unrelated
edits; change the worker so it tracks exactly which files it modifies and
replace that command with one that git-adds only the recorded paths (e.g., git
-c core.hooksPath=/dev/null add <each-recorded-path> && git -c
core.hooksPath=/dev/null commit -q -m "phase-e(port): <file> real bodies").
Update the code that builds the HARD string (reference the HARD constant in
.claude/workflows/phase-e-body-port.workflow.js) to remove the add -A 'src/' and
instead use the recorded-files list when invoking git, ensuring hooksPath
behavior is preserved and only the touched files are committed.
- Line 78: The module contains top-level return statements (e.g., the line with
"if (!survey || survey.total === 0) return { rounds: round, done: true, history
};" and other returns inside the same module-scope loop) which are invalid in ES
modules; wrap the entire executable logic block (the loop and surrounding code
currently at module scope) inside a named async function (for example
runPhaseEBodyPort or executeWorkflow) and export or invoke that function from
the module entry point, or alternatively replace those top-level return
statements with control-flow that sets a result variable and breaks the loop;
update any callers to call the new function name (e.g., runPhaseEBodyPort) so no
return remains at top-level.

In @.claude/workflows/phase-e-test-bringup.workflow.js:
- Line 126: The workflow hard-coded policy (HARD constant) instructs parallel
agents to pull and push to the same branch using the merge strategy `-X ours`
inside the retry loop (the commit+push retry snippet referencing
`origin/claude/phase-a-port`), which allows sibling fixes to be silently
overwritten; change the workflow to avoid `-X ours` and instead give each agent
a unique branch or commit ref (e.g., include an agent/PR-specific suffix) and
push to that branch, or make pulls fail on conflict so the agent rebases/merges
locally before pushing; update the retry loop logic that references the `commit
-m`/`pull --no-rebase --no-edit -X ours`/`push origin claude/phase-a-port`
sequence to use agent-scoped branch names and remove `-X ours` (or switch to
explicit rebase/merge conflict handling) to prevent nondeterministic overwrites.
- Around line 200-206: The reproduction command is hard-coded using the ternary
on u.sample.name (the `${u.sample.name.startsWith("e3") ? "test /tmp/e3/" :
"test /tmp/e1.test.ts"}` expression), so it may not re-run the actual failing
probe; update the workflow step in
.claude/workflows/phase-e-test-bringup.workflow.js to use the real recorded
probe command (use the probe/failure command field produced when the probe
failed — e.g. replace the ternary with the failing probe command variable such
as u.probe.command or u.sample.probeCommand, with a safe fallback to the current
expression) so the "After fix" rebuild actually re-runs the exact failing case.
- Around line 131-224: The workflow body must be moved into an exported async
entry function so the top-level return statements become valid; wrap the entire
loop and subsequent logic (the for loop that uses phase(), agent(), probes/fixes
calculation, and the final return) inside an exported async function (e.g.,
export async function runWorkflow() or export default async function main()) and
ensure any module-level constants/metadata remain outside; update any internal
references (phase, agent, MAX_ROUNDS, TARGET_RUNG, history, etc.) to use the
same scope and replace the top-level returns with returns from that function so
callers import/await the exported async function.

In @.claude/workflows/phase-f-accessor-sweep.workflow.js:
- Around line 106-114: The workflow's Step 3 is overprescriptive: it assumes
every pointer accessor returns &T and rewrites unsafe usages to self.x(), which
is incorrect for accessors that return Option<&T> or raw pointers; update the
guidance so that when Step 2 chooses Option<&T> or a raw-pointer accessor (e.g.
x() -> Option<&T>, x_ptr() -> *mut T) the caller-rewrite guidance uses the
corresponding safe pattern (handle Option via match/unwrap or map, and keep
raw-pointer uses as-is or document unsafe deref patterns) and only suggest
replacing unsafe { &*self.x } with self.x() for the specific case where the
accessor is defined as fn x(&self) -> &T; mention x_mut() and x_ptr() by name so
reviewers know which rewrite applies to which accessor shape.
- Around line 179-181: The code marks every surveyed struct as completed by
doing for (const s of survey.structs) done_structs.add(s.struct) before the
pipeline succeeds; move this mutation so structs are only added to done_structs
after the accessor/retry/fix pipeline actually succeeds. Remove or defer the
loop that adds to done_structs from this location and instead add each s.struct
to done_structs in the success branch of the pipeline handler (the code path
that confirms review accepted and fix step completed), leaving history.push({
round, structs: survey.structs.length, derefs_targeted: removed }) unchanged or
adjust it to reflect only attempted vs completed counts as needed. Ensure you
update any unit tests that check done_structs state to assert addition happens
only on pipeline success.

In @.claude/workflows/phase-f-test-swarm.workflow.js:
- Around line 64-77: The workflow uses the nonstandard runner
"./target/debug/bun-rs test ${area.glob}" for pass/fail and conflict resolution
which can diverge from repo expectations; replace all invocations of that exact
command with "bun bd test ${area.glob}" (and likewise use "bun bd test" wherever
./target/debug/bun-rs test is used in the loop or BUGHUNT steps) so decisions
use the repo's debug-test path, and keep the existing verification guidance
around USE_SYSTEM_BUN=1 bun test vs bun bd test; ensure the git pull/cherry-pick
loop still runs but bases its pass/fail checks on bun bd test rather than
./target/debug/bun-rs test.
- Line 122: The top-level return causes a parse error because this file is an ES
module; wrap the executable body into an exported async function (e.g., export
async function runPhaseTest()) that contains the phase() calls, the await
parallel(...) call, the for loop, and the final return statement; ensure the
function returns the object { areas: AREAS.length, all_pass: ok.length, partial:
partial.length, results, cherry } and keep references to AREAS, ok, partial,
results, cherry and calls to phase() and parallel() unchanged so callers can
invoke the exported function from the workflow runtime.

In @.claude/workflows/phase-g-mega-swarm.workflow.js:
- Line 113: The build-wait step currently reads global files
/tmp/mega-diag/.built and /tmp/mega-diag/.build.log which conflicts across
swarms; change the command to use the workflow's GDIAG namespace (replace
hardcoded /tmp/mega-diag paths with ${GDIAG}/.built and ${GDIAG}/.build.log) and
update the inline message string accordingly (the template using GDIAG and the
backtick command that contains `mkdir -p ${GDIAG}; for i in $(seq 1 60); do test
-f /tmp/mega-diag/.built && break; sleep 5; done; test -f /tmp/mega-diag/.built
&& tail -3 /tmp/mega-diag/.build.log`) so the sentinel and log are created/read
under ${GDIAG} instead of the global /tmp path.
- Line 163: The file contains top-level return statements inside a module-scope
loop (returning { rounds: round, done: true, passing: survey.passing, total:
survey.total, history }) which makes the module syntactically invalid; fix by
removing the top-level returns and either (A) wrap the loop and its logic in a
function (e.g., async function runPhaseGMegaSwarm() { ... } and return the
result from that function) or (B) build the result object (rounds/round, done,
passing, total, history) into a variable and export it (export const result =
...) instead of returning at module scope so the module parses as a valid ES
module. Ensure you update both places that currently use top-level return so
they follow the same approach.

In @.claude/workflows/phase-g-test-swarm-v3.workflow.js:
- Around line 201-206: The workflow currently replays hard-coded paths (/tmp/e3/
for e3* failures and /tmp/e1.test.ts for everything else) which skips many
failing cases; change the reproduction step to derive the replay path from the
actual failing probe identifier instead of those fallbacks—use the failing-test
variable/argument (the probe name, pattern or file provided by the test harness)
to construct the replay path and fall back only if that variable is empty, and
update the logic that checks for "e3*" / "e1" to instead use the real
failingProbe/failingTestPath value so cases like e1-fail, e1-hooks, e2-multi,
etc. are re-run.
- Around line 146-159: The shard slice is only described but never written to
disk or a variable, so the probe loop's "for f in $(cat your-slice)" has nothing
to read; materialize the slice before the probe by running the sed selection
used in the prompt and saving it (e.g., write the sed range expression from the
original selection into a file like ${DIAG}/your-slice or into a shell
variable), then have the probe loop iterate that concrete file/variable; update
references to "your files"/"your-slice" to the same identifier (e.g.,
${DIAG}/your-slice) so the for loop actually enumerates the selected test
filenames.

In @.claude/workflows/phase-g-test-swarm.workflow.js:
- Line 99: The module contains top-level return statements (e.g., the return
that produces { rounds: round, done: true, completing: survey.completing.length,
total: survey.total, history } and the other return near the end) which are
invalid in ES modules; wrap the logic that performs the loop and computes
rounds/round/history/survey into a function (sync or async) and return from that
function, or instead build the result into a local variable and export it (or
export an async function that returns it), ensuring all references to round,
rounds, survey, history now live inside that function body so no return occurs
at module scope.

In @.claude/workflows/phase-h-deep-dive.workflow.js:
- Line 91: The top-level "return { results: results.filter(Boolean) }" is
invalid in an ES module; move the return into a function that the workflow
runtime calls (for example wrap the workflow body in an exported async function
like runPhaseDeepDive or an exported handler function) so that the line becomes
a return inside that function; update any references to the workflow entrypoint
accordingly so the workflow runtime invokes the new function (look for the
top-level return and the symbol "results" to relocate into the new function).

In @.claude/workflows/phase-h-idioms-audit.workflow.js:
- Around line 144-183: The 2-vote unanimity is too strict: change the post-vote
logic around real_votes in the Verify phase (the code building the returned
object where verified is currently set using real_votes >= 2) to distinguish
"verified" (real_votes >= 2) from a new "disputed" state when real_votes === 1
(or add a status enum like verified|disputed|rejected). Update the returned
object produced in the pipeline promise (the block that computes real_votes,
votes, refutes, perf, better_fix) to keep verified: real_votes >= 2 and add
disputed: real_votes === 1 (or status: real_votes >=2 ? "verified" : real_votes
===1 ? "disputed" : "rejected") so a single-yes case is recorded rather than
silently treated as rejected.
- Around line 119-125: The key normalization for grouping patterns (variable
byPattern and computed key from f.pattern) is too aggressive—removing all
non-letters and truncating causes different phrases to collide; update the
normalization to preserve word boundaries and spaces before truncation (e.g.,
extract/keep word tokens, collapse multiple spaces, trim, then slice) so
distinct phrases like "*mut T fields with no invariant" and "*mut T fields with
no lifetime" remain distinct; apply this change where key is computed (the
expression using f.pattern.toLowerCase().replace(...).slice(...)) so downstream
grouping uses the word-preserving normalized key.
- Around line 218-222: Add an inline comment immediately above the git command
that uses "-c core.hooksPath=/dev/null" in
.claude/workflows/phase-h-idioms-audit.workflow.js explaining why hooks are
being bypassed for this automated workflow (e.g., to avoid local developer hooks
blocking CI-generated commits, ensure reproducible automation, and note that
this is intentional and reviewed); reference the exact command string "git -c
core.hooksPath=/dev/null add 'docs/' && git commit -q -m ..." so reviewers can
find it, and keep the comment concise and clear about the safety/approval
rationale and any intended exceptions or follow-up (no code changes beyond the
comment).
- Around line 86-91: The workflow hardcodes the repo path "/root/bun-5" inside
the agent prompt (and elsewhere); update the prompt construction to use a
configurable path instead (e.g., read from an environment variable like
process.env.REPO_DIR with a sensible default such as '.' or the repository root)
so the agent call in phase("Scan") and other agent prompts use that variable;
search for the string "/root/bun-5" referenced near the agent(...) calls and
replace with the variable/interpolated value so SCAN_S, TOP_N_CRATES and other
uses remain unchanged while the repo path becomes portable across environments.

In @.claude/workflows/porting-md-zigleakage.workflow.js:
- Around line 90-134: The comment and runtime log are out of sync with the SEED
array: SEED has three entries but the surrounding comment says "two user-flagged
issues" and the log formats "2 seed". Update the human-facing comment near the
SEED declaration to reflect three seeds (e.g., "three user-flagged issues" or
pluralize), and update the log call that prints `audit: ${all.length} raw
findings (incl. 2 seed)` to use the correct seeded count or pluralized wording
(e.g., "(incl. 3 seed)" or better "(incl. 3 seeds)"); locate and change the text
near the SEED constant and the log(...) call in this file to keep them
consistent.
- Around line 198-212: The module currently mixes a top-level export (export
const meta) with top-level await and a bare return; wrap all executable workflow
logic (the phase(...), agent(...) calls, any awaits and the final return object
that builds raw_findings/confirmed/by_dimension/refuted/patch/confirmed_detail)
inside an exported async function: add export default async function() { /* move
all code that uses await, phase(), agent(), and the return {...} into this
function */ } while keeping export const meta at module scope so the runtime can
import metadata and then invoke the default async function to execute the
workflow.

In `@bench/bundle/index.ts`:
- Around line 1-100: The file exports Three1..Three100 from non-existent
packages (Three1..Three100 symbols in bench/bundle/index.ts) while package.json
only provides "three"; change the exports to reference the actual installed
module instead of non-existent packages—e.g., re-export the real "three" module
for the intended number of copies (per README, export Ten aliases like
Three1..Three10 from 'three' and remove or stop exporting Three11..Three100), or
else add proper aliases/path mappings if you truly need distinct package names;
update bench/bundle/index.ts accordingly so all export statements import from
'three' (or match your tsconfig/packaging aliases).

In `@CLAUDE.md`:
- Line 1: Add a top-level H1 heading as the very first line of the file to
satisfy markdownlint MD041: insert a line like "# Bun" (or another appropriate
project title) above the existing body text so the file begins with an H1
header; ensure you update the first line in CLAUDE.md (where the current body
text starts) rather than appending the heading later.

In `@docs/BABYLIST_REPLACEMENT.md`:
- Line 3: The docs file BABYLIST_REPLACEMENT.md contains a host-specific repo
path `/root/bun-5`; replace that machine-local path with a repo-relative
reference (e.g., `.` or the repository name) and optionally include the commit
hash or tag for reproducibility, ensuring the shared document no longer contains
any absolute filesystem paths.
- Around line 39-41: Add a blank line immediately before the fenced Rust block
that contains the `pub type Arena = bumpalo::Bump;` (and related `pub type
MimallocArena` alias) so the fenced code block has a preceding empty line to
satisfy markdownlint MD031; ensure the fence keeps the language tag (```rust)
and the existing lines `pub type Arena = bumpalo::Bump;` and `pub type
MimallocArena = bumpalo::Bump;` remain unchanged.

In `@docs/CI_RUST_MIGRATION.md`:
- Around line 55-58: The fenced code block showing artifact paths (lines
containing "rust-target/<triple>/<profile>/libbun_rust.a.gz" and
"rust-target/<triple>/<profile>/bun_rust.lib") should include a language
specifier (e.g., "text") on the opening triple-backtick; update the block
delimiter from ``` to ```text so the example is treated as plain text in
rendered docs.

In `@docs/LAYERING_AUDIT.md`:
- Around line 30-324: The markdown headings in the audit section lack blank
lines before and after the level-3 headings (the lines starting with "###" such
as "### `src/bun_core/util.rs:1018` — `__bun_fd_path` / `__bun_fd_path_w`" and
all subsequent `###` entries), which triggers MD022; fix by inserting a single
blank line before and after each "### ..." heading throughout the document so
each heading is separated from surrounding paragraphs and lists, and re-run
markdownlint to verify the MD022 warnings are resolved.

In `@docs/rust-migration-spine.html`:
- Line 2: The root HTML element is missing a document language attribute; update
the root <html> element in the docs/rust-migration-spine.html file to include
lang="en" (i.e., change <html> to <html lang="en">) so assistive technologies
and the HTML linter recognize the document language.

In `@docs/rust-migration-tree.md`:
- Around line 59-64: The fenced code block containing the three file mapping
lines (starting with "src/http/HTTPThread.zig ←→ crates/bun_http/src/thread.rs"
and including "src/runtime/api/glob.zig ..." and "src/jsc/JSRef.zig ...") is
missing a language identifier; update the opening fence to include a language
(e.g., use ```text) so the block becomes a labeled fenced code block to enable
proper syntax highlighting and satisfy the markdownlint rule.

In `@docs/rust-rewrite-plan.md`:
- Around line 97-107: The fenced ASCII diagram blocks in
docs/rust-rewrite-plan.md (the block showing "C++ JSCell wrapper ... C-ABI
extern fns ... Native struct") and the other fenced blocks around lines 613-667
are missing a language tag and trigger MD040; update those backtick-fenced
blocks by adding a language identifier (e.g. text or rust) after the opening ```
so the diagram and any code sections (search for the ASCII diagram and other
```...``` blocks) are explicitly marked (e.g. ```text or ```rust) to satisfy
markdownlint and improve rendering.
- Around line 304-311: Several table cells contain literal pipe characters which
break the Markdown table; update the affected rows (the ones describing
hasPendingActivity, JSRef {weak, strong, finalized}, Strong / OptionalStrong,
JSValue.protect/unprotect, Async.KeepAlive / jsc.Ref, and MarkedArgumentBuffer)
to escape each '|' inside cell content (e.g., replace '|' with '\|' or use an
equivalent HTML entity) so the table keeps its column structure; ensure you edit
the exact cell texts that include `hasPendingActivity`, `JSRef {weak, strong,
finalized}`, `Strong`/`OptionalStrong`, `JSValue.protect`/`unprotect`,
`KeepAlive`/`ActiveTaskRef` and `MarkedArgumentBuffer` occurrences (and apply
the same fix to the other occurrences mentioned) without changing surrounding
formatting.

In `@docs/SPAWN_SYS_PROPOSAL.md`:
- Around line 24-76: The Markdown code fences in this file (the crate-graph
block that starts with "bun_libuv_sys  (already exists...)" and the Cargo deps
block that begins with "libc, bstr, scopeguard" / the ```toml snippet) need
explicit fence languages and surrounding blank lines; change the unlabeled
triple-backticks to labeled fences (e.g., ```text for the crate-graph/diagram
block and ```toml for the Cargo deps section) and ensure there is a blank line
before and after each fenced block so markdownlint and GitHub syntax
highlighting stop flagging them.

In `@docs/zig-restructure-plan.md`:
- Around line 1028-1030: The markdown table rows for `zigGeneratedClasses`,
`cxx`, and `c` contain raw pipe characters (e.g. the substitution snippets like
`s|src/bun.js/bindings|src/jsc/bindings|g`) that break the table; either escape
each `|` as `\|` inside those table cells or convert those rows into bullet list
items so the substitution strings aren’t parsed as column separators — update
the rows referencing `zigGeneratedClasses` (`:45`), `cxx` (`:79-95`) and `c`
(`:100-109`) (and the similar rows at 1042-1043) to use escaped pipes or bullets
consistently.
- Around line 944-945: The table and sections use inconsistent module paths
(e.g., references to bun.schema/bun.Semver pointing to ./api/schema.zig,
./api_schema/schema.zig, ./semver.zig and ./semver/semver.zig) which drift from
Section 2 that moves src/api/schema.zig into src/options_types/schema.zig; make
the document consistent by choosing one canonical directory layout (either
options_types or api_schema/cli_types) and update all occurrences of the symbols
`bun.schema` and `bun.Semver` and their file path examples to that layout, and
also update the other affected locations (the two additional occurrences noted)
so every reference points to the same directory names and filenames used in
Section 2.

In `@scripts/b2-aggregate-blocked.ts`:
- Line 6: Replace the hardcoded TASKS constant with a parameterized value: read
the tasks directory from a CLI argument or environment variable (e.g.,
process.argv or process.env.TASKS_DIR) in the script, validate that the path
exists and is a directory before scanning (fs.existsSync/fs.statSync or
fs.promises.stat), and if invalid log an error and exit; update any references
to the TASKS constant to use the new variable so the script works across
different runs/sessions.

In `@scripts/b2-cycle-args.ts`:
- Around line 92-97: The loop currently calls readdirSync("src") and join("src",
d) which assume the current working directory is the repo root; resolve the
repository src directory once to an absolute path and use that everywhere
instead. Create a resolvedSrc (or similar) using path.resolve/path.join with
__dirname to point at the repo's src directory, call readdirSync(resolvedSrc),
use statSync(path.join(resolvedSrc, d)) and pass absolute paths into gateCount
and any other functions (instead of join("src", d)) so the script works
regardless of the caller's CWD; keep KEYSTONE, crates push, and tier(d) logic
unchanged except to pass the correct path or basename as needed.

In `@scripts/build/bun.ts`:
- Around line 877-890: The escape detection fails on Windows when realBuild is
on a different drive because relative(realCwd, realBuild) can return an absolute
path; update the escapes calculation to treat an absolute rel as escaping (i.e.,
make escapes true when rel startsWith("..") or rel === "" or
path.isAbsolute(rel)). Modify the code around realpathSync/relative (variables
rel and escapes) and ensure you reference/import path.isAbsolute (the Node
path.isAbsolute function) so the assert still correctly detects symlinked build
directories outside the source tree.

In `@scripts/build/codegen.ts`:
- Around line 600-635: The filter building rsInputs in emitHostExports fails on
Windows because it compares absolute paths with platform backslashes; change the
filter on sources.rust to normalize each input to a forward-slash relative path
(e.g. path.relative(cfg.cwd, p).replace(/\\/g, "/")) and then test that relative
path startsWith "src/runtime/" or "src/jsc/" and does not end with
"generated_host_exports.rs" (keep the existing exclusion). Update references to
the test from p.includes(...) to use the normalized relative path so rsInputs is
populated correctly on Windows.

In `@scripts/build/rust.ts`:
- Around line 105-116: The array allRustTargets is missing the two triples that
rustTarget() and rustCanCrossFromLinux() treat as supported, causing
rust:check-all to skip them; update the allRustTargets constant to include
"x86_64-linux-android" and "aarch64-unknown-freebsd" (in the same format as the
other entries) so it matches the supported triples modelled by rustTarget() and
rustCanCrossFromLinux().
- Around line 333-337: The CARGO_TARGET_<TRIPLE>_LINKER env var is always set to
cfg.cxx but for Windows MSVC targets it must use cfg.msvcLinker to avoid
PATH-based linker resolution; update the code that builds the env object (the
entry that sets `CARGO_TARGET_${triple.toUpperCase().replace(/-/g,
"_")}_LINKER`) to choose cfg.msvcLinker when cfg.windows is true and
cfg.msvcLinker is defined, otherwise fall back to cfg.cxx (refer to symbols:
`cfg.msvcLinker`, `cfg.cxx`, `triple`, and the `CARGO_TARGET_${...}_LINKER`
key); follow the same guard logic used in source.ts to ensure Windows MSVC cargo
invocations use the explicit msvc linker.

In `@scripts/build/stream.ts`:
- Around line 40-45: The usage/help string emitted by scripts/build/stream.ts is
missing the documented flags --console and --stamp=PATH; update the script's
usage text (the runtime usage string printed by the help/usage routine in
stream.ts) to include both --console and --stamp=PATH so the built-in help
matches the comment block, and ensure any code paths that format or validate
arguments reference the same canonical usage string to keep them in sync.

In `@scripts/build/tools.ts`:
- Around line 562-594: The code in findRustLld currently chooses the LLD wrapper
based on the build-machine os parameter, which breaks cross-compilation; change
the selection to inspect the extracted hostTriple (from vv.match) instead of os
when constructing candidate, e.g. detect Windows/Darwin/Linux by checking
hostTriple for substrings like "windows" or "darwin" (and fall back to os only
if hostTriple is missing/ambiguous), and then pick "rust-lld.exe",
"gcc-ld/ld64.lld" or "gcc-ld/ld.lld" accordingly so the search in bin uses the
target host triple rather than the local os.

In `@scripts/fix-imports.ts`:
- Around line 11-12: The script currently uses process.cwd() via the root
variable which makes git pathspecs and readFileSync/writeFileSync calls relative
to the current working directory; replace that by resolving the repository root
once (via running git rev-parse --show-toplevel) into a repoRoot constant and
use path.resolve(repoRoot, ...) for all file I/O and any git pathspecs. Update
the declaration of root to repoRoot, change every readFileSync/writeFileSync and
any child_process/git invocation that previously used root or relative paths
(the code around the current root usage and the blocks that read/write files) to
build absolute paths from repoRoot, and ensure the git command is executed from
repoRoot (or passed the resolved path) so discovery and writes are anchored to
the repository root.

In `@scripts/fix-mod-paths.ts`:
- Around line 47-49: The script currently uses a relative target
(process.argv[2] || "src") which is resolved from the caller's cwd; instead,
compute an absolute path anchored at the repository root and pass that into
walk() so file mutations always operate on the repo tree. Replace how target is
computed by resolving the repo root (e.g., based on __dirname or known repo
layout) and join/resolve the user argument or default "src" into an absolute
path, then call walk(absTarget) and keep the final console message using the
same absolute/normalized target and the fixed counter.

In `@scripts/fix-vm-import.ts`:
- Around line 10-13: The cleanup loop that builds the Glob list omits the
valkey_jsc tree, so misplaced or duplicate imports like "use
bun_jsc::virtual_machine::VirtualMachine;" in that tree are never visited;
update the array of Glob patterns used with Glob(...).scanSync by adding the
pattern "src/valkey_jsc/**/*.rs" alongside the existing "src/runtime/**/*.rs"
and "src/sql_jsc/**/*.rs" entries so the scanSync loop covers that directory and
removes the stray imports.

In `@scripts/gen-cargo.ts`:
- Around line 164-167: The generated lib.rs template in scripts/gen-cargo.ts
currently inserts a blanket allow line (`#![allow(unused, non_snake_case,
non_camel_case_types, non_upper_case_globals, clippy::all)]`) which should be
annotated for future cleanup; update the template string (the multi-line string
that contains the allow attributes and the `${mods.map(...).join("\n")}`
insertion) to include a clear TODO comment such as `// TODO(port): remove
blanket allows in Phase B` immediately above or on the same line as the allow
attribute so the generated files explicitly document that these suppressions are
temporary and must be removed in Phase B.

In `@scripts/gen-link-stubs.sh`:
- Around line 24-29: The script gen-link-stubs.sh currently pipes grep on
"$UNDEFS" and treats “file missing” as no matches; add an explicit
existence/readability check for the UNDEFS input before the pipeline (e.g. test
-f or -r "$UNDEFS") and if the file is absent, emit a clear error message and
exit nonzero so the failure is visible; update the block that builds the tmp
file (the pipeline referencing CAT1, UNDEFS, and the grep filters for JS2Zig__,
Sink__, bindgen_) to rely on an ensured UNDEFS file or short-circuit early with
the error, so you no longer silently ignore a missing UNDEFS.
- Line 67: The script currently writes generated Rust directly to "$OUT" (the
command ending with ' "$tmp" > "$OUT"'), which can leave a partial file if
interrupted; change the flow to write into a safe temporary file (use "$tmp" or
create one with mktemp), ensure successful generation and any required
validations complete, then atomically replace "$OUT" by moving the temp file
into place with mv -f "$tmp" "$OUT" (and clean up the temp on errors); update
gen-link-stubs.sh to follow this write-then-mv pattern so link_stubs.rs is
replaced atomically.
- Line 19: The OUT assignment uses dirname "$0" which can be wrong depending on
invocation; change the OUT default assignment (variable OUT) to resolve script
path via ${BASH_SOURCE[0]} and canonicalize it (e.g., resolve to an absolute
path once) before appending /../src/runtime/link_stubs.rs so all file operations
use an absolute, canonical path; update the assignment that sets OUT to use
BASH_SOURCE[0] and a canonicalization step instead of dirname "$0".

In `@scripts/migrate-hostfn-vm.ts`:
- Around line 189-195: The current import-detection uses a too-broad regex (/use
.*VirtualMachine/) and can false-match or miss multiline/grouped imports; update
the check in scripts/migrate-hostfn-vm.ts to mirror scripts/fix-vm-import.ts by
testing for a whole-word, semicolon-terminated import of VirtualMachine (e.g.,
ensure the pattern matches strings like "use ...::VirtualMachine;" and accounts
for grouped imports or exact word boundaries) before deciding to inject use
bun_jsc::virtual_machine::VirtualMachine; so you neither skip needed injections
nor create duplicates; adjust the condition that examines txt (the file content)
and reuse the same regex logic/symbols (VirtualMachine, txt) as the referenced
script.

In `@scripts/patch-genclasses-paths.ts`:
- Around line 10-12: The GEN constant currently uses a relative path; change it
to an absolute path resolved from the repository root (derived from
import.meta.url / fileURLToPath and path.resolve or a repoRoot variable) and use
that absolute GEN for both the git show execSync call and
readFileSync/writeFileSync operations so the script works when invoked outside
the repo root; update the GEN declaration and the two usages (the execSync(`git
show HEAD:${GEN}`) call and readFileSync(GEN, ...)) to reference the resolved
absolute path.

In `@scripts/port-batch.ts`:
- Around line 8-14: The code calls readFileSync to build the manifest (const
manifest = readFileSync(...).trim().split(...)) without checking for the file's
existence or catching errors; wrap the readFileSync call in a guard (e.g., check
fs.existsSync("/tmp/port-manifest-filtered.tsv") or try/catch around
readFileSync) and, if the file is missing or reading fails, log a clear error
message including the path and error details (use processLogger or
console.error) and terminate (e.g., process.exit(1)) before attempting to parse
into manifest.

In `@scripts/port-cycle.sh`:
- Around line 14-17: The script currently counts and commits the entire index;
restrict both the staged-file count and the commit to only the Rust pathspecs by
changing the N computation to list cached names filtered to the intended
pathspecs (e.g., use git diff --cached --name-only -- 'src/**/*.rs' 'src/*.rs'
when computing N) and invoke git commit with those same pathspecs (e.g., git
commit ... -- 'src/**/*.rs' 'src/*.rs') so only the files the script manages are
counted and committed; update the commands in scripts/port-cycle.sh where N is
set and where git commit is invoked.
- Around line 4-12: The script currently can continue on errors (missing
/tmp/port-results, cd, cp, printf) because it lacks errexit; update the top of
the script to fail fast (add -e to the existing set -uo pipefail) and ensure the
artifact dir exists before use by creating /tmp/port-results (mkdir -p
/tmp/port-results) and aborting if that fails; also ensure critical commands (cd
"$(dirname "$0")/..", command cp -f "$OUT" "/tmp/port-results/$NAME.json", and
the printf append to /tmp/port-results/log.tsv) are allowed to propagate errors
(rely on set -e) or explicitly check their exit codes so the script stops on
failure, referencing variables NAME, OUT and the target files
/tmp/port-results/$NAME.json and /tmp/port-results/log.tsv.

In `@scripts/rust-check-all.ts`:
- Around line 25-27: The rustup output split leaves CRLFs on Windows so entries
can have trailing "\r" and never match `triple`; update the check around the
`installed` result (the `spawnSync("rustup", ["target", "list", "--installed"],
...)` call and the subsequent `installed.stdout.split("\n").includes(triple)`
logic) to normalize line endings and whitespace before matching (e.g., split on
CRLF-aware pattern or map and trim each entry) so `triple` is matched correctly
across platforms.

In `@scripts/test-scoreboard.sh`:
- Around line 43-56: The script runs the xargs loop over "$DIAG/all.txt" without
guarding the zero-case which can produce misleading output; before invoking the
xargs blocks (the pipelines that read "$DIAG/all.txt" and append to
"$DIAG/results.txt"), add an explicit check for an empty or non-existent
"$DIAG/all.txt" (e.g. test -s or wc -l) and short-circuit: write a clear
empty-results / zero-tests marker to "$DIAG/results.txt" (or exit 0) and skip
the xargs/unshare/timeout invocation (the block that executes '"$BIN"' test "{}"
> '"$DIAG"'/"$slug".log and its duplicate later) so totals and reporting aren’t
produced when there are no tests to run.
- Line 5: Replace the hardcoded "cd /root/bun-5" with robust repo-root detection
and ensure the script exits on failure: compute REPO_ROOT (e.g. via git
rev-parse --show-toplevel || realpath "$(dirname "$0")/.."), then run cd
"$REPO_ROOT" || { echo "Failed to change directory to $REPO_ROOT" >&2; exit 1;
}; also enable failing on errors if not already (add set -e or use set -euo
pipefail) so a failed cd stops execution.
- Around line 29-30: The pids.max cgroup is being set to "max" which disables
PID limiting and contradicts the fork-bomb containment intent; change the write
to "$CG/pids.max" to a sensible numeric cap (e.g., 1024 or another value based
on expected load) instead of "max" so the script enforces a PID limit for the
cgroup (locate the echo that writes to "$CG/pids.max" and replace the "max"
token with the chosen numeric limit and optionally document the chosen value in
the comment).

In `@scripts/utils.mjs`:
- Around line 2665-2669: The blank-line sentinel passed to readUntil is matching
immediately because originalLines contains newline tokens; replace the call
readUntil(/^$/, 30) with a pattern that matches an actual newline token (for
example readUntil(/^\r?\n$/, 30)) so the function readUntil will consume the
full rustc diagnostic body (span, caret, help/note lines) after the --> line
(see the surrounding use of readUntil and locMatch to locate the diagnostic
header).

In `@src/aio/ParentDeathWatchdog.rs`:
- Line 568: The cast from info.pbi_ppid (u32) to libc::pid_t currently uses
libc::pid_t::try_from(info.pbi_ppid).expect("int cast"), which can panic on
overflow; change it to safely handle overflow by using .unwrap_or(0) (or
equivalent non-panicking fallback) so an out-of-range parent PID becomes 0
instead of causing a panic—update the expression around info.pbi_ppid /
libc::pid_t::try_from in ParentDeathWatchdog.rs to use unwrap_or(0) and return
that value.
- Line 362: Replace the explicit to_visit.swap_remove(to_visit.len() - 1) with
to_visit.pop() and handle the returned Option; either use
to_visit.pop().expect("...")/unwrap() if empty is impossible in this context, or
match/if-let to handle None safely. This change targets the code that removes
the last element from the Vec (the variable to_visit) so update the call site
accordingly to use pop() and adjust for the Option return type.

---

Outside diff comments:
In `@docs/RUST_PATTERNS.md`:
- Around line 486-488: The file ends without a trailing newline; add a single
newline character at EOF to terminate the file. Open docs/RUST_PATTERNS.md,
locate the table rows such as the line containing "`ptr::read(self) to clone`"
(near the `*mut c_void` ctx callback / `ptr::read(self)` entries) and ensure the
file ends with a newline, then save and commit the change.

In `@src/aio/Cargo.toml`:
- Around line 1-28: The crate manifest for package "bun_aio" (Cargo.toml) is
missing the workspace lints opt-in so workspace-defined Clippy lints like
ptr_as_ptr won’t apply; update the Cargo.toml for the bun_aio crate by adding a
top-level [lints] table with workspace = true (i.e., add `[lints]` followed by
`workspace = true`) so the workspace lints declared in the root are opt‑in for
this member crate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

],
};

const REPO = "/root/bun-5";

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.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Hardcoded repository path is environment-specific.

/root/bun-5 assumes a specific container/CI environment. Consider accepting this as a workflow argument or deriving from an environment variable for portability.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/workflows/lifetime-classify.workflow.js at line 11, The REPO
constant is hardcoded to "/root/bun-5" making the workflow environment-specific;
change the code that defines REPO so it reads from a configurable source (e.g.,
process.env.REPO_PATH or a workflow input like inputs.repo) with a sensible
fallback and a clear warning/error if neither is provided. Specifically replace
the literal const REPO = "/root/bun-5"; with logic that checks
process.env.REPO_PATH || workflow input (e.g., inputs.repo) || a default, and
ensure any code that uses REPO still works; add validation/logging so missing
configuration fails fast or logs the chosen path.

Return ONLY fields that are pointer types. Skip [*]const u8 / []u8 (slices, handled separately).`,
{ label: `classify:${f.zig.replace(/^src\//, "")}`, phase: "Classify", schema: CLASSIFY_SCHEMA },
).then(r => (r ? r.fields.map(x => ({ ...x, file: f.zig, crate: f.crate })) : [])),

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Potential null reference if r.fields is undefined.

The check r ? guards against falsy r, but if the agent returns an object without fields (e.g., { error: "..." }), calling .map() on undefined will throw.

🛡️ Proposed fix
-  ).then(r => (r ? r.fields.map(x => ({ ...x, file: f.zig, crate: f.crate })) : [])),
+  ).then(r => (r?.fields ? r.fields.map(x => ({ ...x, file: f.zig, crate: f.crate })) : [])),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
).then(r => (r ? r.fields.map(x => ({ ...x, file: f.zig, crate: f.crate })) : [])),
).then(r => (r?.fields ? r.fields.map(x => ({ ...x, file: f.zig, crate: f.crate })) : [])),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/workflows/lifetime-classify.workflow.js at line 106, The current
then handler assumes r.fields exists and calls r.fields.map(...), which can
throw if r is an object without fields; update the handler that returns fields
for the promise (the arrow function using r and f.zig/f.crate) to
guard/normalize r.fields first — e.g., check Array.isArray(r.fields) or use
optional chaining/defaulting so you only call .map when r.fields is an array and
otherwise return an empty array; keep the mapping that spreads each field and
adds file: f.zig and crate: f.crate.

Comment thread .claude/workflows/lifetime-classify.workflow.js
Comment on lines +157 to +165
return {
total_fields: final.length,
by_class: byClass,
unknown_rate: ((byClass.UNKNOWN || 0) / final.length).toFixed(3),
verified_count: verified.length,
overturned: verified.filter(v => v.refutes >= 2).length,
tsv_preview: tsv.split("\n").slice(0, 20).join("\n"),
tsv,
};

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Division by zero when no fields are found.

If all files are processed but contain no pointer fields, final.length === 0 and unknown_rate becomes "NaN". The early return on line 13 only guards against empty FILES, not empty results.

Additionally, this top-level return has the same parse error as line 13.

🛡️ Proposed fix
 return {
   total_fields: final.length,
   by_class: byClass,
-  unknown_rate: ((byClass.UNKNOWN || 0) / final.length).toFixed(3),
+  unknown_rate: final.length > 0 ? ((byClass.UNKNOWN || 0) / final.length).toFixed(3) : "0.000",
   verified_count: verified.length,
   overturned: verified.filter(v => v.refutes >= 2).length,
   tsv_preview: tsv.split("\n").slice(0, 20).join("\n"),
   tsv,
 };
🧰 Tools
🪛 Biome (2.4.14)

[error] 157-165: Illegal return statement outside of a function

(parse)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/workflows/lifetime-classify.workflow.js around lines 157 - 165, The
return block can produce NaN when final.length === 0 and repeats the same unsafe
toFixed usage noted earlier; change the computation of unknown_rate to guard
against division-by-zero by computing unknown_rate = final.length === 0 ?
"0.000" : ((byClass.UNKNOWN || 0) / final.length).toFixed(3), and ensure the
returned value is a string/number consistently; keep the rest of the fields
(total_fields, by_class, verified_count, overturned, tsv_preview, tsv) unchanged
and reference the variables final, byClass, unknown_rate, verified and
tsv_preview when applying the fix.

notes: { type: "string" },
},
};
const dir_for = c => ({ bun_str: "string", bun_core: "bun_core", bun_alloc: "bun_alloc" })[c] || c.replace(/^bun_/, "");

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.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Document or validate dir_for mapping assumptions.

The dir_for mapping includes special cases (bun_str→"string", bun_core→"bun_core", bun_alloc→"bun_alloc") and a fallback that strips bun_ prefix. This suggests the crate-to-directory mapping isn't uniform.

If an unexpected crate name is passed (e.g., one not matching these patterns), the fallback may produce an incorrect directory. Consider adding validation or a comment documenting which crates require special handling.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/workflows/phase-b2-fill.workflow.js at line 21, The dir_for helper
currently maps a few special crate names and otherwise strips a bun_ prefix,
which can produce incorrect directories for unexpected crate names; update
dir_for to validate inputs and document the mapping: add a short comment
explaining the special cases (bun_str→"string", bun_core, bun_alloc) and the
fallback behavior, and implement explicit validation in the dir_for function to
either (a) check the crate name against an allowed set of known crate keys and
throw or log an error when unknown, or (b) return a clearly marked fallback only
after confirming the name begins with "bun_"—use the existing dir_for identifier
to locate and change the mapping logic and add the comment nearby.

Comment thread scripts/port-batch.ts
Comment on lines +8 to +14
const manifest = readFileSync("/tmp/port-manifest-filtered.tsv", "utf8")
.trim()
.split("\n")
.map(l => {
const [zig, loc] = l.split("\t");
return { zig, loc: Number(loc) };
});

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Missing error handling for manifest file.

If /tmp/port-manifest-filtered.tsv doesn't exist, readFileSync will throw an unhandled exception. Consider adding a guard with a helpful error message.

🛡️ Proposed fix
+import { existsSync, readFileSync } from "node:fs";
+import { basename, dirname, join } from "node:path";
+
 const REPO = process.cwd();
+const MANIFEST_PATH = "/tmp/port-manifest-filtered.tsv";
+if (!existsSync(MANIFEST_PATH)) {
+  console.error(`error: ${MANIFEST_PATH} not found. Run port-manifest.sh first.`);
+  process.exit(1);
+}
-const manifest = readFileSync("/tmp/port-manifest-filtered.tsv", "utf8")
+const manifest = readFileSync(MANIFEST_PATH, "utf8")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/port-batch.ts` around lines 8 - 14, The code calls readFileSync to
build the manifest (const manifest = readFileSync(...).trim().split(...))
without checking for the file's existence or catching errors; wrap the
readFileSync call in a guard (e.g., check
fs.existsSync("/tmp/port-manifest-filtered.tsv") or try/catch around
readFileSync) and, if the file is missing or reading fails, log a clear error
message including the path and error details (use processLogger or
console.error) and terminate (e.g., process.exit(1)) before attempting to parse
into manifest.

# Full test-suite scoreboard. Runs all tests vs current build/debug/bun-debug,
# writes /tmp/SCOREBOARD.md. Idempotent; safe to re-run anytime.
set -uo pipefail
cd /root/bun-5

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden repo-root resolution and fail fast on cd failure.

Line 5 hardcodes /root/bun-5, and with set -uo pipefail (no -e) a failed cd won’t stop execution. The script can continue from the wrong working directory and produce invalid discovery/report output.

Suggested fix
-cd /root/bun-5
+REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || {
+  echo "[scoreboard] not inside a git repository" >&2
+  exit 1
+}
+cd "$REPO_ROOT" || {
+  echo "[scoreboard] failed to cd to repo root: $REPO_ROOT" >&2
+  exit 1
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cd /root/bun-5
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || {
echo "[scoreboard] not inside a git repository" >&2
exit 1
}
cd "$REPO_ROOT" || {
echo "[scoreboard] failed to cd to repo root: $REPO_ROOT" >&2
exit 1
}
🧰 Tools
🪛 Shellcheck (0.11.0)

[warning] 5-5: Use 'cd ... || exit' or 'cd ... || return' in case cd fails.

(SC2164)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test-scoreboard.sh` at line 5, Replace the hardcoded "cd /root/bun-5"
with robust repo-root detection and ensure the script exits on failure: compute
REPO_ROOT (e.g. via git rev-parse --show-toplevel || realpath "$(dirname
"$0")/.."), then run cd "$REPO_ROOT" || { echo "Failed to change directory to
$REPO_ROOT" >&2; exit 1; }; also enable failing on errors if not already (add
set -e or use set -euo pipefail) so a failed cd stops execution.

Comment on lines +29 to +30
echo 32G > "$CG/memory.max" 2>/dev/null || true
echo max > "$CG/pids.max" 2>/dev/null || true

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

PID limiting is currently disabled despite the safety comment.

Line 30 sets pids.max to max, which removes the PID cap entirely. That contradicts the “fork-bomb” containment goal in the comment above and weakens isolation.

Suggested fix
 echo 32G   > "$CG/memory.max"    2>/dev/null || true
-echo max   > "$CG/pids.max"      2>/dev/null || true
+echo 4096  > "$CG/pids.max"      2>/dev/null || true
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
echo 32G > "$CG/memory.max" 2>/dev/null || true
echo max > "$CG/pids.max" 2>/dev/null || true
echo 32G > "$CG/memory.max" 2>/dev/null || true
echo 4096 > "$CG/pids.max" 2>/dev/null || true
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test-scoreboard.sh` around lines 29 - 30, The pids.max cgroup is
being set to "max" which disables PID limiting and contradicts the fork-bomb
containment intent; change the write to "$CG/pids.max" to a sensible numeric cap
(e.g., 1024 or another value based on expected load) instead of "max" so the
script enforces a PID limit for the cgroup (locate the echo that writes to
"$CG/pids.max" and replace the "max" token with the chosen numeric limit and
optionally document the chosen value in the comment).

Comment on lines +43 to +56
cat "$DIAG/all.txt" | xargs -P 32 -I{} sh -c '
slug=$(echo "{}" | tr / _)
td='"$TMPROOT"'/"$slug"; mkdir -p "$td"
# PID namespace: test sees only its own children, so kill(-1)/pkill cannot
# touch the host. --mount-proc gives it a clean /proc. --kill-child reaps
# everything in the namespace on timeout.
TMPDIR="$td" TEMP="$td" TMP="$td" \
timeout --kill-after=5 15 \
unshare --pid --fork --mount-proc --kill-child \
'"$BIN"' test "{}" > '"$DIAG"'/"$slug".log 2>&1
rc=$?
rm -rf "$td"
echo "{}|$rc"
' >> "$DIAG/results.txt"

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add an explicit zero-test guard to avoid misleading runs/reporting.

When no files are discovered, execution/reporting is not explicitly guarded. This can produce misleading output depending on command behavior and makes totals fragile.

Suggested fix
 TOTAL=$(wc -l < "$DIAG/all.txt")
 echo "[scoreboard] running $TOTAL files (timeout 15s each, -P32, pid-ns isolated)..." >&2
+
+if [ "$TOTAL" -eq 0 ]; then
+  echo "[scoreboard] no test files discovered" >&2
+fi
@@
-cat "$DIAG/all.txt" | xargs -P 32 -I{} sh -c '
+if [ "$TOTAL" -gt 0 ]; then
+cat "$DIAG/all.txt" | xargs -P 32 -I{} sh -c '
@@
-' >> "$DIAG/results.txt"
+' >> "$DIAG/results.txt"
+fi
@@
-  awk -F'|' '
-    { n++; if($2==0)p++; else if($2==1)f++; else if($2==124)h++; else if($2>=128)c++; else o++ }
-    END{ printf "| **TOTAL** | **%d** | **%d** (%.0f%%) | **%d** | **%d** | **%d** | **%d** |\n", n, p, 100.0*p/n, f, h, c, o }
-  ' "$DIAG/results.txt"
+  awk -F'|' '
+    { n++; if($2==0)p++; else if($2==1)f++; else if($2==124)h++; else if($2>=128)c++; else o++ }
+    END{
+      pct = (n > 0) ? (100.0*p/n) : 0
+      printf "| **TOTAL** | **%d** | **%d** (%.0f%%) | **%d** | **%d** | **%d** | **%d** |\n", n, p, pct, f, h, c, o
+    }
+  ' "$DIAG/results.txt"

Also applies to: 76-79

🧰 Tools
🪛 Shellcheck (0.11.0)

[info] 43-45: Expressions don't expand in single quotes, use double quotes for that.

(SC2016)


[info] 45-52: Expressions don't expand in single quotes, use double quotes for that.

(SC2016)


[info] 52-56: Expressions don't expand in single quotes, use double quotes for that.

(SC2016)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test-scoreboard.sh` around lines 43 - 56, The script runs the xargs
loop over "$DIAG/all.txt" without guarding the zero-case which can produce
misleading output; before invoking the xargs blocks (the pipelines that read
"$DIAG/all.txt" and append to "$DIAG/results.txt"), add an explicit check for an
empty or non-existent "$DIAG/all.txt" (e.g. test -s or wc -l) and short-circuit:
write a clear empty-results / zero-tests marker to "$DIAG/results.txt" (or exit
0) and skip the xargs/unshare/timeout invocation (the block that executes
'"$BIN"' test "{}" > '"$DIAG"'/"$slug".log and its duplicate later) so totals
and reporting aren’t produced when there are no tests to run.

Comment thread scripts/utils.mjs
Comment on lines +2665 to +2669
const { match: locMatch } = readUntil(/-->\s+(.+?):(\d+):(\d+)/, 3);
// Swallow the diagnostic body up to the blank-line separator (rustc
// always emits one between diagnostics in the human format; cap at 30
// for `--message-format=short` which doesn't).
readUntil(/^$/, 30);

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Consume the full rustc diagnostic body.

Because originalLines still contains newline tokens, readUntil(/^$/, 30) matches the first \n immediately after the --> line and stops there. That means the annotation body loses the rendered span, caret, and help/note lines, which are the most useful part of the CI preview.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/utils.mjs` around lines 2665 - 2669, The blank-line sentinel passed
to readUntil is matching immediately because originalLines contains newline
tokens; replace the call readUntil(/^$/, 30) with a pattern that matches an
actual newline token (for example readUntil(/^\r?\n$/, 30)) so the function
readUntil will consume the full rustc diagnostic body (span, caret, help/note
lines) after the --> line (see the surrounding use of readUntil and locMatch to
locate the diagnostic header).

Comment thread scripts/build/codegen.ts
Comment on lines +609 to +615
const rsInputs = sources.rust.filter(
p =>
p.endsWith(".rs") &&
(p.includes(`${cfg.cwd}/src/runtime/`.replace(/\//g, "/")) ||
p.includes(`${cfg.cwd}/src/jsc/`.replace(/\//g, "/"))) &&
!p.endsWith("generated_host_exports.rs"),
);

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.

🔴 The .replace(/\//g, "/") calls here replace forward-slash with forward-slash — a complete no-op (presumably meant to be .replace(/\\/g, "/")). On a Windows host, cfg.cwd and sources.rust entries both use backslashes (from process.cwd() / path.resolve()), so the mixed-separator search string C:\repo\bun/src/runtime/ never matches and rsInputs is always empty — meaning the ninja edge for generated_host_exports.rs gets zero .rs implicit inputs and editing a host-fn .rs file won't trigger regeneration on incremental Windows builds. Both p and the search string need normalization, e.g. p.replace(/\\/g, "/").includes(...).

Extended reasoning...

What the bug is

In emitHostExports() (new code added by this PR), the rsInputs filter at lines 609–615 builds the list of .rs files that should be implicit inputs to the generated_host_exports.rs ninja edge. The filter checks whether each path p contains ${cfg.cwd}/src/runtime/ or ${cfg.cwd}/src/jsc/, with each search string passed through .replace(/\//g, "/"):

const rsInputs = sources.rust.filter(
  p =>
    p.endsWith(".rs") &&
    (p.includes(`${cfg.cwd}/src/runtime/`.replace(/\//g, "/")) ||
      p.includes(`${cfg.cwd}/src/jsc/`.replace(/\//g, "/"))) &&
    !p.endsWith("generated_host_exports.rs"),
);

The regex /\//g matches the forward-slash character and replaces it with… a forward-slash. This is provably a no-op — the .replace() call returns its input unchanged on every platform. The presence of the call shows the author intended to normalize path separators (almost certainly /\\/g"/" to convert Windows backslashes), but the regex was mistyped.

The code path that triggers it

On a Windows host, both sides of the .includes() comparison use native backslash separators, but in incompatible ways:

  1. cfg.cwd comes from findRepoRoot() (config.ts:987–995), which returns process.cwd() walked upward via path.join(). On Windows this yields e.g. C:\repo\bun.
  2. sources.rust entries are produced by files.push(resolve(root, normalized)) in glob-sources.ts:141, where resolve is node:path's resolve() — also native backslashes on Windows, e.g. C:\repo\bun\src\runtime\foo.rs.
  3. The template literal therefore produces a mixed-separator search string: C:\repo\bun + /src/runtime/ = C:\repo\bun/src/runtime/.
  4. The no-op .replace() does nothing to fix this.
  5. p.includes("C:\\repo\\bun/src/runtime/") against p = "C:\\repo\\bun\\src\\runtime\\foo.rs"never matches.

Result: rsInputs is an empty array on Windows, so the ninja build edge at line 617 gets implicitInputs: [].

Why nothing prevents it

There's no fallback. The only other inputs to this edge are the codegen script itself (inputs: [script]) and the directory stamp (orderOnlyInputs: [dirStamp]). Neither changes when a developer edits a #[host_fn] marker in src/runtime/*.rs or src/jsc/*.rs. On Linux/macOS the bug is invisible because both cfg.cwd and sources.rust already use forward slashes, so the no-op .replace() is harmless and .includes() matches correctly — but the dead .replace() call is still a clear signal that Windows normalization was intended and miswritten.

Step-by-step proof

  1. Windows host, repo at C:\b\bun.
  2. cfg.cwd = "C:\\b\\bun" (from process.cwd()).
  3. sources.rust contains "C:\\b\\bun\\src\\runtime\\jsc_hooks.rs" (from path.resolve(root, ...) in glob-sources.ts:141).
  4. Search string: `${cfg.cwd}/src/runtime/` = "C:\\b\\bun/src/runtime/".
  5. .replace(/\//g, "/") on that string → "C:\\b\\bun/src/runtime/" (unchanged).
  6. "C:\\b\\bun\\src\\runtime\\jsc_hooks.rs".includes("C:\\b\\bun/src/runtime/")false (the \src\runtime\ substring uses backslashes, the needle uses forward slashes).
  7. Filter returns []. Ninja edge has no .rs implicit inputs.
  8. Developer edits src/runtime/jsc_hooks.rs to add a #[host_fn]. Ninja sees no input changed → generated_host_exports.rs is not regenerated → stale codegen until a clean build or until the script itself changes.

Impact

Per getRustAgent() in .buildkite/ci.mjs, the Windows build-rust step runs natively on a Windows agent, so this affects Windows incremental builds both locally and in CI. Fresh/clean builds still work (the output doesn't exist yet, so ninja runs the rule regardless), which is why CI's typical clean-build won't catch it — but any incremental Windows workflow will silently use stale generated_host_exports.rs.

How to fix

Note that fixing only the search string is insufficient — p also has backslashes, so both sides need normalization. Simplest fix:

const rsInputs = sources.rust.filter(p => {
  const np = p.replace(/\\/g, "/");
  return (
    np.endsWith(".rs") &&
    (np.includes(`${cfg.cwd.replace(/\\/g, "/")}/src/runtime/`) ||
     np.includes(`${cfg.cwd.replace(/\\/g, "/")}/src/jsc/`)) &&
    !np.endsWith("generated_host_exports.rs")
  );
});

Or use path.sep in the search strings instead of hard-coded /.

Comment thread scripts/build/codegen.ts
Comment on lines 212 to +216
/** Outputs that zig `@embedFile`s or imports. */
zigInputs: string[];
rustInputs: string[];

/** Outputs that zig needs to exist but doesn't embed (debug bake runtime). */
zigOrderOnly: string[];
rustOrderOnly: string[];

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.

🟡 The fields were renamed zigInputsrustInputs and zigOrderOnlyrustOrderOnly (commit f8fd66c: "the build is Rust now"), but the per-field JSDoc on lines 212/215 still says "Outputs that zig @embedFiles or imports" and "Outputs that zig needs to exist but doesn't embed", and line ~632 in emitHostExports still says "both zig and the cargo edge consume" even though build.zig and scripts/build/zig.ts are deleted in this PR. Update these to reference Rust include!/include_bytes! and the cargo edge only.

Extended reasoning...

What the bug is

Commit f8fd66c in this PR renamed two fields on the CodegenOutputs interface in scripts/build/codegen.ts:

  • zigInputsrustInputs
  • zigOrderOnlyrustOrderOnly

The commit message is explicit about intent: "the build is Rust now; these are the include!d generated .rs files the cargo edge order-depends on, the 'zig' name was a leftover from before the port". The same commit updated several surrounding comments — the interface-level doc ("zig build" → "rust build"), the cppAll comment, and the bindgenV2Zig comment — but missed three:

  • Line 212: JSDoc on rustInputs still reads "Outputs that zig @embedFiles or imports — these are implicit deps of the zig build edge."
  • Line 215: JSDoc on rustOrderOnly still reads "Outputs that zig needs to exist but doesn't embed — these are order-only deps…"
  • Line ~632 (emitHostExports): "rustInputs is the implicit-dep list both zig and the cargo edge consume"

Why nothing prevents it

These are pure JSDoc/inline comments — neither TypeScript nor any tooling validates that the prose matches the field name or the build graph it describes. The rename was clearly done with a search-and-replace on the identifier; the doc strings on the very next lines didn't match the search term and were left behind.

Step-by-step proof

  1. Read scripts/build/codegen.ts:206 — the interface-level doc says "…the rust build (which then include!s/embeds them)". This line was updated by f8fd66c.
  2. Read line 212 — /** Outputs that zig @embedfiles or imports … */ immediately above rustInputs: string[];. The field name says Rust; the doc says Zig @embedFile (a Zig builtin that does not exist in Rust).
  3. Read line 215 — /** Outputs that zig needs to exist but doesn't embed … */ immediately above rustOrderOnly: string[];. Same mismatch.
  4. Read line ~632 in emitHostExports() — "rustInputs is the implicit-dep list both zig and the cargo edge consume". But this PR deletes build.zig (1152 lines, → /dev/null) and scripts/build/zig.ts, and rules.ts no longer registers any zig_build rule. There is no zig edge that could consume rustInputs.
  5. The diff confirms adjacent comments at lines 206, 232, and 247 were updated from "zig" to "rust" in this same commit, demonstrating the intent was a complete rename — these three were simply overlooked.

Impact

None at runtime — comments only. The cost is reader confusion: someone reading CodegenOutputs will see a field named rustInputs documented as "things zig @embedFiles", and will wonder whether there's still a zig consumer somewhere. There isn't. The line-632 comment is more actively misleading because it asserts a second consumer ("both zig and the cargo edge") that no longer exists.

How to fix

Three one-line edits:

/** Outputs that Rust `include!`s or `include_bytes!`s — these are *implicit deps* of the cargo build edge. */
rustInputs: string[];
/** Outputs that Rust needs to exist but doesn't embed — these are *order-only deps* of the cargo build edge. */
rustOrderOnly: string[];

and at line ~632:

// rustInputs is the implicit-dep list the cargo edge consumes — …

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review continued from previous batch...


**Your task list:**
1. Read \`${REPO}/docs/CYCLEBREAK.md\` — find section "### → \\\`${t.cyclebreak_key}\\\`" under "Per-target-crate move-in tasks". That lists \`from <crate>: <symbol>, ...\` entries.
2. \`grep -iE '\\b(${t.name}|${t.cyclebreak_key}|bun_${t.cyclebreak_key})\\b' /tmp/movein-skipped.txt\` — these are explicit "move-in pass must add X to ${t.name}" requests from move-out agents. Higher priority than CYCLEBREAK.md (more specific).

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find where movein-skipped.txt is created
rg -n 'movein-skipped' .claude/ scripts/

Repository: oven-sh/bun

Length of output: 351


🏁 Script executed:

cat -n .claude/workflows/phase-b0-movein.workflow.js | head -50

Repository: oven-sh/bun

Length of output: 3821


🏁 Script executed:

find .claude -type f -name "*.md" -o -name "*.txt" | head -20

Repository: oven-sh/bun

Length of output: 560


🏁 Script executed:

find .claude -name "*moveout*" -type f

Repository: oven-sh/bun

Length of output: 102


🏁 Script executed:

rg -n 'movein-skipped|tmp.*txt' .claude/workflows/ --context 3

Repository: oven-sh/bun

Length of output: 2189


🏁 Script executed:

cat -n .claude/workflows/phase-b0-moveout.workflow.js | grep -A 5 -B 5 'movein-skipped\|/tmp'

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

wc -l .claude/workflows/phase-b0-moveout.workflow.js

Repository: oven-sh/bun

Length of output: 105


🏁 Script executed:

cat -n .claude/workflows/phase-b0-moveout.workflow.js

Repository: oven-sh/bun

Length of output: 4387


🏁 Script executed:

find .claude -name "*.js" -o -name "*.md" | xargs grep -l "phase-b0-moveout\|phase-b0-movein" 2>/dev/null | head -10

Repository: oven-sh/bun

Length of output: 148


🏁 Script executed:

rg -n 'skipped' .claude/workflows/phase-b0-moveout.workflow.js

Repository: oven-sh/bun

Length of output: 474


Add validation or error handling for /tmp/movein-skipped.txt.

Line 36 references /tmp/movein-skipped.txt without any setup in this workflow or the preceding moveout phase. The moveout workflow collects items in a skipped array field, but no visible code transforms that into the temp file that movein expects.

If the file is missing, the grep silently produces no matches, and agents skip the "explicit requests from move-out agents" that are marked as higher priority than CYCLEBREAK.md. Either add a setup step that creates the file from moveout's skipped results, or add error handling (e.g., test -f /tmp/movein-skipped.txt || touch /tmp/movein-skipped.txt) to ensure the grep doesn't fail.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/workflows/phase-b0-movein.workflow.js at line 36, The workflow
references the grep against movein-skipped.txt but there's no producer for that
file; update the phase-b0 move-in workflow to either (A) add a setup step that
materializes the move-out `skipped` array into the expected movein-skipped file
(so the grep -iE '\b(${t.name}|${t.cyclebreak_key}|bun_${t.cyclebreak_key})\b'
has real data), or (B) add robust error-handling before the grep (ensure the
file exists or create an empty placeholder from the move-out `skipped` data, or
short-circuit the grep with a safe fallback) so explicit move-out requests
aren’t silently missed. Ensure you modify the step that runs the grep command
and/or the move-out producer that exposes the `skipped` array to produce the
expected artifact.

**Your task list:**
1. Read \`${REPO}/docs/CYCLEBREAK.md\` — find section "### → \\\`${t.cyclebreak_key}\\\`" under "Per-target-crate move-in tasks". That lists \`from <crate>: <symbol>, ...\` entries.
2. \`grep -iE '\\b(${t.name}|${t.cyclebreak_key}|bun_${t.cyclebreak_key})\\b' /tmp/movein-skipped.txt\` — these are explicit "move-in pass must add X to ${t.name}" requests from move-out agents. Higher priority than CYCLEBREAK.md (more specific).
3. Read \`${REPO}/docs/PORTING.md\` §Dispatch (lines ~377-440) and §Concurrency (lines ~352-376) for vtable/hook/OnceLock patterns.

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.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Hardcoded line ranges may become stale.

The prompt references specific line ranges in PORTING.md (lines ~377-440 and ~352-376). If PORTING.md is edited and sections move, these line numbers will become incorrect, causing agents to read the wrong sections or miss important guidance.

Consider either:

  • Using section headers (e.g., "§Dispatch", "§Concurrency") which are more stable
  • Adding a validation step to check that the referenced line ranges contain the expected section headers
  • Documenting that PORTING.md line numbers must be kept stable
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/workflows/phase-b0-movein.workflow.js at line 37, The workflow
currently references hardcoded line ranges in PORTING.md which can become stale;
update the workflow that reads PORTING.md to locate sections by header names
instead of fixed line ranges (search for the "§Dispatch" and "§Concurrency"
headers), or add a validation step that confirms the text found at the
referenced ranges contains the expected "§Dispatch" and "§Concurrency" headers
and fails with a clear error if not, or alternatively document that the
PORTING.md line numbers must remain stable—implement one of these fixes and
ensure the code uses the "§Dispatch" and "§Concurrency" markers to locate
content reliably.

const REPO = "/root/bun-5";
const CRATES = (args && args.crates) || []; // [{name, tier}]
if (!CRATES.length) return { error: "no crates" };
log(`B-1 tier ${CRATES[0].tier}: ${CRATES.length} crates`);

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how this workflow is invoked to confirm single-tier batching
rg -n -C5 'phase-b1-tier' .claude/ scripts/

Repository: oven-sh/bun

Length of output: 640


🏁 Script executed:

cat -n .claude/workflows/phase-b1-tier.workflow.js

Repository: oven-sh/bun

Length of output: 3072


🏁 Script executed:

rg -n 'CRATES\[0\]\.tier|\.tier' .claude/workflows/phase-b1-tier.workflow.js

Repository: oven-sh/bun

Length of output: 166


🏁 Script executed:

# Search for orchestration or calling patterns that control how this workflow receives crates
rg -n -B3 -A3 'phase-b1-tier' .claude/ | grep -E '(invoke|call|phase|tier)' | head -30

Repository: oven-sh/bun

Length of output: 519


🏁 Script executed:

# Search for files that reference or invoke phase-b1-tier
rg -l 'phase-b1-tier' .claude/ scripts/ --type-list | head -20

Repository: oven-sh/bun

Length of output: 445


🏁 Script executed:

# Look for workflow orchestration or phase definitions
fd -name '*.js' .claude/ | xargs rg -l 'phase.*tier|workflow' | head -10

Repository: oven-sh/bun

Length of output: 702


🏁 Script executed:

# Search for where crates are prepared/batched for this workflow
rg -n 'phase-b1|batch.*tier' .claude/ scripts/ -B2 -A2 | head -50

Repository: oven-sh/bun

Length of output: 470


🏁 Script executed:

# Find all workflow files
find .claude/workflows -name '*.js' -type f | head -20

Repository: oven-sh/bun

Length of output: 1026


🏁 Script executed:

# Search for references to phase-b1-tier in all workflow files
find .claude/workflows -name '*.js' -type f -exec grep -l 'phase-b1-tier' {} \;

Repository: oven-sh/bun

Length of output: 99


🏁 Script executed:

# Look for orchestration that might batch crates by tier
rg -i 'crate.*tier|batch.*tier|group.*tier' .claude/ -B2 -A2

Repository: oven-sh/bun

Length of output: 8779


🏁 Script executed:

# Look for main orchestration or invocation patterns
find .claude -name '*.js' -o -name '*.ts' -o -name '*.json' | grep -v workflows | head -20

Repository: oven-sh/bun

Length of output: 151


🏁 Script executed:

# Search for workflow invocation patterns
rg -i 'invoke|call|run.*workflow|phase-b1' .claude --type-list 2>/dev/null || rg -i 'invoke|call|run.*workflow|phase-b1' .claude 2>/dev/null | head -30

Repository: oven-sh/bun

Length of output: 6625


🏁 Script executed:

# Check if there's a main index or runner
ls -la .claude/ | head -20

Repository: oven-sh/bun

Length of output: 464


🏁 Script executed:

# Check commands and skills directories
ls -la .claude/commands/
ls -la .claude/skills/

Repository: oven-sh/bun

Length of output: 1109


🏁 Script executed:

# Search for phase-b1-tier invocation in commands and skills
find .claude/commands .claude/skills -type f -exec grep -l 'phase-b1-tier' {} \;

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Look for batching or tier grouping logic anywhere in .claude
rg -i 'batch|group.*crate|crate.*batch' .claude/commands .claude/skills 2>/dev/null | head -20

Repository: oven-sh/bun

Length of output: 37


Validate tier homogeneity assumption.

Lines 10 and 53 assume all crates in args.crates share the same tier value (using CRATES[0].tier). While the workflow description states "one tier at a time," the code doesn't enforce this constraint—it only assumes it. If mixed-tier input reaches this workflow, the log message and final output will report the wrong tier.

Note the inconsistency: individual results correctly preserve per-crate tier (line 47), but the batch output discards this information by assuming a single tier.

🛡️ Proposed validation check
 const CRATES = (args && args.crates) || []; // [{name, tier}]
 if (!CRATES.length) return { error: "no crates" };
+const tiers = [...new Set(CRATES.map(c => c.tier))];
+if (tiers.length > 1) return { error: `mixed tiers: ${tiers.join(", ")} - expected single tier` };
 log(`B-1 tier ${CRATES[0].tier}: ${CRATES.length} crates`);

Also applies to: 53-53

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/workflows/phase-b1-tier.workflow.js at line 10, The code assumes all
input crates share the same tier by using CRATES[0].tier for the batch log and
final output; add a validation in the beginning of the workflow to verify
homogeneity (e.g., compute a set of tiers from args.crates/CRATES and if its
size > 1 throw or normalize), and then use a safe value (the single tier) only
after validation; update references where CRATES[0].tier is used (the initial
log call and the final batch summary around line 53) to either compute the tier
from the validated set or to report that multiple tiers were received if you
choose to error instead.


const by_crate = {};
for (const b of BUGS) {
const crate = b.module.split("/")[0];

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how bug objects are created/passed to understand module field format
rg -n -C3 'module.*:' .claude/ scripts/ | rg -i 'bug|verify'

Repository: oven-sh/bun

Length of output: 5532


🏁 Script executed:

# Read the target file to understand context around line 23
cat -n .claude/workflows/phase-b2-fix-bugs.workflow.js | head -50

Repository: oven-sh/bun

Length of output: 2449


🏁 Script executed:

# Search for all places where module field is created/assigned
rg -n "module.*=" .claude/workflows/ -A2

Repository: oven-sh/bun

Length of output: 1752


🏁 Script executed:

# Check how phase-b2-fix-bugs.workflow.js receives input/bugs
rg -n "bugs\|inputs\|params" .claude/workflows/phase-b2-fix-bugs.workflow.js -B2 -A2

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Search for all places where bugs are created with module field
rg -n "module:" .claude/workflows/ | rg -v "Binary"

Repository: oven-sh/bun

Length of output: 683


🏁 Script executed:

# Search for places that invoke phase-b2-fix-bugs
rg -n "phase-b2-fix-bugs" .claude/workflows/

Repository: oven-sh/bun

Length of output: 134


Add validation for module field format to prevent silent grouping failures.

Line 23 assumes b.module follows the format "crate/..." when extracting the crate name via .split("/")[0]. While this format is consistently produced by phase-b2-verify.workflow.js, the code lacks validation. If a bug entry has a malformed module field (e.g., "crate::file" using a different delimiter, or an empty string), unexpected grouping behavior occurs. Add a format check or clear documentation of the expected format.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/workflows/phase-b2-fix-bugs.workflow.js at line 23, Validate
b.module before using b.module.split("/") to compute crate: ensure b.module is a
non-empty string and contains the expected "/" delimiter (or match a regex like
/^[^\/]+\/.+$/) and handle failures (e.g., log a warning and skip grouping,
throw, or default to a safe value) instead of blindly doing const crate =
b.module.split("/")[0]; update the code around the crate extraction to perform
the check, use the validated part when present, and add a clear log message
documenting the expected "crate/..." format when invalid.

const FOCUS = args.verify_focus || "logic correctness vs spec";
const SCOPE = (args.edit_scope || []).join(", ") || "the files you created";

if (!TASK) return { error: "args.task required" };

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.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does JavaScript allow a return statement at the top level of an ES module?

💡 Result:

No. A JavaScript return statement is not allowed at the top level of an ES module; it must appear inside a function body. Reasoning (spec + docs): The ECMAScript specification defines ReturnStatement as something that “causes a function to cease execution” and (in most cases) returns a value to the caller [1]. MDN similarly states that the return statement “can only be used within function bodies” [2] and documents the SyntaxError: return not in function that occurs when return is used outside a function [3]. Example that fails in module top-level code: - // in an ES module file\nreturn 1; This is invalid because there is no surrounding function to return from [3]. If you need to end module execution, use regular control flow in your own functions (e.g., wrap logic in an IIFE or exported function) rather than attempting return at the module’s top level.

Citations:


Top-level return statements will cause parse errors.

ES modules do not allow return statements outside function bodies. Wrap the executable code in an IIFE or function closure that the workflow runtime expects.

This applies to lines 17, 59, 87, and 103.

🧰 Tools
🪛 Biome (2.4.14)

[error] 17-17: Illegal return statement outside of a function

(parse)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/workflows/phase-b2-keystone.workflow.js at line 17, The file
contains top-level return statements (e.g., the check "if (!TASK) return {
error: 'args.task required' }") which are invalid in ES modules; wrap the
executable workflow code in a function or immediately-invoked async function
(IIFE) that the workflow runtime expects, move all top-level logic (including
checks at TASK and the other return sites referenced) into that function, and
replace those top-level "return" statements with returns from that function (or
throw errors/resolve values from the function) so no return appears at module
top-level; update the checks around the TASK variable and the other early-exit
points so they return from the newly created function scope.

Comment thread scripts/port-cycle.sh
Comment on lines +4 to +12
set -uo pipefail
NAME="$1"; OUT="$2"; NEXT="${3:-100}"
cd "$(dirname "$0")/.."

command cp -f "$OUT" "/tmp/port-results/$NAME.json"
STATS=$(jq -r '.result | "\(.total)\t\(.clean)\t\(.fixed)\t\([.results[]|select(.==null)]|length)\t\(.by_confidence.high)\t\(.by_confidence.medium)\t\(.by_confidence.low)"' "$OUT" 2>/dev/null || echo "? ? ? ? ? ? ?")
DUR=$(jq -r '.usage.duration_ms // "?"' "$OUT" 2>/dev/null)
AG=$(jq -r '.usage.agent_count // "?"' "$OUT" 2>/dev/null)
printf "%s\t%s\t%s\t%s\n" "$NAME" "$STATS" "$DUR" "$AG" >> /tmp/port-results/log.tsv

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast before archiving and logging.

cd, cp, and the append to /tmp/port-results/log.tsv can all fail here without stopping the script. On a fresh runner, a missing /tmp/port-results silently drops the batch artifact/log and the script still proceeds to commit/push.

Suggested hardening
-set -uo pipefail
+set -euo pipefail
 NAME="$1"; OUT="$2"; NEXT="${3:-100}"
-cd "$(dirname "$0")/.."
+cd "$(dirname "$0")/.." || exit 1
+mkdir -p /tmp/port-results
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
set -uo pipefail
NAME="$1"; OUT="$2"; NEXT="${3:-100}"
cd "$(dirname "$0")/.."
command cp -f "$OUT" "/tmp/port-results/$NAME.json"
STATS=$(jq -r '.result | "\(.total)\t\(.clean)\t\(.fixed)\t\([.results[]|select(.==null)]|length)\t\(.by_confidence.high)\t\(.by_confidence.medium)\t\(.by_confidence.low)"' "$OUT" 2>/dev/null || echo "? ? ? ? ? ? ?")
DUR=$(jq -r '.usage.duration_ms // "?"' "$OUT" 2>/dev/null)
AG=$(jq -r '.usage.agent_count // "?"' "$OUT" 2>/dev/null)
printf "%s\t%s\t%s\t%s\n" "$NAME" "$STATS" "$DUR" "$AG" >> /tmp/port-results/log.tsv
set -euo pipefail
NAME="$1"; OUT="$2"; NEXT="${3:-100}"
cd "$(dirname "$0")/.." || exit 1
mkdir -p /tmp/port-results
command cp -f "$OUT" "/tmp/port-results/$NAME.json"
STATS=$(jq -r '.result | "\(.total)\t\(.clean)\t\(.fixed)\t\([.results[]|select(.==null)]|length)\t\(.by_confidence.high)\t\(.by_confidence.medium)\t\(.by_confidence.low)"' "$OUT" 2>/dev/null || echo "? ? ? ? ? ? ?")
DUR=$(jq -r '.usage.duration_ms // "?"' "$OUT" 2>/dev/null)
AG=$(jq -r '.usage.agent_count // "?"' "$OUT" 2>/dev/null)
printf "%s\t%s\t%s\t%s\n" "$NAME" "$STATS" "$DUR" "$AG" >> /tmp/port-results/log.tsv
🧰 Tools
🪛 Shellcheck (0.11.0)

[warning] 6-6: Use 'cd ... || exit' or 'cd ... || return' in case cd fails.

(SC2164)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/port-cycle.sh` around lines 4 - 12, The script currently can continue
on errors (missing /tmp/port-results, cd, cp, printf) because it lacks errexit;
update the top of the script to fail fast (add -e to the existing set -uo
pipefail) and ensure the artifact dir exists before use by creating
/tmp/port-results (mkdir -p /tmp/port-results) and aborting if that fails; also
ensure critical commands (cd "$(dirname "$0")/..", command cp -f "$OUT"
"/tmp/port-results/$NAME.json", and the printf append to
/tmp/port-results/log.tsv) are allowed to propagate errors (rely on set -e) or
explicitly check their exit codes so the script stops on failure, referencing
variables NAME, OUT and the target files /tmp/port-results/$NAME.json and
/tmp/port-results/log.tsv.

Comment thread scripts/port-cycle.sh
Comment on lines +14 to +17
git -c core.hooksPath=/dev/null add 'src/**/*.rs' src/*.rs 2>/dev/null
N=$(git diff --cached --name-only | wc -l)
if [ "$N" -gt 0 ]; then
git -c core.hooksPath=/dev/null commit -q -m "phase-a: draft batch $NAME ($N files)"

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Commit only the Rust paths this script stages.

git diff --cached --name-only and git commit are both operating on the whole index, so any unrelated staged files get swept into the draft batch commit. This script should count and commit only the src/*.rs paths it owns.

Scope the staged-count and commit to the intended paths
-git -c core.hooksPath=/dev/null add 'src/**/*.rs' src/*.rs 2>/dev/null
-N=$(git diff --cached --name-only | wc -l)
+git -c core.hooksPath=/dev/null add -- 'src/**/*.rs' 'src/*.rs' 2>/dev/null
+N=$(git diff --cached --name-only -- 'src/**/*.rs' 'src/*.rs' | wc -l)
 if [ "$N" -gt 0 ]; then
-  git -c core.hooksPath=/dev/null commit -q -m "phase-a: draft batch $NAME ($N files)"
+  git -c core.hooksPath=/dev/null commit -q -m "phase-a: draft batch $NAME ($N files)" -- 'src/**/*.rs' 'src/*.rs'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/port-cycle.sh` around lines 14 - 17, The script currently counts and
commits the entire index; restrict both the staged-file count and the commit to
only the Rust pathspecs by changing the N computation to list cached names
filtered to the intended pathspecs (e.g., use git diff --cached --name-only --
'src/**/*.rs' 'src/*.rs' when computing N) and invoke git commit with those same
pathspecs (e.g., git commit ... -- 'src/**/*.rs' 'src/*.rs') so only the files
the script manages are counted and committed; update the commands in
scripts/port-cycle.sh where N is set and where git commit is invoked.

Comment thread scripts/rust-check-all.ts
Comment on lines +25 to +27
const installed = spawnSync("rustup", ["target", "list", "--installed"], { encoding: "utf8" });
if (installed.status === 0 && !installed.stdout.split("\n").includes(triple)) {
console.log(`\x1b[2m[skip]\x1b[0m ${triple} (rustup target add ${triple})`);

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize rustup output before matching targets.

On Windows, rustup target list --installed is CRLF-delimited, so split("\n").includes(triple) leaves a trailing \r on each entry and falsely marks installed targets as missing. That makes this script skip every installed target on Windows.

Proposed fix
-  const installed = spawnSync("rustup", ["target", "list", "--installed"], { encoding: "utf8" });
-  if (installed.status === 0 && !installed.stdout.split("\n").includes(triple)) {
+  const installed = spawnSync("rustup", ["target", "list", "--installed"], { encoding: "utf8" });
+  const installedTargets = new Set(
+    installed.stdout
+      .split(/\r?\n/)
+      .map(line => line.trim())
+      .filter(Boolean),
+  );
+  if (installed.status === 0 && !installedTargets.has(triple)) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const installed = spawnSync("rustup", ["target", "list", "--installed"], { encoding: "utf8" });
if (installed.status === 0 && !installed.stdout.split("\n").includes(triple)) {
console.log(`\x1b[2m[skip]\x1b[0m ${triple} (rustup target add ${triple})`);
const installed = spawnSync("rustup", ["target", "list", "--installed"], { encoding: "utf8" });
const installedTargets = new Set(
installed.stdout
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean),
);
if (installed.status === 0 && !installedTargets.has(triple)) {
console.log(`\x1b[2m[skip]\x1b[0m ${triple} (rustup target add ${triple})`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/rust-check-all.ts` around lines 25 - 27, The rustup output split
leaves CRLFs on Windows so entries can have trailing "\r" and never match
`triple`; update the check around the `installed` result (the
`spawnSync("rustup", ["target", "list", "--installed"], ...)` call and the
subsequent `installed.stdout.split("\n").includes(triple)` logic) to normalize
line endings and whitespace before matching (e.g., split on CRLF-aware pattern
or map and trim each entry) so `triple` is matched correctly across platforms.

let mut buf: [libc::pid_t; 4096] = [0; 4096];
// Hard cap on tree size so a fork bomb under us can't make exit hang.
while !to_visit.is_empty() && to_kill.len() < 4096 {
let parent = to_visit.swap_remove(to_visit.len() - 1);

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.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider using pop() instead of swap_remove(len - 1).

Vec::pop() is clearer and accomplishes the same thing:

♻️ Suggested simplification
-        while !to_visit.is_empty() && to_kill.len() < 4096 {
-            let parent = to_visit.swap_remove(to_visit.len() - 1);
+        while let Some(parent) = to_visit.pop() {
+            if to_kill.len() >= 4096 {
+                break;
+            }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/aio/ParentDeathWatchdog.rs` at line 362, Replace the explicit
to_visit.swap_remove(to_visit.len() - 1) with to_visit.pop() and handle the
returned Option; either use to_visit.pop().expect("...")/unwrap() if empty is
impossible in this context, or match/if-let to handle None safely. This change
targets the code that removes the last element from the Vec (the variable
to_visit) so update the call site accordingly to use pop() and adjust for the
Option return type.

if rc != size {
return 0;
}
return libc::pid_t::try_from(info.pbi_ppid).expect("int cast");

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Potential overflow casting pbi_ppid (u32) to pid_t (i32).

info.pbi_ppid is a u32, and pid_t is typically i32 on macOS. If pbi_ppid > i32::MAX (unlikely in practice, but possible), try_from().expect() will panic. Consider using unwrap_or(0) to treat overflow as "no parent" rather than panicking in an exit handler.

🛡️ Proposed fix
-            return libc::pid_t::try_from(info.pbi_ppid).expect("int cast");
+            return libc::pid_t::try_from(info.pbi_ppid).unwrap_or(0);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return libc::pid_t::try_from(info.pbi_ppid).expect("int cast");
return libc::pid_t::try_from(info.pbi_ppid).unwrap_or(0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/aio/ParentDeathWatchdog.rs` at line 568, The cast from info.pbi_ppid
(u32) to libc::pid_t currently uses
libc::pid_t::try_from(info.pbi_ppid).expect("int cast"), which can panic on
overflow; change it to safely handle overflow by using .unwrap_or(0) (or
equivalent non-panicking fallback) so an out-of-range parent PID becomes 0
instead of causing a panic—update the expression around info.pbi_ppid /
libc::pid_t::try_from in ParentDeathWatchdog.rs to use unwrap_or(0) and return
that value.

Comment thread scripts/build/profiles.ts
Comment on lines +110 to +112
* Release + assertions + logs. RelWithDebInfo → cargo `release` profile
* with `debug-assertions = true` (runtime safety checks), matching the
* old cmake build:assert script.

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.

🔴 The new comment says "cargo release profile with debug-assertions = true", but nothing actually sets that: cargoProfile() (rust.ts:68-69) maps RelWithDebInfo to plain release, [profile.release] in Cargo.toml doesn't set debug-assertions, and emitRust() never passes CARGO_PROFILE_RELEASE_DEBUG_ASSERTIONScfg.assertions only sets the BUN_RELEASE_SAFE env var (rust.ts:385), which drives bun's custom ALLOW_ASSERT const, not cfg!(debug_assertions)/debug_assert!(). Either fix the comment to describe BUN_RELEASE_SAFE/ALLOW_ASSERT, or (to match the profile's name and the old Zig ReleaseSafe behavior) have emitRust() set CARGO_PROFILE_RELEASE_DEBUG_ASSERTIONS=true when cfg.assertions is set.

Extended reasoning...

What the bug is

This PR rewrote the doc comment on the release-assertions profile in scripts/build/profiles.ts:110-112 from the accurate

Release + assertions + logs. RelWithDebInfo → zig gets ReleaseSafe

to

Release + assertions + logs. RelWithDebInfo → cargo release profile with debug-assertions = true (runtime safety checks), matching the old cmake build:assert script.

The new comment makes a specific, technically false claim: nothing in the build sets cargo's debug-assertions = true for this profile.

The code path

  1. cargoProfile() at scripts/build/rust.ts:68-69 maps every non-Debug buildType to the plain release profile:

    return cfg.buildType === "Debug" ? { name: "dev", subdir: "debug" } : { name: "release", subdir: "release" };

    So buildType: "RelWithDebInfo" → cargo profile release.

  2. [profile.release] in Cargo.toml:138-142 sets only lto, codegen-units, debug, and strip. It does not set debug-assertions, so cargo's default of false for the release profile applies.

  3. emitRust() never passes -C debug-assertions=on in rustflags and never sets CARGO_PROFILE_RELEASE_DEBUG_ASSERTIONS. Grepping scripts/build/rust.ts for assertions|DEBUG_ASSERTIONS|debug-assertions yields exactly one hit:

    // rust.ts:385
    BUN_RELEASE_SAFE: String(cfg.assertions),
  4. BUN_RELEASE_SAFE is consumed at src/bun_core/lib.rs:100 via build_opt_bool! into build_options::RELEASE_SAFE, which feeds bun_core::env::ALLOW_ASSERT (env.rs:48). This is bun's own custom assertion gate — it is not cfg!(debug_assertions), so it has no effect on Rust's standard debug_assert!() / debug_assert_eq!() macros or on integer-overflow checks, in either bun's own crates or any of the ~80 third-party dependencies.

Step-by-step proof

A developer reads the release-assertions profile comment, sees "debug-assertions = true (runtime safety checks)", and runs bun run build:release-assertions. The build:

  1. resolves cfg = { buildType: "RelWithDebInfo", assertions: true, ... };
  2. cargoProfile(cfg) returns { name: "release" };
  3. cargo builds with --profile release, and since [profile.release] has no debug-assertions key, rustc receives -C debug-assertions=off (the release default);
  4. BUN_RELEASE_SAFE=true is set in the env, so ALLOW_ASSERT == true and bun's hand-written bun_core::assert!-style checks are compiled in;
  5. but every debug_assert!() in src/**/*.rs and in every dependency crate is compiled out, and release-mode wrapping integer arithmetic is in effect.

The comment claims (3) does the opposite of what it actually does.

Why nothing prevents it

This is build configuration prose; nothing type-checks it. The PR diff (around line 15372 of pr_diff.txt) shows the comment was deliberately rewritten as part of dropping the Zig path, but the corresponding cargo plumbing was never added. The old comment was accurate for the old build (zigOptimize() mapped RelWithDebInfoReleaseSafe, which in Zig enables bounds + overflow checks); the new comment describes plumbing that doesn't exist.

Impact

This is more than a doc nit because the profile's name is release-assertions and the comment is the only spec of what "assertions" means here. The old Zig ReleaseSafe mode enabled language-level safety checks (bounds, overflow, unreachable). The Rust replacement enables only bun's custom ALLOW_ASSERT flag. A developer choosing this profile to catch a suspected debug_assert!() failure or integer overflow in an optimized build will get neither — the class of checks the comment explicitly promises ("debug-assertions = true") is disabled.

How to fix

Two options:

(a) Make the comment true (matches the profile's intent and the prior Zig behavior): in emitRust(), when cfg.assertions is true, add to the env block:

...(cfg.assertions ? { CARGO_PROFILE_RELEASE_DEBUG_ASSERTIONS: "true" } : {}),

(and optionally CARGO_PROFILE_RELEASE_OVERFLOW_CHECKS: "true" to fully match Zig's ReleaseSafe).

(b) Make the comment accurate: change it to e.g. "RelWithDebInfo → cargo release profile with BUN_RELEASE_SAFE=true (enables bun's ALLOW_ASSERT checks; note: does not enable Rust debug_assert!())."

Option (a) is likely the intended fix given the profile's name and the explicit "matching the old cmake build:assert script" claim.

Comment thread scripts/utils.mjs
Comment on lines +2662 to +2669
const rustHeader = line.match(/^(error|warning)(\[[A-Z0-9]+\])?: (.+)$/);
if (rustHeader && !/\b(generated|emitted)\b/.test(line) /* "warning: 3 warnings emitted" */) {
const [, level, code, title] = rustHeader;
const { match: locMatch } = readUntil(/-->\s+(.+?):(\d+):(\d+)/, 3);
// Swallow the diagnostic body up to the blank-line separator (rustc
// always emits one between diagnostics in the human format; cap at 30
// for `--message-format=short` which doesn't).
readUntil(/^$/, 30);

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.

🔴 The readUntil(/^$/, 30) call at line 2669 is meant to consume the rustc diagnostic body up to the blank-line separator, but because originalLines is produced by content.split(/(\r?\n)/) (capturing group → newline chars become separate array elements), the very first element examined after the --> line is a bare '\n' token which .trim()s to '' and matches /^$/ immediately. The annotation body therefore loses the rendered span (|, ^^^^ carets, help:/note: lines), defeating the explicit intent in the comment at lines 2659-2661. Fix: match the newline token itself (readUntil(/^\r?\n$/, 30)) and likewise bump the maxLength=3 on the locMatch readUntil since it currently counts newline tokens too.

Extended reasoning...

What the bug is

The new rustc-diagnostic branch in parseAnnotations() (added by this PR to replace the old Zig-error parser) builds each BuildKite annotation body by accumulating lines into bufferedLines via two readUntil() calls: first to find the --> file:line:col line, then to swallow everything up to the blank line that separates rustc diagnostics. The second call, readUntil(/^$/, 30), is supposed to stop at that blank-line separator. The inline comment at lines 2659-2661 is explicit about the intent: "Read until the blank line that separates rustc diagnostics so the annotation body contains the rendered span + help/note lines."

However, originalLines is built at line 2572 by content.split(/(\r?\n)/) — note the capturing group. With a capturing group, String.prototype.split interleaves the captured separators into the result array, so for input like:

error[E0308]: mismatched types
  --> src/http/lib.rs:553:5
   |
553|     return foo;
   |     ^^^^^^ expected `Bar`, found `Foo`
   = help: ...

originalLines becomes ['error[E0308]: mismatched types', '\n', ' --> src/http/lib.rs:553:5', '\n', ' |', '\n', '553| return foo;', '\n', ...] — every other element is a bare '\n'.

Inside readUntil (lines 2590-2591), each element is run through stripAnsi(originalLine).trim(). A '\n' element trims to the empty string '', and /^$/.exec('') returns a truthy match. So /^$/ matches the first newline token, not the actual blank-line separator between diagnostics.

Step-by-step proof

Using the example above with indices:

i originalLines[i] trimmed
0 error[E0308]: mismatched types error[E0308]: mismatched types
1 \n ""
2 --> src/http/lib.rs:553:5 --> src/http/lib.rs:553:5
3 \n ""
4 | |
5 \n ""
6 553| return foo;
12 "" "" ← the real blank-line separator
  1. Outer loop is at i = 0 (the header line). bufferedLines = ['error[E0308]: ...'].
  2. First readUntil(/-->\s+(.+?):(\d+):(\d+)/, 3): starts at length=0, reads originalLines[0] (header, no match), originalLines[1] ('\n''', no match — /^$/ isn't the pattern here), originalLines[2] (matches -->). length = 3. originalLines.slice(1, i += 3) = ['\n', ' --> ...'] is pushed to bufferedLines. Now i = 3.
  3. Second readUntil(/^$/, 30): starts at length=0, reads originalLines[3] = '\n', trims to '', matches /^$/ immediately. length = 1. originalLines.slice(4, i += 1) = slice(4, 4) = []nothing is pushed to bufferedLines. Now i = 4.
  4. Final bufferedLines = ['error[E0308]: mismatched types', '\n', ' --> src/http/lib.rs:553:5'].

The rendered span (the | gutter, the ^^^^ caret line, and the help:/note: lines — i.e., elements 4-11) never reaches bufferedLines and is therefore absent from annotation.content.

Why existing code doesn't prevent it

The capturing-group split at line 2572 is pre-existing (it's used so that bufferedLines round-trips with original line endings preserved when the annotation body is later joined). The pre-existing parser branches (CMake's /Call Stack/, Github's ::error::) all match against non-empty content patterns, so they never accidentally match a newline token. This new rustc branch is the first to use /^$/ as a sentinel, and it's the only one bitten by the newline-token interleaving.

The maxLength=3 on the first readUntil at line 2665 has the same shape — it counts newline tokens, so it's effectively ~1.5 real lines of lookahead. That happens to work for standard rustc human output (where --> is always the line immediately after the header, found at length=3 as shown above), but it's tighter than intended.

Impact

BuildKite annotations for rustc compile errors will contain only the one-line title + file:line:col, but not the rendered source span, caret underline, or any help:/note: suggestions — which are the most actionable part of a rustc diagnostic. The annotation isn't useless (file/line/title still let you click through), but it's materially degraded relative to the stated intent and to what the replaced Zig parser provided. Since this PR's whole purpose is migrating CI from Zig to Rust, the rustc annotation quality is squarely on the critical path.

How to fix

Change line 2669 to match the actual newline token rather than the trimmed empty string:

readUntil(/^\r?\n$/, 60);

(The maxLength should also roughly double since half the elements are newline tokens.) Alternatively, split without the capturing group — but other branches of parseAnnotations rely on the newline tokens being preserved in bufferedLines for round-tripping, so matching the token is the minimal fix. The maxLength=3 at line 2665 should similarly become 6 for the same reason.

🔬 also observed by coderabbitai

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 scripts/build/source.ts:669-672 — This PR removes the only callers of depSourceStamp() (the import and both depSourceStamp(cfg, "zstd") call sites in bun.ts are gone; emitRustOnly() now uses resolveDep()lolhtmlDep.outputs instead), yet the same PR also rewrote this function's JSDoc from "Used by zig-only mode to depend on zstd's source" to "Used by rust-only mode to depend on lolhtml's source being on disk without resolving the full dep graph" — a claim that is now false (a repo-wide grep finds zero callers, and rust-only mode does resolve the dep via resolveDep()). Either delete depSourceStamp() entirely or revert/correct the docstring.

    Extended reasoning...

    What the bug is

    depSourceStamp() at scripts/build/source.ts:672 is now dead code, but this PR actively rewrote its JSDoc to describe a use that doesn't exist. The new docstring at lines 668-671 reads:

    Path to a dep's fetch stamp. Used by rust-only mode to depend on lolhtml's source being on disk without resolving the full dep graph.

    This is false on both counts: nothing in rust-only mode (or anywhere else) calls depSourceStamp(), and rust-only mode does resolve the full dep graph for lolhtml via resolveDep().

    The code path

    Before this PR, depSourceStamp() had two callers, both in scripts/build/bun.ts:

    • the full-build path: zstdStamp: depSourceStamp(cfg, "zstd") (passed to emitZig)
    • emitZigOnly(): same call

    This PR removes the import (-import { ..., depSourceStamp, ... }+import { computeDepLibs, resolveDep, ... }) and replaces both call sites with vendorStamps: lolhtmlDep.outputs / vendorStamps: depsByName.get("lolhtml")?.outputs ?? [], where lolhtmlDep comes from resolveDep(n, cfg, lolhtml, ...) at bun.ts:539. So the new mechanism for depending on lolhtml's source is resolveDep().outputs, not depSourceStamp().

    In the same PR, source.ts line ~669 was changed from:

    - * Path to a dep's fetch stamp. Used by zig-only mode to depend on zstd's
    + * Path to a dep's fetch stamp. Used by rust-only mode to depend on lolhtml's
      * source being on disk without resolving the full dep graph.

    So the PR author updated the docstring to track the zig→rust / zstd→lolhtml rename, but didn't notice they'd simultaneously removed every caller and switched the consumer to a different API.

    Step-by-step proof

    1. grep -rn depSourceStamp over the repo at HEAD returns exactly one line: scripts/build/source.ts:672:export function depSourceStamp(...). No imports, no calls.
    2. The PR diff shows bun.ts removing depSourceStamp from its import line and removing both depSourceStamp(cfg, "zstd") call sites.
    3. emitRustOnly() in bun.ts:537-557 (the "rust-only mode" the docstring references) calls resolveDep(n, cfg, lolhtml, new Map()) and passes lolhtmlDep.outputs as vendorStamps to emitRust(). It never references depSourceStamp.
    4. The phrase "without resolving the full dep graph" is therefore doubly wrong: rust-only mode does call resolveDep() (line 539), which is exactly the "full dep graph" resolution the docstring claims to avoid.

    Why nothing prevents it

    This is documentation drift on an exported-but-unused function. TypeScript doesn't warn on unused exports, and there's no doc/lint pass that validates JSDoc claims against call sites. The change slipped through because the docstring edit and the caller removal happened in the same PR but in different files.

    Impact

    Zero runtime impact — the function isn't called, so the misleading docstring can't cause incorrect behavior. The cost is purely maintainability: a future reader grepping for how rust-only mode wires the lolhtml fetch dependency will find this docstring claiming depSourceStamp is the mechanism, follow it, find no callers, and have to re-derive that resolveDep().outputs is the actual path. The docstring is worse than stale — it was freshly rewritten by this PR to make a specific, false claim about new code.

    How to fix

    Either:

    • Delete depSourceStamp() entirely (preferred — it's a 3-line function with zero callers, and depSourceDir() right above it remains for anything that needs the directory path), or
    • Revert the docstring to something accurate, e.g. "Path to a dep's fetch stamp (`.ref` file written by the fetch step). Currently unused; resolveDep().outputs is the canonical way to depend on a fetched dep."

Comment thread scripts/build/config.ts
Comment on lines +1017 to +1026
/**
* Parse the major component out of an X.Y.Z version string.
* Returns undefined for undefined/unparseable input so callers can
* compare without `!` assertions.
*/
function majorOf(version: string | undefined): number | undefined {
if (version === undefined) return undefined;
const m = version.match(/^(\d+)\./);
return m ? Number(m[1]) : undefined;
}

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.

🟡 The two new helper functions majorOf() and readRustToolchainChannel() were inserted between getGitRevision()'s JSDoc block (lines 1009-1016) and the function getGitRevision() definition itself (now at line 1045), so the "Get the current git revision..." doc comment is now orphaned and getGitRevision() has no documentation. Move the two helpers either above line 1009 or below getGitRevision()'s closing brace at line 1056.

Extended reasoning...

What the bug is

The PR adds two new helper functions, majorOf() (config.ts:1022-1026) and readRustToolchainChannel() (config.ts:1038-1043), each with their own JSDoc block. They were inserted at the wrong location: immediately after the closing */ of the pre-existing JSDoc for getGitRevision(), but before the function getGitRevision(cwd: string) declaration that JSDoc was meant to document.

How it manifests

After this PR, scripts/build/config.ts:1009-1045 reads:

/**
 * Get the current git revision (HEAD sha).
 *
 * Uses `git rev-parse` rather than reading .git/HEAD directly — the sha
 * is baked into the binary and surfaces in bug reports, so correctness
 * matters more than the ~20ms spawn. Git's plumbing has edge cases
 * (packed-refs, worktrees, symbolic refs) that rev-parse handles for free.
 */
/**
 * Parse the major component out of an X.Y.Z version string.
 * ...
 */
function majorOf(version: string | undefined): number | undefined { ... }

/**
 * Read `channel` from `rust-toolchain.toml`. ...
 */
function readRustToolchainChannel(cwd: string): string | undefined { ... }

function getGitRevision(cwd: string): string { ... }

Lines 1016-1017 are back-to-back */ /** — two adjacent JSDoc blocks with nothing between them. The first block (about git rev-parse) is now orphaned: per JSDoc/TSDoc rules a doc comment attaches to the immediately following declaration, but what follows is another /** block, not a declaration. Tooling (TypeScript language server hover, typedoc, IDE quickdoc) will therefore associate nothing with the first block, and getGitRevision() at line 1045 — which now has no preceding /** — loses its documentation entirely.

Why nothing prevents it

TypeScript treats both as ordinary block comments and compiles fine; back-to-back JSDoc blocks are syntactically valid. There is no lint rule in this repo that flags orphaned JSDoc. The diff hunk header @@ -982,6 +1014,34 @@ confirms the insertion point sits exactly between the existing */ and function getGitRevision.

Step-by-step proof

  1. Before this PR, lines ~1009-1016 contain the JSDoc "Get the current git revision (HEAD sha)... rev-parse handles for free." immediately followed by function getGitRevision(cwd: string): string {.
  2. The PR's diff inserts 28 new lines (the majorOf JSDoc + body, then the readRustToolchainChannel JSDoc + body) starting at the line after the closing */ of that JSDoc.
  3. After insertion: line 1016 is */, line 1017 is /** (start of majorOf's doc). The git-revision JSDoc has no following declaration to attach to.
  4. function getGitRevision is pushed to line 1045 with no /** block immediately preceding it.
  5. Hovering getGitRevision in an editor or running typedoc would show no description; the carefully-written rationale about why git rev-parse is used (vs. reading .git/HEAD) is effectively dead documentation.

Impact

Zero runtime impact — this is purely a documentation/code-organization defect. The git-revision JSDoc contains non-obvious rationale ("correctness matters more than the ~20ms spawn", packed-refs/worktrees edge cases) that a future maintainer would want to see when hovering getGitRevision, and it's now detached from that function. It is, however, clearly an editing slip introduced by this PR rather than a behavioral bug.

How to fix

Move the two new helpers (lines 1017-1043, including their JSDoc blocks) to either:

  • above the getGitRevision JSDoc, i.e. before line 1009 (after findRepoRoot's closing brace at line 1007), or
  • below getGitRevision()'s closing brace at line 1056.

Either restores the /** Get the current git revision... */ block to immediately precede function getGitRevision(cwd: string): string {.

Comment thread scripts/build/rust.ts Outdated
Comment thread scripts/build/CLAUDE.md
2. `resolveConfig(partial, toolchain)` — produce the flat `Config`. Detect host, derive all target booleans, compute paths, read package.json version + git sha.
3. `validateBunConfig(cfg)` + `checkWorkarounds(cfg)` — fail early with clear errors.
- `generateCargoConfig(cfg)` — write the repo-root `.cargo/config.toml` (git-ignored) with the per-target `linker = ` from the discovered `cfg.cxx`. Advisory only for `bun bd` (the ninja cargo edge sets the linker via env); it's there for `cargo build`/`cargo check`/rust-analyzer run directly.
4. `globAllSources()` — one filesystem snapshot of all `.cpp`/`.c`/`.zig`/codegen-input globs.

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.

🟡 This line lists globAllSources() as snapshotting .cpp/.c/.zig/codegen-input globs, but this PR added the rust glob group (scripts/glob-sources.ts:81-83, src/**/*.rs + Cargo manifests) and updated the surrounding lines (142, 145, etc.) for the rust migration while missing this one. Since .zig is still globbed (kept as the porting spec), the accurate text is .cpp/.c/.zig/.rs/codegen-input.

Extended reasoning...

What the bug is

scripts/build/CLAUDE.md:146 documents Phase 1 step 4 of the configure flow:

  1. globAllSources() — one filesystem snapshot of all .cpp/.c/.zig/codegen-input globs.

This PR added a new rust glob group to globAllSources() (scripts/glob-sources.ts:81-83), which collects src/**/*.rs, src/**/Cargo.toml, Cargo.toml, Cargo.lock, and rust-toolchain.toml as implicit inputs to the cargo build edge. The doc line was not updated to mention .rs, so it now under-describes what the function does.

How it manifests / step-by-step proof

  1. Before this PR, globAllSources() had glob groups for cpp/c/zig/codegen-input (and a few internal ones), and scripts/build/CLAUDE.md:146 accurately summarized that as ".cpp/.c/.zig/codegen-input".
  2. This PR adds the rust: group at scripts/glob-sources.ts:81-83 with paths: ["src/**/*.rs", "src/**/Cargo.toml", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml"].
  3. This PR also extensively updates scripts/build/CLAUDE.md for the zig→rust migration: line 142 adds cargo to the resolveToolchain() list, line 145 adds the generateCargoConfig(cfg) step, the module-inventory table swaps zig.tsrust.ts+cargo-config.ts, emitZigemitRust, bun-zig.olibbun_rust.a, the split CI modes zig-onlyrust-only, etc.
  4. Line 146, sandwiched between two of those edits, was missed in the sweep. It still lists only .cpp/.c/.zig/codegen-input.
  5. Note the zig: group at glob-sources.ts:73-75 (src/**/*.zig) is still present — the .zig files remain in-tree as the porting spec — so the existing .zig mention is not strictly wrong; the line is incomplete by omitting .rs, which is now the primary build input feeding the cargo step.

Why nothing prevents it

This is prose in a developer-facing CLAUDE.md; nothing type-checks it against the implementation. The PR's own doc-sync sweep over this exact file (lines 142, 145, 157, 167, the entire module table) demonstrates the intent to keep it accurate; this line was simply overlooked because the surrounding hunk boundary fell between the lines that were edited.

Impact

Documentation drift only, no functional effect. scripts/build/CLAUDE.md is the developer-facing spec for the build system, and globAllSources() is the function that determines which source edits invalidate which ninja edges, so an inaccurate description here is mildly misleading for anyone reading the build-system overview — but no more so than the analogous already-posted nits on root CLAUDE.md (line 268) and codegen.ts JSDoc (lines 212/215), which are the same class of "PR's own zig→rust doc-sync sweep missed a line".

How to fix

Change line 146 to:

  1. globAllSources() — one filesystem snapshot of all .cpp/.c/.zig/.rs/codegen-input globs.

(Listing both .zig and .rs is the most accurate since both groups exist; if the intent is to list only build-relevant inputs, .cpp/.c/.rs/codegen-input would also be defensible since .zig is no longer fed to a build edge — but keeping .zig matches the actual glob-group set.)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
scripts/build/bun.ts (1)

461-474: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Wrap libbun_rust with rustLinkFlags() in both link paths.

Right now both full and link-only treat the Rust staticlib as a plain archive input. That lets the linker drop members with no inbound static reference, which is exactly the case rustLinkFlags() was added for in scripts/build/rust.ts.

Suggested fix
-import { emitRust, rustLibPath } from "./rust.ts";
+import { emitRust, rustLibPath, rustLinkFlags } from "./rust.ts";
   const exe = link(n, cfg, exeName, [...allObjects, ...rustObjects, ...windowsRes], {
     libs: depLibs,
-    flags: [...flags.ldflags, ...systemLibs(cfg), ...manifestLinkFlags(cfg), ...shims.ldflags],
+    flags: [...flags.ldflags, ...rustLinkFlags(cfg, rustObjects), ...systemLibs(cfg), ...manifestLinkFlags(cfg), ...shims.ldflags],
     implicitInputs: [...linkImplicitInputs(cfg), ...shims.implicitInputs],
   });
   const exe = link(n, cfg, exeName, [archive, ...rustObjects, ...windowsRes], {
     libs: depLibs,
-    flags: [...flags.ldflags, ...systemLibs(cfg), ...manifestLinkFlags(cfg), ...shims.ldflags],
+    flags: [...flags.ldflags, ...rustLinkFlags(cfg, rustObjects), ...systemLibs(cfg), ...manifestLinkFlags(cfg), ...shims.ldflags],
     implicitInputs: [...linkImplicitInputs(cfg), ...shims.implicitInputs],
   });

Also applies to: 609-613

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/build/bun.ts` around lines 461 - 474, The Rust staticlib
(libbun_rust) is currently passed as a plain archive to link(), allowing the
linker to drop unused members; call rustLinkFlags(cfg) and include its ldflags
and implicitInputs in both places where link(...) is invoked (the full-exe link
that builds exeName with [...allObjects, ...rustObjects, ...windowsRes] and the
link-only path mentioned later) so the Rust archive is wrapped correctly; update
the flags and implicitInputs arrays to merge ...rustLinkFlags(cfg).ldflags and
...rustLinkFlags(cfg).implicitInputs (or the single rustLinkFlags(cfg) result)
alongside the existing flags/shims values when calling link().
scripts/build/config.ts (1)

525-533: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Skip NDK runtime patching in rust-only mode.

Changing this path to warn-only still leaves resolveConfig() calling linkNdkRuntimesIntoClang() for every Android target, so --mode=rust-only can still try to mutate the host Clang resource dir and emit a sudo hint even though no C/C++ link step will run. Please gate the call at the caller once mode is known.

Proposed fix
   const buildType = partial.buildType ?? "Debug";
+  const mode = partial.mode ?? "full";
   const debug = buildType === "Debug";
   const release = buildType === "Release" || buildType === "RelWithDebInfo" || buildType === "MinSizeRel";
   const smol = buildType === "MinSizeRel";
@@
-    linkNdkRuntimesIntoClang(toolchain.cc, androidNdk, host, crossTarget);
+    if (mode !== "rust-only") {
+      linkNdkRuntimesIntoClang(toolchain.cc, androidNdk, host, crossTarget);
+    }
@@
-    mode: partial.mode ?? "full",
+    mode,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/build/config.ts` around lines 525 - 533, resolveConfig() is still
invoking linkNdkRuntimesIntoClang() for every Android target even when the build
mode is "rust-only"; gate that call at the caller when the mode is known so we
never attempt to mutate host Clang dirs or emit sudo hints in rust-only mode.
Specifically, change the logic that calls
resolveConfig()/linkNdkRuntimesIntoClang() so the caller checks config.mode (or
the equivalent mode flag) and skips calling linkNdkRuntimesIntoClang() when mode
=== "rust-only" (or isRustOnlyMode()), ensuring linkNdkRuntimesIntoClang() is
only invoked for modes that actually perform C/C++ linking.
♻️ Duplicate comments (6)
docs/SPAWN_SYS_PROPOSAL.md (1)

24-38: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language tag to the crate-graph code fence.

Line 24 uses an unlabeled fenced block, which will keep triggering markdownlint (MD040). Please label it (for example text).

Suggested fix
-```
+```text
 bun_libuv_sys  (already exists, src/libuv_sys/)
      │  raw uv_* FFI: uv_spawn, uv_process_t, uv_pipe_t, uv_loop_t
      │  Windows-only at runtime; no bun deps.
      ▼
 bun_spawn_sys  (NEW, src/spawn_sys/)
      │  deps: libc, bun_sys (Fd/E/Tag/Result), bun_libuv_sys (cfg(windows))
      │  NO bun_event_loop / bun_aio / bun_io / bun_threading.
      ▼
 bun_spawn      (src/spawn/, slimmed)
      │  deps: bun_spawn_sys, bun_aio, bun_event_loop, bun_io, bun_ptr,
      │        bun_threading, bun_crash_handler
      ▼
 bun_runtime    (re-exports api::bun_process from bun_spawn)
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @docs/SPAWN_SYS_PROPOSAL.md around lines 24 - 38, The unlabeled fenced code
block that begins with "bun_libuv_sys (already exists, src/libuv_sys/)" should
be updated to include a language tag (e.g., text) to satisfy markdownlint MD040;
edit the fenced block in SPAWN_SYS_PROPOSAL.md that contains the crate-graph
ASCII diagram and change the opening triple backticks to include the tag (for
example, ```text) so the block is recognized as labeled code.


</details>

</blockquote></details>
<details>
<summary>docs/rust-migration-tree.md (1)</summary><blockquote>

`59-63`: _⚠️ Potential issue_ | _🟡 Minor_ | _⚡ Quick win_

**Add a language identifier to the fenced block.**

This code fence is still unlabeled; use something like ```text to satisfy MD040.

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @docs/rust-migration-tree.md around lines 59 - 63, The fenced code block
showing the file mapping entries (e.g., "src/http/HTTPThread.zig ←→
crates/bun_http/src/thread.rs", "src/runtime/api/glob.zig ←→
crates/bun_runtime/src/api/glob.rs", "src/jsc/JSRef.zig ←→
crates/bun_jsc/src/jsref.rs") is missing a language identifier; update the
opening fence to include a language token such as text (or diff if you
prefer highlighting) so the block is labeled and satisfies MD040.


</details>

</blockquote></details>
<details>
<summary>scripts/b2-cycle-args.ts (1)</summary><blockquote>

`92-97`: _⚠️ Potential issue_ | _🟠 Major_ | _⚡ Quick win_

**Resolve the src directory once to an absolute path.**

This script depends on the current working directory being the repository root. The calls to `readdirSync("src")`, `statSync(join("src", d))`, and `gateCount(join("src", d))` will fail or scan the wrong directory tree if invoked from elsewhere.

This issue has already been flagged in a previous review with a detailed fix suggestion.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/b2-cycle-args.ts` around lines 92 - 97, The loop uses relative "src"
paths which break when cwd isn't repo root; resolve the src directory once to an
absolute path (e.g., const SRC = path.resolve(process.cwd(), "src") or similar)
and then replace all literal "src" uses in readdirSync, statSync(join("src",
d)), and gateCount(join("src", d)) with SRC and join(SRC, d); keep existing
checks (KEYSTONE.has(d), gateCount result, crates.push with tier(d)) unchanged.
```

</details>

</blockquote></details>
<details>
<summary>scripts/build/bun.ts (1)</summary><blockquote>

`877-883`: _⚠️ Potential issue_ | _🟠 Major_ | _⚡ Quick win_

**Treat absolute `relative()` results as escaping on Windows.**

When `realBuild` resolves to another drive, `path.relative(realCwd, realBuild)` comes back as an absolute path like `D:\...`, so the current `startsWith("..")` check misses the cross-volume symlink case this guard is meant to reject.

   

<details>
<summary>Suggested fix</summary>

```diff
-import { relative, resolve, sep } from "node:path";
+import { isAbsolute, relative, resolve, sep } from "node:path";
```

```diff
     const realCwd = realpathSync(cfg.cwd);
     const realBuild = realpathSync(cfg.buildDir);
     const rel = relative(realCwd, realBuild);
-    const escapes = rel.startsWith("..") || rel === "";
+    const escapes = isAbsolute(rel) || rel.startsWith("..") || rel === "";
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/build/bun.ts` around lines 877 - 883, The check that sets escapes
based on rel (computed via path.relative(realCwd, realBuild)) fails on Windows
when rel is an absolute path (cross-drive) — update the escapes condition in
scripts/build/bun.ts to treat absolute relative() results as escaping by
including a check for path.isAbsolute(rel) (i.e., set escapes =
rel.startsWith("..") || rel === "" || path.isAbsolute(rel)); reference the
variables/funcs realpathSync, relative, rel and the escapes assignment when
making the change.
```

</details>

</blockquote></details>
<details>
<summary>scripts/build/rust.ts (1)</summary><blockquote>

`106-117`: _⚠️ Potential issue_ | _🟡 Minor_ | _⚡ Quick win_

**Keep `allRustTargets` aligned with the supported triples.**

This list still omits `x86_64-linux-android` and `aarch64-unknown-freebsd`, so `rust:check-all` skips paths the rest of the build logic already models.

  

<details>
<summary>Suggested fix</summary>

```diff
 export const allRustTargets = [
   "x86_64-unknown-linux-gnu",
   "aarch64-unknown-linux-gnu",
   "x86_64-unknown-linux-musl",
   "aarch64-unknown-linux-musl",
+  "x86_64-linux-android",
   "x86_64-apple-darwin",
   "aarch64-apple-darwin",
   "x86_64-pc-windows-msvc",
   "aarch64-pc-windows-msvc",
   "x86_64-unknown-freebsd",
+  "aarch64-unknown-freebsd",
   "aarch64-linux-android",
 ] as const;
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/build/rust.ts` around lines 106 - 117, The allRustTargets array is
missing two supported triples so rust:check-all skips some paths; update the
exported constant allRustTargets to include "x86_64-linux-android" and
"aarch64-unknown-freebsd" alongside the existing entries (preserve the const
tuple and ordering consistent with other build logic) so the list matches the
rest of the build's supported triples.
```

</details>

</blockquote></details>
<details>
<summary>scripts/build/tools.ts (1)</summary><blockquote>

`604-615`: _⚠️ Potential issue_ | _🟠 Major_ | _⚡ Quick win_

**Select the rust-lld wrapper from `hostTriple`, not `os`.**

`lib/rustlib/<host>/bin` is keyed by rustc's host triple, but this branch still picks the wrapper name from the requested `os`. When those differ, `findRustLld()` looks for the wrong binary and silently returns `undefined`.

   

<details>
<summary>Suggested fix</summary>

```diff
   const candidate =
-    os === "windows"
+    hostTriple.includes("windows")
       ? join(bin, "rust-lld.exe")
-      : os === "darwin"
+      : hostTriple.includes("darwin")
         ? join(bin, "gcc-ld", "ld64.lld")
         : join(bin, "gcc-ld", "ld.lld");
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/build/tools.ts` around lines 604 - 615, The code chooses the rust-lld
wrapper based on the requested os instead of the actual host triple, causing
wrong candidate paths; update the selection logic to derive the platform from
hostTriple (e.g., test hostTriple for "windows", "darwin"/"apple" or default
Linux-like) when constructing candidate in the function that computes rustLld
(the code around hostTriple, candidate, isExecutable and findRustLld). Replace
uses of the external os variable with a hostTriple-based check (string
contains/regex for "windows" and "darwin"/"apple") so the correct wrapper names
("rust-lld.exe", "gcc-ld/ld64.lld", "gcc-ld/ld.lld") are picked from
lib/rustlib/<host>/bin and then test with isExecutable as before.
```

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🤖 Prompt for all review comments with AI agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @docs/CYCLEBREAK.md:

  • Around line 4-6: Update the wording around the “NEVER run git” rule to
    explicitly exempt the human maintainer step that performs Tier-6 collapse
    operations: change the sentence that currently reads “NEVER run git” to
    something like “Agents must never run git; the human maintainer performs any
    required git operations (e.g., git mv) during Tier-6 collapse).” Apply this
    clarification where the phrase “NEVER run git” appears and in the section
    describing Tier-6 collapse mechanics (references: the literal strings “NEVER run
    git”, “git mv”, and “Tier-6 collapse”) so it’s clear “git mv” is a human
    maintainer step, not an agent action.
  • Around line 55-68: The opening sentence incorrectly says "These five
    union(enum) sites are per-tick hot" while the table lists six entries
    (bun_event_loop::Task, bun_event_loop::ConcurrentTask, bun_aio::FilePoll,
    bun_event_loop::EventLoopTimer, bun_io::Source, bun_threading::WorkPoolTask);
    update that sentence to "These six union(enum) sites are per-tick hot" (or
    remove one table row if you intend five) so the count matches the table and the
    scope is unambiguous.

In @docs/RUST_IDIOMS_AUDIT.md:

  • Around line 3-4: Update the intro sentence that claims "two reviewers
    verified" by adding a concise "Method/Artifacts" pointer showing where the
    verification evidence lives (for example a CI workflow run link, the generated
    audit report location, or specific commit/PR IDs); edit the paragraph containing
    the phrase "two reviewers confirmed the finding" to append a short parenthetical
    or footnote reference like "Method/Artifacts: <workflow/report/commit refs>" so
    readers can reproduce the votes and see the verification artifacts.

In @docs/RUST_PATTERNS.md:

  • Around line 3-4: The docs/RUST_PATTERNS.md header currently contains a
    machine-local path string "/root/bun-5"; remove that environment-specific path
    and replace it with a repo-relative reference or no path at all (e.g., "Repo: /
    (branch claude/phase-a-port)" or simply omit the path), updating the header line
    that embeds "/root/bun-5" so the document does not contain machine-local
    information.
  • Around line 23-24: The text incorrectly attributes the stability of the
    deref-and-field form (*ptr).field to Rust 1.82; update docs/RUST_PATTERNS.md
    to either remove the version annotation or explicitly distinguish the two
    features: keep (*ptr).field = x as the generic example (no MSRV claim) and
    correct the 1.82 note to refer specifically to the raw-pointer operator syntax
    &raw const (*ptr).field / &raw mut (*ptr).field (RFC 2582) instead of saying
    (*ptr).field was stabilized in 1.82; also update the comparison line that
    shows (&mut *ptr).field = x to clarify that it’s older syntax and not the 1.82
    addition.

In @scripts/build/config.ts:

  • Around line 1046-1050: The function readRustToolchainChannel currently returns
    undefined whether the rust-toolchain.toml file is missing or
    present-but-unparsable; change it so that when the file exists (existsSync(path)
    is true) but the regex match (m) fails, the function throws a clear error
    instead of returning undefined, so callers can distinguish "file missing" from
    "file present but no channel parsed"; update readRustToolchainChannel to read
    the file (readFileSync(path, "utf8")), attempt the
    /^\schannel\s=\s*"([^"]+)"/m match, return m[1] on success, and throw an error
    mentioning the file and that the channel could not be parsed when m is falsy
    (refer to readRustToolchainChannel, resolve, existsSync, readFileSync, and the
    regex match).

In @scripts/fix-imports.ts:

  • Line 14: Replace the shell-quoted execSync calls (used to populate the diff
    and the other git result) with execFileSync from child_process, calling
    execFileSync("git", [...args...], { encoding: "utf8", cwd }) instead of
    execSync("git ..."); specifically, import execFileSync, then change the call
    that sets the diff variable (currently using execSync with a single string
    containing "git diff --cached --name-status -M90% -- '.zig'") to call
    execFileSync with args ["diff","--cached","--name-status","-M90%","--","
    .zig"]
    and include encoding and cwd options, and do the same for the other execSync
    call at the other location so both use argument arrays and an explicit cwd.

In @scripts/fix-vm-import.ts:

  • Around line 26-30: The current hasImport logic misclassifies grouped imports
    because the exclusion regex is applied without first isolating the matched
    import and ensuring plain "VirtualMachine" is absent; change the code to first
    capture the matched import string (the result of body.match(/use
    [^;]\bVirtualMachine\b[^;];/s)?.[0] ?? ""), then set hasImport by testing that
    this import string contains a plain \bVirtualMachine\b and does NOT only contain
    excluded variants — i.e. use something like importStmt = ...; hasImport =
    /\bVirtualMachine\b/.test(importStmt) &&
    !/\bVirtualMachine(?:Ref|SqlExt|InitOptions)\b/.test(importStmt) (or equivalent
    logic) so grouped imports like ::{VirtualMachine, VirtualMachineRef} are
    recognized as having the plain VirtualMachine.

In @scripts/migrate-hostfn-vm.ts:

  • Around line 111-118: The grouping currently collapses calls by method name
    into byName, ignoring the owning typePath and allowing rewrites of unrelated
    impls; change the grouping so it includes the owner type (use the collected
    typePath when building the key) instead of just the method name—e.g., key by
    ${typePath}::${name} or maintain a nested Map keyed first by typePath then
    name—so that later rewrites (the logic that inspects calls, Shape, and
    JSGlobalObject parameters) only apply when both the name and owning type match;
    apply the same change to the other similar block that also collapses by name
    (the block referenced in the comment).

In @src/aio/posix_event_loop.rs:

  • Around line 1713-1718: In wake(), casting a usize to [u8; 8] is UB on 32-bit
    platforms; change the written value to a u64 and serialize it to an 8-byte
    buffer (e.g., via to_ne_bytes()) before calling bun_sys::write(self.fd, ...).
    Update the wake() implementation (referencing wake, self.fd, and bun_sys::write)
    to create a u64 constant (1u64), convert it to an [u8; 8], and pass that buffer
    to bun_sys::write so the code always writes exactly 8 bytes safely across
    platforms.
  • Around line 209-212: The disable() method on KeepAlive currently calls
    unref(get_vm_ctx(AllocatorType::Js)) which hardcodes AllocatorType::Js and can
    be wrong for Mini event loops; update KeepAlive so disable knows the correct
    allocator: either add a field to KeepAlive to store the AllocatorType (like
    FilePoll does) and use that stored allocator when calling get_vm_ctx in
    disable(), or change disable(&mut self) to disable(&mut self, ctx: EventLoopCtx)
    (or similar) and call unref(ctx) with the passed-in EventLoopCtx; ensure
    references to KeepAlive::disable, KeepAlive struct, unref and get_vm_ctx are
    updated accordingly.

Outside diff comments:
In @scripts/build/bun.ts:

  • Around line 461-474: The Rust staticlib (libbun_rust) is currently passed as a
    plain archive to link(), allowing the linker to drop unused members; call
    rustLinkFlags(cfg) and include its ldflags and implicitInputs in both places
    where link(...) is invoked (the full-exe link that builds exeName with
    [...allObjects, ...rustObjects, ...windowsRes] and the link-only path mentioned
    later) so the Rust archive is wrapped correctly; update the flags and
    implicitInputs arrays to merge ...rustLinkFlags(cfg).ldflags and
    ...rustLinkFlags(cfg).implicitInputs (or the single rustLinkFlags(cfg) result)
    alongside the existing flags/shims values when calling link().

In @scripts/build/config.ts:

  • Around line 525-533: resolveConfig() is still invoking
    linkNdkRuntimesIntoClang() for every Android target even when the build mode is
    "rust-only"; gate that call at the caller when the mode is known so we never
    attempt to mutate host Clang dirs or emit sudo hints in rust-only mode.
    Specifically, change the logic that calls
    resolveConfig()/linkNdkRuntimesIntoClang() so the caller checks config.mode (or
    the equivalent mode flag) and skips calling linkNdkRuntimesIntoClang() when mode
    === "rust-only" (or isRustOnlyMode()), ensuring linkNdkRuntimesIntoClang() is
    only invoked for modes that actually perform C/C++ linking.

Duplicate comments:
In @docs/rust-migration-tree.md:

  • Around line 59-63: The fenced code block showing the file mapping entries
    (e.g., "src/http/HTTPThread.zig ←→ crates/bun_http/src/thread.rs",
    "src/runtime/api/glob.zig ←→ crates/bun_runtime/src/api/glob.rs",
    "src/jsc/JSRef.zig ←→ crates/bun_jsc/src/jsref.rs") is missing a language
    identifier; update the opening fence to include a language token such as text (or diff if you prefer highlighting) so the block is labeled and satisfies
    MD040.

In @docs/SPAWN_SYS_PROPOSAL.md:

  • Around line 24-38: The unlabeled fenced code block that begins with
    "bun_libuv_sys (already exists, src/libuv_sys/)" should be updated to include a
    language tag (e.g., text) to satisfy markdownlint MD040; edit the fenced block
    in SPAWN_SYS_PROPOSAL.md that contains the crate-graph ASCII diagram and change
    the opening triple backticks to include the tag (for example, ```text) so the
    block is recognized as labeled code.

In @scripts/b2-cycle-args.ts:

  • Around line 92-97: The loop uses relative "src" paths which break when cwd
    isn't repo root; resolve the src directory once to an absolute path (e.g., const
    SRC = path.resolve(process.cwd(), "src") or similar) and then replace all
    literal "src" uses in readdirSync, statSync(join("src", d)), and
    gateCount(join("src", d)) with SRC and join(SRC, d); keep existing checks
    (KEYSTONE.has(d), gateCount result, crates.push with tier(d)) unchanged.

In @scripts/build/bun.ts:

  • Around line 877-883: The check that sets escapes based on rel (computed via
    path.relative(realCwd, realBuild)) fails on Windows when rel is an absolute path
    (cross-drive) — update the escapes condition in scripts/build/bun.ts to treat
    absolute relative() results as escaping by including a check for
    path.isAbsolute(rel) (i.e., set escapes = rel.startsWith("..") || rel === "" ||
    path.isAbsolute(rel)); reference the variables/funcs realpathSync, relative, rel
    and the escapes assignment when making the change.

In @scripts/build/rust.ts:

  • Around line 106-117: The allRustTargets array is missing two supported triples
    so rust:check-all skips some paths; update the exported constant allRustTargets
    to include "x86_64-linux-android" and "aarch64-unknown-freebsd" alongside the
    existing entries (preserve the const tuple and ordering consistent with other
    build logic) so the list matches the rest of the build's supported triples.

In @scripts/build/tools.ts:

  • Around line 604-615: The code chooses the rust-lld wrapper based on the
    requested os instead of the actual host triple, causing wrong candidate paths;
    update the selection logic to derive the platform from hostTriple (e.g., test
    hostTriple for "windows", "darwin"/"apple" or default Linux-like) when
    constructing candidate in the function that computes rustLld (the code around
    hostTriple, candidate, isExecutable and findRustLld). Replace uses of the
    external os variable with a hostTriple-based check (string contains/regex for
    "windows" and "darwin"/"apple") so the correct wrapper names ("rust-lld.exe",
    "gcc-ld/ld64.lld", "gcc-ld/ld.lld") are picked from lib/rustlib//bin and
    then test with isExecutable as before.

</details>

<details>
<summary>🪄 Autofix (Beta)</summary>

Fix all unresolved CodeRabbit comments on this PR:

- [ ] <!-- {"checkboxId": "4b0d0e0a-96d7-4f10-b296-3a18ea78f0b9"} --> Push a commit to this branch (recommended)
- [ ] <!-- {"checkboxId": "ff5b1114-7d8c-49e6-8ac1-43f82af23a33"} --> Create a new PR with the fixes

</details>

---

<details>
<summary>ℹ️ Review info</summary>

<details>
<summary>⚙️ Run configuration</summary>

**Configuration used**: Path: .coderabbit.yaml

**Review profile**: ASSERTIVE

**Plan**: Pro

**Run ID**: `c3b606e1-2e54-474f-89a7-e4d8f468881e`

</details>

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between c272a7740f59d48513e7499f15a95c50cfa4d3ed and b8c43118810c52de59ec9843253d285c3102a283.

</details>

<details>
<summary>⛔ Files ignored due to path filters (1)</summary>

* `Cargo.lock` is excluded by `!**/*.lock`

</details>

<details>
<summary>📒 Files selected for processing (52)</summary>

* `.prettierignore`
* `Cargo.toml`
* `docs/.rust-rewrite-verified-claims.md`
* `docs/BABYLIST_REPLACEMENT.md`
* `docs/CI_RUST_MIGRATION.md`
* `docs/CYCLEBREAK.md`
* `docs/LAYERING_AUDIT.md`
* `docs/PORTING.md`
* `docs/RUST_IDIOMS_AUDIT.md`
* `docs/RUST_PATTERNS.md`
* `docs/SPAWN_SYS_PROPOSAL.md`
* `docs/rust-migration-tree.md`
* `docs/rust-rewrite-plan.md`
* `docs/zig-restructure-plan.md`
* `rust-toolchain.toml`
* `scripts/b2-cycle-args.ts`
* `scripts/build/CLAUDE.md`
* `scripts/build/bun.ts`
* `scripts/build/config.ts`
* `scripts/build/rust.ts`
* `scripts/build/source.ts`
* `scripts/build/tools.ts`
* `scripts/fix-imports.ts`
* `scripts/fix-vm-import.ts`
* `scripts/migrate-hostfn-vm.ts`
* `scripts/patch-genclasses-paths.ts`
* `src/aio/posix_event_loop.rs`
* `src/base64/lib.rs`
* `src/bun_bin/Cargo.toml`
* `src/bun_bin/lib.rs`
* `src/bun_bin/phase_c_exports.rs`
* `src/bun_core/Cargo.toml`
* `src/bun_core/env.rs`
* `src/bun_core/heap.rs`
* `src/bun_core/lib.rs`
* `src/bun_core/result.rs`
* `src/bun_core/util.rs`
* `src/bundler/Cargo.toml`
* `src/bundler/OutputFile.rs`
* `src/bundler/ParseTask.rs`
* `src/bundler/bundle_v2.rs`
* `src/bundler/linker_context/findImportedFilesInCSSOrder.rs`
* `src/bundler/linker_context/generateCodeForLazyExport.rs`
* `src/bundler/linker_context/generateCompileResultForCssChunk.rs`
* `src/bundler/linker_context/prepareCssAstsForChunk.rs`
* `src/bundler/linker_context/scanImportsAndExports.rs`
* `src/bundler/transpiler.rs`
* `src/bundler/ungate_support.rs`
* `src/bundler_jsc/Cargo.toml`
* `src/bunfig/Cargo.toml`
* `src/codegen/generate-classes.ts`
* `src/crash_handler/lib.rs`

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

Comment thread docs/CYCLEBREAK.md Outdated
Comment on lines +4 to +6
Agents read **only their crate's section** below. Edit only `src/<your-crate>/`.
NEVER run git. NEVER edit another crate.

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clarify who is allowed to run git mv.

The doc currently states “NEVER run git” but also prescribes git mv for Tier-6 collapse mechanics. Please explicitly scope this (e.g., “human maintainer step” vs “agent step”) to prevent workflow mistakes.

Also applies to: 26-30

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/CYCLEBREAK.md` around lines 4 - 6, Update the wording around the “NEVER
run git” rule to explicitly exempt the human maintainer step that performs
Tier-6 collapse operations: change the sentence that currently reads “NEVER run
git” to something like “Agents must never run git; the human maintainer performs
any required git operations (e.g., git mv) during Tier-6 collapse).” Apply this
clarification where the phrase “NEVER run git” appears and in the section
describing Tier-6 collapse mechanics (references: the literal strings “NEVER run
git”, “git mv”, and “Tier-6 collapse”) so it’s clear “git mv” is a human
maintainer step, not an agent action.

Comment thread docs/CYCLEBREAK.md Outdated
Comment on lines +55 to +68
These five `union(enum)` sites are per-tick hot. Use the **hoisted-match**
pattern (PORTING.md §Dispatch): low tier stores `(tag: u8, ptr: *mut ())`,
`bun_runtime` owns the `match`. Tag constants live in the low tier as bare
`pub const TAG_X: u8`; `runtime` defines what each maps to.

| low-tier owner | Zig type | runtime dispatch fn | tag module |
| -------------------------------- | ------------------------------------ | -------------------------------------------- | ------------------------------------- |
| `bun_event_loop::Task` | `Task = union(Tag)` | `runtime::dispatch::run_task(Task)` | `event_loop::task_tag` (~70 variants) |
| `bun_event_loop::ConcurrentTask` | `ConcurrentTask` | `runtime::dispatch::run_concurrent(..)` | shares `task_tag` |
| `bun_aio::FilePoll` | `Owner = TaggedPointerUnion(.{...})` | `runtime::dispatch::on_poll(tag, ptr, ev)` | `aio::poll_tag` (~13) |
| `bun_event_loop::EventLoopTimer` | `Tag = enum` | `runtime::dispatch::fire_timer(tag, ptr)` | `event_loop::timer_tag` (~20) |
| `bun_io::Source` | `Source = union(enum)` | `runtime::dispatch::on_source(tag, ptr, ev)` | `io::source_tag` (~6) |
| `bun_threading::WorkPoolTask` | `WorkPool.Task` | `runtime::dispatch::run_work(tag, ptr)` | `threading::work_tag` (~8) |

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix the hot-site count mismatch.

Line 55 says “five union(enum) sites,” but the table currently lists six rows. Please align the sentence with the table to avoid ambiguous implementation scope.

🧰 Tools
🪛 LanguageTool

[grammar] ~56-~56: Use a hyphen to join words.
Context: ...ch** pattern (PORTING.md §Dispatch): low tier stores (tag: u8, ptr: *mut ()), `...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/CYCLEBREAK.md` around lines 55 - 68, The opening sentence incorrectly
says "These five `union(enum)` sites are per-tick hot" while the table lists six
entries (bun_event_loop::Task, bun_event_loop::ConcurrentTask,
bun_aio::FilePoll, bun_event_loop::EventLoopTimer, bun_io::Source,
bun_threading::WorkPoolTask); update that sentence to "These six `union(enum)`
sites are per-tick hot" (or remove one table row if you intend five) so the
count matches the table and the scope is unambiguous.

Comment thread docs/RUST_IDIOMS_AUDIT.md Outdated
Comment on lines +3 to +4
Adversarial review of 30 crates. Each candidate pattern was independently verified by two reviewers reading the cited code in context; only patterns where **both** reviewers confirmed the finding as a genuine, sweep-worthy anti-pattern appear in §1. Patterns where either reviewer demonstrated the finding was context-blind, factually wrong, or already-policy are listed in §2 with the refutation. Ranked by (severity × instances).

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.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add an evidence pointer for the “two reviewers verified” claim.

Line 3 makes a strong process assertion, but the intro doesn’t link to where those two votes/artifacts live. Add a short “Method/Artifacts” pointer (workflow run, generated report location, or commit references) so readers can reproduce and trust the ranking.

Based on learnings: "Never overstate what you got done or what actually works in commits, PRs, or messages to users."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/RUST_IDIOMS_AUDIT.md` around lines 3 - 4, Update the intro sentence that
claims "two reviewers verified" by adding a concise "Method/Artifacts" pointer
showing where the verification evidence lives (for example a CI workflow run
link, the generated audit report location, or specific commit/PR IDs); edit the
paragraph containing the phrase "two reviewers confirmed the finding" to append
a short parenthetical or footnote reference like "Method/Artifacts:
<workflow/report/commit refs>" so readers can reproduce the votes and see the
verification artifacts.

Comment thread docs/RUST_PATTERNS.md Outdated
Comment on lines +3 to +4
Repo: `/root/bun-5` @ branch `claude/phase-a-port` (HEAD `9712c4542e2`)
Scope: 1,414 `.rs` files under `src/` (excludes `vendor/`, `test/`, `packages/`)

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove machine-local pathing from the doc header.

Line 3 embeds /root/bun-5, which is environment-specific and quickly stale in repo docs. Prefer repo-relative wording (or omit local path entirely).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/RUST_PATTERNS.md` around lines 3 - 4, The docs/RUST_PATTERNS.md header
currently contains a machine-local path string "/root/bun-5"; remove that
environment-specific path and replace it with a repo-relative reference or no
path at all (e.g., "Repo: / (branch claude/phase-a-port)" or simply omit the
path), updating the header line that embeds "/root/bun-5" so the document does
not contain machine-local information.

Comment thread docs/RUST_PATTERNS.md Outdated
Comment on lines +23 to +24
1. **Short-lived, non-overlapping:** keep `*mut`, but only materialize `&mut` for a single statement; never hold across a call that could re-derive. Use `(*ptr).field = x` raw-place syntax (stable since 1.82) instead of `(&mut *ptr).field = x`.
2. **Tree/graph backrefs (Scope, parent links):** wrap node payload in `UnsafeCell<T>` (or `Cell<T>` for Copy fields), store `NonNull<ScopeNode>`, expose `fn cell(&self) -> &UnsafeCell<Scope>`. The `UnsafeCell` is the _only_ legal way to get interior mutability through a shared 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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== rust toolchain files =="
fd -HI 'rust-toolchain(\.toml)?$' .

echo
echo "== contents =="
for f in $(fd -HI 'rust-toolchain(\.toml)?$' .); do
  echo "--- $f ---"
  sed -n '1,160p' "$f"
done

echo
echo "== doc line with version claim =="
rg -n --fixed-strings 'stable since 1.82' docs/RUST_PATTERNS.md

echo
echo "== nearby PORTING/toolchain guidance (if present) =="
rg -n 'MSRV|rust-version|toolchain|minimum rust|Rust [0-9]+\.[0-9]+' docs src Cargo.toml Cargo.lock 2>/dev/null || true

Repository: oven-sh/bun

Length of output: 1863


🌐 Web query:

Rust 1.82 release stabilized raw-place syntax (*ptr).field

💡 Result:

Rust 1.82.0 stabilized the native raw-pointer borrow operators &raw const <place> and &raw mut <place> (RFC 2582) [1][2]. What that means for the “raw-place syntax (*ptr).field” part: - The <place> grammar is a “place expression” (e.g., a field projection like (*ptr).field) and Rust’s existing field-access syntax defines expr.field as a field expression (a kind of place expression) [3][4]. - Therefore you can write &raw const (*ptr).field / &raw mut (*ptr).field to directly produce a raw pointer to that field, without creating an intermediate reference (the key motivation of RFC 2582) [5][6][7]. Where it was announced/stabilized: - The Rust 1.82.0 release announcement lists “Native syntax for creating a raw pointer” / “Stabilized APIs” in the 1.82.0 highlights [2]. - The 1.82.0 release notes/changelog explicitly mention that &raw const and &raw mut operators were stabilized (RFC 2582) [8][2]. Example (shape): - Stable (>=1.82): let p: *const T = &raw const (*ptr).field; / let p: *mut T = &raw mut (*ptr).field; [5][7].

Citations:


🌐 Web query:

bun project minimum supported Rust version MSRV

💡 Result:

The Bun project (oven-sh/bun) primarily uses Zig as its implementation language, with minimal Rust components such as the native plugin API (bun-native-plugin crate) [1][2][3][4]. There is no documented Minimum Supported Rust Version (MSRV) policy in official documentation, README, or Cargo.toml files [5][6][7][4]. The project's rust-toolchain.toml specifies a pinned nightly channel ("nightly-2025-12-10"), which overrides any MSRV and requires contributors to use that exact toolchain for Rust development [2]. An experimental Rust port branch exists as of May 2026, but it is not committed to main and remains unmerged [8][9]. For the stable Bun project, no explicit MSRV is defined or required for end-users, as Rust is not central to its runtime or core functionality.

Citations:


Clarify the Rust-version claim for raw-pointer syntax—the cited version is inaccurate.

Line 23 claims "(*ptr).field = x raw-place syntax (stable since 1.82)". However, the dereference-and-field syntax (*ptr).field has been stable for far longer than Rust 1.82. What did stabilize in 1.82 (RFC 2582) is the raw-pointer operator syntax &raw const (*ptr).field and &raw mut (*ptr).field—a different feature. Since the Bun project pins a nightly Rust toolchain and doesn't enforce an MSRV for its Rust code, either use a plain example without a version annotation, or explicitly distinguish between the old dereference syntax and the 1.82 raw-pointer operators.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/RUST_PATTERNS.md` around lines 23 - 24, The text incorrectly attributes
the stability of the deref-and-field form `(*ptr).field` to Rust 1.82; update
docs/RUST_PATTERNS.md to either remove the version annotation or explicitly
distinguish the two features: keep `(*ptr).field = x` as the generic example (no
MSRV claim) and correct the 1.82 note to refer specifically to the raw-pointer
operator syntax `&raw const (*ptr).field` / `&raw mut (*ptr).field` (RFC 2582)
instead of saying `(*ptr).field` was stabilized in 1.82; also update the
comparison line that shows `(&mut *ptr).field = x` to clarify that it’s older
syntax and not the 1.82 addition.

Comment thread scripts/fix-imports.ts
const root = process.cwd();

// 1. Build rename map from staged diff (R<score>\told\tnew)
const diff = execSync("git diff --cached --name-status -M90% -- '*.zig'", { encoding: "utf8" });

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use argumentized git execution instead of shell-quoted command strings.

At Line 14 and Line 26, single-quoted globs inside execSync("...") are shell-dependent and can break on Windows. Use execFileSync("git", [...]) with explicit args and cwd.

Suggested fix
-import { execSync } from "node:child_process";
+import { execFileSync } from "node:child_process";

-const diff = execSync("git diff --cached --name-status -M90% -- '*.zig'", { encoding: "utf8" });
+const diff = execFileSync("git", ["diff", "--cached", "--name-status", "-M90%", "--", "*.zig"], {
+  encoding: "utf8",
+  cwd: repoRoot,
+});

-const all = execSync("git ls-files -- 'src/**/*.zig' 'src/*.zig'", { encoding: "utf8" })
+const all = execFileSync("git", ["ls-files", "--", "src/**/*.zig", "src/*.zig"], {
+  encoding: "utf8",
+  cwd: repoRoot,
+})
   .trim()
   .split("\n")
   .filter(Boolean);

Also applies to: 26-26

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/fix-imports.ts` at line 14, Replace the shell-quoted execSync calls
(used to populate the diff and the other git result) with execFileSync from
child_process, calling execFileSync("git", [...args...], { encoding: "utf8", cwd
}) instead of execSync("git ..."); specifically, import execFileSync, then
change the call that sets the diff variable (currently using execSync with a
single string containing "git diff --cached --name-status -M90% -- '*.zig'") to
call execFileSync with args
["diff","--cached","--name-status","-M90%","--","*.zig"] and include encoding
and cwd options, and do the same for the other execSync call at the other
location so both use argument arrays and an explicit cwd.

Comment thread scripts/fix-vm-import.ts
Comment on lines +26 to +30
const hasImport =
/use [^;]*\bVirtualMachine\b[^;]*;/s.test(body) &&
!/use [^;]*\bVirtualMachine(?:Ref|SqlExt|InitOptions)\b/s.test(
body.match(/use [^;]*\bVirtualMachine\b[^;]*;/s)?.[0] ?? "",
);

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Grouped imports are misclassified as “missing”.

hasImport flips to false for statements like use ...::{VirtualMachine, VirtualMachineRef}; because the exclusion regex is applied to the whole matched import. The cleanup then reinserts TARGET, creating a duplicate import instead of recognizing the existing one.

Suggested fix
-  const hasImport =
-    /use [^;]*\bVirtualMachine\b[^;]*;/s.test(body) &&
-    !/use [^;]*\bVirtualMachine(?:Ref|SqlExt|InitOptions)\b/s.test(
-      body.match(/use [^;]*\bVirtualMachine\b[^;]*;/s)?.[0] ?? "",
-    );
+  const hasImport = /use [^;]*\bVirtualMachine\b[^;]*;/s.test(body);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/fix-vm-import.ts` around lines 26 - 30, The current hasImport logic
misclassifies grouped imports because the exclusion regex is applied without
first isolating the matched import and ensuring plain "VirtualMachine" is
absent; change the code to first capture the matched import string (the result
of body.match(/use [^;]*\bVirtualMachine\b[^;]*;/s)?.[0] ?? ""), then set
hasImport by testing that this import string contains a plain \bVirtualMachine\b
and does NOT only contain excluded variants — i.e. use something like importStmt
= ...; hasImport = /\bVirtualMachine\b/.test(importStmt) &&
!/\bVirtualMachine(?:Ref|SqlExt|InitOptions)\b/.test(importStmt) (or equivalent
logic) so grouped imports like ::{VirtualMachine, VirtualMachineRef} are
recognized as having the plain VirtualMachine.

Comment on lines +111 to +118
// Map method name → set of shapes (a method name may be shared across types
// with the same shape; if shapes conflict we apply per-occurrence heuristics).
const byName = new Map<string, Set<Shape>>();
for (const [k, s] of calls) {
const name = k.split("::")[1];
if (!byName.has(name)) byName.set(name, new Set());
byName.get(name)!.add(s);
}

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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Constrain rewrites by owning type, not just method name.

typePath is collected but never used; after collapsing everything into byName, any unrelated fn <name> in the scanned trees with a compatible shape and JSGlobalObject parameter can be rewritten if some generated host-fn shares that name. That makes this migration capable of silently mutating the wrong impls.

Also applies to: 154-177

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/migrate-hostfn-vm.ts` around lines 111 - 118, The grouping currently
collapses calls by method name into byName, ignoring the owning typePath and
allowing rewrites of unrelated impls; change the grouping so it includes the
owner type (use the collected typePath when building the key) instead of just
the method name—e.g., key by `${typePath}::${name}` or maintain a nested Map
keyed first by typePath then name—so that later rewrites (the logic that
inspects calls, Shape, and JSGlobalObject parameters) only apply when both the
name and owning type match; apply the same change to the other similar block
that also collapses by name (the block referenced in the comment).

Comment thread src/io/posix_event_loop.rs Outdated
Comment thread src/aio/posix_event_loop.rs Outdated
Comment on lines +1713 to +1718
pub fn wake(&self) {
let bytes: usize = 1;
// SAFETY: usize is 8 bytes; reinterpret as [u8; 8].
let buf = unsafe { &*(&raw const bytes).cast::<[u8; 8]>() };
let _ = bun_sys::write(self.fd, buf);
}

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.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Potential UB on 32-bit platforms: usize cast to [u8; 8].

The wake() method casts a usize to [u8; 8], but usize is only 4 bytes on 32-bit platforms. This would read past the allocated storage (undefined behavior). eventfd always requires an 8-byte write.

🐛 Fix: Use u64 instead of usize
     pub fn wake(&self) {
-        let bytes: usize = 1;
-        // SAFETY: usize is 8 bytes; reinterpret as [u8; 8].
-        let buf = unsafe { &*(&raw const bytes).cast::<[u8; 8]>() };
+        let bytes: u64 = 1;
+        let buf = bytes.to_ne_bytes();
         let _ = bun_sys::write(self.fd, buf);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn wake(&self) {
let bytes: usize = 1;
// SAFETY: usize is 8 bytes; reinterpret as [u8; 8].
let buf = unsafe { &*(&raw const bytes).cast::<[u8; 8]>() };
let _ = bun_sys::write(self.fd, buf);
}
pub fn wake(&self) {
let bytes: u64 = 1;
let buf = bytes.to_ne_bytes();
let _ = bun_sys::write(self.fd, buf);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/aio/posix_event_loop.rs` around lines 1713 - 1718, In wake(), casting a
usize to [u8; 8] is UB on 32-bit platforms; change the written value to a u64
and serialize it to an 8-byte buffer (e.g., via to_ne_bytes()) before calling
bun_sys::write(self.fd, ...). Update the wake() implementation (referencing
wake, self.fd, and bun_sys::write) to create a u64 constant (1u64), convert it
to an [u8; 8], and pass that buffer to bun_sys::write so the code always writes
exactly 8 bytes safely across platforms.

Comment thread scripts/build/rust.ts
Comment thread rust-toolchain.toml
@@ -1,8 +1,32 @@
[toolchain]
channel = "nightly-2025-12-10"
channel = "nightly-2026-05-06"

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.

🔴 Bumping channel to nightly-2026-05-06 makes CI red on pre-existing code this PR doesn't otherwise touch (see robobun build #52929, x64-asan build-rust): src/sys_jsc/error_jsc.rs:151,170 now hits the deny-by-default fn-item-to-integer cast lint (sentry as usize), and src/sys/lib.rs:1588-1592 triggers 14× unreachable_pub (the pub use libc::{statx, STATX_*} lives inside the private mod linux_statx). The bump needs to be paired with the lint fixes — sentry as extern "C" fn(c_int) as usize and pub(super) use respectively — or the lints allow-listed, before this can merge green.

Extended reasoning...

What the bug is

This PR changes rust-toolchain.toml line 2 from channel = "nightly-2025-12-10" to channel = "nightly-2026-05-06". The newer nightly tightens two lints that pre-existing code in src/sys_jsc/ and src/sys/ violates, and CI builds with -D warnings (or the relevant lints have been promoted to deny-by-default), so the build now fails. Neither of those source files is touched by this PR — they appear nowhere in the 300-file diff — so the toolchain bump is the proximate cause of the CI red.

The two failure classes

(1) src/sys_jsc/error_jsc.rs:151,170 — "direct cast of function item into an integer". Line 127 defines extern "C" fn sentry(_: c_int) {}. In Rust, the name sentry has a unique zero-sized function item type, not a function pointer. Line 151 does sa_sigaction: sentry as usize, and line 170 does sentry as usize as f64. Casting a fn item directly to an integer is now linted (rustc's function_item_references/clippy's fn_to_numeric_cast_* family — the newer nightly denies it under the build's lint config). The fix is to coerce to a fn pointer first: sentry as extern "C" fn(core::ffi::c_int) as usize. This is a one-token insertion at each site and changes nothing semantically — the resulting integer is the same address.

(2) src/sys/lib.rs:1588-1592 — 14× "unreachable pub item". The crate has #![warn(unreachable_pub)] at line 11. At line 1585, mod linux_statx is declared private (no pub), and inside it lines 1588-1592 do pub use libc::{statx, AT_STATX_SYNC_AS_STAT, STATX_ATIME, STATX_BLOCKS, STATX_BTIME, STATX_CTIME, STATX_GID, STATX_INO, STATX_MODE, STATX_MTIME, STATX_NLINK, STATX_SIZE, STATX_TYPE, STATX_UID}. A pub item inside a private module is unreachable from outside the crate — exactly what unreachable_pub flags. The 14 re-exported names produce 14 diagnostics. On the old nightly this was either not detected for re-exports or only warned; on the new nightly under CI's -D warnings it's a hard error. The fix is pub(super) use libc::{...} (or pub(crate)), which expresses the actual intent — these names are consumed by the parent module's statx() wrapper, not exported from the crate.

Why nothing prevents it

These files compile cleanly on nightly-2025-12-10. The PR author bumped the channel (likely to pick up a newer rustc for other reasons — the diff shows extensive build-system work) without doing a full cargo check --workspace on the new toolchain across all targets. The robobun CI comment on this PR's own head commit (e4db751) lists these exact file:line failures on "🐧 x64-asan - build-rust", confirming the bump is the trigger. The only commit after that (983412f) touches an unrelated test-output cleanup, so the failures are still present at HEAD.

Step-by-step proof

  1. PR diff at rust-toolchain.toml: -channel = "nightly-2025-12-10" / +channel = "nightly-2026-05-06".
  2. grep '^diff --git' pr_diff.txt | grep -E 'sys/lib.rs|sys_jsc/error_jsc' → no match. Neither file is in the PR.
  3. src/sys_jsc/error_jsc.rs:127 defines extern "C" fn sentry(_: c_int) {} — a fn item, ZST.
  4. src/sys_jsc/error_jsc.rs:151sa_sigaction: sentry as usize,; :170sentry as usize as f64. Both cast the ZST fn item directly to an integer.
  5. src/sys/lib.rs:11#![warn(unreachable_pub)].
  6. src/sys/lib.rs:1585mod linux_statx { (no pub). Lines 1588-1592 → pub use libc::{statx, AT_STATX_SYNC_AS_STAT, STATX_*} — 14 names, each pub but unreachable.
  7. robobun comment on this PR, build #52929: lists exactly src/sys_jsc/error_jsc.rs:151 + :170 ("direct cast of function item into an integer") and src/sys/lib.rs:1589-1591 × 14 ("unreachable pub item") on x64-asan build-rust.

Impact

CI is red on the x64-asan build-rust step, which gates the link step and therefore the test matrix. The PR cannot merge green without addressing this. The fixes are mechanical and risk-free (a fn-pointer coercion and a visibility narrowing), but they must be included in this PR since the toolchain bump is what surfaces them.

How to fix

In src/sys_jsc/error_jsc.rs:

// line 151
sa_sigaction: sentry as extern "C" fn(core::ffi::c_int) as usize,
// line 170
installed.put(global, b"handler", JSValue::js_number(sentry as extern "C" fn(core::ffi::c_int) as usize as f64));

In src/sys/lib.rs:1588 (and the corresponding musl branch below it if it has the same shape):

pub(super) use libc::{
    statx, AT_STATX_SYNC_AS_STAT, STATX_ATIME, /* … */
};

Alternatively, if the intent is to land the toolchain bump separately from code fixes, add #[allow(unreachable_pub)] on the mod linux_statx block and #[allow(clippy::fn_to_numeric_cast_any)] (or the rustc equivalent) on the sentry casts — but the real fixes above are trivially small and strictly better.

Comment thread rust-toolchain.toml
Comment on lines +13 to +14
# Mirrors `allRustTargets` in scripts/build/rust.ts minus aarch64-unknown-freebsd
# (Tier 3 — no prebuilt rust-std, cargo `-Zbuild-std` builds it from rust-src).

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.

🟡 The comment claims this list "Mirrors allRustTargets in scripts/build/rust.ts minus aarch64-unknown-freebsd", but the lists actually diverge: allRustTargets (rust.ts:106-117) has 10 entries and is missing x86_64-linux-android (and doesn't contain aarch64-unknown-freebsd either, so the "minus" subtracts nothing), while this file lists 11 targets including x86_64-linux-android. Since rustTarget() produces x86_64-linux-android and CI builds it (robobun shows "🐧 x64-android - build-bun"), allRustTargets is the incomplete one — bun run rust:check-all and the generated .cargo/config.toml (cargo-config.ts:79) skip that triple. Note: CodeRabbit flagged allRustTargets at rust.ts:117 earlier and it was auto-marked "✅ Addressed in commits d5945cf to 808905b", but those commits only touched docs/build.zig and never modified rust.ts — re-flagging here from the rust-toolchain.toml side since the comment text is also wrong.

Extended reasoning...

What the bug is

rust-toolchain.toml:13-14 carries a comment asserting that its targets list "Mirrors allRustTargets in scripts/build/rust.ts minus aarch64-unknown-freebsd (Tier 3 — no prebuilt rust-std, cargo -Zbuild-std builds it from rust-src)." This claim is false on both halves: (a) aarch64-unknown-freebsd is not in allRustTargets to begin with, so subtracting it is vacuous; and (b) the targets list in rust-toolchain.toml contains x86_64-linux-android (line 26) which allRustTargets does not contain. The actual relationship is rust-toolchain.toml.targets = allRustTargets ∪ {x86_64-linux-android}, not allRustTargets ∖ {aarch64-unknown-freebsd}.

Step-by-step proof

  1. allRustTargets at scripts/build/rust.ts:106-117 (verified at HEAD b8c4311) lists exactly 10 entries:
    • x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-unknown-linux-musl, aarch64-unknown-linux-musl, x86_64-apple-darwin, aarch64-apple-darwin, x86_64-pc-windows-msvc, aarch64-pc-windows-msvc, x86_64-unknown-freebsd, aarch64-linux-android.
    • It does not contain x86_64-linux-android. It does not contain aarch64-unknown-freebsd.
  2. rust-toolchain.toml targets (lines 20-32) lists exactly 11 entries: all 10 from allRustTargets plus x86_64-linux-android (line 26).
  3. So the comment's claimed relation ("mirrors … minus aarch64-unknown-freebsd") is wrong: subtracting aarch64-unknown-freebsd from allRustTargets yields the same 10 entries (it was never present), and rust-toolchain.toml then has one extra entry, not one fewer.
  4. Which side is wrong? rustTarget() at rust.ts:47-54 maps {abi: 'android', x64: true}'x86_64-linux-android', rustCanCrossFromLinux() returns true for it, and the robobun comment on this PR shows 🐧 x64-android - build-bun actually building. So CI does build that triple via the ninja path; allRustTargets is the one that's incomplete.

Functional impact

allRustTargets has two consumers:

  • scripts/rust-check-all.ts:21bun run rust:check-all iterates this array running cargo check --target <t>. With x86_64-linux-android missing, the dev-convenience "check all platforms" command silently skips it.
  • scripts/build/cargo-config.ts:79 — the generated .cargo/config.toml emits one [target.<triple>] section per entry. With x86_64-linux-android missing, direct cargo build --target x86_64-linux-android (outside ninja) gets no per-target linker config. CI is unaffected because the ninja-driven emitRust() path sets CARGO_TARGET_<TRIPLE>_LINKER and CARGO_ENCODED_RUSTFLAGS via env per-build, not via the config file.

So the impact is limited to (a) a misleading code comment in a file added by this PR, and (b) two dev-tooling gaps. CI builds are unaffected, hence nit severity.

On the refutation (duplicate of CodeRabbit comment 3211780323)

One verifier flagged this as a duplicate of the existing CodeRabbit inline comment at rust.ts:117, which proposed adding x86_64-linux-android and aarch64-unknown-freebsd to allRustTargets. That comment is marked resolved="true" with "✅ Addressed in commits d5945cf to 808905b" appended. However, that resolved-mark is incorrect:

  • git show --stat d5945cf → touches only build.zig, src/**/*.zig, and test files.
  • git show --stat 808905b → an autofix.ci commit touching only docs/.rust-rewrite-verified-claims.md, docs/RUST_IDIOMS_AUDIT.md, docs/zig-restructure-plan.md.
  • Neither commit touches scripts/build/rust.ts. allRustTargets at HEAD still has 10 entries.

This report is therefore not redundant: (1) the original CodeRabbit thread is marked resolved despite not being fixed, so it will not surface to the author; (2) this report targets a different file and line — the rust-toolchain.toml comment text added by this PR, which is independently wrong regardless of whether allRustTargets gets fixed (if you only add x86_64-linux-android to allRustTargets, the "minus aarch64-unknown-freebsd" wording is still vacuous and confusing). The two are sibling symptoms of the same root divergence, but flagging the comment here gives the author the full picture from the file they're more likely to read when bumping the toolchain channel.

How to fix

Either fix is acceptable; doing both is best:

  1. Add "x86_64-linux-android" to allRustTargets in scripts/build/rust.ts (between x86_64-unknown-freebsd and aarch64-linux-android). This makes the two lists actually mirror each other modulo the Tier-3 triple.
  2. Reword the comment at rust-toolchain.toml:13-14 to be accurate, e.g.: "Mirrors allRustTargets in scripts/build/rust.ts (Tier 1/2 triples only — aarch64-unknown-freebsd is Tier 3 with no prebuilt rust-std, so it's omitted here and built via -Zbuild-std from rust-src)." — and only after (1) makes the mirror claim true.

Comment thread scripts/build/tools.ts
const rustup = findTool({ names: ["rustup"], paths: [join(cargoHome, "bin")], required: false })?.path;
const channel = readRustToolchainChannel();
if (rustup !== undefined && channel !== undefined) {
spawnSync(rustup, ["toolchain", "install", channel, "--force", "--profile", "minimal", "--component", "rust-src"], {

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.

🟡 This rustup toolchain install --force --profile minimal contradicts the policy this same PR establishes at rust.ts:213-217 (commit e4db751 deliberately removed --profile minimal from the other --force reinstalls, noting "requesting a narrower profile on a --force reinstall is asking for trouble"). For consistency, drop "--profile", "minimal" here too — --component rust-src already ensures the only extra component this path needs.

Extended reasoning...

What the inconsistency is

Commit e4db751 in this PR ("build: surface rustup repair output; drop --profile minimal") removed --profile minimal from the rustup toolchain install --force invocations in scripts/build/rust.ts and scripts/build/source.ts, and codified the rationale in a comment that now lives at rust.ts:213-217:

No --profile minimal: the agent already has the default profile, and rustup applies --profile to the install spec, not just first-install — requesting a narrower profile on a --force reinstall is asking for trouble. We only care that rust-src and rust-std-<triple> exist on top of whatever profile is there.

However, the later commit b8c4311 (30 minutes after e4db751, same PR) added findRustLld() in scripts/build/tools.ts, and at line 581 it does:

spawnSync(rustup, ["toolchain", "install", channel, "--force", "--profile", "minimal", "--component", "rust-src"], { ... });

— exactly the --force + --profile minimal pattern that e4db751 just removed elsewhere and documented as undesirable. A grep of scripts/build/ confirms tools.ts:581 is now the only remaining --profile minimal in the build scripts.

The code path

findRustLld() runs at configure time (called from resolveLlvmToolchain) on every build, including the link-only CI agent — which is precisely the case the function's own comment (lines 568-577) describes: an agent that downloads libbun_rust.a rather than building it, so it may or may not already have the pinned toolchain installed. If that agent (or a developer machine) already has the toolchain installed with the default profile, this --force --profile minimal reinstall is the same "narrowing on --force" hazard the rust.ts comment warns about.

Step-by-step proof

  1. e4db751 (2026-05-08 16:58) removes --profile minimal from rust.ts and source.ts, and adds the comment now at rust.ts:213-217 explaining why.
  2. b8c4311 (2026-05-08 17:28, same PR, later) adds findRustLld() at tools.ts:562-… with ["toolchain", "install", channel, "--force", "--profile", "minimal", "--component", "rust-src"] at line 581.
  3. rust_build_cross at rust.ts:222 now reads rustup toolchain install ${channel} --force --component rust-src $rust_target_arg — no --profile.
  4. grep -rn 'profile.*minimal' scripts/build/ → only tools.ts:581.
  5. The hazard described at rust.ts:215 ("requesting a narrower profile on a --force reinstall") applies identically to tools.ts:581: same rustup binary, same pinned channel, same --force, same risk that the agent has a wider profile already installed.

Why nothing prevents it

This is a build-script convention/policy the PR itself just introduced; nothing type-checks or tests it. The two commits were authored ~30 minutes apart and the second one simply missed applying the convention the first one established — a textbook same-PR oversight.

Impact

Low / defensive. The link-only agent's functional needs are satisfied either way: rust-lld ships in the rustc component (present in the minimal profile) and rust-src is explicitly requested via --component. The concern is consistency with the PR's own documented convention, plus the theoretical case where (a) the link agent shares ~/.rustup with a step that needs default-profile components like rustfmt/clippy, and (b) rustup's --force + narrower-profile actually drops those components. Whether (b) happens in practice depends on rustup version behavior — which is exactly why rust.ts:213-217 phrases it as "asking for trouble" rather than a definite breakage.

How to fix

Drop two array elements:

spawnSync(rustup, ["toolchain", "install", channel, "--force", "--component", "rust-src"], {

This matches the form used at rust.ts:222 and source.ts. --component rust-src is kept (it's what -Zbuild-std needs and is harmless here); only the profile narrowing is removed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
scripts/build/rust.ts (1)

106-117: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Re-add the omitted supported triples to allRustTargets.

This list no longer matches the targets the rest of this file models as supported. In particular, x86_64-linux-android is still produced by rustTarget(), and aarch64-unknown-freebsd is still documented below as the CI matrix's Tier 3 triple. As written, rust:check-all skips both the x64 Android path and the only -Zbuild-std Tier 3 path.

💡 Proposed fix
 export const allRustTargets = [
   "x86_64-unknown-linux-gnu",
   "aarch64-unknown-linux-gnu",
   "x86_64-unknown-linux-musl",
   "aarch64-unknown-linux-musl",
   "x86_64-apple-darwin",
   "aarch64-apple-darwin",
   "x86_64-pc-windows-msvc",
   "aarch64-pc-windows-msvc",
   "x86_64-unknown-freebsd",
+  "aarch64-unknown-freebsd",
+  "x86_64-linux-android",
   "aarch64-linux-android",
 ] as const;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/build/rust.ts` around lines 106 - 117, The allRustTargets array is
missing triples that other codepaths expect; update allRustTargets to include
the omitted platform triples so rustTarget() and the CI/Tier3 logic are covered:
add "x86_64-linux-android" (to match rustTarget's produced triple) and
"aarch64-unknown-freebsd" (the documented Tier 3 triple) to the exported
allRustTargets const so rust:check-all and -Zbuild-std paths are not skipped.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@scripts/build/rust.ts`:
- Around line 106-117: The allRustTargets array is missing triples that other
codepaths expect; update allRustTargets to include the omitted platform triples
so rustTarget() and the CI/Tier3 logic are covered: add "x86_64-linux-android"
(to match rustTarget's produced triple) and "aarch64-unknown-freebsd" (the
documented Tier 3 triple) to the exported allRustTargets const so rust:check-all
and -Zbuild-std paths are not skipped.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a646d80a-080f-4e42-9be1-3452b4aba2fb

📥 Commits

Reviewing files that changed from the base of the PR and between 946684b and 1e306f9.

📒 Files selected for processing (3)
  • scripts/build/rust.ts
  • src/bun_core/env.rs
  • src/bun_core/lib.rs

Comment on lines +78 to +97
{
id: "rust-lld-for-crosslang-lto",
issue: "https://rustc-dev-guide.rust-lang.org/backend/updating-llvm.html",
description:
"rustc's bundled LLVM is newer than clang's, so clang's ld.lld can't read " +
"-Clinker-plugin-lto bitcode (forward-compatible only). Link with rust-lld instead.",
applies: cfg => cfg.lto && cfg.rustLlvmVersion !== undefined && cfg.clangVersion !== undefined,
expectedToBeFixed: cfg => {
// Obsolete once clang's LLVM major catches up to (or passes) rustc's —
// at that point clang's own ld.lld reads rustc's bitcode and the
// rust-lld swap in resolveConfig() never fires.
const clangMajor = Number(cfg.clangVersion!.split(".")[0]);
const rustMajor = Number(cfg.rustLlvmVersion!.split(".")[0]);
return clangMajor >= rustMajor;
},
cleanup:
`Delete the rust-lld swap block in resolveConfig() (config.ts), findRustLld() and its call ` +
`in resolveLlvmToolchain() (tools.ts), the rustLld/rustLlvmVersion fields on Toolchain/Config, ` +
`and this entry.`,
},

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.

🔴 The rust-lld-for-crosslang-lto entry's applies() is true on every LTO build (both versions are always populated) and expectedToBeFixed() returns true whenever clangMajor >= rustMajor — so the moment clang's LLVM catches up to rustc's (a normal recurring state, e.g. on the next LLVM_MAJOR bump), every LTO configure will throw BuildError: Workaround 'rust-lld-for-crosslang-lto' is obsolete and instruct deleting findRustLld()/rustLld/rustLlvmVersion/the swap block. The swap at config.ts:629-637 is already self-gating (only fires when rustLlvmMajor > clangMajor) and harmless when dormant, so this entry adds no functional value and should be dropped from workarounds[] (or have applies() narrowed to rustMajor > clangMajor).

Extended reasoning...

What the bug is

The workarounds[] registry in scripts/build/workarounds.ts is designed (per its file-level doc, lines 1-22) for one-time upstream bugs that get permanently fixed in some release: each entry has an expectedToBeFixed() predicate that compares a config-derived version against a constant threshold, and when it trips, checkWorkarounds() (lines 104-116) hard-fails configure with a BuildError instructing the developer to delete the workaround code. The error's escape hatch — "bump the threshold in expectedToBeFixed()" — assumes there is a constant to bump.

The new rust-lld-for-crosslang-lto entry (lines 78-97) misuses this mechanism for a recurring bidirectional version skew: rustc periodically bumps its bundled LLVM ahead of the pinned clang's, then clang catches up, then rustc bumps again. Its expectedToBeFixed() compares two config-derived values (clangMajor >= rustMajor) rather than one config value against a constant — so there's no threshold to bump, and the "fixed" state is not permanent.

The code path that triggers it

  1. checkWorkarounds(cfg) is called unconditionally at configure.ts:202 after resolveConfig().
  2. applies: cfg => cfg.lto && cfg.rustLlvmVersion !== undefined && cfg.clangVersion !== undefined — both versions are always populated on any rustc-installed host (rustc is now mandatory; findRustc() populates rustLlvmVersion, resolveLlvmToolchain() populates clangVersion), so this is effectively cfg.lto.
  3. expectedToBeFixed: cfg => clangMajor >= rustMajor (lines 89-91).
  4. When both predicates are true, line 109 throws BuildError.

Meanwhile, the actual workaround code at config.ts:629-637 is already self-gating: it only swaps ld = toolchain.rustLld when rustLlvmMajor > clangMajor. When clangMajor >= rustMajor, the swap branch is dormant and clang's own ld.lld is used — exactly the correct behavior, with no intervention needed.

Step-by-step proof

Suppose the next LLVM_MAJOR bump moves LLVM_VERSION_RANGE to accept clang 22 (the sibling asan-dyld-shim entry's FIXED_IN_LLVM = "22.1.4" already anticipates this). With clang 22.x and rustc nightly-2026-05-06 bundling LLVM 22:

  1. cfg.lto = true (release CI build), cfg.clangVersion = "22.1.4", cfg.rustLlvmVersion = "22.0.0"applies() returns true.
  2. clangMajor = 22, rustMajor = 22expectedToBeFixed() returns 22 >= 22 = true.
  3. checkWorkarounds() throws: "Workaround 'rust-lld-for-crosslang-lto' is obsolete — upstream fix is available" with cleanup instructions to "Delete the rust-lld swap block in resolveConfig(), findRustLld() and its call in resolveLlvmToolchain(), the rustLld/rustLlvmVersion fields on Toolchain/Config, and this entry."
  4. Every LTO configure on every platform now hard-fails.
  5. A maintainer reads the error. The "bump the threshold" hint doesn't apply (no constant). They follow the cleanup instructions and delete findRustLld(), the rustLld/rustLlvmVersion fields, and the swap block.
  6. Three months later, rust-toolchain.toml is bumped to a nightly with LLVM 23. Now rustLlvmMajor = 23 > clangMajor = 22, but the swap infrastructure is gone — clang's ld.lld can't read rustc's bitcode → link fails with Unknown attribute kind (the exact error documented at config.ts:617-618). The deleted code has to be re-written from scratch.

The comment at config.ts:624-625 ("so this branch self-obsoletes once clang's LLVM catches up") confirms the author expected the entry to fire and intended the deletion — but didn't account for the cycle restarting.

Why existing code doesn't prevent it

The workarounds.ts framework has no concept of a "dormant" or "recurring" workaround — it only models monotonic obsolescence. Compare with the well-formed sibling entry asan-dyld-shim (lines 59-77): it checks cfg.clangVersion >= FIXED_IN_LLVM against a constant "22.1.4", which is monotonic and bumpable. The rust-lld entry's predicate is non-monotonic over time and has no constant.

Currently this doesn't fire: clang is pinned to 21.1.x (tools.ts:268-270) and rust nightly-2026-05-06 has LLVM 22, so 21 >= 22 is false. But it's a one-version-bump landmine.

Impact

When triggered: every release/LTO configure hard-fails until a maintainer either deletes permanent infrastructure (creating a future link failure) or figures out that the entry itself is the bug and removes it. Either way it blocks CI.

How to fix

Preferred: Remove the rust-lld-for-crosslang-lto entry from workarounds[] entirely, and delete the misleading "Tracked in workarounds.ts ... self-obsoletes" comment at config.ts:624-625. The swap branch at config.ts:629-637 already self-gates on rustLlvmMajor > clangMajor and is harmless when dormant — it doesn't need an obsolescence tracker because it's a permanent compat shim, not a workaround for a one-time bug.

Alternative: Narrow applies() to cfg.lto && ... && rustMajor > clangMajor so the entry only matches when the swap is actually active. This makes expectedToBeFixed() unreachable (it would only be evaluated when rustMajor > clangMajor, where it returns false), reducing the entry to pure documentation — at which point a code comment is simpler.

Comment on lines +214 to 216
// rust-only/link-only don't run LUT codegen — skip the check so split-CI
// steps don't require perl on the rust cross-compile box.
if (cfg.mode === "full" || cfg.mode === "cpp-only") {

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.

🟡 The comment at lines 214-215 says "rust-only/link-only don't run LUT codegen", but this PR's own change at codegen.ts:920 (o.rustInputs.push(jssinkRs)) makes rust-only depend on the JSSink edge — whose comment at codegen.ts:917-919 explicitly says "Without this, mode: rust-only … never runs this edge" — and that edge spawns create-hash-table.ts which invokes perl. The build likely still succeeds without perl (generate-jssink.ts:1206 writes generated_jssink.rs before the perl spawn and discards the spawnSync result, and rust-only consumes nothing that needs JSSink.lut.h), so this is a stale-comment / consistency issue rather than a CI breaker — but the comment should either be corrected or the check extended to rust-only.

Extended reasoning...

What the bug is

This PR makes two changes that contradict each other:

  1. scripts/build/configure.ts:214-216 — The comment was updated (zig→rust rename) to read: "rust-only/link-only don't run LUT codegen — skip the check so split-CI steps don't require perl on the rust cross-compile box", and the perl check remains gated to cfg.mode === "full" || cfg.mode === "cpp-only".

  2. scripts/build/codegen.ts:920 — Added o.rustInputs.push(jssinkRs), with an explicit 6-line comment (914-919) stating "Without this, mode: \"rust-only\" (CI's build-rust job, which compiles no C++ so nothing else pulls JSSink.cpp/.h) never runs this edge and cargo hits the missing file."

So the same PR (a) wrote a comment asserting rust-only never runs LUT codegen, and (b) added a dependency whose own comment asserts rust-only does run it.

The dependency chain (verified)

  1. bun.ts:531-548emitRustOnly() calls emitCodegen() then passes codegen.rustInputs to emitRust() as codegenInputs.
  2. codegen.ts:882-921emitJsSink() declares a single ninja edge with four co-outputs: [JSSink.cpp, JSSink.h, JSSink.lut.h, generated_jssink.rs], and pushes generated_jssink.rs into o.rustInputs.
  3. The cargo build edge depends on rustInputs, so ninja must build generated_jssink.rs, which means running the JSSink edge.
  4. The JSSink edge runs generate-jssink.ts, which at line 1210 does Bun.spawnSync([..., "create-hash-table.ts", ...]).
  5. create-hash-table.ts:23 spawns ["perl", create_hash_table, "-"].

So rust-only mode does run LUT codegen and does invoke perl. The configure.ts comment is factually wrong.

Why the impact is overstated

The original bug description claims that without perl, "the build now fails at build-time with a cryptic error instead of at configure-time." Tracing through the actual code shows this is unlikely:

  • generate-jssink.ts:1206 writes generated_jssink.rs before the perl spawn at line 1210.
  • generate-jssink.ts:1210-1221 calls Bun.spawnSync(...) and discards the result — no exit-code check, no error propagation. The script ends immediately after.
  • So even when create-hash-table.ts exits 1 (which it does at line 39 when perl is missing), generate-jssink.ts itself exits 0.
  • JSSink.lut.h is never written, but in rust-only mode nothing consumes it: it goes into o.cppHeaders (codegen.ts:913), and per the comment at bun.ts:542-544, "The cpp-related outputs … have no consumer in this graph — ninja skips them." The codegen rule has restat: true (codegen.ts:142), so ninja stat()s the missing output but doesn't fail the build.
  • The cargo edge's only dependency from this step is generated_jssink.rs, which exists.

Net effect: missing perl on the rust cross-compile agent → stderr noise from create-hash-table.ts's "Failed to generate JSSink.lut.h" message, but the rust-only build succeeds. This is messy but not a CI break.

Step-by-step proof

On a rust-only configure with perl absent from PATH:

  1. configure.ts:216 evaluates cfg.mode === "full" || cfg.mode === "cpp-only"false → perl check skipped.
  2. ninja runs the JSSink edge (pulled by generated_jssink.rsrustInputs ∈ cargo's implicit deps).
  3. generate-jssink.ts writes JSSink.h, JSSink.cpp, JSSink.lut.txt, generated_jssink.rs (lines 1201-1206).
  4. generate-jssink.ts:1210 spawns create-hash-table.ts; that script tries spawn({cmd: ["perl", ...]}), fails, prints error to stderr, exits 1.
  5. Bun.spawnSync returns {exitCode: 1, ...}; generate-jssink.ts ignores it; falls off the end → exit 0.
  6. ninja sees exit 0; with restat: true it stats outputs, finds JSSink.lut.h missing — but nothing downstream in the rust-only graph needs it.
  7. cargo edge runs with generated_jssink.rs present → success.

Why nothing prevents it

The configure-time perl check exists precisely to catch this class of error early, but it's gated on mode and the gate condition wasn't updated when the dependency was added. There's no test or assertion linking the comment to the actual ninja graph shape.

How to fix

Two reasonable options:

(a) Extend the check to include rust-only (most defensive — catches the case where someone later makes generate-jssink.ts check the spawn result, and avoids the stderr noise):

if (cfg.mode === "full" || cfg.mode === "cpp-only" || cfg.mode === "rust-only") {

…and update the comment to "link-only doesn't run LUT codegen".

(b) Just fix the comment to be accurate, e.g. "rust-only runs the JSSink edge (which spawns perl) but only consumes generated_jssink.rs, which is written before perl is invoked, so a missing perl is non-fatal there; link-only runs no codegen at all." — and accept the stderr noise.

Option (a) is cleaner since the whole point of this check is to fail early with a clear hint rather than emit confusing stderr mid-build.

Comment thread scripts/build/rust.ts
Comment on lines +22 to +24
* link's `--gc-sections` still DCEs per-function. `rustLinkFlags()` wraps
* the archive in `--whole-archive` so members that are *only* referenced via
* the dynamic-list / NAPI surface (no inbound static ref) are retained too.

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: This module doc says (present tense) "rustLinkFlags() wraps the archive in --whole-archive…", implying it's active in the link, but rustLinkFlags() (defined at line 521) has zero callers — bun.ts:470 puts rustObjects directly in the link's $in list and bun.ts:466-468 explicitly says "no --whole-archive wrapping is needed; if a member ever isn't, rustLinkFlags() in rust.ts is the wrapping helper." The two new comments contradict each other; change "wraps" → "can wrap" (or describe it as an available helper) so the module doc matches reality.

Extended reasoning...

What the bug is

The module-level doc comment at scripts/build/rust.ts:22-24 describes the link flow in present indicative:

The staticlib goes into the link's $in list between the C++ objects and the dependency archives; crt1.o's undefined main plus the C++ side's hundreds of extern "C" Bun__*/Zig* references pull every reachable member, and the release link's --gc-sections still DCEs per-function. rustLinkFlags() wraps the archive in --whole-archive so members that are only referenced via the dynamic-list / NAPI surface (no inbound static ref) are retained too.

The bolded sentence reads as describing what the build does. But rustLinkFlags() is never called anywhere — it's a defined-but-unused helper.

Step-by-step proof

  1. grep -rn rustLinkFlags scripts/ returns exactly 3 hits:
    • rust.ts:22 — the module doc sentence in question
    • rust.ts:521export function rustLinkFlags(cfg: Config, libs: string[]): string[] { (the definition)
    • bun.ts:467 — a comment at the actual link site
  2. Zero call sites. The function is exported but not imported anywhere.
  3. The actual link happens at bun.ts:470: link(n, cfg, exeName, [...allObjects, ...rustObjects, ...windowsRes], {...})rustObjects (the libbun_rust.a path) is placed directly in the $in array with no --whole-archive wrapping.
  4. The comment at bun.ts:461-468 is explicit and authoritative:

    rustObjects (the staticlib) sits between cppAll and depLibs … Every #[no_mangle] export the C++ side touches is reached transitively from those roots, so no --whole-archive wrapping is needed; if a member ever isn't, rustLinkFlags() in rust.ts is the wrapping helper.

  5. So bun.ts says "not used, available as fallback"; rust.ts:22 says "wraps" (active). Both comments were added in this PR and contradict each other.

Why this is just a nit

This is prose-only with no behavioral impact:

  • The actual link is correctly configured per the bun.ts call-site comment, which gives a detailed and correct rationale (crt1.o's main + C++'s Bun__*/Zig* extern refs reach every archive member transitively).
  • The original bug report's option (b) — that NAPI/dynamic-list-only #[no_mangle] symbols might actually be silently dropped — has no supporting evidence; bun.ts:465-466 explicitly addresses why that doesn't happen, and emitSmokeTest provides runtime coverage. If symbols were dropped, link errors or test failures would surface it.
  • The function's own JSDoc at line 512-520 is fine (it describes what the function does when called, which is correct).

The only defect is that the module-header overview at lines 18-24 narrates the build flow as if rustLinkFlags() participates in it, when it's actually a dormant helper. A reader following the module doc to understand "what happens at link time" would be misled into thinking whole-archive wrapping is active.

Why nothing prevents it

These are two comments in two files added by the same PR; nothing type-checks prose. The bun.ts comment was clearly written after deciding not to call rustLinkFlags() (it explains why it's unnecessary), but the rust.ts module header was either written earlier or wasn't updated to match.

How to fix

One-word change at rust.ts:22: "rustLinkFlags() wraps" → "rustLinkFlags() can wrap". Or reword the sentence to make the helper's status explicit, e.g.:

--gc-sections still DCEs per-function. (rustLinkFlags() is available to wrap the archive in --whole-archive if a member ever turns out to be referenced only via the dynamic-list / NAPI surface with no inbound static ref — currently unused since every export is reached transitively; see bun.ts.)

This is the same class of zig→rust doc-sync nit as the others already filed on this PR (CLAUDE.md line 268, codegen.ts JSDoc lines 212/215, scripts/build/CLAUDE.md line 146).

Comment thread scripts/build/tools.ts
Comment on lines +578 to +585
const rustup = findTool({ names: ["rustup"], paths: [join(cargoHome, "bin")], required: false })?.path;
const channel = readRustToolchainChannel();
if (rustup !== undefined && channel !== undefined) {
spawnSync(rustup, ["toolchain", "install", channel, "--force", "--profile", "minimal", "--component", "rust-src"], {
encoding: "utf8",
timeout: 300_000,
stdio: ["ignore", "ignore", "inherit"], // surface download/error output
});

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.

🟡 findRustLld() runs rustup toolchain install <channel> --force --profile minimal at configure time on every build, but rust.ts:213-217 (also added in this PR) explicitly warns against exactly this combination: "No --profile minimal: … requesting a narrower profile on a --force reinstall is asking for trouble" — and rust_build_cross accordingly omits --profile. Either drop "--profile", "minimal" here to match rust.ts's reasoning, or remove the rust.ts caveat if it was unfounded; both can't be right.

Extended reasoning...

What the inconsistency is

This PR adds two rustup toolchain install --force call sites that disagree on whether --profile minimal is safe to combine with --force:

scripts/build/tools.ts:581 (findRustLld()):

spawnSync(rustup, ["toolchain", "install", channel, "--force", "--profile", "minimal", "--component", "rust-src"], )

scripts/build/rust.ts:213-217 (comment on rust_build_cross):

No --profile minimal: the agent already has the default profile, and rustup applies --profile to the install spec, not just first-install — requesting a narrower profile on a --force reinstall is asking for trouble. We only care that rust-src and rust-std-<triple> exist on top of whatever profile is there.

And indeed the rust_build_cross rule's command (rust.ts:222) deliberately omits --profile. Both files are new in this PR, so this is an internal contradiction within the same change.

Why it matters where it runs

findRustLld() is called from resolveLlvmToolchain() (tools.ts:487), which runs at configure time on every build mode — local bun bd, rust-only, cpp-only, and link-only CI agents alike (per its own comment at lines 568-577). So unlike rust_build_cross (which only fires when the build edge runs), this --force --profile minimal invocation happens unconditionally on every machine that has rustup + a pinned channel.

If the rust.ts comment's concern is valid — that --force reinstalling with a narrower profile can drop components like rustfmt/clippy from an existing default-profile install — then every bun bd would silently degrade the developer's pinned-nightly toolchain. This PR also adds "fmt:rust": "cargo fmt --all" to package.json (line 64), which depends on rustfmt being present.

Step-by-step trace

  1. Developer has nightly-2026-05-06 installed with the default profile (rustc, cargo, rustfmt, clippy, rust-docs, rust-std).
  2. Developer runs bun bd.
  3. configure()resolveToolchain()resolveLlvmToolchain()findRustLld(os) (tools.ts:487).
  4. findRustLld() finds rustup and reads channel = "nightly-2026-05-06" from rust-toolchain.toml, then runs rustup toolchain install nightly-2026-05-06 --force --profile minimal --component rust-src (tools.ts:581).
  5. Per rust.ts:213-215's own stated concern, rustup "applies --profile to the install spec, not just first-install" — so --force + --profile minimal is, in this PR's own words, "asking for trouble."
  6. If components are dropped, the developer's next bun run fmt:rust fails with "rustfmt not installed".

Why this is a nit, not a blocker

The observable harm is speculative: in practice, modern rustup generally treats the profile as the minimum component set to ensure, not the exact set to enforce, so already-installed components like rustfmt are typically retained across a --force --profile minimal reinstall. The rust.ts comment may be overcautious. But that's exactly the point — one of these two pieces of code is wrong about rustup's behavior, and they were both written in this PR. Either:

  • the rust.ts warning is unfounded → remove it (it actively misleads the next person who touches rust_build_cross), or
  • the rust.ts warning is correct → drop "--profile", "minimal" from tools.ts:581 (the conservative fix; the install still works, just downloads ~30MB more on a fresh agent).

Suggested fix

Drop --profile minimal from tools.ts to match rust.ts's reasoning — it's the conservative choice and costs nothing on agents that already have the default profile (which the rust.ts comment says they do):

spawnSync(rustup, ["toolchain", "install", channel, "--force", "--component", "rust-src"], {

This makes both call sites consistent and removes the risk the rust.ts author was worried about, without needing to settle the question of whether that risk is real.

Comment thread scripts/build/rust.ts
Comment on lines +106 to +117
export const allRustTargets = [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-musl",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc",
"x86_64-unknown-freebsd",
"aarch64-linux-android",
] as const;

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.

🔴 allRustTargets lists 10 triples but rustTarget() produces 12 — it's missing aarch64-unknown-freebsd (which rustTargetIsTier3() at L126 explicitly handles) and x86_64-linux-android, both of which CI builds (visible in this PR's robobun output). This means bun run rust:check-all skips 2 of 12 CI platforms and cargo-config.ts omits their [target.<triple>] linker stanzas. Note: CodeRabbit flagged this earlier and the thread is marked "✅ Addressed in commits d5945cf to 808905b", but those commits don't touch allRustTargets and at HEAD the list still has only 10 entries — the resolution marker is stale.

Extended reasoning...

What the bug is

allRustTargets (scripts/build/rust.ts:106-117) is documented at L102-104 as "All target triples CI builds. Exposed so rust:check-all can iterate". It contains 10 entries. But rustTarget() (L47-57), which maps a build Config to its triple, can return 12 distinct values — the array omits two that CI actually builds:

  • aarch64-unknown-freebsdrustTarget() returns this for cfg.freebsd && !cfg.x64 (L51). rustTargetIsTier3() at L125-126 special-cases exactly this triple, and its doc comment (L122-123) says "the only Tier 3 triple in CI's matrix is aarch64-freebsd". rustCanCrossFromLinux() at L82/L97 explicitly says "freebsd × {x64,aarch64}: yes".
  • x86_64-linux-androidrustTarget() returns this for cfg.abi === 'android' && cfg.x64 (L54). rustCanCrossFromLinux() at L80 says "linux-{gnu,musl,android} × {x64,aarch64}".

Both targets appear in this PR's own robobun CI output: 🐧 x64-android - build-rust and :freebsd: aarch64 - build-rust are running jobs in Build #52948.

The code path that triggers it

allRustTargets has two consumers:

  1. scripts/rust-check-all.ts:21 — iterates the array running cargo check --target <triple> for each. This is wired as bun run rust:check-all in package.json, replacing the old zig:check-all per CLAUDE.md and docs/CI_RUST_MIGRATION.md. It's the dev-facing "does my change compile on every CI platform?" check. With the array short by 2, a developer running rust:check-all gets a green result while CI may still fail on x64-android or aarch64-freebsd — the exact thing this script exists to prevent.

  2. scripts/build/cargo-config.ts:79 — iterates the array to emit [target.<triple>] linker = "..." stanzas in the generated .cargo/config.toml. The two missing triples get no linker stanza, so a local cargo check --target aarch64-unknown-freebsd or --target x86_64-linux-android falls back to default cc resolution instead of the discovered cross-toolchain.

Why nothing prevents it

CI itself is unaffected: the build-rust step calls rustTarget(cfg) directly (not allRustTargets), so all 12 platforms still build. The bug is in dev-facing tooling that's supposed to match CI but silently doesn't. There's no test or assertion that allRustTargets is exhaustive over rustTarget()'s range.

Step-by-step proof

  1. rustTarget({freebsd: true, x64: false, ...})"aarch64-unknown-freebsd" (rust.ts:51).
  2. allRustTargets.includes("aarch64-unknown-freebsd")false (L106-117: only x86_64-unknown-freebsd is listed).
  3. rustTarget({linux: true, abi: "android", x64: true, ...})"x86_64-linux-android" (rust.ts:54).
  4. allRustTargets.includes("x86_64-linux-android")false (L106-117: only aarch64-linux-android is listed).
  5. A developer makes a change that breaks compilation only on aarch64-freebsd (e.g., the libc::max_align_t issue visible in this PR's CI). They run bun run rust:check-all → the loop at rust-check-all.ts:21 never reaches that triple → script reports "10 ok, 0 failed" → developer pushes → CI fails on :freebsd: aarch64 - build-rust.

On the refutation ("duplicate of existing CodeRabbit comment")

CodeRabbit did flag this in inline comment 3211780323 on rust.ts:117. However, that thread is marked resolved="true" with the annotation "✅ Addressed in commits d5945cf to 808905b". I checked: git log d5945cf..808905b -- scripts/build/rust.ts returns commits about cargo-config generation, codegen-output renames, and cross-target nightly fixes — none modify allRustTargets. At HEAD (f843206), L106-117 still lists exactly 10 entries. The resolution marker is incorrect (likely an autofix.ci heuristic mismatch — the same range was applied to several unrelated comments). Since the thread is marked resolved, the author will not see it in their open-conversations view; re-flagging is appropriate precisely because the prior feedback was incorrectly closed.

How to fix

Add the two missing triples to the array:

export const allRustTargets = [
  "x86_64-unknown-linux-gnu",
  "aarch64-unknown-linux-gnu",
  "x86_64-unknown-linux-musl",
  "aarch64-unknown-linux-musl",
  "x86_64-apple-darwin",
  "aarch64-apple-darwin",
  "x86_64-pc-windows-msvc",
  "aarch64-pc-windows-msvc",
  "x86_64-unknown-freebsd",
  "aarch64-unknown-freebsd",
  "x86_64-linux-android",
  "aarch64-linux-android",
] as const;

Optionally, add a unit test or static assertion that derives the set of triples rustTarget() can produce (12 = 2 archs × {darwin, windows, freebsd, linux-gnu, linux-musl, linux-android}) and checks allRustTargets covers it, so future additions to rustTarget() can't silently desync.

@Jarred-Sumner Jarred-Sumner force-pushed the claude/phase-a-port branch from 8b95b37 to 0de544c Compare May 9, 2026 03:35
Comment thread rust-toolchain.toml
Comment on lines +13 to +14
# Mirrors `allRustTargets` in scripts/build/rust.ts minus aarch64-unknown-freebsd
# (Tier 3 — no prebuilt rust-std, cargo `-Zbuild-std` builds it from rust-src).

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.

🟡 The comment "Mirrors allRustTargets in scripts/build/rust.ts minus aarch64-unknown-freebsd" is inaccurate: this targets list has 11 entries including x86_64-linux-android (line 26), but allRustTargets (rust.ts:106-117) has only 10 and omits x86_64-linux-android — so bun run rust:check-all and the generated .cargo/config.toml skip a target CI actually builds. (CodeRabbit flagged the allRustTargets side at rust.ts:117 and marked it "✅ Addressed in commits d5945cf to 808905b", but those changes are not at HEAD dd4b1a5allRustTargets still has 10 entries.)

Extended reasoning...

What the bug is

rust-toolchain.toml:13 claims:

Mirrors allRustTargets in scripts/build/rust.ts minus aarch64-unknown-freebsd (Tier 3 — no prebuilt rust-std, cargo -Zbuild-std builds it from rust-src).

This is doubly inaccurate at HEAD (dd4b1a5):

  1. allRustTargets (scripts/build/rust.ts:106-117) does not contain aarch64-unknown-freebsd, so there is nothing to subtract.
  2. rust-toolchain.toml's targets array (lines 20-32) contains 11 entries including x86_64-linux-android (line 26), while allRustTargets contains only 10 and omits x86_64-linux-android.

So the two lists are not mirrors: the actual delta is +x86_64-linux-android in the toml, not -aarch64-unknown-freebsd.

Step-by-step proof

  1. rust-toolchain.toml:20-32 — count the entries: aarch64-unknown-linux-gnu, x86_64-unknown-linux-gnu, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl, aarch64-linux-android, x86_64-linux-android, x86_64-unknown-freebsd, aarch64-apple-darwin, x86_64-apple-darwin, aarch64-pc-windows-msvc, x86_64-pc-windows-msvc = 11.
  2. scripts/build/rust.ts:106-117allRustTargets has exactly 10 entries: the same set minus x86_64-linux-android. aarch64-unknown-freebsd appears in neither list.
  3. rustTarget() (rust.ts) maps {os:"linux", arch:"x64", abi:"android"}x86_64-linux-android, and the robobun CI comment on this PR shows a 🐧 x64-android - build-rust job — so CI does build this triple via getBuildRustStep directly.
  4. scripts/rust-check-all.ts:21 iterates allRustTargetsbun run rust:check-all silently skips x86_64-linux-android.
  5. scripts/build/cargo-config.ts:79 iterates allRustTargets to emit per-target [target.<triple>] linker sections in the generated .cargo/config.toml → no linker entry is written for x86_64-linux-android.

Why nothing prevents it

The comment is prose; nothing validates it. allRustTargets is the only place the "all triples" list is materialized for dev tooling, and its consumers (rust-check-all.ts, cargo-config.ts) silently iterate whatever it contains. CI itself is unaffected because getBuildRustStep calls rustTarget(cfg) per-platform (not allRustTargets), and emitRust() sets CARGO_TARGET_<TRIPLE>_LINKER via env directly — so the missing .cargo/config.toml section does not break the CI build.

On the existing CodeRabbit comment

CodeRabbit inline comment id=3211780323 on scripts/build/rust.ts:117 flagged the allRustTargets side of this same inconsistency (recommending adding both x86_64-linux-android and aarch64-unknown-freebsd). It is marked resolved=true with "✅ Addressed in commits d5945cf to 808905b". However, at HEAD dd4b1a5 allRustTargets still has exactly 10 entries — those commits' changes are not present in the PR branch. The auto-resolution appears to be incorrect, which is why re-flagging this from the rust-toolchain.toml angle has independent value.

Impact

Minor — this only affects developer-convenience tooling, not CI correctness:

  • bun run rust:check-all (the zig:check-all replacement) skips one target that CI exercises, so a developer running it locally before pushing could miss x64-android-only cfg errors.
  • The generated .cargo/config.toml lacks a [target.x86_64-linux-android] linker section, so a direct cargo build --target x86_64-linux-android from a dev machine may fail to find the cross-linker (CI is fine because emitRust() passes the linker via env).
  • The comment itself is misleading for anyone trying to keep the two lists in sync.

How to fix

Either:

  • Add "x86_64-linux-android" to allRustTargets in scripts/build/rust.ts (and optionally "aarch64-unknown-freebsd" per the original CodeRabbit suggestion, though that one is Tier 3 and rust:check-all would skip it anyway since rustup target add fails for it). This makes the comment true.
  • Or update the comment at rust-toolchain.toml:13 to accurately describe the actual delta, e.g. "Superset of allRustTargets in scripts/build/rust.ts: includes x86_64-linux-android (in CI matrix but not yet in allRustTargets); excludes aarch64-unknown-freebsd (Tier 3 — …)."

The first option is preferable since it fixes the functional gap in rust:check-all rather than just documenting it.

Comment thread scripts/build/tools.ts
const rustup = findTool({ names: ["rustup"], paths: [join(cargoHome, "bin")], required: false })?.path;
const channel = readRustToolchainChannel();
if (rustup !== undefined && channel !== undefined) {
spawnSync(rustup, ["toolchain", "install", channel, "--force", "--profile", "minimal", "--component", "rust-src"], {

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.

🟡 This passes --force --profile minimal to rustup toolchain install, but scripts/build/rust.ts:213-217 (also new in this PR) explicitly documents not passing --profile minimal with --force ("requesting a narrower profile on a --force reinstall is asking for trouble") and accordingly omits it at line 222. findRustLld() runs at configure time on every bun bd — including local dev machines with full default-profile installs and the same CI agents rust.ts is protecting — so the two call sites apply contradictory policies to the same toolchain. Either drop --profile minimal here to match rust.ts's reasoning, or update the rust.ts comment if it's overly cautious.

Extended reasoning...

What the inconsistency is

This PR adds two pieces of code that both run rustup toolchain install <channel> --force against the pinned nightly, but with directly contradictory rationale about --profile:

scripts/build/rust.ts:213-217 (the comment above the rust_build_cross ninja rule) says:

No --profile minimal: the agent already has the default profile, and rustup applies --profile to the install spec, not just first-install — requesting a narrower profile on a --force reinstall is asking for trouble. We only care that rust-src and rust-std-<triple> exist on top of whatever profile is there.

…and accordingly rust.ts:222 runs:

rustup toolchain install ${cfg.rustToolchain} --force --component rust-src $rust_target_arg

with --profile deliberately omitted.

scripts/build/tools.ts:581 (findRustLld()) does the opposite:

spawnSync(rustup, ["toolchain", "install", channel, "--force", "--profile", "minimal", "--component", "rust-src"], ...)

Both lines were added in this same PR.

Why they hit the same toolchain on the same machines

findRustLld() is called from resolveLlvmToolchain() (tools.ts:487), which is called from resolveToolchain() (configure.ts:31), which runs unconditionally during every bun bd configure pass — on local dev machines and on every CI agent. The rust_build_cross rule then runs at ninja time on the build-rust agents. So on a CI build-rust agent, configure first runs the tools.ts version (--force --profile minimal), and then ninja runs the rust.ts version (--force, no profile) — two consecutive invocations with contradictory flags against the same ~/.rustup/toolchains/<channel>-<host>/.

On a local dev machine (which typically has the default profile installed — i.e. rustfmt, clippy, rust-docs), only the configure-time tools.ts call runs (since the ninja-time rule may not fire on a non-cross host), so the version with --profile minimal is the one that always executes.

Step-by-step proof of the contradiction

  1. Read rust.ts:213-217: explicit, considered rationale for not passing --profile minimal with --force.
  2. Read rust.ts:222: --profile is absent, matching that rationale.
  3. Read tools.ts:581: "--force", "--profile", "minimal" — does exactly what the rust.ts comment says is "asking for trouble".
  4. Trace callers: tools.ts:487configure.ts:31 → runs on every configure, on every host. There is no gate that restricts findRustLld() to CI link-only agents (the comment at tools.ts:568-577 mentions link-only as the motivation, but the call is unconditional).
  5. Therefore one of the two pieces of reasoning in this PR is wrong, and they target the same install.

Why this is a nit and not a normal bug

The actual harm is uncertain. Per rustup's documented semantics, --force means "force an update, even if some of the requested components are missing" — it does not mean "wipe and reinstall from scratch". And --profile selects the initial component set; on an already-installed toolchain rustup typically just ensures the requested components exist and updates to the latest build of that channel without removing extra components. So the rust.ts comment may be overly cautious FUD rather than describing real component-stripping behavior, and tools.ts:581 may in practice be harmless.

But that's exactly the point: the PR contains two pieces of new code, one of which carries a multi-line comment explaining why pattern X is dangerous, and the other of which does pattern X. Either:

  • (a) rust.ts's concern is valid → tools.ts:581 should drop --profile minimal (and possibly --force, since the tools.ts use case is "ensure the toolchain exists for sysroot lookup", not "repair a broken-manifest install" — a plain toolchain install <channel> --component rust-src is idempotent and sufficient there); or
  • (b) rust.ts's concern is overstated → the rust.ts:213-217 comment should be removed or rewritten so future readers aren't misled into thinking --profile minimal is unsafe.

Either way the two should be reconciled before merging.

How to fix

Simplest: drop "--profile", "minimal" from tools.ts:581 to match rust.ts:222. This makes both call sites consistent with the documented rationale and costs nothing (rustup will just keep whatever profile is already installed, plus rust-src).

Comment thread scripts/build/rust.ts
Comment on lines +106 to +117
export const allRustTargets = [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-musl",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc",
"x86_64-unknown-freebsd",
"aarch64-linux-android",
] as const;

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.

🟡 allRustTargets (doc'd as "All target triples CI builds") is missing aarch64-unknown-freebsd and x86_64-linux-android, both of which rustTarget() returns and CI's buildPlatforms actually builds (.buildkite/ci.mjs:136,140) — so rust:check-all and generateCargoConfig() silently skip them. Note: CodeRabbit already flagged this and the thread is marked "✅ Addressed in commits d5945cf to 808905b", but no commit in that range touched this array (808905b is a docs-only autofix; 71348ae added rustTargetIsTier3() referencing aarch64-unknown-freebsd after the array but didn't add it to the array) — at HEAD it still has 10 entries.

Extended reasoning...

What the bug is

allRustTargets at scripts/build/rust.ts:106-117 is a 10-entry const array whose doc comment (line 103) says "All target triples CI builds. Exposed so rust:check-all can iterate cargo check --target <t> without re-deriving the list." It omits two triples that the rest of the file and CI both treat as supported:

  • aarch64-unknown-freebsd — produced by rustTarget() line 51 when cfg.freebsd && !cfg.x64; explicitly handled by rustTargetIsTier3() at line 126 (whose own doc comment at line 122-123 says it's "the only Tier 3 triple in CI's matrix"); built by CI per .buildkite/ci.mjs:140 ({os:'freebsd', arch:'aarch64'}).
  • x86_64-linux-android — produced by rustTarget() line 54 when cfg.abi === 'android' && cfg.x64; built by CI per .buildkite/ci.mjs:136 ({os:'linux', arch:'x64', abi:'android'}).

The two consumers

allRustTargets has exactly two callers:

  1. scripts/rust-check-all.ts:21 iterates it to run cargo check --target <triple>. With these two triples missing, bun run rust:check-all never type-checks the freebsd-aarch64 or android-x64 cfg paths. A contributor could land code that fails cargo check only on those targets and not find out until the actual CI build step.

  2. scripts/build/cargo-config.ts:79 iterates it to emit [target.<triple>] linker = "..." sections into the generated .cargo/config.toml. With these two triples missing, a developer running cargo build --target aarch64-unknown-freebsd directly (outside the ninja-driven build) gets no linker configured for that target.

Why this is a nit, not a normal-severity bug

The actual CI build path is not broken: emitRust() sets CARGO_TARGET_<TRIPLE>_LINKER as an env var on the ninja edge (rust.ts line ~337), so the ninja-invoked cargo build for these targets gets the right linker regardless of what's in .cargo/config.toml. And rust-check-all.ts is a developer-convenience script (it already skips any target whose std isn't rustup target add-installed locally), not a CI gate. The impact is limited to (a) two triples missing from local cross-target type-check coverage and (b) direct-cargo invocations losing linker config for those triples — both real but low-impact gaps in tooling consistency.

Why re-flag despite the resolved marker

CodeRabbit's inline comment 3211780323 already flagged exactly this and shows resolved="true" with "✅ Addressed in commits d5945cf to 808905b". I checked: in that range, 808905b is an autofix.ci commit touching only docs/*.md; 71348ae did touch rust.ts but only to add rustTargetIsTier3() after the array (the diff context line is @@ export const allRustTargets = [] as const; followed by the new function — the array body is unchanged); 7636b8e is the commit that created the 10-entry array; the others (f8fd66c, 1e6186b, dbf906b) don't touch the array. git show HEAD:scripts/build/rust.ts | sed -n '106,117p' confirms 10 entries at HEAD. The resolution marker is a false positive — without re-flagging, this ships with the doc comment claiming completeness it doesn't have.

Step-by-step proof

  1. rustTarget({freebsd:true, x64:false, ...}) → line 51 returns "aarch64-unknown-freebsd".
  2. rustTarget({linux:true, abi:'android', x64:true, ...}) → line 54 returns "x86_64-linux-android".
  3. .buildkite/ci.mjs buildPlatforms contains {os:'freebsd', arch:'aarch64'} (line 140) and {os:'linux', arch:'x64', abi:'android'} (line 136), so both feed through getBuildRustStep()getBuildArgs(... 'rust-only')rustTarget().
  4. allRustTargets (lines 106-117) lists exactly: linux-gnu×2, linux-musl×2, apple-darwin×2, pc-windows-msvc×2, x86_64-unknown-freebsd, aarch64-linux-android = 10 entries. Neither triple from steps 1-2 is present.
  5. rust-check-all.ts:21 for (const triple of allRustTargets) therefore never reaches either triple.
  6. cargo-config.ts:79 for (const triple of allRustTargets) therefore writes no [target.aarch64-unknown-freebsd] or [target.x86_64-linux-android] section.

How to fix

Add the two entries (CodeRabbit's committable suggestion was correct):

export const allRustTargets = [
  "x86_64-unknown-linux-gnu",
  "aarch64-unknown-linux-gnu",
  "x86_64-unknown-linux-musl",
  "aarch64-unknown-linux-musl",
  "x86_64-apple-darwin",
  "aarch64-apple-darwin",
  "x86_64-pc-windows-msvc",
  "aarch64-pc-windows-msvc",
  "x86_64-unknown-freebsd",
  "aarch64-unknown-freebsd",
  "x86_64-linux-android",
  "aarch64-linux-android",
] as const;

Note that rust-check-all.ts will still skip aarch64-unknown-freebsd locally unless -Zbuild-std handling is added there too (it's Tier 3, no rustup target add), but listing it keeps the array honest with its doc comment and lets cargo-config.ts emit the linker section.

Comment thread scripts/build/flags.ts
join(cwd, "src/jsc/modules"),
join(cwd, "src/js/builtins"),
join(cwd, "src/napi"),
join(cwd, "src/runtime/napi"),

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.

🟡 This PR moves src/napi/src/runtime/napi/ (commit 0336c86) and updates the include path here, but scripts/run-clang-format.sh:35 still excludes via [[ "$file" =~ src/napi/ ]], which won't match src/runtime/napi/... — so format.yml/autofix.ci will now run clang-format over the vendored upstream Node-API headers (js_native_api.h, node_api.h, etc.). Note line 34 immediately above (src/runtime/ffi/ffi-) was updated for the same tier-6 collapse, so line 35 was simply missed. .github/workflows/CLAUDE.md:24 also still documents the stale src/napi/ path.

Extended reasoning...

What the bug is

This PR's tier-6 collapse (documented in the PR's own docs/CYCLEBREAK.md: "napi become[s] modules of bun_runtime") moves src/napi/src/runtime/napi/. The PR updates scripts/build/flags.ts:1126 to reflect the new include path (join(cwd, "src/napi")join(cwd, "src/runtime/napi")), but the clang-format exclusion list in scripts/run-clang-format.sh was not updated: line 35 still reads [[ "$file" =~ src/napi/ ]]. The bash regex pattern src/napi/ requires the literal sequence s-r-c-/-n-a-p-i-/, which does not appear in src/runtime/napi/js_native_api.h (the character before napi/ is /, but the character before that is e, not c).

The code path that triggers it

scripts/run-clang-format.sh:43 runs find src packages -type f \( -name "*.h" -o -name "*.hpp" \), which now discovers:

  • src/runtime/napi/js_native_api.h
  • src/runtime/napi/js_native_api_types.h
  • src/runtime/napi/node_api.h
  • src/runtime/napi/node_api_types.h

The exclusion loop at lines 32-41 then tests each file against five regex patterns. None match (src/napi/ doesn't, and the others are for ffi/libuv/sqlite/usockets/vendor), so all four headers are appended to FILES_TO_FORMAT and passed to clang-format -i.

Why nothing prevents it

The sibling exclusion at line 34 (src/runtime/ffi/ffi-) was updated for the same restructuring — proving the intent was to keep these exclusions current with the directory layout, and that line 35 was simply overlooked in the same sweep. There is no test or CI check that verifies the exclusion list points at directories that exist.

Step-by-step proof

  1. ls src/napiNo such file or directory (verified).
  2. ls src/runtime/napi/js_native_api.h js_native_api_types.h mod.rs napi.zig napi_body.rs node_api.h node_api_types.h (verified).
  3. git diff <merge-base>..HEAD --name-only shows both src/napi/node_api*.h (deleted) and src/runtime/napi/* (added) — the move is part of this PR's 2082-file, 116-commit range, landing in commit 0336c86.
  4. scripts/run-clang-format.sh is not in the PR's changed-files list — the script was not touched.
  5. In bash, [[ "src/runtime/napi/node_api.h" =~ src/napi/ ]] → exit 1 (no match). The exclusion is dead.
  6. .github/workflows/format.yml invokes this script via bun run clang-format, and autofix-ci commits the result.

Impact

The next format.yml run (and every subsequent autofix.ci pass) will reformat four vendored upstream Node-API headers to Bun's local .clang-format style. This doesn't break the build or change runtime behavior, but it (a) pollutes git blame/history on third-party code, (b) creates spurious diffs that complicate future syncs from nodejs/node upstream, and (c) is exactly the outcome the exclusion was written to prevent. Hence nit rather than normal — concrete unwanted CI behavior, but cosmetic/maintenance impact only.

How to fix

In scripts/run-clang-format.sh:35, change:

[[ "$file" =~ src/napi/ ]] || \

to:

[[ "$file" =~ src/runtime/napi/ ]] || \

And in .github/workflows/CLAUDE.md:24, change src/napi/src/runtime/napi/ to keep the documentation in sync.

Comment thread scripts/build/tools.ts
Comment on lines +578 to +583
const rustup = findTool({ names: ["rustup"], paths: [join(cargoHome, "bin")], required: false })?.path;
const channel = readRustToolchainChannel();
if (rustup !== undefined && channel !== undefined) {
spawnSync(rustup, ["toolchain", "install", channel, "--force", "--profile", "minimal", "--component", "rust-src"], {
encoding: "utf8",
timeout: 300_000,

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.

🟡 This PR's own rust.ts:213-217 documents why --profile minimal is omitted from the rust_build_cross rustup invocation ("requesting a narrower profile on a --force reinstall is asking for trouble"), but findRustLld() here — which runs unconditionally at configure time on every bun bd, before rust_build_cross — does exactly that with --force --profile minimal. If the rust.ts comment's reasoning is correct, this could narrow a default-profile install on dev machines (breaking bun run fmt:rust/rust:clippy); if it's not, the rust.ts comment is misleading. Either drop --profile minimal here to match, or update the rust.ts comment.

Extended reasoning...

What the inconsistency is

This PR adds two new rustup toolchain install --force invocations targeting the same pinned channel, with contradictory positions on --profile minimal:

scripts/build/rust.ts:213-217 (above the rust_build_cross ninja rule):

No --profile minimal: the agent already has the default profile, and rustup applies --profile to the install spec, not just first-install — requesting a narrower profile on a --force reinstall is asking for trouble.

and accordingly line 222 omits --profile:

toolchain install ${cfg.rustToolchain} --force --component rust-src $rust_target_arg

scripts/build/tools.ts:581 (inside findRustLld()):

spawnSync(rustup, ["toolchain", "install", channel, "--force", "--profile", "minimal", "--component", "rust-src"], { ... });

Both target the same channel (readRustToolchainChannel() reads the same rust-toolchain.toml in both files). The two pieces of code disagree on whether --force --profile minimal is safe.

Why the ordering matters

findRustLld() is called from resolveLlvmToolchain() at tools.ts:487, which runs unconditionally at configure time on every bun bd (per scripts/build/CLAUDE.md's configure-flow description, and confirmed by workarounds.ts:94 which documents this same call chain). The comment at tools.ts:568-577 explains the configure-time install exists so the link-only CI agent has the pinned nightly before rustc --print sysroot is queried — but the call is not gated to link-only CI; it fires on every developer's bun bd too.

rust_build_cross runs at ninja time, strictly after configure. So if the rust.ts comment's hazard is real, tools.ts has already triggered it before rust.ts's careful avoidance ever runs.

Step-by-step

  1. Developer has the pinned nightly installed with the default profile (rustfmt, clippy, rust-docs) — the normal state after rustup auto-installs from rust-toolchain.toml on first cargo invocation.
  2. Developer runs bun bd.
  3. Configure → resolveToolchain()resolveLlvmToolchain()findRustLld() (tools.ts:487).
  4. findRustLld() finds rustup and the pinned channel, runs rustup toolchain install <channel> --force --profile minimal --component rust-src (tools.ts:581).
  5. If rust.ts:213-216's reasoning is correct ("rustup applies --profile to the install spec, not just first-install"), this reinstalls the toolchain to the minimal profile, removing rustfmt/clippy/rust-docs.
  6. Later, ninja runs rust_build_cross which deliberately omits --profile minimal — but the damage from step 4 is already done.
  7. bun run fmt:rust (cargo fmt --all, package.json:64) and bun run rust:clippy (package.json:73) — both new in this PR — would then fail with "component 'rustfmt' is not installed".

Addressing the refutation

One verifier argued this is a non-issue because rustup's --profile is additive (it specifies which components to ensure are present, not which to remove) and --force means "proceed even if components are unavailable", not "wipe and reinstall to exact spec". Under that reading, step 5 is a fast no-op for the extra components, and the tools.ts:575 comment "idempotent ~70ms when already installed" suggests the author tested exactly this.

That may well be correct — rustup's documented behavior leans that way. But it means the rust.ts:213-216 comment is wrong, and that comment is the only documented rationale for why rust_build_cross omits --profile minimal. So the inconsistency stands either way:

  • If rust.ts's comment is right → tools.ts:581 risks stripping rustfmt/clippy on dev machines, and runs first.
  • If rust.ts's comment is wrong → it's misleading documentation that should be removed (and --profile minimal could be added to rust_build_cross for symmetry / faster fresh-install).

Both code paths are new in this PR, so this is a self-introduced contradiction, not a pre-existing issue.

Why nothing prevents it

There's no test for component presence after configure, and CI agents are typically provisioned with minimal profiles anyway (so wouldn't notice). The two contexts also differ in intent: tools.ts:568-577 is written for link-only CI agents that may have no toolchain (where --profile minimal minimizes a fresh download), while rust.ts:213-217 is written for build agents that already have the default profile. Neither comment acknowledges that the tools.ts call also runs on the latter class of machine.

How to fix

Simplest: drop "--profile", "minimal" from tools.ts:581 to match rust.ts:222's reasoning. rustup toolchain install --force without --profile uses the configured default profile and won't narrow an existing install regardless of which interpretation of rustup's behavior is correct. The first-install download on a bare link-only CI agent gets slightly larger (default vs minimal profile), but that's a one-time cost per agent.

Alternatively, if the tools.ts behavior has been verified as safe, delete or correct the rust.ts:213-216 comment so the two sites don't contradict each other.

Comment thread scripts/build/rust.ts
Comment on lines +106 to +117
export const allRustTargets = [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-musl",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc",
"x86_64-unknown-freebsd",
"aarch64-linux-android",
] as const;

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.

🟡 allRustTargets omits x86_64-linux-android, but rust-toolchain.toml:26 lists it (with the comment "Mirrors allRustTargets in scripts/build/rust.ts"), rustTarget() produces it, and CI builds it (robobun shows a 🐧 x64-android - build-rust job on this PR). This means bun run rust:check-all skips a target CI builds — add "x86_64-linux-android" to the array. (Note: coderabbitai flagged this earlier but it was auto-marked "✅ Addressed in commits d5945cf to 808905b" — those commits are about bun.sys.sigaction and did not touch this array, so the omission persists at HEAD.)

Extended reasoning...

What the bug is

allRustTargets at scripts/build/rust.ts:106-117 is documented (lines 102-104) as "All target triples CI builds. Exposed so rust:check-all can iterate cargo check --target <t>". It contains 10 entries but omits x86_64-linux-android, even though that triple is part of the CI build matrix and is listed in the file that claims to mirror this array.

Evidence the omission is unintentional

Four independent sources establish that x86_64-linux-android is a supported, CI-built target that allRustTargets should include:

  1. rust-toolchain.toml:26 lists "x86_64-linux-android" in targets = [...], and the comment at line 13 explicitly says "Mirrors allRustTargets in scripts/build/rust.ts minus aarch64-unknown-freebsd". So the toolchain file declares itself the mirror of this array — and it has 11 Tier 1/2 entries while allRustTargets has 10.
  2. rustTarget() at scripts/build/rust.ts:54 produces x86_64-linux-android when cfg.abi === "android" && cfg.x64, so the build system models it as a valid target.
  3. rustCanCrossFromLinux() at line 96 returns true for it (cfg.linux covers android), so it cross-compiles from the shared Linux box like its aarch64 sibling.
  4. CI actually builds it: the robobun comment on this PR shows a 🐧 x64-android - build-rust job (Build #52979), proving the buildkite pipeline includes it.

The aarch64-linux-android entry is already in the array at line 116, so the asymmetry (aarch64-android present, x64-android absent) is clearly an oversight rather than a deliberate exclusion.

Why the existing coderabbitai comment doesn't cover it

CodeRabbit raised this exact issue in inline-comment 3211780323 (at line 117), but that comment is now marked resolved="true" with the footer "✅ Addressed in commits d5945cf to 808905b". Checking those commits: d5945cf is PR #30389 "Add bun.sys.sigaction" (Android bionic struct layout), and 808905b is an autofix.ci formatting commit. Neither touches scripts/build/rust.tsgit log -S 'x86_64-linux-android' -- scripts/build/rust.ts returns nothing. The "addressed" marker is a misattribution (coderabbit's auto-resolution heuristic matched on the word "android" in unrelated commits), and since the comment is now hidden as resolved it will not surface to the author. Re-flagging is therefore warranted.

Step-by-step proof

  1. Read scripts/build/rust.ts:106-117 at HEAD (0eae3f3): the array contains exactly x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-unknown-linux-musl, aarch64-unknown-linux-musl, x86_64-apple-darwin, aarch64-apple-darwin, x86_64-pc-windows-msvc, aarch64-pc-windows-msvc, x86_64-unknown-freebsd, aarch64-linux-android — 10 entries, no x86_64-linux-android.
  2. Read rust-toolchain.toml:20-32: targets contains 11 entries including x86_64-linux-android at line 26.
  3. scripts/rust-check-all.ts imports allRustTargets and iterates it, calling cargo check --target <triple> for each. With the entry missing, a developer running bun run rust:check-all locally will check 10 targets and get a green result — but a #[cfg(all(target_os = "android", target_arch = "x86_64"))] compile error would still fail the 🐧 x64-android - build-rust CI job.
  4. Diff rust-toolchain.toml targets vs allRustTargets: the only delta (besides the documented Tier-3 aarch64-unknown-freebsd exclusion) is x86_64-linux-android — present in the former, absent in the latter.

Impact

Low — this only affects the local bun run rust:check-all convenience script (scripts/rust-check-all.ts), not CI itself. CI builds x64-android via the buildkite pipeline (.buildkite/ci.mjs iterates buildPlatforms), not via this list, so any x64-android-specific compile error would be caught — just one push later than if rust:check-all had covered it locally. The bug is a tooling-consistency gap, not a correctness hole.

Note on the secondary aarch64-unknown-freebsd claim from the original report: that omission is intentional per rust-toolchain.toml:13-14 ("minus aarch64-unknown-freebsd, Tier 3 — no prebuilt rust-std"). rust-check-all.ts would need -Zbuild-std plumbing to handle Tier 3, which is a separate feature; the actionable fix here is just the x86_64-linux-android entry.

How to fix

Add one line to allRustTargets:

export const allRustTargets = [
  // ...
  "x86_64-unknown-freebsd",
  "x86_64-linux-android",
  "aarch64-linux-android",
] as const;

Comment on lines +214 to 216
// rust-only/link-only don't run LUT codegen — skip the check so split-CI
// steps don't require perl on the rust cross-compile box.
if (cfg.mode === "full" || cfg.mode === "cpp-only") {

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.

🔴 The comment at line 214 says "rust-only/link-only don't run LUT codegen", but this PR adds o.rustInputs.push(jssinkRs) at codegen.ts:954 (with an explicit comment at codegen.ts:951-953: "Without this, mode: rust-only ... never runs this edge"), so rust-only now does run the JSSink ninja edge, which spawns create-hash-table.tsperl. Before this PR zig-only genuinely skipped JSSink (it had no zigInputs entry); the comment here was just text-replaced zig-onlyrust-only without re-evaluating. Either add || cfg.mode === "rust-only" to line 216 so a missing perl on the rust cross-compile agent fails at configure time with the clear hint instead of a cryptic spawn error mid-codegen, or update the comment if perl is in fact guaranteed on those agents (docs/CI_RUST_MIGRATION.md §Agent image requirements doesn't mention it).

Extended reasoning...

What the bug is

configure.ts:211-222 contains a configure-time perl check, gated by cfg.mode === "full" || cfg.mode === "cpp-only". The justifying comment at lines 214-215 says "rust-only/link-only don't run LUT codegen — skip the check so split-CI steps don't require perl on the rust cross-compile box". That claim is false after this PR: rust-only mode now runs the JSSink codegen edge, and that edge invokes perl. The comment was mechanically renamed from zig-onlyrust-only (diff at configure.ts shows a pure text replacement of zig-only/link-onlyrust-only/link-only and zig cross-compile boxrust cross-compile box, with the condition on line 216 left untouched), but the underlying invariant the comment describes was simultaneously broken by another change in the same PR.

The specific code path that triggers it

  1. emitRustOnly() at bun.ts:531-550 is the rust-only mode entry point. It calls emitCodegen(n, cfg, sources) (line 545), which calls emitJsSink() (codegen.ts:302), then passes codegen.rustInputs as codegenInputs to emitRust() (line 548). Those become implicit inputs to the cargo ninja edge.
  2. emitJsSink() at codegen.ts:920-955 declares a single ninja edge with four outputs: JSSink.cpp, JSSink.h, JSSink.lut.h, and generated_jssink.rs. This PR adds line 954: o.rustInputs.push(jssinkRs) — brand new, not a rename (the pre-PR emitJsSink pushed only to cppSources/cppHeaders, never to zigInputs). The PR's own comment at codegen.ts:951-953 is unambiguous about why: "Without this, mode: \"rust-only\" (CI's build-rust job, which compiles no C++ so nothing else pulls JSSink.cpp/.h) never runs this edge and cargo hits the missing file."
  3. The JSSink ninja edge runs generate-jssink.ts (codegen.ts:933-943), which at line 1210-1217 unconditionally Bun.spawnSyncs create-hash-table.ts.
  4. create-hash-table.ts:23 spawns perl: spawn({ cmd: ["perl", create_hash_table, "-"], ... }).

So in rust-only mode: ninja sees the cargo edge depends on generated_jssink.rs → runs the JSSink edge → runs generate-jssink.ts → spawns create-hash-table.ts → spawns perl. If perl is absent, this fails mid-build.

Why existing code doesn't prevent it

The whole point of the configure-time check at lines 216-222 is to catch missing-perl early with a clear hint ("LUT codegen (create-hash-table.ts) needs perl. Install it: apt install perl / brew install perl"). But because the condition excludes rust-only, that mode skips the check entirely. The comment justifying the skip is now factually wrong — and is in fact directly contradicted by the comment this same PR adds at codegen.ts:951-953.

The PR's docs/CI_RUST_MIGRATION.md §"Agent image requirements" lists rustup, the pinned nightly, rust-src, and rustup target add — but does not mention perl. Per .buildkite/ci.mjs getRustAgent(), the rust-only step runs on (a) the shared r8g.2xlarge linux-aarch64 box for linux/freebsd/android targets, (b) the build-darwin queue for macOS, and (c) Azure Windows agents for Windows. Perl is ubiquitous on Linux/macOS base images so this likely won't fire there in practice, but Windows agents are the most plausible place for perl to be absent — and even on the Linux box, the design intent recorded in the (now-stale) comment was explicitly that the rust cross-compile box should not need perl.

Step-by-step proof

Before this PR:

  • emitJsSink pushed nothing to zigInputs. The diff confirms this: the o.rustInputs.push(jssinkRs) line at codegen.ts:954 appears as a pure + addition (diff line 13013), not a zigInputsrustInputs rename like the other dozen sites in the same file.
  • Therefore emitZigOnly()emitCodegen()codegen.zigInputs did not include any JSSink output, so ninja never scheduled the JSSink edge in zig-only mode.
  • The comment "zig-only/link-only don't run LUT codegen" was true.

After this PR:

  • emitJsSink pushes jssinkRs to rustInputs (line 954).
  • emitRustOnly() passes rustInputs to the cargo edge (bun.ts:548).
  • Ninja schedules the JSSink edge in rust-only mode → perl is invoked.
  • The comment "rust-only/link-only don't run LUT codegen" is false.
  • The condition at line 216 still excludes rust-only from the perl check.

Impact

If perl is missing on a rust-only build agent, the build fails at codegen time with a spawn ENOENT-style error from create-hash-table.ts:23 — exactly the "fails cryptically" scenario the configure-time check exists to prevent. The user sees no hint about installing perl. This is most likely to bite on Windows build-rust agents (which run natively per getRustAgent()) where perl is not a default system component, or on any minimal container image used for the linux cross-compile box.

How to fix

Either:

(a) Add || cfg.mode === "rust-only" to the condition on line 216, and update the comment to reflect that only link-only skips LUT codegen:

// link-only doesn't run LUT codegen — skip the check so the link-only CI
// step doesn't require perl. rust-only DOES need it (JSSink codegen → perl).
if (cfg.mode === "full" || cfg.mode === "cpp-only" || cfg.mode === "rust-only") {

(b) If perl is in fact guaranteed on every build-rust agent, just fix the stale comment at lines 214-215 (and ideally add perl to docs/CI_RUST_MIGRATION.md §Agent image requirements so the guarantee is documented).

Option (a) is the conservative fix — the check is cheap (findSystemTool("perl")) and the failure mode it guards against is real.

Comment thread rust-toolchain.toml
Comment on lines +13 to +14
# Mirrors `allRustTargets` in scripts/build/rust.ts minus aarch64-unknown-freebsd
# (Tier 3 — no prebuilt rust-std, cargo `-Zbuild-std` builds it from rust-src).

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: This comment says the targets list "Mirrors allRustTargets in scripts/build/rust.ts minus aarch64-unknown-freebsd", but it doesn't — this file includes x86_64-linux-android (line 26) while allRustTargets (rust.ts:106-117) omits it, and allRustTargets doesn't contain aarch64-unknown-freebsd to subtract in the first place. Since rustTarget() (rust.ts:54) returns x86_64-linux-android for real builds, bun run rust:check-all silently skips a supported target. Note the related coderabbit comment on rust.ts:117 was marked "Addressed in commits d5945cf to 808905b" / resolved, but at HEAD allRustTargets still has only 10 entries — that fix did not land. Either add x86_64-linux-android to allRustTargets or fix this comment.

Extended reasoning...

What the bug is

rust-toolchain.toml lines 13-14 carry a comment claiming the targets = [...] list "Mirrors allRustTargets in scripts/build/rust.ts minus aarch64-unknown-freebsd (Tier 3 — no prebuilt rust-std)." This claim is doubly inaccurate:

  1. rust-toolchain.toml line 26 includes "x86_64-linux-android", but allRustTargets at scripts/build/rust.ts:106-117 does not — it lists only aarch64-linux-android. So the toolchain file has a target that the "mirrored" array lacks.
  2. allRustTargets does not contain aarch64-unknown-freebsd in the first place, so describing the relationship as "allRustTargets minus aarch64-unknown-freebsd" is also wrong — there is nothing to subtract.

The toolchain file has 11 entries; allRustTargets has 10; the symmetric difference is {x86_64-linux-android}, not {aarch64-unknown-freebsd}.

Why it matters (the functional gap behind the comment drift)

rustTarget() at scripts/build/rust.ts:47-57 returns `${arch}-linux-android` for cfg.abi === "android" with either arch — so x86_64-linux-android is a real build target the system can produce. allRustTargets is what scripts/rust-check-all.ts (the bun run rust:check-all script) iterates, so cargo check --target x86_64-linux-android is silently never run by that dev convenience script even though (a) rustup installs std for it via rust-toolchain.toml, and (b) the build system can target it.

Step-by-step proof

  1. rust-toolchain.toml:13-14: # Mirrors allRustTargets in scripts/build/rust.ts minus aarch64-unknown-freebsd
  2. rust-toolchain.toml:20-32: 11 targets, including line 26 "x86_64-linux-android".
  3. scripts/build/rust.ts:106-117: allRustTargets has exactly 10 string literals; grep x86_64-linux-android over those lines → 0 matches; grep aarch64-unknown-freebsd → 0 matches.
  4. scripts/build/rust.ts:54: if (cfg.abi === "android") return \${arch}-linux-android`;— withcfg.x64 === truethis yieldsx86_64-linux-android`.
  5. scripts/rust-check-all.ts iterates allRustTargets → never checks x86_64-linux-android.
  6. Therefore the comment's "mirrors … minus …" claim is false in both directions, and a supported target is skipped by rust:check-all.

Relationship to the (incorrectly resolved) coderabbit comment

CodeRabbit inline-comment 3211780323 on scripts/build/rust.ts:117 already suggested adding both x86_64-linux-android and aarch64-unknown-freebsd to allRustTargets. That comment is marked resolved=true with "✅ Addressed in commits d5945cf to 808905b" — but at PR HEAD (4aef65c3), allRustTargets still has exactly 10 entries with neither triple present. Inspection of commit 808905b shows it is an autofix.ci formatting commit touching only docs/.rust-rewrite-verified-claims.md, docs/RUST_IDIOMS_AUDIT.md, and docs/zig-restructure-plan.md — not scripts/build/rust.ts. So the coderabbit auto-resolution heuristic misfired and the underlying issue is still open.

This finding is therefore (a) framed on a different file (rust-toolchain.toml's stale comment, both files added by this PR) and (b) flags that a previously-marked-resolved review comment did not actually land.

Impact

Minor. This is comment-accuracy drift plus a coverage gap in a developer-convenience script (rust:check-all), not a build-correctness issue — CI builds drive rustTarget(cfg) per-platform rather than iterating allRustTargets, so production builds are unaffected. Hence nit severity.

How to fix

Either:

  • Add "x86_64-linux-android" to allRustTargets in scripts/build/rust.ts (the cleaner fix, since rustTarget() already supports it; optionally also add "aarch64-unknown-freebsd" so the comment's "minus" clause becomes accurate), or
  • Rewrite the rust-toolchain.toml:13-14 comment to accurately describe the actual relationship between the two lists.

🔬 also observed by coderabbitai

Comment thread bench/bundle/index.ts
Comment on lines +1 to +10
export * as Three1 from 'three-1';
export * as Three2 from 'three-2';
export * as Three3 from 'three-3';
export * as Three4 from 'three-4';
export * as Three5 from 'three-5';
export * as Three6 from 'three-6';
export * as Three7 from 'three-7';
export * as Three8 from 'three-8';
export * as Three9 from 'three-9';
export * as Three10 from 'three-10';

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.

🟡 This replaces the stub with 100 export * as ThreeN from 'three-N' lines, but bench/bundle/package.json only declares "three": "^0.184.0" — there are no three-1..three-100 packages, no tsconfig path mappings, and no setup step that creates them (run-bench.sh clones a separate esbuild fork and never bundles this file). The README also says "10 copies" but the file has 100. Either revert to a stub, add a setup step that materializes node_modules/three-N, or align the count with the README.

Extended reasoning...

What the issue is

bench/bundle/index.ts was changed from console.log("Hello via Bun!") to 100 namespace re-exports:

export * as Three1 from 'three-1';
export * as Three2 from 'three-2';
...
export * as Three100 from 'three-100';

None of the specifiers three-1 through three-100 resolve. bench/bundle/package.json declares exactly one dependency ("three": "^0.184.0"), bun.lockb locks only three, tsconfig.json has no paths mapping, and .gitignore lists only esbuild (the cloned dir from run-bench.sh) — no generated three-N directories. There is no setup script in this directory or anywhere in the repo that creates the three-N packages.

There is also a count mismatch: bench/bundle/README.md says "The benchmark bundles 10 copies of the large three.js", but the file has 100 imports.

Why nothing in the repo makes this work

The intent appears to follow esbuild's bench-three pattern, where a Makefile copies node_modules/threenode_modules/three-N N times before bundling. But run-bench.sh here is just:

git clone git@github.com:colinhacks/esbuild.git
cd esbuild
make bench-three

It clones a separate esbuild fork and runs make bench-three inside that clone — it never references bench/bundle/index.ts, never runs bun build against it, and never sets up three-N packages in this directory's node_modules. So index.ts is dead with respect to the documented workflow.

Step-by-step proof

  1. cd bench/bundle && bun install → installs only three and bun-types (per package.json + lockfile).
  2. ls node_modules/ | grep three → only three, no three-1..three-100.
  3. bun build index.ts → 100 errors of the form error: Could not resolve: "three-1" (one per import).
  4. ./run-bench.sh → clones colinhacks/esbuild, cds into it, runs make bench-three there. index.ts in the parent directory is never touched.

Impact (and why this is a nit)

This is bench-fixture scaffolding, not production code. Nothing in CI, the build system, or the test suite references bench/bundle/index.ts. The documented entrypoint (./run-bench.sh) bypasses it entirely. Before this PR the file was console.log("Hello via Bun!") — a bun init placeholder that was equally non-functional as a bundler benchmark. So the practical regression is: a dead stub became a broken dead stub. The only people affected are those who cd bench/bundle && bun build index.ts directly, which the README does not instruct anyone to do.

Addressing the objection that this is a duplicate

CodeRabbit already flagged this same issue (inline comment 3211775312 on bench/bundle/index.ts:100). That observation is correct and is credited below. The finding itself is real; the duplication is why it's filed as a corroboration rather than a fresh report, and the negligible blast radius is why it's filed as a nit rather than a blocking issue.

How to fix

Any of:

  • (a) Revert index.ts to a stub (or delete it) since run-bench.sh doesn't use it.
  • (b) Add a setup step that materializes the copies, e.g. for i in $(seq 1 10); do cp -r node_modules/three node_modules/three-$i; done, and trim the file to 10 imports to match the README.
  • (c) Change all 100 imports to from 'three' (though re-exporting the same module 100× under different namespaces doesn't replicate esbuild's bench-three semantics, where each copy is a distinct module graph).

🔬 also observed by coderabbitai

Comment thread scripts/build/flags.ts
join(cwd, "src/jsc/modules"),
join(cwd, "src/js/builtins"),
join(cwd, "src/napi"),
join(cwd, "src/runtime/napi"),

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.

🟡 This updates the napi include path to src/runtime/napi (the directory move — src/napi/ no longer exists), but scripts/run-clang-format.sh:35 still excludes the old src/napi/ path, so vendored Node-API headers (node_api.h, js_native_api.h, etc.) under src/runtime/napi/ will now be picked up and reformatted by autofix-ci. Note line 34 was already updated to src/runtime/ffi/, so napi was missed in the same sweep; .github/workflows/CLAUDE.md:24 also still documents the stale src/napi/ path.

Extended reasoning...

What the bug is

The napi directory has moved from src/napi/ to src/runtime/napi/ (this PR's own docs/CYCLEBREAK.md documents "napi become modules of bun_runtime"). This PR updates scripts/build/flags.ts:1126 accordingly:

-    join(cwd, "src/napi"),
+    join(cwd, "src/runtime/napi"),

However, scripts/run-clang-format.sh:35 still excludes the old path:

if [[ "$file" =~ src/runtime/ffi/ffi- ]] || \
   [[ "$file" =~ src/napi/ ]] || \          # ← stale; matches nothing
   [[ "$file" =~ src/jsc/bindings/libuv/ ]] || \

Line 34 was already updated from src/bun.js/api/ffi-src/runtime/ffi/ffi- in the same restructure, so napi was simply missed in that sweep. Additionally, .github/workflows/CLAUDE.md:24 still documents "src/napi/ - Node API headers (third-party)".

Step-by-step proof

  1. src/napi/ does not exist at HEAD: ls src/napi/ → "No such file or directory".
  2. src/runtime/napi/ contains vendored third-party headers: js_native_api.h, js_native_api_types.h, node_api.h, node_api_types.h — these are upstream Node-API headers, not Bun-authored code.
  3. The exclusion no longer matches: bash [[ "$file" =~ src/napi/ ]] treats the RHS as a regex; src/napi/ is a literal substring that does not appear in src/runtime/napi/node_api.h (the path segment is /napi/, but the prefix src/ immediately before it is required by the pattern). So the continue never fires for these files.
  4. The find discovers them: run-clang-format.sh:43 runs find src packages -type f \( -name "*.h" -o -name "*.hpp" \) with no -not -path clause covering src/runtime/napi/, so all four headers enter the loop and reach FILES_TO_FORMAT.
  5. CI runs it and auto-commits: .github/workflows/format.yml:54 invokes ./scripts/run-clang-format.sh format, and the workflow ends with autofix-ci/action, which commits any resulting diff.
  6. It has likely already fired: git log --diff-filter=A -- src/runtime/napi/node_api.h shows the file was added at its new path by commit f0a32bf5 "[autofix.ci] apply automated fixes" — i.e., autofix-ci has already touched the vendored header at the new location.

Why nothing prevents it

The exclusion list in run-clang-format.sh is hand-maintained string matching with no validation that the excluded paths exist. When the directory moved, the regex silently became a no-op. The adjacent src/runtime/ffi/ entry on line 34 was updated, demonstrating the intent to keep this list in sync — napi was simply overlooked.

Impact

clang-format will reformat upstream Node-API headers to Bun's .clang-format style, and autofix-ci will commit the result. This is not a runtime or build correctness issue, but it (a) pollutes git blame/history on vendored files, (b) creates spurious diffs against upstream Node.js when those headers are next synced, and (c) generates autofix-ci commit noise on every PR that touches anything triggering the format workflow. run-clang-format.sh itself is not in this PR's changed-files set (the directory move appears to predate the diff), so this is borderline pre-existing — but since this PR is explicitly sweeping path references for the napi move (flags.ts:1126, CYCLEBREAK.md) and already updated the sibling ffi exclusion on line 34, it's the natural place to complete the sweep.

How to fix

Two one-line changes:

# scripts/run-clang-format.sh:35
-       [[ "$file" =~ src/napi/ ]] || \
+       [[ "$file" =~ src/runtime/napi/ ]] || \
# .github/workflows/CLAUDE.md:24
-- `src/napi/` - Node API headers (third-party)
+- `src/runtime/napi/` - Node API headers (third-party)

Comment thread package.json
"rust:check-all": "bun scripts/rust-check-all.ts",
"rust:clippy": "cargo clippy --workspace --keep-going",
"codegen:string-maps": "for f in src/**/*.string-map.ts; do bun src/codegen/generate-string-map.ts \"$f\" \"${f%.string-map.ts}.generated.rs\"; done",
"codegen:verify": "bun run codegen:string-maps && git diff --exit-code 'src/**/*.generated.rs' || (echo '\\n*.generated.rs is stale — run `bun run codegen:string-maps` and commit the result.' >&2; exit 1)",

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: Because A && B || C evaluates as (A && B) || C, if bun run codegen:string-maps itself fails (e.g. syntax error in a .string-map.ts), the || branch still fires and prints "*.generated.rs is stale — run `bun run codegen:string-maps`" — which is misleading since that command just failed for a different reason. The generator's own error appears above it in the log so this only affects diagnostic clarity; consider bun run codegen:string-maps || exit 1; git diff --exit-code ... || (echo stale; exit 1) to distinguish the two cases.

Extended reasoning...

What the bug is

The new codegen:verify script at package.json:75 is:

bun run codegen:string-maps && git diff --exit-code 'src/**/*.generated.rs' || (echo '...stale...' >&2; exit 1)

In POSIX sh, && and || have equal precedence and are left-associative, so this parses as (A && B) || C. The intent is for C (the "stale" message) to fire only when B (git diff --exit-code) detects uncommitted regenerated output. But C also fires whenever A (the generator) fails, since a non-zero A short-circuits the &&, making the whole left side non-zero, which triggers ||.

Step-by-step proof

  1. Suppose src/foo/bar.string-map.ts has a syntax error, or src/codegen/generate-string-map.ts throws.
  2. bun run codegen:string-maps exits non-zero (the for loop in that script propagates the last iteration's exit code, and bun's script runner propagates non-zero).
  3. A is non-zero → A && B short-circuits without running git diff → the compound (A && B) is non-zero.
  4. || C fires: stderr gets *.generated.rs is stale — run bun run codegen:string-maps and commit the result.
  5. CI fails with exit 1 (correct behavior), but the trailing diagnostic line is factually wrong: the files aren't stale, the generator crashed — and the suggested remedy is the very command that just failed.

Why this is only a nit

Three mitigating factors keep the impact small:

  • The real error is still visible. The generator's own stderr (stack trace, syntax error, etc.) prints before the misleading "stale" line. A developer reading the CI log top-to-bottom sees the actual cause first; the bad message is a confusing footer, not the only signal.
  • CI correctness is unaffected. Exit code is 1 either way; the step fails as it should.
  • The suggested remedy isn't harmful. Running bun run codegen:string-maps locally (as the message says) reproduces the same generator error, so the developer lands on the real cause anyway — just via a slightly confusing route.

This is also a very common npm-script idiom (cmd && check || fail), and most uses of it don't bother distinguishing the two failure modes. It's flagged here only because (a) the message is unusually specific about which failure occurred ("is stale"), making it actively wrong rather than merely vague, and (b) this script is newly added in this PR and wired into CI at .github/workflows/format.yml:113, so it's cheap to fix now.

Addressing the counter-argument

One could argue this is below the bar for a 300-file PR. That's fair — it's purely a UX wart in an edge case (broken generator), not a correctness issue. But the fix is a one-line restructure with no downside, and the script's whole purpose is to give a clear signal about codegen state, so having it emit a wrong diagnosis in one of its two failure modes seems worth the nit.

How to fix

Split the chain so generator failure exits before the staleness check:

"codegen:verify": "bun run codegen:string-maps || exit 1; git diff --exit-code 'src/**/*.generated.rs' || (echo '\\n*.generated.rs is stale — run `bun run codegen:string-maps` and commit the result.' >&2; exit 1)"

Or equivalently with set -e, or by grouping with braces: { A && B; } || C doesn't help (same evaluation), but A; B || C with set -e does.

Comment on lines +107 to 114
- name: Verify checked-in codegen
# `*.generated.rs` are deterministic outputs of `*.string-map.ts`; the
# source `.ts` is the truth. This step regenerates and fails if the
# committed `.generated.rs` is stale, so a forgotten regen surfaces in
# CI rather than as a confusing behaviour diff later.
run: |
bun run codegen:verify
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27

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.

🔴 Running codegen:verify before autofix-ci/action defeats autofix: when .generated.rs is stale, codegen:string-maps writes the corrected files to disk and then the step exits 1, so the autofix-ci step (which has no if: always()) is skipped — the regenerated files are never committed, and any prettier/clang-format/zig-fmt fixes from the "Format Code" step are also lost. Either replace codegen:verify with codegen:string-maps here (regenerate-only, exit 0, let autofix commit — matching how the formatters work) or move this step after the autofix-ci action as a pure verification gate.

Extended reasoning...

What the bug is

The new step at format.yml:107-113 runs bun run codegen:verify immediately before the autofix-ci/action step at line 114. Per package.json:75, codegen:verify is:

bun run codegen:string-maps && git diff --exit-code 'src/**/*.generated.rs' || (echo '...stale...'; exit 1)

So when checked-in codegen is stale, this step (1) regenerates the correct files to disk via codegen:string-maps, (2) sees a non-empty git diff, and (3) exits 1. The fix is literally sitting in the working tree when the job dies.

The specific code path

  1. Format Code step (lines 38-103) runs prettier / clang-format / zig fmt, which write fixes to disk and exit 0 — they rely on autofix-ci to commit.
  2. Verify checked-in codegen (lines 107-113) runs. If any .generated.rs is stale, codegen:string-maps writes the regenerated content, then git diff --exit-code fails, then the step exits 1.
  3. autofix-ci/action (line 114) has no if: condition, so per GitHub Actions' default if: success() semantics it is skipped when any prior step failed.
  4. Result: neither the regenerated .generated.rs files nor the unrelated formatting fixes from step 1 are committed back to the PR.

Why existing code doesn't prevent it

The inline comment (lines 108-111) shows the author intended a hard gate ("fails if the committed .generated.rs is stale, so a forgotten regen surfaces in CI"). That intent IS achieved — the job goes red. But the implementation is inconsistent with the rest of this workflow: the Format Code step (lines 38-103) follows the autofix.ci pattern of "write the fix, exit 0, let the action commit it". This new step instead writes the fix and then fails, throwing away both its own work and any pending formatting changes.

The pre-existing Ban Words step at line 104 also sits before autofix-ci and can fail, but ban-words has no autofixable output (you can't auto-fix a banned word), so its placement is sensible. By contrast, stale codegen is the expected common case (someone edits a .string-map.ts and forgets to regen), and the script literally produces the fix on disk — making it the perfect candidate for autofix rather than a hard fail.

Step-by-step proof

Concrete scenario:

  1. A contributor edits src/foo.string-map.ts and also makes a TypeScript change with bad formatting. They forget to run bun run codegen:string-maps.
  2. PR opened → autofix.ci workflow runs.
  3. Format Code step: prettier reformats the .ts file → working tree now has formatting fix → exit 0.
  4. Ban Words step: passes → exit 0.
  5. Verify checked-in codegen step: codegen:string-maps regenerates src/foo.generated.rs → working tree now has the correct codegen → git diff --exit-code 'src/**/*.generated.rs' exits 1 → || (echo ...; exit 1)step fails.
  6. autofix-ci/action: default if: success() evaluates false → skipped.
  7. Job fails. The contributor sees a red X with "*.generated.rs is stale" but gets neither the codegen fix nor the formatting fix auto-pushed. They must manually run codegen:string-maps AND prettier locally, even though CI already computed both fixes.

Impact

This is a CI ergonomics regression introduced by this PR, not a runtime bug. The primary intent (surface staleness) works, but: (a) the workflow is named autofix.ci and the job is named autofix, yet this step makes an autofixable problem non-autofixable; (b) it has collateral damage — when codegen is stale, unrelated prettier/clang-format/zig-fmt fixes are also blocked from being committed, which is a regression in pre-existing autofix behavior.

How to fix

Two clean options:

(a) Match the existing Format Code pattern — change line 113 to bun run codegen:string-maps (regenerate only, exit 0). The autofix-ci action then commits the regenerated files alongside any formatting fixes. Optionally add a second bun run codegen:verify step after autofix-ci as a belt-and-suspenders gate (it would only fail if codegen is non-deterministic).

(b) Keep codegen:verify but move the step to after autofix-ci/action (and add the regenerate-only step before it), so formatting fixes always get committed and the verify step acts as a pure post-commit gate.

Option (a) is the smaller diff and matches the workflow's existing design.

Comment thread package.json
Comment on lines +74 to +75
"codegen:string-maps": "for f in src/**/*.string-map.ts; do bun src/codegen/generate-string-map.ts \"$f\" \"${f%.string-map.ts}.generated.rs\"; done",
"codegen:verify": "bun run codegen:string-maps && git diff --exit-code 'src/**/*.generated.rs' || (echo '\\n*.generated.rs is stale — run `bun run codegen:string-maps` and commit the result.' >&2; exit 1)",

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.

🟡 The for f in src/**/*.string-map.ts loop here relies on **, but bun-run scripts execute via bash without shopt -s globstar, so ** is treated as * and only matches one directory level (src/*/*.string-map.ts). It happens to work today because the only .string-map.ts is at depth 1, but any future deeper file (e.g. src/a/b/foo.string-map.ts) would be silently skipped — and codegen:verify (now wired into CI by this PR) would not catch the staleness since the unregenerated .generated.rs produces no git diff. Fix: prefix with shopt -s globstar && for f in ..., or use find src -name "*.string-map.ts".

Extended reasoning...

What the bug is

The codegen:string-maps script in package.json:74 is:

"codegen:string-maps": "for f in src/**/*.string-map.ts; do bun src/codegen/generate-string-map.ts \"$f\" \"${f%.string-map.ts}.generated.rs\"; done",

The src/**/*.string-map.ts pattern is intended to recurse arbitrarily deep, but ** only does that when bash's globstar shell option is enabled. bun run on POSIX executes scripts via the system shell (src/runtime/cli/run_command.rs:161 has SHELLS_TO_SEARCH = ["bash", "sh", "zsh"], and src/options_types/Context.rs:302 defaults use_system_shell = !cfg!(windows)), so on Linux/macOS CI this runs as bash -c '<script>'. Bash's globstar is off by default in non-interactive shells, so src/**/*.string-map.ts is parsed identically to src/*/*.string-map.ts — exactly one directory level under src/.

Why it works today (by coincidence)

find src -name '*.string-map.ts' returns exactly one file: src/js_parser/defines_table.string-map.ts, which is at depth 1 under src/ and therefore matches src/*/*.string-map.ts. So the script currently regenerates everything it should.

Why it's still a bug

This PR wires codegen:verify into CI via .github/workflows/format.yml with the explicit purpose (per the inline comment) that "a forgotten regen surfaces in CI rather than as a confusing behaviour diff later." But codegen:verify is:

"codegen:verify": "bun run codegen:string-maps && git diff --exit-code 'src/**/*.generated.rs' || (...)"

The git diff pathspec is quoted, so git handles ** (correctly, recursively). But the regenerate step that precedes it would skip any deeper file. So for a hypothetical src/css/properties/foo.string-map.ts:

  1. codegen:string-maps does not regenerate src/css/properties/foo.generated.rs (depth 2, doesn't match src/*/*).
  2. git diff --exit-code 'src/**/*.generated.rs' checks the file but sees no change (it wasn't touched).
  3. CI passes ✅ even though the checked-in .generated.rs is stale relative to the .string-map.ts source.

The verify step's whole stated purpose is silently defeated for exactly the case it's meant to catch.

Step-by-step proof

$ bash -c 'for f in src/**/*.string-map.ts; do echo "$f"; done'
src/js_parser/defines_table.string-map.ts          # depth 1: matches

$ bash -c 'shopt -s globstar; for f in src/**/*.string-map.ts; do echo "$f"; done'
src/js_parser/defines_table.string-map.ts          # same (only file)

# Simulate a depth-2 file:
$ mkdir -p /tmp/t/src/a/b && touch /tmp/t/src/a/b/x.string-map.ts /tmp/t/src/a/y.string-map.ts
$ bash -c 'cd /tmp/t && for f in src/**/*.string-map.ts; do echo "$f"; done'
src/a/y.string-map.ts                               # depth-2 file SKIPPED
$ bash -c 'cd /tmp/t && shopt -s globstar && for f in src/**/*.string-map.ts; do echo "$f"; done'
src/a/b/x.string-map.ts
src/a/y.string-map.ts                               # both found

Why nothing else catches it

The actual ninja build (emitStringMaps in scripts/build/codegen.ts, fed by sources.stringMaps from scripts/glob-sources.ts) uses a proper recursive glob and is unaffected — so the product is correct. Only this convenience script and the CI verify step are wrong, which is precisely why staleness would go undetected: the build regenerates into build/<profile>/codegen/, not into src/, so it never updates the checked-in .generated.rs.

Impact

None today (the only .string-map.ts is at depth 1). This is a latent/forward-looking robustness issue: the pattern looks recursive but isn't, and the CI guard this PR adds would silently miss the very staleness it documents itself as catching, the moment a second .string-map.ts lands at depth ≥2.

Fix

Either of:

"codegen:string-maps": "shopt -s globstar && for f in src/**/*.string-map.ts; do ...; done"

or (portable to sh/zsh):

"codegen:string-maps": "find src -name '*.string-map.ts' | while read -r f; do bun src/codegen/generate-string-map.ts \"$f\" \"${f%.string-map.ts}.generated.rs\"; done"

Comment thread scripts/build/rust.ts
Comment on lines +64 to +66
* build still uses `dev`. RelWithDebInfo / MinSizeRel collapse to `release` —
* cargo's stock release already keeps debuginfo (`debug = 1` is the workspace
* default), and we don't ship a `MinSizeRel` Rust path yet.

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.

🟡 The parenthetical says "(debug = 1 is the workspace default)", but [profile.release] in Cargo.toml:141 actually sets debug = "line-tables-only" — a lower debuginfo level than 1 (limited). The broader point (release retains debuginfo, so RelWithDebInfo can collapse to it) is still correct; just the specific value cited is wrong.

Extended reasoning...

What the bug is

The JSDoc on cargoProfile() at scripts/build/rust.ts:64-66 reads:

RelWithDebInfo / MinSizeRel collapse to release — cargo's stock release already keeps debuginfo (debug = 1 is the workspace default) so a separate "with-debinfo" profile is unnecessary.

The parenthetical asserts that the workspace [profile.release] sets debug = 1. It does not — Cargo.toml:141 (also new in this PR) sets:

[profile.release]
lto = "fat"
codegen-units = 1
debug = "line-tables-only"
strip = "none"

In Cargo's debuginfo levels these are distinct settings: "none" (0) < "line-tables-only" < "limited" (1) < "full" (2). line-tables-only emits only file:line mappings for backtraces; debug = 1 ("limited") additionally includes type information and some variable info. So the comment cites the wrong value.

Why nothing prevents it

This is a JSDoc comment — neither TypeScript nor cargo validates that the prose matches Cargo.toml. Both files are new in this PR, so the drift was introduced together rather than one going stale. The actual code on the next line (return cfg.buildType === "Debug" ? dev : release;) is unaffected: it just selects the cargo profile name, and whatever Cargo.toml configures for that profile is what gets used.

Step-by-step proof

  1. Read scripts/build/rust.ts:65"(debug = 1 is the workspace default)".
  2. Read Cargo.toml:141debug = "line-tables-only".
  3. Per the Cargo reference on profile.debug, 1/"limited" and "line-tables-only" are different levels; line-tables-only is strictly less debuginfo than 1.
  4. Therefore the parenthetical is factually incorrect about what the workspace configures.

The comment's conclusion — that release already retains debuginfo, so collapsing RelWithDebInfo→release doesn't lose backtrace info — remains valid: line-tables-only is non-zero debuginfo and is sufficient for symbolicated backtraces. Only the specific value cited is wrong.

Impact

Zero runtime/build impact; this is purely a comment-accuracy issue. The minor harm is that a reader trusting the JSDoc might assume a RelWithDebInfo build carries debug = 1-level info (e.g. some type info for variable inspection in a debugger) when it actually only has line tables. This is the same comment-vs-Cargo.toml drift theme as the already-posted comments on Cargo.toml:152 (release-profiling no-op) and scripts/build/profiles.ts:112 (debug-assertions claim).

How to fix

Change the parenthetical at line 65 to match the actual config:

 * already keeps debuginfo (`debug = "line-tables-only"` is the workspace default) so a

Or, if debug = 1 was the intended workspace setting for RelWithDebInfo-equivalent builds, change Cargo.toml:141 instead — but given the explicit choice of line-tables-only (smaller binaries, sufficient for backtraces), updating the comment is almost certainly the right fix.

Comment thread package.json
Comment on lines +74 to +75
"codegen:string-maps": "for f in src/**/*.string-map.ts; do bun src/codegen/generate-string-map.ts \"$f\" \"${f%.string-map.ts}.generated.rs\"; done",
"codegen:verify": "bun run codegen:string-maps && git diff --exit-code 'src/**/*.generated.rs' || (echo '\\n*.generated.rs is stale — run `bun run codegen:string-maps` and commit the result.' >&2; exit 1)",

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: for f in src/**/*.string-map.ts relies on globstar, but bun run executes package.json scripts via the system shell on POSIX (use_system_shell = !isWindows), and neither dash nor bash-without-shopt -s globstar treats ** as recursive — it expands the same as src/*/*.string-map.ts. It works today only because the sole file (src/js_parser/defines_table.string-map.ts) happens to be at depth 2; a future .string-map.ts at any other depth would be silently skipped by both regen and the new CI codegen:verify step (git diff would show no change → false pass). Use find src -name "*.string-map.ts" or Bun.Glob instead.

Extended reasoning...

What the bug is

The new codegen:string-maps script (package.json:74) is written as:

for f in src/**/*.string-map.ts; do bun src/codegen/generate-string-map.ts "$f" "${f%.string-map.ts}.generated.rs"; done

The ** here is intended as a recursive glob. However, bun run <script> on POSIX executes package.json scripts via the system shell, not Bun Shell — src/options_types/Context.rs:302 sets use_system_shell: !cfg!(windows), and run_command.rs resolves the shell via findShell() (search order: bash → sh → zsh) and invokes <shell> -c <script>. Neither POSIX sh (dash on Ubuntu CI), bash without shopt -s globstar, nor zsh in sh-emulation mode treats ** as recursive; in all of them ** is exactly equivalent to *, so the glob expands as src/*/*.string-map.ts — files at exactly depth 2 only.

Why it works today

find src -name '*.string-map.ts' returns exactly one file: src/js_parser/defines_table.string-map.ts, which is at depth 2 (src/<dir>/<file>). So the non-recursive glob happens to match it. Empirically:

$ bash -c 'for f in src/**/*.string-map.ts; do echo "$f"; done'
src/js_parser/defines_table.string-map.ts

The failure mode

This PR also wires codegen:verify (package.json:75) into CI via .github/workflows/format.yml ("Verify checked-in codegen"). That step runs codegen:string-maps and then git diff --exit-code 'src/**/*.generated.rs' to catch stale checked-in codegen.

If someone later adds e.g. src/css/properties/colors.string-map.ts (depth 3) or src/foo.string-map.ts (depth 1):

  1. bun run codegen:string-maps silently skips it — the for loop never sees the file, no error is raised.
  2. bun run codegen:verify regenerates nothing for that file → git diff sees no change → exit 0, CI passes.
  3. The checked-in .generated.rs goes stale with no signal whatsoever.

This is the worst kind of footgun: the CI step whose explicit purpose (per its own comment: "so a forgotten regen surfaces in CI rather than as a confusing behaviour diff later") is to catch staleness would itself silently miss the stale file. Note the git diff pathspec 'src/**/*.generated.rs' would catch a depth-3 file (git pathspecs do support recursive **), but only if the regen step actually touched it — which it won't.

Step-by-step proof

  1. bun run on Linux/macOS resolves a system shell (run_command.rs:161: SHELLS_TO_SEARCH = ["bash", "sh", "zsh"]) and runs <shell> -c '<script>'.
  2. In bash, globstar is off by default (shopt globstaroff); per man bash: "If set, the pattern ** used in a pathname expansion context will match all files and zero or more directories and subdirectories. If not set, ** behaves like *." In dash there is no globstar at all.
  3. So src/**/*.string-map.tssrc/*/*.string-map.ts → matches src/<one-dir>/<file>.string-map.ts only.
  4. Create src/css/values/x.string-map.ts (depth 3). Run bun run codegen:string-maps → loop body never executes for it. Run bun run codegen:verify → exit 0.

How to fix

Replace the glob with portable recursion, e.g.:

"codegen:string-maps": "find src -name '*.string-map.ts' -print0 | xargs -0 -I{} sh -c 'bun src/codegen/generate-string-map.ts \"{}\" \"${0%.string-map.ts}.generated.rs\"' {}"

or, more cleanly, use Bun's own glob (which does support ** recursively and is shell-independent):

"codegen:string-maps": "bun -e 'for (const f of new Bun.Glob(\"src/**/*.string-map.ts\").scanSync()) await Bun.$\`bun src/codegen/generate-string-map.ts ${f} ${f.replace(/\\.string-map\\.ts$/, \".generated.rs\")}\`'"

or simply prefix with shopt -s globstar 2>/dev/null; (bash-only, breaks on dash/Alpine).

Severity is nit: nothing is broken today (only one file, at the depth that happens to match), this is dev/CI tooling not shipped code, and the fix is trivial. But it's worth fixing in this PR since both the script and the CI step that depends on it are newly introduced here, and the failure mode is completely silent.

Comment thread scripts/build/rules.ts
// rust_build
registerRustRules(n, cfg);

// shim_dylib (darwin+asan only)

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: This PR's shims.ts change adds a second rule shim_crt_decompress (gated on needsMuslCrtDecompress: linux+musl+rust-lld) to registerShimRules(), restructuring it from single-purpose into two conditional branches — but the call-site comment here still says // shim_dylib (darwin+asan only). The "(darwin+asan only)" parenthetical is now actively misleading; consider // shim_dylib (darwin+asan), shim_crt_decompress (musl+rust-lld).

Extended reasoning...

What the bug is

scripts/build/rules.ts:55 carries a one-line comment describing what registerShimRules() registers:

// shim_dylib (darwin+asan only)
registerShimRules(n, cfg);

Before this PR, that comment was exactly right — registerShimRules opened with if (!(cfg.darwin && cfg.asan)) return; and only ever defined the shim_dylib rule. This PR's diff to scripts/build/shims.ts removes that early return and restructures the function into two independent conditional branches:

  • if (cfg.darwin && cfg.asan) → registers shim_dylib (line 59)
  • if (needsMuslCrtDecompress(cfg)) → registers shim_crt_decompress (line 69), where needsMuslCrtDecompress is cfg.linux && cfg.abi === "musl" && cfg.rustLld !== undefined && cfg.ld === cfg.rustLld

The second branch is brand new in this PR (the rust-lld-musl-crt-zlib workaround, also referenced from this PR's new workarounds.ts:99 entry). The rules.ts:55 comment was not updated and is now stale.

Step-by-step proof

  1. Pre-PR shims.ts: registerShimRules body is if (!(cfg.darwin && cfg.asan)) return; … n.rule("shim_dylib", …). Single rule, single condition.
  2. Pre-PR rules.ts:55: // shim_dylib (darwin+asan only) — accurate.
  3. This PR's shims.ts diff (verified via git diff merge-base..HEAD): removes the early return, adds needsMuslCrtDecompress(), MUSL_CRT_OBJECTS, and a second n.rule("shim_crt_decompress", …) block at lines 65–74.
  4. This PR does not touch rules.ts:55 (it appears as unchanged context in the diff). The line still reads // shim_dylib (darwin+asan only).
  5. So on a linux-musl release build with cross-language LTO (where cfg.ld === cfg.rustLld), registerShimRules registers shim_crt_decompress and not shim_dylib — the comment describes neither the rule nor the condition that actually applies.

Why nothing prevents it

This is a pure inline comment; nothing type-checks or tests it. rules.ts itself wasn't substantively changed by this PR (only the import on line 26 and the registerRustRules call on lines 52–53 are new), so the stale line on 55 is unchanged context that's easy to miss during review.

On the local convention

One verifier noted that this PR's own new comment on line 52 (// rust_build) is similarly terse — registerRustRules actually registers rust_build, rust_build_cross, and rust_build_tier3. That's fair, and arguably the file's convention is "name the primary rule" rather than "exhaustively list every rule." However, line 55 is worse than line 52: the parenthetical (darwin+asan only) makes a positive claim about the condition under which the function does anything, and that claim is now false. An incomplete list is one thing; an incorrect gating assertion is more misleading. The neighboring lines 45–46 (// dep_fetch, dep_fetch_prebuilt, dep_configure, dep_build, dep_cargo) and 49 (// codegen, esbuild, bun_install + …) do enumerate, so updating 55 to match would also be consistent with the file's prevailing style.

Impact

Zero runtime impact — comment-only. The cost is reader confusion: someone scanning rules.ts to understand which rules exist on a musl build would conclude registerShimRules is a no-op there, when in fact it's the only place shim_crt_decompress is defined. This is the same class of doc-drift as the already-flagged codegen.ts:212/215 JSDoc nit (field renamed but per-field doc not updated) and the CLAUDE.md nit — a comment describing what a function does wasn't updated when the function gained a new branch in the same PR.

How to fix

One-line edit at rules.ts:55:

// shim_dylib (darwin+asan), shim_crt_decompress (musl+rust-lld)
registerShimRules(n, cfg);

Optionally also tighten line 52 to // rust_build, rust_build_cross, rust_build_tier3 while in the file, since this PR introduced that line too.

},
{
id: "rust-lld-musl-crt-zlib",
issue: "https://github.com/rust-lang/rust/issues/data-compression-not-enabled",

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.

🟡 The issue field here is a placeholder — https://github.com/rust-lang/rust/issues/data-compression-not-enabled is not a valid GitHub issue path (issue URLs use numeric IDs) and 404s. Since this string is rendered as Tracked: ${w.issue} in the obsolescence error (line 133) so a maintainer can verify upstream status, it should point to the real rust-lang issue tracking rust-lld's missing LLVM_ENABLE_ZLIB, or be replaced with a non-URL note like "no upstream issue filed" if none exists.

Extended reasoning...

What the bug is

The new rust-lld-musl-crt-zlib workaround entry at scripts/build/workarounds.ts:100 sets:

issue: "https://github.com/rust-lang/rust/issues/data-compression-not-enabled",

GitHub issue URLs are of the form /issues/<numeric-id>; a slug like data-compression-not-enabled is not a valid issue path and resolves to a 404. This is a descriptive placeholder dressed up as a URL — most likely the author wrote a slug describing the problem and never replaced it with the actual upstream tracker reference.

Where it surfaces

The Workaround.issue field is documented at line 39 as "Upstream tracker reference (issue URL, PR number, etc.)". It is consumed in exactly one place: checkWorkarounds() at line 133, which embeds it in the BuildError hint as Tracked: ${w.issue} when expectedToBeFixed() trips. The whole point of that line — per the file-level docstring at lines 4-11 — is to tell a future maintainer where to go check whether the upstream fix actually shipped before they delete the workaround. A 404 link defeats that purpose: the maintainer follows the link, gets nothing, and has to re-derive what upstream condition the workaround is waiting on.

Why nothing else catches it

The string is opaque metadata; nothing type-checks or validates it as a URL, and the error path it appears in only fires once clangMajor >= rustMajor (i.e., some future toolchain bump). So this would sit dormant until exactly the moment a maintainer needs the link to work.

Step-by-step proof

  1. The two sibling entries use real, resolvable URLs: line 61 → https://github.com/llvm/llvm-project/issues/182943 (numeric issue ID), line 80 → https://rustc-dev-guide.rust-lang.org/backend/updating-llvm.html (a docs page). Both demonstrate the intended convention.
  2. Line 100 → https://github.com/rust-lang/rust/issues/data-compression-not-enabled. GitHub's issue router only accepts numeric IDs after /issues/; navigating to this URL returns 404.
  3. At a future configure where cfg.linux && cfg.abi === "musl" && cfg.ld === cfg.rustLld and clang's LLVM major has caught up to rustc's, checkWorkarounds() throws a BuildError whose hint contains Tracked: https://github.com/rust-lang/rust/issues/data-compression-not-enabled.
  4. The maintainer following that link gets a 404 and has no upstream reference to verify against.

Impact

Zero functional impact today — the workaround logic, description, applies, expectedToBeFixed, and cleanup fields are all correct and complete. This is purely a documentation/maintainability defect in a string that surfaces only in a future developer-facing error. Hence nit severity.

How to fix

Replace the string with either (a) the actual rust-lang/rust issue number that tracks rust-lld being built without LLVM_ENABLE_ZLIB (the relevant upstream context is around src/bootstrap's lld config), or (b) if no upstream issue exists, a non-URL note such as "(no upstream issue — rust-lld dist build omits LLVM_ENABLE_ZLIB)" so the field doesn't masquerade as a clickable link.

Comment thread bench/bundle/index.ts
Comment on lines +1 to +10
export * as Three1 from 'three-1';
export * as Three2 from 'three-2';
export * as Three3 from 'three-3';
export * as Three4 from 'three-4';
export * as Three5 from 'three-5';
export * as Three6 from 'three-6';
export * as Three7 from 'three-7';
export * as Three8 from 'three-8';
export * as Three9 from 'three-9';
export * as Three10 from 'three-10';

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: index.ts now imports three-1..three-100, but package.json only declares three and nothing in this directory creates the aliased copies (run-bench.sh clones a separate esbuild fork and never references index.ts), so bun build bench/bundle/index.ts would fail with 100 unresolved-module errors. README.md also says the bench bundles "10 copies" of three.js, not 100. Since this is bench/ scaffolding off any CI path (the previous content was just the bun init placeholder), no real impact — but consider either reverting, adding a setup step (e.g. for i in $(seq 1 100); do cp -r node_modules/three node_modules/three-$i; done), or syncing the README count.

Extended reasoning...

What changed

This PR replaces bench/bundle/index.ts (previously the one-line console.log("Hello via Bun!") from bun init) with 100 namespace re-exports:

export * as Three1 from 'three-1';
export * as Three2 from 'three-2';
...
export * as Three100 from 'three-100';

and adds "three": "^0.184.0" to bench/bundle/package.json dependencies.

Why it doesn't work as committed

The 100 imports are unresolvable. bench/bundle/package.json declares only the single three package; there are no three-1..three-100 packages on npm, no entries for them in package.json or bun.lockb, no tsconfig.json path mappings, and no setup script in bench/bundle/ that creates them. The standard convention for this style of bundler benchmark (esbuild's bench-three does the same) is a manual for i in 1..N; do cp -r node_modules/three node_modules/three-$i; done step, but that step is not checked in here.

run-bench.sh doesn't use index.ts at all. The documented entrypoint is:

git clone git@github.com:colinhacks/esbuild.git
cd esbuild
make bench-three

It clones a separate esbuild fork and runs that repo's Makefile target — bench/bundle/index.ts is never referenced. So the new file is effectively dead code, or a manual local-stress-test fixture that requires undocumented setup.

README is out of sync. bench/bundle/README.md:11 says the benchmark "bundles 10 copies of the large three.js", but index.ts now imports 100 copies.

Step-by-step proof

  1. bench/bundle/package.json dependencies = { "three": "^0.184.0" } — no three-N entries.
  2. bench/bundle/ contains: .gitignore, README.md, bun.lockb, index.ts, package.json, run-bench.sh, tsconfig.json — no setup script.
  3. tsconfig.json has no paths mapping for three-*.
  4. run-bench.sh (3 lines) clones colinhacks/esbuild and runs make bench-three — never touches index.ts.
  5. Therefore cd bench/bundle && bun install && bun build index.ts → 100× error: Could not resolve "three-N".
  6. README.md:11: "bundles 10 copies" vs index.ts: 100 imports.

Why nothing prevents it

bench/ is manual benchmark scaffolding, not on any CI or test path. Nothing in .buildkite/ci.mjs, test/, or any package.json script references bench/bundle/index.ts, so the unresolvable imports never surface in CI. The previous content (console.log("Hello via Bun!")) was equally non-functional as a bundler fixture, so this isn't a regression in any executed path.

Impact

Nil for runtime/CI/tests. This appears to be local-benchmarking scratch (likely from the bundler perf work referenced in docs/.rust-rewrite-verified-claims.md around bun-bench::bundler::prepare()) that got swept into this 300-file PR. The only practical effect is that someone trying to use bench/bundle/ per its README will find index.ts doesn't match what run-bench.sh actually does, and can't be built standalone.

How to fix

Any of:

  • Revert bench/bundle/index.ts and the package.json/bun.lockb changes (cleanest if this was accidental).
  • Add a setup step, e.g. a prepare.sh with bun install && for i in $(seq 1 100); do cp -r node_modules/three node_modules/three-$i; done, and reference it from README.
  • At minimum, update README.md:11 from "10 copies" → "100 copies" so the doc matches the fixture.

🔬 also observed by coderabbitai

Comment on lines +214 to 216
// rust-only/link-only don't run LUT codegen — skip the check so split-CI
// steps don't require perl on the rust cross-compile box.
if (cfg.mode === "full" || cfg.mode === "cpp-only") {

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.

🔴 The comment at lines 214-215 ("rust-only/link-only don't run LUT codegen") is now false: this same PR makes emitJsSink() push generated_jssink.rs to o.rustInputs (codegen.ts:954), so rust-only builds pull the JSSink edge → generate-jssink.ts:1214create-hash-table.ts:23 spawns perl. On a perl-less rust-only agent, configure passes but the build fails with a cryptic spawn error instead of this configure-time hint. Add || cfg.mode === "rust-only" to line 216 and update the comment.

Extended reasoning...

What the bug is

The configure-time perl check at scripts/build/configure.ts:216 only runs when cfg.mode is "full" or "cpp-only", with the comment at lines 214-215 stating "rust-only/link-only don't run LUT codegen — skip the check so split-CI steps don't require perl on the rust cross-compile box." This claim was correct for the old zig-only mode (the JSSink codegen edge fed only cppSources/cppHeaders, never zigInputs), but this same PR makes it false for rust-only: the diff shows the comment was mechanically renamed from zig-only/link-onlyrust-only/link-only without re-evaluating whether the invariant still holds — and a different commit in the same PR added the dependency that breaks it.

The code path that triggers it

  1. emitRustOnly() at bun.ts:545 calls emitCodegen(), then at bun.ts:548 passes codegen.rustInputs to emitRust() as codegenInputs.
  2. emitCodegen() at codegen.ts:302 calls emitJsSink().
  3. emitJsSink() (codegen.ts:925-954, new in this PR) declares generated_jssink.rs as a fourth output of the JSSink ninja edge and pushes it to o.rustInputs. The inline comment at codegen.ts:948-953 is explicit about this: "Without this, mode: "rust-only" (CI's build-rust job, which compiles no C++ so nothing else pulls JSSink.cpp/.h) never runs this edge and cargo hits the missing file." — i.e., the PR author knowingly wired rust-only to depend on this edge.
  4. emitRust() at rust.ts:503 lists codegenInputs (which now includes generated_jssink.rs) in implicitInputs of the rust_build edge.
  5. The JSSink edge runs generate-jssink.ts, which at line ~1214 spawns create-hash-table.ts, which at line 23 spawns perl directly: cmd: ["perl", create_hash_table, "-"].

So building the default bun target in rust-only mode pulls generated_jssink.rs → runs the JSSink codegen → spawns perl. Rust-only mode now requires perl at build time.

Why nothing prevents it

The perl check at lines 211-222 exists precisely to catch this case at configure time with the clear hint "perl not found in PATH — install it: apt install perl / brew install perl". But because the condition at line 216 excludes rust-only, that check is skipped, configure succeeds, and the failure surfaces later inside ninja as a less actionable spawn-ENOENT (or similar) error from Bun.spawn(["perl", ...]) in create-hash-table.ts.

Step-by-step proof

On a rust-only CI agent without perl (e.g. a minimal Alpine linux-aarch64-musl cross-compile box, per getRustAgent() in .buildkite/ci.mjs, or a Windows agent without perl in PATH):

  1. CI invokes bun scripts/build.ts --profile=ci-rust-only --os=... --arch=....
  2. configure.ts:216: cfg.mode === "rust-only", so the if is false → perl check skipped → configure succeeds.
  3. emitRustOnly() writes build.ninja with libbun_rust.a depending (implicitly) on generated_jssink.rs.
  4. ninja bun → needs libbun_rust.a → needs generated_jssink.rs → runs the codegen rule for JSSink.
  5. generate-jssink.ts runs, writes JSSink.lut.txt, then spawns create-hash-table.ts (line ~1214).
  6. create-hash-table.ts:23 does Bun.spawn({ cmd: ["perl", create_hash_table, "-"], ... }) → fails with ENOENT/spawn error because perl is not installed.
  7. Build fails mid-codegen with a stack trace pointing at a spawn failure, instead of the intended "perl not found in PATH — install it: apt install perl / brew install perl" configure-time hint.

The whole stated purpose of lines 211-213 ("Check here so the error is at configure time with a clear hint") is defeated for rust-only mode.

Impact

This degrades a clear, actionable configure-time error into a cryptic mid-build codegen failure on any rust-only build agent that lacks perl. Per getRustAgent(), linux/freebsd/android targets cross-compile on a shared linux-aarch64-musl (Alpine) box, and Windows/darwin run natively — minimal Alpine images and Windows agents commonly lack perl. The bug doesn't break agents that do have perl, but it makes the failure mode on perl-less agents materially worse, which is exactly what the check exists to prevent.

How to fix

Change line 216 to include rust-only:

if (cfg.mode === "full" || cfg.mode === "cpp-only" || cfg.mode === "rust-only") {

and update the comment at lines 214-215 to reflect that only link-only skips LUT codegen (link-only consumes pre-built artifacts and runs no codegen at all).

Comment thread rust-toolchain.toml
Comment on lines +13 to +32
# Mirrors `allRustTargets` in scripts/build/rust.ts minus aarch64-unknown-freebsd
# (Tier 3 — no prebuilt rust-std, cargo `-Zbuild-std` builds it from rust-src).
# darwin/windows triples build natively on their own agents, so the host std
# comes with the toolchain install regardless; they're listed here so a dev
# running `bun run rust:check-all` or `cargo check --target` from any host
# still gets prebuilt std for every Tier 1/2 triple.
components = ["rust-src"]
targets = ["x86_64-unknown-freebsd", "aarch64-linux-android", "x86_64-linux-android"] No newline at end of file
targets = [
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"x86_64-unknown-linux-musl",
"aarch64-linux-android",
"x86_64-linux-android",
"x86_64-unknown-freebsd",
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"aarch64-pc-windows-msvc",
"x86_64-pc-windows-msvc",
]

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.

🟡 The comment at line 13 says this list "Mirrors allRustTargets in scripts/build/rust.ts minus aarch64-unknown-freebsd", but it doesn't: allRustTargets (rust.ts:106-117) has 10 entries and contains neither x86_64-linux-android (which this list adds at line 26) nor aarch64-unknown-freebsd (so there's nothing to subtract). CodeRabbit flagged the allRustTargets side at rust.ts:117 as '✅ Addressed' but the array is unchanged at HEAD — either add x86_64-linux-android (and aarch64-unknown-freebsd) to allRustTargets so rust:check-all and generateCargoConfig() cover it, or correct this comment.

Extended reasoning...

What the bug is

This PR adds a new comment at rust-toolchain.toml:13-14 claiming the targets list "Mirrors allRustTargets in scripts/build/rust.ts minus aarch64-unknown-freebsd (Tier 3 — no prebuilt rust-std…)". That claim is false on two independent counts at HEAD:

  1. rust-toolchain.toml's targets list (lines 20-32, 11 entries) includes x86_64-linux-android (line 26), which allRustTargets (rust.ts:106-117, 10 entries) does not contain — only aarch64-linux-android is there.
  2. allRustTargets does not contain aarch64-unknown-freebsd either, so "minus aarch64-unknown-freebsd" describes subtracting an element that isn't present.

For the comment's set arithmetic to hold, allRustTargets would need 12 entries (the 11 in the toml + aarch64-unknown-freebsd); it has 10.

Step-by-step proof

allRustTargets (rust.ts:106-117) rust-toolchain.toml targets (20-32)
count 10 11
x86_64-linux-android ❌ absent ✅ line 26
aarch64-unknown-freebsd ❌ absent ❌ absent (correctly excluded — Tier 3)

So {toml targets} = {allRustTargets} ∖ {aarch64-unknown-freebsd} is false: the LHS has x86_64-linux-android and the RHS doesn't, and the subtrahend isn't in the minuend to begin with.

Why the surrounding code doesn't catch it

rustTarget() at rust.ts:54 emits ${arch}-linux-android for either arch, so x86_64-linux-android is a triple the build system can target. The two consumers of allRustTargets are scripts/rust-check-all.ts:21 (iterates the list to run cargo check --target <t>) and generateCargoConfig() in scripts/build/cargo-config.ts:79 (writes a [target.<triple>] linker section per entry). Both therefore silently skip x86_64-linux-android. The toml's extra entry is harmless in the other direction — rustup just installs an unused prebuilt std component.

Relation to the prior CodeRabbit comment

CodeRabbit inline comment 3211780323 (on rust.ts:117) already flagged "add x86_64-linux-android and aarch64-unknown-freebsd to allRustTargets" and is marked resolved / "✅ Addressed in commits d5945cf to 808905b" — but allRustTargets at HEAD is unchanged (still 10 entries, still missing both), and verifiers confirmed those commit SHAs are not in the repo history. So the resolved marker appears stale. This report is anchored at a different location — the new comment text in rust-toolchain.toml added by this PR — which is factually self-contradicting on day one regardless of how the allRustTargets side is resolved.

Impact

Low. x86_64-linux-android is not in docs/CI_RUST_MIGRATION.md's build matrix, so CI doesn't depend on it; the practical effect is that bun run rust:check-all skips one buildable triple and direct cargo check --target x86_64-linux-android won't get a generated [target.…].linker hint (the ninja build path sets the linker via env anyway, so this is advisory). The main cost is reader confusion: the comment promises a single source of truth that doesn't exist.

How to fix

Either:

  • Make allRustTargets the source of truth — add "x86_64-linux-android" and "aarch64-unknown-freebsd" to it (per the original CodeRabbit suggestion). Then the comment becomes true and rust:check-all/generateCargoConfig() cover the full set.
  • Or fix the comment — e.g. "Tier 1/2 triples that rustTarget() can produce, minus Tier-3 aarch64-unknown-freebsd", and accept that it's maintained independently of allRustTargets.

Comment thread scripts/build/rules.ts
Comment on lines 55 to 56
// shim_dylib (darwin+asan only)
registerShimRules(n, cfg);

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.

🟡 The inventory comment still reads // shim_dylib (darwin+asan only), but this PR's changes to shims.ts make registerShimRules() also register shim_crt_decompress (gated on needsMuslCrtDecompress(cfg), i.e. linux+musl+rust-lld). The same diff hunk updated the adjacent line 52 comment from // zig_fetch, zig_build to // rust_build — this one was missed in the same sweep. Should read e.g. // shim_dylib (darwin+asan), shim_crt_decompress (musl+rust-lld).

Extended reasoning...

What the bug is

scripts/build/rules.ts maintains an inventory comment above each registerXxxRules() call enumerating the ninja rule names that call registers — e.g. line 42 // cxx, cc, pch, link, ar, line 45-46 // dep_fetch, dep_fetch_prebuilt, dep_configure, dep_build, dep_cargo, line 49 // codegen, esbuild, bun_install + codegen/stamps dir stamps. Line 55 above registerShimRules(n, cfg) reads:

// shim_dylib (darwin+asan only)
registerShimRules(n, cfg);

But this PR's diff to scripts/build/shims.ts changes registerShimRules() so it now registers two rules under separate gates, not one:

  • shim_dylib (shims.ts:59), gated on cfg.darwin && cfg.asan
  • shim_crt_decompress (shims.ts:69), gated on needsMuslCrtDecompress(cfg)cfg.linux && cfg.abi === "musl" && cfg.rustLld !== undefined && cfg.ld === cfg.rustLld

The comment is therefore stale: it both omits the second rule and asserts "darwin+asan only", which is no longer the only condition under which registerShimRules emits a rule.

Step-by-step proof

  1. The convention exists and is actively maintained. Every other call in registerAllRules() (rules.ts:37-57) carries a comment listing its rule names. scripts/build/CLAUDE.md documents this file as the central rule registry.

  2. This PR updated the adjacent comment in the same hunk. The PR diff for rules.ts shows:

    -  // zig_fetch, zig_build
    -  registerZigRules(n, cfg);
    +  // rust_build
    +  registerRustRules(n, cfg);
    
       // shim_dylib (darwin+asan only)
       registerShimRules(n, cfg);

    Line 52's comment was rewritten as part of the zig→rust migration; line 55 sits two lines below in the same hunk's context and was left untouched.

  3. This PR is what added the second rule. The shims.ts changes in this PR remove the early-return if (!(cfg.darwin && cfg.asan)) return; from registerShimRules and add the needsMuslCrtDecompress() helper plus the n.rule("shim_crt_decompress", ...) registration. workarounds.ts (also touched in this PR) consumes it. Before this PR, "darwin+asan only" was accurate.

  4. Result: the comment now under-describes what the function does and the "only" qualifier is actively misleading.

Why nothing prevents it

These are inventory comments for human readers/grepping; nothing type-checks them against n.rule() calls. The PR's own doc-sync sweep over rules.ts (line 52) and over the whole scripts/build/CLAUDE.md module table demonstrates the intent to keep these accurate; line 55 was simply overlooked because the diff hunk's edit was two lines above it.

Impact

None functionally — comment-only drift. A reader grepping rules.ts to find which registerXxx owns shim_crt_decompress won't find it, and the "(darwin+asan only)" qualifier will mislead them about when shim rules are emitted. Same class of zig→rust doc-sync miss as the already-flagged scripts/build/CLAUDE.md:146 and codegen.ts:212/215 JSDoc nits on this PR.

How to fix

One-line edit at rules.ts:55:

// shim_dylib (darwin+asan), shim_crt_decompress (musl+rust-lld)
registerShimRules(n, cfg);

Comment on lines +98 to +100
{
id: "rust-lld-musl-crt-zlib",
issue: "https://github.com/rust-lang/rust/issues/data-compression-not-enabled",

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.

🟡 The issue URL https://github.com/rust-lang/rust/issues/data-compression-not-enabled is a placeholder, not a valid GitHub issue URL (issue paths are always numeric IDs, never slugs — this 404s). Since this field is surfaced to maintainers via Tracked: ${w.issue} in the BuildError hint at line 133, replace it with the real rust-lang/rust issue number for rust-lld lacking LLVM_ENABLE_ZLIB, or use a description-only string if no upstream issue exists.

Extended reasoning...

What the bug is

The new rust-lld-musl-crt-zlib workaround entry at scripts/build/workarounds.ts:98-118 sets:

issue: "https://github.com/rust-lang/rust/issues/data-compression-not-enabled",

This is not a valid GitHub issue URL. GitHub issue URLs always take the form /issues/<numeric-id> — slugs like data-compression-not-enabled are never valid issue identifiers. Visiting this URL returns a 404. The string data-compression-not-enabled looks like a placeholder note (perhaps the LLVM error message fragment, or a TODO marker) that was never replaced with the real upstream issue number before commit.

The code path that surfaces it

The Workaround.issue field is documented at workarounds.ts:39-40 as "Upstream tracker reference (issue URL, PR number, etc.)", and the file's module-level docstring (lines 1-30) explains that the entire purpose of this registry is to give future maintainers a pointer to verify when the upstream fix ships so the workaround can be removed.

The field is consumed at workarounds.ts:133, inside checkWorkarounds():

throw new BuildError(`Workaround '${w.id}' is obsolete — upstream fix is available`, {
  hint:
    `${w.description}\n` +
    `  Tracked: ${w.issue}\n\n` +
    ...
});

So when this workaround's expectedToBeFixed predicate trips (i.e., when clang's LLVM major catches up to rustc's bundled LLVM major on a musl build), the maintainer sees Tracked: https://github.com/rust-lang/rust/issues/data-compression-not-enabled and clicks through to a 404 — defeating the field's documented purpose.

Why nothing prevents it

The issue field is typed as plain string (workarounds.ts:40), and scripts/build/CLAUDE.md (referenced in the bug description) says only that workarounds should reference an upstream tracking issue — there's no validation. The two sibling entries in the same array follow the convention correctly: asan-dyld-shim at line 61 uses a real numeric LLVM issue URL (llvm-project/issues/182943), and rust-lld-for-crosslang-lto at line 80 uses a rustc-dev-guide doc page. This entry is the outlier.

Step-by-step proof

  1. workarounds.ts:100 — issue: "https://github.com/rust-lang/rust/issues/data-compression-not-enabled".
  2. GitHub's issue URL schema is /<owner>/<repo>/issues/<integer>. data-compression-not-enabled is not an integer. The URL 404s.
  3. workarounds.ts:39-40 documents issue as "Upstream tracker reference (issue URL, PR number, etc.)" — i.e., something resolvable.
  4. workarounds.ts:133 interpolates w.issue into a user-facing BuildError hint as Tracked: ${w.issue}.
  5. The expectedToBeFixed predicate (lines 107-114) returns true when clangMajor >= rustMajor on a musl LTO build with rust-lld selected. When that fires (next LLVM release that catches rustc's bundled major), configure fails and prints the broken link.
  6. The neighboring entries at lines 61 and 80 use real, resolvable URLs, demonstrating the intended convention this entry violates.

Impact

Metadata-only — no runtime or build behavior changes. The error message only appears when the workaround becomes obsolete, and even then the broken link is annoying rather than blocking (the cleanup instructions at line 115-117 are still actionable). But it's a clear leftover placeholder in newly-added code that defeats the explicit, documented purpose of the field, and is trivially fixable.

How to fix

Replace line 100 with the actual rust-lang/rust (or rust-lang/cargo, or LLVM) issue number tracking "rust-lld is built without LLVM_ENABLE_ZLIB". If no upstream issue exists, either file one, or use a description string that doesn't masquerade as a URL (e.g., issue: "rust-dist: rust-lld built without LLVM_ENABLE_ZLIB (no upstream tracker)") so a maintainer doesn't waste time clicking a 404.

Comment thread Cargo.toml
Comment on lines +138 to +142
[profile.release]
lto = "fat"
codegen-units = 1
debug = "line-tables-only"
strip = "none"

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.

🔴 This [profile.release] (no debug-assertions = true) plus the build-zigbuild-rust CI rewiring causes src/install/windows-shim/bun_shim_impl.rs to be release-compiled on Windows for the first time, where it fails with E0080 ~85× across all three Windows build-rust jobs (Build #53015). The debug! macro at line 144 contains const _: () = assert!(cfg!(debug_assertions)); — but unlike Zig's comptime assert(builtin.mode == .Debug) (skipped in dead branches), Rust evaluates const items unconditionally even inside if DBG { ... } with DBG = false, so every expansion becomes const _: () = assert!(false). Fix: delete line 144 (the if DBG wrapper at every call site already gates execution) or wrap the macro body in #[cfg(debug_assertions)].

Extended reasoning...

What the bug is

The debug! macro in src/install/windows-shim/bun_shim_impl.rs:142-154 is a port of Zig's fn debug (bun_shim_impl.zig:108-115), which used comptime assert(builtin.mode == .Debug) to ensure it was never invoked in release builds. The Rust port translates this to:

macro_rules! debug {
    ($fmt:literal $(, $arg:expr)* $(,)?) => {{
        const _: () = assert!(cfg!(debug_assertions));   // line 144
        ...
    }};
}

Every call site is wrapped if DBG { debug!(...) } where const DBG: bool = cfg!(debug_assertions) (line 54). In Zig this pattern works: with dbg comptime-false in release, the function body is never analyzed and the comptime assert never fires. In Rust it does not work: const items declared inside a block expression are items, not statements — they are hoisted and evaluated at compile time regardless of runtime control-flow reachability. if false { const _: () = assert!(false); } is a hard E0080 compile error.

Why this PR triggers it

The broken line itself isn't modified by this PR, but this PR is what causes it to be compiled in release mode for the first time:

  1. The new workspace Cargo.toml (this hunk, lines 138-142) defines [profile.release] with lto, codegen-units, debug, strip — but not debug-assertions = true, so cargo's release default (debug-assertions = false) applies and cfg!(debug_assertions) evaluates to false.
  2. .buildkite/ci.mjs rewires CI from getBuildZigStepgetBuildRustStep, and getRustAgent() runs Windows builds natively on a Windows agent.
  3. src/install is a workspace member, and bun_shim_impl.rs is gated #![cfg(windows)] (line 42), so only Windows targets compile it.

Before this PR, CI built Zig (getBuildZigStep); the .rs file existed but was never compiled by CI's release profile.

Step-by-step proof

  1. Windows CI runs cargo build --profile release (via scripts/build/rust.ts, cargoProfile() maps non-Debug → release).
  2. cfg!(debug_assertions) = false under [profile.release].
  3. rustc parses bun_shim_impl.rs. At each of ~30 if DBG { debug!(...) } call sites (lines 469-470, 549-551, 584-585, 636-637, 664-665, 679-680, 701-702, 724-725, 764, …, 1154), the macro expands to a block containing const _: () = assert!(false);.
  4. Item collection hoists every const _ into the enclosing function's set of required-consts. Const evaluation runs before dead-code elimination at the MIR level — the if false guard is irrelevant to whether the const is evaluated.
  5. Each const evaluates assert!(false) → panic in const context → error[E0080]: evaluation panicked: assertion failed: cfg!(debug_assertions).

This is exactly what robobun reports on this PR's Build #53015 (commit 16fbc07): [E0080] evaluation panicked: assertion failed: cfg!(debug_assertions) at src/install/windows-shim/bun_shim_impl.rs#L144, repeated ~28× per job across 🪟 x64, 🪟 x64-baseline, and 🪟 aarch64 build-rust. No commits between 16fbc07 and HEAD (a5bc96d) touch this file.

Why the existing code doesn't prevent it

The if DBG { ... } wrapper is a runtime (well, const-folded-to-nothing) guard. It successfully ensures the body of the macro never executes in release. But const _: () = ...; is not an executable statement — it's an item declaration, and item declarations inside blocks are processed unconditionally during compilation, just like a fn or struct declared inside a block. This is the same mechanism the static_assertions crate exploits: a const item's initializer is always evaluated, period.

The other Windows blockers already commented on this PR — the && shell-chaining bug (#3212044052), the cfg.msvcLinker bug (#3211780331), the tier3 rustup self-heal (#3211967077) — all gate the build before cargo gets to compile this crate. But CI Build #53015 demonstrates cargo did reach this file (the E0080 is a rustc compile error, not a build-script/linker error), so even after those are fixed, this E0080 fires next.

Impact

All three Windows release build-rust jobs fail with ~85 instances of E0080. Windows CI is hard-blocked.

How to fix

Simplest: delete line 144. The if DBG { debug!(...) } wrapper at every call site already guarantees the macro body only executes in debug builds, and in release the entire if false { ... } block is DCE'd anyway. The const-assert was Zig belt-and-suspenders that doesn't translate to Rust.

Alternative: replace the macro body with conditional compilation so it's a true no-op in release:

macro_rules! debug {
    ($fmt:literal $(, $arg:expr)* $(,)?) => {{
        #[cfg(debug_assertions)]
        {
            #[cfg(not(feature = "shim_standalone"))]
            { bun_output::scoped_log!(bun_shim_impl, $fmt $(, $arg)*); }
            #[cfg(feature = "shim_standalone")]
            { let _ = ($($arg,)*); }
        }
    }};
}

This also lets the if DBG { } wrappers at call sites be dropped if desired.

@markojak

Copy link
Copy Markdown

Must be the most epic rewrite in history

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.