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;