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

nordisk/nornir

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

//! **The recipe** — nornir's `impl BuildThing`, the ONE declaration of what this
//! repository builds. `.nornir/build-variants.toml` is emitted from
//! [`NornirBuildThing::variants`] (see [`crate::emit`]); nothing here reads it.
//!
//! # What nornir declares
//!
//! ONE delivery (`ORIGINAL`) and FIVE atoms, and the five are the fleet build
//! matrix's five rows (`workspace_nordisk/.nornir/build-matrix-2026-08-02.md`)
//! with nothing added:
//!
//! | variant | role | linkage | bin | what it is |
//! |---|---|---|---|---|
//! | `nornir-server` ⭐ | server | fat | `nornir-server` | the daemon: tonic gRPC on :7878, all 20 services, sole owner of the redb registry and the iceberg warehouse lock |
//! | `nornir` ⭐ | client | fat | `nornir` | the CLI, everything compiled in |
//! | `nornir-cli` | client | mixed | `nornir-cli` | the CLI that REACHES holger instead of embedding it |
//! | `nornir-ui` | client | mixed | `nornir-ui` | the GUI (`src/bin/urdr-threads.rs`), `viz` alone |
//! | `nornir-solo` ⭐ | solo | fat | `nornir-solo` | the one-box shape, server embedded |
//!
//! # `linkage = "mixed"` is a DEFINED value, and this is where nornir needs it
//!
//! The build-matrix doc once called `mixed` "a fourth linkage value nobody
//! documented". It is declared, parsed, rendered and dispatched on in the
//! evaluator ([`ServiceShape::Mixed`], parsed from `"mixed"|"mix"|"configurable"`,
//! audited as embedding like `fat`, mapped to `AgentMode::Fat` because it holds
//! state). `nornir-cli` and `nornir-ui` link skade in-process and REACH holger
//! over the wire — neither `fat` nor `thin` is true of them, and saying either
//! would be the lie this axis exists to prevent.
//!
//! # `embeds` is declared ONLY on `nornir-server`, and that is a shape rule
//!
//! Not because the other atoms embed nothing — they all link skade, and the fat
//! ones link holger too — but because `validate_shape` REJECTS `embeds` on a
//! non-service kind: *"`embeds` requires kind 'service' (or 'container'); kind
//! 'exe' produces no composed service"*. The information is not lost: `linkage`
//! carries it and `reaches` names the wire edges. Making the CLI `kind =
//! "service"` to keep the field would be worse — `service` wraps as a
//! skidbladnir `ServiceSpec` with a system user and a unit, which is exactly the
//! headless deploy the `solo` role exists to prevent.
//!
//! # Two rows the matrix does not have, and why they are not here
//!
//! * **`nornir-watch`** — the `nornir` CLI plus `inotify`. One feature flag on an
//!   existing atom is not a delivery, and the CLI already degrades honestly
//!   without it. The `[[bin]]` and `src/knowledge/watch.rs` STAY and
//!   `.woodpecker.yml` still compiles `--features inotify`, so the loop cannot
//!   rot; what is gone is the claim that it is a separate product.
//! * **`nornir-mcp`** — Rickard, 2026-08-03: *"mcp skall väl ingå inkompilerat i
//!   server!"* MCP is a CAPABILITY of the service, not a delivery beside it. The
//!   stdio loop moved into the library as `nornir::mcp_stdio` and `nornir-server`
//!   serves it as `nornir-server mcp`. The capability did not leave with the atom.
//!
//! # The feature axis is UNDECLARED, and that is written down rather than hidden
//!
//! [`offers`] is EMPTY, which the evaluator reads as *unconstrained*: every
//! feature the rows name is accepted without ever being checked against
//! `Cargo.toml`. nornir has ~40 real features and the five atoms name seventeen
//! counting the union. This is recorded as known debt rather than closed by
//! inventing atoms — an unconstrained feature axis reads as green because
//! nothing can fail it, which is the same vacuous-green the `linkage`, `default`
//! and `bin` axes exist to close. Closing it means listing nornir's real feature
//! space and writing a reason for each unbuilt one, the way skade and znippy do,
//! and it is the next thing this crate should grow.

use std::collections::{BTreeMap, BTreeSet};

