From 26fb80f1e1c77690018dc57298e3160132929da8 Mon Sep 17 00:00:00 2001 From: rdzehtsiar <105226800+rdzehtsiar@users.noreply.github.com> Date: Wed, 10 Jun 2026 02:36:58 +0200 Subject: [PATCH] Guarantee deterministic risk sorting --- src/pipeline/store_reducer.rs | 101 +++++++++++++++++++++++++++++++++- src/tui.rs | 27 ++++++++- 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/pipeline/store_reducer.rs b/src/pipeline/store_reducer.rs index f73442c..f0227c4 100644 --- a/src/pipeline/store_reducer.rs +++ b/src/pipeline/store_reducer.rs @@ -1950,6 +1950,7 @@ fn finalize_source_dependencies( WHERE is_active = 1 AND language_id = 'go' AND relative_path IS NOT NULL + ORDER BY relative_path ASC ", ) .map_err(StoreReducerError::WriteDatabase)?; @@ -2265,7 +2266,8 @@ fn materialize_file_facts( GROUP BY source_path ) source_out ON file_analysis.relative_path = source_out.source_path - WHERE file_analysis.is_active = 1; + WHERE file_analysis.is_active = 1 + ORDER BY file_analysis.relative_path ASC; INSERT OR REPLACE INTO stage_metadata (key, value) VALUES @@ -3835,6 +3837,50 @@ mod tests { ); } + #[test] + fn materialized_package_and_file_lists_are_path_sorted() { + let fixture = Fixture::new("deterministic-lists"); + write_fixture_file(&fixture.path, "zeta/z.go"); + write_fixture_file(&fixture.path, "alpha/a.go"); + + let (event_sender, _event_receiver) = mpsc::channel(); + let reducer = + StoreReducer::start(&fixture.path, StoreReducerOptions::default(), event_sender) + .expect("reducer should start"); + let handle = reducer.handle(); + + handle + .store_file_analysis(file_result_with_imports( + fixture.path.join("zeta/z.go"), + &[], + )) + .expect("zeta file should enqueue"); + handle + .store_file_analysis(file_result_with_imports( + fixture.path.join("alpha/a.go"), + &[], + )) + .expect("alpha file should enqueue"); + + reducer.finish().expect("reducer should finish"); + + let connection = Connection::open(fixture.db_path()).expect("db should open"); + assert_eq!( + text_rows( + &connection, + "SELECT file_path FROM source_file_packages ORDER BY rowid" + ), + vec!["alpha/a.go".to_owned(), "zeta/z.go".to_owned()] + ); + assert_eq!( + text_rows( + &connection, + "SELECT relative_path FROM file_facts ORDER BY rowid" + ), + vec!["alpha/a.go".to_owned(), "zeta/z.go".to_owned()] + ); + } + #[test] fn materializes_go_file_risk_scores_with_terms_facts_and_flags() { let fixture = Fixture::new("file-risk"); @@ -4007,6 +4053,51 @@ mod tests { ); } + #[test] + fn materialized_risk_rows_break_score_ties_by_path() { + let fixture = Fixture::new("file-risk-tie-sort"); + write_fixture_file(&fixture.path, "zeta.go"); + write_fixture_file(&fixture.path, "alpha.go"); + + let (event_sender, _event_receiver) = mpsc::channel(); + let reducer = + StoreReducer::start(&fixture.path, StoreReducerOptions::default(), event_sender) + .expect("reducer should start"); + let handle = reducer.handle(); + + handle + .store_file_analysis(go_result_with_metrics( + fixture.path.join("zeta.go"), + 100, + 10, + 3, + false, + false, + )) + .expect("zeta go file should enqueue"); + handle + .store_file_analysis(go_result_with_metrics( + fixture.path.join("alpha.go"), + 100, + 10, + 3, + false, + false, + )) + .expect("alpha go file should enqueue"); + + reducer.finish().expect("reducer should finish"); + + let connection = Connection::open(fixture.db_path()).expect("db should open"); + assert_eq!( + text_rows( + &connection, + "SELECT relative_path FROM file_risk_scores ORDER BY rank ASC", + ), + vec!["alpha.go".to_owned(), "zeta.go".to_owned()] + ); + } + #[test] fn materializes_unavailable_project_risk_without_scored_go_files() { let fixture = Fixture::new("project-risk-no-go"); @@ -4181,6 +4272,14 @@ mod tests { .expect("scalar query should run") } + fn text_rows(connection: &Connection, sql: &str) -> Vec { + let mut statement = connection.prepare(sql).expect("text rows should prepare"); + let rows = statement + .query_map([], |row| row.get(0)) + .expect("text rows should query"); + rows.map(|row| row.expect("text row should read")).collect() + } + fn scalar_f64(connection: &Connection, sql: &str) -> f64 { connection .query_row(sql, [], |row| row.get(0)) diff --git a/src/tui.rs b/src/tui.rs index c066ada..8e94f9f 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -842,7 +842,7 @@ fn load_risk_rows(connection: &Connection) -> rusqlite::Result> { FROM file_risk_scores score LEFT JOIN file_facts facts ON facts.relative_path = score.relative_path - ORDER BY score.rank ASC + ORDER BY score.score DESC, score.relative_path ASC ", )?; let rows = statement.query_map([], |row| { @@ -1635,6 +1635,31 @@ mod tests { assert_eq!(snapshot.rows[0].owners.len(), 1); } + #[test] + fn loads_risk_rows_by_score_descending_then_path_ascending() { + let fixture = Fixture::new("risk-sort"); + create_tui_db(&fixture.db_path()); + let connection = Connection::open(fixture.db_path()).expect("db should open"); + connection + .execute( + "UPDATE file_risk_scores SET rank = 1, score = 0.8, risk_10 = 8.0 WHERE relative_path = 'src/safe.go'", + [], + ) + .expect("safe row should update"); + connection + .execute( + "UPDATE file_risk_scores SET rank = 2, score = 0.8, risk_10 = 8.0 WHERE relative_path = 'src/risky.go'", + [], + ) + .expect("risky row should update"); + + let snapshot = TuiDatabaseSnapshot::load_from_dir(&fixture.path); + + assert_eq!(snapshot.rows.len(), 2); + assert_eq!(snapshot.rows[0].relative_path, "src/risky.go"); + assert_eq!(snapshot.rows[1].relative_path, "src/safe.go"); + } + #[test] fn empty_risk_tables_return_scored_go_empty_state() { let fixture = Fixture::new("empty-risk");