Skip to main content

klasp_core/
verdict.rs

1//! `Verdict` — three-tier outcome of a check run, plus aggregation policies.
2//!
3//! Design: [docs/design.md §3.4]. The Warn tier is the staged-rollout gradient
4//! that lets new checks land without immediately blocking. `Finding` derives
5//! `Clone` because verdicts are merged across multiple `CheckResult`s — the
6//! aggregation in `Verdict::merge` requires copies of the input findings.
7
8use std::process::ExitCode;
9
10use serde::{Deserialize, Serialize};
11
12/// Severity attached to an individual `Finding`. Verdict tier is decided by
13/// the runtime aggregating findings; severity here is informational metadata
14/// for the rendered block message.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum Severity {
18    Info,
19    Warn,
20    Error,
21}
22
23/// A single actionable item produced by a check (a lint hit, a failed test,
24/// a rule violation). Renders into the agent-visible block message.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Finding {
27    pub rule: String,
28    pub message: String,
29    /// File path the finding refers to, formatted via `Path::to_string_lossy()`
30    /// at construction time. Stored as `String` (not `PathBuf`) so JSON
31    /// serialisation on Windows can't fail on non-UTF-8 paths — findings are
32    /// human-rendered anyway, lossy display is acceptable.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub file: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub line: Option<u32>,
37    pub severity: Severity,
38}
39
40/// Three-tier verdict. `Pass` and `Warn` are non-blocking (exit 0); `Fail` is
41/// blocking (exit 2 — the Claude Code convention for "deny the tool call").
42#[derive(Debug, Clone)]
43pub enum Verdict {
44    Pass,
45    Warn {
46        findings: Vec<Finding>,
47        message: Option<String>,
48    },
49    Fail {
50        findings: Vec<Finding>,
51        message: String,
52    },
53}
54
55impl Verdict {
56    /// Only `Fail` blocks the tool call. `Warn` renders a notice but allows
57    /// the agent to proceed.
58    pub fn is_blocking(&self) -> bool {
59        matches!(self, Verdict::Fail { .. })
60    }
61
62    /// Map a verdict to the `klasp gate` process exit code.
63    /// Pass / Warn → 0, Fail → 2.
64    pub fn exit_code(&self) -> ExitCode {
65        if self.is_blocking() {
66            ExitCode::from(2)
67        } else {
68            ExitCode::SUCCESS
69        }
70    }
71
72    /// Aggregate per-check verdicts into a single final verdict using the
73    /// supplied [`VerdictPolicy`]. See the policy doc-comment for full
74    /// semantics; the summary is:
75    /// - `AnyFail` — blocks if any check returned `Fail`.
76    /// - `AllFail` — blocks only when every non-`Warn` check returned `Fail`.
77    /// - `MajorityFail` — blocks when strictly more than half the non-`Warn`
78    ///   checks returned `Fail`.
79    pub fn merge(verdicts: Vec<Verdict>, policy: VerdictPolicy) -> Verdict {
80        match policy {
81            VerdictPolicy::AnyFail => merge_any_fail(verdicts),
82            VerdictPolicy::AllFail => merge_all_fail(verdicts),
83            VerdictPolicy::MajorityFail => merge_majority_fail(verdicts),
84        }
85    }
86}
87
88fn merge_any_fail(verdicts: Vec<Verdict>) -> Verdict {
89    let mut fail_findings: Vec<Finding> = Vec::new();
90    let mut fail_messages: Vec<String> = Vec::new();
91    let mut warn_findings: Vec<Finding> = Vec::new();
92    let mut warn_messages: Vec<String> = Vec::new();
93
94    for verdict in verdicts {
95        match verdict {
96            Verdict::Pass => {}
97            Verdict::Warn { findings, message } => {
98                warn_findings.extend(findings);
99                if let Some(m) = message {
100                    warn_messages.push(m);
101                }
102            }
103            Verdict::Fail { findings, message } => {
104                fail_findings.extend(findings);
105                fail_messages.push(message);
106            }
107        }
108    }
109
110    if !fail_messages.is_empty() {
111        Verdict::Fail {
112            findings: fail_findings,
113            message: fail_messages.join("\n"),
114        }
115    } else if !warn_findings.is_empty() || !warn_messages.is_empty() {
116        Verdict::Warn {
117            findings: warn_findings,
118            message: if warn_messages.is_empty() {
119                None
120            } else {
121                Some(warn_messages.join("\n"))
122            },
123        }
124    } else {
125        Verdict::Pass
126    }
127}
128
129/// Partition `verdicts` into three buckets and return the counts + collected
130/// warn findings/messages for use in the non-blocking fallback path shared by
131/// `merge_all_fail` and `merge_majority_fail`.
132struct Partition {
133    fail_count: usize,
134    pass_count: usize,
135    fail_findings: Vec<Finding>,
136    fail_messages: Vec<String>,
137    warn_findings: Vec<Finding>,
138    warn_messages: Vec<String>,
139}
140
141fn partition(verdicts: Vec<Verdict>) -> Partition {
142    let mut p = Partition {
143        fail_count: 0,
144        pass_count: 0,
145        fail_findings: Vec::new(),
146        fail_messages: Vec::new(),
147        warn_findings: Vec::new(),
148        warn_messages: Vec::new(),
149    };
150    for v in verdicts {
151        match v {
152            Verdict::Pass => p.pass_count += 1,
153            Verdict::Warn { findings, message } => {
154                p.warn_findings.extend(findings);
155                if let Some(m) = message {
156                    p.warn_messages.push(m);
157                }
158            }
159            Verdict::Fail { findings, message } => {
160                p.fail_count += 1;
161                p.fail_findings.extend(findings);
162                p.fail_messages.push(message);
163            }
164        }
165    }
166    p
167}
168
169/// Non-blocking fallback used by `AllFail` and `MajorityFail` when the
170/// threshold is not met. If any `Fail` verdicts were present (but insufficient
171/// to trigger the policy), they are downgraded to a `Warn` so the agent is
172/// still informed. Otherwise, any collected `Warn` findings propagate; if
173/// nothing at all, returns `Pass`.
174fn downgrade_to_warn_or_pass(p: Partition) -> Verdict {
175    if !p.fail_messages.is_empty() {
176        // Failing checks exist but didn't hit the policy threshold — downgrade.
177        let mut findings = p.fail_findings;
178        findings.extend(p.warn_findings);
179        let mut messages = p.fail_messages;
180        messages.extend(p.warn_messages);
181        Verdict::Warn {
182            findings,
183            message: Some(messages.join("\n")),
184        }
185    } else if !p.warn_findings.is_empty() || !p.warn_messages.is_empty() {
186        Verdict::Warn {
187            findings: p.warn_findings,
188            message: if p.warn_messages.is_empty() {
189                None
190            } else {
191                Some(p.warn_messages.join("\n"))
192            },
193        }
194    } else {
195        Verdict::Pass
196    }
197}
198
199/// `AllFail`: blocks only when at least one check returned `Fail` AND no check
200/// returned `Pass` (strict unanimity among the non-`Warn` participants).
201/// Mixed `Pass`+`Fail` → `Warn`; empty or all-`Pass` → `Pass`.
202fn merge_all_fail(verdicts: Vec<Verdict>) -> Verdict {
203    let p = partition(verdicts);
204    if p.fail_count > 0 && p.pass_count == 0 {
205        Verdict::Fail {
206            findings: p.fail_findings,
207            message: p.fail_messages.join("\n"),
208        }
209    } else {
210        downgrade_to_warn_or_pass(p)
211    }
212}
213
214/// `MajorityFail`: blocks when strictly more than half of the non-`Warn`
215/// checks returned `Fail`. Ties (equal pass and fail counts) are **not**
216/// a majority — they follow the same downgrade path as `AllFail`. Empty → `Pass`.
217fn merge_majority_fail(verdicts: Vec<Verdict>) -> Verdict {
218    let p = partition(verdicts);
219    let total_decisive = p.fail_count + p.pass_count;
220    if total_decisive > 0 && p.fail_count * 2 > total_decisive {
221        Verdict::Fail {
222            findings: p.fail_findings,
223            message: p.fail_messages.join("\n"),
224        }
225    } else {
226        downgrade_to_warn_or_pass(p)
227    }
228}
229
230/// Policy for combining multiple per-check verdicts.
231///
232/// | Policy | When `Fail` is returned |
233/// |---|---|
234/// | `AnyFail` | At least one check returned `Fail` (strict, v0.1 default). |
235/// | `AllFail` | Every participating check returned `Fail` AND no check returned `Pass` (strict unanimity among non-`Warn` checks). Mixed `Pass`+`Fail` → `Warn`; all `Pass` → `Pass`; empty → `Pass`. |
236/// | `MajorityFail` | Strictly more than half of the non-`Warn` checks returned `Fail`. Ties (equal pass/fail counts) are not majority — same downgrade shape as `AllFail`. Empty → `Pass`. |
237///
238/// `AllFail` edge-case rules (consistently applied by `MajorityFail` for non-blocking outcomes):
239/// - Any `Fail` verdict present but not unanimous (or not majority) → `Warn` (the agent is
240///   informed but not blocked).
241/// - No `Fail` verdicts, some `Warn` → `Warn`.
242/// - No `Fail`, no `Warn` (or empty input) → `Pass`.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
244#[serde(rename_all = "snake_case")]
245pub enum VerdictPolicy {
246    #[default]
247    AnyFail,
248    /// Gate blocks only when every non-`Warn` check failed (no `Pass` votes).
249    AllFail,
250    /// Gate blocks only when strictly more than half the non-`Warn` checks failed.
251    MajorityFail,
252}
253
254#[cfg(test)]
255#[path = "verdict_tests.rs"]
256mod tests;