use nbt::cargo_container::CargoToolchain;
use nbt::contract::Coordinate;
use nbt::plugin::{BuildThing, Manifest, Role, ABI_VERSION};
use nbt::variant::{AllowEntry, ArtifactKind, BuildVariant, BuildVariantSet, Profile, ServiceShape};

/// The crate a receipt names as its recipe.
pub const KRATE: &str = "nornir-build-thing";

/// The content hash over this crate's sources, computed by `build.rs`.
pub const CONTENT_HASH: &str = env!("NORNIR_BUILD_THING_CONTENT_HASH");

/// The one package every atom is built out of.
pub const PACKAGE: &str = "nornir";

/// The suite node every atom belongs to.
pub const MEMBER: &str = "nornir";

/// **The delivery this repo ships in.** `suites` is a LIST — one row may ship in
/// more than one flavour — but nornir has exactly one, and the tag is not
/// redundant: it makes `cargo xtask build --suite ORIGINAL` the SAME command in
/// korp, dwarves, nornir and modgunn, and holger's second delivery is `--suite
/// MVP` with no new flag and no tooling special case. An EMPTY list is not a
/// third flavour, it is a row delivery cannot select.
pub const SUITE: &str = "ORIGINAL";

/// nornir's build thing.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct NornirBuildThing;

impl NornirBuildThing {
    /// The coordinate a receipt names this recipe by: `krate:version:hash`.
    #[must_use]
    pub fn coordinate() -> Coordinate {
        Coordinate {
            krate: KRATE.to_string(),
            version: env!("CARGO_PKG_VERSION").to_string(),
            hash: Some(CONTENT_HASH.to_string()),
        }
    }
}

fn s(x: &str) -> String {
    x.to_string()
}

fn set(xs: &[&str]) -> BTreeSet<String> {
    xs.iter().map(|x| s(x)).collect()
}

/// The features the two FAT client-side atoms share. `nornir` takes this union
/// plus `embed-holger`; `nornir-cli` takes it plus `atom-cli` and WITHOUT
/// `embed-holger` — those two names are the whole fat/mixed difference, and
/// writing the shared part once is what stops the two lists from drifting apart
/// by a typo nothing would notice.
fn client_core() -> Vec<&'static str> {
    vec![
        "server",
        "viz",
        "mcp",
        "net-scan",
        "docs-export",
        "robot",
        "vector",
        "embed-tract",
        "embed-ort",
        "gen-candle",
        "gen-ollama",
        "manual-embed",
        "claude",
    ]
}

fn feats(base: &[&str], extra: &[&str]) -> Vec<String> {
    base.iter().chain(extra.iter()).map(|f| s(f)).collect()
}

