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

nordisk/gunnar

xtask/src/engine_seam.rs

//! **The engine seam, enforced: `gunnar-server` and `gunnar-wire` speak the
//! contract and nothing else.**
//!
//! # The five days this exists to end
//!
//! There is one storage contract in the constellation — `git-storage-trait`'s
//! `GitOps` and `GitServe` — and two implementations of it. The rule is that
//! the serving tier reaches storage *only* through the contract, so whatever an
//! engine does behind it (Arrow-IPC extents, or `gix-odb` packs) is the
//! engine's business and nobody else's.
//!
//! Every day between 2026-08-05 and 2026-08-10 the same question was asked:
//! **why is gix still in the appliance graph?** Each day it was answered by
//! reading call sites, each day the call sites were clean, and each day the
//! graph still had it. The answer, measured on 2026-08-10 and recorded here so
//! it is never rediscovered:
//!
//! > **Nothing was calling it. Four `pub` items were *naming* it.**
//!
//! `gunnar-wire` named `gunnar_pack::{Error, IndexedPack, WriteOutcome,
//! DEFAULT_ALLOC_LIMIT_BYTES}` and `gunnar-server` named
//! `gix_pack::data::Version` — a two-variant enum — in one struct field.
//! No `index_pack`, no `PackWriter`, no `write_pack_copy`, no
//! `iter_from_counts`: `upload-pack` had been emitting through
//! `GitServe::emit_pack` since the engine seam landed, and
//! [`crate::pack_pipeline`]'s own header says the same thing from the other
//! side ("Rules 1 and 2 now have **no live call site anywhere in gunnar**").
//!
//! **Cargo links what a manifest names, not what a call graph reaches.** Those
//! five names put `gix-pack` — and behind it `gix-diff`, `gix-traverse`,
//! `gix-revwalk`, `gix-commitgraph`, `gix-fs`, `gix-tempfile`, `gix-path`,
//! `gix-chunk`, `gix-zlib` and `gix-features/parallel` — into **every** build
//! including `--no-default-features --features server,engine-znippy`, the
//! appliance's, whose entire premise is that the pack pipeline is not there.
//! MEASURED at `e80f175`: `cargo tree -p gunnar-server -e normal
//! --no-default-features --features server,engine-znippy | grep -c gix` → 67,
//! twenty distinct gix crates. After the names moved to `gunnar_wire::pack`:
//! **25, and ten.**
//!
//! # Why this guard reads BOTH source and manifests
//!
//! [`crate::guard`] reads resolved graphs and [`crate::pack_pipeline`] reads
//! call sites. This one is deliberately the pair, because the defect it is
//! named after was invisible to either alone:
//!
//! * a **call-site** guard was green the whole time — there were no calls;
//! * a **graph** guard goes red, but points at `gix-pack` twelve edges away and
//!   cannot say which line of gunnar asked for it.
//!
//! So [`Rule::PackPipeline`] watches the source and [`Rule::ManifestEdge`]
//! watches the two manifests, and the manifest half is the one that would have
//! ended this in a day: it fails on the *line that adds the dependency*, in the
//! file where somebody typed it, and says what it costs.
//!
//! # The second name, 2026-08-10: `gix-object`
//!
//! This module shipped saying `gix-object` stays, and the argument was sound:
//! `CommitRef` and `TreeRefIter` parse bytes that came out of `GitServe::read`
//! through the contract, and **parsing bytes you were handed is not reaching
//! around the thing that handed them to you.** That is still true. What it
//! left out is the *price*.
//!
//! `gix-object` is seven crates — itself plus `gix-actor`, `gix-date`,
//! `gix-error`, `gix-hashtable`, `gix-utils`, `gix-validate` — and gunnar's
//! serving tier asked five questions of it: a commit's tree, a commit's
//! parents, a commit's committer time, a tag's target, and a tree's
//! `(oid, kind)` pairs. Nothing in the appliance ever *writes* an object. Those
//! five answers are `gunnar_store::object` now, one file with no dependency of
//! its own, tested against `gix-object` as a dev-dependency oracle in
//! `gunnar-store/tests/object_decode.rs` — which is the only arrangement under
//! which "gunnar decodes a commit correctly" is a claim anybody has checked.
//!
//! [`Rule::ObjectModel`] is what keeps it out, and its crate list is wider than
//! [`SERVING_TIER`] by one: `gunnar-policy` moved off `gix-object` in the same
//! change and is in the appliance graph, so a rule that did not cover it would
//! let the seven crates back in through the gate. `gunnar-store` is
//! deliberately **not** covered: it keeps `gix-object` as an *optional*
//! dependency behind `conformance`, which is off in every appliance build and
//! is a test harness, not a decode.
//!
//! # What is NOT banned
//!
//! `gix-hash` stays. `ObjectId` is a 32-byte inline array and `Kind` is two
//! variants; the contract's `Oid<'a> = &'a [u8]` is a *borrow* and cannot be
//! what an owned id in a returned struct, a `HashSet` key or a `BinaryHeap`
//! element is spelled as. Replacing it is a rename across nine crates plus
//! `gunnar-bitmap` in edda, and — unlike `gix-object` — it buys **three**
//! crates, not seven.
//!
//! `storage-git-gix` and `znippy-plugin-git` stay too — they are the engines,
//! and `--features engine-gix` is *supposed* to have gix. What
//! [`Rule::EngineByName`] says is only that exactly two files may name them:
//! the adapters in `crates/gunnar-server/src/storage/`, which is what
//! `storage.rs` already claims to be ("the *only* place in gunnar that names an
//! implementation of it") and which was, until this guard, a claim nothing
//! checked.
//!
//! # This guard's own red
//!
//! `xtask/tests/engine_seam.rs` runs every rule against the code as it stood at
//! `e80f175` — the real prior text, not an invented sample — and asserts it goes
//! red, then runs the same rules over the tree and asserts green with a non-zero
//! file count. Zero findings over zero files is shaped exactly like a clean
//! sweep, which is [`crate::guard`]'s empty-graph trap in its other form.

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

