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

nordisk/gunnar

xtask/src/guard.rs

//! The dependency guard: which crate may reach which, enforced.
//!
//! # What it asserts
//!
//! 1. **Forbidden everywhere, both halves**: `rayon` (the multicore rule:
//!    concurrency is gatling),
//!    `libgit2` / `git2`, `openssl`, `nornir` (the server crate), `typst`, and
//!    any HTTP server framework.
//! 2. **Forbidden in the `S` (server) graph**: `gunnar-client`, the edge that
//!    would drag gix's porcelain and the filesystem assumption into the
//!    appliance image; the gix crates a worktree-free server needs none of;
//!    and `skidbladnir`, the systemd installer.
//! 3. **The appliance build must not link gix porcelain with
//!    worktree/index/status/filter features.**
//!
//! # The three shapes, and why `skidbladnir` is a rule and not a comment
//!
//! gunnar deploys in three shapes and `.nornir/build-variants.toml` names a variant
//! for each: a CONTAINER (`gunnar-image`), an OS-less APPLIANCE (`gunnar-appliance-iso`),
//! and ON AN OS (`gunnar-service`). systemd is a feature, default-off, and ON in
//! the one shape where a service manager exists to register with — the third.
//!
//! The first two both chain the same `gunnar-server` artifact, so the [`APPLIANCE`]
//! profile is the single graph whose cleanliness proves it for both. Turning the
//! feature on for the third shape is the easy half; the half that can go wrong
//! silently is a global enable that also reaches the other two, and MEASURED, a
//! system-scope Skidbladnir install inside a container dies on the D-Bus connect
//! and leaves a 418-byte half-written unit behind. So the guard states it as an
//! absence over a resolved graph rather than as a paragraph: see the
//! `skidbladnir` entry in [`FORBIDDEN_IN_SERVER_GRAPH`] and the [`SERVICE`]
//! profile that witnesses it.
//!
//! # Why it reads a RESOLVED graph and not a manifest
//!
//! This is the whole design of the file. Under resolver 2, `cargo build
//! --workspace` unifies features across the workspace: `gunnar-client` asks for
//! `gix/status` (which implies `dirwalk`, `index` and `blob-diff`) and
//! `gix/blocking-network-client`, and every other crate in the same build gets
//! that same `gix`. Measured 2026-08-03 on this repository:
//!
//! ```text
//!   cargo tree --workspace -f "{p}|{f}"   →  gix v0.86.0 | ... dirwalk, index,
//!                                            status, worktree-mutation,
//!                                            blocking-network-client, ...
//! ```
//!
//! Feature *declarations*, meanwhile, are per-crate and say nothing of the
//! kind: `gunnar-store`'s manifest asks for `gix` with `default-features =
//! false` and five plumbing features. **A guard that read `cargo metadata`'s
//! declarations would report green while the compiled artefact was already
//! contaminated**. Precisely the check that can never go red, which is what
//! the measure-never-assert rule exists to catch.
//!
//! So every rule here is evaluated against `cargo tree -e normal` run **for a
//! specific feature set**, which is the graph that is actually compiled.
//! `-e normal` is required rather than incidental: `gunnar-pack`,
//! `gunnar-policy`, `gunnar-wire` and `gunnar-cli` all carry `gix` as a
//! *dev*-dependency for fixtures, and a dev-dependency never reaches an
//! appliance image.
//!
//! # One rule table, not two
//!
//! `crates/gunnar-cli/tests/zero_shell_guard.rs` used to restate the same gix
//! ban from inside `gunnar-cli`'s own test target, with its own list, its own
//! `cargo tree` invocation and the *opposite* policy on the `fs` deviation. It
//! was committed red so the fix had something to satisfy, while this guard
//! recorded the deviation and stayed green. Two rule tables that can disagree
//! about what the dependency budget says, and a CI that cannot tell a
//! regression from the known
//! state. **Both tables are now here**, and that file is deleted; its two
//! properties worth keeping came with it:
//!
//! * the **anti-phantom** check; see [`check_rule_names_can_fire`]. Every name
//!   in [`FORBIDDEN_IN_SERVER_GRAPH`] must be findable in one of the
//!   [`WITNESS_PROFILES`], or a typo (or an upstream rename) leaves an entry
//!   reporting "absent" from every build forever, including a broken one.
//! * the per-crate **reasons**, folded into the tables below so a failure
//!   explains itself without a second file to consult.
//!
//! What that file recorded, kept because it is the *why*: on a tunnr appliance
//! the binary is PID 1 in a distroless image. There is no shell for
//! `gix-command` to spawn, no `/etc/gitconfig` for `gix-config` to read, and no
//! worktree for `gix-worktree` to manage. Code that reaches for them does not
//! fail at build time; it fails at runtime, on hardware, in front of whoever is
//! standing there. Measured 2026-08-03, before the `fs` backend was moved off
//! `gix` porcelain: the appliance build resolved **350 crates, 46 of them
//! `gix-*`**, including `gix-command`, whose whole job is spawning
//! subprocesses, in the build whose specification opens "Zero shell".
//!
//! `crates/gunnar-cli/tests/feature_split.rs` remains: it asserts the `systemd`
//! feature is not paid for unless it is on, which is a *feature-split* property
//! this guard does not model.
//!
//! # The empty-graph trap
//!
//! A `cargo tree` invocation that fails, or names a package that does not
//! exist, produces no output, and "no output" is shaped exactly like "no
//! forbidden crate found". Every profile therefore declares
//! [`Profile::expect_present`], a set of packages that MUST appear, and a graph
//! missing any of them is itself a violation. Without that, all of the
//! assertions below would be vacuous.

use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Command;

// ── the rules ───────────────────────────────────────────────────────────────

