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

nordisk/gunnar

xtask/src/pack_pipeline.rs

//! **Enforced over source: the two gix pack-pipeline stages that are silently
//! expensive.**
//!
//! Pack-copy closed an 18.34× bandwidth gap against stock git — the largest
//! single performance item this repository has measured. It is landed by routing
//! pack production through `gix_pack::data::output`: `count::objects` →
//! `entry::iter_from_counts` → `bytes::FromEntriesIter`. The first two stages
//! are the whole value and gunnar reuses them rather than writing a second
//! copy. Two of the three carry a cost that no test of the resulting pack can
//! see, because the pack is correct either way and only its price changed:
//!
//! 1. **`iter_from_counts` fans out.** It calls
//!    `gix_parallel::in_parallel_if` whenever `counts.len() > 4_000`
//!    (`iter_from_counts.rs:66, 75-79`), a threshold nearly every real clone
//!    exceeds, and `Options::thread_limit` decides how wide. Left at `None` it
//!    takes the machine's core count, **per request**. gunnar has no shared
//!    pool, so per-request intra-request fan-out at forge concurrency spawns
//!    tens of thousands of threads. The batch tier (`repack`, `gc`) is where a
//!    pool belongs; the serve path passes `Some(1)`.
//!
//! 2. **`FromEntriesIter` hardcodes sha1dc.** Its sink is a private
//!    `output: gix_hash::io::Write<W>` (`data/output/bytes.rs:22`), built inside
//!    `new()`, and `gix_hash::io::Write::new` (`gix-hash/src/io.rs:115-125`)
//!    calls `crate::hasher(object_hash)`, meaning `sha1_checked` with UBC
//!    collision detection. No constructor, builder or field access admits a
//!    different hasher. Using it on a SHA-1 repository gives back the landed
//!    win of dropping collision detection from the trailer, and gives it back in
//!    the level-0 shape where that win was **4.02×** rather than 1.06×, because
//!    pack-copy removes the deflate that used to dominate.
//!
//! # Why a source scan, when [`crate::guard`] reads resolved graphs
//!
//! `gix-pack` is a legitimate dependency of the server graph. The violation is
//! not *that* it is linked but *how one function is called*, and that exists
//! only in source. This is the narrow case where a text guard is the only guard
//! available.
//!
//! **Dormant since 2026-08-10, and that is recorded rather than assumed.**
//! `crates/gunnar-store/src/repack.rs` used all three stages and was this
//! module's worked example; it went with the packfile engine. Rules 1 and 2 now
//! have **no live call site anywhere in gunnar** — the four surviving mentions of
//! `iter_from_counts` / `FromEntriesIter::new` are comments, and comments are
//! stripped before a call is looked for. The engine emits the pack now
//! (`gunnar-wire/src/upload_pack.rs`: *"the engine emits; this streams"*).
//! Dormant is not dead: `gunnar_pack` is still in the graph, the stages are still
//! the right ones to reuse, and the day anyone reaches for them the rule must
//! already be watching. What that costs is one thing — the guard's own red-first
//! proofs must not depend on a subject being present, which is why they run on
//! samples and why `gunnars_shipped_sources_declare_what_they_cost` anchors on
//! *directories the scan must read* rather than on a file that held a call.
//!
//! # Neither rule can be satisfied by silence
//!
//! Each rule is satisfied either by the safe form or by an explicit marker
//! comment naming the tier: [`SERVE_LIMIT`], [`BATCH_MARKER`],
//! [`TRAILER_MARKER`]. That is [`crate::guard`]'s tracked-deviation idiom in
//! miniature: the guard cannot tell a `repack` from an `upload-pack` by reading
//! text, so it makes whoever writes the call say which it is. A call site that
//! says nothing is the failure, and that is the state this module exists to
//! prevent.
//!
//! # This guard's own red
//!
//! `xtask/tests/pack_pipeline.rs` runs [`check_source`] against deliberately-bad
//! samples *and* against gunnar's own sources, with the file count asserted, so
//! neither "found nothing" nor "read nothing" can pass for clean.

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

/// Rule 1's subject. Matched with the paren so `iter_from_counts::Options` and
/// `iter_from_counts::Mode` on following lines are not counted as three calls.
const FANOUT_CALL: &str = "iter_from_counts(";

