Skip to main content

kranz_engine/
merge_gate.rs

1//! Repo-configured merge-gate runner.
2//!
3//! The gate suite is read from the live base branch's tracked
4//! [`.kranz/merge-gates.json`](MERGE_GATES_PATH). Keeping the suite on the
5//! base branch prevents a mission from weakening the checks that judge its own
6//! diff, and lets non-Rust repositories define gates in their own language.
7//! The runner itself is pure and injectable: production wraps the existing
8//! bounded shell runner while tests inject deterministic outcomes.
9//!
10//! The suite also runs through the first-class gate interface
11//! ([`crate::gate`], ticket `.kranz/tickets/gate-plugin-interface.md`) via
12//! [`MergeSuiteGate`]; [`crate::merge`] drives it that way. The adaptation
13//! changes nothing about ownership or behavior: the suite bytes are still
14//! read from the live base branch, gates still run in declared order, and
15//! the suite still stops at the first failure.
16
17use serde::Deserialize;
18use std::path::{Component, Path, PathBuf};
19
20pub const MERGE_GATES_PATH: &str = ".kranz/merge-gates.json";
21
22#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
23#[serde(rename_all = "camelCase", deny_unknown_fields)]
24pub struct GateSuite {
25    pub gates: Vec<Gate>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
29#[serde(rename_all = "camelCase", deny_unknown_fields)]
30pub struct Gate {
31    pub command: String,
32    #[serde(default = "default_cwd")]
33    pub cwd: String,
34    /// Relative path prefixes. Empty means the gate always runs; otherwise it
35    /// runs when at least one changed path equals or is below a prefix.
36    #[serde(default)]
37    pub when_paths: Vec<String>,
38}
39
40fn default_cwd() -> String {
41    ".".to_string()
42}
43
44/// Result of running the applicable gates in a suite.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum GateSuiteResult {
47    Passed,
48    Failed { gate: String, output: String },
49}
50
51/// Parse and validate a tracked gate-suite file. Invalid or empty suites fail
52/// closed: a Merge action must never become green merely because its repo
53/// config is absent or malformed.
54pub fn parse_gate_suite(bytes: &[u8]) -> Result<GateSuite, String> {
55    let mut suite: GateSuite =
56        serde_json::from_slice(bytes).map_err(|e| format!("invalid {MERGE_GATES_PATH}: {e}"))?;
57    validate_gate_suite(&suite)?;
58    for gate in &mut suite.gates {
59        gate.cwd = normalize_relative_path(&gate.cwd, true);
60        for prefix in &mut gate.when_paths {
61            *prefix = normalize_relative_path(prefix, false);
62            if prefix.is_empty() {
63                return Err(format!(
64                    "{MERGE_GATES_PATH} whenPaths entries must name a repo path, not only '.' components"
65                ));
66            }
67        }
68    }
69    Ok(suite)
70}
71
72/// Message-free `.`-component normalization, shared with the pack contract
73/// ([`crate::pack`]) — same normalization, pack-worded errors live with the
74/// caller.
75pub(crate) fn normalize_relative_path(raw: &str, dot_for_empty: bool) -> String {
76    let normalized = Path::new(raw)
77        .components()
78        .filter_map(|component| match component {
79            Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
80            Component::CurDir => None,
81            _ => None,
82        })
83        .collect::<Vec<_>>()
84        .join("/");
85    if normalized.is_empty() && dot_for_empty {
86        ".".to_string()
87    } else {
88        normalized
89    }
90}
91
92fn validate_gate_suite(suite: &GateSuite) -> Result<(), String> {
93    if suite.gates.is_empty() {
94        return Err(format!(
95            "{MERGE_GATES_PATH} must define at least one merge gate"
96        ));
97    }
98    if !suite.gates.iter().any(|gate| gate.when_paths.is_empty()) {
99        return Err(format!(
100            "{MERGE_GATES_PATH} must include at least one unconditional gate so every diff is validated"
101        ));
102    }
103
104    for (index, gate) in suite.gates.iter().enumerate() {
105        if gate.command.trim().is_empty() {
106            return Err(format!(
107                "{MERGE_GATES_PATH} gate {} has an empty command",
108                index + 1
109            ));
110        }
111        if gate.command.contains(['\n', '\r', '\0']) {
112            return Err(format!(
113                "{MERGE_GATES_PATH} gate {} command must be a single non-NUL line",
114                index + 1
115            ));
116        }
117        validate_relative_path(&gate.cwd, "cwd", index)?;
118        for prefix in &gate.when_paths {
119            validate_relative_path(prefix, "whenPaths entry", index)?;
120            if prefix == "." {
121                return Err(format!(
122                    "{MERGE_GATES_PATH} gate {} should omit whenPaths to run unconditionally",
123                    index + 1
124                ));
125            }
126        }
127    }
128    Ok(())
129}
130
131fn validate_relative_path(raw: &str, field: &str, gate_index: usize) -> Result<(), String> {
132    if raw.trim().is_empty() {
133        return Err(format!(
134            "{MERGE_GATES_PATH} gate {} has an empty {field}",
135            gate_index + 1
136        ));
137    }
138    let path = Path::new(raw);
139    if path.is_absolute()
140        || path
141            .components()
142            .any(|part| !matches!(part, Component::CurDir | Component::Normal(_)))
143    {
144        return Err(format!(
145            "{MERGE_GATES_PATH} gate {} {field} must be repo-relative without parent components: {raw:?}",
146            gate_index + 1
147        ));
148    }
149    Ok(())
150}
151
152/// Run applicable gates in declared order, stopping on the first failure.
153pub fn run_gate_suite<F>(
154    repo_root: &Path,
155    changed_paths: &[String],
156    suite: &GateSuite,
157    executor: F,
158) -> GateSuiteResult
159where
160    F: Fn(&str, &Path) -> (bool, String),
161{
162    for gate in &suite.gates {
163        if !gate_applies(gate, changed_paths) {
164            continue;
165        }
166        let cwd: PathBuf = if gate.cwd == "." {
167            repo_root.to_path_buf()
168        } else {
169            repo_root.join(&gate.cwd)
170        };
171        let (ok, output) = executor(&gate.command, &cwd);
172        if !ok {
173            return GateSuiteResult::Failed {
174                gate: gate.command.clone(),
175                output,
176            };
177        }
178    }
179    GateSuiteResult::Passed
180}
181
182fn gate_applies(gate: &Gate, changed_paths: &[String]) -> bool {
183    when_paths_match(&gate.when_paths, changed_paths)
184}
185
186/// The `whenPaths` applicability rule, shared with the pack contract
187/// ([`crate::pack`]): empty prefixes match everything (the gate runs
188/// unconditionally); otherwise at least one changed path must equal a prefix
189/// or sit below it. Trailing slashes on a prefix are insignificant.
190pub(crate) fn when_paths_match(when_paths: &[String], changed_paths: &[String]) -> bool {
191    when_paths.is_empty()
192        || when_paths.iter().any(|prefix| {
193            let prefix = prefix.trim_end_matches('/');
194            changed_paths.iter().any(|path| {
195                path == prefix
196                    || path
197                        .strip_prefix(prefix)
198                        .is_some_and(|rest| rest.starts_with('/'))
199            })
200        })
201}
202
203/// The merge-gate suite adapted to the first-class [`crate::gate::Gate`]
204/// interface (ticket `.kranz/tickets/gate-plugin-interface.md`).
205///
206/// The adapter is a thin, behavior-preserving wrapper: `evaluate` delegates
207/// to [`run_gate_suite`], so the suite runs the same commands in the same
208/// declared order and still stops at the first failure. The suite is a
209/// deterministic, boolean-only gate — it reports no confidence score — and
210/// its artefact is the failing gate's command plus captured output, or the
211/// tracked suite path when every gate passed. Ownership is unchanged: the
212/// suite bytes are read from the live base branch by [`crate::merge`], so a
213/// mission cannot weaken or reorder the gates that judge its own diff.
214pub struct MergeSuiteGate<F> {
215    repo_root: PathBuf,
216    changed_paths: Vec<String>,
217    suite: GateSuite,
218    executor: F,
219}
220
221impl<F> MergeSuiteGate<F>
222where
223    F: Fn(&str, &Path) -> (bool, String),
224{
225    pub fn new(repo_root: &Path, changed_paths: &[String], suite: GateSuite, executor: F) -> Self {
226        Self {
227            repo_root: repo_root.to_path_buf(),
228            changed_paths: changed_paths.to_vec(),
229            suite,
230            executor,
231        }
232    }
233}
234
235impl<F> crate::gate::Gate for MergeSuiteGate<F>
236where
237    F: Fn(&str, &Path) -> (bool, String),
238{
239    fn name(&self) -> &str {
240        "merge-gate-suite"
241    }
242
243    fn kind(&self) -> crate::gate::GateKind {
244        crate::gate::GateKind::Deterministic
245    }
246
247    fn evaluate(&self) -> crate::gate::GateOutcome {
248        use crate::gate::{ArtefactRef, GateOutcome};
249        match run_gate_suite(
250            &self.repo_root,
251            &self.changed_paths,
252            &self.suite,
253            &self.executor,
254        ) {
255            GateSuiteResult::Passed => GateOutcome::pass(ArtefactRef::new(MERGE_GATES_PATH)),
256            GateSuiteResult::Failed { gate, output } => {
257                GateOutcome::fail(ArtefactRef::new(gate).with_detail(output))
258            }
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use std::cell::RefCell;
267
268    struct FakeExecutor {
269        calls: RefCell<Vec<(String, PathBuf)>>,
270        failing_command: Option<&'static str>,
271    }
272
273    impl FakeExecutor {
274        fn all_pass() -> Self {
275            Self {
276                calls: RefCell::new(Vec::new()),
277                failing_command: None,
278            }
279        }
280
281        fn failing(command: &'static str) -> Self {
282            Self {
283                calls: RefCell::new(Vec::new()),
284                failing_command: Some(command),
285            }
286        }
287
288        fn run(&self, command: &str, cwd: &Path) -> (bool, String) {
289            self.calls
290                .borrow_mut()
291                .push((command.to_string(), cwd.to_path_buf()));
292            if self.failing_command == Some(command) {
293                (false, "gate failed".to_string())
294            } else {
295                (true, String::new())
296            }
297        }
298    }
299
300    fn suite() -> GateSuite {
301        parse_gate_suite(
302            br#"{
303                "gates": [
304                    {"command":"cargo test --workspace","cwd":"."},
305                    {"command":"npm test","cwd":"apps/dashboard","whenPaths":["apps/dashboard"]}
306                ]
307            }"#,
308        )
309        .unwrap()
310    }
311
312    #[test]
313    fn parse_rejects_empty_or_conditional_only_suites() {
314        assert!(parse_gate_suite(br#"{"gates":[]}"#)
315            .unwrap_err()
316            .contains("at least one"));
317        assert!(
318            parse_gate_suite(br#"{"gates":[{"command":"npm test","whenPaths":["web"]}]}"#)
319                .unwrap_err()
320                .contains("unconditional")
321        );
322    }
323
324    /// Composition audit (ticket `config-fail-open-audit`): the merge-gate
325    /// suite fails CLOSED on every weakening shape — an unparseable file, an
326    /// empty gate list, a suite with no unconditional gate (every gate
327    /// skippable by a narrow diff), a `whenPaths` entry spelling `.` (which
328    /// must be omitted, not spelled), and path escapes. A merge must never
329    /// go green because the config judging it got narrower.
330    #[test]
331    fn composition_audit_merge_gate_suite_fails_closed_on_every_weakening_shape() {
332        assert!(parse_gate_suite(b"not json").is_err());
333        assert!(parse_gate_suite(br#"{"gates":[]}"#).is_err());
334        assert!(
335            parse_gate_suite(br#"{"gates":[{"command":"npm test","whenPaths":["web"]}]}"#).is_err()
336        );
337        assert!(parse_gate_suite(
338            br#"{"gates":[{"command":"a","whenPaths":["."]},{"command":"b"}]}"#
339        )
340        .is_err());
341        assert!(parse_gate_suite(br#"{"gates":[{"command":"a","cwd":"../x"}]}"#).is_err());
342        // The same weakening shapes are refused when the suite is otherwise
343        // well-formed — the fail-closed checks are not order-dependent.
344        assert!(parse_gate_suite(
345            br#"{"gates":[{"command":"ok"},{"command":"npm test","whenPaths":["web"]}]}"#
346        )
347        .is_ok());
348    }
349
350    #[test]
351    fn parse_rejects_paths_that_escape_the_repo() {
352        for text in [
353            br#"{"gates":[{"command":"test","cwd":"../outside"}]}"#.as_slice(),
354            br#"{"gates":[{"command":"test","whenPaths":["/tmp"]},{"command":"ok"}]}"#.as_slice(),
355        ] {
356            assert!(parse_gate_suite(text)
357                .unwrap_err()
358                .contains("repo-relative without parent components"));
359        }
360    }
361
362    #[test]
363    fn unconditional_and_matching_conditional_gates_run_in_order() {
364        let root = PathBuf::from("/repo");
365        let exec = FakeExecutor::all_pass();
366        let result = run_gate_suite(
367            &root,
368            &["apps/dashboard/src/App.tsx".to_string()],
369            &suite(),
370            |cmd, cwd| exec.run(cmd, cwd),
371        );
372        assert_eq!(result, GateSuiteResult::Passed);
373        assert_eq!(
374            *exec.calls.borrow(),
375            vec![
376                ("cargo test --workspace".to_string(), root.clone()),
377                ("npm test".to_string(), root.join("apps/dashboard")),
378            ]
379        );
380    }
381
382    #[test]
383    fn dot_prefixed_paths_are_normalized_before_matching() {
384        let suite = parse_gate_suite(
385            br#"{"gates":[{"command":"always"},{"command":"web","cwd":"./apps/dashboard","whenPaths":["./apps/dashboard/"]}]}"#,
386        )
387        .unwrap();
388        assert_eq!(suite.gates[1].cwd, "apps/dashboard");
389        assert_eq!(suite.gates[1].when_paths, ["apps/dashboard"]);
390
391        let exec = FakeExecutor::all_pass();
392        let result = run_gate_suite(
393            Path::new("/repo"),
394            &["apps/dashboard/src/App.tsx".to_string()],
395            &suite,
396            |cmd, cwd| exec.run(cmd, cwd),
397        );
398        assert_eq!(result, GateSuiteResult::Passed);
399        assert_eq!(exec.calls.borrow().len(), 2);
400    }
401
402    #[test]
403    fn unrelated_diff_skips_conditional_gate() {
404        let root = PathBuf::from("/repo");
405        let exec = FakeExecutor::all_pass();
406        run_gate_suite(
407            &root,
408            &["crates/engine/src/lib.rs".to_string()],
409            &suite(),
410            |cmd, cwd| exec.run(cmd, cwd),
411        );
412        assert_eq!(exec.calls.borrow().len(), 1);
413        assert_eq!(exec.calls.borrow()[0].0, "cargo test --workspace");
414    }
415
416    #[test]
417    fn first_failure_stops_the_suite() {
418        let root = PathBuf::from("/repo");
419        let exec = FakeExecutor::failing("cargo test --workspace");
420        let result = run_gate_suite(
421            &root,
422            &["apps/dashboard/src/App.tsx".to_string()],
423            &suite(),
424            |cmd, cwd| exec.run(cmd, cwd),
425        );
426        assert_eq!(
427            result,
428            GateSuiteResult::Failed {
429                gate: "cargo test --workspace".to_string(),
430                output: "gate failed".to_string(),
431            }
432        );
433        assert_eq!(exec.calls.borrow().len(), 1);
434    }
435
436    #[test]
437    fn gate_plugin_merge_suite_runs_through_the_interface_unchanged() {
438        use crate::gate::Gate;
439        let root = PathBuf::from("/repo");
440        let exec = FakeExecutor::all_pass();
441        let gate = MergeSuiteGate::new(
442            &root,
443            &["apps/dashboard/src/App.tsx".to_string()],
444            suite(),
445            |cmd, cwd| exec.run(cmd, cwd),
446        );
447        assert_eq!(gate.name(), "merge-gate-suite");
448        assert_eq!(gate.kind(), crate::gate::GateKind::Deterministic);
449
450        let outcome = gate.evaluate();
451        assert!(outcome.passed());
452        assert_eq!(outcome.score, None, "the suite is a boolean-only gate");
453        assert_eq!(outcome.artefact.reference, MERGE_GATES_PATH);
454        assert_eq!(outcome.artefact.detail, None);
455        assert_eq!(
456            *exec.calls.borrow(),
457            vec![
458                ("cargo test --workspace".to_string(), root.clone()),
459                ("npm test".to_string(), root.join("apps/dashboard")),
460            ],
461            "same commands, same order as run_gate_suite"
462        );
463    }
464
465    #[test]
466    fn gate_plugin_merge_suite_stops_at_first_failure_through_the_interface() {
467        use crate::gate::Gate;
468        let root = PathBuf::from("/repo");
469        let exec = FakeExecutor::failing("cargo test --workspace");
470        let gate = MergeSuiteGate::new(
471            &root,
472            &["apps/dashboard/src/App.tsx".to_string()],
473            suite(),
474            |cmd, cwd| exec.run(cmd, cwd),
475        );
476
477        let outcome = gate.evaluate();
478        assert!(!outcome.passed());
479        assert_eq!(outcome.artefact.reference, "cargo test --workspace");
480        assert_eq!(outcome.artefact.detail.as_deref(), Some("gate failed"));
481        assert_eq!(exec.calls.borrow().len(), 1, "later gates never ran");
482    }
483
484    #[test]
485    fn gate_plugin_merge_suite_skips_unrelated_conditional_gates() {
486        use crate::gate::Gate;
487        let root = PathBuf::from("/repo");
488        let exec = FakeExecutor::all_pass();
489        let gate = MergeSuiteGate::new(
490            &root,
491            &["crates/engine/src/lib.rs".to_string()],
492            suite(),
493            |cmd, cwd| exec.run(cmd, cwd),
494        );
495
496        assert!(gate.evaluate().passed());
497        assert_eq!(exec.calls.borrow().len(), 1);
498        assert_eq!(exec.calls.borrow()[0].0, "cargo test --workspace");
499    }
500}