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