/// Rule 2's subject.
const TRAILER_CALL: &str = "FromEntriesIter::new(";

/// Rule 1's safe form: no fan-out, whatever the count.
pub const SERVE_LIMIT: &str = "thread_limit: Some(1)";

/// Rule 1's declared deviation: this call is a batch job and may use the pool.
pub const BATCH_MARKER: &str = "pack-pipeline: batch tier";

/// Rule 2's declared deviation: this pack's trailer is knowingly on sha1dc.
pub const TRAILER_MARKER: &str = "pack-pipeline: sha1dc trailer accepted";

/// How far past a call site to look. Both calls take an options struct or a
/// hash kind as a trailing argument, so in any formatting rustfmt produces the
/// relevant text is within a few lines; the marker may also sit just above.
const WINDOW: usize = 25;

/// The storage-trait methods whose default **turns an optimisation off**, so
/// that omitting one changes what the server does rather than merely leaving a
/// question unanswered.
///
/// # The class, and why it earned a rule
///
/// It happened **five** times in one tree on 2026-08-04, on the old
/// `gunnar_store::ObjectStore`: `gunnar_bench::stages::StageCounting` (fixed at
/// `468db46`), `gunnar_store::NoPackCopy` and `gunnar_bench::readside::Counting`
/// (fixed at `b3bf145`), and then `gunnar_bench::have_lookup::IndexedContains`
/// and `nornir-bench`'s own `Counting<S>`, which this rule found. `packed()`
/// answering `None` sent the server down `write_pack`, inflating and
/// re-deflating every object instead of copying it; `bitmap()` answering `None` sent
/// `select_objects` down the walk instead of the reachability bitmap. Two of the
/// five were the BEFORE column of a published comparison, so the difference
/// those arms attributed to pack-copy was pack-copy *plus* bitmap.
///
/// Nothing else can catch it. The bitmap path is defined to select the same
/// object set as the walk, so wire bytes, object counts and every fidelity
/// digest across all 19 corpus repositories agree either way — which is the
/// definition of a change no test can see.
///
/// # Why it is empty, and why that is now a PROPERTY rather than a gap
///
/// It first went empty on 2026-08-10 for a weak reason: `packed()` and
/// `bitmap()` left `ObjectStore` with the packfile engine, so there was nothing
/// left to scan for *on that trait*. `ObjectStore` itself is now gone, and its
/// successor is
/// [`GitOps`](https://docs.rs/) — the eleven, in `znippy/git-storage-trait`.
///
/// **`GitOps` has no defaulted method at all.** All eleven — `put`, `put_pack`,
/// `put_refs`, `get`, `has`, `size`, `extents`, `refs`, `update_ref`,
/// `reachable`, `gc` — are required, so an implementer cannot inherit a
/// behaviour it did not write, and this whole class of defect is impossible on
/// the contract *by construction*. That is the fix the five silent wrappers
/// asked for, arrived at from the other end: not a guard watching for a
/// forgotten forward, but a trait on which forgetting is not expressible.
///
/// Note what did **not** survive that: `ObjectStore::reachable()` defaulted to
/// `None` and had exactly this polarity — a wrapper that forgot to forward it
/// turned reachability off silently, with identical wire bytes.
/// `GitOps::reachable(want, have)` is required and returns the answer rather
/// than an `Option<Arc<dyn ReachIndex>>`, so there is no "off" to fall into.
///
/// The machinery stays and is still exercised — see
/// [`check_impls`], which takes the list as an argument precisely so the rule's
/// own red-first proofs do not depend on the list being non-empty. The day a
/// defaulted, optimisation-toggling method appears on the storage trait, its
/// name goes here and the rule fires again.
pub const OPTIONAL_BUT_LOAD_BEARING: &[&str] = &[];

/// What an `impl` of the storage contract block starts with.
///
/// `"ObjectStore for "` until 2026-08-10. `gunnar_store::ObjectStore` is deleted
/// and `GitOps` is the only storage contract, so a rule still scanning for the
/// old name would read every file in the tree and find nothing — green because
/// it is blind, which is the exact failure the rest of this module is about.
const STORAGE_IMPL: &str = "GitOps for ";