/// The two crates this seam is about: gunnar's serving tier.
///
/// `gunnar-store`, `gunnar-core` and `gunnar-policy` are deliberately **not**
/// here. They are below the seam or beside it; `gunnar-store` is where the
/// contract is re-exported from and `gunnar-policy` reads objects through it.
/// This rule is about the two crates that *serve a transport*.
pub const SERVING_TIER: &[&str] = &["gunnar-server", "gunnar-wire"];

/// Crate paths that mean **a pack pipeline**, banned in [`SERVING_TIER`] source.
///
/// Each is a module path as it appears in Rust source, trailing `::` included,
/// so the token cannot match a crate name inside a string or a manifest.
pub const PACK_PIPELINE_PATHS: &[(&str, &str)] = &[
    (
        "gunnar_pack::",
        "the pack writer and indexer. The serve path emits through \
         `GitServe::emit_pack` and the receive path stores through \
         `GitOps::put_pack`; both are the contract, and neither needs a second \
         pack implementation linked beside it. Naming one type from this crate \
         links `gix-pack` and ten more gix crates into the appliance",
    ),
    (
        "gix_pack::",
        "gix's pack pipeline. `iter_from_counts` fans out per request, which the \
         serving tier does not do, and `FromEntriesIter` hardcodes sha1dc, which \
         costs collision detection the trailer cannot benefit from. The engine owns \
         the pack format; this tier owns the protocol",
    ),
    (
        "gix_odb::",
        "gix's object database — an ENGINE's storage, reached without the \
         contract. `storage-git-gix` is where this belongs",
    ),
    (
        "gix_ref::",
        "gix's ref store. Refs come from `GitOps::refs` / `put_refs_cas` and \
         `HEAD` from `GitServe::head`",
    ),
];

