/** * The bun executable target — orchestrates everything. * * This is where all the phases come together: * - emit codegen → generated .cpp/.h/.rs * - emit cargo build → libbun_rust.a * - resolve all deps → lib paths + include dirs * - build PCH from root-pch.h (implicit deps: WebKit libs + all codegen) * - compile all C/C++ with the PCH * - link everything → bun-debug (or bun-profile, bun-asan, etc.) * - smoke test: run ` --revision` to catch load-time failures * * ## Build modes * * `cfg.mode` controls what we actually produce: * - "full": everything (default, local dev) * - "cpp-only": compile to libbun.a, skip rust/link (CI upstream) * - "rust-only": codegen + cargo → libbun_rust.a (CI upstream) * - "link-only": link pre-built artifacts (CI downstream) * - "rust-and-link": cargo + link; downloads cpp-only's archive (CI) * - "archive-link": full build on one agent, linked from the cpp-only-style archive; uploads it + libbun_rust.a (CI) * * The split modes are for CI where C++ and Rust build in parallel on * separate machines. rust-and-link folds the rust + link steps onto one * agent (cargo runs while cpp-only is still compiling elsewhere; the * cpp archive is polled for and downloaded before ninja links). */ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs"; import { dirname, relative, resolve, sep } from "node:path"; import type { Sources } from "../glob-sources.ts"; import { emitCodegen, type CodegenOutputs } from "./codegen.ts"; import { ar, cc, cxx, link, pch } from "./compile.ts"; import { bunExeName, shouldStrip, type Config } from "./config.ts"; import { generateDepVersionsHeader } from "./depVersionsHeader.ts"; import { allDeps } from "./deps/index.ts"; import { lolhtml } from "./deps/lolhtml.ts"; import { assert } from "./error.ts"; import { bunIncludes, computeFlags, extraFlagsFor, linkDepends } from "./flags.ts"; import { writeIfChanged } from "./fs.ts"; import type { BuildNode, Ninja } from "./ninja.ts"; import { emitRust, linkerMapPath, rustLibPath, rustLtoLinkInputs } from "./rust.ts"; import { quote, slash } from "./shell.ts"; import { emitShims, machoPostlinkCommand, machoPostlinkImplicitInputs } from "./shims.ts"; import { computeDepLibs, resolveDep, type ResolvedDep } from "./source.ts"; import { streamPath } from "./stream.ts"; import { generateUnifiedSources } from "./unified.ts"; // ─────────────────────────────────────────────────────────────────────────── // Executable naming // ─────────────────────────────────────────────────────────────────────────── // Re-exported for existing importers (configure.ts, ci.ts). These live // in config.ts now so flags.ts can use bunExeName without circular import. export { bunExeName, shouldStrip }; /** * System libraries to link. Platform-dependent. */ function systemLibs(cfg: Config): string[] { const libs: string[] = []; if (cfg.linux) { if (cfg.abi === "android") { // bionic: pthread/dl/rt are folded into libc; no separate libatomic // (compiler-rt builtins). -llog for __android_log_*. libs.push("-lc", "-lm", "-llog"); } else { libs.push("-lc", "-lpthread", "-ldl"); // libatomic: static by default (CI distros ship it), dynamic on Arch-like. // The static path needs to be the actual file path for lld to find it; // dynamic uses -l syntax. We emit what CMake does: bare libatomic.a gets // found in lib search paths, -latomic.so doesn't exist so we use -latomic. if (cfg.staticLibatomic) { libs.push("-l:libatomic.a"); } else { libs.push("-latomic"); } } // Linux local WebKit: link system ICU (prebuilt bundles its own). // Assumes system ICU is in default lib paths — true on most distros. // Android: no system ICU; the local WebKit build must bundle it. if (cfg.webkit === "local" && cfg.abi !== "android") { libs.push("-licudata", "-licui18n", "-licuuc"); } } if (cfg.darwin) { // icucore: system ICU framework. // resolv: DNS resolution (getaddrinfo et al). libs.push("-licucore", "-lresolv"); } if (cfg.freebsd) { // pthread/m: explicit on FreeBSD (not folded into libc). // execinfo: backtrace() — separate library on FreeBSD. // kvm/procstat/elf/util: process introspection for node:os and crash handler. libs.push("-lc", "-lpthread", "-lm", "-lexecinfo", "-lkvm", "-lprocstat", "-lelf", "-lutil"); } if (cfg.windows) { // Explicit .lib: these go after /link so no auto-suffixing by the // clang-cl driver. lld-link auto-appends .lib but link.exe doesn't; // explicit is portable. libs.push( "winmm.lib", "bcrypt.lib", "ntdll.lib", "userenv.lib", "dbghelp.lib", "crypt32.lib", "wsock32.lib", // ws2_32 + wsock32 — wsock32 has TransmitFile (sendfile equiv) "ws2_32.lib", "delayimp.lib", // required for /delayload: in release ); } return libs; } // ─────────────────────────────────────────────────────────────────────────── // Main orchestration // ─────────────────────────────────────────────────────────────────────────── /** * Output of `emitBun()`. Paths to the produced artifacts and resolved * deps — used by configure.ts for mkdir + default-target selection, and * by ci.ts for artifact upload. * * Optional fields are present only when the mode produces them: * full: exe, strippedExe?, dsym?, rustObjects, objects, deps, codegen * cpp-only: archive, objects, deps, codegen * rust-only: rustObjects, deps (lolhtml), codegen * link-only: exe, strippedExe?, dsym? */ export interface BunOutput { /** Linked executable (bun-debug, bun-profile). Full/link-only. */ exe?: string; /** Stripped `bun`. Plain release full/link-only. */ strippedExe?: string | undefined; /** .dSYM bundle (darwin plain release). Added to default targets so ninja builds it. */ dsym?: string | undefined; /** libbun.a — all C/C++ objects archived. cpp-only. */ archive?: string; /** All resolved deps (full libs list). Empty in link-only (paths computed separately). */ deps: ResolvedDep[]; /** All codegen outputs. Not present in link-only. */ codegen?: CodegenOutputs; /** The Rust staticlib path(s). Empty in cpp-only. */ rustObjects: string[]; /** All compiled .o files. Empty in link-only/rust-only. */ objects: string[]; /** Stamps of the buildkite artifact-upload edges; archive-link adds them to the default targets. */ uploadStamps?: string[]; } /** * Emit the full bun build graph. Returns the output executable path. * * Call after `registerAllRules(n, cfg)`. `sources` is the globbed file * snapshot from `globAllSources()` — passed in so globbing happens once. */ export function emitBun(n: Ninja, cfg: Config, sources: Sources): BunOutput { // Split modes get minimal graphs — separate functions. if (cfg.mode === "rust-only") { return emitRustOnly(n, cfg, sources); } if (cfg.mode === "link-only") { return emitLinkOnly(n, cfg); } if (cfg.mode === "rust-and-link") { return emitRustAndLink(n, cfg, sources); } const exeName = bunExeName(cfg); n.comment("════════════════════════════════════════════════════════════════"); n.comment(` Building ${exeName}`); n.comment("════════════════════════════════════════════════════════════════"); n.blank(); // ─── Step 1: codegen + rust ─── // Emitted before the deps: ninja breaks scheduling ties by emission order, and cargo is the critical path (see the compile pool in compile.ts). const codegen = emitCodegen(n, cfg, sources); const depsByName = new Map(); // One cargo invocation produces a single staticlib that occupies the // same slot in the link as the C++ archive. Rust `include!`s codegen // `.rs` outputs (written as side effects of the generate-classes / // bundle-modules / generate-jssink edges), so the codegen output set // is forwarded as implicit inputs to order it first. // // cpp-only: skip rust entirely (runs on a separate CI machine). let rustObjects: string[] = []; if (cfg.mode !== "cpp-only") { // lol-html is a direct path dep of `bun_runtime`/`bun_bundler` // (`lol_html = { path = "vendor/lolhtml" }` in the workspace Cargo.toml), // not built into a separate archive — cargo needs `vendor/lolhtml/` on // disk before it resolves the manifest. The `.ref` stamp's content is // the pinned commit, so a bump re-invokes cargo. const lolhtmlDep = resolveDep(n, cfg, lolhtml, depsByName); assert(lolhtmlDep !== null, "lolhtml resolveDep returned null — should never be skipped"); depsByName.set(lolhtml.name, lolhtmlDep); rustObjects = emitRust(n, cfg, { codegenInputs: codegen.rustInputs, codegenOrderOnly: codegen.rustOrderOnly, rustSources: sources.rust, vendorStamps: lolhtmlDep.outputs, }); } // ─── Step 2: resolve all deps ─── n.comment("─── Dependencies ───"); n.blank(); const deps: ResolvedDep[] = []; for (const dep of allDeps) { const resolved = depsByName.get(dep.name) ?? resolveDep(n, cfg, dep, depsByName); if (resolved !== null) { deps.push(resolved); depsByName.set(dep.name, resolved); } } // Collect all dep lib paths, include dirs, output stamps, and directly- // compiled source files (deps like picohttpparser that provide .c files // instead of a .a — we compile those alongside bun's own sources). const depLibs: string[] = []; const depObjects: string[] = []; const depIncludes: string[] = []; // Outputs of deps that provide headers — used as implicit inputs on PCH/cc/ // no-PCH cxx so a dep rebuild invalidates compiles that #include its headers // (the .a is the signal — see comment at the PCH step). Deps with no provided // includes (tinycc, lolhtml) are skipped: nothing to invalidate, and a tinycc // no-op rebuild (ar has no restat) would otherwise cascade to a full PCH+cxx // rebuild. Link still gets every dep via depLibs/depObjects. const depHeaderSignal: string[] = []; for (const d of deps) { depLibs.push(...d.libs); depObjects.push(...d.objects); depIncludes.push(...d.includes); // d.outputs is the "headers are ready" signal: for nested-cmake/ // prebuilt that's the .a/stamp (headers are undeclared side-effects), // for direct deps it's the generated-header set + source stamp. if (d.includes.length > 0) depHeaderSignal.push(...d.outputs); } // ─── Step 3: configure-time generated header + assemble flags ─── // bun_dependency_versions.h — written at configure time, not a ninja rule. // BunProcess.cpp includes it for process.versions. writeIfNotChanged // semantics so bumping an unrelated dep doesn't recompile everything. generateDepVersionsHeader(cfg); const flags = computeFlags(cfg); // Full include set: bun's own + all dep includes + buildDir (for the // generated versions header). const allIncludes = [...bunIncludes(cfg), cfg.buildDir, ...depIncludes]; const includeFlags = allIncludes.map(inc => `-I${inc}`); const defineFlags = flags.defines.map(d => `-D${d}`); // Final flag arrays for compile. const cxxFlagsFull = [...flags.cxxflags, ...includeFlags, ...defineFlags]; const cFlagsFull = [...flags.cflags, ...includeFlags, ...defineFlags]; // ─── Step 4: PCH ─── // CI full mode (unused by the pipeline) skips the PCH; cpp-only/archive-link use it. const usePch = !cfg.ci || cfg.mode !== "full"; let pchOut: { pch: string; wrapperHeader: string } | undefined; if (usePch) { n.comment("─── PCH ───"); n.blank(); // Dep outputs are IMPLICIT inputs (not order-only). The crucial case is // local WebKit: headers live in buildDir and get REGENERATED by dep_build // mid-run. At startup, ninja sees old headers via PCH's depfile → thinks // PCH is fresh. dep_build then regenerates them. cxx fails with "file // modified since PCH was built". As implicit inputs, restat sees the .a // changed → PCH rebuilds → one-build convergence. See the pch() docstring. // // Codegen stays order-only: those outputs only change if inputs change, // and inputs don't change mid-build. cppAll (not all) — bake/.rs outputs // are rust-only; pulling them here would run bake-codegen in cpp-only CI // mode where it fails on the pinned bun version (see cppAll docstring). // Scripts that emit undeclared .h also emit a .cpp/.h in cppAll, so they // still run. cxx transitively waits: cxx → PCH → deps+cppAll. pchOut = pch(n, cfg, "src/jsc/bindings/root-pch.h", { flags: cxxFlagsFull, implicitInputs: depHeaderSignal, orderOnlyInputs: codegen.cppAll, }); } // ─── Step 5: compile C/C++ ─── n.comment("─── C/C++ compilation ───"); n.blank(); // Source lists: from the pre-globbed snapshot + platform extras. // Unified sources: bundle the globbed .cpp into N-per-TU wrappers (see // unified.ts for N). Generated at configure time; depfiles track the underlying // .cpp files so editing one rebuilds its bundle. Codegen .cpp are kept // separate — those are already large single TUs (ZigGeneratedClasses.cpp // is 3.3 MB) and bundling them would serialize work. Always called so // stale bundles are pruned even with --unifiedSources=false. const split = generateUnifiedSources(cfg, sources.cxx); const cxxSources = [...split.unified, ...split.standalone]; const cSources = [...sources.c]; // Sources that must NOT use the PCH. Anything that needs to set defines // before (UNICODE, WIN32_LEAN_AND_MEAN opt-outs, etc.) goes // here — root-pch.h transitively includes Windows.h via WTF, so the // force-include would lock those in before the source can speak. const noPchSources = new Set(); // highway_json.cpp is compiled -O2 even in debug profiles (see its // fileOverrides entry in flags.ts); a TU at a different -O level than the // PCH cannot use the PCH ("__OPTIMIZE__ ... was disabled in precompiled // file"). It only includes highway + libc headers anyway. if (cfg.debug) { noPchSources.add(resolve(cfg.cwd, "src/jsc/bindings/highway_json.cpp")); noPchSources.add(resolve(cfg.cwd, "src/jsc/bindings/highway_xml.cpp")); } // Windows-only cpp sources (rescle — PE resource editor for --compile). if (cfg.windows) { // rescle.h does `#define UNICODE` before including ATL; with PCH the // headers are already past in MBCS mode and ATL's TCHAR mismatches. const rescle = resolve(cfg.cwd, "src/jsc/bindings/windows/rescle.cpp"); const rescleBinding = resolve(cfg.cwd, "src/jsc/bindings/windows/rescle-binding.cpp"); cxxSources.push(rescle, rescleBinding); noPchSources.add(rescle); noPchSources.add(rescleBinding); } // Deps with provides.sources compiled in the loop below so each dep's // phony can point at its own .o files. // Codegen .cpp files — compiled like regular sources. cxxSources.push(...codegen.cppSources); cxxSources.push(...codegen.bindgenV2Cpp); // All deps must be ready (headers extracted, libs built) before compile. // // depHeaderSignal are IMPLICIT inputs, not order-only. A locally-built dep's // sub-build (e.g. WebKit) rewrites forwarding headers as an undeclared side // effect of the edge whose declared outputs are only lib*.a. Depfiles record // those headers, but ninja stats them BEFORE the sub-build runs — so with // order-only, any compile that #includes a dep header lags one build behind // a dep rebuild (observed: uv-posix-*.c → wtf/Compiler.h). // Implicit deps on the libs make "dep rebuilt" itself the invalidation // signal. Cost is negligible: if the libs changed you're relinking anyway. // // codegen.cppAll stays order-only: those headers ARE declared ninja outputs // with restat, so depfile tracking is exact and doesn't lag. // // PCH also has implicit deps on depHeaderSignal (see above). When PCH is enabled, // cxx inherits the dep transitively via its implicit dep on the PCH, so we // don't add it again. const codegenOrderOnly = codegen.cppAll; // Compile all .cpp with PCH. // Emit compile_commands.json entries for the ORIGINAL bundled .cpp files // too — clangd looks up flags by the file you opened, and a bundled source // has no ninja edge of its own. Same flags as the bundle (no PCH listed — // clangd parses standalone, and the PCH path is build-internal). for (const src of split.bundled) { n.addCompileCommand({ directory: cfg.buildDir, file: src, arguments: [cfg.cxx, ...cxxFlagsFull, "-c", src], }); } const cxxObjects: string[] = []; for (const src of cxxSources) { const relSrc = relative(cfg.cwd, src); const extraFlags = extraFlagsFor(cfg, relSrc); const opts: Parameters[3] = { flags: [...cxxFlagsFull, ...extraFlags], }; if (pchOut !== undefined && !noPchSources.has(src)) { // PCH has implicit deps on depHeaderSignal. cxx has implicit dep on PCH. // Transitively: cxx waits for deps. No need to repeat them here. opts.pch = pchOut.pch; opts.pchHeader = pchOut.wrapperHeader; } else { // No PCH (CI full mode, or per-file opt-out) — each cxx needs the dep // signal directly. opts.implicitInputs = depHeaderSignal; opts.orderOnlyInputs = codegenOrderOnly; } cxxObjects.push(cxx(n, cfg, src, opts)); } // Compile all .c files. No PCH — dep signal applied directly. const cObjects: string[] = []; const compileC = (src: string): string => { const obj = cc(n, cfg, src, { flags: cFlagsFull, implicitInputs: depHeaderSignal, orderOnlyInputs: codegenOrderOnly, }); cObjects.push(obj); return obj; }; for (const src of cSources) compileC(src); // InternalModuleRegistryConstants.S — `.incbin`s the bundled JS module sources // so InternalModuleRegistry.cpp sees a tiny {offset, length} table instead of // megabytes of byte-array initializers. The `.bin` payload is an implicit // input: `.incbin` is opaque to depfiles, and the `.S` itself rarely changes. // cFlagsFull carries --target/--sysroot/-march so a cross-compile's // preprocessor picks the right __APPLE__/_WIN32 branch and object format. cObjects.push( cc(n, cfg, codegen.internalModulesAsm, { flags: cFlagsFull, implicitInputs: [codegen.internalModulesBin], }), ); // Deps that contribute source files for bun to compile directly (via // provides.sources) instead of building a lib. Compile them here with // bun's full flag set and give each a phony so `--target ` builds // its .o files. libs.length === 0 guard: deps with a build step already // got a phony in resolveDep — don't emit a duplicate. for (const d of deps) { if (d.sources.length === 0 || d.libs.length > 0) continue; n.phony(d.name, d.sources.map(compileC)); } // Dep objects (when !cfg.archiveDeps) are linked alongside bun's own // objects — same response file, same archive in cpp-only mode. With // cfg.archiveDeps they live in depLibs as .a files instead. const allObjects = [...cxxObjects, ...cObjects, ...depObjects]; // ─── Step 6: cpp-only / archive-link → archive (cpp-only returns here) ─── // CI's build-cpp step: archive all .o into libbun.a, stop. The sibling // build-rust step produces libbun_rust.a independently; build-bun // downloads both artifacts and links them. Archive name uses the exe // name (not just "libbun") so asan/debug variants are distinguishable. const archived = cfg.mode === "cpp-only" || cfg.mode === "archive-link"; let archive: string | undefined; const uploadStamps: string[] = []; if (archived) { n.comment(`─── Archive (${cfg.mode}) ───`); n.blank(); archive = ar(n, cfg, `${cfg.libPrefix}${exeName}${cfg.libSuffix}`, allObjects); // Upload dep libs as soon as they're built — they're ready ~minutes // before the archive (WebKit copies from prefetch in seconds; lolhtml // builds in ~30s), so the upload overlaps the cxx compile instead of // waiting for it. Own pool so it doesn't take a compile slot. ci.ts's // uploadArtifacts() then only handles the archive. if (cfg.buildkite) { registerBkUploadRules(n, cfg); if (depLibs.length > 0) uploadStamps.push(emitBkUpload(n, cfg, ".dep-libs-uploaded", depLibs)); // archive-link: each upload edge depends only on its input, so it starts the moment the archive / staticlib exists and overlaps the link. if (cfg.mode === "archive-link") { uploadStamps.push(emitBkUpload(n, cfg, ".archive-uploaded", [archive], { gzip: !cfg.windows })); uploadStamps.push(emitBkUpload(n, cfg, ".rust-lib-uploaded", rustObjects, { gzip: !cfg.windows })); } } // depLibs explicit in the phony: deps with no provided includes (tinycc, // lolhtml) aren't in depHeaderSignal, so the archive doesn't pull them // transitively — but link-only still needs them uploaded. if (cfg.mode === "cpp-only") { n.phony("bun", [archive, ...depLibs, ...uploadStamps]); n.default(["bun"]); return { archive, deps, codegen, rustObjects, objects: allObjects }; } } // ─── Step 6: link ─── n.comment("─── Link ───"); n.blank(); // Windows resources (.rc → .res): icon, VersionInfo. Compiled at link // time (not archived in cpp-only) — .res is small and the .rc depends // on cfg.version which the link step already has. Matches cmake's // behavior of adding WINDOWS_RESOURCES to add_executable in link-only. const windowsRes = cfg.windows ? [emitWindowsResources(n, cfg)] : []; // Full link. // The Rust staticlib goes into `$in` between bun's own objects and the // dependency archives so symbol resolution order is preserved: C++ // objects create the `Bun__*` undefined refs, the Rust archive satisfies // them (and `main`, via crt1.o) and in turn references JSC/WTF, depLibs // satisfies those. 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. const shims = emitShims(n, cfg); // rustLtoLinkInputs(): on ELF cross-language LTO targets the Rust bitcode // is rewritten with a regular-LTO summary first (identity elsewhere). const linkObjects = [ ...(archive !== undefined ? [archive] : allObjects), ...rustLtoLinkInputs(n, cfg, rustObjects), ...windowsRes, ]; const ldflags = [...flags.ldflags, ...systemLibs(cfg), ...shims.ldflags]; const exe = link(n, cfg, exeName, linkObjects, { libs: depLibs, flags: ldflags, implicitInputs: [...linkImplicitInputs(cfg), ...shims.implicitInputs], // Declare the `-Wl,-Map=` side-product so `perf` symbolication picks it // up. Linux release only — the map flag itself is gated identically in // flags.ts. linkerMapOutput: cfg.linux && cfg.release && !cfg.asan && !cfg.valgrind ? linkerMapPath(cfg) : undefined, }); // ─── Step 7: post-link (strip, dsymutil, smoke test) ─── const { strippedExe, dsym } = emitPostLink(n, cfg, exe, exeName, flags.stripflags); return { exe, strippedExe, dsym, deps, codegen, rustObjects, objects: allObjects, uploadStamps }; } function registerBkUploadRules(n: Ninja, cfg: Config): void { n.pool("bk_upload", 1); // Paths are buildDir-relative so artifact names match downloadArtifacts(); `;` is the agent's path delimiter. const win = cfg.host.os === "windows"; n.rule("bk_upload", { command: win ? `cmd /c buildkite-agent artifact upload "$paths" && type nul > $out` : `buildkite-agent artifact upload '$paths' && touch $out`, description: "buildkite upload $out", pool: "bk_upload", }); if (!win) { n.rule("bk_upload_gz", { command: `gzip -1 -k -f $in && buildkite-agent artifact upload '$paths' && touch $out`, description: "gzip + buildkite upload $out", pool: "bk_upload", }); } } function emitBkUpload(n: Ninja, cfg: Config, stamp: string, files: string[], { gzip = false } = {}): string { const useGz = gzip && cfg.host.os !== "windows"; const rel = files.map(p => relative(cfg.buildDir, p)); const out = resolve(cfg.buildDir, stamp); n.build({ outputs: [out], rule: useGz ? "bk_upload_gz" : "bk_upload", inputs: files, vars: { paths: (useGz ? rel.map(p => `${p}.gz`) : rel).join(";") }, }); return out; } /** * rust-only mode: emit just the cargo build graph. CI's build-rust step * uses this to compile libbun_rust.a in parallel with build-cpp; target * set via --os/--arch overrides (cargo `--target `). * * Needs: * - lolhtml FETCHED (path dep of `bun_runtime`/`bun_bundler`) — not built separately * - codegen (Rust `include!`s/`include_bytes!`s the same generated set) * - cargo build → libbun_rust.a * * Does NOT need: any C dep built, any cxx, PCH, link. ninja only pulls * what's depended on — lolhtml's configure/build rules are emitted but * unused (only its `.ref` fetch stamp is depended on by emitRust). */ function emitRustOnly(n: Ninja, cfg: Config, sources: Sources): BunOutput { n.comment("════════════════════════════════════════════════════════════════"); n.comment(` Building libbun_rust.a (rust-only, target: ${cfg.os}-${cfg.arch})`); n.comment("════════════════════════════════════════════════════════════════"); n.blank(); // Only dep: lolhtml, fetched as a cargo path dependency. resolveDep // emits its fetch; emitRust depends on the fetch stamp via vendorStamps. const lolhtmlDep = resolveDep(n, cfg, lolhtml, new Map()); assert(lolhtmlDep !== null, "lolhtml resolveDep returned null — should never be skipped"); // Codegen: emitted fully, but only the embed-input subset is pulled. // The cpp-related outputs (cppSources, bindgenV2Cpp) have no consumer // in this graph — ninja skips them. const codegen = emitCodegen(n, cfg, sources); const rustObjects = emitRust(n, cfg, { codegenInputs: codegen.rustInputs, codegenOrderOnly: codegen.rustOrderOnly, rustSources: sources.rust, vendorStamps: lolhtmlDep.outputs, }); n.phony("bun", rustObjects); n.default(["bun"]); return { deps: [lolhtmlDep], codegen, rustObjects, objects: [] }; } /** * link-only mode: link artifacts downloaded from sibling buildkite steps. * CI's build-bun step. Build.ts downloads into buildDir BEFORE ninja runs; * ninja sees the files as source inputs (no build rule — errors cleanly * if download failed or paths drift). * * Expected artifacts (same paths cpp-only/rust-only produced): * - libbun-profile.a — from cpp-only's ar() * - libbun_rust.a / bun_rust.lib — from rust-only's cargo (rustLibPath) * - deps//lib.a — from cpp-only's dep builds * - cache/webkit-/lib/... — WebKit prebuilt (same cache path) */ function emitLinkOnly(n: Ninja, cfg: Config): BunOutput { const exeName = bunExeName(cfg); n.comment("════════════════════════════════════════════════════════════════"); n.comment(` Linking ${exeName} (link-only — artifacts from buildkite)`); n.comment("════════════════════════════════════════════════════════════════"); n.blank(); // Dep lib paths — computed, not built. Must match cpp-only's output // paths exactly; computeDepLibs() and emitNestedCmake()'s path logic // share the same formula. If they drift, link fails with "file not // found" — loud enough to catch in CI. const depLibs: string[] = []; for (const dep of allDeps) { depLibs.push(...computeDepLibs(cfg, dep)); } // Archive from cpp-only: same name cpp-only emits (exe name + lib // prefix/suffix, e.g. libbun-profile.a). const archive = resolve(cfg.buildDir, `${cfg.libPrefix}${exeName}${cfg.libSuffix}`); // libbun_rust.a from rust-only: same path emitRust writes to. Shared // helper so both sides of the CI split agree (cargo's // `///` layout). rustLtoLinkInputs(): on ELF // cross-language LTO targets the downloaded archive's bitcode is rewritten // with a regular-LTO summary on this (link) agent before the link. const rustObjects = rustLtoLinkInputs(n, cfg, [rustLibPath(cfg)]); // Only need ldflags + stripflags (no cflags/cxxflags — no compile). const flags = computeFlags(cfg); n.comment("─── Link ───"); n.blank(); // Windows resources: compiled here, not downloaded from cpp-only. // .res is small; .rc substitution depends on cfg.version which link-only // knows. Matches cmake's BUN_LINK_ONLY adding WINDOWS_RESOURCES directly. const windowsRes = cfg.windows ? [emitWindowsResources(n, cfg)] : []; const shims = emitShims(n, cfg); const linkObjects = [archive, ...rustObjects, ...windowsRes]; const ldflags = [...flags.ldflags, ...systemLibs(cfg), ...shims.ldflags]; const exe = link(n, cfg, exeName, linkObjects, { libs: depLibs, flags: ldflags, implicitInputs: [...linkImplicitInputs(cfg), ...shims.implicitInputs], linkerMapOutput: cfg.linux && cfg.release && !cfg.asan && !cfg.valgrind ? linkerMapPath(cfg) : undefined, }); // Strip + smoke test — same as full mode. const { strippedExe, dsym } = emitPostLink(n, cfg, exe, exeName, flags.stripflags); return { exe, strippedExe, dsym, deps: [], // no ResolvedDep — we only computed lib paths rustObjects, objects: [], }; } /** * rust-and-link mode: cargo build + link on one CI agent. The cpp archive * and dep libs are downloaded from the sibling build-cpp step (ci.ts polls * for its outcome and downloads before ninja runs); libbun_rust.a is built * locally. Graph = emitRustOnly's cargo edge + emitLinkOnly's link edge. * * Expected downloaded artifacts (same paths cpp-only produced): * - libbun-profile.a — from cpp-only's ar() * - deps//lib.a — from cpp-only's dep builds * - cache/webkit-/lib/... — WebKit prebuilt (same cache path) */ function emitRustAndLink(n: Ninja, cfg: Config, sources: Sources): BunOutput { const exeName = bunExeName(cfg); n.comment("════════════════════════════════════════════════════════════════"); n.comment(` Building ${exeName} (rust-and-link — cpp archive from buildkite)`); n.comment("════════════════════════════════════════════════════════════════"); n.blank(); // ─── Rust (built here) ─── // lolhtml fetch + codegen + cargo — same as emitRustOnly. The cargo edge // runs while build-cpp is still compiling on its own agent; by the time // ninja reaches the link edge, ci.ts has already downloaded the archive. const lolhtmlDep = resolveDep(n, cfg, lolhtml, new Map()); assert(lolhtmlDep !== null, "lolhtml resolveDep returned null — should never be skipped"); const codegen = emitCodegen(n, cfg, sources); const rustObjects = emitRust(n, cfg, { codegenInputs: codegen.rustInputs, codegenOrderOnly: codegen.rustOrderOnly, rustSources: sources.rust, vendorStamps: lolhtmlDep.outputs, }); // ─── C++ archive + dep libs (downloaded, not built) ─── // Paths computed exactly as emitLinkOnly does — must match cpp-only's // output layout. ninja sees them as source inputs (no build rule). const depLibs: string[] = []; for (const dep of allDeps) { depLibs.push(...computeDepLibs(cfg, dep)); } const archive = resolve(cfg.buildDir, `${cfg.libPrefix}${exeName}${cfg.libSuffix}`); // ─── Link ─── const flags = computeFlags(cfg); n.comment("─── Link ───"); n.blank(); const windowsRes = cfg.windows ? [emitWindowsResources(n, cfg)] : []; const shims = emitShims(n, cfg); const linkObjects = [archive, ...rustLtoLinkInputs(n, cfg, rustObjects), ...windowsRes]; const ldflags = [...flags.ldflags, ...systemLibs(cfg), ...shims.ldflags]; const exe = link(n, cfg, exeName, linkObjects, { libs: depLibs, flags: ldflags, implicitInputs: [...linkImplicitInputs(cfg), ...shims.implicitInputs], linkerMapOutput: cfg.linux && cfg.release && !cfg.asan && !cfg.valgrind ? linkerMapPath(cfg) : undefined, }); const { strippedExe, dsym } = emitPostLink(n, cfg, exe, exeName, flags.stripflags); return { exe, strippedExe, dsym, deps: [lolhtmlDep], codegen, rustObjects, objects: [], }; } /** * Post-link steps shared by every linking mode (full, link-only, * rust-and-link): strip, dsymutil, the `bun` phony, and the `--revision` * smoke test. * * Centralized because the smoke_test and dsymutil edges must be ordered * after strip — their rule commands wrap through `cfg.jsRuntime` * (process.execPath), which can BE the strip output when `bun` on PATH * resolves into the build directory (build/release/bun). Without the * ordering, ninja runs strip and the wrapper exec concurrently (both * depend only on `exe`) and the wrapper fails with "Permission denied" on * the half-written file. Open-coding this in each mode already caused one * call site to be missed (#30539), so the invariant lives here. */ export function emitPostLink( n: Ninja, cfg: Config, exe: string, exeName: string, stripflags: string[], ): { strippedExe: string | undefined; dsym: string | undefined } { // Plain release only: produce stripped `bun` alongside `bun-profile`. // Debug/asan/valgrind/assertions keep symbols (you want them for // debugging). let strippedExe: string | undefined; let dsym: string | undefined; if (shouldStrip(cfg)) { strippedExe = emitStrip(n, cfg, exe, stripflags); // darwin: extract debug symbols from the UNSTRIPPED exe into a .dSYM // bundle. dsymutil reads DWARF from bun-profile, writes // bun-profile.dSYM. The input exe is never stripped in-place (strip // writes a new file via -o), so the read is safe. if (cfg.darwin) dsym = emitDsymutil(n, cfg, exe, exeName, strippedExe); } // `bun` phony — only when strip didn't produce a literal file named // `bun` (which would collide with the phony). When strip runs, `ninja // bun` builds the stripped file; no phony needed. if (strippedExe === undefined) n.phony("bun", [exe]); // Run ` --revision`. If it exits non-zero or crashes, something // broke at load time (missing symbol, static initializer blowup, ABI // mismatch). Catching this HERE is much better than "CI passes, user // runs bun, it segfaults". // // Linux+ASAN quirk: some systems need ASLR disabled (`setarch -R`) for // ASAN binaries to run from subprocesses (shadow memory layout conflict // with ELF_ET_DYN_BASE, see sanitizers/856). We try with setarch first, // fall back to direct invocation. emitSmokeTest(n, cfg, exe, exeName, strippedExe); return { strippedExe, dsym }; } /** * Smoke test: run the built executable with --revision. If it crashes or * errors, the build failed — typically means a link-time issue that the * linker didn't catch (missing symbol only referenced at init, ICU ABI * mismatch, etc.). * * `strippedExe` is the strip output (release builds only), added as an * order-only input so this rule never runs while strip is mid-write; see * emitPostLink for why. */ function emitSmokeTest(n: Ninja, cfg: Config, exe: string, exeName: string, strippedExe: string | undefined): void { // Skip when the binary can't run on this host (different os/arch/abi) — // `ninja check` becomes a no-op alias for the exe. if (!cfg.canRunOnHost) { n.phony("check", [exe]); return; } const stamp = resolve(cfg.buildDir, `${exeName}.smoke-test-passed`); // Linux+ASAN: wrap in `setarch -R` to disable ASLR. Fall back // to direct invocation if setarch fails (not all systems have it). // The `|| true` on the outer command isn't there — if BOTH fail, we // want the rule to error. const envWrap = "env BUN_DEBUG_QUIET_LOGS=1"; let testCmd: string; if (cfg.linux && cfg.asan) { const arch = cfg.x64 ? "x86_64" : "aarch64"; testCmd = `${envWrap} setarch ${arch} -R ${exe} --revision || ${envWrap} ${exe} --revision`; } else if (cfg.windows) { // Windows: no setarch, no env wrapper syntax differences matter for // this simple case. cmd /c handles the pipe. testCmd = `${exe} --revision`; } else { testCmd = `${envWrap} ${exe} --revision`; } // stream.ts --console: passthrough + ninja Windows buffering fix. // sh -c with parens: testCmd may contain `||` (ASAN setarch fallback); // without grouping, `a || b && touch` parses as `a || (b && touch)` — // stamp wouldn't get written when setarch succeeds. const q = (p: string) => quote(p, cfg.windows); const wrap = `${cfg.jsRuntime} ${q(streamPath)} check --console`; n.rule("smoke_test", { command: cfg.windows ? `${wrap} cmd /c "${testCmd} && type nul > $out"` : `${wrap} sh -c '( ${testCmd} ) && touch $out'`, description: `${exeName} --revision`, // pool = console: user wants to see the revision output. pool: "console", }); n.build({ outputs: [stamp], rule: "smoke_test", inputs: [exe], ...(strippedExe !== undefined ? { orderOnlyInputs: [strippedExe] } : {}), }); // Phony target — `ninja check` runs the smoke test. n.phony("check", [stamp]); } /** * Strip the linked executable → plain `bun`. Returns absolute path to * the stripped output. * * Input (bun-profile) is NOT modified — strip writes a new file via `-o`. * The profile binary keeps its symbols for profiling/debugging release crashes. */ function emitStrip(n: Ninja, cfg: Config, inputExe: string, stripflags: string[]): string { const out = resolve(cfg.buildDir, "bun" + cfg.exeSuffix); // Windows: strip equivalent is handled at link time (/OPT:REF etc), no // separate strip binary. The "stripped" bun is just a copy. Copy command // follows the HOST shell (cmd natively, cp when cross-compiling). if (cfg.windows) { // Copy as-is. /OPT:REF already applied at link. n.rule("strip", { command: cfg.host.os === "windows" ? `cmd /c "copy /Y $in $out"` : `cp $in $out`, description: "copy $out (windows: no strip)", }); } else { // Darwin cross: llvm-strip regenerates a bare linker-style ad-hoc // signature on its output, dropping the entitlements the link step // embedded — so the stripped `bun` needs its own postlink pass. // (machoPostlinkCommand is "" everywhere else.) n.rule("strip", { command: `${quote(cfg.strip, false)} $stripflags $in -o $out${machoPostlinkCommand(cfg)}`, description: "strip $out", }); } const node: BuildNode = { outputs: [out], rule: "strip", inputs: [inputExe], vars: cfg.windows ? {} : { stripflags: stripflags.join(" ") }, }; const postlinkInputs = machoPostlinkImplicitInputs(cfg); if (postlinkInputs.length > 0) node.implicitInputs = postlinkInputs; n.build(node); return out; } /** * Extract debug symbols from the linked (unstripped) executable into a * .dSYM bundle. darwin-only. * * Runs dsymutil on bun-profile (which has full DWARF). The .dSYM lets you * symbolicate crash logs from the stripped `bun` — lldb/Instruments find * it automatically by UUID. * * `strippedExe` is order-only for the same reason as emitSmokeTest: the * `cfg.jsRuntime` wrapper may be the strip output itself. */ function emitDsymutil(n: Ninja, cfg: Config, inputExe: string, exeName: string, strippedExe: string): string { assert(cfg.darwin, "dsymutil is darwin-only"); assert(cfg.dsymutil !== undefined, "dsymutil not found in toolchain"); const out = resolve(cfg.buildDir, `${exeName}.dSYM`); // --flat: single-file .dSYM (not a bundle directory). Simpler to upload // as a CI artifact. // --keep-function-for-static: keep symbols for static functions (more // complete backtraces). // --object-prefix-map: rewrite DWARF path prefixes so debuggers find // source in the repo root rather than the build machine's absolute path. // -j: parallelism. Use all cores (dsymutil parallelizes per compile unit). // CMake uses CMAKE_BUILD_PARALLEL_LEVEL; we use the host's core-count // command via a subshell (sysctl on a darwin host, nproc when // cross-compiling from linux). // stream.ts --console for pool:console consistency (no-op on darwin). const q = (p: string) => quote(p, false); // darwin/linux host → posix const ncpu = cfg.host.os === "linux" ? "nproc" : "sysctl -n hw.ncpu"; const wrap = `${cfg.jsRuntime} ${q(streamPath)} dsym --console`; n.rule("dsymutil", { command: `${wrap} sh -c '${cfg.dsymutil} $in --flat --keep-function-for-static --object-prefix-map .=${cfg.cwd} -o $out -j $$(${ncpu})'`, description: "dsymutil $out", // Not restat — dsymutil always writes. pool: "console", // Can take a while, show progress }); n.build({ outputs: [out], rule: "dsymutil", inputs: [inputExe], orderOnlyInputs: [strippedExe], }); return out; } // ─────────────────────────────────────────────────────────────────────────── // Windows resources (.rc → .res) // ─────────────────────────────────────────────────────────────────────────── /** * Template-substitute windows-app-info.rc and compile it with llvm-rc. * Returns the path to the .res output (to be linked like an object file). * * The .rc file provides: * - Icon (bun.ico) * - VS_VERSION_INFO resource (ProductName, FileVersion, CompanyName, ...) * - The application manifest (longPathAware + SegmentHeap) as an * RT_MANIFEST resource. Embedding it here instead of via the linker's * /MANIFEST:EMBED keeps the link independent of the linker's manifest * tooling: lld-link only handles /MANIFEST:EMBED itself when built with * libxml2 and otherwise shells out to mt.exe — rustc's bundled lld-link * (used for the cross-language-LTO links) has neither, and mt.exe does * not exist on non-Windows hosts. The resource route produces the same * RT_MANIFEST id-1 resource with any linker. * * This resource section is what rescle's ResourceUpdater modifies when * `bun build --compile --windows-title ...` runs. Without it, the copied * bun.exe has no VersionInfo to update and rescle silently does nothing. */ function emitWindowsResources(n: Ninja, cfg: Config): string { assert(cfg.windows, "emitWindowsResources is windows-only"); assert(cfg.rc !== undefined, "llvm-rc not found in toolchain"); // ─── Template substitution (configure time) ─── // The .rc uses @VAR@ cmake-style placeholders. Substitute and write to // buildDir (not codegenDir — link-only doesn't create codegenDir). // writeIfChanged → mtime preserved → no spurious rc rebuild when the // substituted content hasn't changed. const rcTemplate = resolve(cfg.cwd, "src/windows-app-info.rc"); const ico = resolve(cfg.cwd, "src/bun.ico"); const manifest = resolve(cfg.cwd, "src/bun.exe.manifest"); const rcIn = readFileSync(rcTemplate, "utf8"); const [major = "0", minor = "0", patch = "0"] = cfg.version.split("."); const versionWithTag = cfg.canary ? `${cfg.version}-canary.${cfg.canaryRevision}` : cfg.version; // slash(): rc parses .rc as C-like source; backslashes in the ICON path // string would need escaping. Forward slashes work for Windows file APIs. const rcOut = rcIn .replace(/@Bun_VERSION_MAJOR@/g, major) .replace(/@Bun_VERSION_MINOR@/g, minor) .replace(/@Bun_VERSION_PATCH@/g, patch) .replace(/@Bun_VERSION_WITH_TAG@/g, versionWithTag) .replace(/@BUN_ICO_PATH@/g, slash(ico)) .replace(/@BUN_MANIFEST_PATH@/g, slash(manifest)); const rcFile = resolve(cfg.buildDir, "windows-app-info.rc"); writeIfChanged(rcFile, rcOut); // ─── Compile .rc → .res (ninja time) ─── // llvm-rc: /FO sets output. `#include "windows.h"` in the .rc resolves // via the INCLUDE env var set by the VS dev shell (vs-shell.ps1) on a // Windows host; when cross-compiling there is no dev shell, so the SDK // and MSVC include dirs from the winsysroot are passed explicitly. const hostWin = cfg.host.os === "windows"; const rcFlags: string[] = []; if (cfg.winsysroot !== undefined) { const includeDirs = windowsSysrootIncludeDirs(cfg.winsysroot); // The include dirs are baked into the rc edge at configure time, so the // sysroot must already be populated (configure.ts fetches it in CI // before emitBun). An empty set would only surface later as a cryptic // llvm-rc "windows.h not found" — fail here with the real cause instead. assert( includeDirs.length > 0, `Windows sysroot at ${cfg.winsysroot} has no MSVC/SDK include dirs — is it a complete xwin splat?`, ); for (const dir of includeDirs) { rcFlags.push("/I", quote(dir, hostWin)); } } const resFile = resolve(cfg.buildDir, "windows-app-info.res"); n.rule("rc", { command: `${quote(cfg.rc, hostWin)} $rcflags /FO $out $in`, description: "rc $out", }); n.build({ outputs: [resFile], rule: "rc", inputs: [rcFile], // .ico and the manifest are embedded by rc at compile time — rebuild if // they change. The template is NOT tracked here: it's substituted at // configure time, so template edits need a reconfigure (happens rarely). implicitInputs: [ico, manifest], vars: { rcflags: rcFlags.join(" ") }, }); return resFile; } /** * Include dirs inside an xwin-style Windows sysroot, for tools that don't * understand `/winsysroot` themselves (llvm-rc). Layout: * /VC/Tools/MSVC//include * /Windows Kits/10/Include//{ucrt,shared,um} * The SDK "Include" dir is title-case in a real VS/SDK copy and lowercase * in an xwin winsysroot-style splat — accept either. */ function windowsSysrootIncludeDirs(winsysroot: string): string[] { const dirs: string[] = []; const msvcRoot = resolve(winsysroot, "VC", "Tools", "MSVC"); if (existsSync(msvcRoot)) { for (const ver of readdirSync(msvcRoot)) { const d = resolve(msvcRoot, ver, "include"); if (existsSync(d)) dirs.push(d); } } const sdkRoot = resolve(winsysroot, "Windows Kits", "10"); const sdkInclude = ["Include", "include"].map(name => resolve(sdkRoot, name)).find(existsSync); if (sdkInclude !== undefined) { for (const ver of readdirSync(sdkInclude)) { for (const sub of ["ucrt", "shared", "um"]) { const d = resolve(sdkInclude, ver, sub); if (existsSync(d)) dirs.push(d); } } } return dirs; } /** * Files the linker reads via ldflags that ninja should track for relinking * (symbol lists, linker script). CMake's LINK_DEPENDS equivalent. * (The Windows manifest is no longer a link input — it's embedded by the * resource compiler; see emitWindowsResources.) */ function linkImplicitInputs(cfg: Config): string[] { return linkDepends(cfg); } // ─────────────────────────────────────────────────────────────────────────── // Pre-flight checks // ─────────────────────────────────────────────────────────────────────────── /** * Validate config before emitting. Catches obvious problems at configure * time instead of cryptic build failures later. */ export function validateBunConfig(cfg: Config): void { // build.ninja encodes both absolute -I/-D paths derived from cfg.cwd and // buildDir-relative source paths (../../src/...). If buildDir is reached // through a symlink that escapes this checkout — e.g. a sibling worktree // symlinking its build/ at ours to "share" artifacts — a configure from // that worktree overwrites our build.ninja with its own absolute paths // while the relative ones still resolve against whichever cwd ninja is // launched from. The result is the same header included via two distinct // realpaths, defeating #pragma once and producing redefinition errors (or // PCH macro mismatches) the next time the rightful owner builds. Refuse // up front so the misconfigured worktree fails loudly instead of poisoning // a neighbour. An explicit --build-dir pointing outside the repo is still // permitted; only a symlink masquerading as a path under cwd is rejected. if (existsSync(cfg.buildDir)) { const realCwd = realpathSync(cfg.cwd); const realBuild = realpathSync(cfg.buildDir); const rel = relative(realCwd, realBuild); const escapes = rel.startsWith("..") || rel === ""; const claimedRel = relative(cfg.cwd, cfg.buildDir); const claimsInside = !claimedRel.startsWith("..") && claimedRel !== ""; assert( !(claimsInside && escapes), `buildDir '${cfg.buildDir}' resolves to '${realBuild}', outside the source tree '${realCwd}'.\n` + `A symlinked build/ shared between worktrees corrupts build.ninja for both. ` + `Remove the symlink and let this worktree own its build directory ` + `(ccache already shares object files across checkouts).`, ); } // Also reject the common shape directly: /build as a symlink. This // catches the race before the first configure ever creates buildDir. const buildParent = resolve(cfg.cwd, "build"); if (cfg.buildDir.startsWith(buildParent + sep) && existsSync(buildParent)) { assert( !lstatSync(buildParent).isSymbolicLink(), `'${buildParent}' is a symlink (→ ${realpathSync(buildParent)}). ` + `Sharing build/ between worktrees corrupts build.ninja for both — ` + `remove the symlink; ccache already shares compiled objects.`, ); } // Cross-language LTO needs an lld at least as new as the LLVM that emitted // the rust bitcode. `resolveConfig()` swaps `cfg.ld` to `cfg.rustLld` when // rustc's LLVM is newer than clang's; if `rustLld` couldn't be discovered // (rustc/rustup missing, pinned toolchain not installed, agent provisioned // without it), the build would proceed with the stale lld and fail at link // time with an opaque `error: ... .rcgu.o: Invalid record`. Fail at // configure time instead with a hint that points at the real problem. if ( cfg.crossLangLto && cfg.rustToolchain !== undefined && cfg.rustLlvmVersion !== undefined && cfg.clangVersion !== undefined ) { const rustMajor = Number.parseInt(cfg.rustLlvmVersion.split(".")[0] ?? "", 10); const clangMajor = Number.parseInt(cfg.clangVersion.split(".")[0] ?? "", 10); if (Number.isFinite(rustMajor) && Number.isFinite(clangMajor) && rustMajor > clangMajor) { // `cfg.ld` must be one of rustc's bundled lld flavors. On ELF targets // it's `cfg.rustLld` exactly; on darwin/windows cross targets it's the // ld64.lld / lld-link sibling from the same gcc-ld/ directory. assert( cfg.rustLld !== undefined && (cfg.ld === cfg.rustLld || dirname(cfg.ld) === dirname(cfg.rustLld)), `Cross-language LTO is on and rustc's LLVM (${cfg.rustLlvmVersion}) is newer than clang's ` + `(${cfg.clangVersion}), but rustc's bundled lld wasn't found — the link would fail with ` + `"Invalid record" reading libbun_rust.a's bitcode. Install the pinned toolchain on this ` + `host (\`rustup toolchain install ${cfg.rustToolchain}\`), upgrade clang/lld to LLVM ` + `${rustMajor}+, or disable LTO with \`--lto=off\`.`, ); } } // --local-deps names must match a dep — a typo would otherwise silently // build the pinned tarball while the banner claims `local:`. const depsByName = new Map(allDeps.map(d => [d.name, d])); for (const [name, path] of Object.entries(cfg.localDeps)) { const dep = depsByName.get(name); assert(dep !== undefined, `--local-deps: unknown dep '${name}'`, { hint: `Known deps: ${[...depsByName.keys()].sort().join(", ")}`, }); assert( !dep.enabled || dep.enabled(cfg), `--local-deps: ${name} is disabled for ${cfg.os}-${cfg.arch}${cfg.abi ? `-${cfg.abi}` : ""} in this configuration, so the checkout at ${path} would never be built`, { hint: `Drop ${name} from --local-deps, or build a target/config where its \`enabled\` predicate holds` }, ); // A dep the graph fetches but never reads (no build step, no sources, no // includes — lolhtml, which cargo consumes through the workspace // Cargo.toml's `path = "vendor/lolhtml"`) can't be redirected from here. const provides = dep.provides(cfg); assert( dep.build(cfg).kind !== "none" || (provides.sources ?? []).length > 0 || provides.includes.length > 0, `--local-deps: ${name} is only fetched by the build graph, never compiled or included by it, so redirecting it to ${path} would change nothing`, { hint: `Point ${name}'s real consumer at the checkout instead (for a cargo path dependency: the workspace Cargo.toml)`, }, ); } }