/// Rule 4's declared deviation: this wrapper turns the optimisation off on
/// purpose, and that is the point of the type rather than a forgotten line.
///
/// `gunnar_store::NoPackCopy::packed` is the one place in the tree that earns
/// it: cutting the pack-copy seam *is* what the type is for.
pub const NOT_FORWARDED_MARKER: &str = "pack-pipeline: deliberately not forwarded";

/// The storage contract's methods, as text. Used to decide whether an `impl` is
/// a **wrapper** — a type that answers the trait by asking an inner store — as
/// opposed to a leaf backend that answers out of its own data.
///
/// `self.packs.locate(..)` is not a forward; `self.inner.extents(..)` is.
///
/// # The selection rule, which is older than the current list
///
/// A name only earns a place here if **no std type answers to it**, and that was
/// learned the expensive way. The first version of this list held `read` and
/// `write`, which was a false positive with teeth: `MemObjectStore` was a leaf
/// backend over a `HashMap` whose every method called `self.objects.read()` /
/// `self.objects.write()` on an `RwLock`. A rule that called that "forwarding"
/// demanded a marker from the one store in the tree with genuinely nothing to
/// forward to — and the way anyone silences a rule that cries wolf is by
/// deleting it. `contains` came out for the same reason: `HashMap::contains`,
/// `slice::contains`.
///
/// # Rewritten for `GitOps`, 2026-08-10
///
/// It was `["hash_kind", "contains_without_rescan", "header", "packed",
/// "bitmap"]` — five `ObjectStore` methods, and not one of them is on `GitOps`.
/// Left alone, `forwards_to_inner` would have answered `false` for every impl in
/// the tree, every wrapper would have looked like a leaf, and rule 4 could never
/// fire again. That is the silent-green shape this module exists to prevent,
/// reproduced inside the module itself.
///
/// The six below are the eleven minus the five that std also answers to:
/// `get` (`HashMap::get`, `Option::get`), `has`, `size`, `put`, `refs`. What is
/// left is six names a `Vec`, a `HashMap` or a lock will not accidentally match.
const TRAIT_METHODS: &[&str] = &[
    "put_pack",
    "put_refs",
    "extents",
    "update_ref",
    "reachable",
    "gc",
];

/// One call site that has not declared what it costs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
    /// Path as given to the scanner, or the sample name for [`check_source`].
    pub file: String,
    /// 1-based line of the offending call.
    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 of the three properties was not declared.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rule {
    /// `iter_from_counts` with neither [`SERVE_LIMIT`] nor [`BATCH_MARKER`].
    PerRequestFanOut,
    /// `FromEntriesIter::new` without [`TRAILER_MARKER`].
    Sha1dcTrailer,
    /// An `impl GitOps` that does not answer one of the methods whose
    /// trait default **turns an optimisation off**. See
    /// [`OPTIONAL_BUT_LOAD_BEARING`].
    SilentOptOut,
    /// A **wrapper** — an `impl GitOps` that answers other trait methods
    /// by asking an inner store — that writes one of
    /// [`OPTIONAL_BUT_LOAD_BEARING`] out as a bare `None` instead of
    /// forwarding it, and does not say why.
    ///
    /// [`Rule::SilentOptOut`] is satisfied by *typing the method out*, and a
    /// wrapper can satisfy it with `{ None }` — which is byte-for-byte the
    /// behaviour the omission had. Same disabled optimisation, same identical
    /// wire bytes, same green fidelity check, and now a guard signed off on it.
    /// So the rule that makes the class impossible is this one: a forwarder
    /// forwards, or it declares.
    NotForwarded,
}

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
        )
    }
}

