Implement bundle command for Solidity files#37
Merged
Conversation
Adds a `stasis bundle` CLI command that takes a list of .sol entries
plus an optional --mapping=foundry.toml|remappings.txt and produces a
stasis Bundle (workspace bucket "."). The mapping file is parsed for
remappings but is not itself included in the bundle. The loader is
adapted from DeepView's solidity loader (with a link in the file
header), trimmed to produce only `{ sources, resolutions }`.
The bundle CLI now always brotli-compresses its output (matching the on-disk format of stasis.code.br) — both for `-o path` and stdout. The Solidity loader and bundle command modules are no longer in the package's exports map; the only public surface is `stasis bundle`.
There was a problem hiding this comment.
Pull request overview
This PR adds first-class Solidity bundling support to Stasis by introducing a stasis bundle CLI command plus a Solidity loader that discovers and resolves Solidity imports (including Foundry remappings) and emits a Stasis Bundle (JSON or brotli-compressed JSON).
Changes:
- Added
stasis bundleCLI flow that builds a synthetic workspace bundle from one or more.solentry files, optionally usingfoundry.toml/remappings.txtremappings. - Added
src/loaders/solidity.jsto parse imports/remappings, walk the filesystem, and produce{ sources, resolutions }. - Added comprehensive tests and fixtures covering remappings, missing imports, multiple entries, stdout/file output, and
.broutput.
Reviewed changes
Copilot reviewed 30 out of 31 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
bin/stasis.js |
Adds bundle command parsing and updates CLI usage text. |
package.json |
Exposes new ./loaders/solidity and ./cmd/bundle entrypoints and includes them in published files. |
src/cmd/bundle.js |
Implements buildSolidityBundle and bundleCommand (JSON/brotli output). |
src/loaders/solidity.js |
Introduces Solidity import parsing, remappings parsing, disk walking, and resolution mapping. |
tests/bundle-cmd.test.js |
Adds unit + integration tests for bundle building and CLI behavior including .br output. |
tests/solidity-loader.test.js |
Adds tests for Solidity loader behaviors (imports, remappings, missing imports, listings). |
tests/fixtures/solidity-bundle/basic/src/A.sol |
Fixture: basic relative import entry. |
tests/fixtures/solidity-bundle/basic/src/B.sol |
Fixture: basic imported library. |
tests/fixtures/solidity-bundle/listing-empty/list.sol.txt |
Fixture: empty listing file. |
tests/fixtures/solidity-bundle/listing-nonsol/list.sol.txt |
Fixture: listing containing a non-.sol line. |
tests/fixtures/solidity-bundle/listing-nonsol/src/A.sol |
Fixture: minimal Solidity source for nonsol-listing test. |
tests/fixtures/solidity-bundle/listing-toml/foundry.toml |
Fixture: TOML remappings for listing mode. |
tests/fixtures/solidity-bundle/listing-toml/lib/oz/X.sol |
Fixture: remapped library source for listing TOML mode. |
tests/fixtures/solidity-bundle/listing-toml/list.sol.txt |
Fixture: listing file with foundry.toml header. |
tests/fixtures/solidity-bundle/listing-toml/src/A.sol |
Fixture: entry importing remapped @oz/.... |
tests/fixtures/solidity-bundle/listing-txt/lib/oz/X.sol |
Fixture: remapped library source for listing TXT mode. |
tests/fixtures/solidity-bundle/listing-txt/list.sol.txt |
Fixture: listing file with remappings.txt header. |
tests/fixtures/solidity-bundle/listing-txt/remappings.txt |
Fixture: remappings file for listing TXT mode. |
tests/fixtures/solidity-bundle/listing-txt/src/A.sol |
Fixture: entry importing remapped @oz/.... |
tests/fixtures/solidity-bundle/missing/src/A.sol |
Fixture: missing import specifier case. |
tests/fixtures/solidity-bundle/non-relative-entry/src/A.sol |
Fixture: entry used for non-relative import edge case. |
tests/fixtures/solidity-bundle/non-relative-entry/src/B.sol |
Fixture: imports src/A.sol non-relatively. |
tests/fixtures/solidity-bundle/shared/src/A.sol |
Fixture: entry importing shared file. |
tests/fixtures/solidity-bundle/shared/src/B.sol |
Fixture: second entry importing shared file. |
tests/fixtures/solidity-bundle/shared/src/Shared.sol |
Fixture: shared imported library. |
tests/fixtures/solidity-bundle/with-foundry-toml/foundry.toml |
Fixture: Foundry TOML remappings. |
tests/fixtures/solidity-bundle/with-foundry-toml/lib/openzeppelin-contracts/contracts/utils/Math.sol |
Fixture: remapped OpenZeppelin-like library source. |
tests/fixtures/solidity-bundle/with-foundry-toml/src/A.sol |
Fixture: entry importing OpenZeppelin path. |
tests/fixtures/solidity-bundle/with-remappings-txt/lib/openzeppelin-contracts/contracts/utils/Math.sol |
Fixture: remapped OpenZeppelin-like library source. |
tests/fixtures/solidity-bundle/with-remappings-txt/remappings.txt |
Fixture: remappings.txt remapping definition. |
tests/fixtures/solidity-bundle/with-remappings-txt/src/A.sol |
Fixture: entry importing OpenZeppelin path. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+20
to
+26
| function normaliseEntries(entries, cwd) { | ||
| const baseDir = resolve(cwd) | ||
| return entries.map((e) => { | ||
| const abs = resolve(cwd, e) | ||
| const rel = relative(baseDir, abs).split(/[\\/]/u).join('/') | ||
| if (rel.startsWith('..')) throw new Error(`Entry escapes baseDir: ${e}`) | ||
| return rel.replace(/^\.\//u, '') |
Comment on lines
+20
to
+27
| function normaliseEntries(entries, cwd) { | ||
| const baseDir = resolve(cwd) | ||
| return entries.map((e) => { | ||
| const abs = resolve(cwd, e) | ||
| const rel = relative(baseDir, abs).split(/[\\/]/u).join('/') | ||
| if (rel.startsWith('..')) throw new Error(`Entry escapes baseDir: ${e}`) | ||
| return rel.replace(/^\.\//u, '') | ||
| }) |
Comment on lines
+123
to
+126
| toLoad.map(async (relPath) => [relPath, await readFile(join(baseDir, relPath), 'utf8')]) | ||
| ) | ||
| const next = [] | ||
| for (const [relPath, content] of reads) { |
Comment on lines
+55
to
+67
| if (specifier.startsWith('.')) { | ||
| const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : '' | ||
| const parts = [...(fromDir ? fromDir.split('/') : []), ...specifier.split('/')] | ||
| const resolved = [] | ||
| for (const part of parts) { | ||
| if (part === '.' || part === '') continue | ||
| if (part === '..') { | ||
| if (resolved.length > 0) resolved.pop() | ||
| } else { | ||
| resolved.push(part) | ||
| } | ||
| } | ||
| return resolved.join('/') |
Adds a stderr summary line after writing the bundle, showing the file
count, the outermost common parent directory of every bundled file
(expressed relative to cwd, which may yield ".." if a remapping pulls
files above cwd), and the output destination ("<stdout>" when no -o).
- normalize spelling (normaliseEntries → normalizeEntries) and reject paths whose path.relative() result is absolute, which catches the Windows-different-drive escape that doesn't show up as ".." - resolveSolImport returns null when a relative import's .. segments would traverse above the project root instead of silently clamping to root (which would change the imported file) - collectSolidityFilesFromDisk warns + skips ENOENT for a resolved path instead of aborting the entire walk; other read errors still propagate - loadSolidity rejects absolute paths and ..-escaping paths in a .sol.txt listing so a sloppy or hostile listing can't read arbitrary files outside its own directory
buildSolidityTree now returns a `missing` list alongside sources and resolutions. buildSolidityBundle aggregates that list (plus any entries that didn't load from disk) and throws before serializing, so the output file is never created when a bundle would be incomplete. The loader-level warn-and-skip behavior is preserved for diagnostic output; only the bundle command treats unresolved imports as fatal.
buildSolidityBundle now walks up from every loaded .sol file looking for the nearest package.json with name+version, and stamps the file into a Bundle bucket keyed by that package's directory. Files inside node_modules land in node_modules/<pkg> buckets (or deeper, when a nested package.json claims the file); workspace files land in their nearest workspace package.json's dir, falling back to "." with solidity-bundle@0.0.0 when no package.json is found. A node_modules file with no resolvable package.json fails the bundle loudly rather than silently leaking into the workspace bucket. The imports map's condition key is now "solidity" instead of "*" so solidity bundles don't collide with the wildcard used by JS bundles. Also: relax tests/fixtures glob in .gitignore so deeper fixture node_modules trees can be tracked.
The summary computed `files` from `bundle.modules.get('.').files`, which
only saw the workspace bucket and ignored every node_modules bucket
populated by the per-package bucketing. Use `bundle.sources` (which
flattens every bucket to project-relative paths) so the file count and
the outermost dir both reflect the whole bundle.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
stasis bundleCLI command that accepts a list of.solentry files and an optional--mapping=foundry.toml|remappings.txt, then produces a stasisBundlewritten to stdout (default) or-o path(brotli when the path ends in.br, JSON otherwise).src/loaders/solidity.js, adapted from DeepView'sloaders/solidity.js. Trimmed to produce only{ sources, resolutions }. The mapping file is parsed for remappings but not included insources.src/cmd/bundle.jswhich builds a Bundle (workspace bucket".") holding every loaded.solfile, tags each withformat: 'solidity', and stores resolutions under the wildcard"*"condition key inimports.Usage
Test plan
node --run test— 279 tests pass (40 new + 239 existing)node --run lint— cleanstasis bundle --mapping=remappings.txt src/A.solwrites a parseable Bundle JSON to stdoutstasis bundlewith no args prints usage and exits 1Bundle.parseCode(both.jsonand.broutputs)foundry.toml,remappings.txt) are NOT included insourceshttps://claude.ai/code/session_01YFi2BPkgGhtb2vXKZKL2it