Skip to content

feat(plugins): import tracking, options bag, and preload coordination#20

Open
exo-nikita wants to merge 2 commits into
mainfrom
claude/add-webpack-loader-functionality
Open

feat(plugins): import tracking, options bag, and preload coordination#20
exo-nikita wants to merge 2 commits into
mainfrom
claude/add-webpack-loader-functionality

Conversation

@exo-nikita

@exo-nikita exo-nikita commented May 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Three threads of work, now sitting as a single commit on top of post-restructure main.

1. StasisWebpack: record imports and entry status

Migrated to webpack's modern compiler.hooks.normalModuleFactory.tap / nmf.hooks.afterResolve.tap API. afterResolve derives isEntry = !issuer and records both state.addImport(parentURL, rawRequest, url) for every non-entry edge and state.addFile(url, { source, isEntry, isBinary }) once per file. Uses data.resourceResolveData.path (not data.resource) so query strings, inline loaders, data: URIs, and null-loader outputs don't crash. New fixtures (webpack-full, webpack-nm) and a spawning helper.

2. Both plugins accept a constructor options bag

new StasisWebpack({ lock, bundle, bundleFile, scope, debug }) and new StasisEsbuild({ ... }) accept the same keys as the CLI flags. Validation goes through the existing validatePluginOptions / assertOptionsMatchConfig helpers in stasis-core/config.

3. Plugin↔preload coordination rules

The plugin used to assert "Stasis preload is not active" at module import, which blocked the standalone use case entirely. We now codify a richer contract via a new resolvePluginState(label, options, cwd) helper in stasis-core/state:

# Scenario Behavior
1 Plugin asks for any lock other than none/ignore, no preload Hard throw — lockfile would silently miss the bundler's own deps
2 Plugin lock disagrees with preload lock Throw
3 Plugin lock agrees with preload Reuse the preload State (single, unified lockfile)
4 Preload has bundle on, plugin can't disable Throw on bundle: 'none' / 'ignore'
5 Plugin bundleFile unset or matches preload's Reuse the preload State
6 Plugin bundleFile differs from preload's Sidecar State — shares hashes/entries/modules with preload by reference (unified lockfile), has its own sources/formats/imports/resources and writes only its own bundle file
7 Plugin says lock: 'none' AND bundle: 'none' with no preload Noop — useful for env/flag-controlled builds

Sidecar State is enabled by a new parent: preload option on the State constructor (mutually exclusive with preload: true); write() on a sidecar emits the bundle but skips the lockfile (the preload owns it). Both plugins also wire compiler.hooks.done / build.onEnd to call state.write() for non-preload State (skipping on build failure to avoid clobbering).

Hardening that came out of review

  • Both plugins filter to isTrackedPath (js/mjs/cjs/ts/jsx/tsx/json/mts/cts) before addFile/addImport — untracked extensions (.css, .wasm, asset modules) compile but stay out of the lockfile, since the lockfile/loader semantics can't round-trip them.
  • StasisEsbuild now accepts the type import attribute (with { type: 'json' }), tolerates benign warnings from sibling plugins, and falls back to the default loader for unknown extensions instead of asserting.
  • resolvePluginState validates scope and debug against the preload (previously it silently dropped plugin values on the sidecar paths).

Test plan

  • node --run lint — clean
  • node --run test — 770/770 pass (excluding 26 pre-existing failures in popular-npm-modules.test.js, unrelated)
  • stasis/tests/state-sidecar.test.js — sidecar mechanics + rules 2/4/5/6 of resolvePluginState
  • stasis/tests/state-resolve-no-preload.test.js — rule 1 (hard throw across add/frozen/replace) + rule 7 (noop)
  • stasis/tests/state-resolve-nobundle-preload.test.js — preload-with-no-bundle reuse and sidecar paths (incl. bundle: 'ignore' regression)
  • stasis/tests/{webpack,esbuild}.test.js — env-driven, plugin-option, rule-1, rule-7, untracked-extension, failed-build-no-clobber, and rule-6 sidecar coverage end-to-end through the actual bundler harness
  • STASIS_TEST_PRELOAD=0 opts a test out of the helper's auto-preload to exercise standalone paths
  • STASIS_TEST_PRELOAD_OPTIONS lets a test point preload and plugin at different bundleFile paths (for the sidecar test)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a comprehensive node:test suite for the Stasis webpack plugin (lock add/frozen/replace/ignore, bundle add/replace, scope variants, config conflicts, tamper detection) along with two fixture projects, and modernizes src/webpack.js to use the hooks.tap() API with node:assert/strict, recording imports via state.addImport and tagging entries via addFile(..., { isEntry }).