/// *Forbidden anywhere in the build graph, both halves.*
///
/// Names are matched exactly against the resolved package name, never as a
/// substring: the graph legitimately carries `openssl-probe` (pure Rust, it
/// only locates a CA-bundle path) and `gix-features` (git plumbing, unrelated
/// to Cargo features), and a substring match would fail on both, after which
/// the guard would be retuned rather than trusted.
pub const FORBIDDEN_ANYWHERE: &[(&str, &str)] = &[
    ("rayon", "concurrency is `gatling`, never rayon"),
    ("rayon-core", "concurrency is `gatling`, never rayon"),
    ("git2", "no libgit2, no C git"),
    ("libgit2-sys", "no libgit2, no C git"),
    ("openssl", "openssl is banned"),
    ("openssl-sys", "openssl again, and a system library too"),
    ("native-tls", "pulls the system openssl on Linux"),
    (
        "nornir",
        "the nornir SERVER crate must never be in gunnar's graph",
    ),
    ("typst", "docs generation is never a build dependency"),
    // Named HTTP server frameworks. `hyper` is deliberately absent from this
    // list: it arrives under `tonic` as the HTTP/2 transport gRPC is defined
    // over, and it is what `gunnar-http` speaks smart HTTP with directly.
    //
    // What this list rules out is a **framework**, and the rule it enforces
    // survived smart HTTP arriving rather than being loosened for it.
    // Smart HTTP is four fixed URLs whose repository path is an
    // arbitrary-depth prefix matched by suffix — `gunnar_http::route::resolve`
    // is a `strip_suffix` and a split, which no router expresses better. A
    // framework in this graph would therefore not be serving git; it would be
    // serving the REST API or the web UI the server rules out, and that is still the
    // thing being forbidden. See `TRACKED_DEVIATIONS` for `axum`.
    ("actix-web", "no HTTP framework, no REST, no web UI"),
    ("warp", "no HTTP framework, no REST, no web UI"),
    ("rocket", "no HTTP framework, no REST, no web UI"),
    ("poem", "no HTTP framework, no REST, no web UI"),
    ("salvo", "no HTTP framework, no REST, no web UI"),
    ("tide", "no HTTP framework, no REST, no web UI"),
    ("axum", "no HTTP framework, no REST, no web UI"),
];

/// *Forbidden in the `S` (server) graph specifically.*
///
/// # 2026-08-30: `gix` is now banned BY NAME, and that is why `gunnar-client`
/// # could stop being a proxy for it
///
/// The `gunnar-client` entry used to read *"that edge drags gix porcelain and
/// the filesystem assumption into the appliance image"*, and every word of the
/// reason was about **gix**, not about `gunnar-client`. The crate was standing
/// in for the thing actually forbidden, because at the time there was no way to
/// have one without the other.
///
/// There is now. `gunnar-client 1.1.4` and `nornir-git 0.1.9` each put their
/// porcelain behind a default-on `porcelain` feature, so the WIRE half —
/// send-pack, the russh dialler, the ref plan — resolves alone. MEASURED
/// 2026-08-30, `cargo tree -p gunnar-server -e normal`, unique packages:
///
/// ```text
///   appliance                                365
///   appliance + replication (the wire half)  369   +4   gix-packetline,
///                                                       gunnar-client,
///                                                       nornir-git, nornir-hash
///   appliance + the old `client` feature     493  +128  gix + 57 gix-* + the
///                                                       reqwest/rustls/icu tail
/// ```
///
/// So the invariant did NOT change and was NOT eroded: the appliance still
/// links no gix porcelain, and it is asserted directly now — [`FORBIDDEN_GIX`]
/// bans the `gix` facade crate itself, which is the one package whose presence
/// means porcelain. That ban is strictly stronger than the two proxies it
/// replaces, because a future edge that reached gix by any other route would
/// have slipped past both of them.
///
/// What DID change is a product decision, taken on 2026-08-30: **the
/// appliance replicates.** The durability measurement is why — replication
/// was proven end to end (60 ms lag, a standby that served fsck-clean clones
/// after the primary was `SIGKILL`ed) and was not in the shipped binary. The two
/// proxy entries therefore stay in this list, because an `S` crate like
/// `gunnar-store` or `gunnar-wire` still has no business dialling ssh, and they
/// are excused on the two whole-binary appliance profiles ALONE, in
/// [`TRACKED_DEVIATIONS`], with that decision written next to them.
///
/// The `gix-*` entries are the list of what a worktree-free server needs
/// none of. The `gix-` prefix is spelled out per entry rather than matched as a
/// family: some `gix-*` crates are plumbing the server legitimately needs
/// (`gix-hash`, `gix-object`, `gix-pack`, `gix-odb`, `gix-ref`, `gix-features`,
/// and `gix-packetline` under the replication wire), so a blanket rule would be
/// wrong.
///
/// The `skidbladnir` entry is banned for a different reason from all the rest:
/// not the client HALF, but the two deployment SHAPES this scope covers, neither
/// of which has an init system. See its own note below, and [`SERVICE`].
///
/// Every name here is held to [`check_rule_names_can_fire`]: it must be
/// findable in one of the [`WITNESS_PROFILES`], so a typo cannot sit in this
/// list reporting "absent" forever.
pub const FORBIDDEN_IN_SERVER_GRAPH: &[(&str, &str)] = &[
    (
        // THE rule the two entries below used to stand in for. See this list's
        // header: it is the gix FACADE crate, the one package that is porcelain
        // by definition — a repository handle, a worktree, a fetch negotiation.
        // Banning it directly is what let `gunnar-client` become a layering rule
        // instead of a dependency-diet one.
        "gix",
        "gix's porcelain is a repository on a filesystem with a worktree, and \
         the appliance has neither. The plumbing the server does need is named per \
         crate below and above (gix-hash, gix-packetline); this is the facade, and \
         its presence in an S graph means something opened a repository",
    ),
    (
        "gunnar-client",
        "no S crate may depend on gunnar-client. A store, a wire codec or a \
         listener has no business dialling ssh. EXCUSED on the whole-binary appliance \
         profiles alone since 2026-08-30 — the appliance replicates, and with \
         gunnar-client's `porcelain` feature off that edge is +4 packages and no gix. \
         See TRACKED_DEVIATIONS",
    ),
    (
        "nornir-git",
        "gunnar-client's SSH bridge. Same scope and same exception as the entry \
         above: banned per S crate, excused on the appliance binary, and with \
         `porcelain` off it links no gix either",
    ),
    (
        "gunnar-delta",
        "the delta ENCODER. The SERVE path is `write_pack_copy`: it REUSES \
         the deltas already in the pack and computes none, so no crate that answers a \
         fetch may link an encoder. That is what this entry still enforces, on every \
         per-crate `S:` graph — `gunnar-wire`, `gunnar-ssh`, `gunnar-http`, \
         `gunnar-store`, `gunnar-pack` — and it is a SHARPER rule than the one it \
         replaced, not a weaker one. What changed on 2026-08-04 is the MAINTENANCE \
         path: `gunnar gc` re-encodes deltas now, because a forge that only ever \
         copies can never be better than the client that pushed into it, and gunnar's \
         own client pushed at 0.0% deltified. See `TRACKED_DEVIATIONS`, which records \
         the two whole-binary graphs where the encoder is therefore present and what \
         it costs them",
    ),
    (
        "gix-worktree",
        "worktree management. The server serves bare repos and never checks \
         anything out",
    ),
    ("gix-index", "the git index, and a bare server has none"),
    (
        "gix-status",
        "worktree status, with no working tree to diff against",
    ),
    (
        "gix-filter",
        "smudge/clean filters shell out, and there is no CRLF conversion either",
    ),
    (
        "gix-attributes",
        ".gitattributes is what drives the filters",
    ),
    ("gix-ignore", ".gitignore is a worktree concern"),
    ("gix-glob", "no pathspecs"),
    (
        "gix-config",
        "server config is gunnar's own, not .git/config",
    ),
    (
        "gix-discover",
        "repositories are registered, not discovered",
    ),
    ("gix-credentials", "credential helpers are subprocesses"),
    (
        "gix-prompt",
        "prompts a terminal, and PID 1 in a distroless image has none",
    ),
    (
        "gix-command",
        "spawns subprocesses, and the server is zero-shell",
    ),
    (
        "skidbladnir",
        "the systemd installer. systemd is a FEATURE, and the two shapes \
         this scope covers are the two with no service manager to install into — a \
         container has no init at all (a system-scope install inside one dies on the \
         D-Bus connect and leaves a half-written unit behind, MEASURED), and the OS-less \
         appliance IS the init. It is also +46 packages (278 → 324, MEASURED 2026-08-04): \
         28 of them `zbus`'s smol stack, a SECOND async runtime beside gunnar's tokio, and \
         12 an airgap PACKER gunnar never calls. The `service` profile is where it belongs \
         and where it is asserted PRESENT; here its absence is what says the feature really \
         is default-off",
    ),
];

