Fix explicit bundleFile root selection in monorepo layouts#56
Merged
Conversation
… monorepo `stasis run --bundle=load --bundle-file=<path>` crashed in a nested-package (monorepo) layout with `Cannot find module '/.../node_modules/<dep>/index.js'` (thrown from patchCjsResolution's Module._resolveFilename shim, hooks.js), or the sibling `stasis: file not attested in bundle` / `file is outside the project root`. Root cause is State's root discovery, not the CJS shim. An explicit `bundleFile` (a --bundle-file flag or EXODUS_STASIS_BUNDLE_FILE) is a rootDir-INDEPENDENT path: reading it yields the same file at every candidate root. It was used as a per-rootDir root-detection signal, so at load it matched the INNERMOST package.json dir and committed that as `state.root` -- while capture, run before the bundle existed (so with no such signal), committed the OUTER repo root. Because every bundle key is stored relative to the capture root, that divergence puts a dependency hoisted to <repo>/node_modules OUTSIDE the (leaf) load root, so the loader classifies it as non-bundled: it defers to Node, and Node's ESM->CJS translator re-resolves the dependency through Module._load(absolutePath, /* no parent */). That call falls through the shim's `typeof parent?.filename === 'string'` guard to native disk resolution and throws when node_modules was pruned / never shipped. Fix: treat an explicit bundleFile exactly like explicitLockPath -- suppress it as a per-rootDir selection signal (root is chosen from rootDir-DEPENDENT signals: stasis.config.json, the default lockfile/bundle at the dir), then load it at the committed root, or at the outermost root post-loop when nothing else committed. This keeps capture and load agreed on the root, so a hoisted dependency stays in-root and is served from the bundle (integrity preserved) instead of read from disk. A `bundleFile` that comes only from stasis.config.json is left as a signal: it lives at a dir already carrying the (rootDir-dependent) config signal, so it selects that dir consistently at capture and load alike. The shared bundle-absorption logic is extracted into #absorbCodeBundle for the in-loop and post-loop paths. Test: monorepo (.git + root package.json + packages/app with no stasis.config.json) with a CJS dependency hoisted to the repo-root node_modules, run from the leaf with an explicit --bundle-file; a bundle=add then bundle=load round-trip serves the dep from the bundle (verified by tampering the on-disk copy) instead of crashing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UenCGinFJrGDyt8E7XEftP
79b5689 to
836db6b
Compare
exo-nikita
pushed a commit
that referenced
this pull request
Jul 1, 2026
…sorb helpers Follow-up polish to #56, from its review findings. The State constructor carried the artifact-loading gates twice: once in the discovery loop (a committed rootDir) and once, copy-pasted, in the post-loop fallbacks for explicit (rootDir-independent) paths -- the lockfile pair was even flagged in-code ("a future refactor could merge the two by extracting a helper"), and #56 added a second such pair for the bundle side. A future change to mode gating edited only in the obvious in-loop branch would silently leave the explicit-path fallbacks -- exactly the paths #56 exists to protect -- on stale semantics. Extract both into shared helpers: - #absorbLockfile(lock, lockPath): the lock='none' guard + parse/populate + frozen warnings; returns lockfileLoaded. - #loadBundleArtifacts(sources, sourcesPath, lockfileLoaded): the bundle='none' and missing-lockfile guards, #absorbCodeBundle, and the split-resources branch. Two deliberate, commented semantic notes: - The in-loop missing-lockfile guard now tests `lockfileLoaded` (post-absorb) where it previously tested raw lockfile presence (pre-absorb). Equivalent whenever the guard can fire (useLockfile && !replaceLockfile), since #absorbLockfile absorbs the lockfile iff it exists; the only observable difference is error PRECEDENCE in doubly-broken setups (e.g. a corrupt lockfile now reports its parse error before a bundle='none' violation). - The post-loop fallback now also fires for an explicit resourcesBundleFile WITHOUT a bundleFile, absorbing the resources half -- the resources-only split shape #absorbResourcesBundle documents as legal. Previously the block was gated on explicitBundlePath alone and silently skipped it. Consistency fix: no CLI-reachable byte-serving change found (a resources bundle only carries workspace bytes in full scope, where a load needs the code bundle anyway -- which is itself a root signal). Also documents the post-loop root caveat surfaced in review: the outermost root depends on the upward-walk boundary (PROJECT_CWD, .git, pnpm-workspace.yaml), which capture and load must agree on. Test polish (same review): the monorepo regression test now pins the capture side (bundle exists + carries the hoisted dep under its monorepo-root-relative key) instead of only the near-tautological save stdout; its header comment covers both Node-version-dependent pre-fix symptoms (crash vs silent tampered-disk read); and cli.test.js's cleanEnv strips the remaining stasis env vars Config reads (RESOURCES_BUNDLE_FILE, RESOURCES, FS) so ambient values can't leak into spawned runs. No behavior change intended outside the two notes above; the full suite passes on Node 24.14 and 26.3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UenCGinFJrGDyt8E7XEftP
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
Fix a critical bug where explicit
--bundle-fileflags orEXODUS_STASIS_BUNDLE_FILEenvironment variables would incorrectly bias root detection toward the innermost package.json in monorepo layouts, causing bundle keys to become unreachable at load time.Problem
Explicit bundleFile paths are rootDir-independent (they resolve to the same file at every candidate root). When used as a root-detection signal, they would match the innermost package.json first, committing that as the load root. However, during capture (before the bundle existed), no such signal existed, so the outer repo root was committed instead. Since bundle keys are stored relative to the capture root, this divergence placed hoisted dependencies outside the load root, making them unreachable and causing "Cannot find module" errors when Node's ESM→CJS translator fell back to native disk resolution.
Solution
<rootDir>/stasis.code.brcounts toward root selection. Explicit bundleFile paths are excluded from the root-detection condition.#absorbCodeBundle()method used by both the in-loop and post-loop paths.Key Changes
explicitBundlePathvariable to track construction-time bundleFile separatelybundleProbe(null when explicit) instead ofsources#absorbCodeBundle(sources, sourcesPath, lockfileLoaded)methodImplementation Details
The fix ensures that root selection is driven only by rootDir-dependent signals (stasis.config.json, default lockfile/bundle), while explicit paths are loaded after root commitment. This maintains consistency between capture-time and load-time root selection, keeping bundle keys aligned with their storage location.
https://claude.ai/code/session_01UenCGinFJrGDyt8E7XEftP