Skip to content

fix(bundle): Solidity loader — decide file ownership by real path; invalid configs are errors - #178

Merged
ChALkeR merged 20 commits into
mainfrom
claude/magical-davinci-bs3pob
Oct 2, 2026
Merged

ChALkeR merged 20 commits into
mainfrom
claude/magical-davinci-bs3pob

Conversation

@exo-nikita

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

Copy link
Copy Markdown
Collaborator

Follow-up to #177's post-merge review, plus the reviews of this PR itself. Rebased onto main after #194 (Solidity bundles from soldeer.lock), so this PR uses @preventive/lockfile 1.0.0-alpha.4 (#193) for TOML (#183) and .gitmodules, and contains no --manifests redaction: carried files are included as written. The Solidity loader, the ownership walk included, reads the project through main's filesystem host (#189, #194), so a bundle built from a Vfs gets the same containment as one read from disk. I reproduced each finding before fixing it, and each has a regression test that fails on the base it fixes.

Ownership by real path

Whether a file belonged to a dependency used to be decided from its lexical path, with the real path checked only in some places. loaders/solidity-ownership.js now resolves each path once, one component at a time, and decides its owner from where the file really is. The same check is used by import resolution, the file walk, entries, --manifests (which files may be carried), forge's nested-config discovery, and the package.json files that decide which package a source belongs to.

  • Dependencies: node_modules/<pkg> (and @scope/<pkg>), the entries of forge's libs (an absolute one by its real path), Soldeer's dependencies/, and git submodules. A symlinked lib/ entry counts as the dependency wherever it points. A file belongs to a dependency when its real path lies in one, however the path got there, so code reached through src/vendor -> ../lib/dep/src is the dependency's.

  • A dependency's imports must land on its own files or another dependency's, never the project's.

  • Links nobody trusted placed are never followed. This covers:

    • a symlink planted inside a dependency that leads out of it to anything but another dependency;
    • a symlink outside the project that leads back into it. Example: a dependency linked from elsewhere, lib/evil -> ../../shared/evil, that holds a link back to the project's .env.

    Following one of these is refused with the reason, whether it's reached as an import (including one routed by a dependency's remapping), an entry, a carried manifest, a package.json used to assign a package, or the dependency's own foundry.toml, extends base or remappings.txt.

  • Checked against the OS: the link-by-link walk's result is compared with the host's own realpath: on disk, the OS's realpath(3), which gives the filesystem's own spelling. A path the two resolve differently is refused rather than trusted. So is one the walk can't resolve but the OS can, and one the OS can't resolve at all: a real path past PATH_MAX, or a link whose end it can't name, such as /proc/self/fd/0 or /dev/stdin on an open pipe. A path counts as missing only when nothing is there at all. A .. in a path is resolved by the OS from where the link leads, not textually. Link targets are split on the OS's separators only (on POSIX a \ is part of a name), and a target that isn't UTF-8 counts as unresolved, not missing.

  • A dependency's config reaches only what the path from the root does. One found through an absolute or /proc/self/cwd lib is judged by its real path. A dependency outside the root reads nothing, and a directory a dependency's libs names must be a dependency itself (not the project's own directory passed off as one). A refused config says why, with the ownership reason.

  • Case-insensitive filesystems: each path component takes the filesystem's own spelling, so LIB/evil is checked as lib/evil (tested by emulation, with a host that lowercases paths).

  • .gitmodules is read once per bundle, with @preventive/lockfile's parseGitmodules, which reads it the way git's config parser does. A URL is taken as written (checkUrls: false): one relative to the superproject's remote (../x.git), a path, or none. Stasis only uses the URL to name a GitHub submodule's bucket, so such a submodule is still a dependency, just without a GitHub name.

  • A .gitmodules the library refuses never fails the bundle, and never makes a submodule's directory the project's own code. It is warned about and read one submodule section at a time. This covers:

    • something git reads two ways: a key twice, or a second section;
    • something git reads but the library doesn't check: update = none, active, a [core] or [include] section, a tab in a value;
    • a [submodule.x] header, which is read the way git reads it, as [submodule "x"].

    Each submodule's first path, url and branch are kept, as git's submodule commands read them, and each is re-read by the library. A branch, then a URL, that still doesn't read is dropped with a warning. A submodule whose path doesn't read fails closed: if the path names a directory inside the repository (./deps/x, deps/x/), that directory is still a dependency, unnamed. Only a path outside the repository (../x) is skipped. So a planted link in such a directory, even outside forge's libs, stays refused.

  • A .gitmodules git itself refuses is an error, as it is to git. That means any "bad config line":

    • a header such as [submodule x], [submodule.deps/x], or [submodule "x" with no ];
    • a key followed by something other than =;
    • a value with no closing quote, or an unknown escape.

    Reading past such a line could lose a submodule's section, and its directory would then count as the project's own code. The lenient read now follows git's config.c character by character and refuses exactly what git refuses. A differential fuzz against git config -f --list -z (16,000 files) matched git on every file, in both what's refused and the keys and values read.

  • Links the project placed can point anywhere in the root. That covers a workspace package in node_modules, a linked lib/ entry, and a link on the path the project was opened by (a symlinked checkout).

Invalid input is an error

Each of these fails the bundle naming the file from the project root (or naming the variable), whether it's the project's, a dependency's, or read under --mapping. Nothing falls back to a default:

  • TOML: a foundry.toml or extends base that isn't TOML.
  • Not UTF-8: a .sol source, a .sol.txt listing, a foundry.toml, a remappings.txt, a --mapping file or a .gitmodules. They're decoded strictly with @exodus/bytes (now a direct dependency of stasis; stasis-core stays dependency-free), keeping a byte-order mark. Before, a .sol file was read with readFile(…, 'utf8'), which bundles a stray \xe9 as U+FFFD: text that isn't the file's.
  • Not a regular file: config files are read only if they're regular files, and a package.json that isn't one is never read. A FIFO, a socket, a device, or a link to one (the project's own remappings.txt -> /dev/stdin) can't stall the bundle or feed it the process's input. A config path the host can't stat is read to learn why: only "nothing there" counts as no file, so a loop or an unsearchable directory is still an error.
  • Wrong types: a mistyped src, test, script, libs, auto_detect_remappings or extends (for example libs = "deps").
  • Remappings: a line or FOUNDRY_REMAPPINGS/DAPP_REMAPPINGS entry that isn't [context:]prefix=target, or a remappings value that isn't an array of such strings.
  • package.json: one that decides a Solidity source's package but doesn't parse, isn't UTF-8, or isn't a regular file. A leading byte-order mark is skipped, as npm skips it. The error gives the file and position and never quotes the text, because the parser's own message would. Bash and Rust bundles, stasis add and JS --package-json still walk past a malformed one, as on main.

Forge refuses all of these too, except that it silently skips a dependency's foundry.toml it can't read. A dependency's config that forge rejects for its settings (a missing or nested extends, colliding keys, a link out of the dependency) is still skipped with a warning, as forge skips it. Only that class of error (ConfigRefused) is caught.

A remappings.txt line is trimmed the way forge trims it, so a byte-order mark stays part of the first remapping, as it does in forge.

--manifests

--manifests carries every config file the resolution read, whatever it's called (extends = "base.conf", --mapping=remaps). With --mapping that includes the root foundry.toml and its extends base, read for the lib dirs. It also carries the lock files and the package.json, foundry.toml and remappings.txt of each bundled package, all as written. A package.json is tagged json; every other file is a resource, including a --mapping=remaps.json. .env files and hardhat.config.* are never carried, however they're cased.

A config file is carried under its path in the project. It is carried under its real path instead when a .., or an absolute or /proc/self/cwd lib, leads somewhere else. So extends = "cfg/base.toml" with cfg -> shared is carried as cfg/base.toml, where forge will look for it after stasis extract.

A config whose real path the OS can't give (past PATH_MAX) is never given a normalized name, since that could name another file. It keeps the name it was read by, .. and all, however the root was spelled in the path (L/../base.toml, <root>/./L/../base.toml, a doubled /, the root's real path), and is refused. A path from no spelling of the root stays absolute and is refused as unresolvable.

A config the resolution read that can't be carried fails --manifests with an error naming it, because without it the bundle couldn't reproduce the resolution. That covers one outside the bundle root (extends = "../shared-base.toml"), a .env one (base.env, .env.toml, Base.ENV, .env.local), a hardhat.config.* one in any case, one the ownership rules refuse, and one that's gone. Without --manifests nothing is carried, and these bundle as forge resolves them.

Other fixes

  • --mapping:
    • a root foundry.toml whose settings forge would reject (a broken extends) falls back to the default lib dirs, with a warning;
    • FOUNDRY_PROFILE is reported only when it names a profile the root foundry.toml has, and warned about otherwise.
  • forge and solc fidelity:
    • an extends path is joined as forge joins it, not normalized, so a .. after a symlink leads where forge's does, in the project's config and a dependency's alike (extends = "sub/../base.toml" with sub -> real/in reads real/base.toml, not base.toml);
    • a legacy [<name>] table's extends is ignored, as forge ignores it;
    • a remappings.txt taken as written accepts an empty target (x/=), as solc does.
  • Mistyped extensionless entry: reported as no such file or directory. The directory-entry rules are in directoryEntryError and isSolidityEntry, used by both the CLI and buildBundle.
  • Shared helpers:
    • realpathOrNull is the one realpath helper for solidity.js, foundry.js and the ownership walk (realpath(3) on disk, the host's own otherwise);
    • readRegularFileOrNull (in stasis-core, through host) is the one reader for config files and carried manifests, and decodeUtf8 the one strict UTF-8 decoder for Solidity sources and configs;
    • a carried manifest is read by the real path the ownership check resolved, so the file carried is the one that was vouched for;
    • readPackageJson is the one package.json reader behind findPackageMetadata, and it reads through host;
    • projectRelative names the config files read, for both solidity.js and foundry.js;
    • ownership.assert is the one refuse-and-throw check, and ownership.submodules the one parsed .gitmodules;
    • packageLookup is the one per-directory package cache, shared by a JS bundle's --package-json pass and its buckets.

Checks

  • Forge oracle (v1.8.3): the scenario, profile, legacy-extends and test-case suites all match, as do 5,700 fuzzed projects across the runs and the symlinked-extends cases (the project's and a dependency's). The oracle also confirms forge refuses a bad remappings.txt line, rejects libs = "deps" and keeps a leading BOM. solc-js 0.8.30 confirms the empty remapping target.
  • git 2.43: the .gitmodules reader matches git config -f --list -z on 16,000 fuzzed files. The library also refuses every one of about 187,000 files git refuses, so the strict check can't be bypassed on the main path.
  • node --run test (exodus-test, Node 24): 2173 pass, 0 fail, all 81 suites, main's Soldeer Vfs bundles included. node --run lint: clean.
  • A Solidity bundle built through a Vfs host refuses a dependency's link to the project's .env. That test fails if the ownership walk reads the disk instead of the host.
  • Two tests guard against hangs, and both hang on the previous code:
    • one runs the bundle in a child whose stdin is an open anonymous pipe;
    • another puts a FIFO at a dependency's package.json.
  • Deliberately unchanged:
    • a project's own symlink (e.g. src/E.sol -> ../.env) is still trusted, because the project placed it;
    • a symlink an extends path goes through isn't carried itself (bundles don't hold symlinks), only the file read;
    • packages that pnpm doesn't hoist to the top-level node_modules still can't be found from inside another package, as on main.

🤖 Generated with Claude Code

https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA

@exo-nikita exo-nikita changed the title fix(bundle): Solidity loader — decide file ownership by real path; broaden --manifests redaction fix(bundle): Solidity loader — decide file ownership by real path; structural --manifests redaction Sep 28, 2026
@exo-nikita
exo-nikita force-pushed the claude/magical-davinci-bs3pob branch from 1b9aaca to 3a52659 Compare September 29, 2026 03:22
@exo-nikita
exo-nikita force-pushed the claude/magical-davinci-bs3pob branch from 3a52659 to 0563ffe Compare September 29, 2026 09:05
@exo-nikita exo-nikita changed the title fix(bundle): Solidity loader — decide file ownership by real path; structural --manifests redaction fix(bundle): Solidity loader — decide file ownership by real path; invalid configs are errors Sep 29, 2026
@exo-nikita
exo-nikita force-pushed the claude/magical-davinci-bs3pob branch 4 times, most recently from e1998ad to ad8caa2 Compare October 1, 2026 19:56
claude added 20 commits October 1, 2026 22:01
The leaks and the workspace-link regression share one cause: containment was
decided from the lexical path.
- solidityOwnership resolves each path once, component by component, and
  gives its owner from where it really is: a file is a dependency's when its
  real path lies in one (node_modules packages, forge's libs entries, Soldeer's
  dependencies/, git submodules; a symlinked lib/ entry is the dependency where
  it points), however the path got there.
- A symlink planted inside a dependency that leads out of it to anything but
  another dependency is never followed: not for an import (even one the
  project makes, or one a dependency's remapping routes, e.g. forge-std/), an
  entry, a carried manifest, or the dependency's own foundry.toml, extends base
  or remappings.txt.
- Dependency code reached through a project symlink (src/vendor ->
  ../lib/dep/src) is the dependency's, so it can't import the project's files.
- A workspace package linked into node_modules and a symlinked lib/forge-std
  can import their own files again.

Also:
- --mapping tolerates a root foundry.toml whose settings forge would reject
  (default lib dirs, warned) and reports FOUNDRY_PROFILE when it picks the
  lib dirs.
- A legacy [<name>] table's `extends` is ignored, as forge ignores it (checked
  against forge v1.8.3); a remappings.txt taken as written accepts an empty
  target (`x/=`), as solc does.
- A lone missing extensionless entry is reported as "no such file or directory"
  instead of being bundled as a Solidity directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
Ownership (moved to loaders/solidity-ownership.js, shared with foundry.js):
- A link outside the project root that leads back into it is untrusted (a
  dependency linked from elsewhere, lib/evil -> ../../shared/evil, holding a
  link to the project's .env), unless it lies on the path the root was named
  by (a symlinked checkout).
- Each path component takes the filesystem's own spelling, so on a
  case-insensitive filesystem LIB/evil is checked as lib/evil.
- .gitmodules is read as git reads it (quotes, escapes, comments, key case,
  continuations, merged sections); bundle.js's classifier uses the same parser.
- A dependency's foundry.toml, extends base and remappings.txt may be another
  dependency's file (by real path), as for sources: a remappings.txt linked
  into another dependency is read again.

Also:
- FOUNDRY_PROFILE is reported only when it names a profile of the root
  foundry.toml, and warned about otherwise, with or without --mapping.
- The directory-entry rules live in one place (directoryEntryError) for the
  CLI and buildBundle.

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

loadNestedConfig (a dependency's foundry.toml and its `extends` base) and
foundryLibs (the root foundry.toml under --mapping) caught every error and
went on with a warning. A TomlError now propagates: the bundle fails naming
the file and line, whosever the file is. forge quietly skips a dependency's
config it can't read; what can't be read is not left out silently here. A
config forge rejects for its settings (a missing `extends` base, nested
inheritance, a link out of the dependency) is still skipped with a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…endency's

A remappings.txt line or FOUNDRY_REMAPPINGS/DAPP_REMAPPINGS entry that isn't
`[context:]prefix=target` used to be skipped with a warning; it now throws,
naming the file (or variable) and line, as forge and solc refuse the file.
That holds for the root remappings.txt (Foundry or as written for solc), a
--mapping file and a dependency's remappings.txt.

A foundry.toml `remappings` value that isn't an array of such strings throws
too, naming the file: the project's, a --mapping one and a dependency's
(forge skips a dependency's config holding one; here it's an error, as for
one that isn't TOML). loadNestedConfig and foundryLibs now catch only
ConfigRefused -- the settings forge answers by skipping a dependency's config
(a missing or nested `extends`, colliding keys) and a link out of the
dependency -- and let every other error through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…ct config reading

Containment:
- The link-by-link walk is checked against the OS's realpath: where it can't
  resolve a path the OS can, or lands elsewhere, the path is refused instead of
  trusted. Link targets split on the OS's separators only (a `\` is part of a
  name on POSIX), and one that isn't UTF-8 is unresolved, not missing.
- A dependency's config is judged by its path from the root, lexical or else
  canonical (an absolute or /proc/self/cwd lib); one outside the root reads
  nothing, and a dir a dependency's `libs` names must be a dependency itself.
  Absolute libs count as dependency dirs by their real path.
- .gitmodules takes a key on its section header's line, as git does.
- A package.json that decides a file's package is refused when a dependency
  planted it as a link, and one that doesn't parse is an error (naming the
  file and position, never quoting it) instead of giving its files to the
  parent package. `stasis add` and --package-json keep walking past one.

Configs:
- foundry.toml, remappings.txt, --mapping files and .gitmodules that aren't
  UTF-8 are errors; a byte-order mark stays, and remappings.txt lines are
  trimmed as forge trims them (a BOM is part of the first remapping).
- src/test/script/libs/auto_detect_remappings/extends of the wrong type are
  errors naming the file, instead of quietly falling back to defaults.
- --manifests carries every config file the resolution read, whatever it's
  called (extends = "base.conf", --mapping=remaps); `.env` files and
  hardhat.config.* never.

One realpath helper (realpathOrNull, realpath(3)) for solidity.js, foundry.js
and the ownership walk.

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

No behavior change; one `.env` rule, see below.
- solidityOwnership's `of()` returns the refusal `reason`, so callers stop
  formatting escapes themselves (escapeReason is private). The walk
  realpaths only at links and once at the end, instead of every component;
  the dependency-dir list is built once, and the node_modules test is
  hasNodeModulesSegment.
- One readGitmodules for the ownership roots and the bundle classifier.
- One package.json reader (readPackageJson: check, read, parse or a
  content-free error) behind findPackageMetadata and the submodule
  classifier, which reads each submodule's once. buildSolidityBundle builds
  one ownership check and one per-directory package lookup, shared by
  bucketing (assembleCodeBundle's `packageOf`) and --manifests.
- --manifests' `.env` rule is stasis-core's isDotEnvFile (so `*.env` and any
  case are never carried either), plus hardhat.config.*.
- readMapping is synchronous; discoverSolidityConfig returns foundryProject's
  result as is; parseRemappingLines takes `{ label, emptyPath }`; one
  hasProfile rule; a dependency's remappings.txt is checked before it's read,
  as its foundry.toml is.
- Test loops that must run sequentially say so to the linter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…oes; --manifests fails on a config it can't carry

- The ownership walk refuses a path the OS can't resolve at all (a real
  path past PATH_MAX, ENAMETOOLONG) instead of reporting it as missing:
  a dependency's remappings.txt reached through such a chain is skipped
  with a warning rather than read, with or without --manifests.
- An `extends` path is joined as forge joins it, not normalized, so a
  `..` after a symlink leads where forge's does; the base is recorded by
  the real path of the file read.
- Every config file the resolution read must be carried by --manifests:
  one outside the bundle root (`../shared-base.toml`), a .env one
  (`base.env`, `.env.toml`, `Base.ENV`, `.env.local`), a refused one or
  one that is gone is an error naming it, not a silent skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
… naming and manifests

- One projectRelative (solidity-ownership.js) names the config files
  read, for foundry.js and solidity.js alike: as spelled when inside the
  project, by real path after a `..` or an absolute or /proc/self/cwd
  lib. A /proc/self/cwd lib's nested config was named `../../proc/...`
  and failed --manifests; an `extends` base through a symlinked dir is
  now carried under the path forge reads it by.
- The ownership walk already refuses a path the OS can't resolve, so a
  dependency's config read no longer needs existsSync pre-checks:
  nothing there is left for the read to find missing.
- ownership.assert replaces the refuse-and-throw copies (readableBy,
  the entry check).
- solidityManifests: one carry() for the required configs and the
  optional manifests, posixPathEscapes for the outside-root check.
- packageLookup serves --package-json and assembleCodeBundle's default
  too; isExtends uses isPlainObject; cargo.js's readFileOrNull is no
  longer exported (its other callers moved to readUtf8OrNull).

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

Blocking:
- A link whose end the OS can't name (/proc/self/fd/0 or /dev/stdin on
  an open pipe) counted as "nothing there", so a dependency's
  remappings.txt, foundry.toml or extends base linked to it was read:
  stasis's stdin became the config, and the bundle hung on an open pipe.
  A path is missing now only when nothing is there at all; otherwise an
  unresolvable one is refused. Config reads also open the file without
  blocking and read only a regular file, so a FIFO, socket or device
  (the project's own link to /dev/stdin included) is an error naming it.
- A config whose real path the OS can't give (past PATH_MAX) was named
  by its textually normalized path, so --manifests carried another file.
  It keeps the name it was read by and --manifests refuses it.
- With --mapping, the root foundry.toml and its extends base, read for
  the lib dirs, are recorded: --manifests carries them, or fails on one
  it can't carry.

Also:
- The ownership walk resolved a `..` in the path it was asked about
  textually for the OS cross-check: a dependency's extends through its
  own symlink (sub/../base.toml) was falsely refused.
- A package.json with a byte-order mark is read, as npm reads it,
  instead of aborting the bundle.
- Bash, Rust and JS bundles walk past a malformed package.json again;
  only Solidity's package lookup is strict.
- A refused dependency config says why (the ownership reason, not
  always "a link out of the dependency"), and config messages name
  files from the project root instead of by absolute path.
- --manifests tags only a package.json `json`; another config
  (--mapping=remaps.json) is a `resource`.

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

- readRegularFileOrNull moves to stasis-core's bundle-util, and
  readPackageJson reads through it: a package.json that is a FIFO no
  longer stalls Solidity's strict package lookup (it is an error naming
  it; the lenient lookups walk past it).
- --manifests reads a carried file by the real path ownership resolved
  it to, and its containment check comes from that same answer, instead
  of re-resolving the name with a second realpath.
- realpathOrNull no longer pays for the "nothing there" lstat it
  discards; the ownership walk runs that check only when it needs it.
- One `lexical` rule names a dependency's files for messages and for the
  files read; shownFrom takes the canonical root already computed; the
  `show` defaults that never ran are gone (loadFoundryConfig names from
  the root by default), labels are required, and names are computed once.

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

Our hand-written reader agreed with git where git reads a .gitmodules
one way, and picked an answer where it doesn't: a key twice (git's
submodule commands read the first, git config the last; we took the
last), a second section, `[submodule.x]`, merged or taken silently. It
also took a path outside the repository (`../x`) or out of normal form
(`./lib/x`, which the bucket classifier then didn't match).

The library reads it as git's config.c does and refuses each of those,
and a url that isn't a host's (one relative to the superproject's
remote, or none); the error names .gitmodules. Its paths are in normal
form, so the callers' trailing-slash and empty-path guards go.

The test running a copy of stasis without oxc-parser vendors
@exodus/bytes too, the library's dependency, which its foundry.js entry
needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…Urls: false)