/// Feature *tokens* that must not be enabled on the resolved `gix` package in
/// an `S` graph. Matched as substrings of each enabled feature name, so
/// `status` catches both `status` and `gix-status`, and `worktree` catches
/// `worktree-mutation` and `worktree-stream`.
///
/// This is the rule that the resolver-2 unification would trip, and it is
/// deliberately **not** covered by any tracked deviation: if a workspace build
/// ever leaks the client's `status`/`dirwalk` feature set into the appliance
/// profile, this goes red.
pub const FORBIDDEN_GIX_FEATURE_TOKENS: &[(&str, &str)] = &[
    ("worktree", "gix with a worktree feature in the S graph"),
    ("index", "gix with the index feature in the S graph"),
    ("status", "gix with the status feature in the S graph"),
    ("filter", "gix with a filter feature in the S graph"),
    (
        "attributes",
        "gix with the attributes feature in the S graph",
    ),
    ("dirwalk", "the server walks no directories"),
    ("blocking-network-client", "the server is not a git client"),
    ("credentials", "no credential helper"),
];

// ── tracked deviations ──────────────────────────────────────────────────────

/// A dependency rule that the tree violates **today**, recorded rather than hidden.
///
/// A deviation does not weaken the rule it names: it narrows the exception to
/// one item in one profile, with the reason and the file that records it. And
/// it is not free: [`Report::stale_deviations`] fails the guard for any entry
/// that stopped matching, so the ledger is forced to shrink when the underlying
/// deviation is fixed rather than quietly outliving it.
#[derive(Debug, Clone, Copy)]
pub struct Deviation {
    /// The item as a [`Violation`] names it: a package name, or `gix:<token>`.
    pub item: &'static str,
    /// Profiles this exception applies to. `"*"` means every profile.
    pub profiles: &'static [&'static str],
    pub why: &'static str,
}

/// Every dependency rule an actual graph steps outside of, and why.
///
/// **Two kinds live here, and the `why` is what tells them apart.** Most are
/// DEBTS: something the tree does today that it should stop doing, kept visible
/// so it cannot rot quietly (`axum`, below, is one — removing it means dropping
/// tonic). Since 2026-08-30 there is also a DECISION: the appliance links
/// `gunnar-client` and `nornir-git` on purpose, because it replicates. A
/// decision is not a debt and must not be written as one, or the next reader
/// will "fix" it. Both are held to the same test — [`Report::stale_deviations`]
/// fails the guard on an entry that stopped matching — because an exception
/// that excuses nothing is noise whichever kind it is.
///
/// **The gix block is gone, and that is the ledger working.** Until
/// 2026-08-03 there were eleven more entries here: `gunnar-cli`'s `server`
/// feature turned on `gunnar-store/fs`, `fs` went through `gix` porcelain, and
/// the appliance build linked `gix-worktree`, `gix-index`, `gix-filter`,
/// `gix-attributes`, `gix-ignore`, `gix-glob`, `gix-config`, `gix-discover` and
/// `gix-command` plus gix's `index` and `attributes` features. The `fs` backend
/// was then rebuilt on `gix-odb`/`gix-ref` plumbing directly, and
/// [`Report::stale_deviations`] failed the guard on all eleven, with *"this
/// exception excused nothing"*, which is what forced them out. An exception
/// here cannot outlive its cause.
///
/// **What the guard reports with this table emptied** (measured 2026-08-03, by
/// doing exactly that and re-running `cargo test -p xtask`): **3 violations**,
/// all of them `axum`, once per graph on the `appliance`, `default` and
/// `S:gunnar-grpc` profiles. No other dependency rule is broken; in particular no
/// `rayon`, no `git2`, no `openssl`, no `gix-*` porcelain and no
/// `S → gunnar-client` edge anywhere in the tree.
pub const TRACKED_DEVIATIONS: &[Deviation] = &[
    // ── replication on the appliance ────────────────────────────────────────
    //
    // NOT a debt. A DECISION, taken on 2026-08-30 after
    // The durability measurement showed replication working end to end and
    // absent from the shipped binary. The `S -> gunnar-client` ban keeps every
    // one of its teeth on the per-crate `S:` graphs, which is where it was
    // always about layering; here the appliance BINARY is meant to contain the
    // edge, and this pair of entries is how that is said out loud instead of by
    // deleting a rule.
    //
    // The reason the ban existed — gix porcelain in the appliance — is now
    // asserted directly by the `gix` entry in FORBIDDEN_IN_SERVER_GRAPH, which
    // no deviation excuses on any profile. MEASURED 2026-08-30: the appliance
    // graph is 369 packages with replication on, against 365 without and 493
    // under the old all-or-nothing `client` feature.
    //
    // `Report::stale_deviations` is what keeps this honest in the other
    // direction: the day the edge leaves the appliance, both entries go red as
    // exceptions that excuse nothing.
    Deviation {
        item: "gunnar-client",
        profiles: &[APPLIANCE, APPLIANCE_HTTP],
        why: "the appliance replicates. `gunnar-server`'s `replication` \
              feature takes gunnar-client with `default-features = false` — send-pack, \
              the russh dialler and the ref plan, no porcelain and no gix. +4 packages \
              (365 -> 369), MEASURED 2026-08-30. Removing this means the appliance \
              cannot keep a standby.",
    },
    Deviation {
        item: "nornir-git",
        profiles: &[APPLIANCE, APPLIANCE_HTTP],
        why: "the SSH transport under the replication push, with nornir-git's own \
              `porcelain` feature off: russh + ssh-key, no gix. Same decision as the \
              entry above and it stands or falls with it.",
    },
    // The repacker's delta-encoder deviation (`gunnar-delta` on the appliance
    // graphs) lived here 2026-08-04 → 2026-08-10. The 2026-08-10 amputation removed the
    // packfile repack engine from the server (`gunnar-server/src/admin.rs::gc`
    // has the argument: the znippy tier's own `seal` + `gc` replace it), so the
    // appliance graphs no longer contain the encoder and the guard reported the
    // entry stale. The ban itself stands, unweakened, on every graph.
    // ── tonic's transport ───────────────────────────────────────────────────
    Deviation {
        item: "axum",
        profiles: &["*"],
        why: "tonic 0.13's `router` feature mounts gRPC services on axum. gRPC IS \
              HTTP/2, so this is the transport, not a surface of gunnar's own. The \
              rule survives the smart-HTTP transport unchanged, because what it \
              forbids is a REST API or a web UI, not git over HTTP: gunnar registers \
              no route with any framework, and `gunnar-http` speaks hyper directly \
              over four fixed URLs. Removing this means dropping tonic.",
    },
];

