Skip to main content

codemem_engine/enrichment/
change_impact.rs

1//! Change impact prediction: co-change, call graph, and test associations.
2
3use super::EnrichResult;
4use crate::CodememEngine;
5use codemem_core::{CodememError, GraphBackend, NodeKind, RelationshipType};
6use serde_json::json;
7
8impl CodememEngine {
9    /// Predict the impact of changes to a given file by combining co-change edges,
10    /// call graph edges, and test file associations.
11    pub fn enrich_change_impact(
12        &self,
13        file_path: &str,
14        namespace: Option<&str>,
15    ) -> Result<EnrichResult, CodememError> {
16        let graph = self.lock_graph()?;
17
18        let file_id = format!("file:{file_path}");
19        if graph.get_node(&file_id).ok().flatten().is_none() {
20            return Err(CodememError::NotFound(format!(
21                "File node not found: {file_path}"
22            )));
23        }
24
25        let mut co_changed: Vec<String> = Vec::new();
26        let mut callers: Vec<String> = Vec::new();
27        let mut callees: Vec<String> = Vec::new();
28        let mut test_files: Vec<String> = Vec::new();
29
30        // Get edges for the file node
31        if let Ok(edges) = graph.get_edges(&file_id) {
32            for edge in &edges {
33                match edge.relationship {
34                    RelationshipType::CoChanged => {
35                        let other = if edge.src == file_id {
36                            &edge.dst
37                        } else {
38                            &edge.src
39                        };
40                        if let Some(path) = other.strip_prefix("file:") {
41                            co_changed.push(path.to_string());
42                        }
43                    }
44                    RelationshipType::Calls => {
45                        let other = if edge.src == file_id {
46                            callees.push(edge.dst.clone());
47                            &edge.dst
48                        } else {
49                            callers.push(edge.src.clone());
50                            &edge.src
51                        };
52                        let _ = other;
53                    }
54                    RelationshipType::RelatesTo => {
55                        // Check if this is a test mapping edge
56                        if edge.properties.contains_key("test_mapping") {
57                            let other = if edge.src == file_id {
58                                &edge.dst
59                            } else {
60                                &edge.src
61                            };
62                            if let Ok(Some(node)) = graph.get_node(other) {
63                                if node.kind == NodeKind::Test {
64                                    if let Some(fp) =
65                                        node.payload.get("file_path").and_then(|v| v.as_str())
66                                    {
67                                        test_files.push(fp.to_string());
68                                    }
69                                }
70                            }
71                        }
72                    }
73                    _ => {}
74                }
75            }
76        }
77
78        // Also check symbols contained in this file for their callers
79        let all_nodes = graph.get_all_nodes();
80        for node in &all_nodes {
81            if !matches!(node.kind, NodeKind::Function | NodeKind::Method) {
82                continue;
83            }
84            let sym_file = node
85                .payload
86                .get("file_path")
87                .and_then(|v| v.as_str())
88                .unwrap_or("");
89            if sym_file != file_path {
90                continue;
91            }
92            if let Ok(edges) = graph.get_edges(&node.id) {
93                for edge in &edges {
94                    if edge.relationship == RelationshipType::Calls && edge.dst == node.id {
95                        // Something calls this symbol
96                        if let Ok(Some(caller_node)) = graph.get_node(&edge.src) {
97                            if let Some(fp) = caller_node
98                                .payload
99                                .get("file_path")
100                                .and_then(|v| v.as_str())
101                            {
102                                if fp != file_path {
103                                    callers.push(fp.to_string());
104                                }
105                            }
106                        }
107                    }
108                }
109            }
110        }
111
112        drop(graph);
113
114        // Dedup
115        co_changed.sort();
116        co_changed.dedup();
117        callers.sort();
118        callers.dedup();
119        callees.sort();
120        callees.dedup();
121        test_files.sort();
122        test_files.dedup();
123
124        let impact_score = co_changed.len() + callers.len() + callees.len();
125
126        let mut insights_stored = 0;
127
128        if impact_score > 0 {
129            let mut parts: Vec<String> = Vec::new();
130            if !callers.is_empty() {
131                parts.push(format!(
132                    "{} caller files ({})",
133                    callers.len(),
134                    callers
135                        .iter()
136                        .take(5)
137                        .cloned()
138                        .collect::<Vec<_>>()
139                        .join(", ")
140                ));
141            }
142            if !co_changed.is_empty() {
143                parts.push(format!(
144                    "{} co-changed files ({})",
145                    co_changed.len(),
146                    co_changed
147                        .iter()
148                        .take(5)
149                        .cloned()
150                        .collect::<Vec<_>>()
151                        .join(", ")
152                ));
153            }
154            if !test_files.is_empty() {
155                parts.push(format!(
156                    "{} test files ({})",
157                    test_files.len(),
158                    test_files.join(", ")
159                ));
160            }
161            let content = format!("Change impact for {}: {}", file_path, parts.join("; "));
162            let importance = (impact_score as f64 / 20.0).clamp(0.4, 0.9);
163            if self
164                .store_insight(&content, "impact", &[], importance, namespace, &[file_id])
165                .is_some()
166            {
167                insights_stored += 1;
168            }
169        }
170
171        self.save_index();
172
173        Ok(EnrichResult {
174            insights_stored,
175            details: json!({
176                "file": file_path,
177                "callers": callers.len(),
178                "callees": callees.len(),
179                "co_changed": co_changed.len(),
180                "test_files": test_files.len(),
181                "impact_score": impact_score,
182                "insights_stored": insights_stored,
183            }),
184        })
185    }
186}