Skip to main content

mago_reporting/
baseline.rs

1//! Baseline functionality for filtering known issues.
2//!
3//! This module provides functionality to track and filter known issues using baseline files.
4//! A baseline allows teams to adopt static analysis gradually by suppressing existing issues
5//! while ensuring no new issues are introduced.
6//!
7//! Two baseline variants are supported:
8//!
9//! - **Strict**: Stores exact line numbers for each issue. Changes to line numbers require
10//!   baseline regeneration. This is the most precise variant.
11//!
12//! - **Loose**: Stores issue counts per (file, code, message) tuple. More resilient to code
13//!   changes as line number shifts don't affect the baseline. This is the default.
14//!
15//! File paths in baselines are normalized to use forward slashes for cross-platform compatibility,
16//! ensuring baselines created on Windows work on Unix systems and vice versa.
17
18use std::borrow::Cow;
19use std::collections::BTreeMap;
20
21use ahash::HashMap;
22use ahash::HashSet;
23use schemars::JsonSchema;
24use serde::Deserialize;
25use serde::Serialize;
26
27use mago_database::DatabaseReader;
28use mago_database::ReadDatabase;
29
30use crate::IssueCollection;
31
32/// The variant of baseline format to use.
33#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)]
34#[serde(rename_all = "lowercase")]
35pub enum BaselineVariant {
36    /// Strict baseline with exact line matching.
37    ///
38    /// Each issue is stored with its exact start and end line numbers.
39    /// Any change in line numbers requires baseline regeneration.
40    Strict,
41    /// Loose baseline with count-based matching.
42    ///
43    /// Issues are grouped by (file, code, message) and stored with a count.
44    /// More resilient to code changes as line shifts don't affect the baseline.
45    #[default]
46    Loose,
47}
48
49/// Represents a single issue in the strict baseline format.
50///
51/// This is a simplified representation of an issue for storage in the baseline file.
52#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord)]
53pub struct StrictBaselineIssue {
54    pub code: String,
55    pub start_line: u32,
56    pub end_line: u32,
57}
58
59/// Represents a collection of issues for a specific file path in the strict baseline.
60#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
61pub struct StrictBaselineEntry {
62    pub issues: Vec<StrictBaselineIssue>,
63}
64
65/// The strict baseline structure containing all entries organized by file path.
66///
67/// File paths are stored in a normalized format (using forward slashes)
68/// to ensure cross-platform compatibility.
69#[derive(Serialize, Deserialize, Debug, Default)]
70pub struct StrictBaseline {
71    /// The baseline variant marker. When present, indicates this is a strict baseline.
72    /// When absent (for backward compatibility), the baseline is assumed to be strict.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub variant: Option<BaselineVariant>,
75    /// The entries organized by file path.
76    pub entries: BTreeMap<Cow<'static, str>, StrictBaselineEntry>,
77}
78
79/// Represents a single issue entry in the loose baseline format.
80///
81/// Issues are grouped by (file, code, message) tuple with a count.
82#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord)]
83pub struct LooseBaselineIssue {
84    /// The normalized file path where the issues occur.
85    pub file: String,
86    /// The issue code (e.g., "missing-type-hint").
87    pub code: String,
88    /// The issue message.
89    pub message: String,
90    /// The number of occurrences of this issue.
91    pub count: u32,
92}
93
94/// The loose baseline structure with count-based issue tracking.
95#[derive(Serialize, Deserialize, Debug, Default)]
96pub struct LooseBaseline {
97    /// The baseline variant marker.
98    pub variant: BaselineVariant,
99    /// The list of issues with their counts.
100    pub issues: Vec<LooseBaselineIssue>,
101}
102
103/// A baseline that can be either strict or loose.
104#[derive(Debug)]
105pub enum Baseline {
106    /// Strict baseline with exact line matching.
107    Strict(StrictBaseline),
108    /// Loose baseline with count-based matching.
109    Loose(LooseBaseline),
110}
111
112/// The result of comparing a baseline with current issues.
113#[derive(Debug, Clone)]
114pub struct BaselineComparisonResult {
115    /// Whether the baseline is up to date with current issues
116    pub is_up_to_date: bool,
117    /// Number of new issues not in the baseline
118    pub new_issues_count: usize,
119    /// Number of issues in baseline that no longer exist
120    pub removed_issues_count: usize,
121    /// Number of files with changes (new, removed, or modified issues)
122    pub files_with_changes_count: usize,
123}
124
125/// Normalizes a file path to use forward slashes for cross-platform compatibility.
126///
127/// This ensures that baselines created on Windows work on Linux and vice versa.
128fn normalize_path(path: &str) -> String {
129    path.replace('\\', "/")
130}
131
132impl StrictBaseline {
133    /// Creates a new empty strict baseline.
134    #[must_use]
135    pub fn new() -> Self {
136        Self { variant: Some(BaselineVariant::Strict), entries: BTreeMap::new() }
137    }
138
139    /// Generates a strict baseline from a collection of issues.
140    ///
141    /// The baseline will contain all issues organized by their file paths with exact line numbers.
142    /// File paths are normalized to ensure cross-platform compatibility.
143    #[must_use]
144    pub fn generate_from_issues(issues: &IssueCollection, read_database: &ReadDatabase) -> Self {
145        let mut entries: BTreeMap<Cow<'static, str>, StrictBaselineEntry> = BTreeMap::new();
146
147        for issue in issues.iter() {
148            let Some(primary_annotation) = issue.annotations.iter().find(|a| a.is_primary()) else {
149                continue;
150            };
151
152            let Ok(file) = read_database.get(&primary_annotation.span.file_id) else {
153                continue;
154            };
155
156            let normalized_path = normalize_path(&file.name);
157            let entry = entries.entry(Cow::Owned(normalized_path)).or_default();
158
159            let start_line = file.line_number(primary_annotation.span.start.offset);
160            let end_line = file.line_number(primary_annotation.span.end.offset);
161
162            let baseline_issue = StrictBaselineIssue {
163                code: issue.code.as_ref().unwrap_or(&String::from("unknown")).clone(),
164                start_line,
165                end_line,
166            };
167
168            if !entry.issues.contains(&baseline_issue) {
169                entry.issues.push(baseline_issue);
170            }
171        }
172
173        // Sort issues within each entry for consistent output
174        for entry in entries.values_mut() {
175            entry.issues.sort();
176        }
177
178        Self { variant: Some(BaselineVariant::Strict), entries }
179    }
180
181    /// Filters an issue collection against this strict baseline.
182    ///
183    /// Returns a new issue collection containing only issues that are not in the baseline.
184    /// Issues are matched by exact file path, code, and line numbers.
185    #[must_use]
186    pub fn filter_issues(&self, issues: IssueCollection, read_database: &ReadDatabase) -> IssueCollection {
187        let mut filtered_issues = Vec::new();
188
189        for issue in issues {
190            let Some(primary_annotation) = issue.annotations.iter().find(|a| a.is_primary()) else {
191                filtered_issues.push(issue);
192                continue;
193            };
194
195            let Ok(file) = read_database.get(&primary_annotation.span.file_id) else {
196                filtered_issues.push(issue);
197                continue;
198            };
199
200            let normalized_path = normalize_path(&file.name);
201            let Some(baseline_entry) = self.entries.get(normalized_path.as_str()) else {
202                filtered_issues.push(issue);
203                continue;
204            };
205
206            let start_line = file.line_number(primary_annotation.span.start.offset);
207            let end_line = file.line_number(primary_annotation.span.end.offset);
208
209            let baseline_issue = StrictBaselineIssue {
210                code: issue.code.as_ref().unwrap_or(&String::from("unknown")).clone(),
211                start_line,
212                end_line,
213            };
214
215            if !baseline_entry.issues.contains(&baseline_issue) {
216                filtered_issues.push(issue);
217            }
218        }
219
220        IssueCollection::from(filtered_issues)
221    }
222
223    /// Compares this strict baseline with a collection of current issues.
224    ///
225    /// Returns a comparison result with statistics about differences between the baseline
226    /// and current issues.
227    #[must_use]
228    pub fn compare_with_issues(
229        &self,
230        issues: &IssueCollection,
231        read_database: &ReadDatabase,
232    ) -> BaselineComparisonResult {
233        let current_baseline = Self::generate_from_issues(issues, read_database);
234
235        // Quick check - if they're exactly the same, we're done
236        if self.entries == current_baseline.entries {
237            return BaselineComparisonResult {
238                is_up_to_date: true,
239                new_issues_count: 0,
240                removed_issues_count: 0,
241                files_with_changes_count: 0,
242            };
243        }
244
245        // Analyze what's different
246        let mut new_issues = 0;
247        let mut removed_issues = 0;
248        let mut files_with_changes = HashSet::default();
249
250        // Check for new issues (in current but not in baseline)
251        for (file_path, current_entry) in &current_baseline.entries {
252            if let Some(baseline_entry) = self.entries.get(file_path) {
253                let baseline_issues: HashSet<_> = baseline_entry.issues.iter().collect();
254                let current_issues: HashSet<_> = current_entry.issues.iter().collect();
255
256                let new_in_file = current_issues.difference(&baseline_issues).count();
257                let removed_in_file = baseline_issues.difference(&current_issues).count();
258
259                if new_in_file > 0 || removed_in_file > 0 {
260                    files_with_changes.insert(file_path.as_ref());
261                    new_issues += new_in_file;
262                    removed_issues += removed_in_file;
263                }
264            } else {
265                // Entire file is new
266                new_issues += current_entry.issues.len();
267                files_with_changes.insert(file_path.as_ref());
268            }
269        }
270
271        // Check for files that were removed entirely
272        for (file_path, baseline_entry) in &self.entries {
273            if !current_baseline.entries.contains_key(file_path) {
274                removed_issues += baseline_entry.issues.len();
275                files_with_changes.insert(file_path.as_ref());
276            }
277        }
278
279        BaselineComparisonResult {
280            is_up_to_date: false,
281            new_issues_count: new_issues,
282            removed_issues_count: removed_issues,
283            files_with_changes_count: files_with_changes.len(),
284        }
285    }
286}
287
288impl LooseBaseline {
289    /// Creates a new empty loose baseline.
290    #[must_use]
291    pub fn new() -> Self {
292        Self { variant: BaselineVariant::Loose, issues: Vec::new() }
293    }
294
295    /// Generates a loose baseline from a collection of issues.
296    ///
297    /// Issues are grouped by (file, code, message) tuple and stored with a count.
298    /// File paths are normalized to ensure cross-platform compatibility.
299    #[must_use]
300    pub fn generate_from_issues(issues: &IssueCollection, read_database: &ReadDatabase) -> Self {
301        let mut issue_counts: HashMap<(String, String, String), u32> = HashMap::default();
302
303        for issue in issues.iter() {
304            let Some(primary_annotation) = issue.annotations.iter().find(|a| a.is_primary()) else {
305                continue;
306            };
307
308            let Ok(file) = read_database.get(&primary_annotation.span.file_id) else {
309                continue;
310            };
311
312            let normalized_path = normalize_path(&file.name);
313            let code = issue.code.as_ref().unwrap_or(&String::from("unknown")).clone();
314            let message = issue.message.clone();
315
316            let key = (normalized_path, code, message);
317            *issue_counts.entry(key).or_insert(0) += 1;
318        }
319
320        let mut baseline_issues: Vec<LooseBaselineIssue> = issue_counts
321            .into_iter()
322            .map(|((file, code, message), count)| LooseBaselineIssue { file, code, message, count })
323            .collect();
324
325        baseline_issues.sort();
326
327        Self { variant: BaselineVariant::Loose, issues: baseline_issues }
328    }
329
330    /// Filters an issue collection against this loose baseline.
331    ///
332    /// Returns a new issue collection containing only issues that exceed the baseline counts.
333    /// For each (file, code, message) tuple, issues are filtered out up to the count in the baseline.
334    #[must_use]
335    pub fn filter_issues(&self, issues: IssueCollection, read_database: &ReadDatabase) -> IssueCollection {
336        let mut remaining_counts: HashMap<(String, String, String), u32> =
337            self.issues.iter().map(|i| ((i.file.clone(), i.code.clone(), i.message.clone()), i.count)).collect();
338
339        let mut filtered_issues = Vec::new();
340
341        for issue in issues {
342            let Some(primary_annotation) = issue.annotations.iter().find(|a| a.is_primary()) else {
343                filtered_issues.push(issue);
344                continue;
345            };
346
347            let Ok(file) = read_database.get(&primary_annotation.span.file_id) else {
348                filtered_issues.push(issue);
349                continue;
350            };
351
352            let normalized_path = normalize_path(&file.name);
353            let code = issue.code.as_ref().unwrap_or(&String::from("unknown")).clone();
354            let key = (normalized_path, code, issue.message.clone());
355
356            if let Some(count) = remaining_counts.get_mut(&key)
357                && *count > 0
358            {
359                *count -= 1;
360                continue;
361            }
362
363            filtered_issues.push(issue);
364        }
365
366        IssueCollection::from(filtered_issues)
367    }
368
369    /// Compares this loose baseline with a collection of current issues.
370    ///
371    /// Returns a comparison result with statistics about differences between the baseline
372    /// and current issues.
373    #[must_use]
374    pub fn compare_with_issues(
375        &self,
376        issues: &IssueCollection,
377        read_database: &ReadDatabase,
378    ) -> BaselineComparisonResult {
379        let current = Self::generate_from_issues(issues, read_database);
380
381        let current_map: HashMap<_, _> =
382            current.issues.iter().map(|i| ((i.file.clone(), i.code.clone(), i.message.clone()), i.count)).collect();
383
384        let baseline_map: HashMap<_, _> =
385            self.issues.iter().map(|i| ((i.file.clone(), i.code.clone(), i.message.clone()), i.count)).collect();
386
387        let mut new_issues = 0usize;
388        let mut removed_issues = 0usize;
389        let mut files_with_changes: HashSet<&str> = HashSet::default();
390
391        for (key, &current_count) in &current_map {
392            let baseline_count = baseline_map.get(key).copied().unwrap_or(0);
393            if current_count > baseline_count {
394                new_issues += (current_count - baseline_count) as usize;
395                files_with_changes.insert(key.0.as_str());
396            }
397        }
398
399        for (key, &baseline_count) in &baseline_map {
400            let current_count = current_map.get(key).copied().unwrap_or(0);
401            if baseline_count > current_count {
402                removed_issues += (baseline_count - current_count) as usize;
403                files_with_changes.insert(key.0.as_str());
404            }
405        }
406
407        BaselineComparisonResult {
408            is_up_to_date: new_issues == 0 && removed_issues == 0,
409            new_issues_count: new_issues,
410            removed_issues_count: removed_issues,
411            files_with_changes_count: files_with_changes.len(),
412        }
413    }
414}
415
416impl Baseline {
417    /// Generates a baseline from a collection of issues using the specified variant.
418    #[must_use]
419    pub fn generate_from_issues(
420        issues: &IssueCollection,
421        read_database: &ReadDatabase,
422        variant: BaselineVariant,
423    ) -> Self {
424        match variant {
425            BaselineVariant::Strict => Baseline::Strict(StrictBaseline::generate_from_issues(issues, read_database)),
426            BaselineVariant::Loose => Baseline::Loose(LooseBaseline::generate_from_issues(issues, read_database)),
427        }
428    }
429
430    /// Filters an issue collection against this baseline.
431    ///
432    /// Returns a new issue collection containing only issues that are not in the baseline.
433    #[must_use]
434    pub fn filter_issues(&self, issues: IssueCollection, read_database: &ReadDatabase) -> IssueCollection {
435        match self {
436            Baseline::Strict(strict) => strict.filter_issues(issues, read_database),
437            Baseline::Loose(loose) => loose.filter_issues(issues, read_database),
438        }
439    }
440
441    /// Compares this baseline with a collection of current issues.
442    ///
443    /// Returns a comparison result with statistics about differences between the baseline
444    /// and current issues.
445    #[must_use]
446    pub fn compare_with_issues(
447        &self,
448        issues: &IssueCollection,
449        read_database: &ReadDatabase,
450    ) -> BaselineComparisonResult {
451        match self {
452            Baseline::Strict(strict) => strict.compare_with_issues(issues, read_database),
453            Baseline::Loose(loose) => loose.compare_with_issues(issues, read_database),
454        }
455    }
456
457    /// Returns the variant of this baseline.
458    #[must_use]
459    pub fn variant(&self) -> BaselineVariant {
460        match self {
461            Baseline::Strict(_) => BaselineVariant::Strict,
462            Baseline::Loose(_) => BaselineVariant::Loose,
463        }
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::Annotation;
471    use crate::Issue;
472    use mago_database::Database;
473    use mago_database::file::File;
474    use mago_database::file::FileId;
475    use mago_span::Position;
476    use mago_span::Span;
477
478    fn create_test_database() -> (Database<'static>, FileId) {
479        let file =
480            File::ephemeral(Cow::Borrowed("test.php"), Cow::Borrowed("<?php\n// Line 1\n// Line 2\n// Line 3\n"));
481        let file_id = file.id;
482        let config =
483            mago_database::DatabaseConfiguration::new(std::path::Path::new("/"), vec![], vec![], vec![], vec![])
484                .into_static();
485        let db = Database::single(file, config);
486        (db, file_id)
487    }
488
489    fn create_test_issue(file_id: FileId, code: &str, start_offset: u32, end_offset: u32) -> Issue {
490        Issue::error("test error").with_code(code).with_annotation(Annotation::primary(Span::new(
491            file_id,
492            Position::new(start_offset),
493            Position::new(end_offset),
494        )))
495    }
496
497    fn create_test_issue_with_message(
498        file_id: FileId,
499        code: &str,
500        message: &str,
501        start_offset: u32,
502        end_offset: u32,
503    ) -> Issue {
504        Issue::error(message).with_code(code).with_annotation(Annotation::primary(Span::new(
505            file_id,
506            Position::new(start_offset),
507            Position::new(end_offset),
508        )))
509    }
510
511    #[test]
512    fn test_normalize_path() {
513        assert_eq!(normalize_path("foo/bar/baz.php"), "foo/bar/baz.php");
514        assert_eq!(normalize_path("foo\\bar\\baz.php"), "foo/bar/baz.php");
515        assert_eq!(normalize_path("C:\\Users\\test\\file.php"), "C:/Users/test/file.php");
516    }
517
518    #[test]
519    fn test_strict_generate_baseline_from_issues() {
520        let (db, file_id) = create_test_database();
521        let read_db = db.read_only();
522
523        let mut issues = IssueCollection::new();
524        issues.push(create_test_issue(file_id, "E001", 0, 5));
525        issues.push(create_test_issue(file_id, "E002", 10, 15));
526
527        let baseline = StrictBaseline::generate_from_issues(&issues, &read_db);
528
529        assert_eq!(baseline.variant, Some(BaselineVariant::Strict));
530        assert_eq!(baseline.entries.len(), 1);
531        let entry = baseline.entries.get("test.php").unwrap();
532        assert_eq!(entry.issues.len(), 2);
533    }
534
535    #[test]
536    fn test_strict_filter_issues() {
537        let (db, file_id) = create_test_database();
538        let read_db = db.read_only();
539
540        let mut baseline = StrictBaseline::new();
541        let mut entry = StrictBaselineEntry::default();
542        entry.issues.push(StrictBaselineIssue { code: "E001".to_string(), start_line: 0, end_line: 0 });
543        baseline.entries.insert(Cow::Borrowed("test.php"), entry);
544
545        let mut issues = IssueCollection::new();
546        issues.push(create_test_issue(file_id, "E001", 0, 5));
547        issues.push(create_test_issue(file_id, "E002", 10, 15));
548
549        let filtered = baseline.filter_issues(issues, &read_db);
550
551        assert_eq!(filtered.len(), 1);
552        assert_eq!(filtered.iter().next().unwrap().code.as_ref().unwrap(), "E002");
553    }
554
555    #[test]
556    fn test_strict_compare_baseline_with_issues() {
557        let (db, file_id) = create_test_database();
558        let read_db = db.read_only();
559
560        let mut baseline = StrictBaseline::new();
561        let mut entry = StrictBaselineEntry::default();
562        entry.issues.push(StrictBaselineIssue { code: "E001".to_string(), start_line: 0, end_line: 0 });
563        entry.issues.push(StrictBaselineIssue { code: "E003".to_string(), start_line: 2, end_line: 2 });
564        baseline.entries.insert(Cow::Borrowed("test.php"), entry);
565
566        let mut issues = IssueCollection::new();
567        issues.push(create_test_issue(file_id, "E001", 0, 5));
568        issues.push(create_test_issue(file_id, "E002", 10, 15));
569
570        let result = baseline.compare_with_issues(&issues, &read_db);
571
572        assert!(!result.is_up_to_date);
573        assert_eq!(result.removed_issues_count, 1);
574        assert_eq!(result.new_issues_count, 1);
575        assert_eq!(result.files_with_changes_count, 1);
576    }
577
578    #[test]
579    fn test_loose_generate_baseline_from_issues() {
580        let (db, file_id) = create_test_database();
581        let read_db = db.read_only();
582
583        let mut issues = IssueCollection::new();
584        issues.push(create_test_issue_with_message(file_id, "E001", "error 1", 0, 5));
585        issues.push(create_test_issue_with_message(file_id, "E001", "error 1", 10, 15));
586        issues.push(create_test_issue_with_message(file_id, "E002", "error 2", 20, 25));
587
588        let baseline = LooseBaseline::generate_from_issues(&issues, &read_db);
589
590        assert_eq!(baseline.variant, BaselineVariant::Loose);
591        assert_eq!(baseline.issues.len(), 2);
592
593        let e001 = baseline.issues.iter().find(|i| i.code == "E001").unwrap();
594        assert_eq!(e001.count, 2);
595
596        let e002 = baseline.issues.iter().find(|i| i.code == "E002").unwrap();
597        assert_eq!(e002.count, 1);
598    }
599
600    #[test]
601    fn test_loose_filter_issues() {
602        let (db, file_id) = create_test_database();
603        let read_db = db.read_only();
604
605        let baseline = LooseBaseline {
606            variant: BaselineVariant::Loose,
607            issues: vec![LooseBaselineIssue {
608                file: "test.php".to_string(),
609                code: "E001".to_string(),
610                message: "test error".to_string(),
611                count: 2,
612            }],
613        };
614
615        let mut issues = IssueCollection::new();
616        issues.push(create_test_issue(file_id, "E001", 0, 5));
617        issues.push(create_test_issue(file_id, "E001", 10, 15));
618        issues.push(create_test_issue(file_id, "E001", 20, 25));
619
620        let filtered = baseline.filter_issues(issues, &read_db);
621
622        assert_eq!(filtered.len(), 1);
623    }
624
625    #[test]
626    fn test_loose_compare_baseline_with_issues() {
627        let (db, file_id) = create_test_database();
628        let read_db = db.read_only();
629
630        let baseline = LooseBaseline {
631            variant: BaselineVariant::Loose,
632            issues: vec![
633                LooseBaselineIssue {
634                    file: "test.php".to_string(),
635                    code: "E001".to_string(),
636                    message: "test error".to_string(),
637                    count: 2,
638                },
639                LooseBaselineIssue {
640                    file: "test.php".to_string(),
641                    code: "E003".to_string(),
642                    message: "test error".to_string(),
643                    count: 1,
644                },
645            ],
646        };
647
648        let mut issues = IssueCollection::new();
649        issues.push(create_test_issue(file_id, "E001", 0, 5));
650        issues.push(create_test_issue(file_id, "E002", 10, 15));
651
652        let result = baseline.compare_with_issues(&issues, &read_db);
653
654        assert!(!result.is_up_to_date);
655        assert_eq!(result.new_issues_count, 1);
656        assert_eq!(result.removed_issues_count, 2);
657        assert_eq!(result.files_with_changes_count, 1);
658    }
659
660    #[test]
661    fn test_unified_baseline_generate_strict() {
662        let (db, file_id) = create_test_database();
663        let read_db = db.read_only();
664
665        let mut issues = IssueCollection::new();
666        issues.push(create_test_issue(file_id, "E001", 0, 5));
667
668        let baseline = Baseline::generate_from_issues(&issues, &read_db, BaselineVariant::Strict);
669
670        assert!(matches!(baseline, Baseline::Strict(_)));
671        assert_eq!(baseline.variant(), BaselineVariant::Strict);
672    }
673
674    #[test]
675    fn test_unified_baseline_generate_loose() {
676        let (db, file_id) = create_test_database();
677        let read_db = db.read_only();
678
679        let mut issues = IssueCollection::new();
680        issues.push(create_test_issue(file_id, "E001", 0, 5));
681
682        let baseline = Baseline::generate_from_issues(&issues, &read_db, BaselineVariant::Loose);
683
684        assert!(matches!(baseline, Baseline::Loose(_)));
685        assert_eq!(baseline.variant(), BaselineVariant::Loose);
686    }
687}