fix(bundle): close the silently incorrect Rust and Solidity results left after #175 and #178 - #203
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
c382e79and pass here.Solidity
pathgiven 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.envwas then followed and bundled. stasis now reads the last value as git does. Everypatha 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 differentextendsbase orlibsentry 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)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.#[cfg_attr(loom, path = …)] mod imp;, which falls back toimp.rs, lost to a glob'simp.#[cfg(docsrs)]files are dead), andpub usere-exports are followed. For example, serde_json'sforward_to_deserialize_any!now resolves toserde_core/src/macros.rs.use anyhow::{anyhow, bail}; bail!()keeps its edge.mods and includes are declared where rustc expands them. That covers path calls (crate::m!(),$crate::m!()), other crates, inline modules, and nestedmacro_rules!.Cargo (
cargo.js)[source]and the vendor directory,[patch], rustflags, the directorycargo metadataruns in, and which configs--cargo-manifestscarries.[source]keys are merged nearest first, as cargo does.[patch]applies, and anexcluded package is passed over.Cargo.tomlchecksums. A vendoredCargo.tomlthe resolution reads is checked against.cargo-checksum.json.md-5used asmd5::).--cargo, metadata's feature union is "maybe", not certain;cfg_aliases!-style build-dependency, or a partly formatted name likeos_{}.Cargo.lockand.cargo/config.toml, which cargo never reads, are no longer carried.Behaviour changes to note
$CARGO_HOME. That includes~/.cargo/config.tomlwhen 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 metadataruns in the run directory, andresolvedFeatures()is empty, because its features are "maybe".--cargo-targetis given.cfg_ifarms, mio's platformsysmodules, tokio'sctrl_c).url. A submodule whoseurlis given twice is named after the last one.Testing
node --run lintis clean, andnode --run testpasses all 83 suites.🤖 Generated with Claude Code
https://claude.ai/code/session_016XK2nLruNc3U23JzQ3fxNm
Generated by Claude Code