From 50ae358f03fa7040e3c5764e12c6d71c3a7b4f37 Mon Sep 17 00:00:00 2001 From: rdzehtsiar <105226800+rdzehtsiar@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:44:00 +0200 Subject: [PATCH] Deduplicate no-score limitations --- src/pipeline/repo_risk_assessor.rs | 63 +++++++++++++++++++++++++++++- tests/scanner_cli.rs | 42 ++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/pipeline/repo_risk_assessor.rs b/src/pipeline/repo_risk_assessor.rs index e3add7e..97d1bcb 100644 --- a/src/pipeline/repo_risk_assessor.rs +++ b/src/pipeline/repo_risk_assessor.rs @@ -34,13 +34,13 @@ impl RepoRiskAssessor { message: "No Go file risk scores are available.", }); } - if matches!(confidence, "low" | "none") { + if scored_file_count > 0 && matches!(confidence, "low" | "none") { limitations.push(ProjectRiskLimitation { code: "low_language_coverage", message: "Only a small share of active files has Go risk scores.", }); } - if go_score_coverage.is_some_and(|coverage| coverage < 1.0) { + if scored_file_count > 0 && go_score_coverage.is_some_and(|coverage| coverage < 1.0) { limitations.push(ProjectRiskLimitation { code: "partial_go_score_coverage", message: "Some active Go files do not have persisted risk scores.", @@ -387,6 +387,10 @@ mod tests { .limitations .iter() .any(|limitation| limitation.code == "no_scored_files")); + assert!(!assessment + .limitations + .iter() + .any(|limitation| limitation.code == "low_language_coverage")); } #[test] @@ -433,6 +437,61 @@ mod tests { .any(|limitation| limitation.code == "low_language_coverage")); } + #[test] + fn limitations_are_direct_for_zero_scored_files() { + let assessment = RepoRiskAssessor::new().assess(&RepoRiskInput { + active_file_count: 3, + active_go_file_count: 2, + git_index_status: "available".to_owned(), + files: Vec::new(), + terms: Vec::new(), + }); + + assert_eq!(limitation_codes(&assessment), vec!["no_scored_files"]); + assert_eq!(assessment.confidence, "none"); + } + + #[test] + fn limitations_warn_for_partial_scored_files() { + let assessment = RepoRiskAssessor::new().assess(&RepoRiskInput { + active_file_count: 10, + active_go_file_count: 2, + git_index_status: "available".to_owned(), + files: vec![file("a.go", 0.3)], + terms: Vec::new(), + }); + + assert_eq!( + limitation_codes(&assessment), + vec!["low_language_coverage", "partial_go_score_coverage"] + ); + assert_eq!(assessment.confidence, "low"); + assert_eq!(assessment.go_score_coverage, Some(0.5)); + } + + #[test] + fn limitations_are_empty_for_healthy_go_scoring_coverage() { + let assessment = RepoRiskAssessor::new().assess(&RepoRiskInput { + active_file_count: 3, + active_go_file_count: 3, + git_index_status: "available".to_owned(), + files: vec![file("a.go", 0.3), file("b.go", 0.4), file("c.go", 0.5)], + terms: Vec::new(), + }); + + assert_eq!(limitation_codes(&assessment), Vec::<&str>::new()); + assert_eq!(assessment.confidence, "high"); + assert_eq!(assessment.go_score_coverage, Some(1.0)); + } + + fn limitation_codes(assessment: &super::RepoRiskAssessment) -> Vec<&'static str> { + assessment + .limitations + .iter() + .map(|limitation| limitation.code) + .collect() + } + fn file(relative_path: &str, score: f64) -> ProjectFileRiskInput { ProjectFileRiskInput { relative_path: relative_path.to_owned(), diff --git a/tests/scanner_cli.rs b/tests/scanner_cli.rs index 5f30e3d..63cc547 100644 --- a/tests/scanner_cli.rs +++ b/tests/scanner_cli.rs @@ -339,11 +339,40 @@ fn scan_summary_reports_no_go_coverage_and_limitation() { assert!(!stdout.contains("files_analyzed")); assert!(stdout.contains("Top Hotspots\n none")); assert!(stdout.contains(" - No Go file risk scores are available")); + assert!(!stdout.contains(" - Only a small share of active files has Go risk scores")); assert!(!stdout.contains(" - \n")); assert!(!stdout.contains(" - No Go file risk scores are available.")); assert!(!stdout.contains("Index\n .hotpath/index.sqlite")); } +#[test] +fn scan_json_reports_direct_no_score_limitation_without_overlap() { + let fixture = Fixture::new("scan-json-no-score-limitations"); + fixture.write("README.md", "hello\n"); + + let output = hotpath(&["scan", "--json"], &fixture.path); + + assert!( + output.status.success(), + "hotpath scan --json failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stderr.is_empty()); + + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + let json: Value = serde_json::from_str(&stdout).expect("stdout should be JSON"); + + assert_eq!(json["risk"]["band"], "unavailable"); + let codes = limitation_codes(&json); + assert_eq!(codes.first(), Some(&"no_scored_files")); + assert!(codes.contains(&"git_index_unavailable")); + assert!(!codes.contains(&"low_language_coverage")); + assert!(!codes.contains(&"partial_go_score_coverage")); + assert_json_has_no_empty_or_placeholder_limitations(&json); + assert_json_limitation_messages_are_sentences(&json); +} + #[test] fn hotspots_prints_ranked_go_file_table_from_completed_index() { let fixture = Fixture::new("hotspots-table"); @@ -1762,6 +1791,19 @@ fn assert_json_has_no_empty_or_placeholder_limitations(json: &Value) { } } +fn limitation_codes(json: &Value) -> Vec<&str> { + json["limitations"] + .as_array() + .expect("limitations should be an array") + .iter() + .map(|limitation| { + limitation["code"] + .as_str() + .expect("limitation code should be a string") + }) + .collect() +} + fn create_index_lock(root: &Path, contents: &str) { let index_dir = root.join(".hotpath"); fs::create_dir_all(&index_dir).expect("index dir should be created");