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