/// The five atoms, in matrix order.
pub fn variants() -> Vec<BuildVariant> {
    vec![
        // ⭐ ATOM 1/5 — THE DAEMON. `src/bin/nornir-server.rs` →
        // `nornir::server_grpc::run()`. This is what systemd runs; it is the
        // artifact `role = "server"` is actually about, and until 2026-08-01 NO
        // variant built it — three copies of the CLI sat here calling themselves
        // servers instead. `viz` is not optional for it: the server's `Viz.*`
        // RPCs reuse `viz::knowledge`.
        BuildVariant {
            name: s("nornir-server"),
            role: Some(Role::Server),
            is_default: true,
            suites: vec![s(SUITE)],
            member: Some(s(MEMBER)),
            package: Some(s(PACKAGE)),
            bin: Some(s("nornir-server")),
            kind: ArtifactKind::Service,
            shape: ServiceShape::FatService,
            no_default_features: true,
            features: feats(&client_core(), &["embed-holger"]),
            // The ONLY row that may carry these: see the module doc.
            embeds: set(&["holger", "skade"]),
            reaches: set(&["dwarves"]),
            profile: Profile::Release,
            ..BuildVariant::default()
        },
        // ⭐ ATOM 2/5 — THE CLI, fat. `embed-holger` is THE fat/mixed
        // discriminator and the one real compile-time linkage choice nornir has:
        // `embed-holger = ["dep:holger-server-lib"]` and that dep is the only
        // `optional = true` sibling. skade, iceberg and parquet are PLAIN deps —
        // every nornir binary links the warehouse engine in-process, so no
        // feature choice can make nornir thin.
        BuildVariant {
            name: s("nornir"),
            role: Some(Role::Client),
            is_default: true,
            suites: vec![s(SUITE)],
            member: Some(s(MEMBER)),
            package: Some(s(PACKAGE)),
            bin: Some(s("nornir")),
            kind: ArtifactKind::Exe,
            shape: ServiceShape::FatService,
            no_default_features: true,
            features: feats(&client_core(), &["embed-holger"]),
            reaches: set(&["dwarves"]),
            profile: Profile::Release,
            ..BuildVariant::default()
        },
        // ATOM 3/5 — THE CLI that reaches holger. `atom-cli` is the marker
        // feature that keys the `nornir-cli` `[[bin]]`'s `required-features`,
        // which is how three feature builds stopped writing ONE path: distinct
        // `[[bin]]` names off one source, told apart by `required-features`.
        // Renamed `thin` → `atom-cli` on 2026-08-03, because a marker named
        // `thin` on an atom that is measurably `mixed` re-created the exact
        // contradiction the rename removes, one layer down.
        BuildVariant {
            name: s("nornir-cli"),
            role: Some(Role::Client),
            is_default: false,
            suites: vec![s(SUITE)],
            member: Some(s(MEMBER)),
            package: Some(s(PACKAGE)),
            bin: Some(s("nornir-cli")),
            kind: ArtifactKind::Exe,
            shape: ServiceShape::Mixed,
            no_default_features: true,
            features: feats(&client_core(), &["atom-cli"]),
            reaches: set(&["holger", "dwarves"]),
            profile: Profile::Release,
            ..BuildVariant::default()
        },
        // ATOM 4/5 — THE GUI, `src/bin/nornir-ui.rs` (renamed from
        // `urdr-threads` on 2026-08-01). `viz` ALONE: the GUI is the one atom
        // whose feature list is not a slice of the client union, and that is the
        // point — it carries no server, no embedders and no generators.
        BuildVariant {
            name: s("nornir-ui"),
            role: Some(Role::Client),
            is_default: false,
            suites: vec![s(SUITE)],
            member: Some(s(MEMBER)),
            package: Some(s(PACKAGE)),
            bin: Some(s("nornir-ui")),
            kind: ArtifactKind::Exe,
            shape: ServiceShape::Mixed,
            no_default_features: true,
            features: vec![s("viz")],
            reaches: set(&["holger", "dwarves"]),
            profile: Profile::Release,
            ..BuildVariant::default()
        },
        // ⭐ ATOM 5/5 — THE ONE-BOX SHAPE. `solo` is what embeds the server in
        // the desktop artifact, and `tests/solo_embeds_server.rs` goes red if
        // `.woodpecker.yml` stops compiling `--features solo,robot-remote`. A
        // deliberately SHORTER feature list than the fat CLI: the generators and
        // the remote embedders are not what a single box wants compiled in.
        BuildVariant {
            name: s("nornir-solo"),
            role: Some(Role::Solo),
            is_default: true,
            suites: vec![s(SUITE)],
            member: Some(s(MEMBER)),
            package: Some(s(PACKAGE)),
            bin: Some(s("nornir-solo")),
            kind: ArtifactKind::Exe,
            shape: ServiceShape::FatService,
            no_default_features: true,
            features: vec![
                s("solo"),
                s("server"),
                s("viz"),
                s("vector"),
                s("embed-tract"),
                s("manual-embed"),
                s("embed-holger"),
            ],
            reaches: set(&["dwarves"]),
            profile: Profile::Release,
            ..BuildVariant::default()
        },
    ]
}

/// **EMPTY, and said out loud.** See the module doc: nornir's feature space is
/// undeclared, the evaluator reads an empty `offers` as unconstrained, and that
/// is recorded debt rather than a closed axis. It is a function rather than a
/// literal so the day someone lists the ~40 real features, there is one place to
/// put them and [`allow_unbuilt`] below is already waiting for the reasons.
fn offers() -> Vec<&'static str> {
    Vec::new()
}

/// **Features no atom builds, each with a written reason.** Empty while
/// [`offers`] is empty — an excuse for a feature nothing offers would be an
/// excuse for nothing. The two that will go here first are named in the module
/// doc: `inotify` (the `nornir-watch` `[[bin]]`, compiled by `.woodpecker.yml`
/// and guarded by `tests/inotify_watch_wiring.rs`, but named by no atom) and
/// `robot-remote` (compiled by the same CI line, named by no atom).
pub fn allow_unbuilt() -> BTreeMap<String, String> {
    BTreeMap::new()
}

