Skip to content

Commit d245d6c

Browse files
committed
feat(flow): add origin tracing for UI symbols
1 parent 0f186eb commit d245d6c

5 files changed

Lines changed: 449 additions & 2 deletions

File tree

grapha/src/main.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,20 @@ enum FlowCommands {
254254
#[arg(long, value_enum, default_value_t = QueryOutputFormat::Json)]
255255
format: QueryOutputFormat,
256256
},
257+
/// Trace backward to likely API/data origins for a UI symbol
258+
Origin {
259+
/// Symbol name or ID
260+
symbol: String,
261+
/// Maximum traversal depth
262+
#[arg(long, default_value = "10")]
263+
depth: usize,
264+
/// Project directory
265+
#[arg(short, long, default_value = ".")]
266+
path: PathBuf,
267+
/// Output format
268+
#[arg(long, value_enum, default_value_t = QueryOutputFormat::Json)]
269+
format: QueryOutputFormat,
270+
},
257271
/// List auto-detected entry points
258272
Entries {
259273
/// Project directory
@@ -1140,6 +1154,19 @@ fn handle_flow_command(
11401154
|graph| query::dataflow::query_dataflow(graph, &symbol, depth),
11411155
render::render_dataflow_with_options,
11421156
),
1157+
FlowCommands::Origin {
1158+
symbol,
1159+
depth,
1160+
path,
1161+
format,
1162+
} => handle_resolved_graph_query(
1163+
&path,
1164+
format,
1165+
render_options,
1166+
"symbol",
1167+
|graph| query::origin::query_origin(graph, &symbol, depth),
1168+
render::render_origin_with_options,
1169+
),
11431170
FlowCommands::Entries { path, format } => handle_graph_query(
11441171
&path,
11451172
format,

grapha/src/query.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub(crate) mod l10n;
99
pub mod localize;
1010
pub mod map;
1111
pub mod module_summary;
12+
pub mod origin;
1213
pub mod reverse;
1314
pub mod smells;
1415
pub mod trace;

grapha/src/query/origin.rs

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
use std::collections::{HashMap, HashSet};
2+
3+
use serde::Serialize;
4+
5+
use grapha_core::graph::{Graph, Node, NodeKind, NodeRole, TerminalKind};
6+
7+
use super::flow::{is_dataflow_edge, terminal_kind_to_string};
8+
use super::{QueryResolveError, SymbolRef, normalize_symbol_name, strip_accessor_prefix};
9+
10+
#[derive(Debug, Serialize)]
11+
pub struct OriginResult {
12+
pub symbol: String,
13+
pub origins: Vec<OriginPath>,
14+
pub total_origins: usize,
15+
#[serde(skip)]
16+
pub(crate) target_ref: SymbolRef,
17+
}
18+
19+
#[derive(Debug, Clone, Serialize)]
20+
pub struct OriginPath {
21+
pub api: SymbolRef,
22+
pub terminal_kind: String,
23+
pub path: Vec<String>,
24+
pub field_candidates: Vec<String>,
25+
pub confidence: f32,
26+
#[serde(skip_serializing_if = "Vec::is_empty")]
27+
pub notes: Vec<String>,
28+
}
29+
30+
struct StackFrame<'a> {
31+
node_id: &'a str,
32+
path_ids: Vec<&'a str>,
33+
visited: HashSet<&'a str>,
34+
}
35+
36+
fn to_symbol_ref(node: &Node) -> SymbolRef {
37+
SymbolRef::from_node(node)
38+
}
39+
40+
fn is_network_terminal(node: &Node) -> bool {
41+
matches!(
42+
node.role,
43+
Some(NodeRole::Terminal {
44+
kind: TerminalKind::Network
45+
})
46+
)
47+
}
48+
49+
fn fieldish_name(node: &Node) -> Option<String> {
50+
match node.kind {
51+
NodeKind::Property | NodeKind::Field | NodeKind::Constant => {
52+
let name = normalize_symbol_name(&node.name).trim();
53+
(!name.is_empty() && name != "body").then(|| name.to_string())
54+
}
55+
NodeKind::Function => {
56+
let stripped = strip_accessor_prefix(&node.name);
57+
let normalized = normalize_symbol_name(stripped).trim();
58+
(stripped != node.name && !normalized.is_empty() && normalized != "body")
59+
.then(|| normalized.to_string())
60+
}
61+
_ => None,
62+
}
63+
}
64+
65+
fn candidate_field_paths(path_nodes: &[&Node]) -> Vec<String> {
66+
let mut names = Vec::new();
67+
for node in path_nodes {
68+
if let Some(name) = fieldish_name(node)
69+
&& names.last() != Some(&name)
70+
{
71+
names.push(name);
72+
}
73+
}
74+
75+
let mut candidates = Vec::new();
76+
if !names.is_empty() {
77+
candidates.push(names.join("."));
78+
if names.len() > 1 {
79+
candidates.push(names[names.len() - 2..].join("."));
80+
}
81+
candidates.push(names[names.len() - 1].clone());
82+
}
83+
candidates.sort();
84+
candidates.dedup();
85+
candidates
86+
}
87+
88+
fn path_display(path_nodes: &[&Node]) -> Vec<String> {
89+
path_nodes.iter().map(|node| node.name.clone()).collect()
90+
}
91+
92+
fn confidence_for(path_nodes: &[&Node], field_candidates: &[String]) -> f32 {
93+
let mut confidence = 0.35f32;
94+
if !field_candidates.is_empty() {
95+
confidence += 0.25;
96+
}
97+
if path_nodes.len() <= 6 {
98+
confidence += 0.15;
99+
}
100+
if path_nodes
101+
.iter()
102+
.any(|node| matches!(node.kind, NodeKind::Property | NodeKind::Field))
103+
{
104+
confidence += 0.15;
105+
}
106+
if path_nodes.iter().any(|node| is_network_terminal(node)) {
107+
confidence += 0.1;
108+
}
109+
confidence.min(0.95)
110+
}
111+
112+
fn notes_for(path_nodes: &[&Node], field_candidates: &[String]) -> Vec<String> {
113+
let mut notes = Vec::new();
114+
if let Some(network_node) = path_nodes.iter().find(|node| is_network_terminal(node)) {
115+
notes.push(format!("reached network terminal {}", network_node.name));
116+
}
117+
if let Some(candidate) = field_candidates.first() {
118+
notes.push(format!("candidate field path {}", candidate));
119+
}
120+
if path_nodes.iter().any(|node| {
121+
node.kind == NodeKind::Function && strip_accessor_prefix(&node.name) != node.name
122+
}) {
123+
notes.push("path crosses accessor/computed-property logic".to_string());
124+
}
125+
notes
126+
}
127+
128+
pub fn query_origin(
129+
graph: &Graph,
130+
symbol: &str,
131+
max_depth: usize,
132+
) -> Result<OriginResult, QueryResolveError> {
133+
let target_node = crate::query::resolve_node(&graph.nodes, symbol)?;
134+
let node_index: HashMap<&str, &Node> = graph.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
135+
136+
let mut forward_adj: HashMap<&str, Vec<&str>> = HashMap::new();
137+
for edge in &graph.edges {
138+
if is_dataflow_edge(edge.kind)
139+
&& node_index.contains_key(edge.source.as_str())
140+
&& node_index.contains_key(edge.target.as_str())
141+
{
142+
forward_adj
143+
.entry(edge.source.as_str())
144+
.or_default()
145+
.push(edge.target.as_str());
146+
}
147+
}
148+
149+
let mut stack = vec![StackFrame {
150+
node_id: target_node.id.as_str(),
151+
path_ids: vec![target_node.id.as_str()],
152+
visited: HashSet::from([target_node.id.as_str()]),
153+
}];
154+
let mut origins = Vec::new();
155+
let mut seen_api_ids = HashSet::new();
156+
157+
while let Some(frame) = stack.pop() {
158+
let Some(node) = node_index.get(frame.node_id).copied() else {
159+
continue;
160+
};
161+
162+
if frame.path_ids.len() > 1
163+
&& is_network_terminal(node)
164+
&& seen_api_ids.insert(node.id.as_str())
165+
{
166+
let path_nodes: Vec<&Node> = frame
167+
.path_ids
168+
.iter()
169+
.filter_map(|node_id| node_index.get(*node_id).copied())
170+
.collect();
171+
let field_candidates = candidate_field_paths(&path_nodes);
172+
let confidence = confidence_for(&path_nodes, &field_candidates);
173+
let notes = notes_for(&path_nodes, &field_candidates);
174+
origins.push(OriginPath {
175+
api: to_symbol_ref(node),
176+
terminal_kind: terminal_kind_to_string(&TerminalKind::Network),
177+
path: path_display(&path_nodes),
178+
field_candidates,
179+
confidence,
180+
notes,
181+
});
182+
continue;
183+
}
184+
185+
if frame.path_ids.len() > max_depth + 1 {
186+
continue;
187+
}
188+
189+
if let Some(targets) = forward_adj.get(frame.node_id) {
190+
for target_id in targets {
191+
if frame.visited.contains(target_id) {
192+
continue;
193+
}
194+
let mut visited = frame.visited.clone();
195+
visited.insert(target_id);
196+
let mut path_ids = frame.path_ids.clone();
197+
path_ids.push(target_id);
198+
stack.push(StackFrame {
199+
node_id: target_id,
200+
path_ids,
201+
visited,
202+
});
203+
}
204+
}
205+
}
206+
207+
origins.sort_by(|left, right| {
208+
right
209+
.confidence
210+
.partial_cmp(&left.confidence)
211+
.unwrap_or(std::cmp::Ordering::Equal)
212+
.then_with(|| left.api.name.cmp(&right.api.name))
213+
.then_with(|| left.path.len().cmp(&right.path.len()))
214+
});
215+
216+
Ok(OriginResult {
217+
symbol: target_node.id.clone(),
218+
total_origins: origins.len(),
219+
origins,
220+
target_ref: SymbolRef::from_node(target_node),
221+
})
222+
}
223+
224+
#[cfg(test)]
225+
mod tests {
226+
use super::*;
227+
use grapha_core::graph::{Edge, EdgeKind, FlowDirection, NodeKind, Span, Visibility};
228+
use std::collections::HashMap as StdHashMap;
229+
use std::path::PathBuf;
230+
231+
fn node(id: &str, name: &str, kind: NodeKind, role: Option<NodeRole>) -> Node {
232+
Node {
233+
id: id.into(),
234+
kind,
235+
name: name.into(),
236+
file: PathBuf::from("test.swift"),
237+
span: Span {
238+
start: [0, 0],
239+
end: [1, 0],
240+
},
241+
visibility: Visibility::Public,
242+
metadata: StdHashMap::new(),
243+
role,
244+
signature: None,
245+
doc_comment: None,
246+
module: Some("App".into()),
247+
snippet: None,
248+
}
249+
}
250+
251+
fn edge(source: &str, target: &str, kind: EdgeKind) -> Edge {
252+
Edge {
253+
source: source.into(),
254+
target: target.into(),
255+
kind,
256+
confidence: 1.0,
257+
direction: Some(FlowDirection::Read),
258+
operation: None,
259+
condition: None,
260+
async_boundary: None,
261+
provenance: Vec::new(),
262+
}
263+
}
264+
265+
#[test]
266+
fn origin_finds_network_terminal_and_field_candidates() {
267+
let graph = Graph {
268+
version: "0.1.0".to_string(),
269+
nodes: vec![
270+
node("ui::titleText", "titleText", NodeKind::Property, None),
271+
node("vm::displayName", "displayName", NodeKind::Property, None),
272+
node("model::nickname", "nickname", NodeKind::Property, None),
273+
node(
274+
"api::fetchProfile",
275+
"fetchProfile",
276+
NodeKind::Function,
277+
Some(NodeRole::Terminal {
278+
kind: TerminalKind::Network,
279+
}),
280+
),
281+
],
282+
edges: vec![
283+
edge("ui::titleText", "vm::displayName", EdgeKind::Reads),
284+
edge("vm::displayName", "model::nickname", EdgeKind::Reads),
285+
edge("model::nickname", "api::fetchProfile", EdgeKind::Reads),
286+
],
287+
};
288+
289+
let result = query_origin(&graph, "titleText", 10).unwrap();
290+
assert_eq!(result.total_origins, 1);
291+
let origin = &result.origins[0];
292+
assert_eq!(origin.api.name, "fetchProfile");
293+
assert!(
294+
origin
295+
.field_candidates
296+
.iter()
297+
.any(|v| v.contains("nickname"))
298+
);
299+
assert_eq!(
300+
origin.path,
301+
vec!["titleText", "displayName", "nickname", "fetchProfile"]
302+
);
303+
}
304+
}

0 commit comments

Comments
 (0)