1use crate::git_ops::CommitInfo;
18use crate::types::Finding;
19use globset::{Glob, GlobBuilder};
20
21pub const FINDING_CLASS: &str = "out-of-contract-write";
22
23pub const PRIMARY_CHECKOUT_SUBJECT: &str = "primary-checkout";
27
28pub fn grantable_touch_path<'a>(
38 finding: &'a Finding,
39 current_touch_set: &[String],
40) -> Option<&'a str> {
41 if finding.class != FINDING_CLASS || finding.subject == PRIMARY_CHECKOUT_SUBJECT {
42 return None;
43 }
44 matches!(
45 touch_set_includes(current_touch_set, &finding.subject),
46 Ok(false)
47 )
48 .then_some(finding.subject.as_str())
49}
50
51const ENGINE_COMMIT_PREFIX: &str = "[kranz]";
53
54const ENGINE_META_TEMPLATES: &[&str] = &[
64 "[kranz] approved plan for ",
65 "[kranz] revised plan for ",
66 "[kranz] mission report",
67];
68
69pub fn cargo_test_has_anti_vacuity(command: &str) -> bool {
74 let lower = command.to_ascii_lowercase();
75 if !lower.contains("cargo test") && !lower.contains("cargo\ttest") {
76 return true;
77 }
78 command.contains("[1-9]") || command.contains("ok\\. [1-9]") || command.contains("ok. [1-9]")
80}
81
82pub fn checkpoint_commit_message(feature_id: &str) -> String {
84 format!("[{feature_id}] checkpoint (engine commit)")
85}
86
87pub fn parallel_checkpoint_commit_message(feature_id: &str) -> String {
89 format!("[{feature_id}] parallel worktree checkpoint (engine commit)")
90}
91
92pub fn pool_checkpoint_commit_message(feature_id: &str, index: usize) -> String {
97 format!("[{feature_id}] dispatch pool candidate {index} checkpoint (engine commit)")
98}
99
100pub fn is_meta_commit(subject: &str) -> bool {
107 if !subject.starts_with(ENGINE_COMMIT_PREFIX) {
108 return false;
109 }
110 ENGINE_META_TEMPLATES
111 .iter()
112 .any(|tmpl| subject == *tmpl || subject.starts_with(tmpl))
113}
114
115pub fn is_meta_commit_with_paths(
127 subject: &str,
128 mission_id: &str,
129 changed_paths: &[String],
130) -> bool {
131 is_meta_commit(subject)
132 && changed_paths
133 .iter()
134 .all(|path| is_mission_record_path(mission_id, path))
135}
136
137const MISSION_RECORD_FILES: &[&str] = &[
141 "plan.json", "plan.md", "revised-plan.md", "research.md", "report.md", ];
147
148pub fn is_mission_record_path(mission_id: &str, path: &str) -> bool {
163 if path == ".kranz/missions/index.md" {
164 return true;
165 }
166 match path.strip_prefix(&format!(".kranz/missions/{mission_id}/")) {
167 Some(record) => MISSION_RECORD_FILES.contains(&record),
168 None => false,
169 }
170}
171
172pub fn meta_paths(mission_id: &str) -> Vec<String> {
176 vec![
177 format!(".kranz/missions/{mission_id}/plan.json"),
178 format!(".kranz/missions/{mission_id}/plan.md"),
179 format!(".kranz/missions/{mission_id}/report.md"),
180 ".kranz/missions/index.md".to_string(),
181 ]
182}
183
184pub fn is_meta_path(mission_id: &str, path: &str) -> bool {
186 meta_paths(mission_id).iter().any(|p| p == path)
187}
188
189struct TouchPattern {
192 glob: globset::GlobMatcher,
193 negate: bool,
194}
195
196fn compile_touch_set(patterns: &[String]) -> Result<Vec<TouchPattern>, globset::Error> {
199 patterns
200 .iter()
201 .map(|raw| {
202 let (negate, pat) = match raw.strip_prefix('!') {
203 Some(rest) => (true, rest),
204 None => (false, raw.as_str()),
205 };
206 let glob: Glob = GlobBuilder::new(pat).literal_separator(true).build()?;
207 Ok(TouchPattern {
208 glob: glob.compile_matcher(),
209 negate,
210 })
211 })
212 .collect()
213}
214
215pub fn touch_set_includes(patterns: &[String], path: &str) -> Result<bool, globset::Error> {
219 let compiled = compile_touch_set(patterns)?;
220 let mut included = false;
221 for pat in &compiled {
222 if pat.glob.is_match(path) {
223 included = !pat.negate;
224 }
225 }
226 Ok(included)
227}
228
229pub struct AttributedChange<'a> {
231 pub path: &'a str,
232 pub commit: &'a CommitInfo,
233}
234
235pub fn path_findings(touch_set: &[String], changes: &[AttributedChange<'_>]) -> Vec<Finding> {
244 let mut findings = Vec::new();
245 let mut seen = std::collections::HashSet::new();
246 for change in changes {
247 if !seen.insert(change.path) {
248 continue;
249 }
250 match touch_set_includes(touch_set, change.path) {
251 Ok(true) => {}
252 Ok(false) => findings.push(Finding {
253 subject: change.path.to_string(),
254 severity: "major".to_string(),
255 evidence: format!(
256 "commit {} ({}) touched {} which matches none of the declared touch-set globs",
257 change.commit.sha, change.commit.subject, change.path
258 ),
259 suggested_fix: format!(
260 "relocate the change under a declared touch-set path, or add a glob for {} to the mission's touchSet",
261 change.path
262 ),
263 class: FINDING_CLASS.to_string(),
264 rule: None,
265 }),
266 Err(e) => findings.push(Finding {
267 subject: change.path.to_string(),
268 severity: "major".to_string(),
269 evidence: format!(
270 "touch-set glob compile error while checking {}: {e}",
271 change.path
272 ),
273 suggested_fix: "fix the mission's touchSet globs".to_string(),
274 class: FINDING_CLASS.to_string(),
275 rule: None,
276 }),
277 }
278 }
279 findings
280}
281
282pub fn primary_checkout_finding(
285 is_clean: bool,
286 current_branch: &str,
287 branch_at_start: &str,
288) -> Option<Finding> {
289 if is_clean && current_branch == branch_at_start {
290 return None;
291 }
292 let evidence = if !is_clean && current_branch != branch_at_start {
293 format!(
294 "primary checkout is dirty and moved from '{branch_at_start}' to '{current_branch}'"
295 )
296 } else if !is_clean {
297 "primary checkout has tracked changes while a worktree-mode mission is running".to_string()
298 } else {
299 format!("primary checkout moved from '{branch_at_start}' to '{current_branch}'")
300 };
301 Some(Finding {
302 subject: PRIMARY_CHECKOUT_SUBJECT.to_string(),
303 severity: "critical".to_string(),
304 evidence,
305 suggested_fix: "restore the primary checkout to a clean state on the starting branch"
306 .to_string(),
307 class: FINDING_CLASS.to_string(),
308 rule: None,
309 })
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 fn commit(sha: &str, subject: &str) -> CommitInfo {
317 CommitInfo {
318 sha: sha.to_string(),
319 subject: subject.to_string(),
320 }
321 }
322
323 #[test]
326 fn out_of_contract_path_outside_touch_set_produces_one_finding() {
327 let touch_set = vec!["src/**".to_string()];
328 let c = commit("abc123", "[f-1] add");
329 let changes = [AttributedChange {
330 path: "docs/oops.md",
331 commit: &c,
332 }];
333 let findings = path_findings(&touch_set, &changes);
334 assert_eq!(findings.len(), 1);
335 assert_eq!(findings[0].subject, "docs/oops.md");
336 assert_eq!(findings[0].class, FINDING_CLASS);
337 assert_eq!(findings[0].severity, "major");
338 }
339
340 #[test]
346 fn pr_handoff_path_no_longer_flagged_after_touch_set_extension() {
347 let touch_set = vec![
348 "crates/engine/src/ticket.rs".to_string(),
349 "crates/engine/src/types.rs".to_string(),
350 "crates/engine/src/events.rs".to_string(),
351 "crates/engine/src/reducer.rs".to_string(),
352 "crates/engine/src/config.rs".to_string(),
353 "crates/engine/src/orchestrator.rs".to_string(),
354 "crates/engine/src/pr_handoff.rs".to_string(),
355 "crates/engine/tests/reducer_test.rs".to_string(),
356 ];
357 let c = commit(
358 "3922d63716f94635c6c343a17cf45198f19be3b1",
359 "[f-1-2] checkpoint (engine commit)",
360 );
361 let changes = [AttributedChange {
362 path: "crates/engine/src/pr_handoff.rs",
363 commit: &c,
364 }];
365 let findings = path_findings(&touch_set, &changes);
366 assert!(
367 findings.is_empty(),
368 "expected no out-of-contract finding once pr_handoff.rs is declared, got {findings:?}"
369 );
370 assert!(touch_set_includes(&touch_set, "crates/engine/src/pr_handoff.rs").unwrap());
371 }
372
373 #[test]
381 fn slack_outbound_path_no_longer_flagged_after_touch_set_extension() {
382 let touch_set = vec![
383 "crates/engine/src/ticket.rs".to_string(),
384 "crates/engine/src/types.rs".to_string(),
385 "crates/engine/src/events.rs".to_string(),
386 "crates/engine/src/reducer.rs".to_string(),
387 "crates/engine/src/config.rs".to_string(),
388 "crates/engine/src/orchestrator.rs".to_string(),
389 "crates/engine/src/pr_handoff.rs".to_string(),
390 "crates/slack/src/outbound.rs".to_string(),
391 "crates/engine/tests/reducer_test.rs".to_string(),
392 ];
393 let c = commit(
394 "34a0b2fa0f5b88878693c82182381aaeebe5645b",
395 "[f-1-2] fix workspace build: Mission literal in kranz-slack tests, cargo fmt",
396 );
397 let changes = [AttributedChange {
398 path: "crates/slack/src/outbound.rs",
399 commit: &c,
400 }];
401 let findings = path_findings(&touch_set, &changes);
402 assert!(
403 findings.is_empty(),
404 "expected no out-of-contract finding once outbound.rs is declared, got {findings:?}"
405 );
406 assert!(touch_set_includes(&touch_set, "crates/slack/src/outbound.rs").unwrap());
407 }
408
409 #[test]
410 fn grantable_touch_path_only_for_a_real_out_of_contract_path() {
411 let touch_set = vec!["src/**".to_string()];
412
413 let c = commit("abc123", "[f-1] add");
415 let real = path_findings(
416 &touch_set,
417 &[AttributedChange {
418 path: "docs/oops.md",
419 commit: &c,
420 }],
421 )
422 .remove(0);
423 assert_eq!(
424 grantable_touch_path(&real, &touch_set),
425 Some("docs/oops.md")
426 );
427 assert_eq!(grantable_touch_path(&real, &["docs/**".to_string()]), None);
429
430 let pc = primary_checkout_finding(false, "main", "main").unwrap();
433 assert_eq!(pc.class, FINDING_CLASS);
434 assert_eq!(grantable_touch_path(&pc, &touch_set), None);
435
436 let bad_set = vec!["[".to_string()];
439 let glob_err = path_findings(
440 &bad_set,
441 &[AttributedChange {
442 path: "anything.rs",
443 commit: &c,
444 }],
445 )
446 .remove(0);
447 assert!(glob_err.evidence.contains("glob compile error"));
448 assert_eq!(grantable_touch_path(&glob_err, &bad_set), None);
449 }
450
451 #[test]
452 fn out_of_contract_path_inside_touch_set_produces_no_finding() {
453 let touch_set = vec!["src/**".to_string()];
454 let c = commit("abc123", "[f-1] add");
455 let changes = [AttributedChange {
456 path: "src/lib.rs",
457 commit: &c,
458 }];
459 assert!(path_findings(&touch_set, &changes).is_empty());
460 }
461
462 #[test]
463 fn out_of_contract_duplicate_path_produces_exactly_one_finding() {
464 let touch_set = vec!["src/**".to_string()];
465 let c1 = commit("aaa", "[f-1] first");
466 let c2 = commit("bbb", "[f-1] second");
467 let changes = [
468 AttributedChange {
469 path: "docs/oops.md",
470 commit: &c1,
471 },
472 AttributedChange {
473 path: "docs/oops.md",
474 commit: &c2,
475 },
476 ];
477 let findings = path_findings(&touch_set, &changes);
478 assert_eq!(findings.len(), 1);
479 assert!(findings[0].evidence.contains("aaa"));
480 }
481
482 #[test]
483 fn out_of_contract_negated_glob_excludes_from_touch_set() {
484 let touch_set = vec!["src/**".to_string(), "!src/generated/**".to_string()];
485 assert!(touch_set_includes(&touch_set, "src/lib.rs").unwrap());
486 assert!(!touch_set_includes(&touch_set, "src/generated/x.rs").unwrap());
487 }
488
489 #[test]
490 fn out_of_contract_empty_touch_set_is_advisory_off() {
491 let touch_set: Vec<String> = vec![];
494 assert!(!touch_set_includes(&touch_set, "src/anything.rs").unwrap());
495 }
496
497 #[test]
500 fn engine_commit_exempt_kranz_prefixed_commit_is_meta() {
501 assert!(is_meta_commit("[kranz] approved plan for m-abc123"));
502 assert!(is_meta_commit("[kranz] mission report"));
503 assert!(is_meta_commit("[kranz] mission report for m-abc123"));
504 assert!(is_meta_commit("[kranz] revised plan for m-abc123 (rev 2)"));
505 assert!(!is_meta_commit("[f-1-2] add sweep"));
506 assert!(!is_meta_commit("[kranz] spoofed worker commit"));
510 assert!(!is_meta_commit("[kranz]"));
511 assert!(!is_meta_commit(&checkpoint_commit_message("f-1")));
513 assert!(!is_meta_commit(¶llel_checkpoint_commit_message("f-1")));
514 }
515
516 #[test]
517 fn engine_commit_exempt_requires_mission_record_paths_not_just_subject() {
518 let mission_id = "m-abc123";
519 assert!(is_meta_commit_with_paths(
522 "[kranz] approved plan for m-abc123",
523 mission_id,
524 &[
525 ".kranz/missions/m-abc123/plan.json".to_string(),
526 ".kranz/missions/m-abc123/plan.md".to_string(),
527 ".kranz/missions/m-abc123/research.md".to_string(),
528 ".kranz/missions/index.md".to_string(),
529 ],
530 ));
531 assert!(is_meta_commit_with_paths(
532 "[kranz] revised plan for m-abc123 (rev 2)",
533 mission_id,
534 &[".kranz/missions/m-abc123/revised-plan.md".to_string()],
535 ));
536 assert!(is_meta_commit_with_paths(
537 "[kranz] mission report for m-abc123",
538 mission_id,
539 &[
540 ".kranz/missions/m-abc123/report.md".to_string(),
541 ".kranz/missions/index.md".to_string(),
542 ],
543 ));
544 for lesson_path in [".kranz/lessons/m-abc123.md", ".kranz/lessons/index.md"] {
545 assert!(!is_meta_commit_with_paths(
546 "[kranz] mission report for m-abc123",
547 mission_id,
548 &[lesson_path.to_string()],
549 ));
550 }
551 assert!(!is_meta_commit_with_paths(
555 "[kranz] mission report cleanup",
556 mission_id,
557 &["src/lib.rs".to_string()],
558 ));
559 assert!(!is_meta_commit_with_paths(
561 "[kranz] mission report for m-abc123",
562 mission_id,
563 &[
564 ".kranz/missions/m-abc123/report.md".to_string(),
565 "docs/oops.md".to_string(),
566 ],
567 ));
568 assert!(!is_meta_commit_with_paths(
570 "[kranz] mission report for m-abc123",
571 mission_id,
572 &[".kranz/missions/m-other/report.md".to_string()],
573 ));
574 assert!(!is_meta_commit_with_paths(
576 "[f-1-2] add sweep",
577 mission_id,
578 &[".kranz/missions/m-abc123/report.md".to_string()],
579 ));
580 }
581
582 #[test]
583 fn mission_record_path_matches_engine_commit_sites_only() {
584 let mission_id = "m-abc123";
585 for path in [
589 ".kranz/missions/index.md",
590 ".kranz/missions/m-abc123/plan.json",
591 ".kranz/missions/m-abc123/plan.md",
592 ".kranz/missions/m-abc123/revised-plan.md",
593 ".kranz/missions/m-abc123/research.md",
594 ".kranz/missions/m-abc123/report.md",
595 ] {
596 assert!(is_mission_record_path(mission_id, path), "{path}");
597 }
598 for path in [
599 "src/lib.rs",
600 ".kranz/secret-allowlist",
601 ".kranz/missions/m-abc123", ".kranz/missions/m-abc1234/plan.json", ".kranz/missions/m-other/plan.json",
604 "kranz/missions/m-abc123/plan.json", ".kranz/missions/m-abc123/arbitrary.rs",
608 ".kranz/missions/m-abc123/nested/plan.json",
609 ".kranz/lessons/index.md",
610 ".kranz/lessons/m-abc123.md", ".kranz/lessons/evil.md", ".kranz/lessons/m-other.md", ".kranz/lessons/nested/index.md",
614 ] {
615 assert!(!is_mission_record_path(mission_id, path), "{path}");
616 }
617 }
618
619 #[test]
620 fn engine_commit_exempt_meta_paths_never_flagged() {
621 let mission_id = "m-abc123";
622 assert!(is_meta_path(
623 mission_id,
624 ".kranz/missions/m-abc123/plan.json"
625 ));
626 assert!(is_meta_path(mission_id, ".kranz/missions/m-abc123/plan.md"));
627 assert!(is_meta_path(
628 mission_id,
629 ".kranz/missions/m-abc123/report.md"
630 ));
631 assert!(is_meta_path(mission_id, ".kranz/missions/index.md"));
632 assert!(!is_meta_path(mission_id, "src/lib.rs"));
633 }
634
635 #[test]
636 fn engine_commit_exempt_kranz_commit_outside_touch_set_produces_no_finding() {
637 let touch_set = vec!["src/**".to_string()];
638 let meta_commit = commit("abc123", "[kranz] approved plan for m-abc123");
639 let worker_commit = commit("def456", "[f-1-2] add sweep");
640 let all_paths = [
641 (".kranz/missions/m-abc123/plan.json", &meta_commit),
642 ("src/sweep.rs", &worker_commit),
643 ("docs/oops.md", &worker_commit),
644 ];
645 let mission_id = "m-abc123";
646 let filtered: Vec<AttributedChange> = all_paths
647 .iter()
648 .filter(|(_, c)| !is_meta_commit(&c.subject))
649 .filter(|(p, _)| !is_meta_path(mission_id, p))
650 .map(|(p, c)| AttributedChange { path: p, commit: c })
651 .collect();
652 let findings = path_findings(&touch_set, &filtered);
653 assert_eq!(findings.len(), 1);
654 assert_eq!(findings[0].subject, "docs/oops.md");
655 }
656
657 #[test]
660 fn primary_checkout_dirty_yields_critical_finding() {
661 let finding = primary_checkout_finding(false, "main", "main").expect("dirty must flag");
662 assert_eq!(finding.severity, "critical");
663 assert_eq!(finding.subject, "primary-checkout");
664 assert_eq!(finding.class, FINDING_CLASS);
665 }
666
667 #[test]
668 fn primary_checkout_moved_branch_yields_critical_finding() {
669 let finding = primary_checkout_finding(true, "kranz/mission-m-abc123", "main")
670 .expect("moved branch must flag");
671 assert_eq!(finding.severity, "critical");
672 assert!(finding.evidence.contains("main"));
673 }
674
675 #[test]
676 fn primary_checkout_clean_and_unmoved_yields_no_finding() {
677 assert!(primary_checkout_finding(true, "main", "main").is_none());
678 }
679
680 #[test]
681 fn anti_vacuity_detects_cargo_test_without_guard() {
682 assert!(!cargo_test_has_anti_vacuity(
683 "cargo test --workspace foo 2>&1 | grep -qE 'test result: ok\\.'"
684 ));
685 assert!(cargo_test_has_anti_vacuity(
686 "cargo test --workspace foo 2>&1 | grep -qE 'test result: ok\\. [1-9]'"
687 ));
688 assert!(cargo_test_has_anti_vacuity("npm run test"));
689 }
690}