|
1 | | -use std::collections::HashSet; |
| 1 | +use std::collections::{BTreeSet, HashSet}; |
2 | 2 | use std::fs; |
3 | 3 | use std::io; |
4 | 4 | use std::path::{Path, PathBuf}; |
5 | 5 |
|
| 6 | +use maximus_core::config::ConfigSuppression; |
| 7 | +use maximus_core::findings::summarize_findings_with_suppressed_by_config; |
6 | 8 | use maximus_core::{ |
7 | 9 | discover_project, discover_project_with_ignore_root, parse_jsonc, read_text_if_exists, |
8 | | - sort_findings, summarize_findings, unique_fixes, AuditResult, CheckFilterConfig, |
9 | | - ConfigSeverity, MaximusConfig, PlannedFix, ProjectDirectory, ProjectFile, ProjectSnapshot, |
10 | | - Severity, |
| 10 | + sort_findings, unique_fixes, AuditResult, CheckFilterConfig, ConfigSeverity, MaximusConfig, |
| 11 | + PlannedFix, ProjectDirectory, ProjectFile, ProjectSnapshot, Severity, |
11 | 12 | }; |
12 | 13 | use serde_json::{Map, Value}; |
13 | 14 |
|
@@ -173,9 +174,20 @@ pub fn audit_project_with_config_root( |
173 | 174 | }; |
174 | 175 | let mut outcome = run_registered_checks_with_config_root(&project, config, ignore_root)?; |
175 | 176 | apply_severity_overrides(&mut outcome.findings, &config.severity); |
| 177 | + let suppressed_by_config = apply_config_suppressions( |
| 178 | + &mut outcome, |
| 179 | + &config.suppressions, |
| 180 | + &project.root_dir, |
| 181 | + ignore_root, |
| 182 | + ); |
176 | 183 | outcome.findings = sort_findings(&outcome.findings); |
177 | 184 | let structure = build_structure_report(&project, &outcome.findings); |
178 | | - let summary = summarize_findings(&outcome.findings, &outcome.fixes, &structure); |
| 185 | + let summary = summarize_findings_with_suppressed_by_config( |
| 186 | + &outcome.findings, |
| 187 | + &outcome.fixes, |
| 188 | + &structure, |
| 189 | + suppressed_by_config, |
| 190 | + ); |
179 | 191 | let result = AuditResult { |
180 | 192 | root_dir: project.root_dir.clone(), |
181 | 193 | summary, |
@@ -524,6 +536,130 @@ fn run_editorconfig_prettier_check_registered( |
524 | 536 | run_editorconfig_prettier_check_with_ignore_root(project, &ignored_patterns, ignore_root) |
525 | 537 | } |
526 | 538 |
|
| 539 | +fn apply_config_suppressions( |
| 540 | + outcome: &mut CheckOutcome, |
| 541 | + suppressions: &[ConfigSuppression], |
| 542 | + root_dir: &Path, |
| 543 | + ignore_root: &Path, |
| 544 | +) -> usize { |
| 545 | + if suppressions.is_empty() || outcome.findings.is_empty() { |
| 546 | + return 0; |
| 547 | + } |
| 548 | + |
| 549 | + let original_count = outcome.findings.len(); |
| 550 | + outcome |
| 551 | + .findings |
| 552 | + .retain(|finding| !is_suppressed_by_config(finding, suppressions, root_dir, ignore_root)); |
| 553 | + let suppressed_count = original_count - outcome.findings.len(); |
| 554 | + |
| 555 | + if suppressed_count > 0 { |
| 556 | + let active_fix_ids = outcome |
| 557 | + .findings |
| 558 | + .iter() |
| 559 | + .flat_map(|finding| finding.fix_ids.iter().cloned()) |
| 560 | + .collect::<BTreeSet<_>>(); |
| 561 | + outcome.fixes.retain(|fix| active_fix_ids.contains(&fix.id)); |
| 562 | + outcome |
| 563 | + .planned_fixes |
| 564 | + .retain(|fix| active_fix_ids.contains(&fix.public.id)); |
| 565 | + } |
| 566 | + |
| 567 | + suppressed_count |
| 568 | +} |
| 569 | + |
| 570 | +fn is_suppressed_by_config( |
| 571 | + finding: &maximus_core::Finding, |
| 572 | + suppressions: &[ConfigSuppression], |
| 573 | + root_dir: &Path, |
| 574 | + ignore_root: &Path, |
| 575 | +) -> bool { |
| 576 | + suppressions.iter().any(|suppression| { |
| 577 | + suppression.id == finding.id |
| 578 | + && suppression_file_matches(finding, suppression, root_dir, ignore_root) |
| 579 | + }) |
| 580 | +} |
| 581 | + |
| 582 | +fn suppression_file_matches( |
| 583 | + finding: &maximus_core::Finding, |
| 584 | + suppression: &ConfigSuppression, |
| 585 | + root_dir: &Path, |
| 586 | + ignore_root: &Path, |
| 587 | +) -> bool { |
| 588 | + let Some(prefix) = suppression |
| 589 | + .file_prefix |
| 590 | + .as_deref() |
| 591 | + .and_then(normalize_file_prefix) |
| 592 | + else { |
| 593 | + return true; |
| 594 | + }; |
| 595 | + let Some(file) = finding.file.as_ref() else { |
| 596 | + return false; |
| 597 | + }; |
| 598 | + |
| 599 | + finding_file_candidates(file, &[root_dir, ignore_root]) |
| 600 | + .iter() |
| 601 | + .any(|candidate| path_matches_prefix(candidate, &prefix)) |
| 602 | +} |
| 603 | + |
| 604 | +fn finding_file_candidates(file: &Path, roots: &[&Path]) -> Vec<String> { |
| 605 | + let mut candidates = vec![path_to_slash_string(file)]; |
| 606 | + if let Ok(canonical_file) = fs::canonicalize(file) { |
| 607 | + push_unique_candidate(&mut candidates, path_to_slash_string(&canonical_file)); |
| 608 | + } |
| 609 | + |
| 610 | + for root in roots { |
| 611 | + push_relative_candidate(&mut candidates, file, root); |
| 612 | + } |
| 613 | + |
| 614 | + candidates |
| 615 | +} |
| 616 | + |
| 617 | +fn push_relative_candidate(candidates: &mut Vec<String>, file: &Path, root: &Path) { |
| 618 | + if let Ok(relative) = file.strip_prefix(root) { |
| 619 | + push_unique_candidate(candidates, path_to_slash_string(relative)); |
| 620 | + } |
| 621 | + |
| 622 | + if let (Ok(canonical_file), Ok(canonical_root)) = |
| 623 | + (fs::canonicalize(file), fs::canonicalize(root)) |
| 624 | + { |
| 625 | + if let Ok(relative) = canonical_file.strip_prefix(canonical_root) { |
| 626 | + push_unique_candidate(candidates, path_to_slash_string(relative)); |
| 627 | + } |
| 628 | + } |
| 629 | +} |
| 630 | + |
| 631 | +fn push_unique_candidate(candidates: &mut Vec<String>, candidate: String) { |
| 632 | + if !candidates.contains(&candidate) { |
| 633 | + candidates.push(candidate); |
| 634 | + } |
| 635 | +} |
| 636 | + |
| 637 | +fn normalize_file_prefix(value: &str) -> Option<String> { |
| 638 | + let normalized = value |
| 639 | + .trim() |
| 640 | + .replace('\\', "/") |
| 641 | + .trim_start_matches("./") |
| 642 | + .trim_end_matches('/') |
| 643 | + .to_string(); |
| 644 | + |
| 645 | + if normalized.is_empty() { |
| 646 | + None |
| 647 | + } else { |
| 648 | + Some(normalized) |
| 649 | + } |
| 650 | +} |
| 651 | + |
| 652 | +fn path_matches_prefix(path: &str, prefix: &str) -> bool { |
| 653 | + path == prefix |
| 654 | + || path |
| 655 | + .strip_prefix(prefix) |
| 656 | + .is_some_and(|suffix| suffix.starts_with('/')) |
| 657 | +} |
| 658 | + |
| 659 | +fn path_to_slash_string(path: &Path) -> String { |
| 660 | + path.to_string_lossy().replace('\\', "/") |
| 661 | +} |
| 662 | + |
527 | 663 | fn apply_severity_overrides( |
528 | 664 | findings: &mut [maximus_core::Finding], |
529 | 665 | overrides: &std::collections::BTreeMap<String, ConfigSeverity>, |
|
0 commit comments