Skip to content

fix(bundle): close the silently incorrect Rust and Solidity results left after #175 and #178 - #203

Merged
ChALkeR merged 17 commits into
mainfrom
claude/wizardly-keller-mesfi0
Oct 2, 2026
Merged

ChALkeR merged 17 commits into
mainfrom
claude/wizardly-keller-mesfi0

Conversation

@exo-nikita

Copy link
Copy Markdown
Collaborator

This PR fixes the silently incorrect results that a review of main after #175 and #178 found. These are wrong edges, files the build reads but the bundle drops, a guess made without a warning, and one containment leak.

Each fix is either exact, or it fails closed: it keeps every candidate as a cfg-keyed map, or it reports the gap. No answer goes from silent to wrong. Each expected answer was checked against real cargo, rustc or git before the fix. Each commit adds regression tests that fail on c382e79 and pass here.

Solidity

  • A submodule path given twice in .gitmodules. This could be a repeated key, or a second [submodule] section with the same name. stasis kept the first value and git keeps the last, so the real submodule was treated as the project's own code. A link planted in it to .env was then followed and bundled. stasis now reads the last value as git does. Every path a submodule is given counts as a dependency, so no listed directory can become project code.
  • .. after a symlink in the Vfs host. The Vfs host (vfs-bundle/tree.js) resolved .. textually, so a Vfs or GitHub bundle could read a different extends base or libs entry than forge does. Host operations now resolve physically, one component at a time, as the OS does. Node-model lookups still normalize first, as Node does.

Rust resolver (rust.js)

  • More than 16 cfg alternatives. An any(…) past the cap used to mean "holds always", which made one candidate certain and dropped the live one. It now stays undecided and every candidate is kept. The cyclic-glob hang stays fixed.
  • A local module beside a glob. A local module now gives way to a glob only when none of its files may exist. Before, #[cfg_attr(loom, path = …)] mod imp;, which falls back to imp.rs, lost to a glob's imp.
  • Another crate's macro. It is now resolved the way that crate's root resolves it: cfgs on the defining file's mount are honoured (#[cfg(docsrs)] files are dead), and pub use re-exports are followed. For example, serde_json's forward_to_deserialize_any! now resolves to serde_core/src/macros.rs.
  • A path's crate lead. It resolves to the crate even when a macro of the same name is imported, so use anyhow::{anyhow, bail}; bail!() keeps its edge.
  • Template mods and includes are declared where rustc expands them. That covers path calls (crate::m!(), $crate::m!()), other crates, inline modules, and nested macro_rules!.
  • Paths. A path is followed through every module a segment may name, not just its last segment. A dead file is resolved under its own cfgs, and a file whose own cfgs contradict each other is resolved to every candidate.

Cargo (cargo.js)

  • One run directory for every config-derived setting. That directory is the first non-vendored entry's package, else the bundle root. It decides [source] and the vendor directory, [patch], rustflags, the directory cargo metadata runs in, and which configs --cargo-manifests carries. [source] keys are merged nearest first, as cargo does.
  • A workspace root above the bundle root. A package is now a member when a member path-depends on it, the outer root's [patch] applies, and an excluded package is passed over.
  • Vendored Cargo.toml checksums. A vendored Cargo.toml the resolution reads is checked against .cargo-checksum.json.
  • Missing dependencies. Every active declared dependency the bundle lacks is reported, however the code names it (for example md-5 used as md5::).
  • Fail-closed cases:
    • with --cargo, metadata's feature union is "maybe", not certain;
    • cfgs a build script may set but stasis can't know make that package's custom cfgs undecided: a cfg_aliases!-style build-dependency, or a partly formatted name like os_{}.
  • Build files carried. A path dependency's own Cargo.lock and .cargo/config.toml, which cargo never reads, are no longer carried.

Behaviour changes to note

  • Configs above the bundle root. Cargo configs above it are now read, though never bundled, up to the filesystem root but not $CARGO_HOME. That includes ~/.cargo/config.toml when the project sits under the home directory, so resolution can depend on the machine, as cargo's does. A broken parent config now stops the bundle.
  • --cargo. cargo metadata runs in the run directory, and resolvedFeatures() is empty, because its features are "maybe".
  • Missing-crate report. It can list more crates, including platform-specific dependencies when no --cargo-target is given.
  • Edges. More edges are cfg-keyed maps where several files may apply (hashbrown's cfg_if arms, mio's platform sys modules, tokio's ctrl_c).
  • Duplicate url. A submodule whose url is given twice is named after the last one.

Testing

  • Node 24.21: node --run lint is clean, and node --run test passes all 83 suites.
  • Each item's repro, re-run on the integrated branch, now gives the answer cargo, rustc or git gives.
  • Vendored real-crate corpus: the files are unchanged, and every changed edge was checked by hand.
  • Solidity: the repo fixtures are unchanged, and the Vfs-vs-disk differential is now identical.
  • Performance: cyclic globs (N=8) take about 30 ms, and corpus wall time is lower.

🤖 Generated with Claude Code

https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm


Generated by Claude Code

claude added 17 commits October 2, 2026 10:54
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
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
…he 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
… 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
…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
…es 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
…tain

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
… 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
…th'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
…s 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
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
… 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
…ites

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
…er, 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
…e 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
…elf 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
… 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
@ChALkeR
ChALkeR merged commit d336e2f into main Oct 2, 2026
5 checks passed
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