@preventive/lockfile 1.0.0-alpha.4 (#193) gives parseGitmodules a
checkUrls option. stasis reads a url only to name a GitHub submodule's
bucket, so a url relative to the superproject's remote (`../x.git`), a
path, or none no longer fails every Solidity bundle of the project: the
submodule is still a dependency, just without a GitHub name. What git
reads two ways, a path out of the repository or normal form, and a url
git ignores (starting with `-`) or that isn't one are still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
… package lookup per JS bundle

- projectOwnership keeps the submodules it read (`ownership.submodules`),
  and the Solidity classifier takes them from there instead of reading
  and parsing .gitmodules a second time: ownership and bucket naming see
  one list.
- isSolidityEntry is the one "a .sol file or a directory entry" rule, for
  the CLI, classifyEntries and buildSolidityBundle.
- The JS bundler's --package-json pass and assembleCodeBundle share one
  packageLookup, instead of each walking the same directories.
- readPackageJson decides strict-or-null once; parseJson holds the
  content-free JSON error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…ead by; hardhat.config.* in any case

- When the OS can't give a config's real path (past PATH_MAX), its name
  kept the `..` it was read by only if the path started with `<root>/` as
  spelled; any other spelling of the root (`<root>/./sub/..`, a doubled
  `/`, the root's real path) fell back to the normalized name, and
  --manifests carried the root's base.toml where forge read another
  file. The root is now stripped component by component, as given or by
  its real path, keeping `..`; a path from neither stays absolute, and
  --manifests refuses it as unresolvable.
- neverCarried's hardhat.config.* rule ignores case, as the .env one
  does: an `extends = "HARDHAT.CONFIG.TOML"` is no longer carried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…h U+FFFD

Solidity sources (and a .sol.txt listing) were read with
readFile(…, 'utf8'), which turns a stray byte (Latin-1's \xe9) into
U+FFFD: the bundle held text that isn't the file's. They're read as
bytes now and decoded with @exodus/bytes' strict utf8toString
(decodeUtf8, a byte-order mark kept), as the config files and carried
manifests are too; bytes that aren't UTF-8 are an error naming the
file. A strict (Solidity) package.json lookup refuses one the same way;
the lenient ones decode as before. @exodus/bytes becomes a direct
dependency of stasis (stasis-core stays dependency-free).

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

Every Solidity bundle reads .gitmodules (Foundry, --mapping and Hardhat
mode alike), for the submodules' paths (ownership) and urls (naming), so
one entry the strict reader refuses stopped it, though git takes many of
them: `update = none`, an `active` key, a `[core]` or `[include]`
section, a tab in a value. main bundled all of these.

A file @preventive/lockfile refuses is now warned about and read a
`[submodule "name"]` section at a time: each submodule's first `path`,
`url` and `branch` (as git's submodule commands read them; a second
section of the name merged), each read by the library alone. A branch,
then a url, that still doesn't read is dropped with a warning, and a
submodule whose path doesn't (`./lib/x`, `../x`, a `[submodule.x]`) is
skipped with one. A file the library reads is read as before.

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

The lenient .gitmodules read skipped a submodule whose path the library
refuses (`./deps/x`, `deps/x/`, or under a `[submodule.x]` header), and
its directory became the project's own code: outside forge's libs,
a link planted in it to the project's .env was followed and the .env
carried as deps/x/src/Evil.sol.

It fails closed now: the path is read as git reads the value (quotes,
escapes, a comment, a line run on), and one that normalizes to a
directory inside the repository keeps that directory a dependency,
unnamed, with a warning; only a path outside it is skipped. A
`[submodule.x]` section is read as git reads it, `[submodule "x"]`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
The lenient reader for a .gitmodules the library refuses matched section
headers with a regex and dropped any it didn't match: `[submodule.deps/x]`,
`[submodule "deps/x"` with no `]`, `[submodule deps/x]`, or such a header
after a good section. The submodule's directory then became the project's
own code, so a planted link in it to .env was trusted and bundled.

The lenient path now reads the file the way git's config.c does, character
by character. It refuses exactly what git refuses ("bad config line"): a
malformed header, a key followed by something other than `=`, a value with
no closing quote, an unknown escape, or a stray character. Such a file is an
error that names its line. It fails closed and is no stricter than git. A
differential fuzz against `git config -f --list -z` (16,000 files) shows the
same files refused and the same keys and values read. The library refuses
every file git refuses, so the main path can't bypass the check.

The reader also replaces the regex scan's approximations. Sections whose
names differ only in escaping are merged, as git merges them. A `\` at the
end of a comment no longer runs onto the next line. `[submodule.x "y"]` is
read as submodule "x.y".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
…FS bundles do

Main (#194) threads a filesystem `host` through the Solidity loader so a
bundle can be built from soldeer.lock in a Vfs. This branch rewrote the
same code to decide ownership by real path, reading the disk directly.
This commit carries `host` through that code:

- The ownership walk reads links with `host.readlink`, lists directories
  with `host.readdir`, and checks its result against the host's realpath.
  On disk, the check still uses realpath(3), which gives the filesystem's
  own spelling.
- Config files, carried manifests, .gitmodules, and the `package.json`
  files that bucket a source are read through `host`.
- `readRegularFileOrNull` stats through the host and reads only a regular
  file. When the stat fails, a read says why, so a loop or an unsearchable
  directory is still an error rather than a missing file.
- `discoverSolidityConfig`, `collectSolidityFilesFromDisk` and
  `readRemappingsFile` are synchronous, as on main. `readPackageJson`
  decodes with main's `packageJSONText`.

The case-insensitive-filesystem test now emulates that filesystem with a
host instead of patching node:fs. A new test checks that a bundle read
through a Vfs host still refuses a dependency's link to the project's
.env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
@exo-nikita
exo-nikita force-pushed the claude/magical-davinci-bs3pob branch from 635f65a to 7ab19b9 Compare October 1, 2026 22:03
@ChALkeR
ChALkeR merged commit 3a70446 into main Oct 2, 2026
5 checks passed
exo-nikita pushed a commit that referenced this pull request Oct 2, 2026
buildGitHubBundle and suggestedEntries check their options against an
empty Vfs, before anything is fetched: "what the tree is never decides
them" (#197). #178's directoryEntryError then found every extensionless
entry missing from that empty tree. So `src` with pnpm was refused as "no
such file or directory" rather than "only JS bundles are built with pnpm",
and main's "buildGitHubBundle reads nothing from disk" test fails.

The pre-fetch checks now pass `fetched: false`, which skips the
missing-entry errors. buildVfsBundle still checks entries against the
project's own tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6oBS5QX4oqZcd2d3STiGA
ChALkeR pushed a commit that referenced this pull request Oct 2, 2026
…uote; green main after #178 (#201)

* fix(vfs-bundle): check entries before the fetch without looking for them

buildGitHubBundle and suggestedEntries check their options against an
empty Vfs, before anything is fetched: "what the tree is never decides
them" (#197). #178's directoryEntryError then found every extensionless
entry missing from that empty tree. So `src` with pnpm was refused as "no
such file or directory" rather than "only JS bundles are built with pnpm",
and main's "buildGitHubBundle reads nothing from disk" test fails.

The pre-fetch checks now pass `fetched: false`, which skips the
missing-entry errors. buildVfsBundle still checks entries against the
project's own tree.

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

* fix(bundle): a package.json that's there but can't be read is refused, as Node refuses it

host.stat returns null for any failure, as Node's module lookup does. So
every package.json reader took a link loop, or a file behind a directory
that may not be searched, for no package.json and walked past it:

- the strict Solidity reader, which bucketed a forge lib's files as the
  project's own;
- the resolver the Vfs host uses, and the Vfs host's findPackageJSON;
- State's walk up to a bucket's name, and its root discovery;
- readModuleManifest.

Node refuses such a package.json with ERR_INVALID_PACKAGE_CONFIG, and
counts only "nothing there" as none, a link to nothing included. Real stat
tells the two apart and host.stat doesn't. That's right for the module and
lib probes, which treat any failure as absent as Node and Rust do, so stat
stays as it is. When a package.json reader's stat fails, a read now says
why:

- statStrict, for configs and the strict package.json read, names the file
  from the project root (`lib/dep/package.json: can't be read (ELOOP)`).
  readRegularFileOrNull uses it too, instead of rethrowing the raw fs
  error with its absolute path.
- packageJSONStat throws Node's ERR_INVALID_PACKAGE_CONFIG, for the
  resolver, the Vfs host's findPackageJSON and State.

Lenient lookups (Bash, Rust, `stasis add`, --package-json) still walk
past a malformed or unreadable one. A differential against Node's
require.resolve and findPackageJSON now agrees on every case: missing,
dangling, a directory, malformed, a self-loop, a loop through
directories, and (unprivileged) a link into an unsearchable directory.

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

* fix(bundle): invalid-remapping errors name the entry, never its text

An invalid remapping's error quoted it. The text is normally a remapping
the project wrote, but a file named as a mapping by mistake can hold
anything: `--mapping=token.txt` printed `token.txt:1: invalid remapping
"ghp_…"` to stderr, and into CI logs with it. A token pasted into
FOUNDRY_REMAPPINGS did the same.

Errors now give the location and the form the entry should have:
`remappings.txt:2: invalid remapping, expected [context:]prefix=target`.
A foundry.toml entry is named by its position (`` `remappings` entry 2 ``,
`` `remappings` entry 1 is not a string ``). This is the last error of
stasis's own that quoted file content.

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

---------

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