// ── profiles ────────────────────────────────────────────────────────────────

/// The feature set an appliance image is built with, and the one profile
/// whose contents decide what ships on bare metal.
pub const APPLIANCE: &str = "appliance";
/// The appliance **plus** the default-off smart-HTTP transport.
///
/// Its whole purpose is to be compared against [`APPLIANCE`]: the smart-HTTP
/// decision says an
/// appliance that does not want an HTTP surface must compile none of it, and
/// the claim that turning it on costs nothing is only worth making if the two
/// graphs are checked. See [`Report::render`], which prints both counts.
pub const APPLIANCE_HTTP: &str = "appliance+http";
/// Both halves on: what a developer's `cargo build` produces.
pub const DEFAULT_BUILD: &str = "default";
/// **The third deployment shape: an OS with an init system under gunnar.**
///
/// `--no-default-features --features server,systemd`, the argv of the
/// `gunnar-service` cell in `.nornir/build-variants.toml`. gunnar has three
/// shapes — container, OS-less appliance, and on-an-OS — and this is the only
/// one where a service manager exists to register with, so it is the only one
/// where D16's `systemd` feature is on.
///
/// It is resolved here for two reasons, and the second is the load-bearing one:
///
/// 1. the shape is built, so its graph should be checked like the others;
/// 2. it is the **witness** that `skidbladnir` in [`FORBIDDEN_IN_SERVER_GRAPH`]
///    can fire. That entry's whole job is to keep the systemd stack out of the
///    appliance graph, and a ban on a crate that is in no graph at all is
///    decoration — see [`check_rule_names_can_fire`]. The default build does not
///    contain `skidbladnir` (the feature is default-off, which is the point), so
///    without this profile the anti-phantom rule would correctly report the new
///    ban as unprovable.
pub const SERVICE: &str = "service";

/// Graphs a [`FORBIDDEN_IN_SERVER_GRAPH`] name may be shown to exist in.
///
/// The anti-phantom rule asks "could this ban ever fire?", and it answers by
/// finding the crate in a build gunnar really produces. One graph was enough
/// while every banned crate was client porcelain; `skidbladnir` is banned for a
/// different reason (a shape, not a half) and lives in a different build, so the
/// question is asked of the union rather than of one profile. A name absent from
/// **all** of these is decoration.
pub const WITNESS_PROFILES: &[&str] = &[DEFAULT_BUILD, SERVICE];

/// Which rule set applies to a resolved graph.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
    /// Only [`FORBIDDEN_ANYWHERE`]. The client half is a normal-OS program and
    /// gix's porcelain is exactly what it is made of.
    EitherHalf,
    /// [`FORBIDDEN_ANYWHERE`] plus [`FORBIDDEN_IN_SERVER_GRAPH`] and
    /// [`FORBIDDEN_GIX_FEATURE_TOKENS`].
    ServerHalf,
}

/// One `cargo tree` invocation, and what its output must contain for the
/// absence assertions over it to mean anything.
#[derive(Debug, Clone)]
pub struct Profile {
    pub name: String,
    /// Arguments after `cargo tree`, excluding `--manifest-path` and
    /// `-e normal`, which [`resolve`] always supplies.
    pub args: Vec<String>,
    pub scope: Scope,
    /// Packages that MUST be in the resolved graph. See the module docs on the
    /// empty-graph trap.
    pub expect_present: Vec<String>,
}

impl Profile {
    pub fn new(name: &str, args: &[&str], scope: Scope, expect_present: &[&str]) -> Self {
        Self {
            name: name.to_string(),
            args: args.iter().map(|s| s.to_string()).collect(),
            scope,
            expect_present: expect_present.iter().map(|s| s.to_string()).collect(),
        }
    }
}

/// The `S` (server) crates, the ones the no-OS rule applies to.
///
/// `gunnar-pack` left this list on 2026-08-10: it moved to `edda/crates/` in
/// the server/edda split and is no longer a workspace member. A per-crate
/// `cargo tree -p gunnar-pack` from here resolves it as an out-of-workspace
/// path dep, where feature unification and dev-dep resolution differ from a
/// member's and produce graphs no build compiles (its dev-only `gix` showed up
/// as violations). The whole-binary profiles still ban the same crates from
/// every graph that actually links it.
pub const SERVER_CRATES: &[&str] = &[
    "gunnar-store",
    "gunnar-core",
    "gunnar-wire",
    "gunnar-policy",
    "gunnar-ssh",
    // The smart-HTTP transport. An `S` crate like the rest: it is a
    // *listener*, it may end up on an appliance, and the no-OS rule
    // applies to it. Listed here so `cargo tree -p gunnar-http` is checked in
    // its own right, which catches a banned edge added to it before any binary
    // happens to reach it.
    "gunnar-http",
    "gunnar-grpc",
];

