feat(stasis-plugins): add Rollup plugin (StasisRollup) - #167
Merged
Conversation
Add a Rollup bundler plugin alongside the esbuild, webpack, and Metro ones,
exported as @exodus/stasis-plugins/rollup and re-exported as
@exodus/stasis/rollup.
Capture mode wraps the remaining resolveId chain (ctx.resolve with skipSelf)
to record the observed as-written edges, and serves attested raw bytes from
its load hook so the build consumes exactly what was recorded; resources are
attested and then deferred to the user's asset plugin. bundle=load resolves
every specifier from the bundle's import map and serves bytes through
getFile's fail-closed gate, deferring built-ins and unattested externals back
to rollup. Import attributes (with { type: 'json' }) round-trip through
addImport/getImport like the esbuild plugin's.
Because rollup asset plugins read their files from disk inside their own load
hook (no seam to hand them attested bytes, unlike esbuild's loader replay or
webpack's inputFileSystem), bundle=load refuses resource imports loudly
instead of letting them read unattested disk bytes.
Watch/rebuild capture is refused from the second buildStart (same
path-keyed-dedupe staleness hazard as the sibling plugins); load mode allows
rebuilds. Standalone/sidecar States are written on a clean buildEnd.
tests/rollup.test.js drives real rollup builds through the same spawned-helper
harness as the esbuild suite (lock/bundle modes, tamper + fail-closed cases, a
clean-dir byte-identical round-trip, nm scope with node_modules deleted,
plugin<->preload coordination rules, resources); the rebuild-refusal and
public-exports suites cover the new plugin too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHeFB5fiezsMo1CKGPMafQ
Correctness fixes to the StasisRollup plugin, each with a regression
test in the new tests/rollup-plugin.test.js:
- Refuse '?'/'#'-suffixed variants of on-disk module ids at capture
instead of silently skipping them: the underlying bytes feed the
build, so a silent skip left a build input invisible to frozen
verification (the esbuild plugin refuses its suffix analogue).
- Strip a warm `cache:` via the options hook (with a warning): cached
modules bypass resolveId/load, so a warm-cache capture recorded no
import edges and a warm-cache load served unverified code.
- Attest entries from the completed graph in buildEnd instead of
trusting resolveId's opts.isEntry, which rollup defaults to
!importer: a sibling's bare this.resolve() probe widened the
attested set of runnable roots at capture and aborted clean-bundle
loads. A resource entry now fails loudly ("a resource can't be an
entry"), matching StasisEsbuild, instead of silently writing an
entries list that omits the build's real entry.
- Anchor load-mode entry resolution against the importer when one is
given (emitFile'd chunks), falling back to cwd; assert entries in
the load hook from the graph's authoritative isEntry.
- Guard the load-mode resolver against an undefined importer
(sibling this.resolve(spec, undefined, { isEntry: false })) --
previously a TypeError failed the whole build.
- Gate the load-mode resource refusal on serving scope, so an
attested workspace resource under node_modules scope round-trips
(it is served from disk, like every workspace file in that scope).
- Move the import-attribute assert to the edge record/lookup sites,
so an attributed EXTERNAL passes through as it does under esbuild.
- Refuse out-of-root resolutions with a contextual error naming the
specifier/importer instead of State's bare path-less assertion,
and refuse a second constructor argument instead of silently
ignoring an esbuild-style { transform } bag.
- Document the load-mode moduleSideEffects KNOWN GAP (sibling
resolution metadata is not persisted in the bundle).
Also: dedupe the classify-throw block, destructure rollup 4's
guaranteed resolveId options, reuse getImport's format instead of a
second getFormat pass, drop the redundant existsSync stat before
every capture read, restore posixPathEscapes' allocation-free '..'
prefilter, and rebuild the artifact-graph builtin pin on oxc's AST
records (static + re-export + dynamic) so quoting or indentation
can't hide an edge from it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHeFB5fiezsMo1CKGPMafQ
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.
Adds a Rollup bundler plugin alongside the existing esbuild, webpack, and Metro ones, exported as
@exodus/stasis-plugins/rollupand re-exported as@exodus/stasis/rollup.Design
StasisRollupmirrorsStasisEsbuild's shape: same options handling viaresolvePluginState(preload reuse, sidecars, rules 0/1/7), same#seen-keyed capture dedupe, same watch/rebuild refusal, same fail-closed load-mode gates. Hooks are per-instance functions rather than prototype methods because rollup bindsthisto itsPluginContext(whose.resolvethe capture path needs); an inert plugin assigns no hooks at all, so rollup sees a name-only plugin.resolveIdwraps the remaining resolver chain (ctx.resolvewithskipSelf) to observe the final resolution, records the as-written edge viaaddImport, and passes the resolution through unchanged. Theloadhook attests raw bytes viaaddFileand returns them so the build consumes exactly what was recorded; resources are attested and then deferred to the user's asset plugin. Standalone/sidecar States write on a cleanbuildEnd.resolveIdanswers from the bundle's import map (the file needn't be on disk), deferring built-ins and unattested externals back to rollup; theloadhook serves any in-scope path throughgetFile's hash-verified, fail-closed gate — never a silent disk fallback. A clean dir holding only the bundle plus a minimalpackage.jsonrebuilds byte-identically.with { type: 'json' }edges round-trip throughaddImport/getImportkeyed the same way as the esbuild plugin's.loadhook, and load hooks don't chain, so there is no seam to hand them attested bytes (unlike esbuild's loader replay or webpack'sinputFileSystemwrap).bundle=loadtherefore refuses resource imports loudly instead of letting them read unattested (or missing) disk bytes; capture/frozen modes fully support resources.pluginsso it sees edges before sibling resolvers; file bytes still fail closed at the load hook regardless.Tests
tests/rollup.test.jsdrives real rollup builds (newrollupworkspace devDependency) through the same spawned-helper harness as the esbuild suite: all lock/bundle modes, tamper and fail-closed cases (dropped bytes, dropped edges), a clean-dir byte-identical round-trip, built-in/external deferral, import-attribute round-trip, node_modules scope including a load withnode_modulesdeleted, plugin↔preload coordination rules, options validation, resources allowlisting, and the load-mode resource refusal. The helper ships minimal stand-ins for@rollup/plugin-node-resolve/@rollup/plugin-json/asset plugins (rollup core resolves only relative/absolute paths and parses only JS), which also exercises the sibling-plugin chain. The rebuild-refusal and public-exports suites cover the new plugin too.node --run test: 1823 pass, 0 fail (+42 over baseline, ~+2s wall).node --run lint: clean.🤖 Generated with Claude Code
https://claude.ai/code/session_01LHeFB5fiezsMo1CKGPMafQ
Generated by Claude Code