1use 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 #[serde(default)]
37 pub when_paths: Vec<String>,
38}
39
40fn default_cwd() -> String {
41 ".".to_string()
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum GateSuiteResult {
47 Passed,
48 Failed { gate: String, output: String },
49}
50
51pub 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
72pub(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
152pub 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
186pub(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
203pub 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 #[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 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}