/// Every graph the guard resolves for gunnar itself.
///
/// Two kinds, and both are needed:
///
/// * **whole-binary profiles**: `gunnar-cli` under the appliance feature set
///   and under the default one. These are the graphs that are really compiled,
///   with resolver-2 feature unification applied across everything the binary
///   pulls in. Only here can the gix feature rule mean anything, because only
///   here is there a resolved `gix` to ask about.
/// * **per-crate profiles**: one `cargo tree -p <S crate>` each. These catch
///   an `S → gunnar-client` edge added to a crate that the appliance build does
///   not happen to reach yet, and they catch it in the crate that introduced
///   it rather than somewhere downstream.
pub fn gunnar_profiles() -> Vec<Profile> {
    let mut profiles = vec![
        Profile::new(
            APPLIANCE,
            &[
                "-p",
                "gunnar-cli",
                "--no-default-features",
                "--features",
                "server",
            ],
            Scope::ServerHalf,
            // Present in every honest appliance graph: the SSH listener, the
            // gRPC service, the runtime under both — and, since 2026-08-30, the
            // The replication push half. `gunnar-client` is a SENTINEL here
            // and not an oversight: this profile's job is now to assert that the
            // appliance links no `gix` WHILE linking the client edge that used
            // to be the only way to get one. Without the sentinel a feature that
            // quietly stopped resolving replication would make the `gix` absence
            // vacuous, which is the shape a red-first proof exists to catch.
            &[
                "gunnar-cli",
                "gunnar-ssh",
                "gunnar-grpc",
                "gunnar-client",
                "nornir-git",
                "tonic",
                "russh",
                "tokio",
            ],
        ),
        Profile::new(
            APPLIANCE_HTTP,
            &[
                "-p",
                "gunnar-cli",
                "--no-default-features",
                "--features",
                "server,http",
            ],
            Scope::ServerHalf,
            // The transport itself must be present, or this profile would be
            // asserting the absence of banned crates from a graph that does not
            // contain the thing being tested.
            &[
                "gunnar-cli",
                "gunnar-http",
                "gunnar-ssh",
                "gunnar-grpc",
                // Same sentinel, same reason, as the `appliance` profile above.
                "gunnar-client",
                "hyper",
                "tokio",
            ],
        ),
        Profile::new(
            DEFAULT_BUILD,
            &["-p", "gunnar-cli"],
            Scope::EitherHalf,
            // The client half MUST be here. If it were not, the appliance
            // profile's "gunnar-client is absent" would be proving nothing
            // about a feature split; it would just mean the crate is nowhere.
            &["gunnar-cli", "gunnar-client", "nornir-git", "gix"],
        ),
        Profile::new(
            SERVICE,
            &[
                "-p",
                "gunnar-cli",
                "--no-default-features",
                "--features",
                "server,systemd",
            ],
            // `EitherHalf`, and not because the rules are inconvenient here.
            // `ServerHalf` is the *no-OS* rule set — no shell, no service
            // manager, nothing that has to exist underneath — and this shape is
            // defined by having all three. Scoring it `ServerHalf` would mean
            // asserting `skidbladnir` is absent from the one build whose whole
            // purpose is to contain it: a contradiction, not a check. What still
            // binds is `FORBIDDEN_ANYWHERE` (rayon, libgit2, openssl, an HTTP
            // surface of gunnar's own), and the no-OS rules keep their teeth on the
            // shapes they are about — the `appliance` profile and every `S:`
            // crate profile, both unchanged.
            Scope::EitherHalf,
            // `skidbladnir` MUST be here, and that sentinel is what makes the
            // systemd ban on the appliance graph mean anything: if the feature
            // stopped resolving it, this profile goes vacuous and says so,
            // instead of quietly leaving the ban unwitnessed and unfireable.
            &[
                "gunnar-cli",
                "gunnar-ssh",
                "gunnar-grpc",
                "skidbladnir",
                "tokio",
            ],
        ),
    ];
    for krate in SERVER_CRATES {
        profiles.push(Profile::new(
            &format!("S:{krate}"),
            &["-p", krate],
            Scope::ServerHalf,
            &[krate],
        ));
    }
    profiles
}

// ── the resolved graph ──────────────────────────────────────────────────────

/// One package as the resolver actually resolved it, for one feature set.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Node {
    pub name: String,
    pub version: String,
    /// The features Cargo enabled on this package **in this profile**, not the
    /// ones its manifest declares.
    pub features: BTreeSet<String>,
}

/// A resolved dependency graph for one [`Profile`].
#[derive(Debug, Clone)]
pub struct Graph {
    pub profile: String,
    pub nodes: Vec<Node>,
}

impl Graph {
    pub fn get(&self, name: &str) -> Option<&Node> {
        self.nodes.iter().find(|n| n.name == name)
    }
    pub fn contains(&self, name: &str) -> bool {
        self.get(name).is_some()
    }

    /// Parse `cargo tree --prefix none -f "{p}|{f}"` output.
    ///
    /// Lines look like `gix v0.86.0|attributes,blob-diff,index`, or
    /// `gunnar-cli v0.1.0 (/path/to/crate)|server`, and a package printed a
    /// second time carries a trailing ` (*)` where cargo elides its subtree.
    /// Features are unioned across repeats, which matters because the elided
    /// occurrence prints the same set and a future cargo need not.
    pub fn parse(profile: &str, text: &str) -> Graph {
        let mut by_name: BTreeMap<String, Node> = BTreeMap::new();
        for line in text.lines() {
            let line = line.trim();
            if line.is_empty() {
                continue;
            }
            // `-e normal` should suppress these, but a header line would parse
            // as a package named `[build-dependencies]` and pollute the graph.
            if line.starts_with('[') {
                continue;
            }
            let Some((left, right)) = line.split_once('|') else {
                continue;
            };
            let mut words = left.split_whitespace();
            let (Some(name), Some(version)) = (words.next(), words.next()) else {
                continue;
            };
            if !version.starts_with('v') {
                continue;
            }
            let features = right.trim().trim_end_matches("(*)").trim();
            let entry = by_name.entry(name.to_string()).or_insert_with(|| Node {
                name: name.to_string(),
                version: version.trim_start_matches('v').to_string(),
                features: BTreeSet::new(),
            });
            entry.features.extend(
                features
                    .split(',')
                    .map(str::trim)
                    .filter(|f| !f.is_empty())
                    .map(str::to_string),
            );
        }
        Graph {
            profile: profile.to_string(),
            nodes: by_name.into_values().collect(),
        }
    }
}

/// Run `cargo tree` for one profile and parse the result.
///
/// `-e normal` excludes dev- and build-dependencies. That is required, not
/// tidiness: `gunnar-pack`, `gunnar-policy`, `gunnar-wire` and `gunnar-cli` all
/// carry `gix` as a dev-dependency for fixtures, and a dev-dependency is never
/// linked into an appliance image. A guard reading raw `cargo metadata` would
/// trip on every one of them and be softened until it said nothing.
pub fn resolve(manifest_path: &Path, profile: &Profile) -> Result<Graph, String> {
    let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
    let output = Command::new(&cargo)
        .arg("tree")
        .arg("--manifest-path")
        .arg(manifest_path)
        .args(&profile.args)
        .args(["-e", "normal"])
        .args(["--prefix", "none"])
        .args(["-f", "{p}|{f}"])
        .output()
        .map_err(|e| format!("could not run `{cargo} tree`: {e}"))?;
    if !output.status.success() {
        return Err(format!(
            "`cargo tree` failed for profile `{}`:\n{}",
            profile.name,
            String::from_utf8_lossy(&output.stderr)
        ));
    }
    Ok(Graph::parse(
        &profile.name,
        &String::from_utf8_lossy(&output.stdout),
    ))
}