/// Engine crates, and the only directory allowed to name them.
///
/// Not banned — *located*. An adapter has to name the thing it adapts.
pub const ENGINE_PATHS: &[(&str, &str)] = &[
    ("storage_git_gix::", "the gix engine"),
    ("znippy_plugin_git::", "the znippy engine"),
];

/// The one directory where [`ENGINE_PATHS`] may appear.
///
/// `crates/gunnar-server/src/storage.rs`'s own header: *"This module is the
/// only place in gunnar that names an implementation of it."* This is that
/// sentence, checked.
pub const ADAPTER_DIR: &str = "crates/gunnar-server/src/storage/";

/// Crates that must not be a **normal** dependency of a [`SERVING_TIER`]
/// manifest.
///
/// `[dev-dependencies]` is untouched on purpose and is not a loophole: a
/// dev-dependency never reaches a shipped artifact, and `gunnar-pack` as an
/// *outside oracle* that indexes a pack gunnar produced is exactly the twinning
/// one implementation asks for. The rule is about what gets linked.
pub const BANNED_NORMAL_DEPS: &[(&str, &str)] = &[
    (
        "gunnar-pack",
        "a second pack implementation, linked but not called. This edge is what \
         put `gix-pack`, `gix-diff`, `gix-traverse`, `gix-revwalk`, \
         `gix-commitgraph`, `gix-fs`, `gix-tempfile`, `gix-path`, `gix-chunk`, \
         `gix-zlib` and `gix-features/parallel` into the appliance graph for \
         five days with zero call sites behind them. Keep it in \
         `[dev-dependencies]`, where it is the outside oracle it should be",
    ),
    (
        "gix-pack",
        "gix's pack pipeline (see `gunnar-pack`). The version enum this was \
         once here for is `gunnar_wire::pack::PackVersion` now",
    ),
    (
        "gix-odb",
        "an engine's object database, not the serving tier's",
    ),
    ("gix-ref", "an engine's ref store, not the serving tier's"),
    (
        "gix-actor",
        "a commit signature parser. It arrives under `gix-object` anyway, which \
         is what decodes the commit; asking for it by name asserted a need the \
         serving tier does not have",
    ),
];

/// Crates that must not name `gix-object`, in source or in a manifest.
///
/// [`SERVING_TIER`] plus `gunnar-policy`. The seam rules are about the two
/// crates that serve a transport; this one is about a **cost in the appliance
/// graph**, and the gate is in that graph. `gunnar-store` is absent on purpose:
/// its `gix-object` is optional, behind `conformance`, and off in every build
/// that ships.
pub const DECODER_TIER: &[&str] = &["gunnar-server", "gunnar-wire", "gunnar-policy"];

/// What replaced it, named in every failure so the fix is in the message.
pub const OBJECT_DECODER: &str = "gunnar_store::object";

/// Why `gix-object` is not worth its price here. One sentence, two places.
const OBJECT_MODEL_WHY: &str = "`gix-object` costs seven crates — itself, `gix-actor`, `gix-date`, `gix-error`, `gix-hashtable`, `gix-utils`, `gix-validate` — in every appliance build, for four grammars gunnar READS and never writes. `gunnar_store::object` answers the five questions this tier actually asks (a commit's tree, its parents, its committer time, a tag's target, a tree's `(oid, kind)` pairs) in one file with no dependency of its own, and is tested against `gix-object` as an outside oracle in `gunnar-store/tests/object_decode.rs`. Decode there";

/// One place the seam was crossed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
    /// Path as given to the scanner, or the sample name for the pure checks.
    pub file: String,
    /// 1-based line.
    pub line: usize,
    /// Which rule fired. Asserted by the tests, so "a violation" is never
    /// enough to satisfy them.
    pub rule: Rule,
    /// Why it fired, phrased so a failure explains itself without this file.
    pub why: String,
}

