Skip to content

feat(plugins)!: refuse watch/rebuild capture instead of silently attesting stale content#61

Open
exo-nikita wants to merge 1 commit into
mainfrom
claude/stasis-core-plugins-review-wxauv4-no-watch-capture
Open

feat(plugins)!: refuse watch/rebuild capture instead of silently attesting stale content#61
exo-nikita wants to merge 1 commit into
mainfrom
claude/stasis-core-plugins-review-wxauv4-no-watch-capture

Conversation

@exo-nikita

Copy link
Copy Markdown
Collaborator

The hazard

All three bundler plugins dedupe file capture with an instance-level #seen Set that is keyed by path and never cleared (and State.write's compare-and-skip caches are content-blind in the same way). On a watch / dev-server rebuild where a file's bytes change, #seen skips the re-capture: the emitted output uses the new bytes while the stasis bundle/lockfile silently keeps attesting the old ones. Verified empirically by driving the esbuild plugin callbacks across two builds — an attestation tool quietly attesting the wrong content.

The decision

Do not fix the staleness by tracking content changes across rebuilds. Instead, make watch/rebuild capture explicitly unsupported: each plugin refuses it loudly at the first rebuild signal, before anything stale can be recorded.

What now refuses, and where

  • webpack (stasis-plugins/src/webpack.js): capture mode taps compiler.hooks.watchRun and throws StasisWebpack: watch mode is not supported for capture .... watchRun fires only under compiler.watch() and before the first watch build, so the whole watch session is rejected up front (a sync tap's throw propagates through the AsyncSeriesHook and fails the watcher). The done hook's now-dead watching immediate-write arm is removed; single-compiler write-on-done and the MultiCompiler deferred flush are unchanged.
  • esbuild (stasis-plugins/src/esbuild.js): capture mode registers an onStart guard that fails the second build start the esbuild-idiomatic way — returned errors (StasisEsbuild: watch/rebuild is not supported for capture ...). Rebuilds re-fire onStart against the same setup registrations, and the counter lives on the plugin instance, so it also catches one instance reused across two esbuild.build() calls (same staleness hazard). onEnd's clean-build gate then skips the write.
  • metro (stasis-plugins/src/metro.js): the serializer throws on its second invocation (StasisMetro: watch/dev-server rebuilds are not supported for capture -- use a one-shot metro build``). A one-shot metro build serializes once; only a dev server (`metro start`) re-invokes it per rebuild, and Metro hands the serializer no watch signal on the first build, so the first rebuild is the earliest refusal point. The class-level KNOWN LIMITATIONS block now documents the enforced one-shot stance.

Deliberately untouched

  • Load mode (bundle=load) in all three plugins: it serves immutable attested bytes back into the build (getFile re-verifies hashes on every read), so rebuilds have no drift hazard — and Metro's load path (the companion worker transformer) legitimately runs under a dev server. The esbuild plugin registers no onStart in load mode.
  • stasis-core: State.write's compare-and-skip caches stay — they still serve the webpack MultiCompiler multi-done case.

Tests

  • tests/webpack-defer-write.test.js: the watch case now pins the refusal — watchRun throws and nothing writes on any path (immediate or deferred).
  • tests/plugins-rebuild-refusal.test.js (new, dep-free like the other stub suites): drives esbuild's setup() with stub build hooks (second onStart returns the error; a fresh setup() on a reused instance still refuses; load mode registers no guard) and metro's customSerializer with a minimal mock graph (second serialization throws before the base serializer runs).
  • Prose-only touch-ups where tests/comments described the old watch-capture flow (webpack-fs.test.js, state-write-skip.test.js, state-split-bundle.test.js). No README/doc promises watch capture, so no doc changes were needed.

Ran locally (Node 22, stub-driven, all green): plugins-rebuild-refusal, webpack-defer-write, webpack-fs, metro-fs, metro, metro-transformer, plugins, state-write-skip, state-split-bundle. The real-bundler suites (webpack*, esbuild*, cli/build-cmd) need installed devDependencies + Node 24 — covered by CI.

🤖 Generated with Claude Code

https://claude.ai/code/session_01M16Lwt8cM1azwev94UP81X


Generated by Claude Code

…sting stale content

All three bundler plugins dedupe file capture with an instance-level #seen
Set keyed by PATH and never cleared, and State.write's compare-and-skip
caches are content-blind in the same way. On a watch / dev-server REBUILD
where a file's bytes change, capture therefore skipped the re-read: the
emitted output used the new bytes while the stasis bundle/lockfile silently
kept attesting the OLD ones -- an attestation tool quietly attesting the
wrong thing.

Rather than track content changes across rebuilds, make watch/rebuild
capture explicitly unsupported and refuse it loudly at the first rebuild
signal:

- StasisWebpack taps compiler.hooks.watchRun and THROWS. watchRun fires
  only under compiler.watch() and BEFORE the first watch build, so the
  whole watch session is rejected before anything is captured (a sync
  tap's throw propagates through the AsyncSeriesHook and fails the
  watcher). The done hook's now-dead `watching` immediate-write arm is
  gone with it.
- StasisEsbuild registers an onStart guard (capture mode only) and fails
  the SECOND build start with returned errors -- rebuilds re-fire onStart
  against the same setup registrations, and the instance-level counter
  also catches one plugin instance reused across two esbuild.build()
  calls, which has the same staleness hazard.
- StasisMetro throws on the serializer's SECOND invocation: a one-shot
  `metro build` serializes once; only a dev server (`metro start`)
  re-invokes it per rebuild, and Metro hands the serializer no watch
  signal on the first build, so the first rebuild is the earliest
  refusal point.

Load mode is deliberately untouched: it serves immutable attested bytes
back into the build (getFile re-verifies hashes on every read), so
rebuilds have no drift hazard -- and Metro's load path (the companion
worker transformer) legitimately runs under a dev server. stasis-core is
untouched too: the compare-and-skip write caches still serve the webpack
MultiCompiler multi-`done` case.

Tests: webpack-defer-write's watch case now pins the watchRun refusal
(throw, no write on any path); new plugins-rebuild-refusal.test.js drives
esbuild's setup() with stub hooks (second onStart returns errors; a fresh
setup() on a reused instance still refuses; load mode registers no guard)
and metro's customSerializer with a minimal mock graph (the second
serialization throws before the base serializer runs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M16Lwt8cM1azwev94UP81X
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