Skip to content

fix(bundle): Rust loader follow-ups: lexer offsets, dead items, paths through re-exports and macros, include!, review fixes, --cargo-target, --cargo-manifests - #175

Merged
ChALkeR merged 10 commits into
mainfrom
claude/rust-bundling-support-02j8b2
Oct 2, 2026
Merged

ChALkeR merged 10 commits into
mainfrom
claude/rust-bundling-support-02j8b2

Conversation

@exo-nikita

@exo-nikita exo-nikita commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-ups to the Rust loader from #173, all found by diffing bundles across that PR's commits, by reviews of the result, and by surveying 44 crates from crates.io (45 with mio, added for section 15) for constructs the loader did not model. The review rounds through section 17 are squashed into one commit on top of main, rebased onto #179 (the strict TOML reader, section 16), #180, #181, #182 (manifests carried as written, section 17), #183 (@preventive/lockfile's TOML parser, section 19) and main at #193 (section 21) and #201 (section 25). Section 18's round follows as four commits of its own; sections 20, 22, 23, 24 and 26 follow as one commit each.

1. Lexer offsets drift after an emoji in a string

The chunked lexer that landed with the simplification pass blanks a string or comment with a u-flag regex. That regex sees a two-unit surrogate pair (an emoji) as one character and emits one space for it, so the masked view comes out shorter than the source from that point on. The scanner finds items on the masked view and slices their text out of the code view at the same offsets, which only agree while the two views have equal length.

Real case, zerocopy 0.7.35 src/lib.rs: test data on line 7790 holds "❤️🧡💛💚💙💜" (five astral chars), and line 8110, inside #[cfg(kani)] mod proofs { … }, has

use crate::util::{core_layout::padding_needed_for, max, min};

With the views out of step by five units, the use body was sliced five units early, parsed as the single garbage path use, and the bundle lost these three edges:

"crate::util::core_layout::padding_needed_for": "vendor/zerocopy/src/third_party/rust/layout.rs",
"crate::util::max": "vendor/zerocopy/src/util.rs",
"crate::util::min": "vendor/zerocopy/src/util.rs",

Fix: lexRust blanks a range line by line with as many spaces as the line has UTF-16 units, so both views keep the source's length whatever characters a string or comment holds.

2. Dead items leaking through commas in their generics

The bounded dead-item skip from 2770be4 ended at the first top-level comma, meant for a field, variant or match arm. A comma inside a generic list or a rustfmt-style where clause is at top level too, so of a feature-gated

#[cfg(feature = "serde")]
fn deser_sampler<'de, D>(d: D) -> Result<UniformInt<u32>, D::Error>
where
    D: serde::Deserializer<'de>,
{ … }

only fn deser_sampler<'de was skipped and the body was scanned as live code (rand); same for impl<'de, A: Array> Deserialize<'de> for SmallVec<A> (smallvec) and pub struct Builder<'a, T> (tokio). That recorded use serde edges nothing live justifies and, because the walk follows crate references, pulled whole dependency trees into the bundle: smallvec's dead serde impl dragged in serde, serde_derive, syn, quote and proc-macro2. Two smaller leaks sat beside it: the attributes of a dead item (#[derive(serde::Serialize)]) and a #[cfg_attr(<pred>, …)] whose predicate can't hold were never blanked from the path scan.

Fix:

  • An item that starts with a keyword (fn, impl, struct, enum, trait, type, const, static, mod, use, extern, macro_rules, unsafe, async, let) runs to its ; or { … } body and never ends at a comma. Weak keywords a field may be named after (default, union) are left out.
  • A field, variant or arm still ends at a top-level comma, but a generic argument list nests (a < right after a name or :: up to its >; the > of -> / => excepted), so map: HashMap<K, V>, is one field.
  • A dead item is dead from its first attribute, and a cfg_attr whose predicate can't hold applies nothing at all (no path, no macro_export, no include_str!), so the paths in either's text are dead too.

3. Paths resolved through imports, re-exports and exported macros

A path edge was resolved to the deepest module prefix that names a file, with no notion of items, pub use or macros. Through a crate's __private re-export module or its #[macro_export] macros that prefix is just crate, so every $crate::… path in bitflags' macro_rules! bodies pointed at lib.rs:

"crate::__impl_external_bitflags_arbitrary": "vendor/bitflags/src/lib.rs",   // defined in this very file
"crate::__private::serde::Deserialize": "vendor/bitflags/src/lib.rs",         // serde, via two re-exports
"crate::serde::deserialize": "vendor/bitflags/src/lib.rs",                    // external/serde.rs, via `pub use external::*`

Change: the scanner also records each use import with its visibility, each macro_rules! definition with #[macro_export], and the inline mod x { … } blocks; the tree registers inline modules; a path resolves module by module, and where a segment names no module it may be an import of the module reached, a #[macro_export] macro of the crate, or else an item of the module reached, whose file is the target. Sections 6, 11, 12, 13 and 15 have the rules as they stand after the reviews.

bitflags' external.rs now keeps mod serde, crate::serde::serialize and crate::serde::deserialize on external/serde.rs and, with serde vendored, use serde; the macro self edges are gone.

4. Review follow-ups

Loader:

  • A dead #[cfg]-gated item is skipped whatever token starts it, not only a word: a { … } block statement, a match arm whose pattern is a literal, &x, (a, b) or [a, ..], a tuple-struct field type, a *x statement. A "json" => serde_json::to_string(..) arm with the json feature off no longer bundles serde_json, and a cfg_if! branch whose cfg can't hold (if #[cfg(feature = "wasm_js")] { mod wasm_js; }, hashbrown's feature = "nightly" lsx branch, ahash's feature = "std" hash_map/hash_set) is dead code now.
  • A file mounted both through #[path] and a plain mod keeps the plain mount's <stem>/ dir as a fallback for its own mod declarations (R5).
  • The scan cache compares the file's content too (R10).
  • The exported resolveModDecl drops cfg_attr path variants whose predicate can't hold when features / test / target are passed; without them it trusts the scanner's build, as the loader's own calls do.

Cargo:

  • R2: a bare --cargo-features name applies to every root package that has it, not only the first.
  • R3: the Cargo.lock that governs is the workspace root's (the nearest manifest at or above the bundle root with [workspace]), else the bundle root's own; a lock further up belongs to another project and no longer makes cargo metadata run --locked.
  • R4: a target-specific dependency table is a request of its own beside the plain one (kinds keys such as normal@cfg(unix)), so it neither turns the plain request optional nor strips its defaults.
  • R6: [patch.crates-io.foo] sub-tables and bar.path = "…" dotted keys are read like the inline-table form.
  • R7: a workspace-inherited edition decides the feature resolver version.
  • R8: a weak dep?/x asked for with --cargo-features was applied once, before the fixed-point loop, when default had not yet activated dep, so it did nothing and said nothing (the manifest form always worked, being re-applied every pass). The requested features now re-apply on every pass too; the unknown-name warning still fires once.
  • R9: a wildcard requirement with an operator keeps the operator (>=1.* is >=1, >1.* is >=2.0.0); only a bare 1.* is a tilde range.

