From cf6a77e4d90a00cb15fcb5b37c8e249815e64ce3 Mon Sep 17 00:00:00 2001 From: rdzehtsiar <105226800+rdzehtsiar@users.noreply.github.com> Date: Wed, 10 Jun 2026 02:36:54 +0200 Subject: [PATCH] Tag Go test files in scan facts --- README.md | 3 ++ src/pipeline/file_analyzer.rs | 29 +++++++++++++++++- src/pipeline/store_reducer.rs | 57 +++++++++++++++++++++++++++-------- tests/scanner_cli.rs | 53 ++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 707be25..5dbe07c 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,9 @@ language-derived metrics or Go file risk scores. Current Go processing is intentionally limited: - Go recognition is extension-based: only paths ending in `.go` are considered. +- Go files ending in `_test.go` are tagged as test files in the local index so + their churn, size, and complexity can be interpreted separately from + production source files. - Go files must be readable as UTF-8 text. - Files larger than the active content window are not parsed. The default content window is 1 MiB. diff --git a/src/pipeline/file_analyzer.rs b/src/pipeline/file_analyzer.rs index 530b3de..c235720 100644 --- a/src/pipeline/file_analyzer.rs +++ b/src/pipeline/file_analyzer.rs @@ -48,6 +48,7 @@ impl FileAnalyzer { line_count, is_generated: is_generated_path(file.path()), is_vendor: is_vendor_path(file.path()), + is_test: is_test_path(file.path()), diagnostics, parser_status: parser.status, parser_output: parser.output, @@ -142,7 +143,7 @@ impl Default for FileAnalyzerOptions { pub fn file_analyzer_options_signature(options: &FileAnalyzerOptions) -> String { format!( - "file-local-v3-source-refs;content-window={};parsers={}", + "file-local-v4-test-files;content-window={};parsers={}", options.content_window_bytes, options .parsers @@ -167,6 +168,7 @@ pub struct FileAnalysisResult { pub line_count: Option, pub is_generated: bool, pub is_vendor: bool, + pub is_test: bool, pub diagnostics: Vec, pub parser_status: FileParserStatus, pub parser_output: Option, @@ -430,6 +432,12 @@ fn is_vendor_path(path: &Path) -> bool { }) } +fn is_test_path(path: &Path) -> bool { + path.file_name() + .and_then(|file_name| file_name.to_str()) + .is_some_and(|file_name| file_name.to_ascii_lowercase().ends_with("_test.go")) +} + fn is_generated_path(path: &Path) -> bool { let file_name = path .file_name() @@ -656,6 +664,25 @@ mod tests { assert!(vendor.is_vendor); } + #[test] + fn analyzer_tags_go_test_files() { + let fixture = Fixture::new("test-file-classification"); + let test_path = fixture.write("service_test.go", b"package main\n"); + let regular_path = fixture.write("service.go", b"package main\n"); + let similarly_named_path = fixture.write("service_test.rs", b"fn main() {}\n"); + let analyzer = FileAnalyzer::new(); + + let test_file = analyzer.analyze(FileAnalysisInput { path: test_path }); + let regular_file = analyzer.analyze(FileAnalysisInput { path: regular_path }); + let similarly_named = analyzer.analyze(FileAnalysisInput { + path: similarly_named_path, + }); + + assert!(test_file.is_test); + assert!(!regular_file.is_test); + assert!(!similarly_named.is_test); + } + #[test] fn empty_parser_registry_reports_unsupported_without_attempts() { let fixture = Fixture::new("empty-parser-registry"); diff --git a/src/pipeline/store_reducer.rs b/src/pipeline/store_reducer.rs index f73442c..103324a 100644 --- a/src/pipeline/store_reducer.rs +++ b/src/pipeline/store_reducer.rs @@ -713,6 +713,7 @@ fn initialize_database(connection: &Connection) -> Result<(), StoreReducerError> line_count INTEGER, is_generated INTEGER NOT NULL, is_vendor INTEGER NOT NULL, + is_test INTEGER NOT NULL DEFAULT 0, parser_status TEXT NOT NULL, parser_recognition_attempts INTEGER NOT NULL, language_id TEXT, @@ -877,6 +878,7 @@ fn initialize_database(connection: &Connection) -> Result<(), StoreReducerError> line_count INTEGER, is_generated INTEGER NOT NULL, is_vendor INTEGER NOT NULL, + is_test INTEGER NOT NULL DEFAULT 0, parser_status TEXT NOT NULL, parser_recognition_attempts INTEGER NOT NULL, language_id TEXT, @@ -927,6 +929,7 @@ fn initialize_database(connection: &Connection) -> Result<(), StoreReducerError> risk_band TEXT NOT NULL, is_generated INTEGER NOT NULL, is_vendor INTEGER NOT NULL, + is_test INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (relative_path, formula_id) ); @@ -1045,6 +1048,12 @@ fn initialize_database(connection: &Connection) -> Result<(), StoreReducerError> "is_active", "INTEGER NOT NULL DEFAULT 1", )?; + add_column_if_missing( + connection, + "file_analysis", + "is_test", + "INTEGER NOT NULL DEFAULT 0", + )?; add_column_if_missing(connection, "file_analysis", "language_id", "TEXT")?; add_column_if_missing( connection, @@ -1088,6 +1097,12 @@ fn initialize_database(connection: &Connection) -> Result<(), StoreReducerError> "max_function_complexity", "INTEGER", )?; + add_column_if_missing( + connection, + "file_facts", + "is_test", + "INTEGER NOT NULL DEFAULT 0", + )?; add_column_if_missing(connection, "file_facts", "language_id", "TEXT")?; add_column_if_missing( connection, @@ -1128,6 +1143,12 @@ fn initialize_database(connection: &Connection) -> Result<(), StoreReducerError> )?; add_column_if_missing(connection, "file_facts", "source_coupling_in", "INTEGER")?; add_column_if_missing(connection, "file_facts", "source_coupling_out", "INTEGER")?; + add_column_if_missing( + connection, + "file_risk_scores", + "is_test", + "INTEGER NOT NULL DEFAULT 0", + )?; add_column_if_missing( connection, "git_chunks", @@ -1308,6 +1329,7 @@ fn flush_batch( line_count, is_generated, is_vendor, + is_test, parser_status, parser_recognition_attempts, language_id, @@ -1319,7 +1341,7 @@ fn flush_batch( cognitive_complexity, max_function_complexity, diagnostics - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22) + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23) ", ) .map_err(StoreReducerError::WriteDatabase)?; @@ -1343,6 +1365,7 @@ fn flush_batch( result.line_count.map(|value| value as i64), bool_to_i64(result.is_generated), bool_to_i64(result.is_vendor), + bool_to_i64(result.is_test), parser_status_name(result.parser_status), result.parser_recognition_attempts as i64, result.language_id.as_deref(), @@ -2176,6 +2199,7 @@ fn materialize_file_facts( line_count, is_generated, is_vendor, + is_test, parser_status, parser_recognition_attempts, language_id, @@ -2216,6 +2240,7 @@ fn materialize_file_facts( file_analysis.line_count, file_analysis.is_generated, file_analysis.is_vendor, + file_analysis.is_test, file_analysis.parser_status, file_analysis.parser_recognition_attempts, file_analysis.language_id, @@ -2283,6 +2308,7 @@ struct FileRiskRow { active_scan_id: i64, is_generated: bool, is_vendor: bool, + is_test: bool, input: FileRiskInput, assessment: FileRiskAssessment, } @@ -2303,6 +2329,7 @@ fn materialize_file_risk_scores( line_count, is_generated, is_vendor, + is_test, total_churn_lines, recent_churn_lines, owner_count, @@ -2327,16 +2354,16 @@ fn materialize_file_risk_scores( relative_path: relative_path.clone(), line_count: optional_i64_to_u64(row.get::<_, Option>(4)?), byte_size: optional_i64_to_u64(row.get::<_, Option>(3)?), - total_churn_lines: i64_to_u64(row.get::<_, i64>(7)?), - recent_churn_lines: i64_to_u64(row.get::<_, i64>(8)?), - owner_count: optional_i64_to_u64(row.get::<_, Option>(9)?), - dominant_owner_share: row.get::<_, Option>(10)?, - co_changed_file_count: i64_to_u64(row.get::<_, i64>(11)?), - file_age_days: optional_i64_to_u64(row.get::<_, Option>(12)?), - source_coupling_in: optional_i64_to_u64(row.get::<_, Option>(13)?), - source_coupling_out: optional_i64_to_u64(row.get::<_, Option>(14)?), - cognitive_complexity: optional_i64_to_u64(row.get::<_, Option>(15)?), - max_function_complexity: optional_i64_to_u64(row.get::<_, Option>(16)?), + total_churn_lines: i64_to_u64(row.get::<_, i64>(8)?), + recent_churn_lines: i64_to_u64(row.get::<_, i64>(9)?), + owner_count: optional_i64_to_u64(row.get::<_, Option>(10)?), + dominant_owner_share: row.get::<_, Option>(11)?, + co_changed_file_count: i64_to_u64(row.get::<_, i64>(12)?), + file_age_days: optional_i64_to_u64(row.get::<_, Option>(13)?), + source_coupling_in: optional_i64_to_u64(row.get::<_, Option>(14)?), + source_coupling_out: optional_i64_to_u64(row.get::<_, Option>(15)?), + cognitive_complexity: optional_i64_to_u64(row.get::<_, Option>(16)?), + max_function_complexity: optional_i64_to_u64(row.get::<_, Option>(17)?), }; Ok(FileRiskRow { path: row.get(0)?, @@ -2344,6 +2371,7 @@ fn materialize_file_risk_scores( active_scan_id: row.get(2)?, is_generated: row.get::<_, i64>(5)? != 0, is_vendor: row.get::<_, i64>(6)? != 0, + is_test: row.get::<_, i64>(7)? != 0, input, assessment: FileRiskAssessment { formula_id: FORMULA_ID, @@ -2397,8 +2425,9 @@ fn materialize_file_risk_scores( risk_10, risk_band, is_generated, - is_vendor - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + is_vendor, + is_test + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) ", ) .map_err(StoreReducerError::WriteDatabase)?; @@ -2458,6 +2487,7 @@ fn materialize_file_risk_scores( risk_row.assessment.risk_band, bool_to_i64(risk_row.is_generated), bool_to_i64(risk_row.is_vendor), + bool_to_i64(risk_row.is_test), ]) .map_err(StoreReducerError::WriteDatabase)?; @@ -4095,6 +4125,7 @@ mod tests { line_count: Some(1), is_generated: false, is_vendor: false, + is_test: false, diagnostics: Vec::new(), parser_status: FileParserStatus::Unsupported, parser_output: None, diff --git a/tests/scanner_cli.rs b/tests/scanner_cli.rs index 2d7e035..05564c4 100644 --- a/tests/scanner_cli.rs +++ b/tests/scanner_cli.rs @@ -87,6 +87,53 @@ fn scan_prints_file_and_git_progress_summary() { assert_eq!(row_count(&connection, "file_analysis"), 2); } +#[test] +fn scan_tags_go_test_files_in_index_facts_and_risk_rows() { + let fixture = Fixture::new("scan-go-test-files"); + fixture.write("service.go", "package main\n\nfunc Service() {}\n"); + fixture.write("service_test.go", "package main\n\nfunc TestService() {}\n"); + + let output = hotpath(&["scan"], &fixture.path); + + assert!( + output.status.success(), + "hotpath failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let connection = + Connection::open(fixture.path.join(".hotpath").join("index.sqlite")).expect("db opens"); + assert_eq!( + scalar_i64( + &connection, + "SELECT is_test FROM file_analysis WHERE relative_path = 'service_test.go'", + ), + 1 + ); + assert_eq!( + scalar_i64( + &connection, + "SELECT is_test FROM file_facts WHERE relative_path = 'service_test.go'", + ), + 1 + ); + assert_eq!( + scalar_i64( + &connection, + "SELECT is_test FROM file_risk_scores WHERE relative_path = 'service_test.go'", + ), + 1 + ); + assert_eq!( + scalar_i64( + &connection, + "SELECT is_test FROM file_facts WHERE relative_path = 'service.go'", + ), + 0 + ); +} + #[test] fn scan_respects_ignore_rules_in_file_count() { let fixture = GitFixture::new("scan-ignore"); @@ -329,3 +376,9 @@ fn scalar_text(connection: &Connection, sql: &str) -> String { .query_row(sql, [], |row| row.get(0)) .expect("scalar query should run") } + +fn scalar_i64(connection: &Connection, sql: &str) -> i64 { + connection + .query_row(sql, [], |row| row.get(0)) + .expect("scalar query should run") +}