/// Check one file's text. Pure, so the red-first proof needs no fixture tree.
///
/// Comments are stripped before the *call* is looked for. A doc line naming
/// either function is prose, not a call, and a guard that fired on its own
/// explanation would be switched off within a day. Comments are kept when
/// looking for the *markers*, because that is where a marker lives.
pub fn check_source(file: &str, text: &str) -> Vec<Finding> {
    let lines: Vec<&str> = text.lines().collect();
    let mut findings = Vec::new();
    for (i, line) in lines.iter().enumerate() {
        let code = line.split("//").next().unwrap_or("");
        let window = {
            let lo = i.saturating_sub(4);
            lines[lo..lines.len().min(i + WINDOW)].join("\n")
        };

        if code.contains(FANOUT_CALL)
            && !window.contains(SERVE_LIMIT)
            && !window.contains(BATCH_MARKER)
        {
            findings.push(Finding {
                file: file.to_string(),
                line: i + 1,
                rule: Rule::PerRequestFanOut,
                why: format!(
                    "`iter_from_counts` is called with neither `{SERVE_LIMIT}` nor a \
                     `{BATCH_MARKER}` comment. It fans out through \
                     `gix_parallel::in_parallel_if` above 4000 counts, which \
                     nearly every real clone exceeds. That is per-request fan-out, and \
                     gunnar has no shared pool for it. Serve passes Some(1); repack and gc \
                     may use the pool and must say so."
                ),
            });
        }

        if code.contains(TRAILER_CALL) && !window.contains(TRAILER_MARKER) {
            findings.push(Finding {
                file: file.to_string(),
                line: i + 1,
                rule: Rule::Sha1dcTrailer,
                why: format!(
                    "`FromEntriesIter::new` is called without a `{TRAILER_MARKER}` \
                     comment. Its sink is a private `gix_hash::io::Write`, which \
                     hardcodes `sha1_checked`, so this pack's trailer pays collision \
                     detection it cannot benefit from, giving back the measured 4.02× at \
                     level 0, the shape pack-copy produces. Use gix for entry \
                     production and gunnar's own `PackWriter` to emit the bytes, or \
                     state that the cost is accepted here."
                ),
            });
        }
    }
    findings
}

/// Every `impl GitOps` block in `text`, as `(line, body)`.
///
/// Brace-counted from the `{` that opens the block, which is enough here
/// because the input is rustfmt output and no `impl` line carries an unbalanced
/// brace inside a string or comment. A block whose braces do not close runs to
/// the end of the file, which fails the rule rather than passing it.
fn object_store_impls(text: &str) -> Vec<(usize, String)> {
    let lines: Vec<&str> = text.lines().collect();
    let mut out = Vec::new();
    let mut i = 0;
    while i < lines.len() {
        let code = lines[i].split("//").next().unwrap_or("");
        // `impl` and not `trait`: the trait's own definition holds the defaults
        // and is where they are argued for.
        if !(code.contains(STORAGE_IMPL) && code.trim_start().starts_with("impl")) {
            i += 1;
            continue;
        }
        let start = i;
        let mut depth = 0i32;
        let mut body = String::new();
        let mut opened = false;
        while i < lines.len() {
            let code = lines[i].split("//").next().unwrap_or("");
            body.push_str(lines[i]);
            body.push('\n');
            for ch in code.chars() {
                match ch {
                    '{' => {
                        depth += 1;
                        opened = true;
                    }
                    '}' => depth -= 1,
                    _ => {}
                }
            }
            i += 1;
            if opened && depth <= 0 {
                break;
            }
        }
        out.push((start + 1, body));
    }
    out
}

/// Does this `impl` answer the trait by asking an **inner** store?
///
/// True for `self.inner.header(..)`, `self.0.contains(..)` and
/// `(**self).bitmap()`. False for `self.packs.locate(..)` and
/// `self.objects.read()`, which are a leaf backend reaching into its own data
/// and happen to be spelled the same way — the method name is what separates
/// them, so only [`TRAIT_METHODS`] count.
fn forwards_to_inner(body: &str) -> bool {
    for line in body.lines() {
        let code = line.split("//").next().unwrap_or("");
        for method in TRAIT_METHODS {
            let call = format!(".{method}(");
            let mut from = 0;
            while let Some(rel) = code[from..].find(&call) {
                let at = from + rel;
                from = at + 1;
                let before = &code[..at];
                if before.ends_with("(**self)") {
                    return true;
                }
                // `self.<field>` — a receiver that is a field of self, not self.
                let Some(dot) = before.rfind('.') else {
                    continue;
                };
                let field = &before[dot + 1..];
                if !field.is_empty()
                    && field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
                    && before[..dot].ends_with("self")
                {
                    return true;
                }
            }
        }
    }
    false
}

/// One method of an `impl` block, located by its `fn <name>(` text.
struct MethodSpan {
    /// 0-based line of the method's **doc comment**, relative to the impl
    /// block. That is where [`NOT_FORWARDED_MARKER`] belongs — attached to the
    /// method it excuses, so one marker cannot excuse two methods.
    doc_start: usize,
    /// The doc comment, signature and body, as text.
    declared_and_body: String,
    /// The signature and body, without the doc comment.
    body: String,
}