/// **The `[[bin]]` targets no atom builds, each with its reason** —
/// `[build.allow_unbuilt_bins]`. The forge's build gate
/// (`nbt::buildgate::tally_bins`) compares every bin cargo can see in this tree
/// against what the declared atoms produced; the five atoms are the fleet matrix
/// and nothing else (`the_five_atoms_are_the_matrix_rows…`), so every other bin is
/// named HERE with the reason it has no row. MEASURED 2026-10-16 on loki: ⚒ Build on
/// nornir reported "18 of 18 declared bins were NEVER BUILT" — five were the atoms
/// (unbuilt only because the sibling refusal stopped every cell), these thirteen had
/// no row and no reason. A new `[[bin]]` lands red until it gets one or the other.
fn allow_unbuilt_bins() -> BTreeMap<String, AllowEntry> {
    const DRIVER: &str = "a bin of crates/mimir-driver, the Mímir v2 rustc driver: it links \
        the compiler's own private crates, is EXCLUDED from this workspace, pins its own dated nightly in its \
        rust-toolchain.toml and keeps its own Cargo.lock — the stable pinned \
        nordisk-cargo-toolchain image cannot compile it, so no atom of this workspace can.";
    const FIXTURE: &str = "a TEST FIXTURE package under crates/mimir-driver/tests/ (its own \
        workspace, driven by mimir-driver's integration tests), not a deliverable.";
    [
        ("mimir-driver", DRIVER),
        ("cargo-mimir-dump", DRIVER),
        ("mimir-capture", DRIVER),
        ("mimir-super", DRIVER),
        ("bctl", FIXTURE),
        ("twin", FIXTURE),
        (
            "bindrift_probe",
            "the red-when-broken fixture binary of crates/bindrift-fixtures (its build.rs \
             injects code the git diff never shows, for Mimir.BinaryDrift to catch) — a \
             test subject, never shipped.",
        ),
        (
            "mimir-binary",
            "the Mímir v2 binary-layer CLI (join one linked artifact onto a driver dump) — an \
             operator/dev tool run from the workspace; not one of the five fleet-matrix rows.",
        ),
        (
            "mimir-room",
            "the Mímir v2 code-room CLI (stats/find/reach/diff/bench over a lake) — a dev and \
             bench tool; the room ships inside nornir-server as a library, not as this bin.",
        ),
        (
            "nornir-build-thing",
            "this recipe crate's own emit door (`cargo run -p nornir-build-thing -- emit`); the \
             forge loads the crate as wasm, the bin is a maintainer tool.",
        ),
        (
            "xtask",
            "the `cargo xtask` dev runner — a maintainer tool, never a delivery.",
        ),
        (
            "nornir-demo",
            "the Pages demo host: its wasm row is not declared until the owner decides how it \
             enters the matrix (xtask refuses demo-wasm by name until then); not a fleet row.",
        ),
        (
            "nornir-viz-web",
            "the viz-as-website crate (wasm bundle + a native dev window/serve path) — not one \
             of the five fleet-matrix rows.",
        ),
    ]
    .into_iter()
    .map(|(k, v)| (s(k), AllowEntry::Reason(s(v))))
    .collect()
}

/// **The suite dependency graph as an embed-allowlist**: member → the siblings
/// strictly DOWNSTREAM of it, which are the only ones a fat atom of that member
/// may compile IN. nornir may embed holger and skade; **dwarves is deliberately
/// NOT here** — nornir reaches the forge over the wire and compiling it in would
/// be the dependency inversion this list turns from a review comment into a
/// refusal.
fn downstream() -> BTreeMap<String, BTreeSet<String>> {
    [
        (s("nornir"), set(&["holger", "skade"])),
        (s("holger"), set(&["skade"])),
    ]
    .into_iter()
    .collect()
}

