From 4395d9ba3e14ab493aaca54a7e96f2c5221ff193 Mon Sep 17 00:00:00 2001 From: orbit Date: Sun, 24 May 2026 21:49:46 -0700 Subject: [PATCH] feat: Implement orbit-graph trace query (command handler call tree) [ORB-00317] Planned-By: codex --- crates/orbit-graph/src/lib.rs | 44 ++++- crates/orbit-graph/src/query/mod.rs | 1 + crates/orbit-graph/src/query/tests/trace.rs | 201 ++++++++++++++++++++ crates/orbit-graph/src/query/trace.rs | 172 +++++++++++++++++ docs/design/orbit-graph/specs/GRAPH_SPEC.md | 15 ++ 5 files changed, 429 insertions(+), 4 deletions(-) create mode 100644 crates/orbit-graph/src/query/tests/trace.rs create mode 100644 crates/orbit-graph/src/query/trace.rs diff --git a/crates/orbit-graph/src/lib.rs b/crates/orbit-graph/src/lib.rs index d80241ab..4cc9aeb1 100644 --- a/crates/orbit-graph/src/lib.rs +++ b/crates/orbit-graph/src/lib.rs @@ -38,9 +38,15 @@ pub const EXTRACTOR_VERSION: u32 = 2; /// Default graph distance used by callers that do not supply `--depth`. pub const DEFAULT_IMPACT_DEPTH: u8 = 3; +/// Default call-tree distance used by command traces when depth is omitted. +pub const DEFAULT_TRACE_DEPTH: u8 = 5; + /// Maximum number of impacted symbols returned by bounded traversals. pub const IMPACT_NODE_CAP: usize = 200; +/// Maximum number of trace nodes returned by command traces. +pub const TRACE_NODE_CAP: usize = 200; + /// Opaque handle to a worktree-scoped graph database. pub struct Graph { db_path: GraphDbPath, @@ -129,8 +135,8 @@ impl Graph { /// Trace the call tree rooted at a command handler. pub fn trace(&self, command: &str, depth: u8) -> Result { - let _ = (self, command, depth); - todo!("trace graph command") + self.ensure_synced()?; + query::trace::run(self, command, depth) } } @@ -530,5 +536,35 @@ pub struct ImpactEntry { } /// Command trace result returned by [`Graph::trace`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TraceResult; +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TraceResult { + /// Root command handler node, or `None` when the command is unknown. + pub root: Option, + /// Whether traversal stopped because [`TRACE_NODE_CAP`] was reached. + pub truncated: bool, + /// Number of nodes returned in the trace tree, including the root. + pub visited_nodes: usize, +} + +impl TraceResult { + pub(crate) fn empty() -> Self { + Self { + root: None, + truncated: false, + visited_nodes: 0, + } + } +} + +/// A node in a command-handler call tree. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TraceNode { + /// Short name as written at the call site, or the handler symbol name for the root. + pub name: String, + /// Resolved qualified symbol name when the call target was resolved. + pub qualified_name: Option, + /// Resolver confidence for the edge into this node; `None` for the root. + pub confidence: Option, + /// Nested callees reached from this symbol. + pub children: Vec, +} diff --git a/crates/orbit-graph/src/query/mod.rs b/crates/orbit-graph/src/query/mod.rs index de8a4d35..fffa32d6 100644 --- a/crates/orbit-graph/src/query/mod.rs +++ b/crates/orbit-graph/src/query/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod impact; pub(crate) mod refs; pub(crate) mod search; pub(crate) mod show; +pub(crate) mod trace; pub use search::{DEFAULT_SEARCH_LIMIT, Match, SearchKind, SearchQuery, SearchResult}; pub use show::{DEFAULT_SHOW_MAX_BYTES, NodeMetadata, NodeView, SourceSpan}; diff --git a/crates/orbit-graph/src/query/tests/trace.rs b/crates/orbit-graph/src/query/tests/trace.rs new file mode 100644 index 00000000..7bbdc060 --- /dev/null +++ b/crates/orbit-graph/src/query/tests/trace.rs @@ -0,0 +1,201 @@ +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::{SyncPolicy, TRACE_NODE_CAP, TraceNode}; + +#[test] +fn synthetic_command_with_three_level_call_tree_returns_full_tree() { + let worktree = TestWorktree::new("trace-tree"); + let graph = open_graph(&worktree, SyncPolicy::Manual); + let conn = open_connection(&worktree); + + let root_id = seed_symbol(&conn, "src/root.rs", "handler", "crate::handler"); + seed_symbol(&conn, "src/a.rs", "a", "crate::a"); + seed_symbol(&conn, "src/b.rs", "b", "crate::b"); + seed_symbol(&conn, "src/c.rs", "c", "crate::c"); + seed_symbol(&conn, "src/d.rs", "d", "crate::d"); + insert_command(&conn, "job-run", "src/root.rs", root_id); + insert_call_ref(&conn, "src/root.rs", 10, 11, "a", "crate::a"); + insert_call_ref(&conn, "src/root.rs", 20, 21, "b", "crate::b"); + insert_call_ref(&conn, "src/a.rs", 10, 11, "c", "crate::c"); + insert_call_ref(&conn, "src/c.rs", 10, 11, "d", "crate::d"); + + let result = graph + .trace("job-run", 0) + .expect("trace defaults to depth five"); + + assert_eq!(result.visited_nodes, 5); + assert!(!result.truncated); + let root = result.root.expect("trace root"); + assert_eq!(root.name, "handler"); + assert_eq!(root.qualified_name.as_deref(), Some("crate::handler")); + assert_eq!(child_names(&root), vec!["a", "b"]); + + let a = child(&root, "a"); + assert_eq!(child_names(a), vec!["c"]); + let c = child(a, "c"); + assert_eq!(child_names(c), vec!["d"]); + assert!(child(&root, "b").children.is_empty()); + assert!(child(c, "d").children.is_empty()); +} + +#[test] +fn branching_factor_five_depth_five_caps_at_200_nodes() { + let worktree = TestWorktree::new("trace-cap"); + let graph = open_graph(&worktree, SyncPolicy::Manual); + let conn = open_connection(&worktree); + seed_branching_command(&conn, 5, 5); + + let result = graph + .trace("wide-command", 5) + .expect("trace branching graph"); + + assert_eq!(result.visited_nodes, TRACE_NODE_CAP); + assert_eq!(result.root.as_ref().map(count_nodes), Some(TRACE_NODE_CAP)); + assert!(result.truncated); +} + +#[test] +fn unknown_command_returns_empty_trace_result() { + let worktree = TestWorktree::new("trace-missing"); + let graph = open_graph(&worktree, SyncPolicy::Manual); + + let result = graph + .trace("missing-command", 5) + .expect("trace missing command"); + + assert_eq!(result.visited_nodes, 0); + assert!(!result.truncated); + assert!(result.root.is_none()); +} + +#[test] +fn trace_calls_ensure_synced_at_entry() { + let worktree = TestWorktree::new("trace-ensure-synced"); + worktree.write("src/lib.rs", "pub fn synced_trace_marker() {}\n"); + let graph = open_graph(&worktree, SyncPolicy::OnRead); + let db_path = graph_db_path(&worktree); + + let result = graph + .trace("missing-after-sync", 5) + .expect("trace triggers sync"); + + assert_eq!(sync_leader_count(db_path.as_path()), 1); + assert!(result.root.is_none()); +} + +fn seed_symbol(conn: &Connection, file_path: &str, name: &str, qualified: &str) -> i64 { + let content = " ".repeat(100); + insert_file(conn, file_path, "rust", content.as_str()); + insert_symbol(conn, file_path, name, qualified, "function", 0, 100) +} + +fn insert_command(conn: &Connection, name: &str, file_path: &str, handler_symbol: i64) { + conn.execute( + "INSERT INTO commands (name, file_path, span_start, handler_symbol) + VALUES (?1, ?2, 0, ?3)", + params![name, file_path, handler_symbol], + ) + .expect("insert command row"); +} + +fn insert_call_ref( + conn: &Connection, + from_file: &str, + span_start: usize, + span_end: usize, + target_name: &str, + target_qualified: &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', 'exact')", + 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, + ], + ) + .expect("insert call ref"); +} + +fn seed_branching_command(conn: &Connection, branching_factor: usize, max_depth: usize) { + let total_nodes = (0..=max_depth).fold(0usize, |sum, depth| { + sum + branching_factor.pow(u32::try_from(depth).expect("depth fits")) + }); + let content = " ".repeat(total_nodes * 20 + 20); + insert_file(conn, "src/wide.rs", "rust", content.as_str()); + + for index in 0..total_nodes { + let span_start = index * 20; + let name = node_name(index); + let qualified = node_qualified(index); + insert_symbol( + conn, + "src/wide.rs", + name.as_str(), + qualified.as_str(), + "function", + span_start, + span_start + 10, + ); + } + + insert_command(conn, "wide-command", "src/wide.rs", 1); + + let mut parent_level = vec![0usize]; + let mut next_index = 1usize; + for _ in 0..max_depth { + let mut next_level = Vec::new(); + for parent in parent_level { + let parent_span_start = parent * 20; + for branch in 0..branching_factor { + let child_index = next_index; + next_index += 1; + next_level.push(child_index); + insert_call_ref( + conn, + "src/wide.rs", + parent_span_start + branch + 1, + parent_span_start + branch + 2, + node_name(child_index).as_str(), + node_qualified(child_index).as_str(), + ); + } + } + parent_level = next_level; + } +} + +fn node_name(index: usize) -> String { + format!("node_{index:04}") +} + +fn node_qualified(index: usize) -> String { + format!("crate::{}", node_name(index)) +} + +fn child<'a>(node: &'a TraceNode, name: &str) -> &'a TraceNode { + node.children + .iter() + .find(|child| child.name == name) + .expect("child exists") +} + +fn child_names(node: &TraceNode) -> Vec<&str> { + node.children + .iter() + .map(|child| child.name.as_str()) + .collect() +} + +fn count_nodes(node: &TraceNode) -> usize { + 1 + node.children.iter().map(count_nodes).sum::() +} diff --git a/crates/orbit-graph/src/query/trace.rs b/crates/orbit-graph/src/query/trace.rs new file mode 100644 index 00000000..e91dbf3e --- /dev/null +++ b/crates/orbit-graph/src/query/trace.rs @@ -0,0 +1,172 @@ +//! Command handler call-tree query. + +use std::collections::VecDeque; + +use rusqlite::{Connection, OptionalExtension, params}; + +use crate::query::callees; +use crate::{ + DEFAULT_TRACE_DEPTH, Graph, GraphError, SymbolSpan, TRACE_NODE_CAP, TraceNode, TraceResult, +}; + +pub(crate) fn run(graph: &Graph, command: &str, depth: u8) -> Result { + let conn = Connection::open(graph.db_path.path()) + .map_err(|source| GraphError::sqlite("open graph database for trace", source))?; + let Some(origin) = resolve_command_handler(&conn, command)? else { + return Ok(TraceResult::empty()); + }; + + let max_depth = usize::from(if depth == 0 { + DEFAULT_TRACE_DEPTH + } else { + depth + }); + let mut arena = vec![TraceNodeBuilder::root(&origin)]; + let mut queue = VecDeque::from([(0usize, origin, 0usize)]); + let mut truncated = false; + + 'bfs: while let Some((node_index, symbol, distance)) = queue.pop_front() { + if distance >= max_depth { + continue; + } + + let next_distance = distance + 1; + for edge in callees::edges_for_symbol(&conn, &symbol.span())? { + if arena.len() >= TRACE_NODE_CAP { + truncated = true; + break 'bfs; + } + + let child_symbol = match edge.target_qualified.as_deref() { + Some(qualified) => resolve_symbol_by_qualified(&conn, qualified)?, + None => None, + }; + let child = + TraceNodeBuilder::callee(edge.target_name, edge.target_qualified, edge.confidence); + let child_index = arena.len(); + arena.push(child); + arena[node_index].children.push(child_index); + + if next_distance < max_depth + && let Some(symbol) = child_symbol + { + queue.push_back((child_index, symbol, next_distance)); + } + } + } + + Ok(TraceResult { + root: Some(build_tree(&arena, 0)), + truncated, + visited_nodes: arena.len(), + }) +} + +fn resolve_command_handler( + conn: &Connection, + command: &str, +) -> Result, GraphError> { + conn.query_row( + "SELECT s.file_path, s.name, s.qualified, s.span_start, s.span_end + FROM commands c + JOIN symbols s ON s.id = c.handler_symbol + WHERE c.name = ?1 + LIMIT 1", + params![command], + trace_symbol_from_row, + ) + .optional() + .map_err(|source| GraphError::sqlite("resolve trace command handler", source)) +} + +fn resolve_symbol_by_qualified( + conn: &Connection, + qualified: &str, +) -> Result, GraphError> { + conn.query_row( + "SELECT file_path, name, qualified, span_start, span_end + FROM symbols + WHERE qualified = ?1 + ORDER BY id + LIMIT 1", + params![qualified], + trace_symbol_from_row, + ) + .optional() + .map_err(|source| GraphError::sqlite("resolve trace callee symbol", source)) +} + +fn trace_symbol_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(TraceSymbol { + file_path: row.get(0)?, + name: row.get(1)?, + qualified: row.get(2)?, + span_start: row.get(3)?, + span_end: row.get(4)?, + }) +} + +fn build_tree(arena: &[TraceNodeBuilder], index: usize) -> TraceNode { + let node = &arena[index]; + TraceNode { + name: node.name.clone(), + qualified_name: node.qualified_name.clone(), + confidence: node.confidence.clone(), + children: node + .children + .iter() + .map(|child_index| build_tree(arena, *child_index)) + .collect(), + } +} + +#[derive(Debug, Clone)] +struct TraceSymbol { + file_path: String, + name: String, + qualified: String, + span_start: i64, + span_end: i64, +} + +impl TraceSymbol { + fn span(&self) -> SymbolSpan { + SymbolSpan { + file_path: self.file_path.clone(), + span_start: self.span_start, + span_end: self.span_end, + } + } +} + +#[derive(Debug, Clone)] +struct TraceNodeBuilder { + name: String, + qualified_name: Option, + confidence: Option, + children: Vec, +} + +impl TraceNodeBuilder { + fn root(symbol: &TraceSymbol) -> Self { + Self { + name: symbol.name.clone(), + qualified_name: Some(symbol.qualified.clone()), + confidence: None, + children: Vec::new(), + } + } + + fn callee(name: String, qualified_name: Option, confidence: String) -> Self { + Self { + name, + qualified_name, + confidence: Some(confidence), + children: Vec::new(), + } + } +} + +#[cfg(test)] +#[path = "tests/trace.rs"] +mod tests; diff --git a/docs/design/orbit-graph/specs/GRAPH_SPEC.md b/docs/design/orbit-graph/specs/GRAPH_SPEC.md index c246343c..b2d217c9 100644 --- a/docs/design/orbit-graph/specs/GRAPH_SPEC.md +++ b/docs/design/orbit-graph/specs/GRAPH_SPEC.md @@ -455,6 +455,8 @@ orbit graph trace job-run Resolves command name to its handler symbol via `commands.handler_symbol`, then BFS over `callees` with depth 5. Returns the call tree as nested JSON. +Unknown command names return an empty result (`root: null`, `visited_nodes: 0`). Resolved commands return a single root node for the handler; each nested `children` list contains outbound call targets reached from that symbol. `confidence` is `null` on the root and carries the resolver confidence string on call edges. + Like `impact`, `trace` is capped at **200 visited nodes** regardless of `--depth`. When the cap fires, the response carries `truncated: true` and `visited_nodes: 200`; callers can split into multiple narrower traces (e.g. trace from a sub-handler). This keeps the response within reasonable context-window bounds — depth 5 with branching factor 5 is otherwise ~3k nodes worst-case. This is the "structural feature expansion" capability — concrete, bounded, no semantic guessing. @@ -525,6 +527,19 @@ impl Graph { pub fn trace(&self, command: &str, depth: u8) -> Result; } +pub struct TraceResult { + pub root: Option, + pub truncated: bool, + pub visited_nodes: usize, +} + +pub struct TraceNode { + pub name: String, + pub qualified_name: Option, + pub confidence: Option, + pub children: Vec, +} + pub enum SyncMode { Auto, Full } pub enum SyncPolicy { Manual, OnRead, Windowed { window: Duration } }