// ── findings ────────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rule {
    /// The graph did not contain something it must contain, so every
    /// "absent, therefore fine" below it would have been vacuous.
    VacuousGraph,
    ForbiddenAnywhere,
    ForbiddenInServerGraph,
    GixPorcelainFeature,
    /// A [`Deviation`] that matched nothing. The rule it excused is being
    /// obeyed now; delete the entry.
    StaleDeviation,
    /// A rule names a crate that appears in NO build, so it can never fire.
    /// See [`check_rule_names_can_fire`].
    PhantomRule,
    /// A declared allowance is bigger than what the tree actually spends, so it
    /// excuses nothing and silently permits the NEXT crate. Same family as
    /// [`Rule::StaleDeviation`]: an exception has to shrink when its cause does.
    /// See [`check_appliance_growth`].
    SlackBudget,
}

impl fmt::Display for Rule {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s = match self {
            Rule::VacuousGraph => "vacuous graph",
            Rule::ForbiddenAnywhere => "forbidden anywhere",
            Rule::ForbiddenInServerGraph => "forbidden in the S graph",
            Rule::GixPorcelainFeature => "gix porcelain feature in the S graph",
            Rule::StaleDeviation => "stale tracked deviation",
            Rule::PhantomRule => "phantom rule name",
            Rule::SlackBudget => "slack budget",
        };
        f.write_str(s)
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Violation {
    pub profile: String,
    pub rule: Rule,
    /// A package name, or `gix:<feature token>`.
    pub item: String,
    pub why: String,
}

impl fmt::Display for Violation {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "[{}] {}: {} — {}",
            self.profile, self.rule, self.item, self.why
        )
    }
}

/// The outcome of checking one or more profiles.
#[derive(Debug, Clone, Default)]
pub struct Report {
    /// Violations with no matching [`Deviation`]. Any of these fails the guard.
    pub violations: Vec<Violation>,
    /// Violations excused by a [`Deviation`], carried so they are printed and
    /// stay visible rather than silently dropped.
    pub tracked: Vec<Violation>,
    /// Deviation entries that excused nothing.
    pub stale_deviations: Vec<Violation>,
    /// Rule names that could never fire. See [`check_rule_names_can_fire`].
    pub phantom_rules: Vec<Violation>,
    /// How many packages each profile resolved to, in the order they were
    /// checked: `(profile, total, third-party)`. Printed, and asserted against
    /// by [`check_appliance_growth`].
    ///
    /// **Third-party is the number that governs.** gunnar's own crates are what
    /// a feature is *for*; the dependency budget is about what it drags in behind
    /// them. Both are printed so neither can be quietly traded for the other.
    pub sizes: Vec<(String, usize, usize)>,
}

impl Report {
    /// Does the guard pass? Stale deviations count as failure on purpose: an
    /// exception nobody needs is the identity-value trap wearing a comment. So
    /// do phantom rule names, for the same reason from the other side.
    pub fn is_clean(&self) -> bool {
        self.violations.is_empty()
            && self.stale_deviations.is_empty()
            && self.phantom_rules.is_empty()
    }

    /// The resolved package count for one profile: `(total, third-party)`.
    pub fn size(&self, profile: &str) -> Option<(usize, usize)> {
        self.sizes
            .iter()
            .find(|(name, _, _)| name == profile)
            .map(|(_, total, third_party)| (*total, *third_party))
    }

    pub fn render(&self) -> String {
        let mut out = String::new();
        for (profile, total, third_party) in &self.sizes {
            out.push_str(&format!(
                "  size     {profile:<18} {total} crates ({third_party} third-party)\n"
            ));
        }
        for v in &self.tracked {
            out.push_str(&format!("  tracked  {v}\n"));
        }
        for v in &self.stale_deviations {
            out.push_str(&format!("  STALE    {v}\n"));
        }
        for v in &self.phantom_rules {
            out.push_str(&format!("  PHANTOM  {v}\n"));
        }
        for v in &self.violations {
            out.push_str(&format!("  VIOLATION {v}\n"));
        }
        out
    }
}

// ── the check ───────────────────────────────────────────────────────────────

fn deviation_for(item: &str, profile: &str) -> Option<&'static Deviation> {
    TRACKED_DEVIATIONS
        .iter()
        .find(|d| d.item == item && d.profiles.iter().any(|p| *p == "*" || *p == profile))
}

/// **What the smart-HTTP transport is allowed to cost, in third-party
/// crates.** See [`check_appliance_growth`] for the whole argument; the short
/// version is [`HTTP_GROWTH_REASON`].
///
/// Zero from 2026-08-03 to 2026-08-30, one for a day, and **zero again**: the
/// crate it was spent on is gone. See [`HTTP_GROWTH_REASON`].
pub const HTTP_GROWTH_BUDGET: usize = 0;

/// Why [`HTTP_GROWTH_BUDGET`] is what it is.
///
/// It was `rustls-pemfile` for one day. `gunnar-http` reads its certificate
/// chain and private key with **`rustls-pki-types`** now — already in the graph,
/// because rustls itself depends on it, and its PEM support is unconditional
/// rather than a feature (there is no `pem` feature in 1.15; naming one would
/// not compile). So the feature adds nothing again.
///
/// Lowered back to zero rather than left as unused slack, because this rule's
/// own argument says an allowance nobody is spending is what silently permits
/// the NEXT crate.
pub const HTTP_GROWTH_REASON: &str =
    "nothing: `gunnar-http` reads PEM with `rustls-pki-types`, which rustls already puts in \
     the graph, so turning smart HTTP on costs no third-party crate";

