Skip to main content

agentstatedeveloper_core/
dataflow.rs

1//! Intra-process data-flow edges (Plan competitive-harvest t-002, slice 4).
2//!
3//! Distinct from the contract-keyed cross-service layer: a data-flow edge is a
4//! direct symbol→symbol link *within one process* recording that a value at a
5//! call site flows into a callee's parameter (`arg → param`). No contract, no
6//! cross-repo matching — it's the "argument-to-parameter" half of what
7//! codebase-memory-mcp calls `DATA_FLOWS`.
8//!
9//! Detection is split like the endpoint layer: a language adapter reports
10//! [`DetectedDataFlow`] (call site + argument + position, by qname); the index
11//! pipeline resolves the callee's parameter name (from its signature) and
12//! symbol identity to produce a [`DataFlowEdge`].
13
14use serde::{Deserialize, Serialize};
15
16/// How a value reaches its sink. Currently only argument→parameter; field-access
17/// chains are a planned addition.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum DataFlowKind {
21    /// A call-site argument flowing into the callee's parameter.
22    ArgToParam,
23}
24
25/// A resolved data-flow edge: a value flows from a call site in `from` into
26/// `param` of the callee `to`.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct DataFlowEdge {
29    pub kind: DataFlowKind,
30    pub from_symbol_id: String,
31    pub from_qname: String,
32    pub to_symbol_id: String,
33    pub to_qname: String,
34    /// The callee parameter the value flows into.
35    pub param: String,
36    /// The argument expression at the call site (a simple identifier today).
37    pub arg: String,
38    pub file: String,
39    pub line: u32,
40    pub confidence: f64,
41}
42
43/// A data-flow site detected by an adapter, before the pipeline resolves the
44/// callee's parameter name and symbol identity. The adapter knows the callee by
45/// qname and the argument's *position*; the pipeline maps position → param name
46/// using the callee's signature.
47#[derive(Debug, Clone, PartialEq)]
48pub struct DetectedDataFlow {
49    pub caller_qname: String,
50    pub callee_qname: String,
51    /// Zero-based positional index of the argument.
52    pub arg_index: usize,
53    /// The argument expression (a simple identifier).
54    pub arg: String,
55    pub file: String,
56    pub line: u32,
57    pub confidence: f64,
58}
59
60/// Extract positional parameter names from a function signature, e.g.
61/// `"def get_user(id, name=None, *args)"` → `["id", "name"]`. Skips
62/// `*args`/`**kwargs` and the `self`/`cls` receiver. Language-agnostic enough
63/// for the common `name(params)` shape.
64pub fn parse_params(signature: &str) -> Vec<String> {
65    let Some(open) = signature.find('(') else {
66        return Vec::new();
67    };
68    let after = &signature[open + 1..];
69    let Some(close) = matching_paren(after) else {
70        return Vec::new();
71    };
72    let inner = &after[..close];
73    split_top_level(inner)
74        .into_iter()
75        .filter_map(|p| {
76            let p = p.trim();
77            // *args / **kwargs receive no single positional value.
78            if p.starts_with('*') {
79                return None;
80            }
81            // Strip type annotation / default: `id: int = 3` → `id`.
82            let name = p.split([':', '=']).next().unwrap_or("").trim();
83            if name.is_empty() || name == "self" || name == "cls" {
84                None
85            } else {
86                Some(name.to_string())
87            }
88        })
89        .collect()
90}
91
92/// Split on commas at bracket-depth 0, so commas inside a default value's
93/// `foo(1, 2)` / `[a, b]` don't split the parameter list.
94fn split_top_level(s: &str) -> Vec<String> {
95    let mut out = Vec::new();
96    let mut depth = 0i32;
97    let mut start = 0usize;
98    for (i, c) in s.char_indices() {
99        match c {
100            '(' | '[' | '{' => depth += 1,
101            ')' | ']' | '}' => depth -= 1,
102            ',' if depth == 0 => {
103                out.push(s[start..i].to_string());
104                start = i + 1;
105            }
106            _ => {}
107        }
108    }
109    out.push(s[start..].to_string());
110    out
111}
112
113/// Index of the `)` matching the first unconsumed `(` — i.e. paren-depth aware,
114/// so nested parens in defaults/annotations don't end the list early.
115fn matching_paren(s: &str) -> Option<usize> {
116    let mut depth = 0i32;
117    for (i, c) in s.char_indices() {
118        match c {
119            '(' | '[' | '{' => depth += 1,
120            ')' if depth == 0 => return Some(i),
121            ')' | ']' | '}' => depth -= 1,
122            _ => {}
123        }
124    }
125    None
126}
127
128/// Resolve a detected data-flow site into a [`DataFlowEdge`], given a way to
129/// look up a qname's symbol_id and a callee's parameter list. Returns `None`
130/// when the caller or callee can't be resolved, or the arg position has no
131/// corresponding parameter.
132pub fn resolve_edge(
133    det: &DetectedDataFlow,
134    sym_id_of: impl Fn(&str) -> Option<String>,
135    params_of: impl Fn(&str) -> Option<Vec<String>>,
136) -> Option<DataFlowEdge> {
137    let from_symbol_id = sym_id_of(&det.caller_qname)?;
138    let to_symbol_id = sym_id_of(&det.callee_qname)?;
139    let params = params_of(&det.callee_qname)?;
140    let param = params.get(det.arg_index)?.clone();
141    Some(DataFlowEdge {
142        kind: DataFlowKind::ArgToParam,
143        from_symbol_id,
144        from_qname: det.caller_qname.clone(),
145        to_symbol_id,
146        to_qname: det.callee_qname.clone(),
147        param,
148        arg: det.arg.clone(),
149        file: det.file.clone(),
150        line: det.line,
151        confidence: det.confidence,
152    })
153}
154
155/// Flatten the on-disk data-flow registry (`from_symbol_id → [DataFlowEdge]`)
156/// into a flat list. Malformed entries are skipped.
157pub fn edges_from_tree(tree: &serde_json::Value) -> Vec<DataFlowEdge> {
158    let mut out = Vec::new();
159    let Some(by_from) = tree.as_object() else {
160        return out;
161    };
162    for arr in by_from.values() {
163        let Some(arr) = arr.as_array() else {
164            continue;
165        };
166        for e in arr {
167            if let Ok(edge) = serde_json::from_value::<DataFlowEdge>(e.clone()) {
168                out.push(edge);
169            }
170        }
171    }
172    out
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn parse_params_basic_and_annotated() {
181        assert_eq!(parse_params("def f(a, b, c)"), vec!["a", "b", "c"]);
182        assert_eq!(
183            parse_params("def get_user(id: int, name: str = \"x\")"),
184            vec!["id", "name"]
185        );
186    }
187
188    #[test]
189    fn parse_params_skips_receiver_and_varargs() {
190        assert_eq!(parse_params("def m(self, x, *args, **kwargs)"), vec!["x"]);
191        assert_eq!(parse_params("def c(cls, y)"), vec!["y"]);
192    }
193
194    #[test]
195    fn parse_params_handles_nested_parens_in_defaults() {
196        // The default value's parens must not truncate the list.
197        assert_eq!(
198            parse_params("def f(a, b=foo(1, 2), c)"),
199            vec!["a", "b", "c"]
200        );
201    }
202
203    #[test]
204    fn parse_params_empty_and_malformed() {
205        assert!(parse_params("def f()").is_empty());
206        assert!(parse_params("no parens here").is_empty());
207    }
208
209    #[test]
210    fn resolve_edge_maps_position_to_param() {
211        let det = DetectedDataFlow {
212            caller_qname: "m.caller".into(),
213            callee_qname: "m.get_user".into(),
214            arg_index: 0,
215            arg: "uid".into(),
216            file: "m.py".into(),
217            line: 4,
218            confidence: 0.8,
219        };
220        let edge = resolve_edge(
221            &det,
222            |q| Some(format!("sym_{q}")),
223            |q| (q == "m.get_user").then(|| vec!["id".to_string(), "name".to_string()]),
224        )
225        .expect("edge resolves");
226        assert_eq!(edge.from_symbol_id, "sym_m.caller");
227        assert_eq!(edge.to_symbol_id, "sym_m.get_user");
228        assert_eq!(edge.param, "id");
229        assert_eq!(edge.arg, "uid");
230    }
231
232    #[test]
233    fn resolve_edge_none_when_arg_position_has_no_param() {
234        let det = DetectedDataFlow {
235            caller_qname: "m.caller".into(),
236            callee_qname: "m.f".into(),
237            arg_index: 5, // out of range
238            arg: "x".into(),
239            file: "m.py".into(),
240            line: 1,
241            confidence: 0.8,
242        };
243        assert!(
244            resolve_edge(
245                &det,
246                |q| Some(q.to_string()),
247                |_| Some(vec!["only".to_string()])
248            )
249            .is_none()
250        );
251    }
252
253    #[test]
254    fn resolve_edge_none_when_callee_unresolved() {
255        let det = DetectedDataFlow {
256            caller_qname: "m.caller".into(),
257            callee_qname: "external.thing".into(),
258            arg_index: 0,
259            arg: "x".into(),
260            file: "m.py".into(),
261            line: 1,
262            confidence: 0.8,
263        };
264        // params_of returns None for the unknown callee.
265        assert!(resolve_edge(&det, |q| Some(q.to_string()), |_| None).is_none());
266    }
267}