Skip to main content

kranz_engine/
contract_sweep.rs

1//! Engine-computed out-of-contract-write sweep (M7 tier 1, feature f-1-2).
2//!
3//! Two checks, both deterministic and read-only, run in `validation_round`
4//! alongside the spawned validator sessions (orchestrator.rs):
5//!
6//! 1. **Path sweep**: worker-authored paths changed in a milestone's commit
7//!    range, compared against the mission's declared `touch_set` globs.
8//! 2. **Primary-checkout cleanliness**: in worktree mode, the primary
9//!    checkout must stay clean and on its original branch — the M7 tier-1
10//!    guarantee that mission-branch work never touches the primary.
11//!
12//! Both surface as `Finding { class: "out-of-contract-write", .. }`, run
13//! through the same `convert_findings` machinery as any other finding
14//! (docs/scoping/worker-sandboxing.md: this is honestly-labeled detection,
15//! not containment).
16
17use crate::git_ops::CommitInfo;
18use crate::types::Finding;
19use globset::{Glob, GlobBuilder};
20
21pub const FINDING_CLASS: &str = "out-of-contract-write";
22
23/// Sentinel `subject` of the worktree-mode primary-checkout finding — a
24/// condition the touch-set has nothing to do with, so it is NOT a grantable
25/// touch path (see [`grantable_touch_path`]).
26pub const PRIMARY_CHECKOUT_SUBJECT: &str = "primary-checkout";
27
28/// The path a touch-set grant would extend to resolve `finding`, if any. Only a
29/// genuine "path outside the declared touch-set globs" is grantable — extending
30/// `touch_set` with it clears the finding. The `FINDING_CLASS` string is shared
31/// by findings that a grant CANNOT fix: the [`PRIMARY_CHECKOUT_SUBJECT`]
32/// sentinel (a dirty/moved primary checkout) and the glob-compile-error variant
33/// (the touch-set globs are themselves malformed). Both are excluded — the
34/// latter because it is not `Ok(false)` from [`touch_set_includes`] (a broken
35/// glob errors on every path). `current_touch_set` is the mission's touch_set as
36/// of the sweep.
37pub 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
51/// Commit message prefix shared by every engine/meta commit template.
52const ENGINE_COMMIT_PREFIX: &str = "[kranz]";
53
54/// Known engine-authored commit subject templates. A bare `[kranz]` prefix is
55/// NOT enough to match — but even a full template match is only NECESSARY,
56/// never sufficient: a worker with `git commit` can title a commit
57/// "[kranz] mission report cleanup" just as easily. The enforced exemption is
58/// [`is_meta_commit_with_paths`], which additionally requires every touched
59/// path to be mission-record metadata (see [`is_mission_record_path`]).
60///
61/// Dirty-tree checkpoints are intentionally NOT meta: they carry real worker
62/// file changes and must count as deliverables / be path-swept.
63const ENGINE_META_TEMPLATES: &[&str] = &[
64    "[kranz] approved plan for ",
65    "[kranz] revised plan for ",
66    "[kranz] mission report",
67];
68
69/// Whether a contract `command` assertion that runs `cargo test` includes the
70/// anti-vacuity guard (`test result: ok. [1-9]`) so a filter matching zero
71/// tests cannot pass. Returns `true` when the command is not a cargo-test
72/// gate, or when it already has the guard.
73pub 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    // AGENTS.md rule 5: grep must require at least one passed test.
79    command.contains("[1-9]") || command.contains("ok\\. [1-9]") || command.contains("ok. [1-9]")
80}
81
82/// Message used when the engine checkpoints a dirty worker tree.
83pub fn checkpoint_commit_message(feature_id: &str) -> String {
84    format!("[{feature_id}] checkpoint (engine commit)")
85}
86
87/// Message used when a parallel worktree checkpoints a dirty feature branch.
88pub fn parallel_checkpoint_commit_message(feature_id: &str) -> String {
89    format!("[{feature_id}] parallel worktree checkpoint (engine commit)")
90}
91
92/// Message used when a dispatch-pool candidate worktree checkpoints a dirty
93/// stream tree (KRZ-303). Deliberately NOT meta (the checkpoint carries real
94/// worker file changes — the candidate's deliverable), the same posture as
95/// the other checkpoint templates.
96pub fn pool_checkpoint_commit_message(feature_id: &str, index: usize) -> String {
97    format!("[{feature_id}] dispatch pool candidate {index} checkpoint (engine commit)")
98}
99
100/// Whether `subject` matches a known engine/meta commit template.
101///
102/// Subject templates are SPOOFABLE by a worker's own `git commit`, so this
103/// alone must never exempt a commit from the deliverable count or the path
104/// sweep — use [`is_meta_commit_with_paths`] wherever the commit's changed
105/// paths are available (the orchestrator's sweep and final gate both do).
106pub 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
115/// Whether a commit is a GENUINE engine/meta commit (never attributed to a
116/// worker, never counted as a deliverable, never path-swept): the subject
117/// must match a known engine template AND every path the commit touches must
118/// be mission-record metadata (see [`is_mission_record_path`]). A
119/// spoofed-subject worker commit touching anything else (src/, docs/, …) is
120/// treated as a worker commit — counted and swept like any other.
121///
122/// An empty `changed_paths` slice counts as meta when the subject matches:
123/// engine commits are never empty (`commit_paths` no-ops instead of
124/// committing), and an empty spoof carries no deliverable content to count
125/// or sweep anyway.
126pub 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
137/// The record files a genuine engine meta commit writes inside the CURRENT
138/// mission's `.kranz/missions/<id>/` dir. Enumerated (not a prefix match)
139/// from the engine's actual commit sites — see [`is_mission_record_path`].
140const MISSION_RECORD_FILES: &[&str] = &[
141    "plan.json",       // approve_plan / commit_revised_plan_record
142    "plan.md",         // approve_plan / commit_revised_plan_record
143    "revised-plan.md", // commit_revised_plan_record / approve_revised_plan
144    "research.md",     // approve_plan / commit_revised_plan_record
145    "report.md",       // try_write_mission_report
146];
147
148/// Whether `path` (relative to the repo root, forward-slash, as
149/// `git diff --name-only` reports) is mission-record metadata a genuine
150/// engine meta commit writes. Enumerated from the engine's actual commit
151/// sites (orchestrator.rs `approve_plan` / `commit_revised_plan_record` /
152/// `approve_revised_plan` / `try_write_mission_report` / `capture_lesson`):
153/// the missions catalog (`.kranz/missions/index.md`), the exact record files
154/// in the CURRENT mission's dir ([`MISSION_RECORD_FILES`]).
155///
156/// Deliberately no broader than that: a prefix match on all of
157/// `.kranz/lessons/` or `.kranz/missions/<id>/` would let a worker commit
158/// with a spoofed meta subject smuggle arbitrary files past the deliverable
159/// count and the path sweep. No lesson path is exempt, including the current
160/// mission's lesson and index: lessons are ingested into FUTURE missions'
161/// planning prompts, and a worker can spoof every commit-subject template.
162pub 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
172/// Mission meta paths (relative to repo root, forward-slash, matching
173/// `git diff --name-only` output) that are never flagged even when they
174/// fall outside the declared touch-set.
175pub 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
184/// Whether `path` is one of the mission's meta files (see [`meta_paths`]).
185pub fn is_meta_path(mission_id: &str, path: &str) -> bool {
186    meta_paths(mission_id).iter().any(|p| p == path)
187}
188
189/// One compiled touch-set pattern: a glob plus whether it's a `!`-negated
190/// (gitignore-style) exclusion.
191struct TouchPattern {
192    glob: globset::GlobMatcher,
193    negate: bool,
194}
195
196/// Compile the mission's `touch_set` patterns (gitignore-style: `!` negates,
197/// last match wins, `**` crosses path separators, plain `*` does not).
198fn 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
215/// Whether `path` is inside the touch-set: gitignore semantics, the LAST
216/// matching pattern decides; a plain (non-negated) match includes the path,
217/// a `!`-prefixed match excludes it; no match at all excludes by default.
218pub 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
229/// One worker-authored path change, attributed to the commit that made it.
230pub struct AttributedChange<'a> {
231    pub path: &'a str,
232    pub commit: &'a CommitInfo,
233}
234
235/// Build out-of-contract-write findings from a milestone's worker-authored
236/// path changes (engine/meta commits already excluded by the caller — see
237/// [`is_meta_commit_with_paths`]) against the mission's declared touch-set.
238///
239/// Returns one finding per distinct out-of-contract path (first attribution
240/// wins when a path is touched by more than one commit). Empty `touch_set`
241/// means the sweep is advisory-off: callers must not invoke this in that
242/// case (checked by the caller so the "skip entirely" log line has a home).
243pub 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
282/// Finding when the primary checkout is dirty or has moved off the branch it
283/// was on when the mission started (worktree-mode invariant).
284pub 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    // -- out_of_contract: touch-set matching ---------------------------------
324
325    #[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    /// Regression for the ms-1-fix-1-5 finding: commit 3922d63 (checkpoint)
341    /// touched crates/engine/src/pr_handoff.rs, a path outside this mission's
342    /// original declared touch-set. The mission's touchSet was extended with
343    /// an exact-path glob for it; assert that extension actually clears the
344    /// finding via the same `path_findings` sweep the orchestrator uses.
345    #[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    /// Regression for the ms-1-fix-1-6 finding: commit 34a0b2f
374    /// ([f-1-2] fix workspace build: Mission literal in kranz-slack tests,
375    /// cargo fmt) touched crates/slack/src/outbound.rs, a path outside this
376    /// mission's declared touch-set. The mission's touchSet was extended
377    /// with an exact-path glob for it; assert that extension actually
378    /// clears the finding via the same `path_findings` sweep the
379    /// orchestrator uses.
380    #[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        // A genuine path outside the globs is grantable — its subject is the path.
414        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        // Already-in-contract path (were it somehow a finding) is not grantable.
428        assert_eq!(grantable_touch_path(&real, &["docs/**".to_string()]), None);
429
430        // The primary-checkout sentinel shares FINDING_CLASS but a touch grant
431        // can't resolve it → not grantable.
432        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        // A glob-compile-error finding (malformed touch_set) is not grantable —
437        // extending an already-broken glob set can't clear it.
438        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        // touch_set_includes on an empty set always excludes, so callers MUST
492        // gate on emptiness rather than calling path_findings with `[]`.
493        let touch_set: Vec<String> = vec![];
494        assert!(!touch_set_includes(&touch_set, "src/anything.rs").unwrap());
495    }
496
497    // -- engine_commit_exempt -------------------------------------------------
498
499    #[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        // A bare `[kranz]` prefix does not match a template. (A forged
507        // TEMPLATE subject still passes this check — the path-verified
508        // is_meta_commit_with_paths is what closes that hole.)
509        assert!(!is_meta_commit("[kranz] spoofed worker commit"));
510        assert!(!is_meta_commit("[kranz]"));
511        // Dirty-tree checkpoints carry worker files — not meta.
512        assert!(!is_meta_commit(&checkpoint_commit_message("f-1")));
513        assert!(!is_meta_commit(&parallel_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        // Genuine meta commits: template subject, only mission-record paths
520        // (the exact sets the engine's commit sites write).
521        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        // Spoof: a template-matching subject on a commit touching a real
552        // file must NOT be meta — it would otherwise dodge the deliverable
553        // count and the out-of-contract path sweep.
554        assert!(!is_meta_commit_with_paths(
555            "[kranz] mission report cleanup",
556            mission_id,
557            &["src/lib.rs".to_string()],
558        ));
559        // Even one non-record path among record paths breaks the exemption.
560        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        // ANOTHER mission's record dir is not this mission's metadata.
569        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        // A non-template subject is never meta, whatever the paths.
575        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        // The complete exempt set from the engine's commit sites
586        // (approve_plan, commit_revised_plan_record, approve_revised_plan,
587        // try_write_mission_report) — nothing else.
588        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", // the dir itself, not a record
602            ".kranz/missions/m-abc1234/plan.json", // id prefix, other mission
603            ".kranz/missions/m-other/plan.json",
604            "kranz/missions/m-abc123/plan.json", // missing leading .kranz
605            // Smuggle shapes: the engine never commits these, so a spoofed
606            // meta subject touching them must NOT be sweep-exempt.
607            ".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", // even this mission's lesson is spoofable
611            ".kranz/lessons/evil.md",     // ingested into future planning prompts
612            ".kranz/lessons/m-other.md",  // another mission's lesson file
613            ".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    // -- primary_checkout ------------------------------------------------------
658
659    #[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}