Skip to main content

unsafe_review_core/domain/
classification.rs

1#[derive(Clone, Debug, PartialEq, Eq)]
2pub enum ReviewClass {
3    GuardedAndWitnessed,
4    GuardedUnwitnessed,
5    ContractMissing,
6    GuardMissing,
7    ReachableUnwitnessed,
8    UnsafeUnreached,
9    WitnessMismatch,
10    RequiresLoom,
11    RequiresSanitizer,
12    RequiresKaniOrCrux,
13    MiriUnsupported,
14    StaticUnknown,
15    BaselineKnown,
16    Suppressed,
17}
18
19impl ReviewClass {
20    pub fn as_str(&self) -> &'static str {
21        match self {
22            Self::GuardedAndWitnessed => "guarded_and_witnessed",
23            Self::GuardedUnwitnessed => "guarded_unwitnessed",
24            Self::ContractMissing => "contract_missing",
25            Self::GuardMissing => "guard_missing",
26            Self::ReachableUnwitnessed => "reachable_unwitnessed",
27            Self::UnsafeUnreached => "unsafe_unreached",
28            Self::WitnessMismatch => "witness_mismatch",
29            Self::RequiresLoom => "requires_loom",
30            Self::RequiresSanitizer => "requires_sanitizer",
31            Self::RequiresKaniOrCrux => "requires_kani_or_crux",
32            Self::MiriUnsupported => "miri_unsupported",
33            Self::StaticUnknown => "static_unknown",
34            Self::BaselineKnown => "baseline_known",
35            Self::Suppressed => "suppressed",
36        }
37    }
38
39    pub fn is_actionable(&self) -> bool {
40        matches!(
41            self,
42            Self::GuardedUnwitnessed
43                | Self::ContractMissing
44                | Self::GuardMissing
45                | Self::ReachableUnwitnessed
46                | Self::UnsafeUnreached
47                | Self::WitnessMismatch
48                | Self::RequiresLoom
49                | Self::RequiresSanitizer
50                | Self::RequiresKaniOrCrux
51                | Self::MiriUnsupported
52                | Self::StaticUnknown
53        )
54    }
55
56    /// Return the SARIF result level for this class.
57    ///
58    /// The mapping is the single severity language for all surfaces — SARIF
59    /// `level` and LSP diagnostic `severity` both derive from this table via
60    /// [`Self::lsp_severity`].  Priority is a ranking/budget signal only and
61    /// must NOT drive severity.
62    ///
63    /// Advisory boundary: "warning" means "needs attention" for this advisory
64    /// tool, not a merge-blocking verdict.
65    pub fn sarif_level(&self) -> &'static str {
66        match self {
67            // Non-actionable — evidence present or administratively closed.
68            Self::GuardedAndWitnessed | Self::BaselineKnown | Self::Suppressed => "none",
69            // Partially-resolved — has some evidence but not a full witness chain.
70            Self::GuardedUnwitnessed | Self::UnsafeUnreached | Self::StaticUnknown => "note",
71            // Actionable — evidence missing or receipt mismatched.
72            Self::ContractMissing
73            | Self::GuardMissing
74            | Self::ReachableUnwitnessed
75            | Self::WitnessMismatch
76            | Self::RequiresLoom
77            | Self::RequiresSanitizer
78            | Self::RequiresKaniOrCrux
79            | Self::MiriUnsupported => "warning",
80        }
81    }
82
83    /// Return the LSP `DiagnosticSeverity` integer for this class.
84    ///
85    /// The LSP protocol defines: 1=Error, 2=Warning, 3=Information, 4=Hint.
86    /// unsafe-review is advisory and never emits Error (1); the mapping is:
87    ///
88    /// | SARIF level | LSP severity | Meaning                          |
89    /// |-------------|--------------|----------------------------------|
90    /// | `warning`   | 2 (Warning)  | actionable — evidence missing    |
91    /// | `note`      | 3 (Information) | partial — some evidence present |
92    /// | `none`      | 4 (Hint)     | non-actionable — closed/suppressed |
93    ///
94    /// Priority is a ranking/ordering signal only and must NOT affect this
95    /// value.  Both this method and [`Self::sarif_level`] derive from the same
96    /// class table to keep SARIF and LSP in agreement.
97    pub fn lsp_severity(&self) -> usize {
98        match self.sarif_level() {
99            "warning" => 2,
100            "note" => 3,
101            // "none" and anything unexpected — informational hint, lowest severity.
102            _ => 4,
103        }
104    }
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub enum ProofPath {
109    ObservableRedGreen,
110    MutationMiriModel,
111    SourceRouteOnly,
112    HelperGated,
113    HumanReviewOnly,
114}
115
116impl ProofPath {
117    pub fn as_str(&self) -> &'static str {
118        match self {
119            Self::ObservableRedGreen => "observable_red_green",
120            Self::MutationMiriModel => "mutation_miri_model",
121            Self::SourceRouteOnly => "source_route_only",
122            Self::HelperGated => "helper_gated",
123            Self::HumanReviewOnly => "human_review_only",
124        }
125    }
126}
127
128#[derive(Clone, Debug, PartialEq, Eq)]
129pub enum Priority {
130    High,
131    Medium,
132    Low,
133}
134
135impl Priority {
136    pub fn as_str(&self) -> &'static str {
137        match self {
138            Self::High => "high",
139            Self::Medium => "medium",
140            Self::Low => "low",
141        }
142    }
143}
144
145#[derive(Clone, Debug, PartialEq, Eq)]
146pub enum Confidence {
147    High,
148    Medium,
149    Low,
150    Unknown,
151}
152
153impl Confidence {
154    pub fn as_str(&self) -> &'static str {
155        match self {
156            Self::High => "high",
157            Self::Medium => "medium",
158            Self::Low => "low",
159            Self::Unknown => "unknown",
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn review_class_strings_cover_every_variant() {
170        let cases = [
171            (ReviewClass::GuardedAndWitnessed, "guarded_and_witnessed"),
172            (ReviewClass::GuardedUnwitnessed, "guarded_unwitnessed"),
173            (ReviewClass::ContractMissing, "contract_missing"),
174            (ReviewClass::GuardMissing, "guard_missing"),
175            (ReviewClass::ReachableUnwitnessed, "reachable_unwitnessed"),
176            (ReviewClass::UnsafeUnreached, "unsafe_unreached"),
177            (ReviewClass::WitnessMismatch, "witness_mismatch"),
178            (ReviewClass::RequiresLoom, "requires_loom"),
179            (ReviewClass::RequiresSanitizer, "requires_sanitizer"),
180            (ReviewClass::RequiresKaniOrCrux, "requires_kani_or_crux"),
181            (ReviewClass::MiriUnsupported, "miri_unsupported"),
182            (ReviewClass::StaticUnknown, "static_unknown"),
183            (ReviewClass::BaselineKnown, "baseline_known"),
184            (ReviewClass::Suppressed, "suppressed"),
185        ];
186
187        for (class, expected) in cases {
188            assert_eq!(class.as_str(), expected);
189        }
190    }
191
192    #[test]
193    fn review_class_actionability_keeps_closed_outcomes_non_actionable() {
194        // WitnessMismatch is actionable: a saved receipt whose tool does not
195        // match any routed witness tool is a live "fix your receipt" condition,
196        // not a resolved state. Consistent with sarif.rs (level=warning),
197        // comment_plan/selection.rs (specific_receipt_missing bucket),
198        // action_summary.rs (next_action asks for matching receipt), and
199        // outcome/mod.rs (counted in violation list).
200        let actionable = [
201            ReviewClass::GuardedUnwitnessed,
202            ReviewClass::ContractMissing,
203            ReviewClass::GuardMissing,
204            ReviewClass::ReachableUnwitnessed,
205            ReviewClass::UnsafeUnreached,
206            ReviewClass::WitnessMismatch,
207            ReviewClass::RequiresLoom,
208            ReviewClass::RequiresSanitizer,
209            ReviewClass::RequiresKaniOrCrux,
210            ReviewClass::MiriUnsupported,
211            ReviewClass::StaticUnknown,
212        ];
213        let non_actionable = [
214            ReviewClass::GuardedAndWitnessed,
215            ReviewClass::BaselineKnown,
216            ReviewClass::Suppressed,
217        ];
218
219        for class in actionable {
220            assert!(
221                class.is_actionable(),
222                "{} should be actionable",
223                class.as_str()
224            );
225        }
226        for class in non_actionable {
227            assert!(
228                !class.is_actionable(),
229                "{} should not be actionable",
230                class.as_str()
231            );
232        }
233    }
234
235    #[test]
236    fn priority_and_confidence_strings_cover_every_variant() {
237        assert_eq!(Priority::High.as_str(), "high");
238        assert_eq!(Priority::Medium.as_str(), "medium");
239        assert_eq!(Priority::Low.as_str(), "low");
240
241        assert_eq!(Confidence::High.as_str(), "high");
242        assert_eq!(Confidence::Medium.as_str(), "medium");
243        assert_eq!(Confidence::Low.as_str(), "low");
244        assert_eq!(Confidence::Unknown.as_str(), "unknown");
245    }
246
247    #[test]
248    fn proof_path_strings_cover_every_variant() {
249        let cases = [
250            (ProofPath::ObservableRedGreen, "observable_red_green"),
251            (ProofPath::MutationMiriModel, "mutation_miri_model"),
252            (ProofPath::SourceRouteOnly, "source_route_only"),
253            (ProofPath::HelperGated, "helper_gated"),
254            (ProofPath::HumanReviewOnly, "human_review_only"),
255        ];
256
257        for (path, expected) in cases {
258            assert_eq!(path.as_str(), expected);
259        }
260    }
261
262    /// Drift-lock: pin the complete class→(sarif_level, lsp_severity) table.
263    ///
264    /// If a class changes bucket (e.g. from "warning" to "note"), this test
265    /// breaks intentionally — the owner must decide the new severity mapping
266    /// and update both this table and the doc comment on `sarif_level`.
267    ///
268    /// Advisory boundary: "warning" means the card needs attention, NOT that it
269    /// blocks a merge.  unsafe-review never emits LSP severity 1 (Error).
270    #[test]
271    fn severity_table_is_pinned_for_every_class() {
272        // (class, sarif_level, lsp_severity)
273        // lsp: 2=Warning, 3=Information, 4=Hint
274        let cases: &[(ReviewClass, &str, usize)] = &[
275            // Non-actionable — closed / administratively resolved.
276            (ReviewClass::GuardedAndWitnessed, "none", 4),
277            (ReviewClass::BaselineKnown, "none", 4),
278            (ReviewClass::Suppressed, "none", 4),
279            // Partially-resolved — some evidence present but not a full chain.
280            (ReviewClass::GuardedUnwitnessed, "note", 3),
281            (ReviewClass::UnsafeUnreached, "note", 3),
282            (ReviewClass::StaticUnknown, "note", 3),
283            // Actionable — evidence missing or receipt mismatched.
284            (ReviewClass::ContractMissing, "warning", 2),
285            (ReviewClass::GuardMissing, "warning", 2),
286            (ReviewClass::ReachableUnwitnessed, "warning", 2),
287            (ReviewClass::WitnessMismatch, "warning", 2),
288            (ReviewClass::RequiresLoom, "warning", 2),
289            (ReviewClass::RequiresSanitizer, "warning", 2),
290            (ReviewClass::RequiresKaniOrCrux, "warning", 2),
291            (ReviewClass::MiriUnsupported, "warning", 2),
292        ];
293
294        for (class, expected_sarif, expected_lsp) in cases {
295            assert_eq!(
296                class.sarif_level(),
297                *expected_sarif,
298                "sarif_level mismatch for {}",
299                class.as_str()
300            );
301            assert_eq!(
302                class.lsp_severity(),
303                *expected_lsp,
304                "lsp_severity mismatch for {}",
305                class.as_str()
306            );
307        }
308    }
309
310    /// Drift-lock: SARIF level and LSP severity must be consistent signals for
311    /// every class.  Specifically:
312    /// - "warning" → lsp 2 (Warning),
313    /// - "note"    → lsp 3 (Information),
314    /// - "none"    → lsp 4 (Hint).
315    ///
316    /// If these diverge, `severity_table_is_pinned_for_every_class` also fails,
317    /// but this test gives a targeted error message naming the broken mapping.
318    #[test]
319    fn sarif_level_and_lsp_severity_are_consistent_for_every_class() {
320        let all_classes = [
321            ReviewClass::GuardedAndWitnessed,
322            ReviewClass::GuardedUnwitnessed,
323            ReviewClass::ContractMissing,
324            ReviewClass::GuardMissing,
325            ReviewClass::ReachableUnwitnessed,
326            ReviewClass::UnsafeUnreached,
327            ReviewClass::WitnessMismatch,
328            ReviewClass::RequiresLoom,
329            ReviewClass::RequiresSanitizer,
330            ReviewClass::RequiresKaniOrCrux,
331            ReviewClass::MiriUnsupported,
332            ReviewClass::StaticUnknown,
333            ReviewClass::BaselineKnown,
334            ReviewClass::Suppressed,
335        ];
336
337        for class in &all_classes {
338            let sarif = class.sarif_level();
339            let lsp = class.lsp_severity();
340            // Advisory: lsp 1 (Error) must never appear — no blocking verdict.
341            assert!(
342                lsp >= 2,
343                "class {} produced lsp_severity {lsp} — unsafe-review must never emit Error (1)",
344                class.as_str()
345            );
346            // Consistency: the lsp bucket must match the sarif bucket.
347            let expected_lsp = match sarif {
348                "warning" => 2,
349                "note" => 3,
350                _ => 4,
351            };
352            assert_eq!(
353                lsp,
354                expected_lsp,
355                "class {} has sarif_level={sarif} but lsp_severity={lsp} (expected {expected_lsp})",
356                class.as_str()
357            );
358        }
359    }
360}