fn method_span(block: &str, method: &str) -> Option<MethodSpan> {
    let lines: Vec<&str> = block.lines().collect();
    let at = lines.iter().position(|l| l.contains(method))?;
    let mut doc_start = at;
    while doc_start > 0 {
        let prev = lines[doc_start - 1].trim_start();
        if prev.starts_with("//") || prev.starts_with("#[") {
            doc_start -= 1;
        } else {
            break;
        }
    }
    let mut depth = 0i32;
    let mut opened = false;
    let mut end = at;
    for (offset, line) in lines[at..].iter().enumerate() {
        let code = line.split("//").next().unwrap_or("");
        for ch in code.chars() {
            match ch {
                '{' => {
                    depth += 1;
                    opened = true;
                }
                '}' => depth -= 1,
                _ => {}
            }
        }
        end = at + offset;
        if opened && depth <= 0 {
            break;
        }
    }
    Some(MethodSpan {
        doc_start,
        declared_and_body: lines[doc_start..=end].join("\n"),
        body: lines[at..=end].join("\n"),
    })
}

/// Is this method body, comments stripped, nothing but `None`?
///
/// `{ None }` and `{ self.inner.bitmap() }` are the two answers a wrapper can
/// give, and they differ by the whole optimisation.
fn returns_bare_none(method_text: &str) -> bool {
    let code: String = method_text
        .lines()
        .map(|l| l.split("//").next().unwrap_or(""))
        .collect::<Vec<_>>()
        .join("\n");
    let Some(open) = code.find('{') else {
        return false;
    };
    let Some(close) = code.rfind('}') else {
        return false;
    };
    if close <= open {
        return false;
    }
    code[open + 1..close].split_whitespace().collect::<String>() == "None"
}

/// Rule 3 and rule 4 over one file's text, against the **live**
/// [`OPTIONAL_BUT_LOAD_BEARING`] list. Pure, like [`check_source`].
///
/// This is what [`scan_object_stores`] runs, and while the list is empty it
/// finds nothing — correctly, because `GitOps` has no defaulted method to
/// silently opt out of. See [`check_impls`] for how the rule stays falsifiable
/// anyway.
pub fn check_object_store(file: &str, text: &str) -> Vec<Finding> {
    check_impls(file, text, OPTIONAL_BUT_LOAD_BEARING)
}

/// Rule 3 and rule 4 over one file's text, against **a list given by the
/// caller**.
///
/// # Why the list is a parameter
///
/// [`OPTIONAL_BUT_LOAD_BEARING`] is empty, and it is empty for a good reason —
/// the contract it guards has no defaults. But a rule whose only exercise is a
/// loop over an empty slice is a rule that has stopped being tested, and this
/// module's own doc says the thing that makes it worth anything is that
/// `xtask/tests/pack_pipeline.rs` watches it go red.
///
/// **That is not a hypothetical.** When the list was emptied on 2026-08-10 the
/// four red-first proofs were left asserting findings the rule could no longer
/// produce, and six of the fifteen tests in this guard's own suite were RED —
/// discovered 2026-08-10, unnoticed since. The guard against silent-green had
/// gone silently red.
///
/// So the proofs drive this entry point with a list of their own. They prove the
/// **mechanism**: that an omitted method is caught, that a wrapper answering
/// `None` is caught, that a leaf backend is not, that a marker excuses one
/// method and not its neighbour. None of that depends on which names are
/// currently load-bearing, which is exactly the part that should be free to
/// change.
pub fn check_impls(file: &str, text: &str, load_bearing: &[&str]) -> Vec<Finding> {
    let mut findings = Vec::new();
    for (line, body) in object_store_impls(text) {
        let wrapper = forwards_to_inner(&body);
        for method in load_bearing {
            if body.contains(method) {
                // ── rule 4 ─────────────────────────────────────────────────
                // Written out, so rule 3 is satisfied. But a WRAPPER that
                // writes `None` has done exactly what omitting the method did.
                if !wrapper {
                    continue;
                }
                let Some(span) = method_span(&body, method) else {
                    continue;
                };
                if !returns_bare_none(&span.body) {
                    continue;
                }
                if span.declared_and_body.contains(NOT_FORWARDED_MARKER) {
                    continue;
                }
                findings.push(Finding {
                    file: file.to_string(),
                    line: line + span.doc_start,
                    rule: Rule::NotForwarded,
                    why: format!(
                        "this `impl GitOps` answers other trait methods through an inner \
                         store, but answers `{method})` with a bare `None`. That is not a \
                         narrower forward — it is the SAME disabled optimisation the trait \
                         default gives, now with a written-out method vouching for it. Rule 3 \
                         is satisfied by typing the line; only forwarding it, or saying \
                         `{NOT_FORWARDED_MARKER}` on this method and why, is satisfied by \
                         meaning it."
                    ),
                });
                continue;
            }
            findings.push(Finding {
                file: file.to_string(),
                line,
                rule: Rule::SilentOptOut,
                why: format!(
                    "this `impl GitOps` does not write out `{method})`. Its trait \
                     default is `None`, and `None` is not silence — it turns the \
                     optimisation OFF for everything served or measured through this \
                     type. A wrapper then reports the walk path as if it were the \
                     bitmap path, or the re-deflate path as if it were pack-copy, and no \
                     wire byte differs. Write the method out, forwarding it if this type \
                     wraps another and answering `None` if it genuinely has none."
                ),
            });
        }
    }
    findings
}

