Skip to content

Commit 679e1ea

Browse files
committed
feat(cli): 설정 기반 finding suppression 추가
config suppressions를 finding id와 filePrefix 기준으로 적용하고 suppressedByConfig summary를 노출합니다. Closes #81
1 parent 9d61e1f commit 679e1ea

13 files changed

Lines changed: 579 additions & 7 deletions

File tree

‎crates/maximus-checks/src/registry.rs‎

Lines changed: 141 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
use std::collections::HashSet;
1+
use std::collections::{BTreeSet, HashSet};
22
use std::fs;
33
use std::io;
44
use std::path::{Path, PathBuf};
55

6+
use maximus_core::config::ConfigSuppression;
7+
use maximus_core::findings::summarize_findings_with_suppressed_by_config;
68
use maximus_core::{
79
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,
1112
};
1213
use serde_json::{Map, Value};
1314

@@ -173,9 +174,20 @@ pub fn audit_project_with_config_root(
173174
};
174175
let mut outcome = run_registered_checks_with_config_root(&project, config, ignore_root)?;
175176
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+
);
176183
outcome.findings = sort_findings(&outcome.findings);
177184
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+
);
179191
let result = AuditResult {
180192
root_dir: project.root_dir.clone(),
181193
summary,
@@ -524,6 +536,130 @@ fn run_editorconfig_prettier_check_registered(
524536
run_editorconfig_prettier_check_with_ignore_root(project, &ignored_patterns, ignore_root)
525537
}
526538

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+
527663
fn apply_severity_overrides(
528664
findings: &mut [maximus_core::Finding],
529665
overrides: &std::collections::BTreeMap<String, ConfigSeverity>,

‎crates/maximus-cli/src/exit_codes.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ mod tests {
3434
info_findings: 0,
3535
fixable_findings: 0,
3636
fixes_available: 0,
37+
suppressed_by_config: 0,
3738
config_files: 1,
3839
package_count: 1,
3940
env_directories: 0,

‎crates/maximus-cli/src/fail_policy.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ mod tests {
3737
info_findings: 1,
3838
fixable_findings: 0,
3939
fixes_available: 0,
40+
suppressed_by_config: 0,
4041
config_files: 1,
4142
package_count: 1,
4243
env_directories: 0,

‎crates/maximus-cli/src/main.rs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ fn resolve_effective_config(
357357
config.ignore = scope_ignore_patterns(&config.ignore, &config_root, &ignore_root)?;
358358
config.ignore_patterns =
359359
scope_ignore_patterns(&config.ignore_patterns, &config_root, &ignore_root)?;
360+
scope_suppression_file_prefixes(&mut config.suppressions, &config_root, &ignore_root);
360361
}
361362
let mut ignore_file_patterns = load_ignore_file_pattern_sources(&ignore_root, target_dir)?;
362363
if !ignore_file_patterns.maximusignore.is_empty() {
@@ -393,6 +394,69 @@ fn resolve_effective_config(
393394
})
394395
}
395396

397+
fn scope_suppression_file_prefixes(
398+
suppressions: &mut [maximus_core::config::ConfigSuppression],
399+
config_root: &std::path::Path,
400+
ignore_root: &std::path::Path,
401+
) {
402+
for suppression in suppressions {
403+
if let Some(file_prefix) = suppression.file_prefix.as_mut() {
404+
*file_prefix = scope_suppression_file_prefix(file_prefix, config_root, ignore_root);
405+
}
406+
}
407+
}
408+
409+
fn scope_suppression_file_prefix(
410+
value: &str,
411+
config_root: &std::path::Path,
412+
ignore_root: &std::path::Path,
413+
) -> String {
414+
let normalized = normalize_suppression_file_prefix(value);
415+
if normalized.is_empty() || std::path::Path::new(&normalized).is_absolute() {
416+
return normalized;
417+
}
418+
419+
let config_relative_path = config_root.join(&normalized);
420+
relative_path_from_root(&config_relative_path, ignore_root).unwrap_or(normalized)
421+
}
422+
423+
fn normalize_suppression_file_prefix(value: &str) -> String {
424+
value
425+
.trim()
426+
.replace('\\', "/")
427+
.trim_start_matches("./")
428+
.trim_end_matches('/')
429+
.to_string()
430+
}
431+
432+
fn relative_path_from_root(path: &std::path::Path, root: &std::path::Path) -> Option<String> {
433+
if let Ok(relative) = path.strip_prefix(root) {
434+
return Some(path_to_slash_string(relative));
435+
}
436+
437+
if let (Ok(absolute_path), Ok(absolute_root)) =
438+
(std::path::absolute(path), std::path::absolute(root))
439+
{
440+
if let Ok(relative) = absolute_path.strip_prefix(absolute_root) {
441+
return Some(path_to_slash_string(relative));
442+
}
443+
}
444+
445+
if let (Ok(canonical_path), Ok(canonical_root)) =
446+
(std::fs::canonicalize(path), std::fs::canonicalize(root))
447+
{
448+
if let Ok(relative) = canonical_path.strip_prefix(canonical_root) {
449+
return Some(path_to_slash_string(relative));
450+
}
451+
}
452+
453+
None
454+
}
455+
456+
fn path_to_slash_string(path: &std::path::Path) -> String {
457+
path.to_string_lossy().replace('\\', "/")
458+
}
459+
396460
fn effective_fail_on_level(config: &MaximusConfig) -> FailOnLevel {
397461
config.report.fail_on.clone().unwrap_or_default()
398462
}

‎crates/maximus-cli/src/report_json.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ mod tests {
116116
assert_eq!(value["generator"], "maximus");
117117
assert_eq!(value["rootDir"], "/tmp/project");
118118
assert_eq!(value["summary"]["blockingFindings"], 0);
119+
assert_eq!(value["summary"]["suppressedByConfig"], 0);
119120
assert_eq!(value["structure"]["configFiles"], 1);
120121
assert!(value["findings"].as_array().is_some());
121122
}
@@ -206,6 +207,7 @@ mod tests {
206207
info_findings: 0,
207208
fixable_findings: 0,
208209
fixes_available: 0,
210+
suppressed_by_config: 0,
209211
config_files: 1,
210212
package_count: 1,
211213
env_directories: 0,

‎crates/maximus-cli/src/report_markdown.rs‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,14 @@ pub fn format_audit_report(result: &AuditResult) -> String {
1616
result.summary.warning_findings,
1717
result.summary.info_findings
1818
),
19+
];
20+
push_suppression_summary(&mut lines, result.summary.suppressed_by_config);
21+
lines.extend([
1922
format!("- Fixes available: `{}`", result.summary.fixes_available),
2023
String::new(),
2124
"## Structure".to_string(),
2225
format!("{}.", describe_structure(&result.structure)),
23-
];
26+
]);
2427

2528
if result.findings.is_empty() {
2629
lines.push(String::new());
@@ -49,10 +52,13 @@ pub fn format_doctor_report(result: &AuditResult) -> String {
4952
String::new(),
5053
format!("- Target: `{}`", display_path(&result.root_dir)),
5154
format!("- Diagnosis: `{}`", result.summary.status),
55+
];
56+
push_suppression_summary(&mut lines, result.summary.suppressed_by_config);
57+
lines.extend([
5258
format!("- Project shape: {}", describe_structure(&result.structure)),
5359
String::new(),
5460
"## Prescription".to_string(),
55-
];
61+
]);
5662

5763
let manual_findings = result
5864
.findings
@@ -176,6 +182,7 @@ pub fn format_fix_result(
176182
final_result.summary.warning_findings,
177183
final_result.summary.info_findings
178184
));
185+
push_suppression_summary(&mut lines, final_result.summary.suppressed_by_config);
179186

180187
if final_result.findings.is_empty() {
181188
lines.push(String::new());
@@ -251,6 +258,12 @@ fn format_findings(result: &AuditResult) -> Vec<String> {
251258
lines
252259
}
253260

261+
fn push_suppression_summary(lines: &mut Vec<String>, suppressed_by_config: usize) {
262+
if suppressed_by_config > 0 {
263+
lines.push(format!("- Suppressed by config: `{suppressed_by_config}`"));
264+
}
265+
}
266+
254267
fn describe_structure(structure: &StructureReport) -> String {
255268
let repo_type = if structure.is_monorepo {
256269
"monorepo"
@@ -363,6 +376,7 @@ mod tests {
363376
info_findings: 2,
364377
fixable_findings: 1,
365378
fixes_available: 1,
379+
suppressed_by_config: 0,
366380
config_files: 2,
367381
package_count: 1,
368382
env_directories: 1,
@@ -438,6 +452,21 @@ mod tests {
438452
assert!(report.contains("+API_URL="));
439453
}
440454

455+
#[test]
456+
fn markdown_reports_show_nonzero_suppressed_count() {
457+
let root_dir = PathBuf::from("/tmp/project");
458+
let mut result = sample_result(root_dir.clone());
459+
result.summary.suppressed_by_config = 2;
460+
461+
let audit_report = format_audit_report(&result);
462+
let doctor_report = format_doctor_report(&result);
463+
let fix_report = format_fix_result(false, &root_dir, &result, &[], &result, None, None);
464+
465+
assert!(audit_report.contains("- Suppressed by config: `2`"));
466+
assert!(doctor_report.contains("- Suppressed by config: `2`"));
467+
assert!(fix_report.contains("- Suppressed by config: `2`"));
468+
}
469+
441470
fn sample_result(root_dir: PathBuf) -> AuditResult {
442471
AuditResult {
443472
root_dir,
@@ -449,6 +478,7 @@ mod tests {
449478
info_findings: 0,
450479
fixable_findings: 0,
451480
fixes_available: 0,
481+
suppressed_by_config: 0,
452482
config_files: 1,
453483
package_count: 1,
454484
env_directories: 0,

‎crates/maximus-cli/src/report_sarif.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ mod tests {
277277
info_findings: 0,
278278
fixable_findings: 0,
279279
fixes_available: 0,
280+
suppressed_by_config: 0,
280281
config_files: 1,
281282
package_count: 1,
282283
env_directories: 0,
@@ -348,6 +349,7 @@ mod tests {
348349
info_findings: 0,
349350
fixable_findings: 0,
350351
fixes_available: 0,
352+
suppressed_by_config: 0,
351353
config_files: 1,
352354
package_count: 1,
353355
env_directories: 0,

0 commit comments

Comments
 (0)