From 14891ff68f701bef4d951b03439751b6b67e124e Mon Sep 17 00:00:00 2001 From: orbit Date: Sun, 24 May 2026 21:41:26 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20Implement=20orbit-graph=20impact=20quer?= =?UTF-8?q?y=20(BFS=20with=20confidence=20floor=20and=E2=80=A6=20[ORB-0031?= =?UTF-8?q?6]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement orbit-graph impact query (BFS with confidence floor and 200-node cap) Planned-By: codex --- crates/orbit-graph/src/lib.rs | 84 ++--- crates/orbit-graph/src/query/callees.rs | 49 +++ crates/orbit-graph/src/query/impact.rs | 346 +++++++++++++++++++ crates/orbit-graph/src/query/mod.rs | 2 + crates/orbit-graph/src/query/refs.rs | 6 +- crates/orbit-graph/src/query/tests/impact.rs | 263 ++++++++++++++ 6 files changed, 700 insertions(+), 50 deletions(-) create mode 100644 crates/orbit-graph/src/query/callees.rs create mode 100644 crates/orbit-graph/src/query/impact.rs create mode 100644 crates/orbit-graph/src/query/tests/impact.rs diff --git a/crates/orbit-graph/src/lib.rs b/crates/orbit-graph/src/lib.rs index 257f820c..d80241ab 100644 --- a/crates/orbit-graph/src/lib.rs +++ b/crates/orbit-graph/src/lib.rs @@ -35,6 +35,12 @@ mod tests; // L-0052: FTS population invariants require a fresh DB when old indexes may be empty. pub const EXTRACTOR_VERSION: u32 = 2; +/// Default graph distance used by callers that do not supply `--depth`. +pub const DEFAULT_IMPACT_DEPTH: u8 = 3; + +/// Maximum number of impacted symbols returned by bounded traversals. +pub const IMPACT_NODE_CAP: usize = 200; + /// Opaque handle to a worktree-scoped graph database. pub struct Graph { db_path: GraphDbPath, @@ -112,50 +118,13 @@ impl Graph { /// Return outbound call edges from `sel`. pub fn callees(&self, sel: &Selector) -> Result, GraphError> { self.ensure_synced()?; - - let symbol = match resolve_symbol_span(self.db_path.path(), sel)? { - Some(s) => s, - None => return Ok(vec![]), - }; - - let conn = Connection::open(self.db_path.path()) - .map_err(|source| GraphError::sqlite("open graph database for callees", source))?; - - let mut stmt = conn - .prepare_cached( - "SELECT target_name, target_qualified, confidence, from_span_start - FROM refs - WHERE from_file = ?1 - AND from_span_start >= ?2 - AND from_span_end <= ?3 - AND kind = 'call' - ORDER BY from_span_start", - ) - .map_err(|source| GraphError::sqlite("prepare callees query", source))?; - - let edges = stmt - .query_map( - params![symbol.file_path, symbol.span_start, symbol.span_end], - |row| { - Ok(CalleeEdge { - target_name: row.get(0)?, - target_qualified: row.get(1)?, - confidence: row.get(2)?, - from_span: row.get(3)?, - }) - }, - ) - .map_err(|source| GraphError::sqlite("execute callees query", source))? - .collect::, _>>() - .map_err(|source| GraphError::sqlite("collect callees edges", source))?; - - Ok(edges) + query::callees::run(self, sel) } /// Return the bounded impact set around `sel`. pub fn impact(&self, sel: &Selector, depth: u8) -> Result { - let _ = (self, sel, depth); - todo!("query graph impact") + self.ensure_synced()?; + query::impact::run(self, sel, depth) } /// Trace the call tree rooted at a command handler. @@ -205,17 +174,20 @@ fn now_epoch_nanos(operation: &'static str) -> Result { /// Internal result of resolving a Selector to a symbol's file and span for /// span-containment queries like callees. #[derive(Debug, Clone)] -struct SymbolSpan { - file_path: String, - span_start: i64, - span_end: i64, +pub(crate) struct SymbolSpan { + pub(crate) file_path: String, + pub(crate) span_start: i64, + pub(crate) span_end: i64, } /// Resolve a Selector to a single symbol's (file_path, span) if it exists in /// the graph. Returns None for selectors that do not map to a stored symbol /// (including non-Symbol variants and unknown names). Used by read queries /// that then perform containment or adjacency lookups. -fn resolve_symbol_span(db_path: &Path, sel: &Selector) -> Result, GraphError> { +pub(crate) fn resolve_symbol_span( + db_path: &Path, + sel: &Selector, +) -> Result, GraphError> { let Selector::Symbol { path, symbol, kind } = sel else { return Ok(None); }; @@ -536,8 +508,26 @@ pub struct CalleeEdge { } /// Bounded impact result returned by [`Graph::impact`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ImpactResult; +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ImpactResult { + /// Impacted symbols in breadth-first order from the origin. + pub touched: Vec, + /// Whether traversal stopped because [`IMPACT_NODE_CAP`] was reached. + pub truncated: bool, + /// Number of impacted symbols returned in `touched`. + pub visited_nodes: usize, +} + +/// A symbol reached by [`Graph::impact`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ImpactEntry { + /// Qualified symbol name reached by the traversal. + pub qualified_name: String, + /// Breadth-first distance from the origin symbol. + pub distance: usize, + /// Edge kind used for the prior hop into this symbol. + pub edge_kind: RefKind, +} /// Command trace result returned by [`Graph::trace`]. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/orbit-graph/src/query/callees.rs b/crates/orbit-graph/src/query/callees.rs new file mode 100644 index 00000000..f5250cf5 --- /dev/null +++ b/crates/orbit-graph/src/query/callees.rs @@ -0,0 +1,49 @@ +//! Outbound call-edge query. + +use orbit_graph_extract::Selector; +use rusqlite::{Connection, params}; + +use crate::{CalleeEdge, Graph, GraphError, SymbolSpan, resolve_symbol_span}; + +pub(crate) fn run(graph: &Graph, sel: &Selector) -> Result, GraphError> { + let symbol = match resolve_symbol_span(graph.db_path.path(), sel)? { + Some(s) => s, + None => return Ok(vec![]), + }; + + let conn = Connection::open(graph.db_path.path()) + .map_err(|source| GraphError::sqlite("open graph database for callees", source))?; + edges_for_symbol(&conn, &symbol) +} + +pub(crate) fn edges_for_symbol( + conn: &Connection, + symbol: &SymbolSpan, +) -> Result, GraphError> { + let mut stmt = conn + .prepare_cached( + "SELECT target_name, target_qualified, confidence, from_span_start + FROM refs + WHERE from_file = ?1 + AND from_span_start >= ?2 + AND from_span_end <= ?3 + AND kind = 'call' + ORDER BY from_span_start, id", + ) + .map_err(|source| GraphError::sqlite("prepare callees query", source))?; + + stmt.query_map( + params![symbol.file_path, symbol.span_start, symbol.span_end], + |row| { + Ok(CalleeEdge { + target_name: row.get(0)?, + target_qualified: row.get(1)?, + confidence: row.get(2)?, + from_span: row.get(3)?, + }) + }, + ) + .map_err(|source| GraphError::sqlite("execute callees query", source))? + .collect::, _>>() + .map_err(|source| GraphError::sqlite("collect callees edges", source)) +} diff --git a/crates/orbit-graph/src/query/impact.rs b/crates/orbit-graph/src/query/impact.rs new file mode 100644 index 00000000..c6603bf8 --- /dev/null +++ b/crates/orbit-graph/src/query/impact.rs @@ -0,0 +1,346 @@ +//! Bounded blast-radius traversal. + +use std::collections::{HashSet, VecDeque}; + +use orbit_graph_extract::Selector; +use rusqlite::{Connection, OptionalExtension, params}; + +use crate::query::callees; +use crate::{ + DEFAULT_IMPACT_DEPTH, Graph, GraphError, IMPACT_NODE_CAP, ImpactEntry, ImpactResult, + RefConfidence, RefKind, RefOpts, SymbolSpan, +}; + +pub(crate) fn run(graph: &Graph, sel: &Selector, depth: u8) -> Result { + let conn = Connection::open(graph.db_path.path()) + .map_err(|source| GraphError::sqlite("open graph database for impact", source))?; + let Some(origin) = resolve_selector(&conn, sel)? else { + return Ok(empty_result()); + }; + + let max_depth = usize::from(if depth == 0 { + DEFAULT_IMPACT_DEPTH + } else { + depth + }); + let mut queue = VecDeque::from([(origin.clone(), 0usize)]); + let mut seen = HashSet::from([origin.qualified]); + let mut touched = Vec::new(); + let mut truncated = false; + + 'bfs: while let Some((symbol, distance)) = queue.pop_front() { + if distance >= max_depth { + continue; + } + let next_distance = distance + 1; + for neighbor in neighbors(&conn, &symbol)? { + if seen.contains(neighbor.qualified_name.as_str()) { + continue; + } + if touched.len() >= IMPACT_NODE_CAP { + truncated = true; + break 'bfs; + } + + seen.insert(neighbor.qualified_name.clone()); + if next_distance < max_depth + && let Some(next_symbol) = + resolve_symbol_by_qualified(&conn, neighbor.qualified_name.as_str())? + { + queue.push_back((next_symbol, next_distance)); + } + touched.push(ImpactEntry { + qualified_name: neighbor.qualified_name, + distance: next_distance, + edge_kind: neighbor.edge_kind, + }); + } + } + + Ok(ImpactResult { + visited_nodes: touched.len(), + touched, + truncated, + }) +} + +fn empty_result() -> ImpactResult { + ImpactResult { + touched: Vec::new(), + truncated: false, + visited_nodes: 0, + } +} + +fn neighbors(conn: &Connection, symbol: &ImpactSymbol) -> Result, GraphError> { + let mut neighbors = inbound_ref_neighbors(conn, symbol.qualified.as_str())?; + neighbors.extend(outbound_call_neighbors(conn, symbol)?); + neighbors.extend(relation_neighbors(conn, symbol.qualified.as_str())?); + Ok(neighbors) +} + +fn inbound_ref_neighbors( + conn: &Connection, + qualified: &str, +) -> Result, GraphError> { + let mut stmt = conn + .prepare_cached( + "SELECT ( + SELECT s.qualified + FROM symbols s + WHERE s.file_path = r.from_file + AND s.span_start <= r.from_span_start + AND s.span_end >= r.from_span_end + ORDER BY (s.span_end - s.span_start), s.id + LIMIT 1 + ) AS source_qualified, + r.kind, + r.confidence + FROM refs r + WHERE r.target_qualified = ?1 + ORDER BY r.from_file, r.from_span_start, r.id", + ) + .map_err(|source| GraphError::sqlite("prepare impact inbound refs query", source))?; + let rows = stmt + .query_map(params![qualified], |row| { + Ok(RawNeighborRow { + qualified_name: row.get(0)?, + kind: row.get(1)?, + confidence: row.get(2)?, + }) + }) + .map_err(|source| GraphError::sqlite("execute impact inbound refs query", source))? + .collect::, _>>() + .map_err(|source| GraphError::sqlite("collect impact inbound refs rows", source))?; + + rows_to_neighbors(rows) +} + +fn outbound_call_neighbors( + conn: &Connection, + symbol: &ImpactSymbol, +) -> Result, GraphError> { + let span = SymbolSpan { + file_path: symbol.file_path.clone(), + span_start: symbol.span_start, + span_end: symbol.span_end, + }; + let mut neighbors = Vec::new(); + for edge in callees::edges_for_symbol(conn, &span)? { + if !confidence_visible_at_default_floor(edge.confidence.as_str())? { + continue; + } + let Some(qualified_name) = edge.target_qualified else { + continue; + }; + neighbors.push(ImpactNeighbor { + qualified_name, + edge_kind: RefKind::Call, + }); + } + Ok(neighbors) +} + +fn relation_neighbors( + conn: &Connection, + qualified: &str, +) -> Result, GraphError> { + let mut neighbors = relation_rows( + conn, + "SELECT from_qualified, kind, confidence + FROM relations + WHERE to_qualified = ?1 + ORDER BY def_file, def_span_start, id", + qualified, + "impact inbound relations", + )?; + neighbors.extend(relation_rows( + conn, + "SELECT to_qualified, kind, confidence + FROM relations + WHERE from_qualified = ?1 + ORDER BY def_file, def_span_start, id", + qualified, + "impact outbound relations", + )?); + Ok(neighbors) +} + +fn relation_rows( + conn: &Connection, + sql: &str, + qualified: &str, + operation: &'static str, +) -> Result, GraphError> { + let mut stmt = conn + .prepare_cached(sql) + .map_err(|source| GraphError::sqlite("prepare relation rows for impact", source))?; + let rows = stmt + .query_map(params![qualified], |row| { + Ok(RawNeighborRow { + qualified_name: Some(row.get(0)?), + kind: row.get(1)?, + confidence: row.get(2)?, + }) + }) + .map_err(|source| GraphError::sqlite(operation, source))? + .collect::, _>>() + .map_err(|source| GraphError::sqlite("collect relation rows for impact", source))?; + + rows_to_neighbors(rows) +} + +fn rows_to_neighbors(rows: Vec) -> Result, GraphError> { + let mut neighbors = Vec::with_capacity(rows.len()); + for row in rows { + if !confidence_visible_at_default_floor(row.confidence.as_str())? { + continue; + } + let Some(qualified_name) = row.qualified_name else { + continue; + }; + neighbors.push(ImpactNeighbor { + qualified_name, + edge_kind: RefKind::from_db(row.kind.as_str())?, + }); + } + Ok(neighbors) +} + +fn confidence_visible_at_default_floor(confidence: &str) -> Result { + Ok(RefConfidence::from_db(confidence)?.visible_at_floor(RefOpts::default().confidence)) +} + +fn resolve_selector( + conn: &Connection, + selector: &Selector, +) -> Result, GraphError> { + match selector { + Selector::Symbol { path, symbol, kind } => { + resolve_symbol_selector(conn, path, symbol, kind) + } + Selector::Module { qualified } => resolve_module_selector(conn, qualified), + Selector::Command { name } => resolve_command_selector(conn, name), + Selector::File { .. } | Selector::Dir { .. } => Ok(None), + } +} + +fn resolve_symbol_selector( + conn: &Connection, + path: &str, + symbol: &str, + kind: &str, +) -> Result, GraphError> { + if kind.trim().is_empty() { + conn.query_row( + "SELECT file_path, qualified, span_start, span_end + FROM symbols + WHERE file_path = ?1 + AND (name = ?2 OR qualified = ?2) + ORDER BY CASE WHEN qualified = ?2 THEN 0 WHEN name = ?2 THEN 1 ELSE 2 END, id + LIMIT 1", + params![path, symbol], + impact_symbol_from_row, + ) + } else { + conn.query_row( + "SELECT file_path, qualified, span_start, span_end + FROM symbols + WHERE file_path = ?1 + AND kind = ?3 + AND (name = ?2 OR qualified = ?2) + ORDER BY CASE WHEN qualified = ?2 THEN 0 WHEN name = ?2 THEN 1 ELSE 2 END, id + LIMIT 1", + params![path, symbol, kind], + impact_symbol_from_row, + ) + } + .optional() + .map_err(|source| GraphError::sqlite("resolve impact symbol selector", source)) +} + +fn resolve_module_selector( + conn: &Connection, + qualified: &str, +) -> Result, GraphError> { + conn.query_row( + "SELECT file_path, qualified, span_start, span_end + FROM symbols + WHERE kind = 'module' + AND (qualified = ?1 OR name = ?1) + ORDER BY CASE WHEN qualified = ?1 THEN 0 WHEN name = ?1 THEN 1 ELSE 2 END, id + LIMIT 1", + params![qualified], + impact_symbol_from_row, + ) + .optional() + .map_err(|source| GraphError::sqlite("resolve impact module selector", source)) +} + +fn resolve_command_selector( + conn: &Connection, + name: &str, +) -> Result, GraphError> { + conn.query_row( + "SELECT s.file_path, s.qualified, s.span_start, s.span_end + FROM commands c + JOIN symbols s ON s.id = c.handler_symbol + WHERE c.name = ?1 + ORDER BY c.name + LIMIT 1", + params![name], + impact_symbol_from_row, + ) + .optional() + .map_err(|source| GraphError::sqlite("resolve impact command selector", source)) +} + +fn resolve_symbol_by_qualified( + conn: &Connection, + qualified: &str, +) -> Result, GraphError> { + conn.query_row( + "SELECT file_path, qualified, span_start, span_end + FROM symbols + WHERE qualified = ?1 + ORDER BY id + LIMIT 1", + params![qualified], + impact_symbol_from_row, + ) + .optional() + .map_err(|source| GraphError::sqlite("resolve impact frontier symbol", source)) +} + +fn impact_symbol_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(ImpactSymbol { + file_path: row.get(0)?, + qualified: row.get(1)?, + span_start: row.get(2)?, + span_end: row.get(3)?, + }) +} + +#[derive(Debug, Clone)] +struct ImpactSymbol { + file_path: String, + qualified: String, + span_start: i64, + span_end: i64, +} + +#[derive(Debug, Clone)] +struct ImpactNeighbor { + qualified_name: String, + edge_kind: RefKind, +} + +struct RawNeighborRow { + qualified_name: Option, + kind: String, + confidence: String, +} + +#[cfg(test)] +#[path = "tests/impact.rs"] +mod tests; diff --git a/crates/orbit-graph/src/query/mod.rs b/crates/orbit-graph/src/query/mod.rs index 5faa9217..de8a4d35 100644 --- a/crates/orbit-graph/src/query/mod.rs +++ b/crates/orbit-graph/src/query/mod.rs @@ -1,5 +1,7 @@ //! Read-only graph query implementations. +pub(crate) mod callees; +pub(crate) mod impact; pub(crate) mod refs; pub(crate) mod search; pub(crate) mod show; diff --git a/crates/orbit-graph/src/query/refs.rs b/crates/orbit-graph/src/query/refs.rs index 0d7cd44f..97958328 100644 --- a/crates/orbit-graph/src/query/refs.rs +++ b/crates/orbit-graph/src/query/refs.rs @@ -239,7 +239,7 @@ fn row_to_relation_row(row: &Row<'_>) -> rusqlite::Result { } impl RefConfidence { - fn from_db(value: &str) -> Result { + pub(crate) fn from_db(value: &str) -> Result { match value { CONFIDENCE_EXACT => Ok(Self::Exact), CONFIDENCE_IMPORT_RESOLVED => Ok(Self::ImportResolved), @@ -252,7 +252,7 @@ impl RefConfidence { } } - fn visible_at_floor(self, floor: Self) -> bool { + pub(crate) fn visible_at_floor(self, floor: Self) -> bool { self.rank() <= floor.rank() } @@ -267,7 +267,7 @@ impl RefConfidence { } impl RefKind { - fn from_db(value: &str) -> Result { + pub(crate) fn from_db(value: &str) -> Result { match value { "call" => Ok(Self::Call), "type" => Ok(Self::Type), diff --git a/crates/orbit-graph/src/query/tests/impact.rs b/crates/orbit-graph/src/query/tests/impact.rs new file mode 100644 index 00000000..4455a864 --- /dev/null +++ b/crates/orbit-graph/src/query/tests/impact.rs @@ -0,0 +1,263 @@ +use std::collections::BTreeMap; +use std::str::FromStr; +use std::time::Instant; + +use rusqlite::{Connection, params}; + +use crate::query::tests::support::{ + TestWorktree, graph_db_path, insert_file, insert_symbol, open_connection, open_graph, +}; +use crate::sync::sync_leader_count; +use crate::{IMPACT_NODE_CAP, ImpactEntry, RefKind, Selector, SyncPolicy}; + +#[test] +fn five_node_tree_at_depth_two_returns_all_five_touched_symbols() { + let worktree = TestWorktree::new("impact-tree"); + let graph = open_graph(&worktree, SyncPolicy::Manual); + let conn = open_connection(&worktree); + + seed_symbol(&conn, "src/root.rs", "root", "crate::root", 0, 100); + seed_symbol(&conn, "src/a.rs", "a", "crate::a", 0, 50); + seed_symbol(&conn, "src/b.rs", "b", "crate::b", 0, 50); + seed_symbol(&conn, "src/c.rs", "c", "crate::c", 0, 50); + seed_symbol(&conn, "src/d.rs", "d", "crate::d", 0, 50); + seed_symbol(&conn, "src/e.rs", "e", "crate::e", 0, 50); + seed_symbol(&conn, "src/fuzzy.rs", "fuzzy", "crate::fuzzy", 0, 50); + + insert_call_ref(&conn, "src/root.rs", 10, 11, "a", "crate::a", "exact"); + insert_call_ref( + &conn, + "src/root.rs", + 12, + 13, + "fuzzy", + "crate::fuzzy", + "fuzzy_name", + ); + insert_call_ref(&conn, "src/a.rs", 10, 11, "d", "crate::d", "exact"); + insert_call_ref(&conn, "src/b.rs", 10, 11, "root", "crate::root", "exact"); + insert_call_ref(&conn, "src/e.rs", 10, 11, "b", "crate::b", "exact"); + insert_relation( + &conn, + "src/c.rs", + "crate::c", + "crate::root", + "impl", + "exact", + ); + + let result = graph + .impact(&symbol_selector("src/root.rs", "root"), 2) + .expect("query impact tree"); + + assert_eq!(result.visited_nodes, 5); + assert_eq!(result.touched.len(), 5); + assert!(!result.truncated); + assert_by_distance(&result.touched); + + let by_name: BTreeMap<_, _> = result + .touched + .iter() + .map(|entry| { + ( + entry.qualified_name.as_str(), + (entry.distance, entry.edge_kind), + ) + }) + .collect(); + assert_eq!(by_name["crate::a"], (1, RefKind::Call)); + assert_eq!(by_name["crate::b"], (1, RefKind::Call)); + assert_eq!(by_name["crate::c"], (1, RefKind::Impl)); + assert_eq!(by_name["crate::d"], (2, RefKind::Call)); + assert_eq!(by_name["crate::e"], (2, RefKind::Call)); + assert!(!by_name.contains_key("crate::fuzzy")); +} + +#[test] +fn circular_reference_does_not_loop_forever() { + let worktree = TestWorktree::new("impact-cycle"); + let graph = open_graph(&worktree, SyncPolicy::Manual); + let conn = open_connection(&worktree); + + seed_symbol(&conn, "src/root.rs", "root", "crate::root", 0, 100); + seed_symbol(&conn, "src/a.rs", "a", "crate::a", 0, 100); + insert_call_ref(&conn, "src/root.rs", 10, 11, "a", "crate::a", "exact"); + insert_call_ref(&conn, "src/a.rs", 10, 11, "root", "crate::root", "exact"); + + let result = graph + .impact(&symbol_selector("src/root.rs", "root"), 10) + .expect("query impact cycle"); + + assert_eq!(result.visited_nodes, 1); + assert_eq!(result.touched[0].qualified_name, "crate::a"); + assert_eq!(result.touched[0].distance, 1); + assert!(!result.truncated); +} + +#[test] +fn synthetic_300_node_graph_caps_at_200_and_reports_truncation() { + let worktree = TestWorktree::new("impact-cap"); + let graph = open_graph(&worktree, SyncPolicy::Manual); + let conn = open_connection(&worktree); + seed_wide_callee_graph(&conn, 300); + + let result = graph + .impact(&symbol_selector("src/root.rs", "root"), 10) + .expect("query impact cap"); + + assert_eq!(result.visited_nodes, IMPACT_NODE_CAP); + assert_eq!(result.touched.len(), IMPACT_NODE_CAP); + assert!(result.truncated); + assert!(result.touched.iter().all(|entry| entry.distance == 1)); +} + +#[test] +fn impact_calls_ensure_synced_at_entry() { + let worktree = TestWorktree::new("impact-ensure-synced"); + worktree.write("src/lib.rs", "pub fn synced_root() {}\n"); + let graph = open_graph(&worktree, SyncPolicy::OnRead); + let db_path = graph_db_path(&worktree); + let selector = + Selector::from_str("symbol:src/lib.rs#synced_root:function").expect("parse selector"); + + let result = graph.impact(&selector, 1).expect("impact triggers sync"); + + assert_eq!(sync_leader_count(db_path.as_path()), 1); + assert!(result.touched.is_empty()); +} + +#[test] +fn impact_200_node_cap_performance_smoke_prints_elapsed_ms() { + let worktree = TestWorktree::new("impact-perf-cap"); + let graph = open_graph(&worktree, SyncPolicy::Manual); + let conn = open_connection(&worktree); + seed_wide_callee_graph(&conn, 300); + + let started = Instant::now(); + let result = graph + .impact(&symbol_selector("src/root.rs", "root"), 10) + .expect("query impact perf cap"); + let elapsed = started.elapsed(); + + #[allow(clippy::print_stdout)] + { + println!("impact_200_node_cap_ms={}", elapsed.as_millis()); + } + assert_eq!(result.visited_nodes, IMPACT_NODE_CAP); + assert!(result.truncated); +} + +fn seed_symbol( + conn: &Connection, + file_path: &str, + name: &str, + qualified: &str, + span_start: usize, + span_end: usize, +) { + let content = " ".repeat(span_end.max(1)); + insert_file(conn, file_path, "rust", content.as_str()); + insert_symbol( + conn, file_path, name, qualified, "function", span_start, span_end, + ); +} + +fn seed_wide_callee_graph(conn: &Connection, count: usize) { + let root_len = count * 10 + 20; + let root_content = " ".repeat(root_len); + insert_file(conn, "src/root.rs", "rust", root_content.as_str()); + insert_symbol( + conn, + "src/root.rs", + "root", + "crate::root", + "function", + 0, + root_len, + ); + + let target_content = " ".repeat(root_len); + insert_file(conn, "src/targets.rs", "rust", target_content.as_str()); + for index in 0..count { + let name = format!("target_{index:03}"); + let qualified = format!("crate::{name}"); + let span_start = index * 10; + insert_symbol( + conn, + "src/targets.rs", + name.as_str(), + qualified.as_str(), + "function", + span_start, + span_start + 5, + ); + insert_call_ref( + conn, + "src/root.rs", + span_start + 1, + span_start + 2, + name.as_str(), + qualified.as_str(), + "exact", + ); + } +} + +fn insert_call_ref( + conn: &Connection, + from_file: &str, + span_start: usize, + span_end: usize, + target_name: &str, + target_qualified: &str, + confidence: &str, +) { + conn.execute( + "INSERT INTO refs ( + from_file, from_span_start, from_span_end, target_name, target_qualified, + target_symbol_hint, kind, confidence + ) VALUES (?1, ?2, ?3, ?4, ?5, NULL, 'call', ?6)", + params![ + from_file, + i64::try_from(span_start).expect("span start fits"), + i64::try_from(span_end).expect("span end fits"), + target_name, + target_qualified, + confidence + ], + ) + .expect("insert call ref"); +} + +fn insert_relation( + conn: &Connection, + def_file: &str, + from_qualified: &str, + to_qualified: &str, + kind: &str, + confidence: &str, +) { + conn.execute( + "INSERT INTO relations ( + from_qualified, to_qualified, kind, def_file, def_span_start, def_span_end, confidence + ) VALUES (?1, ?2, ?3, ?4, 0, 1, ?5)", + params![from_qualified, to_qualified, kind, def_file, confidence], + ) + .expect("insert relation"); +} + +fn symbol_selector(file_path: &str, symbol: &str) -> Selector { + Selector::Symbol { + path: file_path.to_string(), + symbol: symbol.to_string(), + kind: "function".to_string(), + } +} + +fn assert_by_distance(entries: &[ImpactEntry]) { + assert!( + entries + .windows(2) + .all(|window| window[0].distance <= window[1].distance) + ); +}