crates/nornir-build-thing/src/measure/gate.rs
//! **The drift gate** (Heimdall tooling plan step 6, owner decision
//! 2026-10-31): `measure gate [--row R]… [--target D] [--json FILE]`.
//!
//! The forge runs it on every new head of a repository's main: it checks
//! EVERY row that has `.nornir/profiles/<row>/measure.toml` (or only the
//! named ones) with the same `heimdall profile check` the measure step's
//! `--check` runs, and fails when any row drifts — a call the checked-in
//! profile does not allow, named by row, program, call and test; a generated
//! file that no longer matches its `.json`; or a row that cannot be checked
//! at all (never measured, binary not built, tool missing or not the pinned
//! one). A refused row never hides the others: each row is checked and
//! reported on its own.
//!
//! It only CHECKS: it never probes into, generates or rewrites a profile.
//! A profile changes only by `measure syscalls <row>` and a reviewed commit.
//!
//! The JSON (`--json`, schema [`GATE_SCHEMA`]) is what the forge keeps and
//! the review UI reads. The licence boundary is the measure step's: the
//! `heimdall` binary is an external process, never linked.
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow, bail};
use serde::Serialize;
use super::{
MANIFEST, MeasureVerb, PROFILES_DIR, ProfileTool, ToolIdentity, ToolPin, run_measure_verb,
};
use super::{Drift, MeasureManifest};
/// The gate report's schema name (bumped on an incompatible change).
pub const GATE_SCHEMA: &str = "nornir.measure.gate/v1";
/// The gate's usage line.
pub const GATE_USAGE: &str =
"usage: measure gate [--row <row>]… [--build] [--target <dir>] [--json <file>]";
/// `measure gate …`, parsed.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GateVerb {
/// Only these rows (empty: every row with a manifest).
pub rows: Vec<String>,
/// `{target}` (default `$CARGO_TARGET_DIR`, else `<repo>/target`).
pub target: Option<PathBuf>,
/// Also write the report as JSON here.
pub json: Option<PathBuf>,
/// Build each row's programs first (its `[[build]]` cargo lines).
pub build: bool,
}
/// Parse the words after `measure` (the first is `gate`).
pub fn parse_gate_verb(args: &[String]) -> Result<GateVerb> {
if args.first().map(String::as_str) != Some("gate") {
bail!("{GATE_USAGE}");
}
let mut v = GateVerb::default();
let mut i = 1;
while i < args.len() {
let val = |i: usize| {
args.get(i + 1)
.cloned()
.ok_or_else(|| anyhow!("{} needs a value — {GATE_USAGE}", args[i]))
};
if args[i] == "--build" {
v.build = true;
i += 1;
continue;
}
match args[i].as_str() {
"--row" => v.rows.push(val(i)?),
"--target" => v.target = Some(PathBuf::from(val(i)?)),
"--json" => v.json = Some(PathBuf::from(val(i)?)),
other => bail!("measure gate: unknown argument `{other}` — {GATE_USAGE}"),
}
i += 2;
}
Ok(v)
}
/// Every row with `.nornir/profiles/<row>/measure.toml`, sorted.
pub fn rows_with_profiles(repo_root: &Path) -> Result<Vec<String>> {
let dir = repo_root.join(PROFILES_DIR);
let mut rows = Vec::new();
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(rows),
Err(e) => return Err(anyhow!("read {}: {e}", dir.display())),
};
for e in entries {
let e = e.map_err(|e| anyhow!("read {}: {e}", dir.display()))?;
if e.path().join(MANIFEST).is_file() {
if let Some(n) = e.file_name().to_str() {
rows.push(n.to_string());
}
}
}
rows.sort();
Ok(rows)
}
/// One checked (program, libc).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GateChecked {
pub program: String,
pub libc: String,
}
/// A generated file that no longer matches its source.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GateOutOfSync {
pub program: String,
pub file: String,
}
/// A program the gate cannot cover off a VM (listed, never a failure).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GateVmLater {
pub program: String,
pub why: String,
}
/// One row's verdict.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GateRow {
pub row: String,
pub ok: bool,
/// The row could not be checked: the reason, in the step's words.
#[serde(skip_serializing_if = "Option::is_none")]
pub refused: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool: Option<ToolIdentity>,
pub checked: Vec<GateChecked>,
pub drift: Vec<Drift>,
pub out_of_sync: Vec<GateOutOfSync>,
pub vm_later: Vec<GateVmLater>,
/// The row has an approval file (`dwarves tune approve`): every reviewed file is held to it.
pub reviewed: bool,
/// Reviewed files that are not what was approved (one line each).
pub not_approved: Vec<String>,
}
impl GateRow {
fn refused(row: &str, why: String) -> GateRow {
GateRow {
row: row.into(),
ok: false,
refused: Some(why),
tool: None,
checked: Vec::new(),
drift: Vec::new(),
out_of_sync: Vec::new(),
vm_later: Vec::new(),
reviewed: false,
not_approved: Vec::new(),
}
}
}
/// The gate's verdict over every row.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GateReport {
pub schema: &'static str,
pub ok: bool,
/// The gate as a whole could not run (no row has a manifest).
#[serde(skip_serializing_if = "Option::is_none")]
pub refused: Option<String>,
pub rows: Vec<GateRow>,
}
impl GateReport {
/// The report as pretty JSON (schema [`GATE_SCHEMA`]), newline-terminated.
pub fn to_json(&self) -> String {
let mut s = serde_json::to_string_pretty(self).expect("the gate report serializes");
s.push('\n');
s
}
/// The operator's text: one line per finding, then the verdict.
pub fn render(&self) -> String {
let mut s = String::from("# measure gate — syscall profile drift\n");
if let Some(why) = &self.refused {
s.push_str(&format!("REFUSED: {why}\n"));
}
for r in &self.rows {
if let Some(why) = &r.refused {
s.push_str(&format!("{}: REFUSED — {why}\n", r.row));
continue;
}
for c in &r.checked {
let bad = r.drift.iter().any(|d| d.program == c.program && d.libc == c.libc)
|| r.out_of_sync.iter().any(|o| o.program == c.program);
s.push_str(&format!(
"{}/{} ({}): {}\n",
r.row,
c.program,
c.libc,
if bad { "DRIFT" } else { "OK" }
));
}
for d in &r.drift {
s.push_str(&format!(
"DRIFT {}/{} ({}): new call {} ({}), made by test {}\n",
r.row,
d.program,
d.libc,
d.call,
d.action,
d.tests.join(", ")
));
}
for o in &r.out_of_sync {
s.push_str(&format!(
"DRIFT {}/{}: {} does not match its source (stale or hand-edited)\n",
r.row, o.program, o.file
));
}
if !r.reviewed {
s.push_str(&format!("{}: not reviewed yet (dwarves tune approve {})\n", r.row, r.row));
}
for n in &r.not_approved {
s.push_str(&format!("{}: {n}\n", r.row));
}
for v in &r.vm_later {
s.push_str(&format!("{}/{}: not covered off a VM: {}\n", r.row, v.program, v.why));
}
}
if self.ok {
s.push_str(&format!("GATE OK: {} row(s), no drift\n", self.rows.len()));
} else {
let bad: Vec<&str> = self.rows.iter().filter(|r| !r.ok).map(|r| r.row.as_str()).collect();
s.push_str(&format!(
"GATE FAILED: {}\nfix: measure syscalls <row> (probe + gen), review the profile diff, commit it; never widen a profile by hand\n",
if bad.is_empty() { "no row to check".to_string() } else { bad.join(", ") }
));
}
s
}
}
/// **Run the gate**: check every row (or the named ones). `tool_for` finds
/// the external tool for a row's pin (the real caller:
/// [`super::ExternalTool::locate`]); the pin itself is verified per row by
/// the measure step.
///
/// With `--build`, each row's `[[build]]` cargo lines run first through
/// `build(repo_root, argv, target)` (the real caller runs `cargo <argv>` with
/// `CARGO_TARGET_DIR = target`); a failed build refuses that row only.
pub fn run_gate<T, F, B>(v: &GateVerb, repo_root: &Path, tool_for: &F, build: &B) -> GateReport
where
T: ProfileTool,
F: Fn(&ToolPin) -> Result<T>,
B: Fn(&Path, &[String], &Path) -> Result<()>,
{
let rows = if v.rows.is_empty() {
match rows_with_profiles(repo_root) {
Ok(r) => r,
Err(e) => return gate_refused(format!("{e:#}")),
}
} else {
v.rows.clone()
};
if rows.is_empty() {
return gate_refused(format!(
"no row has {PROFILES_DIR}/<row>/{MANIFEST} under {} — measure a row first (measure syscalls <row>)",
repo_root.display()
));
}
let mut out = Vec::new();
for row in &rows {
out.push(gate_row(row, v, repo_root, tool_for, build));
}
let ok = out.iter().all(|r| r.ok);
GateReport {
schema: GATE_SCHEMA,
ok,
refused: None,
rows: out,
}
}
fn gate_refused(why: String) -> GateReport {
GateReport {
schema: GATE_SCHEMA,
ok: false,
refused: Some(why),
rows: Vec::new(),
}
}
fn gate_row<T, F, B>(row: &str, v: &GateVerb, repo_root: &Path, tool_for: &F, build: &B) -> GateRow
where
T: ProfileTool,
F: Fn(&ToolPin) -> Result<T>,
B: Fn(&Path, &[String], &Path) -> Result<()>,
{
let m = match MeasureManifest::load(repo_root, row) {
Ok(m) => m,
Err(e) => return GateRow::refused(row, format!("{e:#}")),
};
if v.build {
let target = v
.target
.clone()
.or_else(|| std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from))
.unwrap_or_else(|| repo_root.join("target"));
for b in &m.build {
if let Err(e) = build(repo_root, &b.cargo, &target) {
return GateRow::refused(row, format!("cargo {}: {e:#}", b.cargo.join(" ")));
}
}
}
let tool = match tool_for(&m.tool) {
Ok(t) => t,
Err(e) => return GateRow::refused(row, format!("{e:#}")),
};
let verb = MeasureVerb {
row: row.into(),
check: true,
target: v.target.clone(),
..Default::default()
};
let approval = match super::approval_status(repo_root, row) {
Ok(a) => a,
Err(e) => return GateRow::refused(row, format!("{e:#}")),
};
let not_approved = if approval.exists { approval.problems() } else { Vec::new() };
match run_measure_verb(&verb, repo_root, &tool) {
Err(e) => GateRow::refused(row, format!("{e:#}")),
Ok(r) => GateRow {
row: row.into(),
ok: r.ok() && not_approved.is_empty(),
reviewed: approval.exists,
not_approved,
refused: None,
tool: r.tool.clone(),
checked: r
.checked
.iter()
.map(|(p, l)| GateChecked {
program: p.clone(),
libc: l.clone(),
})
.collect(),
drift: r.drift.clone(),
out_of_sync: r
.out_of_sync
.iter()
.map(|(p, f)| GateOutOfSync {
program: p.clone(),
file: f.clone(),
})
.collect(),
vm_later: r
.vm_later
.iter()
.map(|(p, w)| GateVmLater {
program: p.clone(),
why: w.clone(),
})
.collect(),
},
}
}