Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-build-thing/src/wasm.rs

//! **The wasm cell: a cargo build, plus the TYPED STAGES that turn its output into
//! a loadable bundle.**
//!
//! # The gap, measured
//!
//! `ArtifactKind::Wasm` is a cargo cell by construction — `validate_kind` requires a
//! `wasm32` target and the target rides `cargo_args`. That is correct and it is also
//! not enough, because **what cargo produces is not what a consumer loads**.
//! Measured on facett, 2026-09-06:
//!
//! ```text
//! cargo build -p facet-wrapped --target wasm32-unknown-unknown --release
//!   → facet_wrapped.wasm            11 647 012 B      ONE file
//!
//! what the wheel actually stages:
//!   → facet_wrapped_bg.wasm         10 116 976 B      a DIFFERENT, rewritten file
//!   → facet_wrapped.js                 107 555 B      glue cargo has never heard of
//!   → manifest.json                      4 682 B      printed by the crate itself
//! ```
//!
//! So chaining a plain cargo variant into a package cell with `from_variant` cannot
//! work: it would build an 11.6 MB file nothing consumes and then refuse for the
//! same three missing inputs, one step later and **with a success in between**.
//! facett's own declaration says so, and this module is what makes the sentence
//! stop being true.
//!
//! # Why these are TYPED stages and not a command
//!
//! The obvious shape is a `command = "…"` field, and it is the wrong road: a build
//! declaration with a command in it is a shell script with extra steps, and this
//! constellation spent 2026-09-06 removing shell scripts. There is no command field
//! here and `tests/wasm_stage.rs` asserts on the emitted TEXT that there is none.
//!
//! [`PackageFormat::Pip`](crate::package::PackageFormat) is the precedent. The forge
//! knows what pip packaging IS; it does not run a string. These are the same kind of
//! object:
//!
//! * [`BindgenStage`] — the output target is an ENUM, not a string; the outputs are
//!   DERIVED from the package name (`facet-wrapped` → `facet_wrapped.js` +
//!   `facet_wrapped_bg.wasm`), so the `_bg` split is something the stage KNOWS
//!   rather than something a human spells and can spell wrong; and the tool's
//!   VERSION is not declared at all — it comes from `Cargo.lock`
//!   ([`nornir_xtask_support::locked_wasm_bindgen_version`]), because a bindgen CLI
//!   that does not match the lock is the classic silent blank canvas.
//! * [`ComponentManifestStage`] — a cargo EXAMPLE name and the file its stdout is
//!   captured into. `example = "manifest"` is a cargo target name, the same kind of
//!   thing as `bin = "korp-server"` on a variant; the forge builds the argv.
//!
//! # Honest scope
//!
//! The declaration, its validation, the derived output names and the orchestration
//! are REAL and tested here with fakes — no toolchain, no network. RUNNING the
//! stages is the [`WasmStageRunner`] seam. The live leg
//! (`XtaskWasmStageRunner`, feature `wasm-stage`) drives
//! [`nornir_xtask_support`]'s `ensure_wasm_target` / `ensure_wasm_bindgen` /
//! `locked_wasm_bindgen_version` — reused, not re-derived.
//!
//! **It runs host-side, and that is a departure worth stating.** The stage could
//! have gone in a pinned container image the way pip packaging did. It does not,
//! for three reasons: the pin that matters is bindgen-vs-`Cargo.lock` and an image
//! would have to match the lock anyway, so the lock is the authority either way; the
//! wasm cell's own cargo build already runs on the host, so a containerised bindgen
//! would have to bind the host target directory in — and since edda `bd04bf2` that
//! directory is wherever `build.target-dir` says, not `<root>/target`; and putting
//! the tool in the pip image is a different road that was not the one ruled. The
//! seam is what makes a container leg a later drop-in rather than a rewrite.

use std::path::{Path, PathBuf};

use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};

/// **What shape of JS glue `wasm-bindgen` emits** — an enum, because a string here
/// would be a spelling nobody checks until the bundle does not load.
///
/// These are wasm-bindgen's own `--target` values. `Web` is what a browser
/// `<script type="module">` loads directly and is what every bundle in this
/// constellation uses today.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BindgenTarget {
    /// Native ES modules, loaded straight by a browser.
    #[default]
    Web,
    /// For a JS bundler (webpack/rollup) to consume.
    Bundler,
    /// A classic script that defines a global — no module system.
    NoModules,
    /// CommonJS, for node.
    Nodejs,
    /// Deno.
    Deno,
}

