Skip to content

feat(vfs-bundle): Solidity bundles from soldeer.lock; post-merge fixes to the vfs-bundle export - #194

Merged
ChALkeR merged 1 commit into
mainfrom
claude/stasis-pnpm-lib
Oct 1, 2026
Merged

ChALkeR merged 1 commit into
mainfrom
claude/stasis-pnpm-lib

Conversation

@exo-nikita

@exo-nikita exo-nikita commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #189: Soldeer support in @exodus/stasis/vfs-bundle, plus fixes from a post-merge review.

Soldeer

buildVfsBundle({ vfs, packageManager: 'soldeer', entries, mappingFile?, manifests?, env? }) builds a Solidity bundle from the project in the Vfs. buildGitHubBundle takes 'soldeer' too.

  • @preventive/deptree lays out the dependencies folder that soldeer install (0.12.0) makes from soldeer.lock. Each zip comes from Soldeer's registry, is checked against the lockfile's sha256, and is cached only where setCacheDir points.
  • The bundle is the one stasis bundle builds over that folder.
  • What the project holds as installed is never read: its own dependencies folder, and any node_modules (Soldeer installs none, so none can be trusted).
  • What soldeer install writes beside the folder is reproduced (vfs-bundle/soldeer.js):
    • the remappings.txt that Soldeer's update would leave is served from the tree in place of the project's;
    • a foundry.toml that Soldeer would edit is refused: "dependencies" missing from [profile.default] libs, or remappings kept in the config that aren't as it leaves them;
    • so is the rare remapping where only Rust's semver could decide whether Soldeer rewrites it.
    • These outputs were checked byte for byte against the real Soldeer 0.12.0 binary: a missing or stale file, CRLF lines, a prefix and version suffix, regeneration, and a lib/ target rewritten.
  • env supplies FOUNDRY_PROFILE / FOUNDRY_REMAPPINGS and defaults to {}; nothing is taken from process.env.
  • The result has no lockfile (Solidity bundles have none).
  • loadNodeModules stays limited to 'pnpm' and 'yarn1'.