Changes:

  • Rewrite src/webpack.js to use compiler.hooks.normalModuleFactory / nmf.hooks.afterResolve taps, track isEntry, and call state.addImport(parentURL, rawRequest, url).
  • Add tests/webpack.test.js and tests/webpack-run.helper.js running webpack in a child process with a fresh State per test.
  • Add webpack-full and webpack-nm fixtures (with committed stasis.config.json and stasis.lock.json).

Reviewed changes

Copilot reviewed 13 out of 17 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/webpack.js Switch from legacy plugin() callbacks to hooks.tap(), record imports and entry flag
tests/webpack.test.js New 300+ line test suite covering lock modes, bundle, tampering, scopes, conflicts
tests/webpack-run.helper.js Child-process helper that constructs State, runs webpack with the plugin, then writes state
tests/fixtures/webpack-full/* Full-scope fixture: entry, hello, package.json, committed config & lockfile, pnpm-workspace marker
tests/fixtures/webpack-nm/* node_modules-scope fixture: entry/helper, fake-cjs-pkg under node_modules, committed config & lockfile

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@exo-nikita exo-nikita force-pushed the claude/add-webpack-loader-functionality branch 7 times, most recently from 23c5b5f to a55e84a Compare May 18, 2026 16:46
@exo-nikita exo-nikita changed the title Add comprehensive webpack plugin tests and update plugin hooks feat(webpack): record imports + entry; accept plugin options on both bundlers May 18, 2026
@exo-nikita exo-nikita force-pushed the claude/add-webpack-loader-functionality branch from a55e84a to 344e72d Compare May 18, 2026 17:19
exo-nikita pushed a commit that referenced this pull request May 18, 2026
Implements the plugin↔preload rules described in PR #20:

  1. Plugin lockfile mode (other than 'none' / 'ignore') without an
     active preload is a hard throw; the lockfile would otherwise
     silently miss every dependency the bundler itself pulled in.
  2. With a preload, the plugin's lock must agree with the preload's.
  3. Lockfile is unified: when plugin and preload agree, the plugin
     reuses the preload State so one lockfile is written.
  4. With a preload that has bundle on, the plugin can't disable
     bundle and the bundle mode must match; only bundleFile may differ.
  5. Same bundleFile (or unspecified) reuses the preload.
  6. Different bundleFile constructs a sidecar State -- shares the
     preload's hashes/entries/modules by reference so lockfile coverage
     stays unified, but has its own sources/formats/imports/resources
     and writes only its own bundle file.
  7. lock='none' AND bundle='none' with no preload is a no-op plugin
     (no hooks, no State) -- useful for env/flag-controlled builds.

Surface:
- src/state.js: State constructor accepts { parent: preload } for
  sidecar mode (mutually exclusive with preload: true). Sidecar shares
  hashes/entries/modules by reference; only the bundleFile differs.
  write() on a sidecar skips the lockfile. New exported helper
  resolvePluginState(label, options, cwd) encodes all seven rules and
  is the single entry point for plugin constructors.
- src/{webpack,esbuild}.js: constructors delegate to
  resolvePluginState; no-op State means no hooks registered.
- tests/{webpack,esbuild}-run.helper.js: by default construct a
  preload State (simulating loader-active) with options mirrored from
  the plugin's. STASIS_TEST_PRELOAD=0 opts a test out of preload to
  exercise standalone / noop / hard-throw paths.
- tests/state-sidecar.test.js: 9 tests for sidecar mechanics + the
  rule 2/4/5/6 paths of resolvePluginState.
- tests/state-resolve-no-preload.test.js: rule 1 (hard throw on every
  non-ignore lockfile mode) + rule 7 (noop).
- tests/{webpack,esbuild}.test.js: rule 1 + rule 7 coverage through
  the full bundler harness.

275/275 tests pass, lint clean.
@exo-nikita exo-nikita changed the title feat(webpack): record imports + entry; accept plugin options on both bundlers feat(plugins): import tracking, options bag, and preload coordination May 18, 2026
Ports the entire PR-20 series onto the post-restructure monorepo layout
(stasis-core/ + stasis/), retaining the seven plugin-preload rules and
the test surface that pinned them down. Replaces the old class-plugin
shape that asserted "Stasis preload is not active" on import.

stasis-core/src/state.js:
- State constructor accepts { parent: preload } for sidecar mode
  (mutually exclusive with { preload: true }). Sidecar shares
  hashes/entries/modules by reference (unified lockfile coverage) but
  manages its own sources/formats/imports/resources and emits its own
  bundle file. write() on a sidecar skips the lockfile -- the parent
  owns it.
- New exports: TRACKED_EXTENSIONS (js/mjs/cjs/ts/jsx/tsx/json/mts/cts),
  isTrackedPath(filePath), and resolvePluginState(label, options, cwd).
  The resolver encodes the seven rules and is the single entry point
  for plugin constructors.

stasis/src/{webpack,esbuild}.js:
- Both plugins accept { lock, bundle, bundleFile, scope, debug } options
  with the same shape as the CLI flags, validated via
  validatePluginOptions.
- Constructors delegate to resolvePluginState; setup/apply short-
  circuits when state === null (rule 7 noop).
- StasisWebpack moves to the modern compiler.hooks.normalModuleFactory
  /afterResolve API (webpack 4+), records addImport edges with
  isEntry derived from issuer, and uses resourceResolveData.path
  (not data.resource) to avoid query/inline-loader/synthetic resources.
- Both plugins filter to isTrackedPath before addFile/addImport so
  untracked extensions (.css/.wasm/...) compile but stay out of the
  lockfile.
- Both register a build-end hook (compiler.hooks.done /
  build.onEnd) that calls state.write() only when
  state !== State.preload and the build had no errors -- so
  sidecar/standalone State output reaches disk without clobbering on
  a failed compile.
- StasisEsbuild also: accepts the `type` import attribute,
  tolerates warnings from sibling plugins, and falls back to the
  default loader for unknown extensions.

stasis/tests/:
- webpack-run.helper.js (new) and esbuild-run.helper.js mirror plugin
  options onto an auto-constructed preload by default. STASIS_TEST_
  PLUGIN_OPTIONS / STASIS_TEST_PRELOAD_OPTIONS / STASIS_TEST_PRELOAD=0
  let tests pick the coordination path under test.
- webpack.test.js (new) covers lock/bundle/scope basics + the plugin-
  option, rule-1/7, sidecar (rule 6), failed-build no-clobber, and
  untracked-extension coverage.
- esbuild.test.js gains parallel plugin-option / rule-1/7 / sidecar /
  no-clobber / untracked-extension / import-attribute tests.
- state-sidecar.test.js, state-resolve-no-preload.test.js,
  state-resolve-nobundle-preload.test.js cover the resolver and
  sidecar mechanics directly.
- New fixtures: tests/fixtures/webpack-full and webpack-nm mirroring
  the esbuild ones, adapted for webpack 4 + CJS entries.

All 52 new tests pass; 0 lint warnings.
@exo-nikita exo-nikita force-pushed the claude/add-webpack-loader-functionality branch from 37f716c to bc2685b Compare June 13, 2026 13:51
The plugins are tightly coupled to State / resolvePluginState /
isTrackedPath, so they belong alongside them in stasis-core. They
import nothing from esbuild/webpack at runtime (they receive the
compiler/build object), so stasis-core stays a zero-dependency runtime.

- Move stasis/src/{esbuild,webpack}.js -> stasis-core/src/, switching
  their State import from the @exodus/stasis-core/state package
  specifier to the local ./state.js.
- Add ./esbuild and ./webpack to stasis-core exports + files.
- stasis/src/{esbuild,webpack}.js become thin re-export adapters
  (export * from @exodus/stasis-core/{esbuild,webpack}), mirroring the
  existing loader adapter, so @exodus/stasis/{esbuild,webpack} keeps
  working.
- public-exports.test.js asserts the @exodus/stasis entry points
  re-export the identical stasis-core classes.

Tests stay in stasis/tests where the test runner and esbuild/webpack
devDeps live; they import the plugins through the stasis adapter, which
also exercises the public surface. Full suite unchanged: 744 pass, 26
pre-existing failures (network-dependent popular-npm-modules + cli
matrix), 0 lint warnings.
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