impl BindgenTarget {
    /// The `--target` value wasm-bindgen takes. The ONE place the spelling lives.
    pub fn as_str(self) -> &'static str {
        match self {
            BindgenTarget::Web => "web",
            BindgenTarget::Bundler => "bundler",
            BindgenTarget::NoModules => "no-modules",
            BindgenTarget::Nodejs => "nodejs",
            BindgenTarget::Deno => "deno",
        }
    }
}

/// The cdylib artifact rename cargo applies: `facet-wrapped` → `facet_wrapped`.
fn crate_stem(package: &str) -> String {
    package.replace('-', "_")
}

/// **The file stem of the module cargo writes for `variant`** — and so the stem
/// wasm-bindgen names its outputs after (`<stem>.js`, `<stem>_bg.wasm`; bindgen
/// keeps its INPUT's stem). An EXAMPLE and a BIN keep their names as written
/// (`facett-demo` → `facett-demo.wasm`); only a cdylib carries cargo's `-` → `_`
/// rename (`facet-wrapped` → `facet_wrapped.wasm`).
///
/// MEASURED 2026-09-29 (dwarf, facett `facett-demo-wasm`): with the stem always
/// `crate_stem(package)`, a bin row looked for `facett_demo.wasm` beside the
/// `facett-demo.wasm` cargo had just written and refused to bindgen it.
pub fn artifact_stem(variant: &crate::variant::BuildVariant) -> String {
    match (&variant.example, &variant.bin) {
        (Some(e), _) => e.clone(),
        (None, Some(b)) => b.clone(),
        (None, None) => crate_stem(variant.package.as_deref().unwrap_or(&variant.name)),
    }
}

/// **The wasm-bindgen stage.** Named fields, no command line.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct BindgenStage {
    /// The JS shape to emit.
    #[serde(default)]
    pub target: BindgenTarget,
    /// Emit `.d.ts` typings. Default `false` — every bundle here passes
    /// `--no-typescript`, and a `.d.ts` nobody consumes is a file in the release
    /// that no declaration accounts for.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub typescript: bool,
}

impl BindgenStage {
    /// The raw cargo artifact this stage CONSUMES — `<stem>.wasm`.
    ///
    /// A different file from [`wasm_file`](Self::wasm_file), and that difference is
    /// the whole reason this stage exists: if they were the same, chaining a plain
    /// cargo variant would have worked.
    pub fn cargo_wasm_file(&self, package: &str) -> String {
        format!("{}.wasm", crate_stem(package))
    }

    /// The rewritten wasm this stage PRODUCES — `<stem>_bg.wasm`.
    ///
    /// The `_bg` suffix is wasm-bindgen's, and it is derived here rather than
    /// declared so a recipe cannot spell it wrong — a misspelled output is a wheel
    /// that stages a file that was never written and loads nothing.
    pub fn wasm_file(&self, package: &str) -> String {
        format!("{}_bg.wasm", crate_stem(package))
    }

    /// The JS glue module this stage produces — `<stem>.js`.
    pub fn js_file(&self, package: &str) -> String {
        format!("{}.js", crate_stem(package))
    }

    /// Both produced filenames, in a stable order.
    pub fn outputs(&self, package: &str) -> Vec<String> {
        vec![self.wasm_file(package), self.js_file(package)]
    }
}

/// **The component-manifest stage** — the crate emitting a description OF ITSELF.
///
/// `facet-wrapped/examples/manifest.rs` is ten lines and prints
/// `Registry::with_builtins().manifest_json()` on stdout; a loader reads it to
/// enumerate components without instantiating the module first. It is a cargo
/// EXAMPLE, not a command: `example` names a cargo target the same way `bin` does on
/// a variant, and the forge builds the argv.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ComponentManifestStage {
    /// The cargo example (of the cell's own package) that prints the manifest.
    pub example: String,
    /// The file its stdout is captured into, under the cell's `out_dir`.
    pub file: String,
}

