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)]
255mod tests {
256    use super::*;
257
258    fn finding(rule: &str, severity: Severity) -> Finding {
259        Finding {
260            rule: rule.into(),
261            message: "msg".into(),
262            file: None,
263            line: None,
264            severity,
265        }
266    }
267
268    #[test]
269    fn pass_is_not_blocking() {
270        assert!(!Verdict::Pass.is_blocking());
271    }
272
273    #[test]
274    fn warn_is_not_blocking() {
275        let v = Verdict::Warn {
276            findings: vec![finding("r", Severity::Warn)],
277            message: None,
278        };
279        assert!(!v.is_blocking());
280    }
281
282    #[test]
283    fn fail_is_blocking() {
284        let v = Verdict::Fail {
285            findings: vec![],
286            message: "boom".into(),
287        };
288        assert!(v.is_blocking());
289    }
290
291    #[test]
292    fn merge_empty_is_pass() {
293        let v = Verdict::merge(vec![], VerdictPolicy::AnyFail);
294        assert!(matches!(v, Verdict::Pass));
295    }
296
297    #[test]
298    fn merge_all_pass_is_pass() {
299        let v = Verdict::merge(
300            vec![Verdict::Pass, Verdict::Pass, Verdict::Pass],
301            VerdictPolicy::AnyFail,
302        );
303        assert!(matches!(v, Verdict::Pass));
304    }
305
306    #[test]
307    fn merge_warn_among_pass_is_warn() {
308        let v = Verdict::merge(
309            vec![
310                Verdict::Pass,
311                Verdict::Warn {
312                    findings: vec![finding("a", Severity::Warn)],
313                    message: Some("notice".into()),
314                },
315                Verdict::Pass,
316            ],
317            VerdictPolicy::AnyFail,
318        );
319        match v {
320            Verdict::Warn { findings, message } => {
321                assert_eq!(findings.len(), 1);
322                assert_eq!(message.as_deref(), Some("notice"));
323            }
324            other => panic!("expected Warn, got {other:?}"),
325        }
326    }
327
328    #[test]
329    fn merge_any_fail_is_fail() {
330        let v = Verdict::merge(
331            vec![
332                Verdict::Pass,
333                Verdict::Warn {
334                    findings: vec![finding("w", Severity::Warn)],
335                    message: None,
336                },
337                Verdict::Fail {
338                    findings: vec![finding("f", Severity::Error)],
339                    message: "broken".into(),
340                },
341            ],
342            VerdictPolicy::AnyFail,
343        );
344        match v {
345            Verdict::Fail { findings, message } => {
346                assert_eq!(findings.len(), 1);
347                assert_eq!(message, "broken");
348            }
349            other => panic!("expected Fail, got {other:?}"),
350        }
351    }
352
353    // ── AllFail ──────────────────────────────────────────────────────────────
354
355    fn fail(msg: &str) -> Verdict {
356        Verdict::Fail {
357            findings: vec![finding(msg, Severity::Error)],
358            message: msg.into(),
359        }
360    }
361
362    fn warn_v() -> Verdict {
363        Verdict::Warn {
364            findings: vec![finding("w", Severity::Warn)],
365            message: Some("warn".into()),
366        }
367    }
368
369    #[test]
370    fn all_fail_empty_is_pass() {
371        assert!(matches!(
372            Verdict::merge(vec![], VerdictPolicy::AllFail),
373            Verdict::Pass
374        ));
375    }
376
377    #[test]
378    fn all_fail_all_pass_is_pass() {
379        let v = Verdict::merge(
380            vec![Verdict::Pass, Verdict::Pass, Verdict::Pass],
381            VerdictPolicy::AllFail,
382        );
383        assert!(matches!(v, Verdict::Pass));
384    }
385
386    #[test]
387    fn all_fail_all_fail_is_fail() {
388        // 0 pass + 3 fail — unanimous failure → Fail.
389        let v = Verdict::merge(
390            vec![fail("a"), fail("b"), fail("c")],
391            VerdictPolicy::AllFail,
392        );
393        assert!(v.is_blocking(), "expected Fail (blocking), got {v:?}");
394        match v {
395            Verdict::Fail { findings, .. } => assert_eq!(findings.len(), 3),
396            other => panic!("expected Fail, got {other:?}"),
397        }
398    }
399
400    #[test]
401    fn all_fail_mixed_pass_and_fail_is_warn() {
402        // 1 pass + 2 fail — not unanimous → downgrade to Warn.
403        let v = Verdict::merge(
404            vec![Verdict::Pass, fail("x"), fail("y")],
405            VerdictPolicy::AllFail,
406        );
407        assert!(!v.is_blocking(), "expected non-blocking, got {v:?}");
408        assert!(matches!(v, Verdict::Warn { .. }));
409    }
410
411    #[test]
412    fn all_fail_warn_only_is_warn() {
413        // Warns don't count in the decisive set; no fails → Pass through Warn.
414        let v = Verdict::merge(vec![warn_v(), Verdict::Pass], VerdictPolicy::AllFail);
415        assert!(!v.is_blocking());
416        assert!(matches!(v, Verdict::Warn { .. }));
417    }
418
419    #[test]
420    fn all_fail_only_warns_no_pass_no_fail_is_warn() {
421        // Warn-only input: no decisive votes → Warn (not Fail).
422        let v = Verdict::merge(vec![warn_v(), warn_v()], VerdictPolicy::AllFail);
423        assert!(!v.is_blocking());
424        assert!(matches!(v, Verdict::Warn { .. }));
425    }
426
427    #[test]
428    fn all_fail_fail_with_warn_no_pass_is_fail() {
429        // fail_count=2, pass_count=0 → unanimous among decisive votes → Fail.
430        // Warn findings are present but don't veto the unanimous Fail.
431        let v = Verdict::merge(vec![fail("a"), fail("b"), warn_v()], VerdictPolicy::AllFail);
432        assert!(v.is_blocking(), "expected Fail, got {v:?}");
433    }
434
435    #[test]
436    fn all_fail_single_fail_single_pass_is_warn() {
437        // 1 fail + 1 pass → not unanimous → Warn.
438        let v = Verdict::merge(vec![fail("f"), Verdict::Pass], VerdictPolicy::AllFail);
439        assert!(!v.is_blocking());
440        assert!(matches!(v, Verdict::Warn { .. }));
441    }
442
443    // ── MajorityFail ─────────────────────────────────────────────────────────
444
445    #[test]
446    fn majority_fail_empty_is_pass() {
447        assert!(matches!(
448            Verdict::merge(vec![], VerdictPolicy::MajorityFail),
449            Verdict::Pass
450        ));
451    }
452
453    #[test]
454    fn majority_fail_all_pass_is_pass() {
455        let v = Verdict::merge(
456            vec![Verdict::Pass, Verdict::Pass, Verdict::Pass],
457            VerdictPolicy::MajorityFail,
458        );
459        assert!(matches!(v, Verdict::Pass));
460    }
461
462    #[test]
463    fn majority_fail_all_fail_is_fail() {
464        // 0 pass + 3 fail → 100% fail → Fail.
465        let v = Verdict::merge(
466            vec![fail("a"), fail("b"), fail("c")],
467            VerdictPolicy::MajorityFail,
468        );
469        assert!(v.is_blocking(), "expected Fail, got {v:?}");
470    }
471
472    #[test]
473    fn majority_fail_strict_majority_3p_0f() {
474        // 3 pass + 0 fail → no majority → Pass.
475        let v = Verdict::merge(
476            vec![Verdict::Pass, Verdict::Pass, Verdict::Pass],
477            VerdictPolicy::MajorityFail,
478        );
479        assert!(matches!(v, Verdict::Pass));
480    }
481
482    #[test]
483    fn majority_fail_strict_majority_1p_2f() {
484        // 1 pass + 2 fail → 2/3 → majority → Fail.
485        let v = Verdict::merge(
486            vec![Verdict::Pass, fail("x"), fail("y")],
487            VerdictPolicy::MajorityFail,
488        );
489        assert!(v.is_blocking(), "expected Fail, got {v:?}");
490    }
491
492    #[test]
493    fn majority_fail_tie_2p_2f_is_warn() {
494        // 2 pass + 2 fail → tie → NOT majority → downgrade to Warn.
495        let v = Verdict::merge(
496            vec![Verdict::Pass, Verdict::Pass, fail("a"), fail("b")],
497            VerdictPolicy::MajorityFail,
498        );
499        assert!(!v.is_blocking(), "expected non-blocking on tie, got {v:?}");
500        assert!(matches!(v, Verdict::Warn { .. }));
501    }
502
503    #[test]
504    fn majority_fail_2p_1f_is_warn() {
505        // 2 pass + 1 fail → not majority → downgrade to Warn.
506        let v = Verdict::merge(
507            vec![Verdict::Pass, Verdict::Pass, fail("f")],
508            VerdictPolicy::MajorityFail,
509        );
510        assert!(!v.is_blocking());
511        assert!(matches!(v, Verdict::Warn { .. }));
512    }
513
514    #[test]
515    fn majority_fail_warns_ignored_in_count() {
516        // Warns don't participate in the decisive count. 1 pass + 1 fail + 2 warns → tie → Warn.
517        let v = Verdict::merge(
518            vec![Verdict::Pass, fail("f"), warn_v(), warn_v()],
519            VerdictPolicy::MajorityFail,
520        );
521        assert!(!v.is_blocking(), "expected non-blocking, got {v:?}");
522        assert!(matches!(v, Verdict::Warn { .. }));
523    }
524
525    #[test]
526    fn majority_fail_0p_3f_with_warns_is_fail() {
527        // 0 pass + 3 fail (+ warns) → 100% of decisive = fail → Fail.
528        let v = Verdict::merge(
529            vec![fail("a"), fail("b"), fail("c"), warn_v()],
530            VerdictPolicy::MajorityFail,
531        );
532        assert!(v.is_blocking(), "expected Fail, got {v:?}");
533    }
534
535    // ── Unknown policy round-trip via config ──────────────────────────────────
536
537    #[test]
538    fn unknown_policy_rejected_at_config_parse() {
539        use crate::config::ConfigV1;
540        let toml = r#"
541            version = 1
542            [gate]
543            agents = ["claude_code"]
544            policy = "made_up"
545        "#;
546        let err = ConfigV1::parse(toml).expect_err("unknown policy should fail at parse");
547        // The error wraps a toml parse error — just confirm it didn't parse OK.
548        use crate::error::KlaspError;
549        assert!(
550            matches!(err, KlaspError::ConfigParse(_)),
551            "expected ConfigParse, got {err:?}"
552        );
553    }
554
555    #[test]
556    fn all_three_policy_values_parse_in_config() {
557        use crate::config::ConfigV1;
558        for policy_str in &["any_fail", "all_fail", "majority_fail"] {
559            let toml = format!(
560                r#"
561                version = 1
562                [gate]
563                agents = ["claude_code"]
564                policy = "{policy_str}"
565                "#
566            );
567            let cfg = ConfigV1::parse(&toml)
568                .unwrap_or_else(|e| panic!("policy '{policy_str}' should parse, got: {e:?}"));
569            let expected = match *policy_str {
570                "any_fail" => VerdictPolicy::AnyFail,
571                "all_fail" => VerdictPolicy::AllFail,
572                "majority_fail" => VerdictPolicy::MajorityFail,
573                _ => unreachable!(),
574            };
575            assert_eq!(cfg.gate.policy, expected, "policy '{policy_str}' mismatch");
576        }
577    }
578}