/// Which property of the seam was broken.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rule {
    /// [`SERVING_TIER`] source names a [`PACK_PIPELINE_PATHS`] entry.
    PackPipeline,
    /// [`ENGINE_PATHS`] named outside [`ADAPTER_DIR`].
    EngineByName,
    /// A [`BANNED_NORMAL_DEPS`] entry in a [`SERVING_TIER`] manifest's
    /// `[dependencies]`.
    ManifestEdge,
    /// `gix-object` named by a [`DECODER_TIER`] crate, in source or in a
    /// manifest's `[dependencies]`.
    ///
    /// Both halves, for the reason this whole module exists: the source half
    /// says *"stop decoding with it"* and the manifest half says *"stop paying
    /// for it"*, and on 2026-08-10 the two were not the same sentence — the
    /// serving tier had no live `gunnar_pack::` call site for months while the
    /// manifest edge kept eleven crates in the graph.
    ObjectModel,
}

impl std::fmt::Display for Finding {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{}:{} [{:?}] {}",
            self.file, self.line, self.rule, self.why
        )
    }
}

/// Is this path inside one of the [`SERVING_TIER`] crates' `src/`?
fn in_serving_tier(rel: &str) -> bool {
    SERVING_TIER
        .iter()
        .any(|c| rel.starts_with(&format!("crates/{c}/src/")))
}

/// Is this path inside one of the [`DECODER_TIER`] crates' `src/`?
fn in_decoder_tier(rel: &str) -> bool {
    DECODER_TIER
        .iter()
        .any(|c| rel.starts_with(&format!("crates/{c}/src/")))
}

/// The [`DECODER_TIER`] crate this manifest belongs to, if any.
fn decoder_tier_manifest(rel: &str) -> Option<&'static str> {
    DECODER_TIER
        .iter()
        .find(|c| rel == format!("crates/{c}/Cargo.toml"))
        .copied()
}

/// Check one source file's text. Pure, so the red-first proof needs no fixture
/// tree.
///
/// **Comments are stripped**, and that is load-bearing rather than tidy: this
/// module's own header names `gunnar_pack::` and `gix_pack::` a dozen times,
/// `gunnar_wire::pack`'s header explains what naming them cost, and a guard
/// that fired on its own explanation would be switched off within a day. The
/// same reasoning as [`crate::pack_pipeline::check_source`], and the same
/// single-line `//` split, because a rustdoc line and a code line are
/// distinguishable at exactly that point.
pub fn check_source(file: &str, text: &str) -> Vec<Finding> {
    let mut findings = Vec::new();
    let serving = in_serving_tier(file);
    let decoder = in_decoder_tier(file);
    if !serving && !decoder {
        return findings;
    }
    let in_adapter = file.starts_with(ADAPTER_DIR);

    for (i, line) in text.lines().enumerate() {
        let code = line.split("//").next().unwrap_or("");
        if decoder && code.contains("gix_object::") {
            findings.push(Finding {
                file: file.to_string(),
                line: i + 1,
                rule: Rule::ObjectModel,
                why: format!(
                    "`gix_object::` in the decoder tier: {OBJECT_MODEL_WHY} with                      `{OBJECT_DECODER}` instead"
                ),
            });
        }
        if !serving {
            continue;
        }
        for (path, why) in PACK_PIPELINE_PATHS {
            if code.contains(path) {
                findings.push(Finding {
                    file: file.to_string(),
                    line: i + 1,
                    rule: Rule::PackPipeline,
                    why: format!(
                        "`{path}` in the serving tier: {why}. Everything this tier does with \
                         storage goes through `dyn GitOps` / `dyn GitServe`"
                    ),
                });
            }
        }
        if !in_adapter {
            for (path, what) in ENGINE_PATHS {
                if code.contains(path) {
                    findings.push(Finding {
                        file: file.to_string(),
                        line: i + 1,
                        rule: Rule::EngineByName,
                        why: format!(
                            "`{path}` names {what} outside `{ADAPTER_DIR}`. An engine is \
                             selected there and reached everywhere else as `dyn GitServe` — \
                             `storage.rs` says it is the only place in gunnar that names an \
                             implementation, and this is that sentence checked"
                        ),
                    });
                }
            }
        }
    }
    findings
}