/// **A wasm cell** — `[[build.wasm]]`, claimed by the `kind = "wasm"` variant of the
/// SAME NAME (the iso/installer/kernel/package rule; there is no `wasm_ref`).
///
/// The cell is OPTIONAL and its absence is meaningful rather than a defect: every
/// `kind = "wasm"` variant that predates this row declares none, and for those the
/// kind means exactly what it always meant — a plain cargo wasm build. A cell says
/// "and then these typed stages".
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct WasmBuild {
    /// The cell handle — the same name as the variant.
    pub name: String,
    /// Repo-relative directory the bundle lands in. Rebuilt on every run, so a
    /// stale file from a previous shape cannot ride into a consumer.
    pub out_dir: String,
    /// The wasm-bindgen stage.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bindgen: Option<BindgenStage>,
    /// The component-manifest stage.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub manifest: Option<ComponentManifestStage>,
}

impl WasmBuild {
    /// The absolute bundle directory.
    pub fn out_dir(&self, repo_root: &Path) -> PathBuf {
        repo_root.join(&self.out_dir)
    }

    /// **Every file this cell produces, repo-relative** — what a consumer may
    /// declare as an input, and what a chain is checked against.
    ///
    /// `variant` supplies the package name the bindgen outputs are derived from; a
    /// variant with no `package` falls back to its own name, which is the same rule
    /// `cargo_args` uses.
    pub fn outputs(&self, variant: &crate::variant::BuildVariant) -> Vec<String> {
        let mut out = Vec::new();
        if self.bindgen.is_some() {
            // Named after the module bindgen is FED ([`artifact_stem`]): a cdylib's
            // `_` stem, a bin's or an example's name as written.
            let stem = artifact_stem(variant);
            for f in [format!("{stem}_bg.wasm"), format!("{stem}.js")] {
                out.push(format!("{}/{f}", self.out_dir));
            }
        }
        if let Some(m) = &self.manifest {
            out.push(format!("{}/{}", self.out_dir, m.file));
        }
        out
    }

    /// Shape-only validation.
    pub fn validate(&self) -> Result<()> {
        if self.name.trim().is_empty() {
            return Err(anyhow!("wasm build: empty name"));
        }
        if self.out_dir.trim().is_empty() {
            return Err(anyhow!(
                "wasm '{}': `out_dir` is REQUIRED — it is where the bundle lands and \
                 what a consumer declares as its inputs",
                self.name
            ));
        }
        if Path::new(&self.out_dir).is_absolute() {
            return Err(anyhow!(
                "wasm '{}': `out_dir` {:?} is absolute — it must be repo-relative, so \
                 the same declaration means the same directory in every checkout",
                self.name,
                self.out_dir
            ));
        }
        if let Some(m) = &self.manifest {
            if m.example.trim().is_empty() {
                return Err(anyhow!(
                    "wasm '{}': the manifest stage names no `example` — it runs a cargo \
                     example of this cell's own package and captures its stdout, and \
                     there is no target to run",
                    self.name
                ));
            }
            if m.file.trim().is_empty() {
                return Err(anyhow!(
                    "wasm '{}': the manifest stage names no `file` to capture into",
                    self.name
                ));
            }
        }
        if self.bindgen.is_none() && self.manifest.is_none() {
            return Err(anyhow!(
                "wasm '{}': the cell declares no stage, so it produces nothing beyond \
                 the raw cargo artifact — and a cell that promises a product it does \
                 not make is exactly the defect this kind exists to end. Either \
                 declare a stage, or drop the cell and let the variant be the plain \
                 cargo wasm build it already is",
                self.name
            ));
        }
        Ok(())
    }

    /// The canonical byte string this cell's content id is taken over — every axis
    /// that changes the produced BUNDLE.
    pub fn canonical_bytes(&self) -> Vec<u8> {
        let mut s = format!("wasm#name:{}#out:{}", self.name, self.out_dir);
        if let Some(b) = &self.bindgen {
            s.push_str(&format!("#bindgen:{}#ts:{}", b.target.as_str(), b.typescript));
        }
        if let Some(m) = &self.manifest {
            s.push_str(&format!("#manifest:{}:{}", m.example, m.file));
        }
        s.into_bytes()
    }

    /// The content id of this declaration.
    pub fn content_id(&self) -> String {
        nornir_hash::sha256_hex(&self.canonical_bytes())
    }
}