/// **Turning smart HTTP on must not grow the appliance beyond its budget.**
///
/// The claim the transport was built on is that hyper, `hyper-util`,
/// `http-body-util`, `http`, `bytes` and `base64` are already in the appliance
/// graph — tonic pulls them in, because gRPC is HTTP/2 — so a second transport
/// over them costs no crate. A claim like that is worth exactly as much as the
/// check behind it, and the check is one subtraction over two resolved graphs.
///
/// It compares **counts and not names** deliberately: a rule listing what HTTP
/// is allowed to add would have to be edited every time it added something,
/// which is a rule that agrees with whatever the tree does. A count cannot be
/// edited into agreement without the number moving where an operator sees it.
///
/// **Third-party crates only.** `gunnar-http` itself, and the `gunnar-ssh` and
/// `gunnar-wire` edges it brings with it, are the feature; counting them would
/// make the rule read *"a new crate may not exist"*, which is not what the budget
/// says and cannot be satisfied. Measured 2026-08-03: those three gunnar crates
/// were the **entire** delta, and the third-party count did not move at all.
///
/// # 2026-08-30: the budget is ONE, and the one is named
///
/// It stopped being zero on `b2173723`, *"gunnar terminates its own TLS: one
/// executable, its own issuer, ALPN h2"*. Terminating TLS means reading a
/// certificate chain and a private key out of PEM, and `gunnar-http/src/lib.rs`
/// does it with **`rustls-pemfile`** — the only third-party crate the feature
/// adds. It is not something to hand-roll: a PEM parser that is subtly wrong
/// about a chain is a TLS server that is subtly wrong about who it is.
///
/// So the claim is amended rather than silenced, which is what this rule's
/// own failure message asks for (*"either drop the new dependency or change the
/// budget
/// and say what it costs"*). What it costs is **one crate**, and
/// [`HTTP_GROWTH_BUDGET`] is where an operator reads that number.
///
/// **The budget is held from BOTH sides.** Over it is a violation, as before.
/// *Under* it is also a violation — a `SlackBudget` — because an allowance that
/// excuses nothing is the same rot [`Report::stale_deviations`] exists to
/// prevent: the day the PEM parser leaves, this number must go back to zero
/// rather than sit there quietly permitting the next crate.
///
/// The count-not-names property survives. The budget is a number, not a list, so
/// a second crate cannot be absorbed by editing a list of allowed names until
/// the rule agrees with whatever the tree does.
///
/// **When this changed the tree was ALREADY red**, and by the same one crate:
/// `cargo xtask guard` at `922c3003` (before the appliance gained
/// replication) reports *"the smart-HTTP feature adds 1 third-party crate(s)
/// (310 without it, 311 with it)"*. MEASURED 2026-08-30 by running the guard at
/// that commit in a worktree. The +4 packages replication put in both graphs
/// moved the absolute counts to 313/314 and left the delta exactly where it was.
pub fn check_appliance_growth(report: &Report) -> Vec<Violation> {
    let (Some((_, plain)), Some((_, with_http))) =
        (report.size(APPLIANCE), report.size(APPLIANCE_HTTP))
    else {
        // Both profiles are in `gunnar_profiles()`. Missing one means the guard
        // was run over a subset, and reporting nothing would be reporting that
        // the appliance did not grow because nobody looked.
        return vec![Violation {
            profile: APPLIANCE_HTTP.to_string(),
            rule: Rule::VacuousGraph,
            item: "appliance-growth".to_string(),
            why: "the appliance and appliance+http graphs were not both resolved, so the \
                  'HTTP costs no crate' claim was not checked at all"
                .to_string(),
        }];
    };
    let grown = with_http.saturating_sub(plain);
    if grown > HTTP_GROWTH_BUDGET {
        return vec![Violation {
            profile: APPLIANCE_HTTP.to_string(),
            rule: Rule::ForbiddenAnywhere,
            item: "appliance-growth".to_string(),
            why: format!(
                "the smart-HTTP feature adds {grown} third-party crate(s) to the \
                 appliance image ({plain} without it, {with_http} with it), and the declared \
                 budget is {HTTP_GROWTH_BUDGET} ({HTTP_GROWTH_REASON}). It is supposed to \
                 ride the hyper tonic already pulls in for gRPC. Either drop the new \
                 dependency or raise the budget in `xtask/src/guard.rs` AND say there what \
                 the new crate is and why it is worth a crate"
            ),
        }];
    }
    // `budget - grown > 0`, not `grown < budget`: with the budget at 0 the comparison is one
    // rustc 1.98's clippy refuses outright (absurd_extreme_comparisons), and the check must
    // still fire the day the budget is raised.
    if HTTP_GROWTH_BUDGET.saturating_sub(grown) > 0 {
        return vec![Violation {
            profile: APPLIANCE_HTTP.to_string(),
            rule: Rule::SlackBudget,
            item: "appliance-growth".to_string(),
            why: format!(
                "the smart-HTTP growth budget is {HTTP_GROWTH_BUDGET} third-party \
                 crate(s) and the feature only adds {grown} ({plain} without it, \
                 {with_http} with it). The allowance excuses nothing, so lower \
                 `HTTP_GROWTH_BUDGET` to {grown}: {HTTP_GROWTH_REASON} is no longer the \
                 price, and an unused allowance is what silently permits the NEXT crate"
            ),
        }];
    }
    Vec::new()
}

/// Check one resolved graph. Returns every finding, tracked or not.
pub fn check_graph(profile: &Profile, graph: &Graph) -> Vec<Violation> {
    let mut found = Vec::new();

    for must in &profile.expect_present {
        if !graph.contains(must) {
            found.push(Violation {
                profile: profile.name.clone(),
                rule: Rule::VacuousGraph,
                item: must.clone(),
                why: format!(
                    "`{must}` is missing from the resolved graph ({} packages), so every \
                     absence asserted over this profile would prove nothing",
                    graph.nodes.len()
                ),
            });
        }
    }
    // A graph that failed its own sentinels cannot be reasoned about further.
    if !found.is_empty() {
        return found;
    }

    for (name, why) in FORBIDDEN_ANYWHERE {
        if graph.contains(name) {
            found.push(Violation {
                profile: profile.name.clone(),
                rule: Rule::ForbiddenAnywhere,
                item: (*name).to_string(),
                why: (*why).to_string(),
            });
        }
    }

    if profile.scope == Scope::ServerHalf {
        for (name, why) in FORBIDDEN_IN_SERVER_GRAPH {
            if graph.contains(name) {
                found.push(Violation {
                    profile: profile.name.clone(),
                    rule: Rule::ForbiddenInServerGraph,
                    item: (*name).to_string(),
                    why: (*why).to_string(),
                });
            }
        }
        if let Some(gix) = graph.get("gix") {
            for (token, why) in FORBIDDEN_GIX_FEATURE_TOKENS {
                if let Some(feature) = gix.features.iter().find(|f| f.contains(token)) {
                    found.push(Violation {
                        profile: profile.name.clone(),
                        rule: Rule::GixPorcelainFeature,
                        item: format!("gix:{token}"),
                        why: format!("{why} (enabled as `{feature}` on gix {})", gix.version),
                    });
                }
            }
        }
    }

    found
}