/// Check one `Cargo.toml`'s text for a banned **normal** dependency.
///
/// Section-aware, and it has to be: `gunnar-pack` in `[dev-dependencies]` is
/// the correct place for it, and a substring scan that could not tell the two
/// apart would either ban the oracle or miss the edge. `[dependencies]`,
/// `[dependencies.x]` and `[target.'…'.dependencies]` all count; `[build-…]`
/// and `[dev-…]` do not.
pub fn check_manifest(file: &str, text: &str) -> Vec<Finding> {
    let mut findings = Vec::new();
    let serving = SERVING_TIER
        .iter()
        .find(|c| file == format!("crates/{c}/Cargo.toml"))
        .copied();
    let decoder = decoder_tier_manifest(file);
    if serving.is_none() && decoder.is_none() {
        return findings;
    }

    let mut normal = false;
    for (i, raw) in text.lines().enumerate() {
        let line = raw.split('#').next().unwrap_or("").trim();
        if line.starts_with('[') {
            // `[dependencies]`, `[dependencies.foo]`, `[target.…​.dependencies]`
            // — and NOT `[dev-dependencies]` or `[build-dependencies]`, which
            // is why the test is on the segment and not on the whole header.
            let header = line.trim_matches(['[', ']'].as_ref());
            normal = header
                .split('.')
                .any(|seg| seg.trim_matches('"').trim_matches('\'') == "dependencies");
            continue;
        }
        if !normal {
            continue;
        }
        let key = line.split('=').next().unwrap_or("").trim();
        if let Some(crate_name) = decoder {
            // `optional = true` is not an exemption here and there is no
            // entry that needs one: an optional dependency that some default
            // feature turns on is in the graph exactly as hard as a plain one,
            // and `gunnar-store` — the one crate that keeps `gix-object`
            // behind a feature — is deliberately not in `DECODER_TIER`.
            if key == "gix-object" {
                findings.push(Finding {
                    file: file.to_string(),
                    line: i + 1,
                    rule: Rule::ObjectModel,
                    why: format!(
                        "`gix-object` is a normal dependency of `{crate_name}`: \
                         {OBJECT_MODEL_WHY} with `{OBJECT_DECODER}`, and keep this crate in \
                         `[dev-dependencies]` where it is the oracle the decoder is \
                         checked against. Cargo links what a manifest names, not what a \
                         call graph reaches"
                    ),
                });
            }
        }
        let Some(crate_name) = serving else {
            continue;
        };
        for (dep, why) in BANNED_NORMAL_DEPS {
            if key == *dep {
                findings.push(Finding {
                    file: file.to_string(),
                    line: i + 1,
                    rule: Rule::ManifestEdge,
                    why: format!(
                        "`{dep}` is a normal dependency of `{crate_name}`: {why}. Cargo links \
                         what a manifest names, not what a call graph reaches — this line \
                         costs the appliance its dependency budget whether or not one \
                         function calls it"
                    ),
                });
            }
        }
    }
    findings
}

/// Every file this guard reads: `src/*.rs` and `Cargo.toml` for each
/// [`SERVING_TIER`] **and** [`DECODER_TIER`] crate.
///
/// The union, deduplicated. `gunnar-policy` is only in the second list, and
/// only [`Rule::ObjectModel`] looks at it — [`check_source`] and
/// [`check_manifest`] each decide per file which rules apply, so widening the
/// file set does not widen any of Z's three rules.
pub fn seam_files(root: &Path) -> Vec<PathBuf> {
    let mut out = Vec::new();
    let mut crates: Vec<&str> = SERVING_TIER.to_vec();
    for c in DECODER_TIER {
        if !crates.contains(c) {
            crates.push(c);
        }
    }
    for crate_name in crates {
        let dir = root.join("crates").join(crate_name);
        let manifest = dir.join("Cargo.toml");
        if manifest.is_file() {
            out.push(manifest);
        }
        collect_rs(&dir.join("src"), &mut out);
    }
    out.sort();
    out
}