5. Files rustc reads that the bundle left out, and edges it didn't record

  • include! / include_str! / include_bytes! with a literal path. include!("generated/consts.rs") splices Rust source the bundle never carried; the asset macros name files it never carried either, most often a crate's #[doc = include_str!("../README.md")]. The scanner records all three, in items and inside attributes (a live item's #[doc = include_str!(…)] too, a dead item's not), and the walk follows them relative to the including file: Rust source as a file of its own, assets as resource / resource:base64, never scanned. Edges: include <path>, include_str <path>, include_bytes <path>. A literal naming no file warns; build output under concat!(env!("OUT_DIR"), …) can't be followed.
  • Bare macro invocations. A macro_rules! invoked by name and defined in another file of the crate (libc's s! from macros.rs, tokio's ready!, syn's Token!) is a dependency of the invoking file: edge <name>!. A path-qualified invocation ($crate::name!, serde_json::json!) is a path, not a bare invocation.
  • Whole-file #[cfg(…)]. A file whose inner cfg can never hold is compiled empty (log's serde.rs behind feature = "serde_core", tokio's io/uring files behind feature = "fs"): carried, scanned as empty, so nothing in it is followed or reported. The same for an inline module opening with such an attribute.
  • Vendor directory. What .cargo/config.toml names under [source.vendored-sources] directory, else vendor; the cargo ecosystem tag and the cargo vendor hint follow it.

6. The path resolver, after a second review

The re-export follower of section 3 resolved every path by recursion over the imports of each module it passed, capped at depth 8 and guarded per resolution. The review found two regressions against ordinary code and a list of smaller ones.

A glob into the sysroot or another crate claimed every name. use std::io::prelude::*, use rayon::prelude::*, a fn-body use core::arch::x86_64::*, or a use super::* when the parent has such a glob: any name looked up in that module was taken for the glob's, which hid the file's crate edges and unresolved reports. Rule now: a glob provides a name only when the module it names is one of this crate and that module has the name, as a child module or through a use of its own (so a glob does follow a named re-export of a plain item to the file holding it: crate::Item through pub use a::* and a's pub use crate::q::Item lands on q.rs). A glob the loader can't see into claims nothing.

Time grew with the square of the file count. A synthetic 800-module crate glob-re-exported from its root took 33s; libc alone 12s. The resolver now walks the module tree and asks, per (module, name, how much of the module's imports the asking module may see), what the module's imports make of the name, computed once per crate and shared by every path (provided). Glob targets are collected once per module as a transitive closure, settled closures standing in for further walking; import cycles read as nothing while in progress, and only entries computed on top of such a read are discarded, so a later query gets a full answer. libc: 27ms; tokio: 61ms; the synthetic crate: 0.6s.

The rest of the list:

  • An inline mod imp { } under one cfg no longer shadows a mod imp; file under another in the module tree: crate::imp::f points at imp.rs.
  • cfg-variant files (#[cfg_attr(unix, path = …)] beside #[cfg_attr(windows, path = …)]) no longer get an empty-keyed edge to their sibling; a path anchored on a file's own use follows that file's import, not the sibling variant's, and a via edge names the file whose import was followed.
  • A #[macro_export] macro is named only by a path's final segment at the crate root: use log::debug is the crate log, and reported when it isn't vendored, even when the crate defines a log! macro. An invoked path ($crate::span!) names the macro before a module of that name (tracing), and at the root the macro wins over a glob (pub use facade::* beside $crate::helper!).
  • #[macro_export(local_inner_macros)] and a macro_export under cfg_attr count (serde_core).
  • macro_rules! helper { … } pub(crate) use helper; resolves to the macro, not to a crate of that name: no more false use cfg_if edge to vendor/cfg-if, no helper reported unresolved.
  • pub(self), pub(super) and pub(in …) re-exports reach only their scope; a plain use only the module and its descendants.
  • extern crate serde_core as s; is followed like use ::serde_core as s (s::Value records serde_core), and extern crate self as me; like use crate as me.
  • A via edge is never recorded for the crate root, however the path reaches it (super::ToTokens from quote's ext.rs no longer adds super → lib.rs); every file of the crate hangs off the root anyway.
  • When several use items bind one name (cfg variants; serde's trait and derive-macro re-exports of Serialize), the first the bundle can follow wins; one into a crate that isn't in-tree binds the name to its own file as far as the bundle knows, so serde's $crate::__private::Result records lib.rs, the file whose lib module re-exports core::result, with crate::__private → private/mod.rs as the module it went through.
  • The unused resolveUsePath is gone.

7. --cargo-target=<triple|host>: target cfgs

Target cfgs were always undecided, so a bundle carried every platform's code: libc's 376 files, tokio's windows and every getrandom backend, and reported crates only other targets depend on (windows_sys, unsupported_target) as unresolved.

stasis bundle --cargo-target=<triple|host> asks rustc --print cfg --target <triple> (host: the running rustc's triple, from rustc -vV) for the target's cfg set and decides target leaves against it: unix, windows, target_os, target_family, target_arch, target_env, target_vendor, target_abi, target_pointer_width, target_endian, target_has_atomic. target_feature and target_thread_local stay undecided (a build may add features; both vary with the toolchain and its flags), as do profile cfgs (debug_assertions, panic) and custom ones. A cfg_attr(…, path) variant whose predicate holds is the #[path] itself and supersedes the default file. Target-specific dependency tables count only for the target (build-dependency tables always: build scripts compile for the host), and with --cargo the triple goes to cargo metadata --filter-platform. rustc is $RUSTC when set, as cargo honours it — a target only another toolchain knows, such as Solana's sbf-solana-solana, needs that toolchain's rustc (RUSTC=~/.cache/solana/<release>/platform-tools/rust/bin/rustc) — else rustc from PATH; sections 12 and 13 have where it runs from. Only rustc's built-in knowledge of the target is needed, not its standard library. Without the flag nothing changes.

evalCfg moves to cargo.js (the manifest replay needs it too); a Cargo context takes target as a triple/host or a ready { triple, cfgs }, which the tests use to stay rustc-free (the rustc-dependent tests skip without one).

8. --cargo-manifests: manifests, lockfile, cargo config and build scripts

The Rust counterpart of --package-json: the bundle also carries each bundled package's Cargo.toml and the workspace Cargo.toml above it, that workspace's (or, outside any, the package's own) Cargo.lock and cargo config — .cargo/config before .cargo/config.toml, the order cargo reads them; each as written, see section 17 — when they lie inside the bundle root, all as resource files in their package's bucket (the workspace bucket for the root-level ones), and each package's build script ([package] build = "…", else build.rs; build = false means none), walked like a crate root of its own and compiled for the host, so its modules and the in-tree [build-dependencies] it reaches come along as Rust code, and their build scripts in turn, until no package is new. Nothing runs.

9. #[path] on an inline module (solana-program)

solana-program's message/mod.rs declares

#[cfg(not(target_os = "solana"))]
#[path = ""]
mod non_bpf_modules { mod account_keys; mod address_loader; mod sanitized; mod versions; … }

An inline module's own #[path] names the directory its mod declarations resolve under, in the module name's stead; an empty one puts them beside the file. The loader used the module name, looked for message/non_bpf_modules/account_keys.rs, found nothing and — the module being cfg-gated — silently left the four files out of every bundle; with --cargo-target=host the cfg is decidably on and the missing modules were fatal. The scanner now records the directories an inline-module chain stands for and mod resolution uses them; solana-program bundles with and without a target, the four files included.

10. use words that aren't imports; every macro-emitted mod variant

tokio's bundle reported let, true, input, cursor, precise_capture_begin, allow_crate_root_in_path, path and static_macro as unresolved crates.

  • The first seven: syn's Token[use] and serde_derive's quote! { use #path as _serde; }. Any use word was taken for an item and its "tree" read up to the next ;. A use now counts only when a use tree follows it (a name, ::, {, or $crate in a macro_rules! template); one interpolating a metavariable (use $m::X;, #path; a raw identifier's r# is not one) is a template and skipped. A quote! / quote_spanned! / parse_quote! body is generated code for another crate and is skipped whole, so serde_derive no longer carries serde's 22 files (pulled in by quote! { extern crate serde as _serde; }) nor 480 #private::… edges, and a $trait::fmt(…) in a template is a metavariable's path, not a module's.
  • static_macro: tokio's cfg_has_atomic_u64! { #[path = "native.rs"] mod imp; } beside cfg_not_has_atomic_u64! { #[path = "as_mutex.rs"] mod imp; }. Both declarations were keyed * and the second file was dropped from the module tree, so its own paths went unanchored. A declaration a macro emits is keyed by the macro (cfg_has_atomic_u64!), a cfg_if! branch's declarations by the branch's cfg (if #[cfg(unix)] { mod imp; } → unix; a #[cfg] on a block or inline module now gates everything declared inside), and a second file under an already used key gets it numbered (*#2) rather than lost.

The remaining reports on tokio's default-feature bundle (io_uring, backtrace, bytes' extra_platforms) are accurate: the files are bundled because tokio's cfg_io_uring!-style wrapper macros hide the #[cfg(feature = …)] that would exclude them, and they name optional dependencies that aren't vendored. Seeing through such cfg-wrapper macros would be the next improvement.

11. A third review: vendored crates as untrusted input, and the resolver's blind spots

Security. A cargo vendored crate is input the project author didn't write, and --cargo-manifests made three ways for it to carry the project's own files into a bundle: include_str!("../../../.git/config"), include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../.env")), #[path = "../../../secret.rs"] mod x; and [package] build = "../../.env". A vendored package's includes, #[path] targets and build script must now lie inside its own package directory; anything else is refused with a warning (a mod so refused is a missing module, so fatal unless cfg-gated). The project's own code may still include anything under the bundle root. The cargo config carried with --cargo-manifests keeps only the tables that describe a build ([source.*], [build], [target.*], [patch.*], [profile.*], [alias]); [http], [net], [registries], [env], [credential-alias] and the rest may hold tokens and proxies and are dropped. --cargo-target got a 60-second limit and RUSTUP_AUTO_INSTALL=0, so an uninstalled toolchain is an error rather than a download (where it runs from changed again in sections 12 and 13). An include target is checked to be a regular file before it is read (a FIFO hung the bundle).

Resolver regressions found in section 6's rewrite. A glob skipped the items a module defines (libc's crate::sigset_t fell through to a later platform's re-export; mio's sys/windows lost its edges): the tree pass now indexes every module-level struct/enum/union/trait/type/fn/const/static (an extern block's fns too, not an impl's associated items or a fn's local ones) per module, with its visibility, and a glob brings them in, the module's own tree file first when a module is spread over cfg variants; a private mod is not glob-importable. A one-segment macro name hijacked use log (the crate) and an exported macro beat a named re-export for an ordinary call crate::helper(): an invocation names the macro first, any other path the import or module first. Names a sysroot or other-crate glob may provide (use syn::*; … punctuated::Punctuated) are no longer reported as unresolved crates. A bare macro call after a single : (a:helper!()) keeps its edge. Of the partly-fixed items: inline mod imp { mod inner; } beside a same-name mod imp; file now joins one module tree (the tree is built in two rounds per file); self::… paths and glob-provided names in a second platform file resolve through that file's own imports first; extern crate … as s at the crate root is in the extern prelude, visible from every file; the else branch of a dead if #[cfg(…)] is live again. a::self::b stays declined (rustc rejects it).

Correctness. Several #[path]/#[cfg_attr(…, path)] on one mod: the first whose predicate holds decides and ends the list, and the default lookup is off (it used to be "any true path wins"). Build scripts compile for the host: no target cfg is decided in them and every [target.'cfg(…)'.build-dependencies] table counts. A false #[cfg] inside a macro invocation body (m! { #[cfg(never)] … }) no longer empties the file. A file named by both mod/include! and include_str! is Rust, not a resource, whichever is met first. An include inside a macro_rules! body resolves relative to each invoking file, as rustc expands it (verified against rustc). Include arguments: escapes in plain strings, raw strings and concat!(env!("CARGO_MANIFEST_DIR"), "/…") are read; a non-literal argument other than OUT_DIR is warned about instead of silently dropped or misread. .cargo/config is read before .cargo/config.toml, as cargo does; a workspace nested below the bundle root keeps its own Cargo.lock and config instead of the root's. target_feature and target_thread_local are undecided.

12. A fourth review: a crash, the toolchain choice, symlinks, and cfg-aware resolution

Blockers. use log; or use x::{self, …} in a module spread over platform files (#[cfg_attr(unix, path = …)] mod sys;) overflowed the stack: the same-file lookup asked its own module for the name, which is that very import, outside the memo; it goes through it now. --cargo-target ran rustc from the bundle root (after a round of running it from a temp dir), and a rustup proxy picks its toolchain from the rust-toolchain(.toml) files of the working directory and its parents — a file that may name a path to any binary, so the project being bundled (or anyone able to write to /tmp) chose what ran. It now runs from the user's home directory (else the filesystem root), never from the project nor a temp dir; $RUSTC still wins, and RUSTUP_TOOLCHAIN is the way to pin a project's toolchain deliberately. A vendored crate could still read outside its package through a symlink (a linked file or directory of modules, a linked manifest, a linked include target: the check compared paths as text), through an inline module's #[path = "../../.."] (left unnormalized, so a file was carried under a name like vendor/evil/src/../../../x.rs), and through a [lib] path outside the package: every file of a vendored crate must now really lie in its package (real paths, manifests included), the inline path is normalized and held to the package like any other, and an outside [lib] path is refused. Credentials in the URLs of carried config tables (registry = "sparse+https://user:token@…", [patch] git = "https://token@…") are stripped. The fourth blocker, a failing test in rust-loader.test.js, did not reproduce on Node 24; section 13 has what it was.

Resolver. The written-order glob rule of section 11 resolved a unix file's crate::c_int through libc's fuchsia branch (1,837 edges from unix modules into fuchsia, 478 into windows), and a module spread over platform files mixed its variants (mio's sys::Waker from an epoll file went to the poll selector's; tokio's imp::StaticAtomicU64 to the other variant). Each file now carries the cfg leaves of every mod on the way down to it (plus a variant leaf when a mod has several files), each import and defined item its own cfg's on top, and cfg_if! branches after the first are keyed by the earlier cfgs not holding (all(not(unix), windows)); a path skips candidates whose leaves can't hold with its own file's (a leaf and its negation, two values of a single-valued key such as target_os, two variants of one mod, windows against unix), and a file under no such cfg gets the first in written order. So libc's unix files stay in unix, mio's epoll file gets the epoll branch's Waker, tokio's variant files get their own variant's items. The candidates for a (module, name) are listed once, lazily (a closure holds hundreds of modules and the first few usually answer), and shared by every asker; an import among them is followed when first picked. Glob closures computed short of an import cycle are recomputed once every entry is in place and the paths resolved again, so the answer no longer depends on which side of a cycle was asked first — the one shape found where it did (a: pub use crate::b::*; pub use crate::c::* / b: pub use crate::a::inner::* / c: pub mod inner { pub struct X; }, asking a::X first) is a test now; two randomized checks of glob/re-export webs against a brute-force reference (48,000 asks, inline modules and cycles included) found no other, at either commit. A glob into an in-tree crate explains only the names that crate's root has (tracing's use tracing_core::*; no longer hides tracing_attributes), a glob onto an enum or into the sysroot none. crate::helper() and crate::helper!() get separate edges (crate::helper!). A one-segment path names an exported macro when no crate has the name (anyhow's pub use anyhow as format_err;), and ::s::… uses the root's extern crate … as s alias. Bare name! follows rustc's textual scope (checked against rustc 1.94): a macro defined after the mod mounting a file is not in that file's scope, a nested #[macro_use] mod reaches its own parent module only, exported macros are in bare scope in the root file and wherever they are used or glob-imported (use super::*; from the root, use crate::combinator::dispatch;), and nowhere else. A mod whose every cfg names one file is a flat edge again (libc's mod primitives). The items a module defines leave out macro_rules! templates' items, lazy_static's static ref, and the bodies of extern "C" fns, and a use $crate::… in a template is the invoking module's. quote!-family bodies are skipped however the macro is named (quote::quote! too), unless the file defines a macro_rules! of that name itself. The scanner's impl/trait/extern block lookup is a stack, not a scan over every block.

Corrections to section 11. A mod inside an include!d file resolves beside the included file, not the includer (include!("gen/list.rs") with mod bar; → gen/bar.rs, its submodules under gen/bar/), as rustc does — checked against rustc 1.94, both directions; it is still the includer's child module. A dead let … = if … else … and a dead match arm now take their else branches with them, and under --cargo-target the cfg_if! branches after one whose cfg holds are dead. #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))], the common form, is read.

Still open at this point (section 13 takes these up). The synthetic all-to-all glob crate remained quadratic: 200/400/800/1600 modules took 142ms/347ms/0.96s/3.5s; 800 modules with 5 globs 269ms, 1600 479ms, linear. Real crates cost about 3x what they did at 1dfe173 (libc 55ms → 192ms, tokio 108ms → 334ms; the whole 44-crate corpus 0.5s → 1.4s). a::self::b stays declined.

13. A fifth review: the same import in two platform files, namespaces, any(…) cfgs, source order

Blockers. Two platform files of one module importing the same name (use log; in u.rs and w.rs; use libc; in both branches of a cfg_if!) still recursed without end: section 12's guard was per candidate, so following one file's import asked the module for the name and found the other file's. The import being followed is now marked on the import itself, and its answers are kept per asker's cfg set. A use super::*; pub use sibling::*; written in a platform file recursed too: resolving the file's own glob sources asked for the file's own candidate list while it was being built; it reads as nothing until built. A fn log() {} beside use log::info, or a *const libc::c_char in a signature, hid the crate: the scanner records every defined item's namespace (fn/const/static are values; a *const T is a pointer type, not a const item) and a segment with more path after it is looked up in the type namespace only, so no value item ever leads a path. Results depended on the order the sources were listed in (the first variant file scanned won crate::sys::Thing): each module's files are now indexed in tree order, the variants of a mod as declared, whatever order the caller lists the files in (a test runs every rotation of the sources). The failing test of section 12 was a stale expectation: scanRustItems had given the mod imp inside #[cfg(unix)] mod sys { … } its unix cfg since section 10, the test still expected null, and Node 24's legacy assert.deepEqual (unlike Node 22's) passes 'unix' against null inside an array — so CI on Node 24 was green while a Node 22 container failed it. The expectation is 'unix' now.

Wrong edges. Every item of the review's list, checked on the corpus:

  • A file mounted by several declarations (libc's mod linux_l4re_shared in both the linux and the l4re branch) got no cfg at all; it is under any of them now — an any(…) of their predicates.
  • An any(…) predicate whose alternatives each decide something is one leaf, and so is a negated all(…): the leaf is exclusive with a set none of its alternatives can hold with (libc's any(target_os = "linux", target_os = "l4re", target_os = "android", target_os = "emscripten") branch against a file under target_os = "aix"), and holds when one alternative does. Before, an any(…) contributed nothing and its whole subtree was compatible with every file.
  • A module reached by several glob paths (libc's mod primitives, glob re-exported under a dozen cfgs) kept the first path's leaves; it is under either path's now.
  • Only the first hop of an import chain was checked against the asking file's cfgs; every hop is now (the asker goes along when an import is followed), and among the compatible candidates the first whose cfgs the asker's own entail (a candidate under the same target_os leaf its file was mounted under) comes before one merely not ruled out; a file under no cfg keeps written order.
  • Cross-platform edges in libc — a file under one src/unix/<os> subtree pointing into another — go from 668 to 12 (the twelve are arch/<cpu> files, which every libc environment shares, pointing into uclibc).
  • A nested cfg_if! corrupted the chain around it (its else-negations leaked into the outer branches): chains are kept per block depth.
  • Macro shadowing ignored source order (a #[macro_use] mod's macros always beat the file's own): the later definition of a name now shadows the earlier, whichever kind each is, so serde's tri!, defined by the root's crate_root!() after #[macro_use] mod crate_root;, is the root's (private/de.rs and private/ser.rs's tri! edges move from core/crate_root.rs to lib.rs).
  • A mod or macro_rules! a macro_rules! template declares took its scope from the definition; it stands where the macro is first invoked in the file now (serde's crate_root! { … macro_rules! tri … pub mod de; … }), with the template order deciding between items of one invocation.
  • A one-segment use x; in a child module matched a root-exported macro of that name and hid the crate; only a path written at the crate root names it (anyhow's pub use anyhow as format_err; in lib.rs still does).
  • A glob into an in-tree crate was checked only against that crate's root: use tokio::sync::*; use mpsc::… reported mpsc. The glob's path is walked inside the other crate and the module it names there is asked — what it has, what its pub globs bring in, what a glob of its own into a third crate may.
  • The quote!-family skip missed a macro_rules! quote defined in another file of the package (syn's parse_quote!): a package defining such a macro in any of its files has the bodies of that name scanned as macro input throughout, by rescanning only the files that skipped one.

Performance. With the checks above, the corpus first ran 2.2x slower than the previous commit. Leaf lists are now prepared once for the exclusivity check (keys, negations, single-valued values, Windows/unix standing), closures are indexed by module so a name's candidates come from the modules having it rather than from every module of the closure, unions of leaf sets are cached per pair, and the template rescan touches only the files that skipped a body. The 44-crate corpus runs at parity with 0accd58 (1.34s against 1.36s; libc 149ms against 185ms, tokio 315ms against 321ms); the synthetic 800-file crate with 4 globs takes 355ms and scales linearly (200/400/800: 78/152/355ms); 800 files each glob re-exported from the root take 0.99s, the same as before. Both randomized reference checks (51,600 asks) still find no mismatch.

Low priority. Carried Cargo.toml and Cargo.lock lose the credentials in their URLs — a user:token@ userinfo and any query parameter named like a secret (?access_token=…), the rest of the query (?branch=main) and the fragment kept; the cargo config filter does the same and drops a [target.*] table's runner (a command for cargo run to wrap the binary in, not a build's description). rustc runs from the filesystem root when the home directory is the bundle root or lies inside it (a project bundled from $HOME, a CI job whose HOME is its checkout), so the project's toolchain file never chooses.

Still open. Without a target, which cfg_if! branch a file under no cfg means stays a written-order guess (hashbrown's imp is the sse2 branch, mio's Waker the pipe one); --cargo-target decides it. a::self::b stays declined.

14. After the rebase onto #177: one URL credential scrubber

The two URL credential scrubbers that landed side by side — cargo's for carried Cargo manifests and config (section 13), #177's for carried Solidity manifests — are one (stripUrlCredentials: a URL's userinfo and secret-named query parameters go, the rest of the query and the fragment stay; section 15 has the SSH rule), used by both. The move of the shared TOML reader into loaders/toml.js that was here is #179 now, on its own.

15. A sixth review: gates the loader can't see into, cached answers across a cycle's reset, imported values, custom cfgs

Regressions. Section 13's "prefer the candidate whose cfgs hold" rule took a candidate under no leaves for certain, and a mod declared inside a cfg_<x>! macro body carried none, so mio's unix wakers resolved crate::sys::Selector to the windows selector (no target) or the shell one (a Linux target). Two causes: the scanner dropped a #[cfg(…)] placed on a macro invocation (#[cfg(windows)] cfg_os_poll! { mod windows; … }), which now gates everything the body declares; and the body of a cfg_<x>! macro (tokio's cfg_io_uring!, mio's cfg_os_poll!) is now a gate leaf of its own, cfg_not_<x>! its negation, so what the two declare is never taken together and neither counts as certainly compiled. mio (1.0.4, added to the corpus) now resolves the eventfd waker's Selector to epoll.rs, the kqueue waker's to kqueue.rs, the shell waker's to shell/selector.rs, and windows/selector.rs's crate::sys::Events to windows/event.rs. The answers imports had given on top of a glob closure short of an import cycle survived the cycle's reset (a pub use child::run; at the root asked before the cycle through child was closed): they are dropped with the glob sources, and the A1 shape through a named root re-export is a test.

Still open from the last round, now fixed.

  • A name imported only as a value (use crate::util::log; of a fn log) stood for the crate in log::info!, and the crate edge was lost. The path now resolves to nothing in the type namespace, the lead names the crate, and — since the walk from disk had also taken log for the fn and never loaded the crate — buildRustTree reports such crate roots as wantedRoots, which buildRustBundle loads and builds again with, until none is new.
  • A module a second glob path reached under another cfg widened its own leaves but not those of the modules its globs reach: the walk goes on from a widened entry, so everything behind it is under either path too. (The review's count of wrong-platform edges inside libc's unix tree is a different metric from the one in section 13; by that one nothing changes this round.)
  • A mod a macro_rules! template declares took its textual scope from the declaring file even when that file never invokes the macro: it now stands at the invocation in the first file (tree order) that does, so serde's core/de/value.rs gets its forward_to_deserialize_any! edge (crate_root.rs declares pub mod de; inside crate_root!, invoked in lib.rs after #[macro_use] mod macros;).
  • A bare macro call's scope is decided at the call, not the file's end: a macro_rules! written later in the file doesn't shadow what an earlier #[macro_use] mod handed an earlier call (the scanner records each name's call offsets).
  • The walk from disk skipped a package-defined quote!'s bodies as templates when the defining file came later: the walk keeps each package's template definitions and rescans the files that skipped one, so a mod such a body declares is loaded.
  • Unresolved-lead suppression (use serde::* may provide anything) walked the whole glob closure per (module, lead); the globs leading out of a crate are indexed once, gathered once per module, and the per-lead check is a lookup.

Ranking under a target and custom cfgs (the second commit). With --cargo-target, every file compiled for the target now asks under the target's cfgs too (unix, target_os = "linux", …; not a build script's, compiled for the host): a candidate the target rules out is out for every file, and one whose any(…) the target satisfies is certain. And a positive cfg neither rustc nor cargo sets (loom, docsrs, tokio_unstable, mio_unsupported_force_poll_poll) is a --cfg a default build lacks: a candidate under one is doubtful, taken only after a compatible candidate under none, and one under its negation (not(loom)) counts as certain; an any(…) is doubtful when each alternative is, or can't hold with the asking file; a gate leaf and a mod variant's are neither. Items a module defines in several files rank the same way (own tree file first within a rank). So mio's sys::Waker under a Linux target is the eventfd waker (written order had put the poll selector's first; without a target it still does, as section 13 says), getrandom's backends::fill_inner is the getrandom backend rather than the getrandom_backend = "custom" one, and libc's crate::kinfo_proc from freebsdlike/mod.rs is dragonfly's rather than the freebsd15-gated copy.

Low priority. The HOME-in-bundle-root check compares real paths (a linked home or root); a quoted "runner" key goes from a carried [target.*] table too; an SSH URL (git+ssh://git@github.com/…, a lockfile's usual form) keeps its user — the account the key logs into — and loses only a password after it.

Performance. The corpus runs within 7% of 494e9f0 (1.58–1.62s against 1.48–1.50s), libc 250ms; both randomized reference checks find no mismatch (51,600 asks); the synthetic 800-file crate with 5 globs and two unresolved leads per module 440–480ms against 370–430ms. The all-to-all glob crate stays quadratic — 400/800/1600 files: 0.46/1.0/6.6s against 0.38/1.0/4.4s — because every module's glob closure holds every module: N closures of N entries, inherent to per-module closures (sharing them across a glob cycle would need a different representation; real crates' closures are small, and the corpus is at parity).

Still open. use serde::* (a glob into a crate that isn't in-tree) still hides a missing crate the same file names, by design: the loader can't see what such a glob brings in. Without a target, a cfg_if! branch or mod variant under an any(…) of platforms stays a written-order guess. a::self::b stays declined.

doc/file-formats.md describes every rule above.

16. After the rebase onto #179: the strict TOML reader

#179 replaced the line-based TOML reading with a strict TOML 1.0 reader (tomlEntries, entries by table path). This PR's Cargo readers now sit on it:

  • parseCargoManifest reads this PR's additions by table path: [package] build, edition = { workspace = true } / edition.workspace = true, [workspace.package] edition, target-specific dependency tables as their own request (normal@cfg(unix), now without the quotes the header spelled), and every [patch] spelling. The dotted-segment splitter tableParts is gone. Dependency kinds are a Map, so a table named constructor is not a kind.
  • vendorDirOf finds [source.vendored-sources] directory in any spelling, the inline one included.
  • Every reader passes its file name, so errors name the file and line: Cargo.toml:3: duplicate key "package.name", .cargo/config.toml:2: invalid value "third_party".

No try/catch wraps a TOML read. A file that is missing is still fine. A Cargo.toml, Cargo.lock, cargo config or foundry.toml that exists but isn't TOML stops the build. That includes a dependency's foundry.toml: the Solidity loader used to skip a dependency config it couldn't load, with a warning. It now stops the build, for a TOML error and for the refusals too (an extends outside the dependency, nested inheritance, a key collision). forge itself skips a nested config it can't load, so a dependency forge tolerates can now stop a bundle. The doc says so. Since the rebase onto #178 (section 25), main does this itself for a dependency config that isn't TOML, and the Solidity half of this change is dropped. A config forge refuses for its settings is skipped with a warning, as on main.

17. After the rebase onto #182: every carried file as written

#182 dropped the cleanup of what --manifests carries: a carried file is the one the build used, byte for byte, never an edited copy. --cargo-manifests follows it. The Cargo.toml files, the Cargo.lock and the cargo config are carried as written, or refused:

  • The cargo config filter of sections 11 and 13 is gone. The config's [http], [net], [registries] and [env] tables are carried too, and so is a target's runner.
  • The URL scrubber stripUrlCredentials of sections 12 to 15 is gone. The credentials in a git = … URL or a lockfile's source stay.
  • A file that isn't UTF-8 text is refused (Rust manifest is not valid UTF-8: Cargo.lock) rather than decoded with replacement characters.
  • toml.js is main's again: the per-entry source text added for the filter is gone.

What decides which files may be carried is unchanged: nothing outside the bundle root, and a vendored crate's files only from its own package. The doc and the usage text say the carried files may hold credentials. They advise keeping registry tokens in ~/.cargo/credentials.toml or CARGO_REGISTRIES_<NAME>_TOKEN, or not passing --cargo-manifests.

18. A seventh review: host code, build files, cfgs a build sets, gate definitions, variant maps, vendored versions, template mods

This round is four commits on top of section 17's:

  • 0b9422c: the review's findings.
  • eb1fbaf: deciding a cfg in which an unknown leaf repeats.
  • 2d56881: a module's own items ranked with its imports.
  • 03d2421: caches for the tree pass.

The numbers follow the review's.

Silently incorrect, now fixed.

  1. Non-UTF-8 files. A Rust source (a module, a build script, an include!d file) or an include_str! file that isn't UTF-8 now stops the build and names the file; rustc refuses such a file too. It used to be carried with U+FFFD in place of its bytes. An include_bytes! file may hold any bytes, and is carried as resource:base64 when it isn't UTF-8.

  2. Host-built code. Each file records the compile units it is built as, written <features>|<platform>:

    • a package's own code: target|target;
    • its build script: target|host;
    • a build-dependency's or proc-macro crate's code, and everything they depend on (the tree pass's wantedRoots included): host|host.

    Host code is judged against the host's cfgs, which the loader knows only with --cargo-target=host; with another target, no platform cfg is decided in it. A crate built both ways (a dependency that is also a build-dependency) is scanned under both units, and an item in it is dead only when it is dead in each.

  3. Build files under --cargo-manifests. A vendored crate carries its Cargo.toml and .cargo-checksum.json. The Cargo.lock and .cargo/config it was published with are no longer carried (the corpus's 30 extra files). A project package carries the cargo config of every directory from its own up to the bundle root, so a nested workspace also gets the root .cargo/config.toml.

  4. cfgs a build sets. A custom cfg is no longer presumed off when the package's build script prints it (cargo:rustc-cfg=name, or cargo::) or a rustflags --cfg sets it, so its not(…) branch no longer wins by default. The rustflags are read from:

    • the cargo config's build and target.<…> rustflags;
    • RUSTFLAGS, CARGO_ENCODED_RUSTFLAGS and CARGO_BUILD_RUSTFLAGS.

    A build script that formats a cfg name or uses autocfg makes every custom cfg of its package undecided.

  5. cfg_x! / cfg_not_x!. A gate's cfg is read from its package's macro_rules! definition ($( #[cfg(feature = "rt")] $item )*, the same in every arm), not inferred from its name. A gate whose definition can't be read is opaque: never certain, and never exclusive with another gate. That covers a gate with no definition in the package, arms that differ or write no cfg, and two definitions that differ.

  6. Macro scope.

    • A template's bare calls are made where the macro is invoked, through the macros that invoke it up to the outermost call site, rather than where the template is written.
    • A recursive arm's call is not an invocation, so the macro keeps its scope.
    • A template's mod is only ever placed in its own package (see 16).
  7. use serde::*. A glob into a missing crate no longer hides a crate the package declares. The lead is that dependency (rustc rejects the ambiguity, E0659), and it is reported.

    Separately, eb1fbaf decides a cfg in which an unknown leaf occurs more than once, when every value of that leaf gives the same result. zerocopy's #[cfg(any(test, kani))] mod tests { #[cfg(not(kani))] mod compatibility { use rand::…; } } reduces to all(any(test, kani), not(kani)), which never holds, so its use rand is dead code rather than a missing crate.

  8. Wrong variant files.

    • When the asking file's cfgs entail none of the candidates, the edge is now a cfg-keyed map of every compatible candidate under no custom cfg: the bundle format's {platform: file} shape, or a single file when they all agree. A candidate under a cfg the build rules out (a feature that is off) comes last.
    • A child module under such a cfg, or under a custom one, gives way to an import of its name. Example: serde's docsrs-only mod de yields to the pub use serde_core::de of every other build.
    • 2d56881: a module's own items now rank with its named imports, ahead of glob imports, and an import leading out of the bundle counts as an answer under its cfgs.
      • tokio's imp::AtomicU64 (the review's imp::StaticAtomicU64, in the vendored version) maps both variant files: std's AtomicU64, re-exported in atomic_u64_native.rs under target_has_atomic = "64", and the mutex-based one atomic_u64_as_mutex.rs defines under the negation.
      • crate::trace::trace_leaf is the fn defined under cfg_not_taskdump!, not the import under cfg_taskdump!, which is off.
    • mio's sys::Waker without a target maps each platform's waker.
    • libc's unix tree has no single-file edge to another platform's files left (12 at the section-17 head), and 124 of its edges are maps of the platforms that may apply.
  9. Vendored version guessing. A dependency resolves to the version its Cargo.lock records, else to the one vendored version its requirement allows. The loader warns and doesn't follow the dependency when:

    • the locked version isn't vendored;
    • no vendored version fits (No vendored version of rand satisfies zerocopy 0.8.59's requirement 0.8.7 (vendored: 0.10.3));
    • several fit and no lock chooses.

    Cargo would build none of those copies. A package's own name for a crate is also its manifest's word: a name the manifest doesn't declare is no crate of the package. winnow's pub use bytes::Bytes; is its own mod bytes, not the vendored bytes crate.

  10. Out-of-root [patch] / path dependencies are not in-tree, and no longer bind to a vendored crate of the same name instead.

  11. Features.

    • Resolver 2 now resolves what is built for the host (build-dependencies, proc-macro crates and their dependencies) apart from what is built for the target.
    • Resolver 2 also decides target-specific tables. A table counts when --cargo-target says it applies (a build-dependency's table is judged against the host), and not when it says it doesn't.
    • Without a target, what only such a table enables is on maybe. Its code is kept, but a module missing behind it isn't fatal, and a candidate under it is never taken as certain.
    • Resolver 1 unifies everything.
    • The resolver is now taken from the workspace (its resolver, else its edition). It used to come from the bundle root's manifest, which a root holding only vendored crates lacks. So a 2021-edition entry's dev-dependencies no longer count: serde_json's serde with derive is a dev-dependency only.
  12. Macro assets. An include in a macro that another macro's body calls resolves against the outermost call site, and two same-named macros keep both bodies' assets.

  13. Vendored source directory. It is the directory the replace-with chain from crates-io leads to, whatever its name. When the configured directory doesn't exist, the loader warns instead of using a stale vendor/ beside it.

Loud.

  1. The false "crate not found" (gm1). A mod declared in a macro_rules! body is now declared in each module of its package that invokes the macro, and its file is found beside that module's file, as rustc expands it. It carries the cfgs under which the invoking file mounts the definition's file.
    • macro_rules! decl { () => { pub mod gm1; } } invoked in a.rs declares crate::a::gm1 (src/a/gm1.rs), so the root's use a::*; use gm1::X; resolves.
    • A template that nothing in its package invokes by bare name (only crate::m!()) keeps its mods beside the definition, and the walk from disk loads those last.
    • The same fix restores a file the bundle was missing. libc's prelude!(), defined in macros.rs and invoked in lib.rs, declares mod types;, which rustc reads as src/types.rs. The loader used to look for src/macros/types.rs and silently leave the file out.
    • extern crate self as x; now puts the crate itself in the extern prelude, so x::… resolves from every module (syn's own use syn::parse::ParseStream).

Performance (03d2421). The tree pass now caches three things: a leaf's verdict per build, an alternative's cfg text per asker set, and a file's package.

  • Per crate, against the section-17 head (274fe99; 589afce before the rebase onto fix(bundle): read TOML with @preventive/lockfile's parser #183): libc 282 ms (was 227 ms), tokio 464 ms (444 ms), mio 287 ms (240 ms). The extra time goes to the added candidates, definitions and alternatives. Before the caches, libc took 351 ms.
  • Corpus total: about 2.0 s, the same as the section-17 head.
  • Scanner: about 5% slower over the corpus's 2,390 files. evalCfg's repeated-leaf pass costs about 2 ms of that, over the corpus's 10,686 #[cfg]s.

Still open.

  • A mod declared in a template takes the inline modules of the definition, not of the invocation. In every case seen, both sit at a file's top level.
  • A path through a module with cfg variants goes on through the module's first variant file. Only the candidates for the path's final segment are recorded as a map.

doc/file-formats.md describes every rule above.

19. After the rebase onto #183: @preventive/lockfile's TOML parser

#183 replaced #179's reader with @preventive/lockfile's strict TOML parser, which returns a tree of tables. This PR's Cargo readers were ported to it in the commits that introduced them:

  • parseCargoManifest reads the tree: [package] build, an inherited version or edition, [workspace.package] edition, [lib] proc-macro, and target-specific dependency tables as requests of their own (normal@cfg(unix)), however a table is spelled.
  • vendorDirOf reads each [source.<name>] table's replace-with and directory.
  • rustflagsCfgsOf reads the rustflags of [build] and of every [target.<…>].
  • Error messages are the parser's, with the file named: .cargo/config.toml: expected a value, found "third_party" at line 2. The tests follow them.

20. An eighth review: a refused crate root, unreported missing crates, Cargo per dependency table, four resolver regressions

One commit on top of section 19's: a401644.

Blocking, now fixed.

  1. The walk/tree loop never ended. The tree pass can name a crate root that the walk then refuses, for example a vendored crate whose src/lib.rs links out of the bundle root. That root was asked for on every pass, so the loop never ended. Now a root is asked for once; the walk has already said why it refused it.
  2. Missing crates went unreported when a vendor/ dir existed. Every crate the bundle lacks is now reported, with or without a vendor dir: [stasis] 3 crates referenced but not in the bundle: far, linked (vendor/linked/src/lib.rs), missing. A refused root is listed with its path. The cargo vendor hint still appears only when there is no vendor dir. The loader now also warns about each path dependency outside the bundle root, each path that names no [package], and each [patch] outside the bundle root; before, these were dropped silently.
  3. Four resolver regressions:
    • A glob's module beat a local one. Since section 18, a child module under a custom cfg gave way to anything its module had of the name, globs included. Now a module the build rules out still gives way to anything, but one under a custom cfg (serde's docsrs-only mod de) gives way only to its module's own use or item of that name. What a glob brings in never shadows a module that is there.
    • A local fn hid a macro imported through a glob. Since section 18, a module's own items rank with its named imports, so fn m answered the lookup for m!() beside use crate::macros::*. A macro call is now looked up among macros only: an item the module defines, a module (a child mod m too), or an import of a module or crate is no m!. The resolver's typesOnly flag became a namespace (type or macro), carried through imports.
    • A nested macro_rules! took over its template's calls. The calls and includes after an inner macro_rules! inner { … } were recorded on the inner macro, so invoking the outer one lost its include_str! asset and helper! edge. They now stay with the top-level template.
    • cfg values differing only in spaces were merged. Since section 18, the repeated-leaf evaluation keyed a leaf with all its whitespace stripped, so all(my = "a b", not(my = "ab")) came out false and dropped its module. normalizeCfg also collapsed spaces inside quotes. Now both keep a value exactly as written; only spacing outside the quotes is ignored.

Older silent Cargo bugs, now fixed.

  1. One version per dependency name. A dependency's version, path, package and workspace now belong to its table, not to its name. So rand = "0.7" under [dependencies] beside rand = "0.8" under [build-dependencies], or a fake = { package = "rand", version = "0.8" } dev-dependency, are separate crates, as cargo has it. A file follows the table its target uses: the build script uses the build-dependency, a test target the normal or dev one, anything else the normal one.
  2. A lock pin used without checking the requirement. A Cargo.lock version is now taken only if the requirement allows it. A lock that pins only versions the requirement doesn't allow gets the warning Cargo.lock pins … which … doesn't allow: the lock is out of date, and the loader goes by the requirement instead.
  3. [patch].
    • A patch is now used only where its version fits the dependent's requirement, as cargo applies it. One that doesn't fit gets a warning and isn't used.
    • [patch] in .cargo/config.toml is now read: from the package's directory up to the bundle root, nearest first and ahead of the manifest's. Its path is relative to the directory that holds .cargo.

21. After the rebases onto #192 and #193

Main's #174 and #185 to #193 needed no change here beyond one merge in bundle.js: #189's host parameter now sits beside this PR's cargoTarget and cargoManifests in classifyEntries. #192 and #193 bump @preventive/lockfile to 1.0.0-alpha.3 and then alpha.4. alpha.3 refactored the TOML parser with the same rules and messages; alpha.4 changes nothing stasis imports.

22. @preventive/lockfile's Cargo reader, and cargo's resolver where it can run

One commit on top of section 21's: 6a55380.

Reading. @preventive/lockfile (alpha.3 from #192, alpha.4 since #193) now reads Cargo.toml, Cargo.lock and the cargo config's [patch], and its rust-semver.js matches version requirements. What it refuses stops the build, naming the file:

  • a key cargo doesn't know;
  • a feature that names nothing;
  • a dep?/x on a dependency that no table makes optional;
  • [replace];
  • a lockfile that could be read two ways;
  • a Cargo.lock older than version 3.

The loader's own manifest and lock readers, its semver matcher, its implicit-feature builder and its config [patch] reader are gone.

Cargo's resolver. Cargo's resolver runs when all of these hold:

  • there is a Cargo.lock;
  • --cargo-target is given;
  • every locked package is in-tree: path packages inside the bundle root, the rest vendored with their .cargo-checksum.json;
  • the entries are in workspace members.

Features then come from linkCargo and resolveCargoFeatures: the lockfile's graph laid over the manifests, and what cargo build -p <the entries' packages> turns on. The build stops on a lockfile out of date with the manifests, a vendored copy whose checksum isn't the lockfile's, or a feature asked of a package that lacks it. When the target isn't the host, the host is unknown, so the resolver runs twice: once with the host's target-specific tables off and once with them on, and what only they turn on is on maybe.

The replay runs otherwise (no lockfile, no target, or a locked package not vendored). It has these fixes, from the comparison with PreventiveMeasures/libraries#56:

  • Sources: a git dependency takes a git checkout's vendored copy and a registry dependency takes a registry's, told apart by .cargo-checksum.json (a git copy has no package checksum) and by the lockfile's sources. Before, itoa = "1" could land on a vendored git copy, and a git dependency without a version on any copy.
  • Same-named feature: serde/derive now also turns on the package's feature named serde, whether written or implicit. Before, only an implicit one was turned on.
  • Proc-macro entries: a proc-macro entry is resolved for the host, and its files are scanned as host code.
  • Prereleases: they follow the semver crate's rules, so 1 no longer takes 1.1.0-beta.1.
  • Nested workspaces: the lockfile read is the entries' workspace's, including one below the bundle root. Before, only <bundle root>/Cargo.lock was read.

Also new: cfg(true), cfg(false) and raw identifiers (r#unix) in cfgs.

23. A simplification pass

One commit on top of section 22's: a558b7d. What is bundled doesn't change, except where noted below.

Speed. scanRustItems now evaluates each cfg predicate once per scan. Before, it joined and evaluated the conjunction of the open #[cfg] scopes again at every token inside them. A 220 KB file inside one #[cfg] block scans in 36 ms instead of 588 ms, and in 28 ms instead of 1.3 s when it is compiled as two units.

One walk. buildRustBundle and loadRust share one driver, collectRustBundle. It walks the build scripts, tracks the compile units, and loads the crate roots the tree pass asks for until none is new. That loop used to live in bundle.js, so loadRust skipped it and scanned host-built files again as target code. bundle.js also called its own copies of three helpers that are now shared: the check that a vendored file doesn't link out of its package, boundaryOf and addUnits.

cargo.js:

  • Each path dependency's directory is resolved once, against the workspace root its inheritance was read from. Three call sites used to compute it, in two different ways.
  • Each dependency table carries its kind and target, so nothing parses kind@target apart again.
  • Resolver 1's single feature context is applied in one place, the node key.
  • The [patch] source list, whether a vendored copy came from git or a registry, and each package's build script and build files are worked out once.
  • A manifest's TOML is parsed once for the loader's own reading.
  • One helper, shared with toml.js, names the file in a TOML or lockfile error.

rust.js:

  • A cfg leaf is evaluated from its key and value, instead of being turned back into text for evalCfg.
  • The macro-template bookkeeping that the walk and the tree pass each kept is shared. The set of template macros a package doesn't define is now looked up by its contents. The walk used to look it up by the identity of a set that grows later, which could keep a stale set.
  • The four per-asker verdict caches share one helper.
  • resolveModDecl's cfg filter is gone. No caller used it, and the scanner already drops those variants.

24. A tenth review: platform tables, build-script crates, proc-macro roots, out-of-root workspaces

One commit on top of section 23's: 2dd0aa4. Each item was reproduced first with a fixture, and checked against what cargo and rustc 1.94 do.

Crate resolution:

  • Platform-specific dependency tables. A dependency name now resolves from the tables of the platforms the code is compiled for. Before, the first table that resolved won. [target.'cfg(windows)'.dependencies] foo = "2" above [target.'cfg(unix)'.dependencies] foo = "1" gave 2.0.0 on Linux; it now gives 1.0.0, on both paths and for build-dependency tables too. Without a target, both tables may apply, so both versions are carried and the edge is a cfg-keyed map of them.
  • A build script's own crates. A file's role comes from the crate roots it is compiled in, not from its name. A build script's modules, and a file it shares with the lib through #[path], now resolve from the [build-dependencies]; the shared file gets both versions.
  • No spurious version warnings. The lookup by lib name only resolves dependencies that can have that lib name. Before, u8::MAX resolved every declared dependency, dev-only and other platforms' included, and warned about their versions.
  • Missing crates named only in expressions. A declared dependency the bundle lacks is reported when only an expression names it (serde_json::to_string(…), serde_json::json!).
  • Macros of other crates now get an edge:
    • use dep::mac; mac!(), including an alias and a prelude's pub use dep::mac behind a glob, wherever in dep the macro is defined;
    • dep::mac!();
    • a crate root's #[macro_use] extern crate dep.

Cargo:

  • Proc-macro roots. The replay seeds a proc-macro entry in the target context as well as the host's, as cargo activates a member it is asked to build. This fixes a regression from 6a55380: with entries app and pm, shared/src/noth.rs was carried although cargo never compiles it.
  • A workspace above the bundle root. A member bundled from its own directory now reads its workspace root above the bundle root for what it inherits and for the resolver, when that root's members take it. The root is never bundled, and its lockfile is not read. Before, edition.workspace = true was refused and the resolver came from the member's own edition.
  • [patch] sources. A [patch."https://github.com/…"] no longer applies to the crates.io dependency of the same name on the replay, in either the manifest or the config form. This bug was already on a401644.
  • Nested workspaces. The vendor directory comes from the cargo config nearest the entries, so a nested workspace uses its own vendor/.
  • Which resolver ran. stasis bundle now says when the features come from the replay and why, for example [stasis] Rust features from a replay of the manifests, not cargo's resolver: no --cargo-target. A vendored copy the lockfile doesn't list no longer forces the replay for lack of a checksum file. EXODUS_STASIS_DEBUG labels the mode and prints both contexts.
  • Vendored file checksums. A vendored file whose bytes aren't the ones its .cargo-checksum.json lists stops the bundle, as cargo refuses to build it.

cfgs and resolution:

  • r#true and r#false are now custom cfgs in every reader, not the literals.
  • A child module under a custom cfg gives way to a glob that provides its name, as any doubtful candidate does. In a default build rustc takes the glob's: #[cfg(loom)] mod imp; beside use crate::other::*; now resolves imp::g to src/other/imp.rs.
  • Nested templates. An include in a macro_rules! defined inside another's body belongs to that inner macro, resolved where it is invoked. Before, when the two were invoked in different directories, the asset was missing from the bundle.
  • Refused crate roots. A refused or missing crate root is warned about once.
  • Dense glob cycles. Cycles of cfg-gated globs no longer blow up: leaf keys are cached, an alternative another one absorbs is dropped (a ∨ (a ∧ b) is a), and leaf sets are capped at 16 alternatives. A ring of modules each globbing both neighbours took 2.35 s at N=8 and 16.7 s at N=9; it now takes 0.02 to 0.04 s. A complete glob graph at N=5, which took over 150 s, now takes 0.04 s.

Not changed: the review items that are by design. A manifest key cargo only warns about, [replace], and lockfiles older than version 3 are still refused. An out-of-date lock still stops cargo's resolver. --cargo-manifests still carries .cargo-checksum.json without the files it lists that the bundle doesn't reach, and still doesn't carry rust-toolchain.toml.

25. After the rebases onto #198, #178 and #201

Main's #191 and #194 to #198 needed two merges, both in the first commit:

Main's #172, #197, #199 and #178 needed no change to the Rust loaders. #178 makes a Solidity dependency config that isn't TOML an error itself, so this PR's own change to foundry.js (section 16) and its Solidity test are dropped: foundry.js, its tests and doc/file-formats.md's Solidity text are main's. The bundle-cmd comment now names the .gitmodules reader beside the TOML and Cargo ones.

Main's #201 gave classifyEntries a fetched parameter, so main is green again; it sits beside this PR's cargoTarget and cargoManifests. That is the one merge, in the first commit. The Rust loaders, their tests and the fixture archive are byte-identical to the pre-rebase head.

26. The Rust loader's new fixture projects in one file

One commit on top of section 24's: 60ea71e. The two fixture projects this PR adds, includes and cargo-recorded, were 30 files (44 KB). They are now one brotli-compressed JSON file, tests/fixtures/rust-bundle.json.br (10 KB). It maps project name to project-relative path to the file's text, or to { base64 } for a file that isn't UTF-8.

  • rustFixture(name) in tests/rust-fixtures.helper.js writes a project out once per test process and gives its path. It writes to a temporary directory that is removed when the process exits.
  • node tests/rust-fixtures.helper.js unpack <dir> and pack <dir> edit them. An unpack gives back the original files byte for byte, checked with diff -r.
  • The fixtures main already holds under tests/fixtures/rust-bundle/ stay as they are.

Verification

  • New tests for every item above, including a bitflags-shaped tree, a serde-shaped one, one for the module an import was followed from, a typenum/libc-shaped one, dead items of every starting token, a dual-mounted file, a reused sources map, an includes fixture (include!, the two asset macros, a README via #[doc = …], a #[cfg(test)]-dead include, a bare macro invocation, a #[cfg]-gated module, a build script with a module of its own, a Cargo.lock), one test per item of section 6 (glob rule, several globs and the first followable import, inline-vs-file module and cfg variants, macros by final segment/invocation/at the root, macro uses, restricted visibility, extern crate … as, no via edge to the root), target-cfg evaluation and scanning with an injected cfg set, target-specific dependency tables, a target bundle, real-rustc cfg queries, a stand-in $RUSTC for a target the local rustc doesn't know (which now also records where it ran from and that auto-install was off), the solana-shaped inline module, use words in macro input and templates, cfg-gated blocks and macro-keyed mod variants, throwaway Cargo projects for the multi-root, target-table, inherited-edition, both weak-feature, configured-vendor-dir and manifests/build-script cases, for section 11 a vendored crate reaching for .git/config, .env and a #[path] outside its package (refused, warned, fatal when ungated), a nested workspace's lock and config, a build script under --cargo-target keeping its Windows module and build-dependency, a file both modded and include_str!ed, import-cycle probes, glob-provided defined items, private mods and associated items behind a glob, the fn-vs-macro and use log cases, the extern prelude, self:: in a cfg variant, mod in an include!d file, #[cfg] in a macro body, every include form and its warning, a macro-body include per invoker, for section 12 the use log; crash, the libc/mio/tokio platform shapes, the anyhow macro, the defined-items exclusions, quote! by any name, the flattened variant map, the cycle shape above, dead let/arm else branches and cfg_if! after a holding cfg, textual macro scope (definition order, nested #[macro_use]), the #[doc] manifest include, symlinked files/directories/includes and an outside [lib] path in a vendored crate, and the include!d module's directory, for section 13 the same import in two platform files and in both cfg_if! branches, a glob in a platform file beside its parent's, fn log / *const libc::c_char beside the crates (and crate::net::log() still reaching the fn), every rotation of the sources giving one answer, a file mounted under two cfgs and an any(…) branch against another target_os, a module reached by two glob paths, every hop of a chain, nested cfg_if! chains, macro shadowing in both orders, a template's mod and macro_rules! at the invocation, the root-only one-segment macro, use tokio::sync::*; use mpsc::… (and a name the crate lacks still reported), a package-defined quote! in another file (and a template in another package still skipped), and the $RUSTC stub run with HOME at and inside the bundle root, and for section 15 a mio-shaped tree (a #[cfg] on a cfg_os_poll! invocation, cfg_not_os_poll! against it, the unix wakers' Selector, and the same shape with and without a Linux target for sys::Waker), the A1 cycle through a named root re-export, a fn/static imported by name beside the crate of that name (in the tree and end to end, the crate loaded on the tree's request), a module reached again under another cfg with a module behind it, a template's mod invoked from another file, a call before the file's own later definition (with the recorded call offsets), a package-defined quote! met a wave after the file that invokes it, loom against not(loom) and an any(loom, aix) kept without a target, and HOME through links either way. The tests of the config filter and the URL scrubber went with them in section 17.

    Section 18 adds tests for:

    • non-UTF-8 sources and assets refused, and an include_bytes! file carried as base64;
    • host and target units for build scripts, build-dependencies and proc-macro crates, with and without --cargo-target=host;
    • per-context feature resolution, and maybe features from target tables;
    • the files --cargo-manifests carries for vendored and nested packages, and a renamed vendored source directory;
    • cargo:rustc-cfg and rustflags cfgs;
    • gate cfgs read from their definitions, and unreadable ones;
    • a template's bare calls, a recursive arm, and a template invoked from another crate;
    • a declared dependency behind use serde::*;
    • cfg-keyed maps for the libc, mio and tokio shapes, candidates the build rules out taken last, a module's own items against a std re-export, and a docsrs-only child module giving way to an import;
    • vendored version selection and its warnings, and out-of-root path and [patch] dependencies;
    • gm1: a template's mod placed in the module invoking the macro (on disk and in the tree), a template invoked only by path, and extern crate self from child modules;
    • the repeated-leaf cfg decisions.

    Section 20 adds tests for:

    • a refused crate root ending the loop, and the report listing it with or without a vendor dir;
    • one crate name in three tables at three versions, a lock pin the requirement rejects, a [patch] that fits or doesn't (from the manifest and from the cargo config), and path dependencies outside the root or naming no package;
    • a local module under docsrs against a glob, and against a named use under not(docsrs);
    • m!() beside a fn m, a child mod m, and a crate-root mod m, each with the macro brought in by a glob;
    • a template that defines a macro_rules! inside before its include_str! and helper! calls;
    • "a b" against "ab" and "a b" against "a b", in evalCfg and in the tree.

    On the section-19 head, each of the Cargo and resolver tests fails, and the loop test hangs.

    Section 22 adds tests for:

    • the 12 builds cargo recorded for @preventive/lockfile's workspace fixture, checked through createCargoContext, including cargo's refusal of one. The fixture is copied, MIT, as the cargo-recorded project of tests/fixtures/rust-bundle.json.br;
    • in that graph, each table's source, the [patch] and the proc-macro built for the host;
    • a checksum mismatch and a stale lockfile stopping the build, and a missing vendored package falling back to the replay;
    • an unknown host, resolved both ways;
    • each replay fix, a proc-macro entry's bundle, the cfg literals, and a v2 lockfile refused.

    On the section-21 head, each of the resolver and replay tests fails.

  • Differential runs against the previous head over the corpus of current crates from crates.io (serde, syn, tokio, libc, mio, regex, hashbrown, winnow, toml_edit, zerocopy, bincode, ahash and others, ~2400 source files):

    • Fix 1 alone: identical output; without it the run showed the mangled use in zerocopy and a spurious unresolved crate se in regex and regex-syntax.
    • Fix 2: only removals. 14 crates' output differs, every sampled removal is code gated on a feature that is off, and nine vendored crates reached only through such code are no longer bundled (170 of 196 files in the worst case, serde_with as root).
    • Fix 3: reached files unchanged. Edges to a bare crate root go, thousands move to the defining file (crate::BinOp::Sub → syn's op.rs), relative paths through a module's own imports gain edges. False unresolved reports disappear; real missing crates such as thiserror_impl stay reported.
    • Fix 4: only removals. 78 edges from dead blocks and arms and two files whose cfg_if! branch needs a feature that is off (getrandom/src/backends/wasm_js.rs, hashbrown/src/control/group/lsx.rs); false unresolved reports disappear.
    • Fix 5: 3506 <name>! edges and 15 include edges appear; six files join the bundles (four READMEs, borsh's crate-level doc, winnow's example parser embedded by include_str!); 14 edges from two #[cfg]-gated files go, with log's false serde_core report.
    • Section 6: reached files unchanged. 1913 edges appear (a use quote::quote; no longer hides the crate quote from its own file; use crate::io; io::Error lands on io/mod.rs; via edges for tokio's crate::loom), 581 move to the file holding the import that binds the name (libc's crate::c_int → primitives.rs, tokio's crate::loom::sync::Mutex → loom/std/mod.rs), 88 go: via edges to crate roots, duplicate name! edges for path-qualified invocations, and wrong edges from tokio's windows.rs to unix/mod.rs. Two false unresolved reports (die; serde_with's foreach_map/foreach_seq/foreach_set, all macro_rules! + pub(crate) use) disappear. Timing: libc 12.3s → 27ms, tokio 96ms → 61ms; synthetic 800 modules all glob-re-exported from the root 33s → 0.6s, 1600 modules 2.1s; 800 modules with 5 globs 143ms, linear.
    • Section 7: without --cargo-target identical; with host on Linux, 4448 files → 2725 (libc 376 → 48, tokio 838 → 508, getrandom 410 → 70), and the platform-only unresolved reports (windows_sys, unsupported_target, target_arch_not_implemented, __errno) go.
    • Section 8: without --cargo-manifests identical; with it, tokio gains its packages' manifests and the build scripts of libc, serde, proc-macro2 and quote, no warnings.
    • Section 9: identical on the corpus (nothing there uses an inline-module #[path]); solana-program 1.18.18 as a vendored dependency bundles with 118 files (was 111, the four non_bpf_modules files and their submodules missing) and with --cargo-target=host 113, where it failed before.
    • Section 10: 16 crates differ. Gone: the false unresolved reports above plus _ and serde_core from quote templates, serde's 22 files from serde_derive's bundle, 480 #private::… edges, and memchr's and zerocopy's metavariable paths ($memchrty::new, $trait::fmt). New: tokio's atomic_u64_as_mutex.rs and its static_macro variants in the module tree with their edges, memchr's crate::arch::x86_64::avx2::memchr module paths, syn's mac::parse_delimiter-style paths that a swallowed use body had hidden.
    • Section 11: reached files unchanged; 27 crates' edges differ. 4580 edges move to the file that defines the item (libc's crate::c_int-style paths from lib.rs to the platform file whose s! { … } defines it, first in root-glob order; typenum's crate::IsEqual and friends from lib.rs to the defining file), 817 go (self edges: a libc or typenum file's path to an item it defines itself; name! edges to a same-name macro_rules! in a sibling file — borsh, indexmap, memchr, proc-macro2 and serde each define their own copies, which the invoking file's textual scope never held), 13 appear (a:helper!()-style calls, extern-prelude aliases).
    • Section 12: reached files unchanged; 23 crates' edges differ. 4507 edges move (libc: a platform file's crate::c_int-style paths from the first platform in written order to its own platform's file), 612 go (libc: a platform file's path to an item its own platform defines, a self edge now; elsewhere the old spelling of an invoked path, crate::__private::stringify → crate::__private::stringify!, and seven forward_to_deserialize_any! edges in serde's docsrs-only inlined copy of serde_core, whose textual scope rustc's rules don't reach), 97 appear (the ! spellings). libc's false __errno report goes.
    • Section 13: reached files, missing modules and unresolved crates unchanged; 12 crates' edges differ, all libc's (in every bundle that carries it) plus serde's two tri! edges. Of libc's 4649 changed edges, every sampled one moves from another platform's file to the asking file's own platform (aix/mod.rs's crate::passwd from bsd/mod.rs to aix's own, a self edge now dropped; gnu/b32/arm's crate::off_t from musl/mod.rs to gnu/b32/mod.rs; apple's crate::sockaddr from linux_like/mod.rs to bsd/mod.rs) or to the layer above it (crate::uint64_t from lib.rs to primitives.rs); cross-platform edges 668 → 12.
    • Section 15: reached files, missing modules and unresolved crates unchanged; 45 edges differ: mio's 36 (the unix wakers' and shell waker's Selector, windows/selector.rs's Events and event flags to windows/event.rs, sys::Waker from waker.rs off the windows waker), serde's forward_to_deserialize_any! edge in core/de/value.rs appearing, getrandom's backends::fill_inner to the default backend, libc's crate::kinfo_proc from freebsdlike/mod.rs to dragonfly's; with a Linux target, mio's sys::Waker is the eventfd waker and every unix waker's Selector epoll's.
    • Section 16: identical to the pre-rebase head on all 45 corpus crates (reached files, edges, missing modules, unresolved crates, resolved features). All 120 Cargo.toml / Cargo.lock / cargo config files of the corpus and the test fixtures parse under the strict reader. New tests cover the section-16 manifest fields, the inline vendored-sources form, a broken config or manifest stopping the context, and a dependency's foundry.toml stopping the Solidity bundle, both not TOML and with an outside extends.
    • Section 17: identical to the previous head on all 45 corpus crates. A --cargo-manifests bundle carries a manifest and lockfile with tokens in their URLs and a config with [registries] and a runner byte for byte, and refuses a lockfile that isn't UTF-8.
    • Section 18, against the section-17 head (589afce before the rebase onto fix(bundle): read TOML with @preventive/lockfile's parser #183):
      • Reached files change in 12 of the 45 bundles.
        • Dropped, because no vendored version satisfies the dependency (warned, not followed): ahash −447 (getrandom, rand, rand_core, libc), tokio −58 (mio 1.0.4 against 1.2.0), zerocopy −31 (rand), and num_enum_derive's syn.
        • Dropped, because a 2021-edition entry's dev-dependencies no longer count: serde_json −96 (serde_derive, syn, proc-macro2, quote).
        • Dropped, because a crate was named without being declared: winnow −149 through its own bytes module; toml_edit −136 and proc-macro-crate −137 through winnow.
        • Added: libc's src/types.rs in the five bundles that carry libc. Also serde's core/std_error.rs in serde_json's bundle, where serde is only a dev-dependency, so its features are unknown and its gated code is kept.
      • Edges, deduplicated across bundles:
        • 264 become cfg-keyed maps (libc 232, tokio 22, mio 6, getrandom 3);
        • 147 move, among them serde's docsrs-only core/ files now reaching core/de, and tokio's trace_leaf and IoDriverMetrics reaching the definitions that are live;
        • 87 appear, among them template bare calls such as syn's check_keyword_matches!, and lib.rs's mod de/mod ser placed by the template;
        • 2,911 go, nearly all with the files of crates no longer bundled.
      • Unresolved reports: chacha20, r_efi, extra_platforms, serde_core and the platform-only names go with the dropped crates. Three appear: mio (tokio's unsatisfied requirement), syn (num_enum_derive's), and rustc_std_workspace_core (libc's optional dependency, reported in tokio's bundle, where libc's features are unknown).
    • Section 19: identical to the pre-rebase head on all 45 corpus crates (reached files, edges, missing modules, unresolved crates, resolved features). parseCargoManifest and parseCargoLock give the same result before and after on all 120 Cargo.toml and Cargo.lock files of the corpus and the fixtures.
    • Section 20: identical to the section-19 head on all 45 corpus crates (reached files, edges, missing modules, unresolved crates, resolved features). Corpus time is unchanged: 1764 ms before, 1740 ms after.
    • Section 21: identical to the pre-rebase loaders on all 45 corpus crates, with alpha.3's TOML parser in place of alpha.1's, and identical again after the rebase onto chore(deps): bump @preventive/deptree and @preventive/lockfile to 1.0.0-alpha.4 #193, with alpha.4 in place of alpha.3.
    • Section 22: reached files, edges, missing modules and unresolved crates are identical on all 45 corpus crates. The corpus has no lockfile, so it runs the replay. The only change is that the two proc-macro roots, num_enum_derive and serde_derive, now have their features in the host context, with the same sets. Corpus time: 1608 ms before, 1567 ms after.
    • Section 23: identical to the section-22 head on all 45 corpus crates (reached files, edges, missing modules, unresolved crates, and resolved features per context). Corpus time is unchanged: 1578 ms before, 1581 ms after. Two tests change with it: the manifest test now expects each table's kind and target, and the resolveModDecl assertions for the removed filter are gone.
    • Section 24, against the section-23 head:
      • The reached files are unchanged on all 45 corpus crates.
      • The other differences each come from a fix above:
        • New edges to the files defining other crates' macros: cfg_if::cfg_if!, log::debug!, Token!, format_ident!, forward_to_deserialize_any!, zerocopy::transmute!.
        • Declared dependencies the corpus doesn't vendor, named only in expressions, are now reported: getrandom, rustversion, wasi, socket2, signal_hook_registry, zmij.
        • The proc-macro roots serde_derive and num_enum_derive have their features in the target context too.
      • Corpus time: 1689 ms before, 1651 ms after.
      • Each new test fails on the section-23 head; the dense-cycle one hangs there.
      • Tests changed with it:
        • the cfg(windows) cfg-if of the cargo-recorded fixture is no crate of a Linux build;
        • a proc-macro entry's target context now holds its features;
        • the replay notice in the tests that assert no warnings;
        • the custom-cfg module against a glob.
  • Full suite (exodus-test, as on main since Use exodus-test runner and disable NODE_PATH extension #180, Node 24.21), after the rebase onto fix(bundle): read package.json as Node does; remapping errors never quote; green main after #178 #201: 2341 pass, 0 fail, 3 skipped (the cargo/rustc-dependent ones); oxlint clean. fix(bundle): read package.json as Node does; remapping errors never quote; green main after #178 #201 fixed the tests/vfs-bundle-github.test.js failure that main had after fix(bundle): Solidity loader — decide file ownership by real path; invalid configs are errors #178.

🤖 Generated with Claude Code

https://claude.ai/code/session_015nbp2vC4jLPbGN8cmorxAL

@exo-nikita exo-nikita changed the title fix(bundle): keep the Rust lexer's offsets aligned across astral chars fix(bundle): Rust loader follow-ups: lexer offsets across astral chars; dead items skipped whole Sep 26, 2026
@exo-nikita exo-nikita changed the title fix(bundle): Rust loader follow-ups: lexer offsets across astral chars; dead items skipped whole fix(bundle): Rust loader follow-ups: lexer offsets, dead items skipped whole, paths resolved through re-exports and macros Sep 26, 2026
@exo-nikita exo-nikita changed the title fix(bundle): Rust loader follow-ups: lexer offsets, dead items skipped whole, paths resolved through re-exports and macros fix(bundle): Rust loader follow-ups: lexer offsets, dead items, paths through re-exports and macros, include!, review fixes Sep 27, 2026
@exo-nikita exo-nikita changed the title fix(bundle): Rust loader follow-ups: lexer offsets, dead items, paths through re-exports and macros, include!, review fixes fix(bundle): Rust loader follow-ups: lexer offsets, dead items, paths through re-exports and macros, include!, review fixes, --cargo-target, --cargo-manifests Sep 27, 2026
@exo-nikita
exo-nikita force-pushed the claude/rust-bundling-support-02j8b2 branch from 5cd70fc to 0accd58 Compare September 27, 2026 14:26
@exo-nikita
exo-nikita force-pushed the claude/rust-bundling-support-02j8b2 branch from 876f2b6 to 494e9f0 Compare September 28, 2026 01:15
@exo-nikita
exo-nikita force-pushed the claude/rust-bundling-support-02j8b2 branch from e95e7af to 79243b2 Compare September 28, 2026 08:05
@exo-nikita
exo-nikita force-pushed the claude/rust-bundling-support-02j8b2 branch 2 times, most recently from d26953f to abd3f50 Compare September 29, 2026 03:24
@exo-nikita
exo-nikita force-pushed the claude/rust-bundling-support-02j8b2 branch from abd3f50 to 589afce Compare September 29, 2026 09:01
@exo-nikita
exo-nikita force-pushed the claude/rust-bundling-support-02j8b2 branch 5 times, most recently from f973192 to 07c7b18 Compare October 2, 2026 08:54

Copy link
Copy Markdown
Collaborator Author

The test jobs on this head (07c7b18) fail in tests/vfs-bundle-github.test.js, on buildGitHubBundle reads nothing from disk: the repo, its tree and every file come from memory. Main fails the same way at 3a70446 (Test run 879), so this PR doesn't cause it. Everything else passes: 2337 pass, 3 skipped.

The cause is two of main's PRs meeting:

So, over that empty tree, the test's src entry is reported as missing. The test expects it refused with only JS bundles are built with pnpm.

No fix exists yet (#200 doesn't touch this), and the fix is outside this PR's scope, so it isn't ported here. A proposed patch for main: the early check skips the path check and leaves it to the check over the real tree. With it, all 64 vfs-bundle tests pass on main.

--- a/stasis/src/cmd/bundle.js
+++ b/stasis/src/cmd/bundle.js
@@ function classifyEntries
-function classifyEntries(name, { cwd = process.cwd(), entries, …, cargoAllFeatures, host = diskHost }) {
+function classifyEntries(name, { cwd = process.cwd(), entries, …, cargoAllFeatures, host = diskHost, checkPaths = true }) {
   if (!Array.isArray(entries) || entries.length === 0) {
     throw new Error(`${name}: at least one entry file is required`)
   }
-  const dirError = directoryEntryError(entries, cwd, host)
+  // An early check (over an empty tree, before anything is fetched) leaves the paths to the real one.
+  const dirError = checkPaths ? directoryEntryError(entries, cwd, host) : null
   if (dirError !== null) throw new Error(`${name}: ${dirError}`)
--- a/stasis/src/vfs-bundle/github.js
+++ b/stasis/src/vfs-bundle/github.js
-  checkVfsOptions('buildGitHubBundle', pm, packageManager, { ...options, entries: options.entries ?? ['index.js'], cwd: '/', host: vfsHost(new Vfs()) })
+  checkVfsOptions('buildGitHubBundle', pm, packageManager, { ...options, entries: options.entries ?? ['index.js'], cwd: '/', host: vfsHost(new Vfs()), checkPaths: false })
--- a/stasis/src/vfs-bundle.js
+++ b/stasis/src/vfs-bundle.js
-  checkVfsOptions('suggestedEntries', { kind: 'js' }, undefined, { ...resolution, entries: ['index.js'], cwd: '/', host: vfsHost(new Vfs()) })
+  checkVfsOptions('suggestedEntries', { kind: 'js' }, undefined, { ...resolution, entries: ['index.js'], cwd: '/', host: vfsHost(new Vfs()), checkPaths: false })

Generated by Claude Code

@exo-nikita
exo-nikita force-pushed the claude/rust-bundling-support-02j8b2 branch from 07c7b18 to 60ea71e Compare October 2, 2026 09:10
claude added 8 commits October 2, 2026 09:41
… through re-exports and macros, include!, review fixes, --cargo-target, --cargo-manifests

Follow-ups to the Rust loader from #173, squashed from the review rounds of
this PR:

- the lexer's masked view stays aligned after astral characters;
- a dead item is skipped whole whatever commas its generics hold;
- paths resolve through imports, re-exports, globs and exported macros, with
  a memoized per-crate import table, textual macro scope and the extern
  prelude, cfg-aware (`any(…)` leaves, gate macros, custom cfgs presumed off);
- include!, include_str!/include_bytes! and #[doc = include_str!] are carried;
  #[path] on an inline module is honoured;
- --cargo-target decides target cfgs from rustc (run from the home dir,
  $RUSTC honoured, no auto-install);
- --cargo-manifests carries manifests, the lockfile, the cargo config and
  build scripts, each as written (as #182 carries --manifests files): a file
  that isn't UTF-8 text is refused, not altered;
- vendored crates are held to their package through symlinks and #[path].

On the strict TOML reader of #179: Cargo.toml, Cargo.lock and the cargo
config are read by table path (`[package] build`, `edition.workspace`,
target-specific dependency tables, every `[patch]` spelling). A Cargo or
Foundry file that exists but isn't TOML stops the build, naming the file and
line; a dependency's foundry.toml the loader can't read is no longer skipped
with a warning but stops the build too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nbp2vC4jLPbGN8cmorxAL
…plate mods

- Refuse a Rust source or include_str! file that isn't UTF-8 (and a non-UTF-8
  manifest); carry an include_bytes! file as base64.
- Compile units: code built for the host (build scripts, build-dependencies,
  proc-macro crates) is scanned against the host's cfgs and, under resolver 2,
  the host's own feature context; a crate built both ways is followed as both.
  Features are on, maybe (only an undecided target table enables them) or off.
- --cargo-manifests carries a vendored crate's Cargo.toml and
  .cargo-checksum.json, and a project package's configs up to the bundle root;
  the vendored directory follows the replace-with chain whatever its name.
- Custom cfgs a build script prints or rustflags set are not presumed off;
  cfg_x! gates take the cfg their macro_rules! definition writes.
- Ambiguous path edges record a cfg-keyed map of their candidates instead of
  the first written; candidates the build rules out come last; a child module
  under a custom or dead cfg gives way to an import of its name.
- Macro scope: a template's bare calls are made where it is invoked; a mod a
  template declares belongs to each in-package invoking module and is found
  beside it (libc's prelude!() -> src/types.rs), else beside the definition.
- `extern crate self as x;` is in the extern prelude; a declared dependency is
  reported missing whatever a glob into a missing crate may provide.
- Vendored versions come from the lock, else the one that fits, else a warning
  and no guess; out-of-root path/[patch] deps never bind to vendored copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nbp2vC4jLPbGN8cmorxAL
evalCfg is three-valued per leaf, so an item's cfg joined with its enclosing
blocks' -- zerocopy's `#[cfg(any(test, kani))] mod tests { #[cfg(not(kani))]
mod compatibility { use rand::…; } }`, i.e. all(any(test, kani), not(kani)) --
stayed undecided and its `use rand` was reported as a missing crate. When an
unknown leaf occurs more than once (up to six of them), the predicate is
evaluated under each assignment and decided when every one agrees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nbp2vC4jLPbGN8cmorxAL
…that lead out

A path's segment was looked up in the module's imports first and its own
definitions only when no import answered, so an import leading out of the
bundle (tokio's `pub(crate) use std::sync::atomic::AtomicU64;` in one `imp`
variant) hid the mutex-based `AtomicU64` the other variant defines, and a
dead import (`cfg_taskdump!`'s) beat the live fn under `cfg_not_taskdump!`.
The module's items are now candidates right after its named imports, ahead of
globs, and an import leading out of the bundle is an answer under its cfgs
rather than a last resort: `imp::AtomicU64` maps both variant files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nbp2vC4jLPbGN8cmorxAL
…ps in the tree pass

The cfg-keyed alternatives and the build-deadness check added in review 7
rebuilt the same cfg texts and re-parsed the same leaf predicates for every
candidate: libc's tree pass went from 227ms to 351ms. A leaf's verdict is kept
per interned build, an alternative's cfg text per asker set, and a file's
package per tree pass (asked for every import and item). libc 282ms, tokio
492ms -> 464ms (589afce: 444ms); output unchanged on the corpus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nbp2vC4jLPbGN8cmorxAL
…-table deps, resolver regressions

- buildRustBundle: a crate root the tree pass wants but the walk refused is
  asked for once, so the walk/tree loop ends; every crate the bundle lacks is
  reported (refused roots included) whether or not a vendor dir exists, the
  `cargo vendor` hint only without one.
- Cargo: each dependency table is its own dependency (version, path, package,
  workspace per table), and a file follows the one its target uses (build
  script, test target, normal); a Cargo.lock pin is taken only when the
  requirement allows it, else the lock is reported out of date; a [patch] is
  used only when its version fits, and [patch] in .cargo/config.toml (nearest
  first, ahead of the manifest's) is read; a path dependency or patch outside
  the bundle root, or naming no package, is reported.
- Resolver: a local module under a custom cfg gives way only to the module's
  own `use` or item, never to a glob; a macro call is looked up among macros
  (a `fn m`, `mod m` or an import of a module/crate is no `m!`); a template's
  calls and includes stay with it past a nested `macro_rules!`; cfg values
  keep their spacing inside quotes, so `"a b"` and `"ab"` are not merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nbp2vC4jLPbGN8cmorxAL
…th cargo's resolver where it can

- Cargo.toml, Cargo.lock and the cargo config's [patch] are read by
  @preventive/lockfile 1.0.0-alpha.3 (parseCargoManifest, parseCargoLock,
  parseCargoConfig): what cargo refuses, or what the reader can't tell how
  cargo reads, stops the build naming the file; a Cargo.lock older than
  version 3 is refused. Version requirements are matched by its rust-semver.js.
  The loader's own manifest and lock readers and semver matcher are gone.
- With a lockfile, --cargo-target and every locked package in-tree (path
  packages inside the bundle root, the rest vendored with checksums), features
  come from cargo's resolver: linkCargo over the manifests, then
  resolveCargoFeatures for `cargo build -p <the entries' packages>`; a lockfile
  out of date or a vendored copy whose checksum isn't the lockfile's stops the
  build. An unknown host is resolved both ways (its target tables maybe).
- Otherwise the manifests are replayed as before, with fixes: a git
  dependency takes a git checkout's vendored copy and a registry one a
  registry's (by .cargo-checksum.json and the lockfile's sources); `dep/feat`
  turns on the package's feature of that name, written or implicit; a
  proc-macro entry is resolved and scanned for the host; prereleases follow
  the semver crate; the lockfile is the entries' workspace's, below the bundle
  root too.
- cfg(true), cfg(false) and raw identifiers in cfgs.
- Tests: cargo's recorded builds of @preventive/lockfile's workspace fixture
  (PreventiveMeasures/libraries#56, MIT) through createCargoContext, the
  refusals, the fallback, and each fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nbp2vC4jLPbGN8cmorxAL
- scanRustItems: one cfg verdict per predicate per scan, and the open
  scopes' conjunction kept as they open and close, rather than joined and
  evaluated again at every token (a 220 KB file inside one #[cfg] block:
  588 ms -> 36 ms; with two compile units, 1.3 s -> 28 ms).
- cfg leaves: leafFalse evaluates a leaf by key and value (evalCfgKey)
  instead of rebuilding its text for evalCfg; leavesOfCfgs uses cfgLeaves;
  joinCfgs where it was inlined; one cache helper for the per-asker
  verdicts.
- One walk driver (collectRustBundle) for buildRustBundle and loadRust:
  build scripts, compile units and the wantedRoots loop live in rust.js;
  the link-out-of-package check (withinRealDir), boundaryOf and addUnits
  are shared rather than copied into bundle.js.
- cargo.js: each path dependency's directory resolved once, against the
  workspace root its inheritance was read from (depSpec gone); requests
  carry kind and target (no `kind@target` parsing); resolver 1's single
  context folded into nodeKey; one [patch] source list; the git/registry
  flag stored with the checksum; tableOf's TOML reused for the manifest;
  build script and build files memoized per package; one error-naming
  helper shared with toml.js; internals no longer exported.
- rust.js: the macro-template helpers shared by the walk and the tree
  pass (keyed by the package's own template set, not its identity);
  providedFrom's per-module file set computed once; resolveModDecl's
  unused cfg branch removed.
- The Rust-only flag checks in stasis.js and bundle.js table-driven.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nbp2vC4jLPbGN8cmorxAL
claude added 2 commits October 2, 2026 09:41
…tes, proc-macro roots, out-of-root workspaces

Crate resolution:
- A dependency name is resolved from the tables the asking code links:
  per crate root the file is compiled in (a build script's modules and a
  file it shares with the lib through #[path] take the
  [build-dependencies]; tests, benches and examples the dev ones too),
  and only tables for the platforms the code is compiled for. Tables
  that may each apply (no target to decide them, or two roles) give
  cfg-keyed alternatives, each carried. Both on the replay and on
  cargo's resolver.
- A crate's lib-name fallback resolves only dependencies that may have
  that lib name: `u8::MAX` no longer resolves every declared dependency
  and warns about versions.
- A declared dependency the bundle lacks is reported when only an
  expression names it (`serde_json::to_string(…)`).
- Macros of other crates get edges: `use dep::mac;` (an alias, a
  prelude glob too), `dep::mac!(…)` and `#[macro_use] extern crate`.

Cargo:
- The replay seeds a proc-macro root in the target context too, as
  cargo activates a member it is asked to build (fixes a regression
  from f0287e0).
- A member bundled from its own directory reads its workspace root
  above the bundle root (never bundled) for what it inherits and the
  resolver, when that root's members take it.
- [patch] tables apply to their own source only: a git URL's not to
  the crates.io dependency.
- The vendor directory comes from the cargo config nearest the
  entries (a nested workspace's own).
- `stasis bundle` says when the features come from the replay and
  why; a vendored copy the lockfile doesn't list no longer forces the
  replay. EXODUS_STASIS_DEBUG labels the mode and prints both contexts.
- A vendored file whose bytes aren't those .cargo-checksum.json lists
  stops the bundle, as cargo refuses to build it.

cfgs and resolution:
- `r#true` / `r#false` are custom cfgs, not the literals, in every reader.
- A child module under a custom cfg gives way to a glob of its name,
  as any doubtful candidate does.
- An include in a nested macro_rules! is that macro's, resolved where
  it is invoked.
- A refused or missing crate root is warned about once.
- Dense cycles of cfg-gated globs no longer blow up: leaf keys are
  cached, absorbed alternatives dropped, and alternatives capped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nbp2vC4jLPbGN8cmorxAL
…s/fixtures/rust-bundle.json.br

`includes` and `cargo-recorded` (30 files, 44 KB) become one brotli-compressed
JSON file (10 KB): project name -> project-relative path -> the file's text, or
`{ base64 }` for one that isn't UTF-8. tests/rust-fixtures.helper.js writes a
project out once per test process, to a temporary directory removed when the
process exits, and `rustFixture(name)` gives its path; `node
tests/rust-fixtures.helper.js unpack|pack <dir>` edits them. The fixtures
main already holds stay as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nbp2vC4jLPbGN8cmorxAL
@exo-nikita
exo-nikita force-pushed the claude/rust-bundling-support-02j8b2 branch from 60ea71e to 8e6e4e0 Compare October 2, 2026 09:43
@ChALkeR
ChALkeR merged commit c38489e into main Oct 2, 2026
5 checks passed
ChALkeR pushed a commit that referenced this pull request Oct 2, 2026
…eft after #175 and #178 (#203)

* fix(bundle): every path a .gitmodules gives a submodule is a dependency

A .gitmodules that gives a submodule's `path` twice (the key repeated, or
a second `[submodule "x"]` section) is one the library refuses, so it is
read submodule by submodule. That reader kept the first value. git reads
the last when it reads the checkout's .gitmodules, so the submodule it
checks out sits at the second path. stasis took that directory for the
project's own, trusted a link planted in it to .env, and bundled the
file.

The lenient reader now keeps the last `path`, `url` and `branch`, as git
does for the checkout. A path or url starting with `-` is skipped, since
git ignores those. Every other path given to the submodule is also taken
as a dependency, unnamed, with a warning. git reads the first of two
values when it reads .gitmodules from a commit, so neither directory may
be treated as the project's own code. The library refuses every
duplicate, so its strict path never reads one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

* fix(vfs-bundle): a Vfs host reads a path as the OS does

The Vfs host normalized every path with path.resolve before it resolved
links, so a `..` after a symlink was taken textually. The OS, diskHost and
forge take it from where the link leads. A Solidity bundle built from a
Vfs (`stasis github-bundle`, `buildVfsBundle` with soldeer) read a root
`extends = "cfg/link/../base.toml"`, or a `libs` entry with `..` after a
link, from a different file than forge does, and carried that file. A
dependency's extends through its own link was refused, because the
ownership walk and the host's realpath disagreed.

stat, readFile, readdir, readlink and realpath now take the path as
spelled. realpath walks it one component at a time: a `.` or `..` is
taken in the real directory before it, which must be a directory
(ENOTDIR otherwise, as the OS gives), and a link target is resolved the
same way. realpath also honors a trailing `/`, as realpath(3) does.
Node's own lookups, findPackageJSON and resolve, still normalize their
paths first, as Node does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

* fix(bundle): read cargo's configs, workspace root and lockfile from the one directory cargo runs in

The Cargo context assumed several working directories at once: the vendor
directory came from the configs above the first entry's file, the [patch]
tables from the configs above the workspace root, the rustflags from the
bundle root's config alone. A member's .cargo/config.toml could so be taken
for its [source] and ignored for its [patch] (cargo run in the member builds
the patch's fork; the bundle bound the vendored copy), and a member's
rustflags `--cfg` was presumed off.

- One directory is taken as where cargo runs: the package directory of the
  first entry that isn't a vendored crate's (`cargo build` there, which finds
  its workspace root above), else the bundle root. The configs cargo reads
  from there -- that directory's and each one above it, above the bundle root
  too (read, never bundled; not $CARGO_HOME's) -- give the vendor directory,
  the [patch] tables, the rustflags, `cargo metadata`'s cwd and the configs
  --cargo-manifests carries.
- [source] tables are merged key by key, the nearest config first, as cargo
  merges them: a nested workspace's `[source.vendored-sources] directory`
  beside the bundle root's `replace-with` is the nested one's directory, each
  path relative to the directory holding the `.cargo` of the config writing it.
- The workspace root of a package is the nearest [workspace] above it that
  doesn't `exclude` it (cargo passes an excluding one over), whatever its
  `members` say: cargo either takes the package as a member there (a member's
  path dependency is one) or refuses to build it. A member bundled from its
  own directory below such a root above the bundle root now gets that root's
  resolver and inheritance when it is a member only as a path dependency.
- The [patch] of the build's workspace root applies to every package of the
  build, an outer root's above the bundle root too (its paths relative to it).
  A lockfile beside a member whose workspace root lies above the bundle root
  isn't the build's: none is read, and cargo's resolver doesn't run.
- --cargo-manifests carries the build's lockfile and configs only, not a path
  dependency's own Cargo.lock or .cargo/config.toml, which cargo never reads
  building the entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

* fix(bundle): check a vendored Cargo.toml against .cargo-checksum.json before resolving with it

A vendored file the bundle carries was checked against the checksums
`cargo vendor` listed, but a vendored crate's Cargo.toml was only read: without
--cargo-manifests it isn't carried, and an edited one (a feature turned on by
default, a dependency added) drove the feature resolution and so the bundled
files, where cargo refuses to build the crate ("the listed checksum of …
Cargo.toml has changed").

Every vendored package the build takes in -- the copy a dependency resolves to
on the replay, the lockfile's copies on cargo's resolver, the one vendored
crate of a name for a file no manifest claims -- has its Cargo.toml checked
first, once per package; one that isn't the listed file stops the bundle.
Copies nothing resolves to stay unchecked, as cargo never reads them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

* fix(bundle): report every dependency the build links that the bundle lacks, however the code names it

The missing-crate report listed the crates the code names that don't resolve
in-tree. A dependency whose lib name differs from its manifest key (`md-5`,
used as `md5::compute`) and isn't vendored was never named as the package's,
since its lib name lives in the manifest the bundle lacks, and so went
unreported; so did a linked dependency no code names.

The replay records each active dependency table -- the build's platforms (a
maybe one too), an optional one once turned on -- that no package in-tree
answers, and `cargo metadata` the dependencies it couldn't place in-tree.
`stasis bundle` adds those of the bundled packages to the report, as
`md-5 (a dependency of app 0.1.0)`, unless the code's name is already listed:
dev-dependencies only for a test, bench or example entry, build-dependencies
only with --cargo-manifests and a build script. Cargo's resolver needs every
locked package in-tree, so there is nothing to add there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

* fix(bundle): keep code a build compiles where cargo metadata's features or a build's cfgs can't be told apart

Two presumptions dropped or ranked away code a build compiles:

- --cargo took cargo metadata's one feature set per package -- the union over
  every build of the workspace, normal, dev and build dependencies on every
  platform -- as on for certain in the target's build and the host's alike,
  so `#[cfg(not(feature = "h"))]` code dropped out of the target build of a
  crate that only its build-dependency use turns `h` on for (cargo compiles
  it there). Each of those features is now on only maybe; one outside the
  union stays off.
- A custom cfg is presumed off unless the package's build may set it. A build
  script printing a name it formats in part (`cargo::rustc-cfg=os_{}`) was
  read as setting `os_` alone, and one calling a build-dependency that prints
  the cfgs (cfg_aliases' `cfg_aliases!`, as nix and wgpu use it) as setting
  none -- so `#[cfg(os_linux)]` / `#[cfg(linux_like)]` candidates lost to their
  negation as certain. A name formatted in part now makes every custom cfg of
  the package undecided, and so does a build-dependency, or what it depends on
  in turn for the host, whose lib writes `rustc-cfg` outside full-line comments
  -- or that the bundle root lacks, which may for all the loader knows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

* fix(bundle): keep an any(…) past 16 alternatives undecided, never certain

anyOf returned no leaves for an `any(…)` of more than MAX_ALTERNATIVES
alternatives -- the cap that keeps a dense cycle of cfg-gated globs from
blowing up -- and no leaves means "holds always": the candidate under it
became certain and every other one was dropped, without a warning. A cfg
listing 17 platforms one way and the rest the other (cap17) mapped
`crate::T` to the 17-platform file only, and a module 17 cfg-gated glob
paths reach (cap2) hid the `#[cfg(target_os = "linux")] pub use lin::T;`
that a linux build compiles.

Past the cap, and for an `any` with an undecided alternative, the result
is now one undecided leaf: no build decides it, no asker holds it (one
under it included), it is no custom cfg and exclusive with nothing, its
negation too. A candidate under it is only maybe the answer, so the edge
is the cfg-keyed map of every candidate. Folding an `any` with such an
alternative into one keeps the sets along a dense cycle few: cygen N=16
and the 14-module dense cycle resolve in tens of ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

* fix(bundle): a local module gives way to a glob only when none of its files may be there

walkPath let a child module give way to what else its module has of the
name -- a glob's `imp` included -- when the module's first file was under
a cfg the build rules out or a custom cfg it presumably lacks. A module
with a fallback is there in every build all the same:
`#[cfg_attr(loom, path = "loom_imp.rs")] mod imp;` is imp.rs without
loom, and shadows the `imp` a `use other::*` brings in (cm1, a regression
of review 10, which rustc confirms: the glob's `imp::X` lacks the method
called). The same with a target: tokio's `cfg_has_atomic_u64! { mod imp; }`
first, ruled out, and `cfg_not_has_atomic_u64! { mod imp; }` live (cm3).

The module's files are now gathered per crate root, and it gives way only
when every one of them is ruled out or doubtful: a `#[cfg(loom)] mod imp`
alone still yields to the glob (cm2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

* fix(bundle): resolve another crate's macro as its root does, and a path's lead to the crate

Review 10 took another crate's macro from the first `#[macro_export]`
definition of the name in that crate, whatever cfg its file was mounted
under, and never followed a re-export: serde's docsrs-only copy of
serde_core's macros (src/core/macros.rs, `#[cfg(docsrs)]`) answered
serde_json's `forward_to_deserialize_any!`, which every other build gets
through `pub use serde_core::forward_to_deserialize_any` (mx1 is the same
in small; cargo builds it from inner). And `use helper::{helper, mk}` lost
`mk!`: the path's lead resolved to the import of the macro `helper`, and
the walk ended at the crate without looking in it.

A macro of another crate (`use dep::mac;`, `dep::mac!(…)`, a `#[macro_use]
extern crate`'s, a lead the module binds to the crate) is now looked up
from that crate's root as a path there is, in the macro namespace, the
cfgs judged by its own build: the crate root's `#[macro_export]`
definitions are bindings of the name under their files' cfgs, ranked with
its imports (a `pub use inner::m;` followed on into `inner`), and
several that may each apply give the cfg-keyed map, for a bare call too.
An import followed as a macro to a file that defines no macro of the name
(`pub use util::helper;` of a fn) binds none. In the corpus,
serde_json's three `forward_to_deserialize_any!` edges move to
serde_core/src/macros.rs, and serde_derive gains `parse_macro_input!` and
`format_ident!` (`use quote::{format_ident, quote}`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

* fix(bundle): declare a template's mod and includes where rustc expands them

A `mod` or include a `macro_rules!` body holds is made where the macro is
invoked, and the loader placed it right only for a bare call from a file
of the defining package, outside inline modules. Otherwise it stayed beside
the definition -- carrying a file the build never reads and missing the one
it does -- or was dropped silently:

- invoked by path: `crate::decl!()` from src/sub.rs, or `$crate::decl!()`
  in another macro's body, declares src/sub/inner.rs (tm1, tm6), and
  `crate::embed!()`'s `include_str!("data.txt")` reads src/sub/data.txt
  (ti1, ti2). A path invocation now counts as one of the macro of its last
  segment, as a bare one does.
- from another crate: `#[macro_use] extern crate defs; decl!();` declares
  the app's `inner` (tm3). A `#[macro_export]`ed macro's template `mod` is
  hosted in every crate invoking it; the test that kept it beside the
  definition asserted what rustc doesn't do, and now asserts what it does.
- inline modules: invoked inside `pub mod outer { decl!(); }` the module is
  outer's (src/outer/inner.rs, tm9); defined inside `mod defs { … }` the
  definition's inline modules are no part of it (src/inner.rs, tm10). Each
  invocation keeps the inline modules around it, each template `mod` the
  depth of its definition.
- nested templates: a `mod` (nt2) and the calls (nt3) in a `macro_rules!`
  that another body defines are that inner macro's, made where it is
  invoked, as its includes already were.

Every case is checked against cargo: each fixture builds only from the
file the loader now carries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

* fix(bundle): follow a path through every module a segment may name

The cfg-keyed map of candidates was recorded only for a path's last
segment: a module the path went on through took the first candidate, so
without a target `crate::imp::X` beside `#[cfg(windows)] use windows as
imp;` / `#[cfg(unix)] use unix as imp;` was windows.rs alone, while a
linux build compiles unix.rs (al2); likewise a module two cfg-gated globs
bring in (al3), and `#[cfg(not(unix))] mod sys;` beside
`#[cfg(unix)] use fallback as sys;`, where the module always won (al6).

Several candidates of which a module is first now carry their branches,
and walkPath walks the rest of the path through each, the files they
lead to the edge's alternatives under the key of the module they go
through; a single-file module under cfgs the asking file doesn't hold is
one branch beside what else its module binds the name to (a `mod` with
cfg variants, or under a custom cfg, stays the module). In the corpus,
mio's `sys::udp::bind`, tokio's `os_impl::ctrl_c` and hashbrown's
`self::imp::Group` now map each platform's file; under the linux target
hashbrown keeps sse2.rs and the generic fallback a build without sse2
compiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

* fix(bundle): resolve a dead file as it would compile, a contradictory one to every candidate

An asking file's cfg set is its own leaves plus the target's cfgs. For a
file the target rules out -- tokio's atomic_u64_as_mutex.rs and its
static_*.rs submodules under a 64-bit target -- the two contradict, every
candidate looked compatible, and the first written won:
`super::AtomicU64` resolved to atomic_u64_native.rs, a sibling variant,
not as_mutex, the module `super` names there (au2). A file whose own cfgs
contradict each other (static_once_cell.rs, mounted under
`not(all(test, loom))` and `all(loom, test)`) had every candidate ruled
out, and fell back to the module's first file (au3).

A file the target rules out now asks under its own cfgs, as where it is
compiled; one whose own cfgs contradict each other is compiled nowhere,
so every candidate is compatible with it and none certain, and its paths
map them all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

* refactor(bundle): one helper for a path a cargo config or manifest writes

projectRel takes a path relative to an absolute directory, as cargo does,
for the configs' directories, their [source] and [patch] paths and the
outer workspace's inherited paths, which joined an absolute path instead
of resolving it. The outer workspace keeps its label as `file`, and the
root's [patch] reads its paths and file off the manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

* refactor(bundle): one workspace root for the lockfile, cargo's resolver, the feature resolver and [patch]

buildPackage, memoized, is the run directory's package taken once the
vendor directory is known; buildWorkspaceRoot is its root, and whether
that lies above the bundle root is read in one place, buildLockPath. The
feature resolver comes from that root too: a vendored crate an entry is
in no longer sets it (cargo is never run there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

* refactor(bundle): one record of a dependency the build lacks, from the replay and from cargo metadata

Both producers keep `{ dir, key, name, kinds }`, so lackingDependencies
reads them in one loop; the packages whose dev-dependencies are linked
are worked out once, for the replay and the report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

* refactor(bundle): one scanner for the cfgs a build script prints, itself or through a crate it calls

cfgsPrinted reads build scripts and the libs they call alike: full-line
comments stripped (only once the raw text mentions a cfg print), a fixed
`rustc-cfg=<name>` that name, a formatted one, the directive written
apart from its name or autocfg any. So a helper printing one fixed name
makes only that name maybe. The crates a build script links for the host
are resolved before any lib is read: one the bundle lacks is "any"
without I/O.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

* perf(bundle): check a vendored Cargo.toml once, where its manifest is read

readManifest checks a vendored copy's Cargo.toml against its
.cargo-checksum.json from the bytes tableOf read, before anything uses
it; only the vendor directory's listings read copies unchecked, as cargo
checks only the copies it builds. A checked file isn't hashed again, so
--cargo-manifests no longer re-reads and re-hashes it, and a vendored
entry's manifest is checked like a resolved one's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

3 participants