/// **Every rule name must be capable of firing.**
///
/// This came from `crates/gunnar-cli/tests/zero_shell_guard.rs` when the two
/// rule tables were merged, and it is the other half of the staleness check:
/// [`Report::stale_deviations`] catches an *exception* that excuses nothing,
/// this catches a *rule* that forbids nothing.
///
/// Each name in [`FORBIDDEN_IN_SERVER_GRAPH`] is looked for in the graphs of
/// [`WITNESS_PROFILES`] — builds gunnar really produces, which genuinely contain
/// those crates. The DEFAULT build carries `gunnar-client` and therefore gix's
/// whole porcelain; the `service` build carries `skidbladnir`. If a name cannot
/// be found in **any** of them, the string is wrong (a typo, or a crate renamed
/// upstream) and the entry is decoration: it would report "absent" from every
/// build forever, including one that is actually broken.
///
/// The union, rather than one graph, because the table now bans crates for two
/// different reasons. `gix-worktree` is banned for the *half* it belongs to and
/// is witnessed by the client build; `skidbladnir` is banned for the *shape* it
/// belongs to and is witnessed by the service build. Asking only the default
/// build would have reported the systemd ban as a phantom — correctly, since the
/// feature is default-off — and the honest fix is to resolve the shape that has
/// it rather than to weaken the question.
///
/// [`FORBIDDEN_ANYWHERE`] is deliberately **not** checked this way. Those
/// crates are absent from every gunnar graph by design (that is the whole
/// point of the rule), so there is no honest tree to find them in. Their
/// ability to fire is proven instead by the fixtures in
/// `xtask/tests/fixtures/`, which really do resolve `rayon` from crates.io.
pub fn check_rule_names_can_fire(witnesses: &[&Graph]) -> Vec<Violation> {
    let where_ = witnesses
        .iter()
        .map(|g| g.profile.as_str())
        .collect::<Vec<_>>()
        .join(", ");
    FORBIDDEN_IN_SERVER_GRAPH
        .iter()
        .filter(|(name, _)| !witnesses.iter().any(|g| g.contains(name)))
        .map(|(name, why)| Violation {
            profile: where_.clone(),
            rule: Rule::PhantomRule,
            item: (*name).to_string(),
            why: format!(
                "`{name}` is in no build at all — not in {where_} — so the S-graph rule \
                 naming it can never fire. That is decoration, not a check. Either the \
                 name is wrong, the crate no longer exists upstream, or the shape that \
                 would carry it is not resolved by any profile. It was: {why}"
            ),
        })
        .collect()
}

/// Resolve and check every profile, sorting findings into tracked and not.
///
/// `self_check` should be on for gunnar's own graph and off for a fixture. It
/// turns on the two meta-rules, staleness and [`check_rule_names_can_fire`],
/// and neither is meaningful over a fixture: a fixture exercises one rule, so
/// every unrelated deviation would be reported stale and every rule name it
/// does not happen to contain would be reported phantom.
pub fn run(manifest_path: &Path, profiles: &[Profile], self_check: bool) -> Result<Report, String> {
    let mut report = Report::default();
    let mut used: BTreeSet<(String, String)> = BTreeSet::new();
    let mut witnesses: Vec<Graph> = Vec::new();

    for profile in profiles {
        let graph = resolve(manifest_path, profile)?;
        report.sizes.push((
            profile.name.clone(),
            graph.nodes.len(),
            graph
                .nodes
                .iter()
                .filter(|node| !node.name.starts_with("gunnar-"))
                .count(),
        ));
        let found = check_graph(profile, &graph);

        // The anti-phantom check needs graphs that are known good. A vacuous
        // one would report every rule name as phantom and bury the real
        // finding under fourteen false ones.
        if self_check
            && WITNESS_PROFILES.contains(&profile.name.as_str())
            && !found.iter().any(|v| v.rule == Rule::VacuousGraph)
        {
            witnesses.push(graph.clone());
        }

        for violation in found {
            match deviation_for(&violation.item, &violation.profile) {
                Some(dev) if violation.rule != Rule::VacuousGraph => {
                    used.insert((dev.item.to_string(), violation.profile.clone()));
                    report.tracked.push(Violation {
                        why: format!("{} [tracked: {}]", violation.why, dev.why),
                        ..violation
                    });
                }
                _ => report.violations.push(violation),
            }
        }
    }

    if self_check {
        // Only over gunnar's own profile set. A fixture workspace has neither
        // appliance profile, and reporting "the growth claim was not checked"
        // there would be a violation about a claim the fixture never made.
        //
        // `DEFAULT_BUILD` is the discriminator because it is already the one
        // the anti-phantom rule uses to mean *this is the real run*, so there
        // is one answer to that question and not two. A real run that had lost
        // its appliance profiles still fails, loudly, inside the check.
        if profiles.iter().any(|p| p.name == DEFAULT_BUILD) {
            report.violations.extend(check_appliance_growth(&report));
        }
        // The anti-phantom rule, over the union of every witness graph that
        // resolved cleanly.
        //
        // ⚠ A self-check that resolved FEWER witnesses than [`WITNESS_PROFILES`]
        // names has a hole in it, and the hole is silent: each missing graph
        // takes its crates with it, and every ban only that graph could witness
        // reads as a phantom — or, worse, if the profile is simply dropped from
        // the set, the ban is never questioned at all. So the shortfall is
        // itself reported, by name, before the per-rule check runs.
        for want in WITNESS_PROFILES {
            if !witnesses.iter().any(|g| g.profile == *want) {
                report.phantom_rules.push(Violation {
                    profile: (*want).to_string(),
                    rule: Rule::PhantomRule,
                    item: (*want).to_string(),
                    why: format!(
                        "the `{want}` profile resolved no usable graph, so every S-graph \
                         rule that only IT could witness is unproven. The table is not \
                         checked; it is merely unquestioned"
                    ),
                });
            }
        }
        let refs: Vec<&Graph> = witnesses.iter().collect();
        if !refs.is_empty() {
            report
                .phantom_rules
                .extend(check_rule_names_can_fire(&refs));
        }
        for dev in TRACKED_DEVIATIONS {
            let matched = profiles.iter().any(|p| {
                (dev.profiles.contains(&"*") || dev.profiles.contains(&p.name.as_str()))
                    && used.contains(&(dev.item.to_string(), p.name.clone()))
            });
            if !matched {
                report.stale_deviations.push(Violation {
                    profile: dev.profiles.join(","),
                    rule: Rule::StaleDeviation,
                    item: dev.item.to_string(),
                    why: format!(
                        "this exception excused nothing. The rule is obeyed now, so delete \
                         the entry. It was: {}",
                        dev.why
                    ),
                });
            }
        }
    }

    Ok(report)
}

/// The repository root, derived from this crate's manifest directory.
pub fn workspace_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("xtask/ has a parent")
        .to_path_buf()
}

/// `cargo xtask guard`: check gunnar itself.
pub fn run_gunnar() -> Result<Report, String> {
    run(
        &workspace_root().join("Cargo.toml"),
        &gunnar_profiles(),
        true,
    )
}