Skip to main content

lanekeep_core/
violation.rs

1//! Violations, and the order they are always reported in.
2
3use serde::{Deserialize, Serialize};
4
5use crate::fix::Fix;
6use crate::location::Location;
7use crate::rule_id::RuleId;
8use crate::severity::Severity;
9
10/// One reported problem.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct Violation {
13    /// Which rule reported it.
14    pub rule_id: RuleId,
15    /// Where it is.
16    pub location: Location,
17    /// One line saying what is wrong. Usually the rule card's message, but a rule may
18    /// substitute a more specific one for a particular match.
19    pub message: String,
20    /// What to do about it. Usually the rule card's remediation.
21    pub remediation: String,
22    /// Severity as resolved by config, not as the rule declared it.
23    pub severity: Severity,
24    /// A replacement the rule offered, if it offered one.
25    ///
26    /// Not part of the canonical sort: two violations differing only in their fix are the
27    /// same finding, and letting a fix change their order would make output depend on
28    /// something a reader cannot see.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub fix: Option<Fix>,
31}
32
33/// Sort violations into lanekeep's canonical order: `(ruleId, file, line, column)`.
34///
35/// This is part of the output contract, not a presentation choice. An agent reads
36/// lanekeep's output, changes code, and reads it again — if unrelated violations moved
37/// between the two reads, the diff implies a change that did not happen. Architecture §11
38/// states it plainly: repeated runs over identical input produce identical output.
39///
40/// The sort is total. Every field of the key is compared, so two violations can only tie
41/// if they are genuinely at the same position from the same rule, and ties keep their
42/// relative order via a stable sort. Nothing here depends on the order rules ran in,
43/// which matters because they run in parallel and that order is not reproducible.
44pub fn sort(violations: &mut [Violation]) {
45    violations.sort_by(|a, b| {
46        a.rule_id
47            .cmp(&b.rule_id)
48            .then_with(|| a.location.file.cmp(&b.location.file))
49            .then_with(|| a.location.position.line.cmp(&b.location.position.line))
50            .then_with(|| a.location.position.column.cmp(&b.location.position.column))
51    });
52}
53
54/// Whether any violation should fail the run.
55#[must_use]
56pub fn any_failing(violations: &[Violation]) -> bool {
57    violations.iter().any(|v| v.severity.is_failing())
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use crate::location::{FilePath, Position};
64
65    fn violation(rule: &str, file: &str, line: u32, column: u32) -> Violation {
66        Violation {
67            rule_id: rule.parse().expect("valid rule id"),
68            location: Location::new(FilePath::new(file), Position::new(line, column)),
69            message: "message".to_owned(),
70            remediation: "remediation".to_owned(),
71            severity: Severity::Error,
72            fix: None,
73        }
74    }
75
76    fn keys(violations: &[Violation]) -> Vec<String> {
77        violations
78            .iter()
79            .map(|v| format!("{} {}", v.rule_id, v.location))
80            .collect()
81    }
82
83    #[test]
84    fn sorts_by_rule_then_file_then_line_then_column() {
85        let mut violations = vec![
86            violation("local/b", "src/a.ts", 1, 1),
87            violation("lanekeep/a", "src/b.ts", 1, 1),
88            violation("lanekeep/a", "src/a.ts", 2, 1),
89            violation("lanekeep/a", "src/a.ts", 1, 5),
90            violation("lanekeep/a", "src/a.ts", 1, 1),
91        ];
92        sort(&mut violations);
93
94        assert_eq!(
95            keys(&violations),
96            [
97                "lanekeep/a src/a.ts:1:1",
98                "lanekeep/a src/a.ts:1:5",
99                "lanekeep/a src/a.ts:2:1",
100                "lanekeep/a src/b.ts:1:1",
101                "local/b src/a.ts:1:1",
102            ]
103        );
104    }
105
106    #[test]
107    fn line_and_column_compare_numerically() {
108        // The bug this catches: comparing rendered positions as strings puts line 10
109        // before line 9. It survives every small test corpus and appears the first time
110        // a real file has ten lines.
111        let mut violations = vec![
112            violation("local/a", "src/a.ts", 10, 1),
113            violation("local/a", "src/a.ts", 9, 1),
114            violation("local/a", "src/a.ts", 1, 10),
115            violation("local/a", "src/a.ts", 1, 9),
116        ];
117        sort(&mut violations);
118
119        let positions: Vec<String> = violations
120            .iter()
121            .map(|v| v.location.position.to_string())
122            .collect();
123        assert_eq!(positions, ["1:9", "1:10", "9:1", "10:1"]);
124    }
125
126    #[test]
127    fn sorting_is_independent_of_input_order() {
128        // Rules run in parallel, so the order violations arrive in is not reproducible.
129        // Sorting has to erase that completely or output varies run to run on unchanged
130        // input.
131        let canonical = {
132            let mut v = vec![
133                violation("lanekeep/a", "src/a.ts", 1, 1),
134                violation("lanekeep/b", "src/a.ts", 1, 1),
135                violation("local/a", "src/a.ts", 1, 1),
136                violation("local/a", "src/b.ts", 3, 7),
137            ];
138            sort(&mut v);
139            keys(&v)
140        };
141
142        // Every rotation of the input must produce the same output.
143        let base = vec![
144            violation("lanekeep/a", "src/a.ts", 1, 1),
145            violation("lanekeep/b", "src/a.ts", 1, 1),
146            violation("local/a", "src/a.ts", 1, 1),
147            violation("local/a", "src/b.ts", 3, 7),
148        ];
149        for rotation in 0..base.len() {
150            let mut rotated = base.clone();
151            rotated.rotate_left(rotation);
152            sort(&mut rotated);
153            assert_eq!(
154                keys(&rotated),
155                canonical,
156                "rotation {rotation} sorted differently"
157            );
158        }
159
160        let mut reversed = base.clone();
161        reversed.reverse();
162        sort(&mut reversed);
163        assert_eq!(keys(&reversed), canonical);
164    }
165
166    #[test]
167    fn sorting_is_idempotent() {
168        let mut violations = vec![
169            violation("local/z", "src/z.ts", 5, 5),
170            violation("lanekeep/a", "src/a.ts", 1, 1),
171        ];
172        sort(&mut violations);
173        let once = keys(&violations);
174        sort(&mut violations);
175        assert_eq!(keys(&violations), once);
176    }
177
178    #[test]
179    fn ties_keep_their_relative_order() {
180        // Two rules can report the same position. The sort is stable, so which one is
181        // listed first is at least consistent between runs rather than arbitrary.
182        let mut violations = vec![
183            Violation {
184                message: "first".to_owned(),
185                ..violation("local/a", "src/a.ts", 1, 1)
186            },
187            Violation {
188                message: "second".to_owned(),
189                ..violation("local/a", "src/a.ts", 1, 1)
190            },
191        ];
192        sort(&mut violations);
193
194        let messages: Vec<&str> = violations.iter().map(|v| v.message.as_str()).collect();
195        assert_eq!(messages, ["first", "second"]);
196    }
197
198    #[test]
199    fn handles_empty_and_single_element_input() {
200        let mut empty: Vec<Violation> = Vec::new();
201        sort(&mut empty);
202        assert!(empty.is_empty());
203
204        let mut one = vec![violation("local/a", "src/a.ts", 1, 1)];
205        sort(&mut one);
206        assert_eq!(one.len(), 1);
207    }
208
209    #[test]
210    fn only_error_severity_fails_the_run() {
211        let warn = Violation {
212            severity: Severity::Warn,
213            ..violation("local/a", "a.ts", 1, 1)
214        };
215        let error = violation("local/b", "a.ts", 1, 1);
216
217        assert!(!any_failing(&[]));
218        assert!(!any_failing(std::slice::from_ref(&warn)));
219        assert!(any_failing(std::slice::from_ref(&error)));
220        assert!(any_failing(&[warn, error]));
221    }
222
223    #[test]
224    fn round_trips_through_json() {
225        let original = violation("lanekeep/no-default-export", "src/a.ts", 3, 9);
226        let json = serde_json::to_string(&original).expect("serializes");
227        let back: Violation = serde_json::from_str(&json).expect("deserializes");
228        assert_eq!(back, original);
229    }
230}