/// **The pinned build image every cargo variant compiles in** — `[build.toolchain]`.
///
/// An absent table is not "builds on the host": `CargoToolchain::validate`
/// refuses it — *"no `image_sha256` — a named tag is a convenience, NOT a
/// pin"* — the code road closes, and the forge falls back to reading the
/// emitted toml as if it were the source, which is the description driving the
/// build it was written to describe. So the table is declared with the same
/// digest the rest of the constellation's recipes name.
///
/// The digest is the **MANIFEST** digest (64 lowercase hex, no `sha256:`). It
/// moves only when the image RECIPE moves, in the same commit.
/// MOVED 2026-09-20 (loki). The value below was
/// `2cf9b012c9d2ffa42b447e5b0d127184e5a3f998dd2574d771477e9c8e366659` — a
/// second mint of this same recipe which exists on exactly one box, t14s, and
/// sat there UNTAGGED (`<none>:<none>`, 2026-07-13). On loki it does not exist
/// at all, so `CargoToolchain::validate` refused every container build of this
/// repo BY DIGEST — correctly, and that is the whole point of a pin: it told
/// the truth about an environment that was not there. The digest now names the
/// mint that is tagged `nordisk-cargo-toolchain:live` and `:live-uefi` on both
/// boxes, and which composed the live gunnar appliance ISO; `gunnar` and
/// `holger` have pinned it since 2026-09-15. Read back on loki through the
/// engine door, not from podman hearsay:
/// `cargo run -p nornir-build-thing --features image-build --example
/// image_digest -- nordisk-cargo-toolchain:live` ->
/// `3d5115baa07d03ca51eb3e44ebb86b4e64e679bd88f38e156ef9da12c35d2143`.
/// Same `rust_version` 1.97.1, and the target list below is a subset of what
/// that image carries (it adds `x86_64-unknown-uefi`), so the pin moves and
/// nothing else does.
///
/// **RE-PINNED 2026-10-16 to edda's recipe pin** (`nbt::CARGO_TOOLCHAIN_PIN`), the
/// recipe that adds `libgraphblas-dev`: nornir-solo and nornir-server link
/// `-lgraphblas` (the code room), and the `3d5115ba…` image had no GraphBLAS — both
/// cells failed to link on loki. The pin is no longer typed here: it is the digest
/// edda's recipe mints, moved with the recipe in one reviewed commit.
fn toolchain() -> Option<CargoToolchain> {
    Some(CargoToolchain {
        image: s("nordisk-cargo-toolchain"),
        image_sha256: s(nbt::CARGO_TOOLCHAIN_PIN.digest),
        rust_version: s("1.97.1"),
        // What the pinned image CARRIES std for, not what nornir delivers.
        // wasm32 is not optional: it is the target this crate's own plugin
        // module is built for, and omitting it would refuse the code road by
        // name before the container ever ran.
        targets: vec![
            s("x86_64-unknown-linux-gnu"),
            s("x86_64-unknown-linux-musl"),
            s("wasm32-unknown-unknown"),
        ],
        timeout_secs: 3600,
        mem_limit_mb: 8192,
        cpus: 8,
        network: false,
        remap_paths: true,
        // The checkouts nornir's own `path = "../…"` dependencies reach into.
        // A container holding only nornir cannot resolve any of them, and the
        // error it fails with names a manifest path, not this list. If a sibling
        // path dep is added or removed, this list moves in the SAME commit.
        // 2026-10-16: `Skidbladnir` (the root manifest's `skidbladnir` path dep) and
        // `facett` (the optional viz facets + nornir-demo) were missing, and ⚒ Build on
        // loki refused every cell in 0.3 s for it; `openzl-sys-rs` is reached one hop
        // further (znippy-common's default `openzl` feature) — cargo loads it, so the
        // container must carry it. `tests/declaration_guard.rs` walks the closure.
        siblings: vec![
            s("Skidbladnir"),
            s("draupnir"),
            s("edda"),
            s("facett"),
            s("holger"),
            s("modgunn"),
            s("openzl-sys-rs"),
            s("skade"),
            s("znippy"),
            s("znippy-zoomies"),
        ],
    })
}

impl BuildThing for NornirBuildThing {
    fn manifest(&self) -> Manifest {
        Manifest {
            abi: ABI_VERSION,
            repo: s("nornir"),
            source_crate: s(KRATE),
            summary: "nornir — the suite WAREHOUSE: the gRPC daemon that owns the redb \
                      workspace registry and the iceberg warehouse lock, and the four \
                      client shapes around it. ONE delivery, FIVE atoms, and the \
                      fat/mixed axis is one feature (`embed-holger`) rather than a \
                      naming convention."
                .to_string(),
        }
    }

