Skip to content

Fix explicit bundleFile root selection in monorepo layouts#56

Merged
ChALkeR merged 1 commit into
mainfrom
claude/stasis-module-resolution-fix-89tewu
Jul 1, 2026
Merged

Fix explicit bundleFile root selection in monorepo layouts#56
ChALkeR merged 1 commit into
mainfrom
claude/stasis-module-resolution-fix-89tewu

Conversation

@exo-nikita

Copy link
Copy Markdown
Collaborator

Summary

Fix a critical bug where explicit --bundle-file flags or EXODUS_STASIS_BUNDLE_FILE environment 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

  • Suppress explicit bundleFile as a root-detection signal: Only the default <rootDir>/stasis.code.br counts toward root selection. Explicit bundleFile paths are excluded from the root-detection condition.
  • Load explicit bundleFile post-loop: After the discovery loop completes without finding a rootDir-dependent signal, load the explicit bundleFile against the outermost root (matching capture's behavior).
  • Extract bundle absorption logic: Refactor the unified code+resource bundle parsing into a shared #absorbCodeBundle() method used by both the in-loop and post-loop paths.
  • Mirror explicit lockFile handling: Apply the same pattern already used for explicit lockFile to explicit bundleFile and resourcesBundleFile.

Key Changes

  • Added explicitBundlePath variable to track construction-time bundleFile separately
  • Modified root-detection condition to use bundleProbe (null when explicit) instead of sources
  • Extracted bundle absorption logic into #absorbCodeBundle(sources, sourcesPath, lockfileLoaded) method
  • Added post-loop fallback block to load explicit bundleFile/resourcesBundleFile against the committed root
  • Added comprehensive regression test verifying root consistency with explicit bundleFile in monorepo layouts

Implementation 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

… 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
@exo-nikita exo-nikita force-pushed the claude/stasis-module-resolution-fix-89tewu branch from 79b5689 to 836db6b Compare July 1, 2026 14:56
@ChALkeR ChALkeR merged commit aaea74b into main Jul 1, 2026
5 checks passed
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
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