use serde::Deserialize;
use std::path::{Component, Path, PathBuf};
pub const MERGE_GATES_PATH: &str = ".kranz/merge-gates.json";
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GateSuite {
pub gates: Vec<Gate>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Gate {
pub command: String,
#[serde(default = "default_cwd")]
pub cwd: String,
#[serde(default)]
pub when_paths: Vec<String>,
}
fn default_cwd() -> String {
".".to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GateSuiteResult {
Passed,
Failed { gate: String, output: String },
}
pub fn parse_gate_suite(bytes: &[u8]) -> Result<GateSuite, String> {
let mut suite: GateSuite =
serde_json::from_slice(bytes).map_err(|e| format!("invalid {MERGE_GATES_PATH}: {e}"))?;
validate_gate_suite(&suite)?;
for gate in &mut suite.gates {
gate.cwd = normalize_relative_path(&gate.cwd, true);
for prefix in &mut gate.when_paths {
*prefix = normalize_relative_path(prefix, false);
if prefix.is_empty() {
return Err(format!(
"{MERGE_GATES_PATH} whenPaths entries must name a repo path, not only '.' components"
));
}
}
}
Ok(suite)
}
pub(crate) fn normalize_relative_path(raw: &str, dot_for_empty: bool) -> String {
let normalized = Path::new(raw)
.components()
.filter_map(|component| match component {
Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
Component::CurDir => None,
_ => None,
})
.collect::<Vec<_>>()
.join("/");
if normalized.is_empty() && dot_for_empty {
".".to_string()
} else {
normalized
}
}
fn validate_gate_suite(suite: &GateSuite) -> Result<(), String> {
if suite.gates.is_empty() {
return Err(format!(
"{MERGE_GATES_PATH} must define at least one merge gate"
));
}
if !suite.gates.iter().any(|gate| gate.when_paths.is_empty()) {
return Err(format!(
"{MERGE_GATES_PATH} must include at least one unconditional gate so every diff is validated"
));
}
for (index, gate) in suite.gates.iter().enumerate() {
if gate.command.trim().is_empty() {
return Err(format!(
"{MERGE_GATES_PATH} gate {} has an empty command",
index + 1
));
}
if gate.command.contains(['\n', '\r', '\0']) {
return Err(format!(
"{MERGE_GATES_PATH} gate {} command must be a single non-NUL line",
index + 1
));
}
validate_relative_path(&gate.cwd, "cwd", index)?;
for prefix in &gate.when_paths {
validate_relative_path(prefix, "whenPaths entry", index)?;
if prefix == "." {
return Err(format!(
"{MERGE_GATES_PATH} gate {} should omit whenPaths to run unconditionally",
index + 1
));
}
}
}
Ok(())
}
fn validate_relative_path(raw: &str, field: &str, gate_index: usize) -> Result<(), String> {
if raw.trim().is_empty() {
return Err(format!(
"{MERGE_GATES_PATH} gate {} has an empty {field}",
gate_index + 1
));
}
let path = Path::new(raw);
if path.is_absolute()
|| path
.components()
.any(|part| !matches!(part, Component::CurDir | Component::Normal(_)))
{
return Err(format!(
"{MERGE_GATES_PATH} gate {} {field} must be repo-relative without parent components: {raw:?}",
gate_index + 1
));
}
Ok(())
}
pub fn run_gate_suite<F>(
repo_root: &Path,
changed_paths: &[String],
suite: &GateSuite,
executor: F,
) -> GateSuiteResult
where
F: Fn(&str, &Path) -> (bool, String),
{
for gate in &suite.gates {
if !gate_applies(gate, changed_paths) {
continue;
}
let cwd: PathBuf = if gate.cwd == "." {
repo_root.to_path_buf()
} else {
repo_root.join(&gate.cwd)
};
let (ok, output) = executor(&gate.command, &cwd);
if !ok {
return GateSuiteResult::Failed {
gate: gate.command.clone(),
output,
};
}
}
GateSuiteResult::Passed
}
fn gate_applies(gate: &Gate, changed_paths: &[String]) -> bool {
when_paths_match(&gate.when_paths, changed_paths)
}
pub(crate) fn when_paths_match(when_paths: &[String], changed_paths: &[String]) -> bool {
when_paths.is_empty()
|| when_paths.iter().any(|prefix| {
let prefix = prefix.trim_end_matches('/');
changed_paths.iter().any(|path| {
path == prefix
|| path
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('/'))
})
})
}
pub struct MergeSuiteGate<F> {
repo_root: PathBuf,
changed_paths: Vec<String>,
suite: GateSuite,
executor: F,
}
impl<F> MergeSuiteGate<F>
where
F: Fn(&str, &Path) -> (bool, String),
{
pub fn new(repo_root: &Path, changed_paths: &[String], suite: GateSuite, executor: F) -> Self {
Self {
repo_root: repo_root.to_path_buf(),
changed_paths: changed_paths.to_vec(),
suite,
executor,
}
}
}
impl<F> crate::gate::Gate for MergeSuiteGate<F>
where
F: Fn(&str, &Path) -> (bool, String),
{
fn name(&self) -> &str {
"merge-gate-suite"
}
fn kind(&self) -> crate::gate::GateKind {
crate::gate::GateKind::Deterministic
}
fn evaluate(&self) -> crate::gate::GateOutcome {
use crate::gate::{ArtefactRef, GateOutcome};
match run_gate_suite(
&self.repo_root,
&self.changed_paths,
&self.suite,
&self.executor,
) {
GateSuiteResult::Passed => GateOutcome::pass(ArtefactRef::new(MERGE_GATES_PATH)),
GateSuiteResult::Failed { gate, output } => {
GateOutcome::fail(ArtefactRef::new(gate).with_detail(output))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct FakeExecutor {
calls: RefCell<Vec<(String, PathBuf)>>,
failing_command: Option<&'static str>,
}
impl FakeExecutor {
fn all_pass() -> Self {
Self {
calls: RefCell::new(Vec::new()),
failing_command: None,
}
}
fn failing(command: &'static str) -> Self {
Self {
calls: RefCell::new(Vec::new()),
failing_command: Some(command),
}
}
fn run(&self, command: &str, cwd: &Path) -> (bool, String) {
self.calls
.borrow_mut()
.push((command.to_string(), cwd.to_path_buf()));
if self.failing_command == Some(command) {
(false, "gate failed".to_string())
} else {
(true, String::new())
}
}
}
fn suite() -> GateSuite {
parse_gate_suite(
br#"{
"gates": [
{"command":"cargo test --workspace","cwd":"."},
{"command":"npm test","cwd":"apps/dashboard","whenPaths":["apps/dashboard"]}
]
}"#,
)
.unwrap()
}
#[test]
fn parse_rejects_empty_or_conditional_only_suites() {
assert!(parse_gate_suite(br#"{"gates":[]}"#)
.unwrap_err()
.contains("at least one"));
assert!(
parse_gate_suite(br#"{"gates":[{"command":"npm test","whenPaths":["web"]}]}"#)
.unwrap_err()
.contains("unconditional")
);
}
#[test]
fn composition_audit_merge_gate_suite_fails_closed_on_every_weakening_shape() {
assert!(parse_gate_suite(b"not json").is_err());
assert!(parse_gate_suite(br#"{"gates":[]}"#).is_err());
assert!(
parse_gate_suite(br#"{"gates":[{"command":"npm test","whenPaths":["web"]}]}"#).is_err()
);
assert!(parse_gate_suite(
br#"{"gates":[{"command":"a","whenPaths":["."]},{"command":"b"}]}"#
)
.is_err());
assert!(parse_gate_suite(br#"{"gates":[{"command":"a","cwd":"../x"}]}"#).is_err());
assert!(parse_gate_suite(
br#"{"gates":[{"command":"ok"},{"command":"npm test","whenPaths":["web"]}]}"#
)
.is_ok());
}
#[test]
fn parse_rejects_paths_that_escape_the_repo() {
for text in [
br#"{"gates":[{"command":"test","cwd":"../outside"}]}"#.as_slice(),
br#"{"gates":[{"command":"test","whenPaths":["/tmp"]},{"command":"ok"}]}"#.as_slice(),
] {
assert!(parse_gate_suite(text)
.unwrap_err()
.contains("repo-relative without parent components"));
}
}
#[test]
fn unconditional_and_matching_conditional_gates_run_in_order() {
let root = PathBuf::from("/repo");
let exec = FakeExecutor::all_pass();
let result = run_gate_suite(
&root,
&["apps/dashboard/src/App.tsx".to_string()],
&suite(),
|cmd, cwd| exec.run(cmd, cwd),
);
assert_eq!(result, GateSuiteResult::Passed);
assert_eq!(
*exec.calls.borrow(),
vec![
("cargo test --workspace".to_string(), root.clone()),
("npm test".to_string(), root.join("apps/dashboard")),
]
);
}
#[test]
fn dot_prefixed_paths_are_normalized_before_matching() {
let suite = parse_gate_suite(
br#"{"gates":[{"command":"always"},{"command":"web","cwd":"./apps/dashboard","whenPaths":["./apps/dashboard/"]}]}"#,
)
.unwrap();
assert_eq!(suite.gates[1].cwd, "apps/dashboard");
assert_eq!(suite.gates[1].when_paths, ["apps/dashboard"]);
let exec = FakeExecutor::all_pass();
let result = run_gate_suite(
Path::new("/repo"),
&["apps/dashboard/src/App.tsx".to_string()],
&suite,
|cmd, cwd| exec.run(cmd, cwd),
);
assert_eq!(result, GateSuiteResult::Passed);
assert_eq!(exec.calls.borrow().len(), 2);
}
#[test]
fn unrelated_diff_skips_conditional_gate() {
let root = PathBuf::from("/repo");
let exec = FakeExecutor::all_pass();
run_gate_suite(
&root,
&["crates/engine/src/lib.rs".to_string()],
&suite(),
|cmd, cwd| exec.run(cmd, cwd),
);
assert_eq!(exec.calls.borrow().len(), 1);
assert_eq!(exec.calls.borrow()[0].0, "cargo test --workspace");
}
#[test]
fn first_failure_stops_the_suite() {
let root = PathBuf::from("/repo");
let exec = FakeExecutor::failing("cargo test --workspace");
let result = run_gate_suite(
&root,
&["apps/dashboard/src/App.tsx".to_string()],
&suite(),
|cmd, cwd| exec.run(cmd, cwd),
);
assert_eq!(
result,
GateSuiteResult::Failed {
gate: "cargo test --workspace".to_string(),
output: "gate failed".to_string(),
}
);
assert_eq!(exec.calls.borrow().len(), 1);
}
#[test]
fn gate_plugin_merge_suite_runs_through_the_interface_unchanged() {
use crate::gate::Gate;
let root = PathBuf::from("/repo");
let exec = FakeExecutor::all_pass();
let gate = MergeSuiteGate::new(
&root,
&["apps/dashboard/src/App.tsx".to_string()],
suite(),
|cmd, cwd| exec.run(cmd, cwd),
);
assert_eq!(gate.name(), "merge-gate-suite");
assert_eq!(gate.kind(), crate::gate::GateKind::Deterministic);
let outcome = gate.evaluate();
assert!(outcome.passed());
assert_eq!(outcome.score, None, "the suite is a boolean-only gate");
assert_eq!(outcome.artefact.reference, MERGE_GATES_PATH);
assert_eq!(outcome.artefact.detail, None);
assert_eq!(
*exec.calls.borrow(),
vec![
("cargo test --workspace".to_string(), root.clone()),
("npm test".to_string(), root.join("apps/dashboard")),
],
"same commands, same order as run_gate_suite"
);
}
#[test]
fn gate_plugin_merge_suite_stops_at_first_failure_through_the_interface() {
use crate::gate::Gate;
let root = PathBuf::from("/repo");
let exec = FakeExecutor::failing("cargo test --workspace");
let gate = MergeSuiteGate::new(
&root,
&["apps/dashboard/src/App.tsx".to_string()],
suite(),
|cmd, cwd| exec.run(cmd, cwd),
);
let outcome = gate.evaluate();
assert!(!outcome.passed());
assert_eq!(outcome.artefact.reference, "cargo test --workspace");
assert_eq!(outcome.artefact.detail.as_deref(), Some("gate failed"));
assert_eq!(exec.calls.borrow().len(), 1, "later gates never ran");
}
#[test]
fn gate_plugin_merge_suite_skips_unrelated_conditional_gates() {
use crate::gate::Gate;
let root = PathBuf::from("/repo");
let exec = FakeExecutor::all_pass();
let gate = MergeSuiteGate::new(
&root,
&["crates/engine/src/lib.rs".to_string()],
suite(),
|cmd, cwd| exec.run(cmd, cwd),
);
assert!(gate.evaluate().passed());
assert_eq!(exec.calls.borrow().len(), 1);
assert_eq!(exec.calls.borrow()[0].0, "cargo test --workspace");
}
}