Skip to main content

marco_core/intelligence/analysis/
lint.rs

1//! Lint orchestration for markdown analysis.
2
3use crate::intelligence::analysis::{
4    compute_diagnostics_with_options, DiagnosticCode, DiagnosticSeverity, DiagnosticsOptions,
5};
6use crate::parser::Document;
7use std::collections::BTreeMap;
8
9#[derive(Debug, Default, Clone, PartialEq, Eq)]
10pub struct LintReport {
11    pub errors: usize,
12    pub warnings: usize,
13    pub infos: usize,
14    pub hints: usize,
15}
16
17impl LintReport {
18    /// Total number of diagnostics represented by this report.
19    pub const fn total(&self) -> usize {
20        self.errors + self.warnings + self.infos + self.hints
21    }
22
23    /// True when there are no diagnostics.
24    pub const fn is_clean(&self) -> bool {
25        self.total() == 0
26    }
27
28    /// True when at least one error exists.
29    pub const fn has_errors(&self) -> bool {
30        self.errors > 0
31    }
32
33    /// True when warnings, infos, or hints are present.
34    pub const fn has_non_error_issues(&self) -> bool {
35        self.warnings > 0 || self.infos > 0 || self.hints > 0
36    }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct LintCodeBucket {
41    pub code: DiagnosticCode,
42    pub count: usize,
43}
44
45#[derive(Debug, Default, Clone, PartialEq, Eq)]
46pub struct LintDetailedReport {
47    pub summary: LintReport,
48    pub by_code: Vec<LintCodeBucket>,
49}
50
51impl LintDetailedReport {
52    /// Sum of all per-code counts.
53    pub fn total_from_buckets(&self) -> usize {
54        self.by_code.iter().map(|bucket| bucket.count).sum()
55    }
56
57    /// True when bucket counts are consistent with the summary total.
58    pub fn is_consistent(&self) -> bool {
59        self.total_from_buckets() == self.summary.total()
60    }
61}
62
63/// Compute aggregate lint counts from diagnostics.
64pub fn compute_lints(document: &Document) -> LintReport {
65    compute_lints_with_options(document, DiagnosticsOptions::all())
66}
67
68/// Compute aggregate lint counts from diagnostics using policy options.
69pub fn compute_lints_with_options(document: &Document, options: DiagnosticsOptions) -> LintReport {
70    compute_lints_detailed_with_options(document, options).summary
71}
72
73/// Compute aggregate lint counts and per-code breakdown.
74pub fn compute_lints_detailed(document: &Document) -> LintDetailedReport {
75    compute_lints_detailed_with_options(document, DiagnosticsOptions::all())
76}
77
78/// Compute aggregate lint counts and per-code breakdown with policy options.
79pub fn compute_lints_detailed_with_options(
80    document: &Document,
81    options: DiagnosticsOptions,
82) -> LintDetailedReport {
83    let mut report = LintReport::default();
84
85    // Use a deterministic key order so output is stable for tests/logging.
86    let mut counts: BTreeMap<&'static str, (DiagnosticCode, usize)> = BTreeMap::new();
87
88    for diagnostic in compute_diagnostics_with_options(document, options) {
89        match diagnostic.severity {
90            DiagnosticSeverity::Error => report.errors += 1,
91            DiagnosticSeverity::Warning => report.warnings += 1,
92            DiagnosticSeverity::Info => report.infos += 1,
93            DiagnosticSeverity::Hint => report.hints += 1,
94        }
95
96        let code_id = diagnostic.code.as_str();
97        if let Some((_code, count)) = counts.get_mut(code_id) {
98            *count += 1;
99        } else {
100            counts.insert(code_id, (diagnostic.code, 1));
101        }
102    }
103
104    let by_code = counts
105        .into_iter()
106        .map(|(_id, (code, count))| LintCodeBucket { code, count })
107        .collect();
108
109    LintDetailedReport {
110        summary: report,
111        by_code,
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::parser::{Document, Node, NodeKind, Position, Span};
119
120    fn span(line: usize, start_col: usize, end_col: usize, start_offset: usize) -> Span {
121        Span {
122            start: Position {
123                line,
124                column: start_col,
125                offset: start_offset,
126            },
127            end: Position {
128                line,
129                column: end_col,
130                offset: start_offset + (end_col.saturating_sub(start_col)),
131            },
132        }
133    }
134
135    #[test]
136    fn smoke_test_lint_report_helpers() {
137        let report = LintReport {
138            errors: 1,
139            warnings: 2,
140            infos: 3,
141            hints: 4,
142        };
143
144        assert_eq!(report.total(), 10);
145        assert!(!report.is_clean());
146        assert!(report.has_errors());
147        assert!(report.has_non_error_issues());
148    }
149
150    #[test]
151    fn smoke_test_compute_lints_detailed_groups_by_code() {
152        let doc = Document {
153            children: vec![
154                Node {
155                    kind: NodeKind::Image {
156                        url: "".to_string(),
157                        alt: "".to_string(),
158                    },
159                    span: Some(span(1, 1, 5, 0)),
160                    children: vec![],
161                },
162                Node {
163                    kind: NodeKind::Image {
164                        url: "".to_string(),
165                        alt: "image".to_string(),
166                    },
167                    span: Some(span(2, 1, 5, 10)),
168                    children: vec![],
169                },
170            ],
171            ..Default::default()
172        };
173
174        let detailed = compute_lints_detailed(&doc);
175        assert_eq!(detailed.summary.errors, 2);
176
177        let empty_image_url = detailed
178            .by_code
179            .iter()
180            .find(|bucket| bucket.code == DiagnosticCode::EmptyImageUrl)
181            .expect("Expected EmptyImageUrl bucket");
182        assert_eq!(empty_image_url.count, 2);
183    }
184
185    #[test]
186    fn smoke_test_compute_lints_with_options_respects_critical_profile() {
187        let doc = Document {
188            children: vec![Node {
189                kind: NodeKind::Paragraph,
190                span: Some(span(1, 1, 40, 0)),
191                children: vec![Node {
192                    kind: NodeKind::Link {
193                        url: ["http", "://example.com"].concat(),
194                        title: None,
195                    },
196                    span: Some(span(1, 2, 20, 1)),
197                    children: vec![],
198                }],
199            }],
200            ..Default::default()
201        };
202
203        let all = compute_lints_with_options(&doc, DiagnosticsOptions::all());
204        let critical = compute_lints_with_options(&doc, DiagnosticsOptions::critical_only());
205
206        assert!(all.infos > 0);
207        assert_eq!(critical.total(), 0);
208    }
209
210    #[test]
211    fn smoke_test_lint_detailed_report_consistency() {
212        let doc = Document {
213            children: vec![
214                Node {
215                    kind: NodeKind::Image {
216                        url: "".to_string(),
217                        alt: "".to_string(),
218                    },
219                    span: Some(span(1, 1, 5, 0)),
220                    children: vec![],
221                },
222                Node {
223                    kind: NodeKind::Paragraph,
224                    span: Some(span(2, 1, 30, 10)),
225                    children: vec![Node {
226                        kind: NodeKind::Link {
227                            url: ["http", "://example.com"].concat(),
228                            title: None,
229                        },
230                        span: Some(span(2, 2, 24, 11)),
231                        children: vec![],
232                    }],
233                },
234            ],
235            ..Default::default()
236        };
237
238        let detailed = compute_lints_detailed(&doc);
239        assert!(detailed.is_consistent());
240    }
241
242    #[test]
243    fn smoke_test_lint_code_buckets_are_sorted_by_code_id() {
244        let doc = Document {
245            children: vec![
246                Node {
247                    kind: NodeKind::Heading {
248                        level: 10,
249                        text: "".to_string(),
250                        id: None,
251                    },
252                    span: Some(span(1, 1, 2, 0)),
253                    children: vec![],
254                },
255                Node {
256                    kind: NodeKind::Image {
257                        url: "".to_string(),
258                        alt: "".to_string(),
259                    },
260                    span: Some(span(2, 1, 5, 10)),
261                    children: vec![],
262                },
263            ],
264            ..Default::default()
265        };
266
267        let detailed = compute_lints_detailed(&doc);
268        let mut previous = "";
269        for bucket in &detailed.by_code {
270            let current = bucket.code.as_str();
271            assert!(
272                previous <= current,
273                "lint buckets must be sorted by code id"
274            );
275            previous = current;
276        }
277    }
278}