To make this work:

  • The Solidity loaders (solidity.js, foundry.js) and buildSolidityBundle read the project through a host, the disk's by default.
  • The disk host's new optional readdirUnsorted keeps forge's unsorted read_dir order for remapping detection.
  • vfsHost serves whichever paths the tree installs (each project's node_modules, or dependencies plus the files written beside it), and hides a directory name a package manager doesn't reproduce.
  • Each package manager entry carries its bundle kind (js / sol) and the directory it installs into, which buildVfsBundle uses for its checks and dispatch.
  • The Soldeer root is the nearest foundry.toml or soldeer.toml, never above the git root, matching Soldeer's find_project_root.

deptree gap, not fixed here: Soldeer's registry zips for forge-std 1.9.0, 1.9.1 and 1.9.2 use absolute entry names (/LICENSE-APACHE, …).

  • Soldeer 0.12 installs them: zip 8.6's enclosed_name drops the root.
  • @preventive/deptree 1.0.0-alpha.5 still refuses them with its zip cannot be read: entry name "/LICENSE-APACHE" is absolute.
  • So projects on those forge-std versions can't be built here until deptree accepts a leading / the way the zip crate does.

Post-merge fixes

  • Install root, picked the way each package manager picks it:
    • pnpm: the workspace root (pnpm-workspace.yaml) wins over a nearer pnpm-lock.yaml.
    • yarn 1: the root whose workspaces include cwd's package; otherwise the nearest yarn.lock.
    • Before, the nearest lockfile always won, so a stray lockfile inside a workspace member got used.
    • lockfileRoot, which buildGitHubBundle uses to set the bundle's repo location, follows the same rule.
    • buildGitHubBundle downloads a directory alone only when its package manager installs it from there: no pnpm-workspace.yaml above it (pnpm), no package.json above it (yarn 1), or its own foundry.toml/soldeer.toml (Soldeer). Otherwise it downloads the whole repo.
  • Entry pre-check: buildVfsBundle checks entries relative to cwd. Before, an entry of a project under a node_modules directory skipped the check and only failed after the fetch.
  • Shared JS dispatch: buildBundle, bundleCommand and buildVfsBundle now share one JS dispatch. buildBundle and bundleCommand pass their env through it.
  • Error naming: pnpm-lock.yaml refusals now come from deptree, which names the file; the stasis-side pre-parse is gone. yarn.lock and soldeer.lock refusals are named in stasis.
  • Membership check: "none of the lockfile's projects" applies to the JS package managers only; Soldeer reads no package.json.
  • deptree's setCacheDir: the export is now deptree's own.
  • Vfs check: any object with a Vfs's methods is accepted, from any copy of @preventive/vfs; its errors are told apart by code.
  • Byte order mark: every package.json is read past a BOM, as Node reads it: by the resolver, State, packageType, findPackageMetadata and readJson, through one packageJSONText decoder in the zero-dependency core. Before, the resolver accepted such a manifest and State then crashed on it.
  • Node parity in the resolver: an empty main counts as none, and an encoded separator anywhere in an exports target's URL is refused. Node 24 does both; each is covered in the require.resolve parity test.
  • State walk: State's walk up to a package.json with a name now finds it by stat (files only) and never goes above its root, instead of calling findPackageJSON on a directory. A package.json that is no JSON object is refused with ERR_INVALID_PACKAGE_CONFIG. Before:
    • in a Vfs, a type-only /package.json looped forever, because findPackageJSON('/') kept returning /package.json;
    • on disk, findPackageJSON can return the directory itself.

Tests

  • tests/fixtures/soldeer-bundle holds a project that depends on stasis-sol-lib, a small package with its zip in registry/.
    • The project's soldeer.lock and remappings.txt were written by a real soldeer install (0.12.0), run against a stand-in registry API serving that zip; dependencies/ is the folder that install made.
    • A second install from the lockfile changed nothing.
    • The zip is seeded into the cache, so the tests fetch nothing; checked with fetch replaced by a throw.
  • tests/vfs-bundle-soldeer.test.js checks:
    • the tree against the real install (files, modes, bytes);
    • the bundle byte for byte against plain stasis bundle over it, with and without --manifests;
    • that a tampered dependencies folder and an installed node_modules are ignored;
    • the remappings.txt written, against real-install outputs, and the foundry.toml refusals;
    • that a checksum mismatch fails;
    • root discovery, refusals, and repo.
  • Each fix has a regression test, checked to fail without its fix. The State-walk test hangs without its fix.
  • oxlint is clean. The full suite passes locally on Node 24.14 (2129 pass, 3 pre-existing skips). CI needs no new install step.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KPSLumcb6Rdo4xXL5s5CpE

@exo-nikita
exo-nikita force-pushed the claude/stasis-pnpm-lib branch 4 times, most recently from d9d31f2 to ac44d89 Compare October 1, 2026 21:09
…oots as pnpm and yarn find them

`@exodus/stasis/vfs-bundle` takes `packageManager: 'soldeer'`: buildVfsBundle lays out the
dependencies folder `soldeer install` (0.12.0) makes from soldeer.lock with @preventive/deptree,
zips fetched from Soldeer's registry, checked against the lockfile's sha256 and cached only where
setCacheDir says, and builds the Solidity bundle `stasis bundle` builds over it, with `mappingFile`,
`manifests` and an `env` for FOUNDRY_PROFILE / FOUNDRY_REMAPPINGS (none from process.env). The
project's own dependencies folder is never read, nor any node_modules, which Soldeer installs none
of. What the install writes beside the folder is reproduced too: the remappings.txt Soldeer's
update would leave is served from the tree (checked against a real `soldeer install`), and a
foundry.toml it would edit (`dependencies` missing from libs, remappings kept in the config) is
refused. buildGitHubBundle takes 'soldeer' as well. loadNodeModules stays for 'pnpm' and 'yarn1'.

- The Solidity loaders (solidity.js, foundry.js) and buildSolidityBundle read the project through a
  host, the disk's by default; the disk host's readdirUnsorted keeps forge's unsorted read_dir
  order.
- vfsHost serves the directories a tree installs (each project's node_modules, or `dependencies`)
  rather than node_modules alone.
- The directory a project is installed from is the one its package manager takes: pnpm's
  workspace root (pnpm-workspace.yaml) over a nearer pnpm-lock.yaml; for yarn 1, the root whose
  `workspaces` include cwd's package, else the nearest yarn.lock; for Soldeer, the nearest
  foundry.toml or soldeer.toml, never above the git root. lockfileRoot (buildGitHubBundle's `repo`)
  follows the same rule, and buildGitHubBundle downloads a directory alone only when its package
  manager installs it from there (no pnpm-workspace.yaml, or for yarn 1 no package.json, above it).
- buildVfsBundle checks an entry against cwd, so one of a project under a node_modules directory is
  checked before anything is fetched.
- buildBundle, bundleCommand and buildVfsBundle share one JS dispatch; buildBundle and
  bundleCommand hand their `env` to it.
- pnpm-lock.yaml refusals are deptree's own, which name the file; yarn.lock and soldeer.lock
  refusals are named here. setCacheDir is deptree's. A Vfs is any object with a Vfs's methods,
  whatever copy of @preventive/vfs made it, and its errors are told by code.
- Every package.json is read past a byte order mark, as Node reads it: by the resolver, State,
  packageType, findPackageMetadata and readJson (packageJSONText, in the zero-dependency core).
  The resolver also takes an empty `main` for none, and refuses an encoded separator anywhere in an
  exports target's URL, as Node 24 does.
- The package.json membership check is the JS package managers' alone.
- State's walk up to a package.json with a name looks for it by stat and never above its root,
  rather than through findPackageJSON on a directory: on a Vfs, findPackageJSON('/') is
  /package.json again, and on disk it can be the directory itself. A package.json that is no JSON
  object is refused with ERR_INVALID_PACKAGE_CONFIG, as Node refuses it.

Tests: tests/fixtures/soldeer-bundle is a project whose soldeer.lock and remappings.txt, and the
dependencies folder beside it, were written by a real `soldeer install` of a package served as
Soldeer's registry serves it; its zip is seeded into the cache, so the tests fetch nothing. The
tree is checked against that install, and the bundle byte for byte against plain `stasis bundle`
over it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPSLumcb6Rdo4xXL5s5CpE
@exo-nikita
exo-nikita force-pushed the claude/stasis-pnpm-lib branch 2 times, most recently from ac44d89 to 0dee13b Compare October 1, 2026 21:40
@ChALkeR
ChALkeR merged commit 0dc8d9b into main Oct 1, 2026
5 checks passed
exo-nikita pushed a commit that referenced this pull request Oct 1, 2026
…option checks

Rebased onto #194 (Solidity from soldeer.lock), re-applied against its
reshaped tree.js and buildVfsBundle:

- createMetroResolver takes `host` and refuses one that isn't the disk
  (it loads the project's metro-resolver, which reads the disk).
- checkVfsOptions(name, pm, packageManager, options): buildVfsBundle's
  up-front refusals (the package manager's kind alone, no
  metroResolver, every classifyEntries check), which buildGitHubBundle
  now runs over an empty tree before downloading anything.
- `os`, `cpu` and `libc` for loadNodeModules, buildVfsBundle and
  buildGitHubBundle: the machine packages are matched against, this
  one's but for what is given; for another os, libc defaults to
  'unknown'. checkTarget at each entry point. pnpm takes all three,
  yarn 1 os/cpu, Soldeer os: what one matches nothing against changes
  nothing.
- A static test: the host-aware modules (scan, both resolvers, tsc's
  mapping, bundle-util, vfs-bundle/*, foundry.js) name node:fs in no
  form, and the pattern catches each form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C4wWtS6P5ji71GhZ56NZeh
exo-nikita pushed a commit that referenced this pull request Oct 1, 2026
…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
ChALkeR pushed a commit that referenced this pull request Oct 2, 2026
…option checks (#197)

Rebased onto #194 (Solidity from soldeer.lock), re-applied against its
reshaped tree.js and buildVfsBundle:

- createMetroResolver takes `host` and refuses one that isn't the disk
  (it loads the project's metro-resolver, which reads the disk).
- checkVfsOptions(name, pm, packageManager, options): buildVfsBundle's
  up-front refusals (the package manager's kind alone, no
  metroResolver, every classifyEntries check), which buildGitHubBundle
  now runs over an empty tree before downloading anything.
- `os`, `cpu` and `libc` for loadNodeModules, buildVfsBundle and
  buildGitHubBundle: the machine packages are matched against, this
  one's but for what is given; for another os, libc defaults to
  'unknown'. checkTarget at each entry point. pnpm takes all three,
  yarn 1 os/cpu, Soldeer os: what one matches nothing against changes
  nothing.
- A static test: the host-aware modules (scan, both resolvers, tsc's
  mapping, bundle-util, vfs-bundle/*, foundry.js) name node:fs in no
  form, and the pattern catches each form.


Claude-Session: https://claude.ai/code/session_01C4wWtS6P5ji71GhZ56NZeh

Co-authored-by: Claude <noreply@anthropic.com>
ChALkeR pushed a commit that referenced this pull request Oct 2, 2026
…valid configs are errors (#178)

* fix(bundle): Solidity loader -- decide file ownership by real path

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

* fix(bundle): close the remaining Solidity leaks

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

* fix(bundle): a foundry.toml that isn't TOML is an error, not a skipped 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

* fix(bundle): an invalid remapping is an error, the project's or a dependency'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

* fix(bundle): the ownership walk refuses what it can't vouch for; strict 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

* refactor(bundle): simplify the Solidity loader's ownership, config and 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

* fix(bundle): refuse what has no real path; resolve extends as forge does; --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

* refactor(bundle): simplify the Solidity loader's config reading, file 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

* test(bundle): expect the messages of main's TOML parser

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

* fix(bundle): never read stdin as a config; name and record every config 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

* refactor(bundle): one regular-file reader, carried files read where ownership 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

* refactor(bundle): read .gitmodules with @preventive/lockfile's parseGitmodules

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

* fix(bundle): take a submodule's url as written (parseGitmodules checkUrls: 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

* refactor(bundle): read .gitmodules once; one Solidity-entry rule; one 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

* fix(bundle): never carry a config under a normalized name it wasn't read 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

* fix(bundle): a .sol file that isn't UTF-8 is refused, not bundled with 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

* fix(bundle): a .gitmodules the library refuses warns, never fails the 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

* fix(bundle): a submodule whose .gitmodules path doesn't read stays a 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

* fix(bundle): a .gitmodules git refuses is an error, not read past

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

* refactor(bundle): read the Solidity project through host, as main's VFS 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

---------

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.

2 participants