/// Every `.rs` file that may contain an `impl GitOps`.
///
/// **Wider than [`shipped_sources`], on purpose.** Rule 3 is about
/// instruments as much as about servers: three of its five known instances were
/// in `bench/` and in `tests/`, and those are exactly the places where a
/// silently-disabled optimisation becomes a published number.
/// **Every place a crate can put Rust**, not the three that happened to hold
/// the first five instances. `crates/*/{src,tests}` plus `bench/{src,examples}`
/// was the original set and it silently omitted `crates/*/examples` (three of
/// those exist), `crates/*/benches`, `bench/tests`, and any crate outside
/// `crates/` — the deleted engine crate’s nested bench among them, which is a whole
/// second bench harness on a store the workspace `exclude` already hides from
/// `cargo test`. An instrument in an unscanned directory is exactly the state
/// this rule exists to end.
///
/// `xtask/tests` stays out, and only that: its fixtures are deliberately-bad
/// samples that this guard's own red-first proofs assert on.
pub fn object_store_sources(root: &Path) -> Vec<PathBuf> {
    let mut out = Vec::new();
    for crate_dir in ["crates", "."] {
        let Ok(entries) = std::fs::read_dir(root.join(crate_dir)) else {
            continue;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.file_name().and_then(|n| n.to_str()) == Some("target") {
                continue;
            }
            for sub in ["src", "tests", "examples", "benches", "bench"] {
                collect_rs(&path.join(sub), &mut out);
            }
        }
    }
    out.sort();
    out.dedup();
    out.retain(|p| !p.starts_with(root.join("xtask").join("tests")));
    out
}

/// Scan for rule 3. Returns the findings and how many files were read, for the
/// same reason [`scan_workspace`] does.
pub fn scan_object_stores(root: &Path) -> (Vec<Finding>, usize) {
    let files = object_store_sources(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()
            .to_string();
        findings.extend(check_object_store(&rel, &text));
    }
    (findings, files.len())
}

/// Every `.rs` file under `crates/*/src`, which is the code that ships.
///
/// Tests and benches are out of scope on purpose: a bench that measures the
/// fan-out is not a server that performs it.
pub fn shipped_sources(root: &Path) -> Vec<PathBuf> {
    let mut out = Vec::new();
    let Ok(entries) = std::fs::read_dir(root.join("crates")) else {
        return out;
    };
    for entry in entries.flatten() {
        collect_rs(&entry.path().join("src"), &mut out);
    }
    out.sort();
    out
}

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") {
            out.push(path);
        }
    }
}

/// Scan the shipped sources under `root`.
///
/// Returns the findings **and** how many files were read: zero findings over
/// zero files is shaped exactly like a clean sweep, which is the empty-graph
/// trap [`crate::guard`] documents, in its other form.
pub fn scan_workspace(root: &Path) -> (Vec<Finding>, usize) {
    let files = shipped_sources(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()
            .to_string();
        findings.extend(check_source(&rel, &text));
    }
    (findings, files.len())
}