    fn variants(&self) -> BuildVariantSet {
        BuildVariantSet {
            // ONE delivery, NAMED rather than left implicit. `check_suites` is
            // VACUOUS below two suites (there is nothing to be a subset of) —
            // that is correct, and `declares_suites` is what tells "checked and
            // clean" apart from "nothing to check".
            base_suite: Some(s(SUITE)),
            offers: offers().into_iter().map(s).collect(),
            allow_unbuilt: allow_unbuilt(),
            allow_unbuilt_bins: allow_unbuilt_bins(),
            downstream: downstream(),
            variants: variants(),
            toolchain: toolchain(),
            ..BuildVariantSet::default()
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// **The set is the matrix, and nothing else.** Rickard 2026-08-03: *"ta
    /// bort allt inte stämmer med matrisen"*. The two rows that left are named
    /// so that re-adding one is a deliberate act and not a drift.
    #[test]
    fn the_five_atoms_are_the_matrix_rows_and_the_two_removed_stay_removed() {
        let set = NornirBuildThing.variants();
        assert_eq!(
            set.names(),
            vec!["nornir-server", "nornir", "nornir-cli", "nornir-ui", "nornir-solo"],
            "the matrix has FIVE rows"
        );
        for gone in ["nornir-watch", "nornir-mcp", "nornir-thin"] {
            assert!(!set.names().contains(&gone), "'{gone}' left the set deliberately");
        }
    }

    /// **Carrying the suite tag is what makes an atom a build thing.** An
    /// untagged row is not a third flavour, it is a row `--suite ORIGINAL`
    /// cannot select.
    #[test]
    fn every_atom_is_tagged_original_and_the_base_suite_names_it() {
        let set = NornirBuildThing.variants();
        assert!(set.declares_suites(), "an untagged repo is outside the system");
        assert_eq!(set.base_suite.as_deref(), Some(SUITE));
        for v in &set.variants {
            assert_eq!(v.suites, vec![s(SUITE)], "'{}'", v.name);
        }
    }

    /// **`embeds` only on the service row** — the shape rule, asserted rather
    /// than remembered, because the fix for a `validate_shape` refusal is to
    /// drop the key and not to promote a CLI to `kind = "service"`.
    #[test]
    fn embeds_is_declared_on_the_daemon_alone_and_the_shape_gate_agrees() {
        let set = NornirBuildThing.variants();
        for v in &set.variants {
            if v.name == "nornir-server" {
                assert_eq!(v.embeds, set_of(&["holger", "skade"]), "the daemon composes both");
            } else {
                assert!(v.embeds.is_empty(), "'{}' is not service-shaped", v.name);
            }
        }
        // The gate is per ROW and returns a named refusal, not a list.
        for v in &set.variants {
            set.validate_shape(v).unwrap_or_else(|e| panic!("{}: {e:#}", v.name));
        }
    }

    /// **The fat/mixed axis is ONE feature, not a naming convention.** If
    /// `embed-holger` ever appears on a `mixed` row, or leaves a `fat` one, the
    /// declaration has started lying about what is compiled in.
    #[test]
    fn embed_holger_is_exactly_the_fat_rows() {
        for v in &NornirBuildThing.variants().variants {
            let fat = v.shape == ServiceShape::FatService;
            let embeds_holger = v.features.iter().any(|f| f == "embed-holger");
            assert_eq!(fat, embeds_holger, "'{}' — linkage and features disagree", v.name);
        }
    }

    /// **The pin is a pin.** A named tag is a convenience; an environment that
    /// can drift under you is a weather report.
    #[test]
    fn the_toolchain_is_pinned_by_digest_and_carries_the_targets_the_rows_name() {
        let t = toolchain().expect("nornir declares a toolchain");
        assert_eq!(t.image_sha256.len(), 64, "a named tag is a convenience, not a pin");
        assert!(!t.network, "an attested build is hermetic");
        assert!(
            t.targets.iter().any(|x| x == "wasm32-unknown-unknown"),
            "this crate's own plugin module is built for wasm32"
        );
    }

    fn set_of(xs: &[&str]) -> BTreeSet<String> {
        xs.iter().map(|x| s(x)).collect()
    }
}