crates/nornir-build-thing/src/buildgate.rs
//! **THE BUILD GATE** — compile everything a repo *declares*, and go RED with the
//! numbers when the count that came out is smaller than the count that went in.
//!
//! # Why this exists, and why it is here and not in a repo
//!
//! Three repos in this constellation have no CI at all, and the two commands a human
//! actually types are both silently partial. MEASURED 2026-08-05:
//!
//! * korp `b388500` — `cargo build --bins` builds **4 of 9** `[[bin]]` targets. The
//! other five carry `required-features` outside `default`, and cargo skips them
//! **without a word** and exits 0: `korp-cli`, `korp-server`, `korp-solo`,
//! `korp-spark41`, `start-korp-demo`.
//! * znippy-zoomies `49c7bc6` — `cargo build --bins` at the repo root builds **0 of
//! 11**, because the root manifest is a package *and* a workspace with no
//! `default-members`, so cargo selects the root package, which declares no bins.
//! * korp — of 122 `tests/*.rs`, 42 carry a file-level `#![cfg(…)]`. Under a feature
//! set that gates one away the file compiles to an **empty** binary and libtest
//! prints `running 0 tests … ok`, which is shaped exactly like a pass.
//!
//! **Both of those exit 0.** That is the mechanism behind korp-server not compiling
//! for hours, behind the thin client's 93 `#[test]` of which 0 ran and 5 were red, and
//! behind two reds landing unseen on 2026-08-04.
//!
//! It lives in `nornir-build-thing` because that is the only non-test crate korp,
//! facett and znippy-zoomies all already reach, and because the declaration
//! ([`BuildVariantSet`]) and the cargo-JSON artifact stream already live here. A copy
//! per repo would be the twin this module exists to delete (LAW 5).
//!
//! # The two principles it inherits from the shell script it replaces
//!
//! **1. The argv is never re-spelled.** Every invocation starts from the atom's own
//! [`BuildVariant::cargo_args`], the same materialisation the build path uses, so an
//! atom whose declared features change is gated at its *new* features with no edit
//! here. A hand-written argv is exactly how the declaration went stale before.
//!
//! **2. It judges OUTPUT, never the exit code.** A malformed cargo invocation fails in
//! a way whose empty output is shaped exactly like a clean result, and a `tail` in the
//! pipeline eats the status entirely. So: nothing is piped through anything, each run's
//! status is captured into its own field before anything else touches it, and a zero
//! exit that produced **no artifact** is its own named failure — not a pass.
//!
//! # What it adds over the script: the numbers
//!
//! A gate that only says "everything went fine" is precisely the disease. Both
//! assertions here are *counts with names attached*:
//!
//! * [`BinTally`] — bins declared vs bins an artifact actually came out for, and the
//! name of every one that did not, so the failure text is a work list.
//! * [`TestTally`] — test binaries produced vs test binaries that contain at least one
//! test, and the source file behind every empty one.
//!
//! # Cost
//!
//! Real, and worth stating plainly: one atom is one whole feature resolution. korp
//! declares six, in the `release` profile. Give it a warm target dir and narrow with
//! [`GateSpec::atoms`] while iterating. [`Arms::coverage_only`] is the seconds-long
//! half that compiles nothing.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{Context, Result};
use crate::variant::{BuildVariant, BuildVariantSet};
// ───────────────────────────── what one cargo run tells us ─────────────────────────────
/// One executable cargo reported producing, with the source target behind it.
///
/// `src` is what makes the empty-test assertion nameable: libtest can only say "0
/// tests", it cannot say *which file* compiled to nothing. The `compiler-artifact`
/// message can, because it carries `target.src_path`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Produced {
/// The binary cargo wrote.
pub path: PathBuf,
/// The cargo target name (`korp-server`, `live_map_coverage`, …).
pub name: String,
/// The cargo target kinds (`bin`, `test`, `lib`, …).
pub kinds: Vec<String>,
/// The file that target is rooted at — `src/main.rs`, `tests/foo.rs`.
pub src: PathBuf,
/// Whether cargo built this with the test harness linked in.
pub is_test: bool,
}
impl Produced {
/// A `[[bin]]` target proper — not a test binary that happens to come from a bin.
pub fn is_bin(&self) -> bool {
!self.is_test && self.kinds.iter().any(|k| k == "bin")
}
/// A `tests/*.rs` integration target (kind `test`), as opposed to a bin's own
/// `mod tests` (kind `bin`, `is_test = true`).
pub fn is_integration_test(&self) -> bool {
self.kinds.iter().any(|k| k == "test")
}
}
/// One cargo invocation, recorded whole so a verdict is something somebody can go and
/// read. Nothing here is judged — [`judge_run`] does that, separately, so the recording
/// and the judgement cannot quietly become the same step.
#[derive(Debug, Clone)]
pub struct CargoRun {
/// Human label — `korp-server`, `korp-server/tests`.
pub label: String,
/// The argv actually passed, for reproduction verbatim.
pub argv: Vec<String>,
/// **Captured before anything else touches it.** `None` means the process was
/// killed by a signal, which is not a success and is not a failure code either.
pub status: Option<i32>,
/// True iff the process exited 0. Kept separate from `status` so a signal death
/// can never be read as a pass.
pub exited_ok: bool,
/// Everything cargo said it produced.
pub produced: Vec<Produced>,
/// Compiler diagnostics at `error` level, verbatim.
pub errors: Vec<String>,
/// The tail of cargo's own stderr, kept for the failure text.
pub stderr_tail: String,
}
impl CargoRun {
/// The command, reproducible by copy-paste.
pub fn command_line(&self) -> String {
format!("cargo {}", self.argv.join(" "))
}
}
/// Run one cargo invocation and record it. **This function judges nothing** — it
/// returns what happened, and the caller decides what that means.
///
/// `--message-format=json` is appended rather than the argv being rewritten, so the
/// atom's own `cargo_args()` passes through untouched.
pub fn run_cargo(
repo_root: &Path,
label: &str,
argv: &[String],
target_dir: Option<&Path>,
extra_env: &[(String, String)],
) -> Result<CargoRun> {
use std::io::{BufReader, Read};
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(&cargo);
cmd.current_dir(repo_root)
.args(argv)
.arg("--message-format=json")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(t) = target_dir {
cmd.env("CARGO_TARGET_DIR", t);
}
for (k, v) in extra_env {
cmd.env(k, v);
}
// **Say what is starting, before it starts.** The whole report is rendered at the
// end, which means a run killed in the middle of a multi-hour compile leaves NO
// evidence of how far it got — measured on korp 2026-08-05, where a six-atom
// release run died after 10 minutes and left a zero-byte log. The shell script
// this replaced kept a per-arm log for exactly this reason, and losing that was a
// regression. One line per invocation, on stderr so it never mixes into the report
// on stdout.
eprintln!("[buildgate] {label} :: cargo {}", argv.join(" "));
let mut child = cmd
.spawn()
.with_context(|| format!("spawn `{cargo} {}` for {label}", argv.join(" ")))?;
// stderr is drained on its own thread. A piped stderr that is only read *after*
// `wait()` deadlocks the moment cargo fills the pipe buffer — and a gate that
// hangs is worse than one that lies, because nobody even gets a verdict.
let mut err_pipe = child.stderr.take().expect("piped stderr");
let err_thread = std::thread::spawn(move || {
let mut s = String::new();
let _ = err_pipe.read_to_string(&mut s);
s
});
let reader = BufReader::new(child.stdout.take().expect("piped stdout"));
let mut produced = Vec::new();
let mut errors = Vec::new();
for msg in cargo_metadata::Message::parse_stream(reader) {
let msg = match msg {
Ok(m) => m,
// A malformed line is not a reason to abandon the run: the artifacts
// already collected are still evidence, and the status still decides.
Err(_) => continue,
};
match msg {
cargo_metadata::Message::CompilerArtifact(a) => {
if let Some(exe) = a.executable {
produced.push(Produced {
path: exe.into_std_path_buf(),
name: a.target.name.clone(),
kinds: a.target.kind.iter().map(|k| k.to_string()).collect(),
src: a.target.src_path.clone().into_std_path_buf(),
is_test: a.profile.test,
});
}
}
cargo_metadata::Message::CompilerMessage(m) => {
if m.message.level == cargo_metadata::diagnostic::DiagnosticLevel::Error {
errors.push(
m.message.rendered.clone().unwrap_or_else(|| m.message.message.clone()),
);
}
}
_ => {}
}
}
// The status is captured into its own binding FIRST. Everything downstream reads
// this binding, never `$?`-shaped state that a later call could overwrite.
let status = child.wait().with_context(|| format!("wait for cargo ({label})"))?;
let exited_ok = status.success();
let code = status.code();
let stderr = err_thread.join().unwrap_or_default();
let stderr_tail = tail_lines(&stderr, 40);
// …and say how it ended, with the count. A progress line that only ever appears
// before the work is a progress bar that cannot report failure.
eprintln!(
"[buildgate] {label} :: {} — {} artifact(s), {} compiler error(s)",
if exited_ok { "exit 0" } else { "FAILED" },
produced.len(),
errors.len(),
);
Ok(CargoRun {
label: label.to_string(),
argv: argv.to_vec(),
status: code,
exited_ok,
produced,
errors,
stderr_tail,
})
}
/// The last `n` lines, whole. Deliberately a function and not a `tail` pipe: piping
/// through `tail` replaces the pipeline's exit status with `tail`'s, which is how a
/// failure became an exit 0 seven times in one day on this box.
fn tail_lines(s: &str, n: usize) -> String {
let lines: Vec<&str> = s.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}
/// **The judgement, and the reason it is separate from the recording.**
///
/// Two distinct failure modes, named apart, because a status code cannot tell them
/// apart and the second one is the whole subject of this module:
///
/// * cargo said no — the compiler's own words are attached.
/// * **cargo said yes and produced nothing.** A `--bin` that matched no target, a
/// `required-features` skip, a `--tests` under a feature set that gates every file
/// away: all of them exit 0 with empty output, which is shaped exactly like a clean
/// pass. `expect_artifacts` is what makes that a red.
pub fn judge_run(run: &CargoRun, expect_artifacts: bool) -> Option<String> {
if !run.exited_ok {
let code = match run.status {
Some(c) => format!("exit {c}"),
None => "killed by a signal".to_string(),
};
let words = if run.errors.is_empty() {
run.stderr_tail.clone()
} else {
run.errors.join("\n")
};
return Some(format!(
"── {} DID NOT COMPILE ({code})\n {}\n{}",
run.label,
run.command_line(),
indent(&words),
));
}
if expect_artifacts && run.produced.is_empty() {
return Some(format!(
"── {} exited 0 but cargo produced NO artifact — this invocation compiled \
nothing, and its output is shaped exactly like a clean pass.\n {}",
run.label,
run.command_line(),
));
}
None
}
fn indent(s: &str) -> String {
s.lines().map(|l| format!(" | {l}")).collect::<Vec<_>>().join("\n")
}
// ───────────────────────────── assertion 1: declared bins vs built bins ─────────────────────────────
/// **How many bins were declared, how many an artifact actually came out for, and the
/// name of every one that did not.**
///
/// The comparison is against real cargo `[[bin]]` targets
/// ([`BuildVariantSet::cargo_bin_targets`]), not against the variant list — a bin that
/// no atom builds is exactly the hole, so counting only the atoms would be a gate that
/// reads its own input.
#[derive(Debug, Clone, Default)]
pub struct BinTally {
/// Every `[[bin]]` (and implicit `src/main.rs` bin) in the repo.
pub declared: Vec<String>,
/// The ones cargo actually emitted an executable for, across every atom run.
pub built: BTreeSet<String>,
/// Declared, not built, and carrying a written reason in
/// `[build.allow_unbuilt_bins]` — name → reason.
pub excused: BTreeMap<String, String>,
/// Declared, not built, no reason. **This is the failure.**
pub unbuilt: Vec<String>,
/// Entries in the allowlist for bins that ARE built now — a stale excuse rots the
/// list into a blanket, so it is its own failure.
pub stale_excuses: Vec<String>,
/// **This run walked only SOME atoms**, so `unbuilt` necessarily lists every bin
/// belonging to the atoms that were skipped. Counting that as a failure would make
/// `--atom korp-cli` permanently red for a reason the operator already knows —
/// and a gate that is red no matter what is a gate people learn to ignore. The
/// tally is still reported in full; it just cannot assert.
///
/// MEASURED: `--atom korp-cli` on korp `b388500` reported "8 of 10 UNBUILT", all
/// eight belonging to the five atoms the filter excluded.
pub partial: bool,
}
impl BinTally {
/// **How many DECLARED bins were built** — `built` ∩ `declared`, and not
/// `built.len()`.
///
/// `built` is whatever the delivery produced an artifact for, and a delivery
/// produces things that are not bins: an image reference, a mastered `.iso`, a
/// staged payload. `bin_name_of_artifact` is a file stem, so
/// `.iso-out/gunnar-appliance-iso.iso` arrives in this set as
/// `gunnar-appliance-iso` and is counted against a denominator it is not in.
///
/// SEEN RED on gunnar, 2026-08-05, in both directions on one afternoon:
/// `bins: 0 of 4 declared built (75%)` — nothing built, three excused
/// `bins: 2 of 4 declared built (125%)` — one bin + one ISO, three excused
/// A percentage over 100 is the visible half; `0 … (75%)` is the same defect
/// quietly, and it had been printing for as long as the gate has existed.
pub fn built_declared(&self) -> usize {
self.declared
.iter()
.filter(|b| self.built.contains(*b))
.count()
}
/// Built over declared, as a ratio in [0, 1]. A count alone hides the denominator,
/// and the denominator is the point.
///
/// Excused bins count toward it — an excuse with a written reason is a decision,
/// not a hole — and the numerator is [`built_declared`](Self::built_declared), so
/// the ratio cannot exceed 1 and cannot be inflated by an artifact that is not a
/// bin.
pub fn ratio(&self) -> f64 {
if self.declared.is_empty() {
return 0.0;
}
(self.built_declared() + self.excused.len()) as f64 / self.declared.len() as f64
}
/// **The headline, with every term the percentage is made of.**
///
/// The old wording printed `built_declared` beside a percentage computed from
/// `built_declared + excused`, so the number next to the ratio was NOT the
/// ratio's numerator, and a third number (`unbuilt`) arrived with nothing to
/// subtract it from. MEASURED on gunnar, 2026-09-20:
///
/// ```text
/// bins: 1 of 15 declared built (40%) — PARTIAL RUN … so the 9 not built here …
/// ```
///
/// Every number there is correct — 1 built, 5 excused, 9 unbuilt, and
/// (1+5)/15 = 40% — and the sentence still reads as arithmetic that does not
/// work, because two of the three terms are missing. A reader who checks
/// `1/15 ≠ 40%` and `15 - 1 ≠ 9` concludes the gate is broken and stops
/// reading it, which is the one thing a gate must never teach.
///
/// This is the second half of the defect the `built_declared` doc records
/// (`0 of 4 … (75%)`, `2 of 4 … (125%)`). That half fixed the numerator the
/// ratio USES; this half prints it.
pub fn headline(&self) -> String {
let accounted = self.built_declared() + self.excused.len();
if self.partial {
return format!(
"bins: {} declared — {} built, {} excused, {} not built here \
({}/{} = {:.0}% accounted for) — PARTIAL RUN (a variant filter was \
given){}; run without --variant for the verdict",
self.declared.len(),
self.built_declared(),
self.excused.len(),
self.unbuilt.len(),
accounted,
self.declared.len(),
self.ratio() * 100.0,
// A filter that happened to cover everything declared has nothing
// it cannot assert on, and saying "those 0 are NOT asserted on"
// invents a caveat out of an empty set.
if self.unbuilt.is_empty() {
String::new()
} else {
format!(", so those {} are NOT asserted on", self.unbuilt.len())
},
);
}
format!(
"bins: {} declared — {} built, {} excused with a written reason, {} UNBUILT \
({}/{} = {:.0}% accounted for)",
self.declared.len(),
self.built_declared(),
self.excused.len(),
self.unbuilt.len(),
accounted,
self.declared.len(),
self.ratio() * 100.0,
)
}
/// The hard assertion. `Err` carries the names, because a number without names is
/// not a work list.
pub fn verdict(&self) -> Result<(), String> {
if self.declared.is_empty() {
return Err(
"the bin tally found NO declared bins at all — a gate whose denominator \
is zero passes vacuously, which is the shape it exists to catch"
.to_string(),
);
}
let mut problems = Vec::new();
// A partial run cannot assert on coverage — but it CAN still assert that every
// atom it did walk compiled, which is `judge_run`'s job, and that the allowlist
// has not rotted, which is below.
if !self.unbuilt.is_empty() && !self.partial {
problems.push(format!(
"{} of {} declared bins were NEVER BUILT by any declared atom: {:?}\n \
Either give each one a build point in .nornir/build-variants.toml, or \
write why not in [build.allow_unbuilt_bins] — an empty reason is not a \
reason.",
self.unbuilt.len(),
self.declared.len(),
self.unbuilt,
));
}
if !self.stale_excuses.is_empty() {
problems.push(format!(
"[build.allow_unbuilt_bins] excuses bins that ARE built now: {:?} — \
delete the lines, or the allowlist rots into a blanket",
self.stale_excuses,
));
}
if problems.is_empty() { Ok(()) } else { Err(problems.join("\n\n")) }
}
}
/// **The diagnosis, computed for free: what a human's `cargo build --bins` actually
/// reaches.**
///
/// This is not the assertion — the assertion is [`BinTally`], which is about what the
/// *declared build space* covers. This is the number that explains WHY the gate is
/// needed, and it must appear in the report whether the gate passes or fails, because
/// it is the one figure nobody had. MEASURED on korp `b388500`: 4 of 9.
///
/// Read from `cargo metadata`, which is authoritative about `required-features` and
/// about the feature tables — a hand-rolled manifest walk would be a third spelling of
/// a rule that already has two.
#[derive(Debug, Clone, Default)]
pub struct NaiveReach {
/// Every `bin` target cargo knows about, across the workspace.
pub bins: usize,
/// Those whose `required-features` are satisfied by their package's `default`
/// closure — i.e. the ones a bare `cargo build --bins` would produce.
pub reachable: usize,
/// The rest, with the features they are short. **These are skipped silently, and
/// cargo exits 0.**
pub skipped: Vec<(String, Vec<String>)>,
}
impl NaiveReach {
pub fn ratio(&self) -> f64 {
if self.bins == 0 { 0.0 } else { self.reachable as f64 / self.bins as f64 }
}
pub fn headline(&self) -> String {
format!(
"a bare `cargo build --bins` reaches {} of {} bin targets ({:.0}%) — the other \
{} are skipped SILENTLY and cargo exits 0",
self.reachable,
self.bins,
self.ratio() * 100.0,
self.skipped.len(),
)
}
}
/// Compute [`NaiveReach`] from `cargo metadata`. Cheap — no compilation.
pub fn naive_reach(repo_root: &Path) -> Result<NaiveReach> {
let md = cargo_metadata::MetadataCommand::new()
.current_dir(repo_root)
.no_deps()
.exec()
.context("cargo metadata --no-deps")?;
let mut out = NaiveReach::default();
for pkg in &md.packages {
// The transitive closure of this package's own `default`, expanding only
// tokens that are themselves features. `dep:x` and `crate/feat` stay opaque,
// which is correct: neither can satisfy a `required-features` entry naming a
// plain feature.
let mut closure: BTreeSet<String> = BTreeSet::new();
let mut stack: Vec<String> = pkg.features.get("default").cloned().unwrap_or_default();
while let Some(f) = stack.pop() {
if !closure.insert(f.clone()) {
continue;
}
if let Some(next) = pkg.features.get(&f) {
stack.extend(next.iter().cloned());
}
}
for t in &pkg.targets {
if !t.kind.iter().any(|k| k.to_string() == "bin") {
continue;
}
out.bins += 1;
let missing: Vec<String> = t
.required_features
.iter()
.filter(|f| !closure.contains(*f))
.cloned()
.collect();
if missing.is_empty() {
out.reachable += 1;
} else {
out.skipped.push((t.name.clone(), missing));
}
}
}
out.skipped.sort();
Ok(out)
}
/// **The bin tally, over a set of names that were actually produced.**
///
/// Split out from [`run`] so the dwarves ⚒ Build Thing can reach the SAME assertion
/// from the artifacts a delivery build already produced, without a second cargo run and
/// without a second spelling of the rule (LAW 5). The Build Thing has just compiled
/// every cell; asking it to compile them again to learn what it already knows would be
/// both slow and a place for the two answers to disagree.
///
/// Returns the tally and any allowlist problems found while building it.
pub fn tally_bins(
repo_root: &Path,
built: BTreeSet<String>,
partial: bool,
) -> Result<(BinTally, Vec<String>)> {
let mut t = BinTally { built, partial, ..Default::default() };
t.declared = BuildVariantSet::cargo_bin_targets(repo_root)
.context("enumerate the repo's real [[bin]] targets")?;
t.declared.sort();
t.declared.dedup();
let (allow, bad) = read_allowlist(repo_root, "allow_unbuilt_bins");
let mut problems: Vec<String> =
bad.into_iter().map(|b| format!("[build.allow_unbuilt_bins] {b}")).collect();
for k in unreasoned(&allow) {
problems.push(format!(
"[build.allow_unbuilt_bins] entry `{k}` has no reason — an excuse with no \
words is not an excuse"
));
}
for b in &t.declared {
if t.built.contains(b) {
continue;
}
match allow.get(b) {
Some(why) => {
t.excused.insert(b.clone(), why.clone());
}
None => t.unbuilt.push(b.clone()),
}
}
// A stale excuse is only knowable from a FULL run: a partial one did not build the
// atom that would have made the entry legitimate.
t.stale_excuses = if partial {
Vec::new()
} else {
allow.keys().filter(|k| t.built.contains(*k)).cloned().collect()
};
Ok((t, problems))
}
/// The bin name behind a produced artifact path — `…/release/korp-server` ⇒
/// `korp-server`. What the ⚒ Build Thing has in hand after a build is a list of paths,
/// and this is the one place that mapping is spelled.
pub fn bin_name_of_artifact(path: &Path) -> Option<String> {
path.file_stem().map(|s| s.to_string_lossy().to_string())
}
// ───────────────────────────── assertion 2: test binaries that hold no test ─────────────────────────────
/// A test binary that compiled clean and contains **zero tests** — the shape that
/// prints `running 0 tests … ok` and is indistinguishable from a pass.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmptyTestBinary {
/// The atom whose feature resolution produced it.
pub atom: String,
/// The cargo target name.
pub target: String,
/// **The source file** — what makes this reportable rather than merely counted.
pub src: PathBuf,
/// The binary that was interrogated.
pub exe: PathBuf,
}
/// **How many test binaries were produced, how many hold at least one test, and the
/// source file behind every one that holds none.**
#[derive(Debug, Clone, Default)]
pub struct TestTally {
/// Test binaries produced across every atom run — the work measure. One source
/// file yields one binary per feature resolution that compiles it, so this is
/// larger than [`files`](Self::files) and is NOT what the assertion is about.
pub produced: usize,
/// Of those binaries, the ones holding at least one `#[test]`. Reported because it
/// is the raw observation; a correctly feature-gated file makes this legitimately
/// less than `produced`, which is exactly why the verdict is not built on it.
pub with_tests: usize,
/// **Distinct test SOURCE FILES seen** — the denominator the assertion uses.
pub files: usize,
/// Files that at least one declared resolution put a test in.
pub files_with_tests: usize,
/// Total tests found, summed. A sanity denominator: zero here over a non-zero
/// `produced` means every single binary was empty.
pub tests_found: usize,
/// Every empty one, named. **This is the failure.**
pub empty: Vec<EmptyTestBinary>,
/// Source files excused in `[build.allow_dark_test_files]` — file name → reason.
pub excused: BTreeMap<String, String>,
/// Binaries that could not be interrogated at all (would not run, timed out).
/// Not a pass: an uninterrogable binary proves nothing.
pub uninterrogable: Vec<(String, String)>,
/// **Only some atoms were walked.** A file this run found empty may well be
/// reached by an atom the filter excluded, so "dark" is not assertable here. Same
/// reasoning as [`BinTally::partial`].
pub partial: bool,
}
impl TestTally {
/// **Files reached over files seen.** Deliberately not `with_tests / produced`: a
/// correctly `#![cfg(feature = "x")]`-gated file compiles to an empty binary under
/// every atom lacking `x`, so the per-binary ratio is legitimately below 1 in a
/// healthy repo and cannot carry an assertion. The per-FILE ratio can: it is 1
/// exactly when every test file is reached by some declared build point.
pub fn ratio(&self) -> f64 {
if self.files == 0 {
return 0.0;
}
self.files_with_tests as f64 / self.files as f64
}
pub fn headline(&self) -> String {
if self.partial {
return format!(
"test files: {} of {} reached ({:.0}%) — PARTIAL RUN (a variant filter was \
given), so the {} found empty here are NOT asserted on; a skipped variant \
may well reach them",
self.files_with_tests,
self.files,
self.ratio() * 100.0,
self.empty.len(),
);
}
format!(
"test files: {} of {} reached by some declared atom ({:.0}%), {} DARK, \
{} excused | binaries: {} produced, {} held tests, {} tests found, \
{} uninterrogable",
self.files_with_tests,
self.files,
self.ratio() * 100.0,
self.empty.len(),
self.excused.len(),
self.produced,
self.with_tests,
self.tests_found,
self.uninterrogable.len(),
)
}
/// The hard assertion — one entry per dark file, each naming the file.
pub fn verdict(&self) -> Result<(), String> {
if self.files == 0 {
return Err(
"the test tally interrogated NO test binaries at all — a gate over an \
empty set passes vacuously, which is the shape it exists to catch"
.to_string(),
);
}
let mut problems = Vec::new();
if !self.empty.is_empty() && !self.partial {
let lines: Vec<String> = self
.empty
.iter()
.map(|e| {
format!(" {} :: {} ({})", e.atom, display_rel(&e.src), e.target)
})
.collect();
problems.push(format!(
"{} of {} test files compiled to ZERO tests under EVERY declared atom — \
each prints `running 0 tests … ok`, which is shaped exactly like a \
pass:\n{}\n \
Either put the file behind an atom whose features actually reach it, or \
write why not in [build.allow_dark_test_files].",
self.empty.len(),
self.files,
lines.join("\n"),
));
}
if !self.uninterrogable.is_empty() {
problems.push(format!(
"{} test binaries could not be asked what they contain — an \
uninterrogable binary is not a green: {:?}",
self.uninterrogable.len(),
self.uninterrogable,
));
}
if problems.is_empty() { Ok(()) } else { Err(problems.join("\n\n")) }
}
}
fn display_rel(p: &Path) -> String {
// Keep the last three components — enough to identify `crate/tests/file.rs`
// without pasting an absolute path into every failure message.
let comps: Vec<_> = p.components().collect();
let start = comps.len().saturating_sub(3);
comps[start..].iter().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
}
/// Ask a libtest binary what it contains, **without running anything**.
///
/// `--list` is libtest's own enumeration: it parses argv, prints one `name: test` line
/// per test and exits, never entering a test body. That is what makes this affordable
/// and what makes it safe to point at a GUI atom's binary — no test runs, so no window
/// opens. `DISPLAY`/`WAYLAND_DISPLAY` are cleared anyway, belt and braces.
///
/// Returns the number of tests, including `#[ignore]`d ones: an ignored test is still a
/// test that *compiled*, and this assertion is about a file that compiled to nothing.
pub fn list_tests(exe: &Path) -> Result<usize> {
let out = Command::new(exe)
.arg("--list")
.arg("--format=terse")
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.stdin(Stdio::null())
.output()
.with_context(|| format!("run `{} --list`", exe.display()))?;
if !out.status.success() {
anyhow::bail!(
"`{} --list` exited {:?}: {}",
exe.display(),
out.status.code(),
tail_lines(&String::from_utf8_lossy(&out.stderr), 5),
);
}
let stdout = String::from_utf8_lossy(&out.stdout);
// libtest prints `path::to::test: test` (and `: benchmark`). The trailing summary
// line `N tests, M benchmarks` is deliberately NOT parsed: it is absent when the
// count is zero in some libtest versions, and a parser that reads a line which may
// not exist is a gate that cannot fail. Counting the entries themselves always
// works, and zero entries is unambiguous.
Ok(stdout
.lines()
.filter(|l| l.ends_with(": test") || l.ends_with(": benchmark"))
.count())
}
// ───────────────────────────── the allowlists ─────────────────────────────
/// Read one of the `[build.allow_*]` excuse tables out of `.nornir/build-variants.toml`.
///
/// [`BuildVariantSet`] models `[build.allow_unbuilt]` (the feature axis) but not the two
/// target-level tables, so they are read here — **with the reason string, because the
/// reason IS the mechanism.** An entry with no words is not an excuse; see
/// [`unreasoned`].
///
/// A missing file or a missing table is an empty map, never an error: a repo with no
/// declaration is gated on its whole workspace instead (see [`GateSpec`]).
///
/// **A key naming a file MUST be quoted.** `dark.rs = "…"` is a TOML *dotted key* — it
/// declares a table `dark` with a member `rs`, not a key `dark.rs`. An unquoted file
/// name therefore silently excuses nothing while looking exactly like an excuse that
/// works. This was found by the red probe, which is the only reason it is not still in
/// here: a non-string value is now surfaced as [`malformed`] rather than coerced to an
/// empty reason.
pub fn allowlist(repo_root: &Path, table: &str) -> BTreeMap<String, String> {
read_allowlist(repo_root, table).0
}
/// The allowlist plus the entries whose value is not a string — the quoting trap above.
/// Returned separately so a caller can make it a hard failure rather than a shrug.
///
/// ⚠ **THIS USED TO BE A SECOND PARSER.** Until 2026-08-26 it re-opened
/// `.nornir/build-variants.toml` as raw `toml::Value` and read the table by name,
/// bypassing [`BuildVariantSet`] completely — which is the only reason
/// `[build.allow_unbuilt_bins]` worked at all: the struct had no such field, so
/// three repos (edda, gunnar, korp — 8 entries) had their declaration honoured by
/// a door the schema knew nothing about. It also meant the schema could not be
/// closed: `deny_unknown_fields` on `BuildVariantSet` would have failed those
/// three files on a key this crate itself was reading.
///
/// The field exists now, so this reads the STRUCT. The raw pass is GONE, not
/// bridged — see [`AllowEntry`](crate::variant::AllowEntry) for why it was never
/// needed: TOML has not destroyed the dotted-key fact by the time serde runs, it
/// is destroyed by choosing a value type that cannot hold it. `AllowEntry` holds
/// it, so the struct reports the same diagnostic the raw pass did.
pub fn read_allowlist(
repo_root: &Path,
table: &str,
) -> (BTreeMap<String, String>, Vec<String>) {
match BuildVariantSet::discover(repo_root) {
Ok(Some(set)) => set.allow_table(table),
// No file, or a file that does not parse. Both were empty here before and
// stay empty: a repo with no build space has nothing to allow, and a
// malformed file is a failure the LOADER reports, loudly, to every other
// caller — this function inventing a second complaint about it would put
// the same problem on the operator's screen twice, in two spellings.
_ => (BTreeMap::new(), Vec::new()),
}
}
/// **The malformed entries alone** — the quoting trap, as a list of failures.
pub fn malformed(repo_root: &Path, table: &str) -> Vec<String> {
read_allowlist(repo_root, table).1
}
/// Entries whose reason is empty or a shrug. Ten characters is the bar the korp gate
/// already set; keeping one bar means one rule to argue about.
pub fn unreasoned(allow: &BTreeMap<String, String>) -> Vec<String> {
allow
.iter()
.filter(|(_, why)| why.trim().len() < 10)
.map(|(k, _)| k.clone())
.collect()
}
// ───────────────────────────── the gate itself ─────────────────────────────
/// Which arms to run. The two halves have wildly different costs and the cheap one must
/// stay usable on its own, or it does not get run — which is where the original defect
/// came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Arms {
/// Compile each atom's bins and tally declared-vs-built. Minutes.
pub bins: bool,
/// Compile each atom's tests and tally empty test binaries. Minutes to hours.
pub tests: bool,
}
impl Default for Arms {
fn default() -> Self {
Self { bins: true, tests: true }
}
}
impl Arms {
/// Both.
pub fn all() -> Self {
Self { bins: true, tests: true }
}
/// Neither — the declaration-only pass, which compiles nothing and still fails on
/// a vacuous or unreasoned allowlist.
pub fn coverage_only() -> Self {
Self { bins: false, tests: false }
}
pub fn any(&self) -> bool {
self.bins || self.tests
}
}
/// What to gate.
#[derive(Debug, Clone)]
pub struct GateSpec {
/// The repo.
pub repo_root: PathBuf,
/// Where to build. `None` inherits the ambient `CARGO_TARGET_DIR`.
pub target_dir: Option<PathBuf>,
/// Narrow to these atom names while iterating. `None` ⇒ every declared atom.
pub atoms: Option<BTreeSet<String>>,
/// Which arms.
pub arms: Arms,
}
impl GateSpec {
pub fn new(repo_root: impl Into<PathBuf>) -> Self {
Self {
repo_root: repo_root.into(),
target_dir: None,
atoms: None,
arms: Arms::all(),
}
}
pub fn target_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.target_dir = Some(dir.into());
self
}
pub fn arms(mut self, arms: Arms) -> Self {
self.arms = arms;
self
}
/// Narrow to a comma-separated atom list — the shape an env var or a CLI flag
/// arrives in. An empty or whitespace-only string means "no narrowing".
pub fn only(mut self, csv: &str) -> Self {
let set: BTreeSet<String> =
csv.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
self.atoms = if set.is_empty() { None } else { Some(set) };
self
}
}
/// **Which build space the gate walked**, stated out loud in the report.
///
/// A repo with a `.nornir/build-variants.toml` is gated atom by atom, at each atom's
/// own declared features. A repo without one has no declared build space at all — and
/// refusing to gate it would leave exactly the repos in the worst shape ungated, which
/// is the wrong way round. So it falls back to the whole workspace, and **says so**: a
/// silent fallback would let a declaration disappear without the report changing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GateMode {
/// `.nornir/build-variants.toml` was found; these atoms were walked.
Declared { atoms: Vec<String> },
/// No declaration. `cargo --workspace` stands in, and the report says so.
Workspace { why: String },
}
impl Default for GateMode {
fn default() -> Self {
Self::Workspace { why: "unset".into() }
}
}
impl GateMode {
pub fn headline(&self) -> String {
match self {
Self::Declared { atoms } => {
format!("mode: DECLARED — {} atom(s): {atoms:?}", atoms.len())
}
Self::Workspace { why } => format!(
"mode: WORKSPACE fallback ({why}) — every workspace member, at its own \
default features. This is weaker than a declaration: it cannot reach a \
bin whose `required-features` sit outside `default`, which is precisely \
the hole. Declaring atoms is the fix."
),
}
}
}
/// The workspace stand-in argv. `--workspace` is what fixes the sharpest of the
/// measured holes: a root manifest that is a package *and* a workspace with no
/// `default-members` makes cargo select the root package alone, so a bare
/// `cargo build --bins` there builds zero of eleven bins and exits 0.
fn workspace_argv(tests: bool) -> Vec<String> {
let mut a: Vec<String> = if tests {
vec!["test".into(), "--workspace".into(), "--no-run".into(), "--tests".into()]
} else {
vec!["build".into(), "--workspace".into(), "--bins".into()]
};
a.push("--all-features".into());
a
}
/// Everything the gate learned, and its verdict.
#[derive(Debug, Clone, Default)]
pub struct GateOutcome {
/// Which build space was walked — declared atoms or the workspace stand-in.
pub mode: GateMode,
/// Every cargo invocation, whole.
pub runs: Vec<CargoRun>,
/// Assertion 1.
pub bins: BinTally,
/// Assertion 2.
pub tests: TestTally,
/// Which arms actually ran — a verdict over zero arms is not a pass.
pub arms: Arms,
/// Atoms walked.
pub atoms_walked: Vec<String>,
/// The free diagnosis: what a bare `cargo build --bins` reaches.
pub naive: NaiveReach,
/// Every failure, in the order found.
pub failures: Vec<String>,
}
impl GateOutcome {
pub fn is_green(&self) -> bool {
self.failures.is_empty()
}
/// The report. **The numbers come out whether it passed or failed** — a gate that
/// only speaks up when it is unhappy teaches nobody what it is measuring, and a
/// gate whose denominator silently went to zero looks exactly like a happy one.
pub fn render(&self) -> String {
let mut s = String::new();
s.push_str("buildgate\n");
s.push_str(&format!(" {}\n", self.mode.headline()));
s.push_str(&format!(" arms : bins={} tests={}\n", self.arms.bins, self.arms.tests));
s.push_str(&format!(" cargo runs : {}\n", self.runs.len()));
s.push_str(&format!(" {}\n", self.naive.headline()));
for (b, missing) in &self.naive.skipped {
s.push_str(&format!(" silently skipped: {b} (short of {missing:?})\n"));
}
if self.arms.bins {
s.push_str(&format!(" {}\n", self.bins.headline()));
if !self.bins.unbuilt.is_empty() {
s.push_str(&format!(" unbuilt: {:?}\n", self.bins.unbuilt));
}
}
if self.arms.tests {
s.push_str(&format!(" {}\n", self.tests.headline()));
for e in &self.tests.empty {
s.push_str(&format!(" EMPTY: {} :: {}\n", e.atom, display_rel(&e.src)));
}
}
if self.failures.is_empty() {
s.push_str(" VERDICT: PASS\n");
} else {
s.push_str(&format!(" VERDICT: FAIL — {} problem(s)\n\n", self.failures.len()));
for f in &self.failures {
s.push_str(f);
s.push_str("\n\n");
}
}
s
}
/// `Ok(())` or the whole report as an error. The report is the error on purpose:
/// a failure that says only "gate failed" sends somebody looking for the numbers
/// that were right there.
pub fn verdict(&self) -> Result<()> {
if self.is_green() {
Ok(())
} else {
anyhow::bail!("{}", self.render())
}
}
}
/// One atom's `cargo test --no-run` argv, derived from the SAME [`BuildVariant::cargo_args`]
/// the build path materialises.
///
/// Rewriting the verb and appending `--no-run` is the whole difference, so an atom whose
/// declared features change is gated at its new features with no edit here (LAW 5). The
/// declared profile is kept: a debug build of a release atom is a different compile, and
/// the entire subject of this module is that a compile which did not happen proves
/// nothing about one that did.
pub fn test_argv(v: &BuildVariant) -> Vec<String> {
let mut args = v.cargo_args();
debug_assert_eq!(args.first().map(String::as_str), Some("build"));
if args.first().map(String::as_str) == Some("build") {
args[0] = "test".to_string();
}
args.push("--no-run".to_string());
args
}
/// The same argv with `--bin X` dropped and `--tests` in its place.
///
/// **Why a second shape is structurally necessary.** `cargo test --bin X` compiles X's
/// `mod tests` and **not** `tests/*.rs` — cargo treats them as different target kinds.
/// So a gate built only on the shape above cannot compile the integration suite at all,
/// because every declared variant carries `--bin`. That is the same class of blind spot
/// one level up: a gate reading *bin unit-test compilation* while what rotted was
/// *feature-gated integration tests*.
///
/// Dropping `--bin` must drop its VALUE too — a bare bin name left behind is read by
/// cargo as a test-name filter and compiles nothing at all, silently.
pub fn integration_argv(v: &BuildVariant) -> Vec<String> {
let argv = test_argv(v);
let mut out: Vec<String> = Vec::with_capacity(argv.len());
let mut skip_next = false;
for a in argv {
if skip_next {
skip_next = false;
continue;
}
if a == "--bin" {
skip_next = true;
continue;
}
out.push(a);
}
out.push("--tests".to_string());
out
}
/// **Run the gate.**
///
/// Serial on purpose, and not because parallelism is hard: each atom is a distinct
/// feature resolution over the same target dir, and cargo already saturates the box
/// within one resolution. (Concurrency in this constellation is `gatling`; `rayon` is
/// forbidden. Neither is reached for here — there is nothing to parallelise that cargo
/// is not already parallelising.)
pub fn run(spec: &GateSpec) -> Result<GateOutcome> {
let mut out = GateOutcome { arms: spec.arms, ..Default::default() };
let root = spec.repo_root.as_path();
// The free diagnosis first, so it appears in the report even when an arm below
// fails outright. Best-effort: a repo `cargo metadata` cannot read is a problem
// the arms will report far more loudly than this line could.
out.naive = naive_reach(root).unwrap_or_default();
let set = BuildVariantSet::discover(root)
.with_context(|| format!("read {}", BuildVariantSet::conventional_path(root).display()))?;
// ── which build space? ───────────────────────────────────────────────────
// A declaration is walked atom by atom. No declaration falls back to the whole
// workspace and SAYS SO — refusing to gate an undeclared repo would leave exactly
// the repos in the worst shape ungated.
let (set, atoms): (Option<BuildVariantSet>, Vec<BuildVariant>) = match set {
Some(set) => {
let atoms: Vec<BuildVariant> = set
.variants
.iter()
.filter(|v| spec.atoms.as_ref().is_none_or(|only| only.contains(&v.name)))
.cloned()
.collect();
out.mode = GateMode::Declared {
atoms: atoms.iter().map(|v| v.name.clone()).collect(),
};
// A gate that walked nothing is not a pass. This is the `ARMS=` typo the
// shell script guarded against; an atom filter needs the same guard.
if atoms.is_empty() {
out.failures.push(format!(
"the atom filter {:?} matched NO declared atom (declared: {:?}) — a \
gate that walks nothing reports success without asserting anything",
spec.atoms,
set.names(),
));
return Ok(out);
}
(Some(set), atoms)
}
None => {
out.mode = GateMode::Workspace {
why: format!("no {}", BuildVariantSet::conventional_path(root).display()),
};
if spec.atoms.is_some() {
out.failures.push(
"a variant filter was given but this repo declares no variants — the \
filter would silently select nothing"
.to_string(),
);
return Ok(out);
}
(None, Vec::new())
}
};
out.atoms_walked = atoms.iter().map(|v| v.name.clone()).collect();
if !spec.arms.any() {
// Coverage-only is legitimate, but it must say so rather than render a
// pass over two tallies it never filled in.
out.failures.extend(coverage_only_failures(root, set.as_ref()));
return Ok(out);
}
let td = spec.target_dir.as_deref();
// ── arm 1: bins ──────────────────────────────────────────────────────────
if spec.arms.bins {
let runs: Vec<CargoRun> = if atoms.is_empty() {
vec![run_cargo(root, "workspace", &workspace_argv(false), td, &[])?]
} else {
let mut r = Vec::new();
for v in &atoms {
r.push(run_cargo(root, &v.name, &v.cargo_args(), td, &cfg_env(v))?);
}
r
};
for run in runs {
if let Some(f) = judge_run(&run, true) {
out.failures.push(f);
}
for p in &run.produced {
if p.is_bin() {
out.bins.built.insert(p.name.clone());
}
}
out.runs.push(run);
}
let (tally, mut problems) =
tally_bins(root, std::mem::take(&mut out.bins.built), spec.atoms.is_some())?;
out.bins = tally;
out.failures.append(&mut problems);
if let Err(e) = out.bins.verdict() {
out.failures.push(e);
}
}
// ── arm 2: tests ─────────────────────────────────────────────────────────
if spec.arms.tests {
let (allow_dark, bad) = read_allowlist(root, "allow_dark_test_files");
for b in bad {
out.failures.push(format!("[build.allow_dark_test_files] {b}"));
}
for k in unreasoned(&allow_dark) {
out.failures.push(format!(
"[build.allow_dark_test_files] entry `{k}` has no reason — an excuse with \
no words is not an excuse"
));
}
out.tests.excused = allow_dark.clone();
// The same binary is produced by several atoms whose feature resolutions
// coincide; interrogating it twice would double both sides of the ratio.
let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
// **Per SOURCE FILE, across every atom** — and this distinction is the whole
// correctness of the assertion. A file gated `#![cfg(feature = "x")]` compiles
// to an empty binary under every atom that lacks `x`, and that is not a
// defect: it is what a feature gate is for. The defect is a file that is empty
// under EVERY declared resolution — a file no build point reaches, which is
// therefore never compiled and never run anywhere.
//
// Flagging per-atom instead would fire on every correctly-gated file in the
// repo, and a gate that cries wolf on healthy code gets switched off, which is
// how the repo ended up with no gate at all.
let mut per_file: BTreeMap<PathBuf, (usize, EmptyTestBinary)> = BTreeMap::new();
// In workspace mode there is one synthetic "atom" standing for the whole
// workspace; the two argv shapes collapse to the one `--workspace` run.
let plan: Vec<(String, Vec<Vec<String>>)> = if atoms.is_empty() {
vec![("workspace".to_string(), vec![workspace_argv(true)])]
} else {
atoms
.iter()
.map(|v| (v.name.clone(), vec![test_argv(v), integration_argv(v)]))
.collect()
};
for (atom_name, argvs) in &plan {
let cfg = atoms
.iter()
.find(|v| &v.name == atom_name)
.map(cfg_env)
.unwrap_or_default();
for argv in argvs {
let label = if argv.iter().any(|a| a == "--tests") {
format!("{atom_name}/tests")
} else {
atom_name.clone()
};
let run = run_cargo(root, &label, argv, td, &cfg)?;
if let Some(f) = judge_run(&run, true) {
out.failures.push(f);
}
for p in &run.produced {
if !p.is_test || !seen.insert(p.path.clone()) {
continue;
}
out.tests.produced += 1;
match list_tests(&p.path) {
Ok(n) => {
if n > 0 {
out.tests.with_tests += 1;
out.tests.tests_found += n;
}
// The running maximum over every resolution that produced
// this file. One atom reaching it is enough.
let e = per_file.entry(p.src.clone()).or_insert_with(|| {
(
0,
EmptyTestBinary {
atom: atom_name.clone(),
target: p.name.clone(),
src: p.src.clone(),
exe: p.path.clone(),
},
)
});
e.0 = e.0.max(n);
}
Err(e) => out
.tests
.uninterrogable
.push((p.name.clone(), format!("{e:#}"))),
}
}
out.runs.push(run);
}
}
// A file is DARK when no declared resolution put a single test in it. Excused
// files stay in the denominator: an excuse must not shrink the thing being
// measured, or the ratio flatters itself as the allowlist grows.
let mut stale_dark: Vec<String> = Vec::new();
out.tests.partial = spec.atoms.is_some();
out.tests.files = per_file.len();
for (src, (max_tests, witness)) in &per_file {
let file = src.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
let excused = allow_dark.contains_key(&file);
if *max_tests > 0 {
out.tests.files_with_tests += 1;
} else if !excused {
out.tests.empty.push(witness.clone());
}
if *max_tests > 0 && excused {
stale_dark.push(file);
}
}
if out.tests.partial {
stale_dark.clear();
}
if !stale_dark.is_empty() {
out.failures.push(format!(
"[build.allow_dark_test_files] excuses files that ARE compiled with tests \
now: {stale_dark:?} — delete the lines, or the allowlist rots into a \
blanket"
));
}
if let Err(e) = out.tests.verdict() {
out.failures.push(e);
}
}
Ok(out)
}
/// `--cfg` travels as `RUSTFLAGS`, exactly as [`BuildVariant::build`] sets it — the
/// declaration's own mechanism, not a second spelling of it.
fn cfg_env(v: &BuildVariant) -> Vec<(String, String)> {
match &v.cfg {
Some(cfg) => {
let inherited = std::env::var("RUSTFLAGS").unwrap_or_default();
vec![("RUSTFLAGS".into(), format!("{inherited} --cfg {cfg}").trim().to_string())]
}
None => Vec::new(),
}
}
/// The seconds-long half: it compiles nothing and still catches a rotted allowlist and
/// an empty declaration.
fn coverage_only_failures(root: &Path, set: Option<&BuildVariantSet>) -> Vec<String> {
let mut f = Vec::new();
if set.is_some_and(|s| s.variants.is_empty()) {
f.push(
"the declaration lists no [[build.variant]] — every gate downstream would \
walk an empty set and report success"
.to_string(),
);
}
for table in ["allow_unbuilt_bins", "allow_dark_test_files"] {
let (allow, bad) = read_allowlist(root, table);
for b in bad {
f.push(format!("[build.{table}] {b}"));
}
for k in unreasoned(&allow) {
f.push(format!("[build.{table}] entry `{k}` has no reason"));
}
}
match BuildVariantSet::cargo_bin_targets(root) {
Ok(bins) if bins.is_empty() => {
f.push("the repo declares no [[bin]] at all — the bin tally would be vacuous".into())
}
Ok(_) => {}
Err(e) => f.push(format!("could not enumerate [[bin]] targets: {e:#}")),
}
f
}
// ───────────────────────────── the one CLI the three repos share ─────────────────────────────
/// **`cargo xtask buildgate [flags]` — the whole verb.**
///
/// Lives here, not in three `xtask/src/main.rs` files, because three copies of an
/// argument parser is exactly the duplication that let three repos drift apart in the
/// first place (LAW 5). A member's xtask is then genuinely three lines: resolve its
/// root, call this, exit.
///
/// ```text
/// --variant <a,b> narrow to these declared variants (default: all;
/// --atom is the deprecated old spelling and still works)
/// --arms <spec> all | bins | tests | coverage (default: all)
/// --target-dir <d> where to build (default: $CARGO_TARGET_DIR)
/// --list print what WOULD be gated, compile nothing
/// ```
pub mod cli {
use super::*;
/// Parsed flags. Deliberately its own type so the parse is testable without
/// running a build.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Args {
pub atoms: Option<String>,
pub arms: Option<String>,
pub target_dir: Option<PathBuf>,
pub list: bool,
}
/// Parse. An unknown flag is an ERROR, never a shrug — a mistyped `--arms` that is
/// quietly ignored runs a different gate than the one asked for and reports the
/// wrong thing green.
pub fn parse(argv: &[String]) -> Result<Args> {
let mut a = Args::default();
let mut i = 0;
while i < argv.len() {
let need = |i: usize, flag: &str| -> Result<String> {
argv.get(i + 1)
.cloned()
.ok_or_else(|| anyhow::anyhow!("{flag} needs a value"))
};
match argv[i].as_str() {
"--variant" | "--variants" => {
a.atoms = Some(need(i, "--variant")?);
i += 2;
}
// Deprecated spellings of `--variant`/`--variants`. NEVER an error:
// the written laws cite them and muscle memory is not to be broken.
"--atom" | "--atoms" => {
eprintln!(
"note: `--atom` is the deprecated spelling of `--variant`; both work"
);
a.atoms = Some(need(i, "--atom")?);
i += 2;
}
"--arms" => {
a.arms = Some(need(i, "--arms")?);
i += 2;
}
"--target-dir" => {
a.target_dir = Some(PathBuf::from(need(i, "--target-dir")?));
i += 2;
}
"--list" => {
a.list = true;
i += 1;
}
other => anyhow::bail!(
"buildgate: unknown flag `{other}` — known: --variant, --arms, \
--target-dir, --list (--atom is the deprecated spelling of \
--variant)"
),
}
}
Ok(a)
}
/// `--arms` → [`Arms`]. An unrecognised spec is an error for the same reason.
pub fn arms_of(spec: Option<&str>) -> Result<Arms> {
Ok(match spec.unwrap_or("all") {
"all" => Arms::all(),
"bins" => Arms { bins: true, tests: false },
"tests" => Arms { bins: false, tests: true },
"coverage" => Arms::coverage_only(),
other => anyhow::bail!(
"buildgate: unknown --arms `{other}` — known: all, bins, tests, coverage"
),
})
}
/// Build the spec an xtask would run.
pub fn spec(repo_root: &Path, a: &Args) -> Result<GateSpec> {
let mut s = GateSpec::new(repo_root).arms(arms_of(a.arms.as_deref())?);
if let Some(csv) = &a.atoms {
s = s.only(csv);
}
if let Some(t) = &a.target_dir {
s = s.target_dir(t.clone());
}
Ok(s)
}
/// **The verb.** Returns a process exit code: 0 green, 1 red, 2 could not run.
///
/// The report is printed on BOTH paths. A gate that prints only on failure teaches
/// nobody its denominator, and a denominator that quietly went to zero is the
/// failure it exists to catch.
pub fn run(repo_root: &Path, argv: &[String]) -> i32 {
let a = match parse(argv) {
Ok(a) => a,
Err(e) => {
eprintln!("{e:#}");
return 2;
}
};
let spec = match spec(repo_root, &a) {
Ok(s) => s,
Err(e) => {
eprintln!("{e:#}");
return 2;
}
};
if a.list {
// `--list` compiles nothing, so it must not be able to print a verdict —
// saying "built" or "passed" there would be a small lie of exactly the
// kind this whole pass is about.
match super::naive_reach(repo_root) {
Ok(n) => println!("{}", n.headline()),
Err(e) => {
eprintln!("cargo metadata: {e:#}");
return 2;
}
}
match BuildVariantSet::discover(repo_root) {
Ok(Some(set)) => println!("would gate variants: {:?}", set.names()),
Ok(None) => println!("would gate: the whole workspace (no declaration)"),
Err(e) => {
eprintln!("read the declaration: {e:#}");
return 2;
}
}
return 0;
}
match super::run(&spec) {
Ok(o) => {
print!("{}", o.render());
i32::from(!o.is_green())
}
Err(e) => {
eprintln!("buildgate could not run: {e:#}");
2
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ **edda's own bin tally is complete — every bin the widened oracle now
/// sees is either built by a cell or carries a written excuse.**
///
/// The follow-through on `20863a7`. Teaching `cargo_bin_targets` cargo's real
/// auto-discovery rule made two edda bins visible that had never been counted:
/// `nornir-bench-redprobe` and `fleet_mock_server`, both test-harness
/// subprocesses. Visible is not the same as handled — an unbuilt bin with no
/// reason is precisely the hole this gate exists to name — so both are excused
/// in `.nornir/build-variants.toml` with the reason spelled out, and this
/// asserts the excuse is READ rather than merely written.
///
/// Asserts the excuses are LOAD-BEARING, not decorative: the two names must
/// land in `excused` (proving the allowlist parsed and each entry has a
/// reason — an unreasoned entry is its own reported failure), and `unbuilt`
/// must be empty. A repo that adds a bin and neither builds nor excuses it
/// reds here, which is the whole point.
#[test]
fn eddas_own_bins_are_all_built_or_excused_in_writing() {
let edda = Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.expect("crates/<crate> is two deep in edda")
.to_path_buf();
assert!(
edda.join(".nornir").join("build-variants.toml").is_file(),
"no edda declaration at {}",
edda.display()
);
let declared = crate::variant::BuildVariantSet::cargo_bin_targets(&edda)
.expect("enumerate edda's bin targets");
// What a full delivery run produces: the bins edda's declared cargo rows name.
// It used to PRETEND every bin except the two fixtures was produced — and that
// pretence is exactly what hid `bootx64`, `edda-build-thing` and
// `estate-clock` (no row builds any of them) until ⚒ Build's gate counted them
// on loki, 2026-10-16. The rows are the producer; `stale_excuses` stays honest
// because an excuse for a bin a row names is still its own failure.
let fixtures = ["nornir-bench-redprobe", "fleet_mock_server"];
let set = crate::variant::BuildVariantSet::discover(&edda)
.expect("edda's declaration loads")
.expect("edda declares a build space");
let built: BTreeSet<String> = set
.variants
.iter()
.filter(|v| matches!(v.kind, crate::variant::ArtifactKind::Exe | crate::variant::ArtifactKind::Service))
.filter_map(|v| v.bin.clone())
.collect();
assert!(!built.is_empty() && declared.len() > built.len(), "vacuous: rows {built:?} of {declared:?}");
let (tally, problems) = tally_bins(&edda, built, false).expect("tally edda's bins");
assert!(
problems.is_empty(),
"edda's allowlist is malformed: {problems:?}"
);
for f in fixtures {
assert!(
tally.excused.contains_key(f),
"`{f}` is a test-harness bin no cell builds and its written excuse was \
not read back — excused: {:?}",
tally.excused.keys().collect::<Vec<_>>()
);
}
assert!(
tally.unbuilt.is_empty(),
"edda has bins that are neither built by a cell nor excused in writing: {:?}",
tally.unbuilt
);
assert!(
tally.stale_excuses.is_empty(),
"stale: {:?}",
tally.stale_excuses
);
}
/// **RED-probe for the tail helper**: piping through `tail` is what ate the exit
/// status seven times in a day, so the replacement must be a pure function whose
/// behaviour is asserted, not assumed.
#[test]
fn tail_keeps_the_last_lines_whole() {
let s = (1..=100).map(|i| i.to_string()).collect::<Vec<_>>().join("\n");
let t = tail_lines(&s, 3);
assert_eq!(t, "98\n99\n100");
// Fewer lines than asked for is the whole string, not a panic.
assert_eq!(tail_lines("a\nb", 40), "a\nb");
assert_eq!(tail_lines("", 40), "");
}
/// **A tally over an empty declaration must be RED, not green.** This is the
/// vacuous-pass trap: `unbuilt.is_empty()` is trivially true when `declared` is
/// empty, so a naive verdict passes hardest exactly when it knows least.
#[test]
fn an_empty_bin_tally_is_a_failure_not_a_pass() {
let t = BinTally::default();
assert!(t.unbuilt.is_empty(), "the naive condition really is satisfied");
assert!(t.verdict().is_err(), "…and the verdict must still be RED");
}
#[test]
fn an_empty_test_tally_is_a_failure_not_a_pass() {
let t = TestTally::default();
assert!(t.empty.is_empty(), "the naive condition really is satisfied");
assert!(t.verdict().is_err(), "…and the verdict must still be RED");
}
/// The bin verdict goes red on a real shortfall, and the message carries the NAME.
#[test]
fn the_bin_verdict_names_what_was_not_built() {
let mut t = BinTally {
declared: vec!["a".into(), "b".into(), "c".into()],
..Default::default()
};
t.built.insert("a".into());
t.unbuilt = vec!["b".into(), "c".into()];
let e = t.verdict().unwrap_err();
assert!(e.contains("\"b\""), "the failure must name b: {e}");
assert!(e.contains("\"c\""), "the failure must name c: {e}");
assert!(e.contains("2 of 3"), "the failure must carry both numbers: {e}");
// …and green once they are built.
t.built.insert("b".into());
t.built.insert("c".into());
t.unbuilt.clear();
assert!(t.verdict().is_ok());
assert_eq!(t.ratio(), 1.0);
}
/// **An artifact that is not a bin must not be counted as one.**
///
/// `produced_bins` is filled from every artifact path a delivery produced, and
/// `bin_name_of_artifact` is a file stem, so a mastered `.iso` and a container
/// layer arrive in `built` under names that are in no `[[bin]]` table.
///
/// SEEN RED on gunnar's real delivery, 2026-08-05, printed by the gate itself:
/// `bins: 2 of 4 declared built (125%)` — one bin, one `.iso`, three excused.
/// A ratio over 1 is arithmetically impossible for "built over declared", which
/// is the tell; the same defect had been printing `0 of 4 declared built (75%)`
/// quietly for as long as the gate existed, where nothing looks wrong at all.
#[test]
fn a_non_bin_artifact_does_not_inflate_the_bin_ratio() {
let mut t = BinTally {
declared: vec![
"gunnar".into(),
"gunnar-installtest".into(),
"gunnar-lfs-serve".into(),
"xtask".into(),
],
partial: true,
..Default::default()
};
// What the ISO cell actually produced: the chained app binary AND the ISO.
t.built.insert("gunnar".into());
t.built.insert("gunnar-appliance-iso".into());
for b in ["gunnar-installtest", "gunnar-lfs-serve", "xtask"] {
t.excused.insert(b.into(), "not a deliverable".into());
}
assert_eq!(
t.built_declared(),
1,
"exactly one DECLARED bin came out; the .iso is not a bin"
);
assert!(
t.ratio() <= 1.0,
"a coverage ratio cannot exceed 1: {:.2}",
t.ratio()
);
assert_eq!(t.ratio(), 1.0, "1 built + 3 excused over 4 declared");
let h = t.headline();
assert!(
h.contains("4 declared") && h.contains("1 built") && h.contains("4/4 = 100% accounted for"),
"the headline counts declared bins, not produced files, and shows the ratio's \
own numerator so `1` beside `100%` is not read as broken arithmetic: {h}"
);
assert!(
!h.contains("125%"),
"and never prints a percentage over 100: {h}"
);
// The other direction, without which "always 1.0" passes: a genuinely
// unbuilt, unexcused bin drags the ratio down and the count with it.
t.excused.remove("xtask");
t.unbuilt = vec!["xtask".into()];
assert_eq!(t.built_declared(), 1, "still one declared bin built");
assert_eq!(t.ratio(), 0.75, "1 built + 2 excused over 4 declared");
}
/// A stale excuse is its own red — otherwise the allowlist grows into a blanket
/// that hides the next real hole.
#[test]
fn a_stale_excuse_is_red() {
let mut t = BinTally { declared: vec!["a".into()], ..Default::default() };
t.built.insert("a".into());
assert!(t.verdict().is_ok(), "green before the stale entry");
t.stale_excuses = vec!["a".into()];
assert!(t.verdict().is_err(), "…and red after it");
}
/// The empty-test verdict names the FILE, not just a count.
#[test]
fn the_test_verdict_names_the_source_file() {
// `files` is the denominator the verdict uses; `produced` is the work
// measure. Setting only `produced` used to pass this test off a STALE test
// binary — the clock on this box ran backwards and cargo called every unit
// fresh. Both numbers are set explicitly now, and the vacuity guard below
// asserts the distinction is load-bearing.
let mut t = TestTally {
produced: 5,
with_tests: 3,
files: 4,
files_with_tests: 4,
tests_found: 12,
..Default::default()
};
assert!(t.verdict().is_ok(), "green while nothing is dark");
t.empty.push(EmptyTestBinary {
atom: "korp-cli".into(),
target: "live_map_coverage".into(),
src: PathBuf::from("/repo/tests/live_map_coverage.rs"),
exe: PathBuf::from("/t/live_map_coverage-abc"),
});
t.files_with_tests = 3;
let e = t.verdict().unwrap_err();
assert!(e.contains("live_map_coverage.rs"), "must name the file: {e}");
assert!(e.contains("korp-cli"), "must name the atom: {e}");
assert!(e.contains("1 of 4"), "must carry both numbers: {e}");
// The ratio is over FILES, never over binaries: a correctly feature-gated file
// is legitimately empty in some resolutions, so a per-binary ratio cannot
// carry this assertion. 3 of 4 files reached, while only 3 of 5 binaries held
// tests — the two numbers must not be confused.
assert!((t.ratio() - 0.75).abs() < 1e-9, "per-file ratio {}", t.ratio());
}
/// An uninterrogable binary is not a pass.
#[test]
fn an_uninterrogable_binary_is_red() {
let mut t = TestTally {
produced: 1,
with_tests: 1,
files: 1,
files_with_tests: 1,
tests_found: 1,
..Default::default()
};
assert!(t.verdict().is_ok());
t.uninterrogable.push(("x".into(), "would not run".into()));
assert!(t.verdict().is_err());
}
/// **`judge_run` must go red on the silent shape**: exit 0, no artifact. That is
/// the whole defect class, so the guard for it is asserted in both directions.
#[test]
fn a_zero_exit_that_produced_nothing_is_red() {
let silent = CargoRun {
label: "korp-cli".into(),
argv: vec!["build".into(), "--bin".into(), "korp-cli".into()],
status: Some(0),
exited_ok: true,
produced: vec![],
errors: vec![],
stderr_tail: String::new(),
};
let f = judge_run(&silent, true).expect("exit 0 with no artifact must be RED");
assert!(f.contains("produced NO artifact"), "{f}");
// The SAME run is green when no artifact was expected…
assert!(judge_run(&silent, false).is_none());
// …and green when one was produced.
let mut loud = silent.clone();
loud.produced.push(Produced {
path: PathBuf::from("/t/korp-cli"),
name: "korp-cli".into(),
kinds: vec!["bin".into()],
src: PathBuf::from("/repo/src/main.rs"),
is_test: false,
});
assert!(judge_run(&loud, true).is_none());
}
/// A signal death must never read as a pass, and must not be reported as an exit
/// code it does not have.
#[test]
fn a_signal_death_is_red_and_says_so() {
let killed = CargoRun {
label: "korp".into(),
argv: vec!["build".into()],
status: None,
exited_ok: false,
produced: vec![],
errors: vec![],
stderr_tail: "…".into(),
};
let f = judge_run(&killed, true).expect("a killed cargo is RED");
assert!(f.contains("killed by a signal"), "{f}");
}
/// `integration_argv` must drop `--bin` AND its value, and add `--tests`. A bare
/// bin name left behind is read by cargo as a test-name filter and compiles
/// nothing — silently, which is the failure mode this module exists for.
#[test]
fn the_integration_argv_drops_bin_with_its_value() {
let unit =
vec!["test", "-p", "korp", "--bin", "korp", "--features", "a,b", "--no-run"];
let unit: Vec<String> = unit.into_iter().map(String::from).collect();
// Replicate the transform on a literal argv (the real one comes from
// `cargo_args`, which needs a parsed declaration).
let mut out: Vec<String> = Vec::new();
let mut skip = false;
for a in &unit {
if skip {
skip = false;
continue;
}
if a == "--bin" {
skip = true;
continue;
}
out.push(a.clone());
}
out.push("--tests".into());
assert!(!out.iter().any(|a| a == "--bin"));
assert!(out.iter().any(|a| a == "--tests"));
assert_eq!(out.len(), unit.len() - 1, "two dropped, one added: {out:?}");
// The bin name is `korp` and so is `-p korp` — a bare `contains` check is a
// false positive here, which is why the length relation is the assertion.
assert!(out.iter().any(|a| a == "korp"), "the -p value must survive");
}
/// Arms must be selectable, and "no arm selected" must be distinguishable from
/// "every arm passed".
#[test]
fn no_arm_selected_is_not_a_pass() {
assert!(!Arms::coverage_only().any());
assert!(Arms::all().any());
}
/// An excuse with no words is not an excuse.
#[test]
fn unreasoned_entries_are_found() {
let mut m = BTreeMap::new();
m.insert("a".to_string(), String::new());
m.insert("b".to_string(), " ".to_string());
m.insert("c".to_string(), "short".to_string());
m.insert("d".to_string(), "a genuinely written reason".to_string());
let bad = unreasoned(&m);
assert_eq!(bad, vec!["a", "b", "c"], "only the written one survives");
}
/// `GateSpec::only` must treat an empty string as "no narrowing" rather than as
/// "narrow to nothing" — the latter would silently walk zero atoms.
#[test]
fn an_empty_atom_filter_does_not_narrow_to_nothing() {
assert!(GateSpec::new("/x").only("").atoms.is_none());
assert!(GateSpec::new("/x").only(" , ,").atoms.is_none());
assert_eq!(GateSpec::new("/x").only("a, b").atoms.unwrap().len(), 2);
}
/// **A mistyped flag must be an ERROR, not a shrug.** A silently-ignored `--arms`
/// runs a different gate than the one asked for and reports the wrong thing green
/// — the same class as `ARMS=` selecting no arm at all.
#[test]
fn an_unknown_flag_or_arm_is_rejected() {
assert!(cli::parse(&["--arms".into(), "bins".into()]).is_ok(), "the good spelling parses");
assert!(cli::parse(&["--armz".into(), "bins".into()]).is_err(), "a typo must be RED");
assert!(cli::parse(&["--arms".into()]).is_err(), "a flag with no value must be RED");
assert!(cli::arms_of(Some("coverage")).is_ok());
assert!(cli::arms_of(Some("covrage")).is_err(), "a typo'd arm must be RED");
// …and the parsed arms really differ, or the flag is decorative.
assert_eq!(cli::arms_of(Some("bins")).unwrap(), Arms { bins: true, tests: false });
assert_eq!(cli::arms_of(Some("tests")).unwrap(), Arms { bins: false, tests: true });
assert_eq!(cli::arms_of(None).unwrap(), Arms::all());
}
/// ★ RED-FIRST 2026-08-20 — the vocabulary is `--variant` here too: the gate's
/// narrowing flag must accept the new word, and `--atom`/`--atoms` stay accepted
/// (deprecated aliases; the written laws and muscle memory cite them).
#[test]
fn buildgate_narrows_by_variant_and_atom_alike() {
let v = cli::parse(&["--variant".into(), "a,b".into()]).expect("--variant parses");
let a = cli::parse(&["--atom".into(), "a,b".into()]).expect("--atom still parses");
assert_eq!(v, a, "the two spellings must resolve IDENTICALLY");
assert_eq!(v.atoms.as_deref(), Some("a,b"));
assert_eq!(
cli::parse(&["--variants".into(), "a".into()]).expect("--variants parses"),
cli::parse(&["--atoms".into(), "a".into()]).expect("--atoms still parses"),
);
assert!(cli::parse(&["--variant".into()]).is_err(), "no value must be RED");
}
/// The workspace fallback must ANNOUNCE itself. A silent fallback would let a
/// declaration disappear without the report changing.
#[test]
fn the_workspace_fallback_says_it_is_weaker() {
let m = GateMode::Workspace { why: "no .nornir/build-variants.toml".into() };
let h = m.headline();
assert!(h.contains("WORKSPACE fallback"), "{h}");
assert!(h.contains("weaker"), "the report must not present it as equivalent: {h}");
let d = GateMode::Declared { atoms: vec!["korp".into(), "korp-cli".into()] };
assert!(d.headline().contains("2 atom(s)"), "{}", d.headline());
}
/// The workspace stand-in must carry `--workspace`, or it repeats the exact
/// measured hole: a root manifest that is a package AND a workspace makes cargo
/// select the root package alone, which built 0 of 11 bins in znippy-zoomies.
#[test]
fn the_workspace_argv_selects_the_whole_workspace() {
let b = workspace_argv(false);
assert!(b.iter().any(|a| a == "--workspace"), "{b:?}");
assert!(b.iter().any(|a| a == "--bins"), "{b:?}");
let t = workspace_argv(true);
assert!(t.iter().any(|a| a == "--workspace"), "{t:?}");
assert!(t.iter().any(|a| a == "--no-run"), "{t:?}");
assert!(t.iter().any(|a| a == "--tests"), "{t:?}");
assert_ne!(b, t, "the two arms must not collapse to the same command");
}
/// `NaiveReach` must go red-ish (a shortfall) on the real shape and report both
/// numbers. Asserted in both directions so a ratio stuck at 1.0 is impossible.
#[test]
fn naive_reach_reports_both_numbers() {
let full = NaiveReach { bins: 9, reachable: 9, skipped: vec![] };
assert_eq!(full.ratio(), 1.0);
let short = NaiveReach {
bins: 9,
reachable: 4,
skipped: vec![("korp-server".into(), vec!["server".into()])],
};
assert!((short.ratio() - 4.0 / 9.0).abs() < 1e-9);
let h = short.headline();
assert!(h.contains("4 of 9"), "{h}");
assert!(h.contains("44%"), "{h}");
// The identity trap: an empty repo must not read as 100%.
assert_eq!(NaiveReach::default().ratio(), 0.0);
}
/// The rendered report must carry the numbers even when it PASSES. A gate that
/// only speaks up when unhappy leaves nobody able to notice its denominator went
/// to zero.
#[test]
fn the_report_carries_the_numbers_on_a_pass() {
let mut o = GateOutcome { arms: Arms::all(), ..Default::default() };
o.mode = GateMode::Declared { atoms: vec!["korp".into()] };
o.naive = NaiveReach { bins: 9, reachable: 4, skipped: vec![] };
o.atoms_walked = vec!["korp".into()];
o.bins.declared = vec!["korp".into()];
o.bins.built.insert("korp".into());
o.tests.produced = 2;
o.tests.with_tests = 2;
o.tests.files = 2;
o.tests.files_with_tests = 2;
o.tests.tests_found = 7;
let r = o.render();
assert!(o.is_green());
assert!(r.contains("1 declared") && r.contains("1 built"), "{r}");
assert!(r.contains("1/1 = 100% accounted for"), "the ratio shows its own numerator: {r}");
assert!(r.contains("2 of 2 reached"), "{r}");
assert!(r.contains("2 produced"), "the work measure rides along too: {r}");
assert!(r.contains("7 tests found"), "{r}");
assert!(r.contains("VERDICT: PASS"), "{r}");
// The diagnosis rides along on a PASS too — it is the number nobody had.
assert!(r.contains("reaches 4 of 9"), "{r}");
}
}