/// **What one stage needs run** — as DATA, so a test can assert on it without a
/// toolchain, and so there is exactly one place the argv is built.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WasmStageRun {
    /// Run wasm-bindgen over the cargo artifact.
    Bindgen {
        /// The raw `<stem>.wasm` cargo wrote.
        input: PathBuf,
        /// The bundle directory.
        out_dir: PathBuf,
        /// The `--target` value.
        target: &'static str,
        /// Emit typings.
        typescript: bool,
    },
    /// Run a cargo example and capture its stdout into a file.
    ComponentManifest {
        /// The package the example belongs to.
        package: String,
        /// The example's cargo target name.
        example: String,
        /// Where stdout is written.
        out_file: PathBuf,
    },
}

/// **The seam the toolchain fills.** Two methods, so a test injects a fake and
/// proves the orchestration and every red arm with no wasm target installed.
pub trait WasmStageRunner {
    /// One line naming what runs the stages — recorded in the build log.
    fn describe(&self) -> String;
    /// Run one stage.
    fn run(&self, run: &WasmStageRun, repo_root: &Path) -> Result<()>;
}

/// **Drive a cell's stages, in order, and return the files it produced.**
///
/// The order is bindgen then manifest, and it is not arbitrary: the manifest stage
/// writes INTO the bundle directory that bindgen populates, and bindgen's own
/// `--out-dir` is rebuilt fresh on every run (a stale `.js` from a previous shape
/// is exactly the sort of thing that loads and misbehaves). Running the manifest
/// first would have it deleted.
pub fn run_stages(
    cell: &WasmBuild,
    variant: &crate::variant::BuildVariant,
    cargo_wasm: &Path,
    repo_root: &Path,
    runner: &dyn WasmStageRunner,
) -> Result<Vec<PathBuf>> {
    cell.validate()?;
    let package = variant.package.as_deref().unwrap_or(&variant.name).to_string();
    let out_dir = cell.out_dir(repo_root);

    if let Some(b) = &cell.bindgen {
        if !cargo_wasm.is_file() {
            return Err(anyhow!(
                "wasm '{}': the cargo artifact {} is not here — the cell's stages run \
                 AFTER the cargo build of this variant, and there is nothing to \
                 bindgen",
                cell.name,
                cargo_wasm.display()
            ));
        }
        // Fresh, so a file from a previous shape cannot survive into the bundle.
        if out_dir.exists() {
            std::fs::remove_dir_all(&out_dir)
                .map_err(|e| anyhow!("clear {}: {e}", out_dir.display()))?;
        }
        std::fs::create_dir_all(&out_dir)
            .map_err(|e| anyhow!("create {}: {e}", out_dir.display()))?;
        runner.run(
            &WasmStageRun::Bindgen {
                input: cargo_wasm.to_path_buf(),
                out_dir: out_dir.clone(),
                target: b.target.as_str(),
                typescript: b.typescript,
            },
            repo_root,
        )?;
    }

    if let Some(m) = &cell.manifest {
        std::fs::create_dir_all(&out_dir)
            .map_err(|e| anyhow!("create {}: {e}", out_dir.display()))?;
        runner.run(
            &WasmStageRun::ComponentManifest {
                package: package.clone(),
                example: m.example.clone(),
                out_file: out_dir.join(&m.file),
            },
            repo_root,
        )?;
    }

    // READ BACK. "The tool exited 0" is not evidence a file exists, and a bundle
    // missing a member is a consumer that stages a file nobody wrote.
    let mut produced = Vec::new();
    let mut missing = Vec::new();
    for rel in cell.outputs(variant) {
        let p = repo_root.join(&rel);
        match std::fs::metadata(&p) {
            Ok(md) if md.len() > 0 => produced.push(p),
            Ok(_) => missing.push(format!("{rel} (0 bytes)")),
            Err(_) => missing.push(rel),
        }
    }
    if !missing.is_empty() {
        return Err(anyhow!(
            "wasm '{}': the stages exited 0 but the bundle is missing {} — a consumer \
             declaring these as inputs would stage a file nobody wrote",
            cell.name,
            missing.join(", ")
        ));
    }
    Ok(produced)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::variant::{ArtifactKind, BuildVariant};
    use std::cell::RefCell;

    fn a_cell() -> WasmBuild {
        WasmBuild {
            name: "facet-wrapped-wasm".into(),
            out_dir: "facet-wrapped/dist".into(),
            bindgen: Some(BindgenStage { target: BindgenTarget::Web, typescript: false }),
            manifest: Some(ComponentManifestStage {
                example: "manifest".into(),
                file: "manifest.json".into(),
            }),
        }
    }

    fn a_variant() -> BuildVariant {
        BuildVariant {
            name: "facet-wrapped-wasm".into(),
            kind: ArtifactKind::Wasm,
            package: Some("facet-wrapped".into()),
            target: Some("wasm32-unknown-unknown".into()),
            ..BuildVariant::default()
        }
    }

    fn scratch(tag: &str) -> PathBuf {
        let base = std::env::var("CARGO_TARGET_TMPDIR")
            .map(PathBuf::from)
            .unwrap_or_else(|_| std::env::temp_dir());
        let d = base.join(format!("wasmstage-{tag}"));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        d
    }

    /// A runner that records what it was asked to do and writes what the real tools
    /// would have written.
    struct FakeRunner {
        seen: RefCell<Vec<WasmStageRun>>,
        write: bool,
    }

    impl WasmStageRunner for FakeRunner {
        fn describe(&self) -> String {
            "a fake".into()
        }
        fn run(&self, run: &WasmStageRun, _root: &Path) -> Result<()> {
            self.seen.borrow_mut().push(run.clone());
            if !self.write {
                return Ok(());
            }
            match run {
                WasmStageRun::Bindgen { out_dir, .. } => {
                    std::fs::write(out_dir.join("facet_wrapped_bg.wasm"), b"\0asm\x01\0\0\0")?;
                    std::fs::write(out_dir.join("facet_wrapped.js"), b"export default 1;")?;
                }
                WasmStageRun::ComponentManifest { out_file, .. } => {
                    std::fs::write(out_file, b"{\"components\":[]}")?;
                }
            }
            Ok(())
        }
    }

    /// The happy path, and every fact about the run a reader would want asserted.
    #[test]
    fn the_stages_run_in_order_and_the_bundle_is_read_back() {
        let root = scratch("run");
        let cargo_wasm = root.join("facet_wrapped.wasm");
        std::fs::write(&cargo_wasm, b"\0asm raw").unwrap();
        let runner = FakeRunner { seen: RefCell::new(Vec::new()), write: true };

        let produced =
            run_stages(&a_cell(), &a_variant(), &cargo_wasm, &root, &runner).expect("runs");

        let names: Vec<String> = produced
            .iter()
            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
            .collect();
        assert_eq!(names, vec!["facet_wrapped_bg.wasm", "facet_wrapped.js", "manifest.json"]);

        let seen = runner.seen.borrow();
        assert_eq!(seen.len(), 2, "both stages must run");
        // BINDGEN FIRST — the manifest writes into the directory bindgen rebuilds,
        // so the other order deletes it.
        match &seen[0] {
            WasmStageRun::Bindgen { input, target, typescript, .. } => {
                assert_eq!(input, &cargo_wasm);
                assert_eq!(*target, "web");
                assert!(!typescript);
            }
            other => panic!("bindgen did not run first: {other:?}"),
        }
        match &seen[1] {
            WasmStageRun::ComponentManifest { package, example, out_file } => {
                assert_eq!(package, "facet-wrapped");
                assert_eq!(example, "manifest");
                assert!(out_file.ends_with("facet-wrapped/dist/manifest.json"));
            }
            other => panic!("the manifest stage did not run second: {other:?}"),
        }
    }

    /// **The read-back is not decorative.** A runner that exits 0 and writes nothing
    /// is the exact false success this whole lane keeps finding.
    #[test]
    fn a_stage_that_exits_zero_and_writes_nothing_is_refused_by_name() {
        let root = scratch("nothing");
        let cargo_wasm = root.join("facet_wrapped.wasm");
        std::fs::write(&cargo_wasm, b"\0asm raw").unwrap();
        let runner = FakeRunner { seen: RefCell::new(Vec::new()), write: false };

        let e = format!(
            "{:#}",
            run_stages(&a_cell(), &a_variant(), &cargo_wasm, &root, &runner)
                .expect_err("must refuse")
        );
        assert!(e.contains("exited 0"), "{e}");
        for f in ["facet_wrapped_bg.wasm", "facet_wrapped.js", "manifest.json"] {
            assert!(e.contains(f), "the refusal does not name {f}: {e}");
        }
    }

    /// A 0-byte output is a missing output. bindgen writing an empty `.wasm` and the
    /// wheel packaging it is how a green build ships a blank canvas.
    #[test]
    fn a_zero_byte_output_is_a_missing_output() {
        struct Empty;
        impl WasmStageRunner for Empty {
            fn describe(&self) -> String {
                "empty".into()
            }
            fn run(&self, run: &WasmStageRun, _r: &Path) -> Result<()> {
                if let WasmStageRun::Bindgen { out_dir, .. } = run {
                    std::fs::write(out_dir.join("facet_wrapped_bg.wasm"), b"")?;
                    std::fs::write(out_dir.join("facet_wrapped.js"), b"x")?;
                }
                if let WasmStageRun::ComponentManifest { out_file, .. } = run {
                    std::fs::write(out_file, b"{}")?;
                }
                Ok(())
            }
        }
        let root = scratch("zero");
        let cargo_wasm = root.join("facet_wrapped.wasm");
        std::fs::write(&cargo_wasm, b"raw").unwrap();
        let e = format!(
            "{:#}",
            run_stages(&a_cell(), &a_variant(), &cargo_wasm, &root, &Empty).expect_err("refuses")
        );
        assert!(e.contains("0 bytes"), "{e}");
    }

    /// The stages run AFTER the cargo build, and say so when it has not happened.
    #[test]
    fn a_missing_cargo_artifact_is_refused_before_any_tool_runs() {
        let root = scratch("nocargo");
        let runner = FakeRunner { seen: RefCell::new(Vec::new()), write: true };
        let e = format!(
            "{:#}",
            run_stages(&a_cell(), &a_variant(), &root.join("nope.wasm"), &root, &runner)
                .expect_err("must refuse")
        );
        assert!(e.contains("nothing to bindgen"), "{e}");
        assert!(runner.seen.borrow().is_empty(), "a tool ran anyway");
    }

    /// The bundle directory is REBUILT, so a file from a previous shape cannot
    /// survive into a consumer.
    #[test]
    fn a_stale_file_from_a_previous_shape_does_not_survive() {
        let root = scratch("stale");
        let cargo_wasm = root.join("facet_wrapped.wasm");
        std::fs::write(&cargo_wasm, b"raw").unwrap();
        let dist = root.join("facet-wrapped/dist");
        std::fs::create_dir_all(&dist).unwrap();
        std::fs::write(dist.join("old_shape.js"), b"stale").unwrap();

        let runner = FakeRunner { seen: RefCell::new(Vec::new()), write: true };
        run_stages(&a_cell(), &a_variant(), &cargo_wasm, &root, &runner).expect("runs");
        assert!(
            !dist.join("old_shape.js").exists(),
            "a file from a previous bundle shape survived into this one"
        );
    }

    /// The content id moves with every axis that changes the bundle.
    #[test]
    fn the_content_id_moves_with_what_changes_the_bundle() {
        let base = a_cell();
        let id = base.content_id();
        for mutate in [
            (|c: &mut WasmBuild| c.out_dir = "elsewhere".into()) as fn(&mut WasmBuild),
            |c: &mut WasmBuild| c.bindgen.as_mut().unwrap().target = BindgenTarget::Bundler,
            |c: &mut WasmBuild| c.bindgen.as_mut().unwrap().typescript = true,
            |c: &mut WasmBuild| c.manifest.as_mut().unwrap().example = "other".into(),
            |c: &mut WasmBuild| c.manifest = None,
        ] {
            let mut m = base.clone();
            mutate(&mut m);
            assert_ne!(m.content_id(), id, "a changed axis left the content id identical");
        }
        assert_eq!(base.clone().content_id(), id);
    }

    /// Every bindgen target spells itself, and the spelling round-trips through
    /// serde — a `--target` value that serialised differently from what the tool
    /// takes is a bundle built in the wrong shape.
    #[test]
    fn every_bindgen_target_spells_itself_and_round_trips() {
        for (t, s) in [
            (BindgenTarget::Web, "web"),
            (BindgenTarget::Bundler, "bundler"),
            (BindgenTarget::NoModules, "no-modules"),
            (BindgenTarget::Nodejs, "nodejs"),
            (BindgenTarget::Deno, "deno"),
        ] {
            assert_eq!(t.as_str(), s);
            let json = serde_json::to_string(&t).unwrap();
            assert_eq!(json, format!("\"{s}\""), "the serde spelling is not the tool's");
            assert_eq!(serde_json::from_str::<BindgenTarget>(&json).unwrap(), t);
        }
    }

    /// The module cargo writes, and the names bindgen gives its outputs, follow the
    /// TARGET kind: cdylib `_`, bin as written, example as written under
    /// `examples/`. RED-when-broken: the bin case is the facett-demo-wasm defect
    /// (looked for `facett_demo.wasm` beside cargo's `facett-demo.wasm`).
    #[test]
    fn the_stem_follows_the_cargo_target() {
        let t = Path::new("/t");
        let lib = BuildVariant { profile: crate::variant::Profile::Release, ..a_variant() };
        assert_eq!(artifact_stem(&lib), "facet_wrapped");
        assert_eq!(
            cargo_wasm_path(&lib, t),
            PathBuf::from("/t/wasm32-unknown-unknown/release/facet_wrapped.wasm")
        );
        let bin = BuildVariant { bin: Some("facett-demo".into()), package: Some("facett-demo".into()), ..lib.clone() };
        assert_eq!(cargo_wasm_path(&bin, t), PathBuf::from("/t/wasm32-unknown-unknown/release/facett-demo.wasm"));
        let cell = WasmBuild { manifest: None, ..a_cell() };
        assert_eq!(
            cell.outputs(&bin),
            vec!["facet-wrapped/dist/facett-demo_bg.wasm".to_string(), "facet-wrapped/dist/facett-demo.js".to_string()]
        );
        let ex = BuildVariant {
            example: Some("panel".into()),
            package: Some("facett-wasm-demo".into()),
            ..lib.clone()
        };
        assert_eq!(
            cargo_wasm_path(&ex, t),
            PathBuf::from("/t/wasm32-unknown-unknown/release/examples/panel.wasm")
        );
        assert_eq!(
            cell.outputs(&ex),
            vec!["facet-wrapped/dist/panel_bg.wasm".to_string(), "facet-wrapped/dist/panel.js".to_string()]
        );
        // The cdylib outputs are unchanged by the stem rule.
        assert_eq!(
            a_cell().outputs(&lib),
            vec![
                "facet-wrapped/dist/facet_wrapped_bg.wasm".to_string(),
                "facet-wrapped/dist/facet_wrapped.js".to_string(),
                "facet-wrapped/dist/manifest.json".to_string()
            ]
        );
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// THE LIVE LEG — feature `wasm-stage`
// ─────────────────────────────────────────────────────────────────────────────

/// **Where cargo wrote the raw artifact these stages consume.**
///
/// `<target-dir>/<triple>/<profile-dir>/<stem>.wasm`, and every part of that is a
/// place to get it wrong, which is why it is computed HERE and not by each caller:
///
/// * the target directory is asked of CARGO
///   ([`nornir_xtask_support::target_dir`], edda `bd04bf2`) rather than assumed to
///   be `<root>/target`. The version that assumed reported a wasm it had just built
///   as MISSING on any box with `build.target-dir` set — oden has
///   `~/cargo-target` — and a tool that cannot find what it just
///   produced teaches everyone who meets it to stop using the tool. Two lanes
///   hand-built on 2026-09-06 partly because of it;
/// * `dev` builds land in `debug`, not in `dev`;
/// * the stem is [`artifact_stem`]: cargo's cdylib `-` → `_` rename, a bin's or an
///   example's name as written (an example under `examples/`).
#[cfg(feature = "wasm-stage")]
pub fn cargo_artifact(variant: &crate::variant::BuildVariant, repo_root: &Path) -> PathBuf {
    cargo_wasm_path(variant, &nornir_xtask_support::target_dir(repo_root))
}

/// **The raw module cargo writes for `variant` under `target_dir`** —
/// `<target-dir>/<triple>/<profile-dir>/<file>.wasm`. The pure half of
/// [`cargo_artifact`], for a caller that already knows the target directory
/// (the container road binds it, so it is not cargo's to answer).
///
/// `dev` builds land in `debug`. A BIN target keeps its name as written
/// (`gunnar-ui-web` → `gunnar-ui-web.wasm`); a cdylib carries cargo's `-` → `_`
/// rename (`my-guest` → `my_guest.wasm`), and cargo's json stream reports a
/// cdylib with `executable: null`, so this is how its module is found at all.
pub fn cargo_wasm_path(variant: &crate::variant::BuildVariant, target_dir: &Path) -> PathBuf {
    let profile = variant.profile.label();
    let profile_dir = if profile == "dev" { "debug" } else { profile };
    let dir = target_dir
        .join(variant.target.as_deref().unwrap_or(DEFAULT_WASM_TARGET))
        .join(profile_dir);
    // An example lands one level down, in `examples/`.
    let dir = if variant.example.is_some() { dir.join("examples") } else { dir };
    dir.join(format!("{}.wasm", artifact_stem(variant)))
}

/// The wasm target every bundle in this constellation builds for.
pub const DEFAULT_WASM_TARGET: &str = "wasm32-unknown-unknown";

/// **The production [`WasmStageRunner`]**, over `nornir-xtask-support`'s toolchain
/// helpers — REUSED, not re-derived.
///
/// * `ensure_wasm_target` — installs the `wasm32-unknown-unknown` rustup target if
///   it is not there, so a first run on a fresh box does not fail on a missing
///   target with a message about a file;
/// * `ensure_wasm_bindgen` — locates (or `cargo install`s) the bindgen CLI **at the
///   version `Cargo.lock` pins**, via `locked_wasm_bindgen_version`. That helper
///   exists because a bindgen CLI that does not match the lock produces the classic
///   silent blank canvas: a bundle that loads, runs, and paints nothing. Pinning
///   here rather than declaring a version in the recipe is deliberate — the lock is
///   the authority, and a second number in a toml is a second number to drift.
///
/// The argv is built HERE, from typed fields. Nothing a recipe author wrote reaches
/// a command line.
#[cfg(feature = "wasm-stage")]
pub struct XtaskWasmStageRunner;

#[cfg(feature = "wasm-stage")]
impl WasmStageRunner for XtaskWasmStageRunner {
    fn describe(&self) -> String {
        "wasm-bindgen pinned to Cargo.lock (nornir-xtask-support), host-side".to_string()
    }

    fn run(&self, run: &WasmStageRun, repo_root: &Path) -> Result<()> {
        use std::process::Command;

        match run {
            WasmStageRun::Bindgen { input, out_dir, target, typescript } => {
                nornir_xtask_support::ensure_wasm_target()
                    .map_err(|e| anyhow!("ensure the wasm target: {e}"))?;
                let cli = nornir_xtask_support::ensure_wasm_bindgen(repo_root)
                    .map_err(|e| anyhow!("ensure wasm-bindgen at the locked version: {e}"))?;
                let mut cmd = Command::new(&cli);
                cmd.arg(input)
                    .arg("--out-dir")
                    .arg(out_dir)
                    .arg("--target")
                    .arg(target);
                if !typescript {
                    cmd.arg("--no-typescript");
                }
                nornir_xtask_support::run(&mut cmd, "wasm-bindgen")
                    .map_err(|e| anyhow!("{e}"))
            }
            WasmStageRun::ComponentManifest { package, example, out_file } => {
                // A cargo EXAMPLE of the cell's own package, run on the host, its
                // stdout captured. `example` is a cargo target name — the argv is
                // built here, from typed fields.
                let out = Command::new(
                    std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
                )
                .current_dir(repo_root)
                .args(["run", "--quiet", "--example", example, "-p", package])
                .output()
                .map_err(|e| anyhow!("spawn the `{example}` example of `{package}`: {e}"))?;
                if !out.status.success() {
                    return Err(anyhow!(
                        "the `{example}` example of `{package}` exited {} — it prints the \
                         component manifest on stdout and this stage captures it, so a \
                         non-zero exit means there is no manifest to capture\n{}",
                        out.status,
                        String::from_utf8_lossy(&out.stderr)
                            .lines()
                            .rev()
                            .take(20)
                            .collect::<Vec<_>>()
                            .into_iter()
                            .rev()
                            .collect::<Vec<_>>()
                            .join("\n")
                    ));
                }
                if out.stdout.is_empty() {
                    return Err(anyhow!(
                        "the `{example}` example of `{package}` exited 0 and printed \
                         NOTHING — capturing that would write an empty manifest, and a \
                         loader reading it would enumerate no components while every \
                         check passed"
                    ));
                }
                std::fs::write(out_file, &out.stdout)
                    .map_err(|e| anyhow!("write {}: {e}", out_file.display()))
            }
        }
    }
}