Skip to main content

car_multi/patterns/foreman/
gate.rs

1//! The merge-verify gate — the soundness boundary of the Foreman pattern.
2//!
3//! Under the Path-B design (see
4//! `docs/proposals/verified-parallel-coding-orchestrator.md` §7), static
5//! footprint analysis is only an *advisory* scheduling hint. Correctness comes
6//! entirely from this gate, which verifies each farmed-out worktree *before* its
7//! changes are integrated.
8//!
9//! ## Fail-closed by construction
10//!
11//! The cardinal rule (enforced after @neo/@linus review of the first cut): a
12//! verdict of [`MergeVerdict::Accepted`] requires **positive evidence** that the
13//! required checks ran and affirmed. The *absence* of a failure is never
14//! acceptance. Concretely:
15//!
16//! - A build/test that was **not configured** ⇒ [`MergeVerdict::Inconclusive`],
17//!   never accepted — unless the caller supplies an explicit, audited
18//!   [`NoVerifyWaiver`] (e.g. a docs-only change), which yields
19//!   `Accepted { basis: Waived }`.
20//! - An unparseable changed file, a policy denial, or any "we don't know" state
21//!   resolves toward reject/inconclusive, not accept.
22//!
23//! ## Checks
24//!
25//! 1. **AST-diff containment** — [`car_ast::diff_symbols`] tells us which symbols
26//!    the worktree actually changed; any outside the subtask's declared footprint
27//!    is a violation (a non-deterministic agent editing beyond what it promised).
28//!    Advisory: skipped when no footprint was declared.
29//! 2. **Duplicate-declaration scan** — two definitions of the same `(name, kind)`
30//!    introduced in one file (a CodeCRDT semantic-conflict class that physical
31//!    worktree isolation cannot catch).
32//! 3. **Policy consult** — the integration is checked against the shared
33//!    `PolicyEngine` as a *gating input* (it can deny the merge), not just an
34//!    audit. This is the "policy-aware" differentiator vs a bare `git merge`.
35//! 4. **Build/test gate** — the load-bearing soundness leg; the compiler is also
36//!    our broken-reference detector (which is why we don't hand-roll an unsound
37//!    one). Runs only if the AST checks passed and policy allowed.
38//! 5. **Audit** — every verdict is appended to the shared `EventLog` as a
39//!    `GateAccepted` / `GateRejected` event, carrying the evidence provenance B3
40//!    needs to *attribute* (not merely count) false-accepts.
41//!
42//! The pure verification logic ([`extract_changes`], [`containment_violations`],
43//! [`duplicate_declarations`], [`decide`]) takes already-extracted content and is
44//! fully unit-testable without git. [`verify_changes`] orchestrates the I/O
45//! (build/test command, policy consult, audit emission); deriving [`FileChange`]s
46//! from a real git worktree is the caller's job (B2).
47
48use std::collections::HashMap;
49use std::collections::HashSet;
50use std::path::PathBuf;
51use std::time::Duration;
52
53use car_ast::{diff_symbols, parse_file, SymbolChange, SymbolKind};
54use car_eventlog::EventKind;
55use car_ir::{Action, ActionType, FailureBehavior};
56use serde_json::{json, Value};
57
58use crate::shared::SharedInfra;
59
60// One canonical `(file, symbol)` DTO lives in car-ast (next to the symbol index
61// and the footprint scheduler). The gate's containment and the B4 scheduler
62// share it, so the planner (B5) populates a single schema, not two.
63pub use car_ast::SymbolRef;
64
65/// What a subtask declared it would change. Advisory: an empty footprint means
66/// "no declaration", which disables the containment check (but never enables
67/// acceptance on its own — the build/test gate is still required).
68#[derive(Debug, Clone, Default)]
69pub struct DeclaredFootprint {
70    allowed: HashSet<SymbolRef>,
71}
72
73impl DeclaredFootprint {
74    /// A footprint that declares nothing (containment check disabled).
75    pub fn unconstrained() -> Self {
76        Self::default()
77    }
78
79    pub fn from_refs(refs: impl IntoIterator<Item = SymbolRef>) -> Self {
80        Self {
81            allowed: refs.into_iter().collect(),
82        }
83    }
84
85    pub fn is_declared(&self) -> bool {
86        !self.allowed.is_empty()
87    }
88
89    pub fn allows(&self, r: &SymbolRef) -> bool {
90        self.allowed.contains(r)
91    }
92}
93
94/// One file's before/after content as observed in a worktree. `None` content
95/// means the file did not exist on that side (whole-file add or delete).
96#[derive(Debug, Clone)]
97pub struct FileChange {
98    pub path: String,
99    pub before: Option<String>,
100    pub after: Option<String>,
101}
102
103impl FileChange {
104    fn content_changed(&self) -> bool {
105        self.before.as_deref() != self.after.as_deref()
106    }
107}
108
109/// How a symbol changed between before and after.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum ChangeKind {
112    Added,
113    Removed,
114    Modified,
115    SignatureChanged,
116}
117
118/// A symbol the worktree actually changed.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct ChangedSymbol {
121    pub file: String,
122    pub symbol: String,
123    pub change: ChangeKind,
124}
125
126impl ChangedSymbol {
127    fn as_ref(&self) -> SymbolRef {
128        SymbolRef::new(self.file.clone(), self.symbol.clone())
129    }
130}
131
132/// A changed symbol that fell outside the declared footprint.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct ContainmentViolation {
135    pub changed: ChangedSymbol,
136}
137
138/// Two definitions of the same `(name, kind)` in one file after integration.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct DuplicateDeclaration {
141    pub file: String,
142    pub symbol: String,
143    pub kind: String,
144    pub count: usize,
145}
146
147/// Whether a check ran and what it found. `NotRun` is never acceptance-eligible.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum CheckOutcome {
150    Passed,
151    Failed,
152    NotRun,
153}
154
155/// Outcome of the build/test leg. `NotConfigured` (nobody supplied a command)
156/// and `NotRun` (skipped because an earlier check already failed or policy
157/// denied) are deliberately distinct from each other and from `Passed` — so the
158/// verdict logic and the audit trail can never confuse "forgot" with "ran".
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum BuildTestStatus {
161    NotConfigured,
162    NotRun {
163        reason: String,
164    },
165    Passed,
166    Failed {
167        code: Option<i32>,
168        /// Combined stdout+stderr tail, bounded.
169        output: String,
170    },
171}
172
173/// Whether policy permits the integration. A `Deny` is absolute.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum PolicyDecision {
176    Allow,
177    Deny { reasons: Vec<String> },
178}
179
180/// An explicit, audited waiver letting a legitimately test-less change (e.g.
181/// docs-only) be accepted without a build/test run. This is the *only* way a
182/// missing build/test can yield acceptance — it must be deliberately
183/// constructed by the caller, never defaulted.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct NoVerifyWaiver {
186    pub class: String,
187    pub reason: String,
188}
189
190/// Why a worktree was accepted. There is no way to construct this from "nothing
191/// failed" — it requires either a passed build/test or an explicit waiver.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub enum AcceptanceBasis {
194    /// Build/test ran and passed.
195    Verified,
196    /// No build/test, but an explicit [`NoVerifyWaiver`] authorized acceptance.
197    Waived { class: String, reason: String },
198}
199
200/// The provenance of one gate run — what was checked, what ran, what each leg
201/// found. B3 uses this to *attribute* false-accepts to a root cause, not merely
202/// count them.
203#[derive(Debug, Clone)]
204pub struct GateEvidence {
205    pub subtask: String,
206    pub changed_symbols: Vec<ChangedSymbol>,
207    pub footprint_declared: bool,
208    pub containment: CheckOutcome,
209    pub containment_violations: Vec<ContainmentViolation>,
210    /// Files with a real content delta that `car-ast` could not parse (unknown
211    /// extension or syntax error) — symbol-level checks are blind to these, so
212    /// they rely entirely on the build/test leg.
213    pub unparsed_changed_files: Vec<String>,
214    pub duplicates: CheckOutcome,
215    pub semantic_conflicts: Vec<DuplicateDeclaration>,
216    pub build_test: BuildTestStatus,
217    pub policy: PolicyDecision,
218}
219
220/// The gate's verdict on one farmed-out worktree. Three-valued and fail-closed:
221/// `Inconclusive` means "we could not affirm safety" and must be treated by the
222/// caller exactly like a rejection for integration purposes (it just signals a
223/// different remediation — usually "configure a build/test", not "replan").
224#[derive(Debug, Clone)]
225pub enum MergeVerdict {
226    Accepted {
227        basis: AcceptanceBasis,
228        evidence: GateEvidence,
229    },
230    Rejected {
231        reasons: Vec<String>,
232        evidence: GateEvidence,
233    },
234    Inconclusive {
235        reasons: Vec<String>,
236        evidence: GateEvidence,
237    },
238}
239
240impl MergeVerdict {
241    pub fn is_accepted(&self) -> bool {
242        matches!(self, MergeVerdict::Accepted { .. })
243    }
244
245    /// True only for a `Verified` acceptance — a waiver-based acceptance is *not*
246    /// build/test-verified. B3 distinguishes these.
247    pub fn is_verified(&self) -> bool {
248        matches!(
249            self,
250            MergeVerdict::Accepted {
251                basis: AcceptanceBasis::Verified,
252                ..
253            }
254        )
255    }
256
257    pub fn evidence(&self) -> &GateEvidence {
258        match self {
259            MergeVerdict::Accepted { evidence, .. }
260            | MergeVerdict::Rejected { evidence, .. }
261            | MergeVerdict::Inconclusive { evidence, .. } => evidence,
262        }
263    }
264
265    fn audit_kind(&self) -> EventKind {
266        if self.is_accepted() {
267            EventKind::GateAccepted
268        } else {
269            EventKind::GateRejected
270        }
271    }
272}
273
274/// Configuration for a single gate run.
275#[derive(Debug, Clone)]
276pub struct GateConfig {
277    /// Label for the subtask whose worktree is being verified (for audit).
278    pub subtask: String,
279    /// The integrated tree to run the build/test command in.
280    pub cwd: PathBuf,
281    /// Verify command (program + args), e.g. `["cargo", "test"]`. `None` ⇒ the
282    /// build/test leg is `NotConfigured`, which can only be accepted via a
283    /// `no_verify_waiver`.
284    pub verify_command: Option<Vec<String>>,
285    /// Explicit waiver for a legitimately test-less change. Without it, a missing
286    /// build/test is `Inconclusive`, never accepted.
287    pub no_verify_waiver: Option<NoVerifyWaiver>,
288    /// Wall-clock budget for the verify command. On timeout the build/test leg
289    /// resolves to `NotRun` ⇒ `Inconclusive` (we-don't-know), never `Verified`.
290    pub verify_timeout: Duration,
291    /// Max bytes of build/test output retained on failure.
292    pub max_output_bytes: usize,
293}
294
295impl GateConfig {
296    pub fn new(subtask: impl Into<String>, cwd: impl Into<PathBuf>) -> Self {
297        Self {
298            subtask: subtask.into(),
299            cwd: cwd.into(),
300            verify_command: None,
301            no_verify_waiver: None,
302            verify_timeout: Duration::from_secs(600),
303            max_output_bytes: 8 * 1024,
304        }
305    }
306
307    pub fn with_verify_command(mut self, cmd: Vec<String>) -> Self {
308        self.verify_command = Some(cmd);
309        self
310    }
311
312    pub fn with_no_verify_waiver(mut self, waiver: NoVerifyWaiver) -> Self {
313        self.no_verify_waiver = Some(waiver);
314        self
315    }
316
317    pub fn with_verify_timeout(mut self, timeout: Duration) -> Self {
318        self.verify_timeout = timeout;
319        self
320    }
321}
322
323// ---- Pure verification core (no I/O — unit-testable) -----------------------
324
325/// Extract the symbols each file change touched, plus the set of content-changed
326/// files `car-ast` could not parse. A file with no content delta is ignored. A
327/// file with a content delta whose present side fails to parse is recorded in
328/// `unparsed_changed_files` (symbol checks are blind to it — only build/test
329/// covers it).
330pub fn extract_changes(changes: &[FileChange]) -> (Vec<ChangedSymbol>, Vec<String>) {
331    let mut symbols = Vec::new();
332    let mut unparsed = Vec::new();
333
334    for change in changes {
335        if !change.content_changed() {
336            continue;
337        }
338        let before_opt = change
339            .before
340            .as_deref()
341            .map(|s| parse_file(s, &change.path));
342        let after_opt = change.after.as_deref().map(|s| parse_file(s, &change.path));
343
344        // A *present* side that failed to parse means we cannot see this file's
345        // symbols. Record it; the build/test gate is its only coverage.
346        let before_failed = matches!(before_opt, Some(None));
347        let after_failed = matches!(after_opt, Some(None));
348        if before_failed || after_failed {
349            unparsed.push(change.path.clone());
350        }
351
352        let before = before_opt.flatten();
353        let after = after_opt.flatten();
354        match (before, after) {
355            (Some(old), Some(new)) => {
356                for ch in diff_symbols(&old, &new) {
357                    let (name, kind) = match ch {
358                        SymbolChange::Added(s) => (s.name, ChangeKind::Added),
359                        SymbolChange::Removed(s) => (s.name, ChangeKind::Removed),
360                        SymbolChange::Modified {
361                            new,
362                            signature_changed,
363                            ..
364                        } => (
365                            new.name,
366                            if signature_changed {
367                                ChangeKind::SignatureChanged
368                            } else {
369                                ChangeKind::Modified
370                            },
371                        ),
372                    };
373                    symbols.push(ChangedSymbol {
374                        file: change.path.clone(),
375                        symbol: name,
376                        change: kind,
377                    });
378                }
379            }
380            (None, Some(new)) => {
381                for s in new.all_symbols() {
382                    symbols.push(ChangedSymbol {
383                        file: change.path.clone(),
384                        symbol: s.name.clone(),
385                        change: ChangeKind::Added,
386                    });
387                }
388            }
389            (Some(old), None) => {
390                for s in old.all_symbols() {
391                    symbols.push(ChangedSymbol {
392                        file: change.path.clone(),
393                        symbol: s.name.clone(),
394                        change: ChangeKind::Removed,
395                    });
396                }
397            }
398            (None, None) => {}
399        }
400    }
401    (symbols, unparsed)
402}
403
404/// Flag changed symbols outside a declared footprint. Empty when no footprint
405/// was declared (containment disabled).
406pub fn containment_violations(
407    changed: &[ChangedSymbol],
408    footprint: &DeclaredFootprint,
409) -> Vec<ContainmentViolation> {
410    if !footprint.is_declared() {
411        return Vec::new();
412    }
413    changed
414        .iter()
415        .filter(|c| !footprint.allows(&c.as_ref()))
416        .map(|c| ContainmentViolation { changed: c.clone() })
417        .collect()
418}
419
420/// Scan the integrated `after` side for duplicate declarations: two definitions
421/// of the same `(name, kind)` in one file. Uses `all_symbols()` so method-level
422/// duplicates inside `impl`/class blocks are caught (the common CodeCRDT case),
423/// not just top-level ones. Cross-file duplicates are normal and not flagged.
424pub fn duplicate_declarations(changes: &[FileChange]) -> Vec<DuplicateDeclaration> {
425    let mut out = Vec::new();
426    for change in changes {
427        let Some(parsed) = change
428            .after
429            .as_deref()
430            .and_then(|src| parse_file(src, &change.path))
431        else {
432            continue;
433        };
434        let mut counts: HashMap<(String, SymbolKind), usize> = HashMap::new();
435        for sym in parsed.all_symbols() {
436            // Imports legitimately repeat; definitions should not.
437            if matches!(sym.kind, SymbolKind::Import) {
438                continue;
439            }
440            *counts.entry((sym.name.clone(), sym.kind)).or_insert(0) += 1;
441        }
442        for ((name, kind), count) in counts {
443            if count > 1 {
444                out.push(DuplicateDeclaration {
445                    file: change.path.clone(),
446                    symbol: name,
447                    kind: format!("{kind:?}"),
448                    count,
449                });
450            }
451        }
452    }
453    out
454}
455
456/// The pure acceptance decision. This is the most safety-critical logic in the
457/// gate, so it lives here — pure and exhaustively testable — rather than inline
458/// in the async orchestrator. Acceptance requires *affirmative* evidence;
459/// every "we don't know" path resolves to `Rejected` or `Inconclusive`.
460pub fn decide(evidence: GateEvidence, waiver: Option<&NoVerifyWaiver>) -> MergeVerdict {
461    // 1. Policy denial is absolute.
462    if let PolicyDecision::Deny { reasons } = &evidence.policy {
463        let reasons = reasons
464            .iter()
465            .map(|r| format!("policy denied integration: {r}"))
466            .collect();
467        return MergeVerdict::Rejected { reasons, evidence };
468    }
469
470    // 2. Positive detection of a problem ⇒ reject.
471    let mut reasons = Vec::new();
472    for v in &evidence.containment_violations {
473        reasons.push(format!(
474            "changed {}::{} outside declared footprint",
475            v.changed.file, v.changed.symbol
476        ));
477    }
478    for d in &evidence.semantic_conflicts {
479        reasons.push(format!(
480            "{} duplicate {} declarations of {} in {}",
481            d.count, d.kind, d.symbol, d.file
482        ));
483    }
484    if let BuildTestStatus::Failed { code, output } = &evidence.build_test {
485        reasons.push(format!(
486            "build/test failed (exit {code:?}): {}",
487            tail(output, 300)
488        ));
489    }
490    if !reasons.is_empty() {
491        return MergeVerdict::Rejected { reasons, evidence };
492    }
493
494    // 3. Affirmative acceptance — requires a passed build/test, OR an explicit
495    //    waiver. Anything else is "we don't know" ⇒ Inconclusive (fail-closed).
496    match &evidence.build_test {
497        BuildTestStatus::Passed => MergeVerdict::Accepted {
498            basis: AcceptanceBasis::Verified,
499            evidence,
500        },
501        BuildTestStatus::NotConfigured => match waiver {
502            Some(w) => MergeVerdict::Accepted {
503                basis: AcceptanceBasis::Waived {
504                    class: w.class.clone(),
505                    reason: w.reason.clone(),
506                },
507                evidence,
508            },
509            None => MergeVerdict::Inconclusive {
510                reasons: vec![
511                    "build/test not configured and no waiver supplied — cannot affirm safety"
512                        .to_string(),
513                ],
514                evidence,
515            },
516        },
517        BuildTestStatus::NotRun { reason } => MergeVerdict::Inconclusive {
518            reasons: vec![format!("build/test did not run: {reason}")],
519            evidence,
520        },
521        // Unreachable: a Failed build/test was handled in step 2.
522        BuildTestStatus::Failed { .. } => MergeVerdict::Rejected {
523            reasons: vec!["build/test failed".to_string()],
524            evidence,
525        },
526    }
527}
528
529// ---- Orchestration (I/O: policy consult, build/test command, audit) --------
530
531/// Run the full gate on a worktree's changes and return a fail-closed verdict.
532/// `infra` provides the shared policy engine (a gating input) and event log (the
533/// audit trail). The build/test command runs only if the AST checks passed and
534/// policy allowed — integrating a tree we already know is unsafe is wasted work.
535pub async fn verify_changes(
536    config: &GateConfig,
537    changes: &[FileChange],
538    footprint: &DeclaredFootprint,
539    infra: &SharedInfra,
540) -> MergeVerdict {
541    let (changed_symbols, unparsed_changed_files) = extract_changes(changes);
542    let containment_list = containment_violations(&changed_symbols, footprint);
543    let duplicates = duplicate_declarations(changes);
544
545    let containment = if !footprint.is_declared() {
546        CheckOutcome::NotRun
547    } else if containment_list.is_empty() {
548        CheckOutcome::Passed
549    } else {
550        CheckOutcome::Failed
551    };
552    let duplicate_outcome = if duplicates.is_empty() {
553        CheckOutcome::Passed
554    } else {
555        CheckOutcome::Failed
556    };
557
558    let policy = consult_policy(config, changes, infra).await;
559
560    let ast_failed = !containment_list.is_empty() || !duplicates.is_empty();
561    let build_test = if ast_failed {
562        BuildTestStatus::NotRun {
563            reason: "AST checks already failed".to_string(),
564        }
565    } else if let PolicyDecision::Deny { .. } = &policy {
566        BuildTestStatus::NotRun {
567            reason: "policy denied integration".to_string(),
568        }
569    } else {
570        run_verify_command(config).await
571    };
572
573    let evidence = GateEvidence {
574        subtask: config.subtask.clone(),
575        changed_symbols,
576        footprint_declared: footprint.is_declared(),
577        containment,
578        containment_violations: containment_list,
579        unparsed_changed_files,
580        duplicates: duplicate_outcome,
581        semantic_conflicts: duplicates,
582        build_test,
583        policy,
584    };
585
586    let verdict = decide(evidence, config.no_verify_waiver.as_ref());
587    emit_audit(&verdict, infra).await;
588    verdict
589}
590
591/// Consult the shared [`PolicyEngine`] about integrating this worktree, modeled
592/// as a `foreman.integrate` tool action carrying the changed file paths. This
593/// lets operators write policies (e.g. deny merges touching `Cargo.lock` or a
594/// protected path) that *gate* the merge, not merely observe it.
595async fn consult_policy(
596    config: &GateConfig,
597    changes: &[FileChange],
598    infra: &SharedInfra,
599) -> PolicyDecision {
600    let files: Vec<Value> = changes
601        .iter()
602        .filter(|c| c.content_changed())
603        .map(|c| json!(c.path))
604        .collect();
605    let mut parameters = HashMap::new();
606    parameters.insert("subtask".to_string(), json!(config.subtask));
607    parameters.insert("files".to_string(), json!(files));
608
609    let action = {
610        let mut a = Action::new(ActionType::ToolCall);
611        a.id = format!("foreman-integrate-{}", config.subtask);
612        a.tool = Some("foreman.integrate".to_string());
613        a.parameters = parameters;
614        a.idempotent = true;
615        a.max_retries = 0;
616        a.failure_behavior = FailureBehavior::Skip;
617        a
618    };
619
620    let violations = infra.policies.read().await.check(&action, &infra.state);
621    if violations.is_empty() {
622        PolicyDecision::Allow
623    } else {
624        PolicyDecision::Deny {
625            reasons: violations
626                .into_iter()
627                .map(|v| format!("{}: {}", v.policy_name, v.reason))
628                .collect(),
629        }
630    }
631}
632
633async fn run_verify_command(config: &GateConfig) -> BuildTestStatus {
634    let Some(cmd) = &config.verify_command else {
635        return BuildTestStatus::NotConfigured;
636    };
637    let Some((program, args)) = cmd.split_first() else {
638        return BuildTestStatus::NotConfigured;
639    };
640
641    // Fail closed if the tree we were told to verify in doesn't exist. A stale or
642    // wrong cwd that happens to build elsewhere must never yield acceptance.
643    if !config.cwd.is_dir() {
644        return BuildTestStatus::NotRun {
645            reason: format!("verify cwd does not exist: {}", config.cwd.display()),
646        };
647    }
648
649    // A `.cmd`/`.bat` verify program (e.g. `npm test`, `npx …`) can't be
650    // spawned directly on Windows (os error 193); route it through `cmd /C`.
651    let mut cmd = car_engine::spawn::program_command(program);
652    cmd.args(args).current_dir(&config.cwd);
653    // On timeout the future is dropped; `output_with_tree_kill` assigns the
654    // child to a Job Object so a hung verify command's whole tree is reaped on
655    // Windows (a bare `kill_on_drop` reaps only the direct child, orphaning
656    // grandchildren across a benchmark run).
657    let run = car_registry::proc::output_with_tree_kill(cmd);
658
659    // A hung verify command resolves to NotRun (we-don't-know ⇒ Inconclusive),
660    // never to a pass.
661    let output = match tokio::time::timeout(config.verify_timeout, run).await {
662        Ok(res) => res,
663        Err(_) => {
664            return BuildTestStatus::NotRun {
665                reason: format!("verify command timed out after {:?}", config.verify_timeout),
666            };
667        }
668    };
669
670    match output {
671        Ok(out) if out.status.success() => BuildTestStatus::Passed,
672        Ok(out) => {
673            let mut combined = String::from_utf8_lossy(&out.stdout).into_owned();
674            combined.push_str(&String::from_utf8_lossy(&out.stderr));
675            BuildTestStatus::Failed {
676                code: out.status.code(),
677                output: tail(&combined, config.max_output_bytes),
678            }
679        }
680        Err(e) => BuildTestStatus::Failed {
681            code: None,
682            output: format!("failed to launch verify command: {e}"),
683        },
684    }
685}
686
687async fn emit_audit(verdict: &MergeVerdict, infra: &SharedInfra) {
688    let evidence = verdict.evidence();
689    let (outcome, basis, reasons) = match verdict {
690        MergeVerdict::Accepted { basis, .. } => {
691            let basis_str = match basis {
692                AcceptanceBasis::Verified => "verified".to_string(),
693                AcceptanceBasis::Waived { class, .. } => format!("waived:{class}"),
694            };
695            ("accepted", Some(basis_str), Vec::new())
696        }
697        MergeVerdict::Rejected { reasons, .. } => ("rejected", None, reasons.clone()),
698        MergeVerdict::Inconclusive { reasons, .. } => ("inconclusive", None, reasons.clone()),
699    };
700
701    let mut data = HashMap::new();
702    data.insert("subtask".to_string(), json!(evidence.subtask));
703    if let Some(scope) = &infra.gate_audit_scope {
704        data.insert("gate_audit_scope".to_string(), json!(scope));
705    }
706    data.insert("outcome".to_string(), json!(outcome));
707    if let Some(basis) = basis {
708        data.insert("basis".to_string(), json!(basis));
709    }
710    data.insert(
711        "changed_symbols".to_string(),
712        json!(evidence.changed_symbols.len()),
713    );
714    data.insert(
715        "containment_violations".to_string(),
716        json!(evidence.containment_violations.len()),
717    );
718    data.insert(
719        "unparsed_changed_files".to_string(),
720        json!(evidence.unparsed_changed_files),
721    );
722    data.insert(
723        "semantic_conflicts".to_string(),
724        json!(evidence.semantic_conflicts.len()),
725    );
726    data.insert(
727        "build_test".to_string(),
728        json!(match &evidence.build_test {
729            BuildTestStatus::NotConfigured => "not_configured",
730            BuildTestStatus::NotRun { .. } => "not_run",
731            BuildTestStatus::Passed => "passed",
732            BuildTestStatus::Failed { .. } => "failed",
733        }),
734    );
735    if !reasons.is_empty() {
736        data.insert("reasons".to_string(), json!(reasons));
737    }
738
739    infra
740        .log
741        .lock()
742        .await
743        .append(verdict.audit_kind(), None, None, data);
744}
745
746/// Keep the last `max_bytes` bytes of `s`, on a char boundary, prefixed when
747/// truncated so a reader knows output was elided.
748fn tail(s: &str, max_bytes: usize) -> String {
749    if s.len() <= max_bytes {
750        return s.to_string();
751    }
752    let mut start = s.len() - max_bytes;
753    while start < s.len() && !s.is_char_boundary(start) {
754        start += 1;
755    }
756    format!("…[truncated]\n{}", &s[start..])
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762
763    fn rs(path: &str, before: Option<&str>, after: Option<&str>) -> FileChange {
764        FileChange {
765            path: path.to_string(),
766            before: before.map(str::to_string),
767            after: after.map(str::to_string),
768        }
769    }
770
771    // ---- pure core ----
772
773    #[test]
774    fn detects_modified_and_added_symbols() {
775        let (changed, unparsed) = extract_changes(&[rs(
776            "src/lib.rs",
777            Some("pub fn alpha() {}\n"),
778            Some("pub fn alpha() -> u8 { 1 }\npub fn beta() {}\n"),
779        )]);
780        assert!(unparsed.is_empty());
781        let names: Vec<_> = changed.iter().map(|c| c.symbol.as_str()).collect();
782        assert!(names.contains(&"alpha"), "alpha changed: {names:?}");
783        assert!(names.contains(&"beta"), "beta added: {names:?}");
784    }
785
786    #[test]
787    fn unparseable_changed_file_is_recorded() {
788        // A .toml change car-ast cannot parse, with a real content delta.
789        let (changed, unparsed) = extract_changes(&[rs(
790            "Cargo.toml",
791            Some("[package]\n"),
792            Some("[package]\nx=1\n"),
793        )]);
794        assert!(changed.is_empty(), "no symbols from an unparseable file");
795        assert_eq!(unparsed, vec!["Cargo.toml".to_string()]);
796    }
797
798    #[test]
799    fn containment_flags_out_of_footprint_edits() {
800        let (changed, _) = extract_changes(&[rs(
801            "src/lib.rs",
802            Some("pub fn allowed() {}\npub fn sneaky() {}\n"),
803            Some("pub fn allowed() -> u8 { 1 }\npub fn sneaky() -> u8 { 2 }\n"),
804        )]);
805        let footprint = DeclaredFootprint::from_refs([SymbolRef::new("src/lib.rs", "allowed")]);
806        let violations = containment_violations(&changed, &footprint);
807        assert_eq!(violations.len(), 1);
808        assert_eq!(violations[0].changed.symbol, "sneaky");
809    }
810
811    #[test]
812    fn duplicate_declarations_catches_method_level() {
813        // Two `handle` methods in one impl block — nested as children, so this
814        // only passes if we scan all_symbols(), not just top-level.
815        let after =
816            "pub struct S;\nimpl S {\n  pub fn handle(&self) {}\n  pub fn handle(&self) {}\n}\n";
817        let dups =
818            duplicate_declarations(&[rs("src/lib.rs", Some("pub struct S;\n"), Some(after))]);
819        assert!(
820            dups.iter().any(|d| d.symbol == "handle" && d.count == 2),
821            "method-level duplicate must be caught: {dups:?}"
822        );
823    }
824
825    // ---- the decision rule (fail-closed) ----
826
827    fn clean_evidence(build_test: BuildTestStatus) -> GateEvidence {
828        GateEvidence {
829            subtask: "t".to_string(),
830            changed_symbols: vec![],
831            footprint_declared: false,
832            containment: CheckOutcome::NotRun,
833            containment_violations: vec![],
834            unparsed_changed_files: vec![],
835            duplicates: CheckOutcome::Passed,
836            semantic_conflicts: vec![],
837            build_test,
838            policy: PolicyDecision::Allow,
839        }
840    }
841
842    #[test]
843    fn skipped_build_test_is_inconclusive_not_accepted() {
844        // THE fail-open hole the first cut had: no build/test must NOT accept.
845        let verdict = decide(clean_evidence(BuildTestStatus::NotConfigured), None);
846        assert!(
847            matches!(verdict, MergeVerdict::Inconclusive { .. }),
848            "unconfigured build/test must be inconclusive, got {verdict:?}"
849        );
850        assert!(!verdict.is_accepted());
851    }
852
853    #[test]
854    fn passed_build_test_yields_verified_acceptance() {
855        let verdict = decide(clean_evidence(BuildTestStatus::Passed), None);
856        assert!(verdict.is_verified());
857    }
858
859    #[test]
860    fn explicit_waiver_accepts_without_build_test_but_not_verified() {
861        let waiver = NoVerifyWaiver {
862            class: "docs-only".to_string(),
863            reason: "README change".to_string(),
864        };
865        let verdict = decide(
866            clean_evidence(BuildTestStatus::NotConfigured),
867            Some(&waiver),
868        );
869        assert!(verdict.is_accepted(), "explicit waiver accepts");
870        assert!(!verdict.is_verified(), "but it is NOT build/test-verified");
871    }
872
873    #[test]
874    fn failed_build_test_rejects() {
875        let verdict = decide(
876            clean_evidence(BuildTestStatus::Failed {
877                code: Some(101),
878                output: "boom".to_string(),
879            }),
880            None,
881        );
882        assert!(matches!(verdict, MergeVerdict::Rejected { .. }));
883    }
884
885    #[test]
886    fn policy_denial_rejects_even_with_passing_build() {
887        let mut ev = clean_evidence(BuildTestStatus::Passed);
888        ev.policy = PolicyDecision::Deny {
889            reasons: vec!["protected path".to_string()],
890        };
891        let verdict = decide(ev, None);
892        assert!(matches!(verdict, MergeVerdict::Rejected { .. }));
893    }
894
895    #[test]
896    fn containment_violation_rejects_even_with_passing_build() {
897        let mut ev = clean_evidence(BuildTestStatus::Passed);
898        ev.containment_violations = vec![ContainmentViolation {
899            changed: ChangedSymbol {
900                file: "src/lib.rs".to_string(),
901                symbol: "sneaky".to_string(),
902                change: ChangeKind::Modified,
903            },
904        }];
905        assert!(matches!(decide(ev, None), MergeVerdict::Rejected { .. }));
906    }
907
908    // ---- orchestration ----
909
910    #[tokio::test]
911    async fn verify_accepts_clean_change_with_passing_command_and_audits() {
912        let infra = SharedInfra::new();
913        let change = rs(
914            "src/lib.rs",
915            Some("pub fn a() {}\n"),
916            Some("pub fn a() -> u8 { 1 }\n"),
917        );
918        let config = GateConfig::new("subtask-1", std::env::temp_dir())
919            .with_verify_command(crate::patterns::foreman::test_verify::pass());
920        let verdict = verify_changes(
921            &config,
922            &[change],
923            &DeclaredFootprint::unconstrained(),
924            &infra,
925        )
926        .await;
927        assert!(
928            verdict.is_verified(),
929            "clean change + passing build = verified"
930        );
931        let log = infra.log.lock().await;
932        assert_eq!(log.events()[0].kind, EventKind::GateAccepted);
933    }
934
935    #[tokio::test]
936    async fn verify_without_command_is_inconclusive() {
937        let infra = SharedInfra::new();
938        let change = rs(
939            "src/lib.rs",
940            Some("pub fn a() {}\n"),
941            Some("pub fn a() -> u8 { 1 }\n"),
942        );
943        // No verify command, no waiver — must NOT accept.
944        let config = GateConfig::new("subtask-2", std::env::temp_dir());
945        let verdict = verify_changes(
946            &config,
947            &[change],
948            &DeclaredFootprint::unconstrained(),
949            &infra,
950        )
951        .await;
952        assert!(!verdict.is_accepted());
953        assert!(matches!(verdict, MergeVerdict::Inconclusive { .. }));
954        let log = infra.log.lock().await;
955        assert_eq!(log.events()[0].kind, EventKind::GateRejected);
956    }
957
958    #[tokio::test]
959    async fn verify_rejects_containment_escape_and_skips_build() {
960        let infra = SharedInfra::new();
961        let change = rs(
962            "src/lib.rs",
963            Some("pub fn allowed() {}\npub fn sneaky() {}\n"),
964            Some("pub fn allowed() {}\npub fn sneaky() -> u8 { 2 }\n"),
965        );
966        let footprint = DeclaredFootprint::from_refs([SymbolRef::new("src/lib.rs", "allowed")]);
967        let config = GateConfig::new("subtask-3", std::env::temp_dir())
968            .with_verify_command(crate::patterns::foreman::test_verify::pass());
969        let verdict = verify_changes(&config, &[change], &footprint, &infra).await;
970        assert!(matches!(verdict, MergeVerdict::Rejected { .. }));
971        // AST failure short-circuits the build/test.
972        assert!(matches!(
973            verdict.evidence().build_test,
974            BuildTestStatus::NotRun { .. }
975        ));
976    }
977
978    #[tokio::test]
979    async fn policy_can_deny_integration() {
980        let infra = SharedInfra::new();
981        // Deny any foreman integration touching a protected path.
982        infra.policies.write().await.register(
983            "protect-cargo-lock",
984            Box::new(|action: &Action, _| {
985                let touches = action
986                    .parameters
987                    .get("files")
988                    .and_then(|f| f.as_array())
989                    .map(|arr| arr.iter().any(|v| v.as_str() == Some("Cargo.lock")))
990                    .unwrap_or(false);
991                if touches {
992                    Some("integration touches protected Cargo.lock".to_string())
993                } else {
994                    None
995                }
996            }),
997            "block merges touching Cargo.lock",
998        );
999        let change = rs("Cargo.lock", Some("a = 1\n"), Some("a = 2\n"));
1000        let config = GateConfig::new("subtask-4", std::env::temp_dir())
1001            .with_verify_command(crate::patterns::foreman::test_verify::pass());
1002        let verdict = verify_changes(
1003            &config,
1004            &[change],
1005            &DeclaredFootprint::unconstrained(),
1006            &infra,
1007        )
1008        .await;
1009        assert!(matches!(verdict, MergeVerdict::Rejected { .. }));
1010    }
1011
1012    #[tokio::test]
1013    async fn missing_verify_cwd_is_inconclusive_not_accepted() {
1014        let infra = SharedInfra::new();
1015        let change = rs(
1016            "src/lib.rs",
1017            Some("pub fn a() {}\n"),
1018            Some("pub fn a() -> u8 { 1 }\n"),
1019        );
1020        // A verify command IS configured, but the cwd does not exist — we cannot
1021        // run it, so we must not accept. Fail closed to Inconclusive.
1022        let config = GateConfig::new("subtask-5", "/nonexistent/foreman/tree")
1023            .with_verify_command(crate::patterns::foreman::test_verify::pass());
1024        let verdict = verify_changes(
1025            &config,
1026            &[change],
1027            &DeclaredFootprint::unconstrained(),
1028            &infra,
1029        )
1030        .await;
1031        assert!(!verdict.is_accepted());
1032        assert!(matches!(verdict, MergeVerdict::Inconclusive { .. }));
1033    }
1034}