Skip to main content

provable_contracts/lint/
finding.rs

1//! Lint findings — individual diagnostics emitted by `pv lint` gates.
2//!
3//! Each finding maps to a rule from the catalog and carries location
4//! and suppression metadata. Findings are the intermediate representation
5//! that feeds into all output formats (text, JSON, SARIF, GitHub).
6//!
7//! Spec: `docs/specifications/sub/lint.md` Section 4
8
9use serde::{Deserialize, Serialize};
10
11use super::rules::RuleSeverity;
12
13/// A single lint finding (diagnostic).
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct LintFinding {
16    pub rule_id: String,
17    pub severity: RuleSeverity,
18    pub message: String,
19    pub file: String,
20    pub line: Option<u32>,
21    pub contract_stem: Option<String>,
22    pub suppressed: bool,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub suppression_reason: Option<String>,
25    /// Whether this finding is new since the last lint run.
26    #[serde(default)]
27    pub is_new: bool,
28    /// Optional YAML source snippet showing the problematic line.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub snippet: Option<String>,
31    /// Suggested fix (YAML patch or instruction).
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub suggestion: Option<String>,
34    /// Structured evidence supporting the finding (counterexample data, metric values).
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub evidence: Option<String>,
37}
38
39impl LintFinding {
40    /// Create a new unsuppressed finding.
41    pub fn new(
42        rule_id: impl Into<String>,
43        severity: RuleSeverity,
44        message: impl Into<String>,
45        file: impl Into<String>,
46    ) -> Self {
47        Self {
48            rule_id: rule_id.into(),
49            severity,
50            message: message.into(),
51            file: file.into(),
52            line: None,
53            contract_stem: None,
54            suppressed: false,
55            suppression_reason: None,
56            is_new: false,
57            snippet: None,
58            suggestion: None,
59            evidence: None,
60        }
61    }
62
63    #[must_use]
64    pub fn with_line(mut self, line: u32) -> Self {
65        self.line = Some(line);
66        self
67    }
68
69    #[must_use]
70    pub fn with_stem(mut self, stem: impl Into<String>) -> Self {
71        self.contract_stem = Some(stem.into());
72        self
73    }
74
75    #[must_use]
76    pub fn with_snippet(mut self, snippet: impl Into<String>) -> Self {
77        self.snippet = Some(snippet.into());
78        self
79    }
80
81    #[must_use]
82    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
83        self.suggestion = Some(suggestion.into());
84        self
85    }
86
87    #[must_use]
88    pub fn with_evidence(mut self, evidence: impl Into<String>) -> Self {
89        self.evidence = Some(evidence.into());
90        self
91    }
92
93    #[must_use]
94    pub fn suppress(mut self, reason: impl Into<String>) -> Self {
95        self.suppressed = true;
96        self.suppression_reason = Some(reason.into());
97        self
98    }
99
100    /// Format as GitHub Actions workflow command.
101    pub fn to_github_annotation(&self) -> String {
102        let level = match self.severity {
103            RuleSeverity::Error => "error",
104            RuleSeverity::Warning => "warning",
105            RuleSeverity::Info | RuleSeverity::Off => "notice",
106        };
107        let line_part = self.line.map(|l| format!(",line={l}")).unwrap_or_default();
108        format!(
109            "::{level} file={}{line_part}::{}: {}",
110            self.file, self.rule_id, self.message
111        )
112    }
113
114    /// Fingerprint for baseline matching: (`rule_id`, file, message hash).
115    pub fn fingerprint(&self) -> String {
116        use std::collections::hash_map::DefaultHasher;
117        use std::hash::{Hash, Hasher};
118        let mut hasher = DefaultHasher::new();
119        self.rule_id.hash(&mut hasher);
120        self.file.hash(&mut hasher);
121        self.message.hash(&mut hasher);
122        format!("{}:{}:{:016x}", self.rule_id, self.file, hasher.finish())
123    }
124}
125
126/// Read a specific line from a file and return it trimmed, or None if unavailable.
127pub fn read_snippet(file: &str, line: Option<u32>) -> Option<String> {
128    let line_num = line? as usize;
129    if line_num == 0 {
130        return None;
131    }
132    let content = std::fs::read_to_string(file).ok()?;
133    let target = content.lines().nth(line_num - 1)?;
134    let trimmed = target.trim();
135    if trimmed.is_empty() {
136        None
137    } else {
138        Some(trimmed.to_string())
139    }
140}
141
142impl std::fmt::Display for LintFinding {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        let sev = match self.severity {
145            RuleSeverity::Error => "ERROR",
146            RuleSeverity::Warning => "WARN",
147            RuleSeverity::Info => "INFO",
148            RuleSeverity::Off => "OFF",
149        };
150        let suppressed = if self.suppressed { " [suppressed]" } else { "" };
151        let new_badge = if self.is_new { "  [NEW]" } else { "" };
152        write!(
153            f,
154            "[{sev}] {}: {} ({}){suppressed}{new_badge}",
155            self.rule_id, self.message, self.file
156        )
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    fn sample() -> LintFinding {
165        LintFinding::new(
166            "PV-VAL-001",
167            RuleSeverity::Error,
168            "Missing proof_obligations",
169            "contracts/example-v1.yaml",
170        )
171    }
172
173    #[test]
174    fn display_format() {
175        let f = sample();
176        let s = f.to_string();
177        assert!(s.contains("[ERROR]"));
178        assert!(s.contains("PV-VAL-001"));
179        assert!(s.contains("Missing proof_obligations"));
180        assert!(s.contains("example-v1.yaml"));
181    }
182
183    #[test]
184    fn display_suppressed() {
185        let f = sample().suppress("known gap");
186        assert!(f.to_string().contains("[suppressed]"));
187    }
188
189    #[test]
190    fn github_annotation_error() {
191        let f = sample().with_line(42);
192        let ann = f.to_github_annotation();
193        assert!(ann.starts_with("::error "));
194        assert!(ann.contains("file=contracts/example-v1.yaml"));
195        assert!(ann.contains(",line=42"));
196        assert!(ann.contains("PV-VAL-001"));
197    }
198
199    #[test]
200    fn github_annotation_warning() {
201        let f = LintFinding::new("PV-AUD-001", RuleSeverity::Warning, "test", "file.yaml");
202        assert!(f.to_github_annotation().starts_with("::warning "));
203    }
204
205    #[test]
206    fn github_annotation_no_line() {
207        let f = sample();
208        let ann = f.to_github_annotation();
209        assert!(!ann.contains(",line="));
210    }
211
212    #[test]
213    fn fingerprint_deterministic() {
214        let f = sample();
215        let fp1 = f.fingerprint();
216        let fp2 = f.fingerprint();
217        assert_eq!(fp1, fp2);
218    }
219
220    #[test]
221    fn fingerprint_differs_on_rule() {
222        let f1 = sample();
223        let f2 = LintFinding::new(
224            "PV-VAL-002",
225            RuleSeverity::Error,
226            "Missing proof_obligations",
227            "contracts/example-v1.yaml",
228        );
229        assert_ne!(f1.fingerprint(), f2.fingerprint());
230    }
231
232    #[test]
233    fn with_stem() {
234        let f = sample().with_stem("example-v1");
235        assert_eq!(f.contract_stem.as_deref(), Some("example-v1"));
236    }
237
238    #[test]
239    fn serializes_to_json() {
240        let f = sample();
241        let json = serde_json::to_string(&f).unwrap();
242        assert!(json.contains("PV-VAL-001"));
243        assert!(json.contains("error"));
244    }
245
246    #[test]
247    fn suppressed_serializes() {
248        let f = sample().suppress("reason");
249        let json = serde_json::to_string(&f).unwrap();
250        assert!(json.contains("\"suppressed\":true"));
251        assert!(json.contains("\"suppression_reason\":\"reason\""));
252    }
253
254    #[test]
255    fn is_new_defaults_to_false() {
256        let f = sample();
257        assert!(!f.is_new);
258    }
259
260    #[test]
261    fn is_new_display_badge() {
262        let mut f = sample();
263        f.is_new = true;
264        let s = f.to_string();
265        assert!(s.contains("[NEW]"));
266    }
267
268    #[test]
269    fn is_new_no_badge_when_false() {
270        let f = sample();
271        assert!(!f.to_string().contains("[NEW]"));
272    }
273
274    #[test]
275    fn is_new_deserializes_default() {
276        let json = r#"{"rule_id":"PV-VAL-001","severity":"error","message":"msg","file":"f.yaml","suppressed":false}"#;
277        let f: LintFinding = serde_json::from_str(json).unwrap();
278        assert!(!f.is_new);
279    }
280
281    #[test]
282    fn is_new_serializes_when_true() {
283        let mut f = sample();
284        f.is_new = true;
285        let json = serde_json::to_string(&f).unwrap();
286        assert!(json.contains("\"is_new\":true"));
287    }
288}