/// **Is `file` a module compiled only under `cargo test`?**
///
/// Read off the module that DECLARES it — `mod.rs`, `lib.rs` or `main.rs`
/// beside it, or `<dir>.rs` one level up — where `mod <stem>;` must have
/// `#[cfg(test)]` among the attributes stacked directly above it. The skip is
/// therefore never a name: `lib.rs`, `main.rs` and `mod.rs` are never gated,
/// and a declaration that loses its attribute puts the file back in the scan.
pub fn cfg_test_gated(file: &Path) -> bool {
    let Some(stem) = file.file_stem().and_then(|s| s.to_str()) else {
        return false;
    };
    if matches!(stem, "lib" | "main" | "mod") {
        return false;
    }
    let Some(dir) = file.parent() else {
        return false;
    };
    let mut declarers = vec![dir.join("mod.rs"), dir.join("lib.rs"), dir.join("main.rs")];
    if let (Some(up), Some(name)) = (dir.parent(), dir.file_name()) {
        declarers.push(up.join(format!("{}.rs", name.to_string_lossy())));
    }
    let want = format!("mod {stem};");
    declarers.iter().any(|d| {
        let Ok(text) = std::fs::read_to_string(d) else {
            return false;
        };
        let lines: Vec<&str> = text.lines().map(str::trim).collect();
        lines.iter().enumerate().any(|(i, l)| {
            let decl = l
                .strip_prefix("pub(crate) ")
                .or_else(|| l.strip_prefix("pub "))
                .unwrap_or(l);
            decl == want
                && lines[..i]
                    .iter()
                    .rev()
                    .take_while(|p| p.starts_with("#["))
                    .any(|p| *p == "#[cfg(test)]")
        })
    })
}

fn collect_rs(dir: &Path, out: &mut Vec<PathBuf>) {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            collect_rs(&path, out);
        } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
            // A `#[cfg(test)]`-gated module file is not the serving tier: it is
            // compiled only under `cargo test` and never linked into an
            // appliance build, so the `gix_object::` it uses to author a known
            // answer is exactly the outside-oracle use this contract's own
            // message blesses (`gunnar-store/tests/object_decode.rs`). The gate
            // is READ off the module that declares the file — see
            // [`cfg_test_gated`] — so a module that loses its `#[cfg(test)]`
            // is scanned again. Until 2026-09-15 this was one named file,
            // `testgit.rs`, and the next test module (`gunnar-server`'s
            // `twin/tests.rs`, `#[cfg(test)] mod tests;` in `twin.rs`) turned
            // the guard red over a dev-dependency its manifest declares.
            if cfg_test_gated(&path) {
                continue;
            }
            out.push(path);
        }
    }
}

/// Scan the seam under `root`.
///
/// Returns the findings **and** how many files were read. Zero findings over
/// zero files is shaped exactly like a clean sweep; the caller must check the
/// count, and `xtask ci` does.
pub fn scan(root: &Path) -> (Vec<Finding>, usize) {
    let files = seam_files(root);
    let mut findings = Vec::new();
    for path in &files {
        let Ok(text) = std::fs::read_to_string(path) else {
            continue;
        };
        let rel = path
            .strip_prefix(root)
            .unwrap_or(path)
            .to_string_lossy()
            .replace('\\', "/");
        if rel.ends_with("Cargo.toml") {
            findings.extend(check_manifest(&rel, &text));
        } else {
            findings.extend(check_source(&rel, &text));
        }
    }
    (findings, files.len())
}