Skip to main content

agentic_codebase/engine/
impact.rs

1//! Enhanced Impact Analysis — Invention 1.
2//!
3//! Trace forward through the dependency graph to predict ALL affected code
4//! before a change is made. Enriches the basic impact analysis with
5//! ProposedChange, ImpactType classification, BlastRadius, and Mitigations.
6
7use std::collections::{HashMap, HashSet};
8
9use serde::{Deserialize, Serialize};
10
11use crate::graph::traversal::{self, Direction, TraversalOptions};
12use crate::graph::CodeGraph;
13use crate::types::{CodeUnitType, EdgeType};
14
15// ── Types ────────────────────────────────────────────────────────────────────
16
17/// A proposed change to analyse.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ProposedChange {
20    /// What's being changed.
21    pub target: u64,
22    /// Type of change.
23    pub change_type: ChangeType,
24    /// Description.
25    pub description: String,
26}
27
28/// Type of proposed change.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30pub enum ChangeType {
31    /// Signature change (params, return type).
32    Signature,
33    /// Behavior change (same signature, different logic).
34    Behavior,
35    /// Deletion.
36    Deletion,
37    /// Rename.
38    Rename,
39    /// Move to different module.
40    Move,
41}
42
43/// How a node is impacted.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45pub enum ImpactType {
46    /// Will definitely break (type error, missing function).
47    WillBreak,
48    /// Might break (behavior change, edge cases).
49    MightBreak,
50    /// Needs review (semantic dependency).
51    NeedsReview,
52    /// Safe (only reads, doesn't depend on changed behavior).
53    Safe,
54}
55
56/// A node impacted by a change.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ImpactedNode {
59    /// The affected node.
60    pub node_id: u64,
61    /// Path from change to this node.
62    pub impact_path: Vec<u64>,
63    /// Distance from original change.
64    pub distance: u32,
65    /// How it's affected.
66    pub impact_type: ImpactType,
67    /// Confidence this will actually break.
68    pub break_probability: f64,
69}
70
71/// Total blast radius of a change.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct BlastRadius {
74    /// Number of files affected.
75    pub files_affected: usize,
76    /// Number of functions affected.
77    pub functions_affected: usize,
78    /// Number of modules affected.
79    pub modules_affected: usize,
80    /// Lines of code in blast radius.
81    pub loc_affected: usize,
82    /// Test files affected.
83    pub tests_affected: usize,
84}
85
86/// Risk level of the change.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88pub enum RiskLevel {
89    /// Safe to change.
90    Low,
91    /// Review recommended.
92    Medium,
93    /// Careful review required.
94    High,
95    /// Architectural implications.
96    Critical,
97}
98
99/// A suggested mitigation.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct Mitigation {
102    /// Description of the mitigation.
103    pub description: String,
104    /// Estimated effort.
105    pub effort: String,
106    /// How much this reduces risk (0.0 - 1.0).
107    pub risk_reduction: f64,
108}
109
110/// Full enhanced impact analysis result.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct EnhancedImpactResult {
113    /// The change being analyzed.
114    pub change: ProposedChange,
115    /// Directly affected nodes (distance = 1).
116    pub direct_impact: Vec<ImpactedNode>,
117    /// Transitively affected nodes (distance > 1).
118    pub transitive_impact: Vec<ImpactedNode>,
119    /// Risk assessment.
120    pub risk_level: RiskLevel,
121    /// Total blast radius.
122    pub blast_radius: BlastRadius,
123    /// Suggested mitigations.
124    pub mitigations: Vec<Mitigation>,
125}
126
127// ── ImpactAnalyzer ───────────────────────────────────────────────────────────
128
129/// Analyzes the impact of proposed changes on the codebase.
130pub struct ImpactAnalyzer<'g> {
131    graph: &'g CodeGraph,
132}
133
134impl<'g> ImpactAnalyzer<'g> {
135    pub fn new(graph: &'g CodeGraph) -> Self {
136        Self { graph }
137    }
138
139    /// Analyze the full impact of a proposed change.
140    pub fn analyze(&self, change: ProposedChange, max_depth: u32) -> EnhancedImpactResult {
141        let dependency_edges = vec![
142            EdgeType::Calls,
143            EdgeType::Imports,
144            EdgeType::Inherits,
145            EdgeType::Implements,
146            EdgeType::UsesType,
147            EdgeType::References,
148            EdgeType::Returns,
149            EdgeType::ParamType,
150            EdgeType::Overrides,
151        ];
152
153        // BFS backward from target to find all dependents
154        let options = TraversalOptions {
155            max_depth: max_depth as i32,
156            edge_types: dependency_edges,
157            direction: Direction::Backward,
158        };
159
160        let traversal = traversal::bfs(self.graph, change.target, &options);
161
162        // Build parent map for path reconstruction
163        let mut parent_map: HashMap<u64, u64> = HashMap::new();
164        let mut visited_order: Vec<(u64, u32)> = Vec::new();
165
166        // Re-do BFS to track parents
167        {
168            let opts = TraversalOptions {
169                max_depth: max_depth as i32,
170                edge_types: vec![
171                    EdgeType::Calls,
172                    EdgeType::Imports,
173                    EdgeType::Inherits,
174                    EdgeType::Implements,
175                    EdgeType::UsesType,
176                    EdgeType::References,
177                ],
178                direction: Direction::Backward,
179            };
180            let mut visited = HashSet::new();
181            let mut queue = std::collections::VecDeque::new();
182            visited.insert(change.target);
183            queue.push_back((change.target, 0u32));
184
185            while let Some((current, depth)) = queue.pop_front() {
186                visited_order.push((current, depth));
187                if opts.max_depth >= 0 && depth >= opts.max_depth as u32 {
188                    continue;
189                }
190                for edge in self.graph.edges_to(current) {
191                    if !opts.edge_types.is_empty() && !opts.edge_types.contains(&edge.edge_type) {
192                        continue;
193                    }
194                    if visited.insert(edge.source_id) {
195                        parent_map.insert(edge.source_id, current);
196                        queue.push_back((edge.source_id, depth + 1));
197                    }
198                }
199            }
200        }
201
202        let mut direct_impact = Vec::new();
203        let mut transitive_impact = Vec::new();
204
205        for &(node_id, depth) in &traversal {
206            if node_id == change.target {
207                continue;
208            }
209
210            let impact_path = self.reconstruct_path(node_id, change.target, &parent_map);
211            let impact_type = self.classify_impact(&change, node_id, depth);
212            let break_probability = self.compute_break_probability(&change, node_id, depth);
213
214            let impacted = ImpactedNode {
215                node_id,
216                impact_path,
217                distance: depth,
218                impact_type,
219                break_probability,
220            };
221
222            if depth == 1 {
223                direct_impact.push(impacted);
224            } else {
225                transitive_impact.push(impacted);
226            }
227        }
228
229        let blast_radius = self.compute_blast_radius(&direct_impact, &transitive_impact);
230        let risk_level = self.assess_risk(&blast_radius, &direct_impact, &transitive_impact);
231        let mitigations = self.generate_mitigations(&change, &risk_level, &blast_radius);
232
233        EnhancedImpactResult {
234            change,
235            direct_impact,
236            transitive_impact,
237            risk_level,
238            blast_radius,
239            mitigations,
240        }
241    }
242
243    /// Find the shortest impact path between two nodes.
244    pub fn impact_path(&self, from: u64, to: u64) -> Option<Vec<u64>> {
245        traversal::shortest_path(self.graph, from, to, &[])
246    }
247
248    /// Generate a visualization-ready JSON structure.
249    pub fn visualize(&self, result: &EnhancedImpactResult) -> serde_json::Value {
250        let mut nodes = Vec::new();
251        let mut edges = Vec::new();
252
253        // Root node
254        if let Some(unit) = self.graph.get_unit(result.change.target) {
255            nodes.push(serde_json::json!({
256                "id": result.change.target,
257                "name": unit.name,
258                "type": "change_target",
259                "risk": "source",
260            }));
261        }
262
263        // Impact nodes
264        for impacted in result
265            .direct_impact
266            .iter()
267            .chain(result.transitive_impact.iter())
268        {
269            if let Some(unit) = self.graph.get_unit(impacted.node_id) {
270                nodes.push(serde_json::json!({
271                    "id": impacted.node_id,
272                    "name": unit.name,
273                    "type": format!("{:?}", impacted.impact_type),
274                    "distance": impacted.distance,
275                    "break_probability": impacted.break_probability,
276                }));
277
278                if impacted.impact_path.len() >= 2 {
279                    let from = impacted.impact_path[impacted.impact_path.len() - 2];
280                    edges.push(serde_json::json!({
281                        "from": from,
282                        "to": impacted.node_id,
283                    }));
284                }
285            }
286        }
287
288        serde_json::json!({
289            "nodes": nodes,
290            "edges": edges,
291            "blast_radius": {
292                "files": result.blast_radius.files_affected,
293                "functions": result.blast_radius.functions_affected,
294                "modules": result.blast_radius.modules_affected,
295                "tests": result.blast_radius.tests_affected,
296            },
297            "risk_level": format!("{:?}", result.risk_level),
298        })
299    }
300
301    // ── Internal helpers ─────────────────────────────────────────────────
302
303    fn reconstruct_path(&self, from: u64, to: u64, parent_map: &HashMap<u64, u64>) -> Vec<u64> {
304        let mut path = vec![from];
305        let mut current = from;
306        let mut seen = HashSet::new();
307        seen.insert(current);
308        while let Some(&parent) = parent_map.get(&current) {
309            if !seen.insert(parent) {
310                break;
311            }
312            path.push(parent);
313            if parent == to {
314                break;
315            }
316            current = parent;
317        }
318        path
319    }
320
321    fn classify_impact(&self, change: &ProposedChange, node_id: u64, distance: u32) -> ImpactType {
322        let has_test = self
323            .graph
324            .edges_to(node_id)
325            .iter()
326            .any(|e| e.edge_type == EdgeType::Tests);
327
328        match change.change_type {
329            ChangeType::Deletion => {
330                if distance == 1 {
331                    ImpactType::WillBreak
332                } else {
333                    ImpactType::MightBreak
334                }
335            }
336            ChangeType::Signature => {
337                if distance == 1 {
338                    ImpactType::WillBreak
339                } else {
340                    ImpactType::NeedsReview
341                }
342            }
343            ChangeType::Rename => {
344                if distance == 1 {
345                    ImpactType::WillBreak
346                } else {
347                    ImpactType::NeedsReview
348                }
349            }
350            ChangeType::Behavior => {
351                if has_test {
352                    ImpactType::NeedsReview
353                } else {
354                    ImpactType::MightBreak
355                }
356            }
357            ChangeType::Move => {
358                if distance == 1 {
359                    ImpactType::MightBreak
360                } else {
361                    ImpactType::Safe
362                }
363            }
364        }
365    }
366
367    fn compute_break_probability(
368        &self,
369        change: &ProposedChange,
370        _node_id: u64,
371        distance: u32,
372    ) -> f64 {
373        let base = match change.change_type {
374            ChangeType::Deletion => 0.95,
375            ChangeType::Signature => 0.85,
376            ChangeType::Rename => 0.80,
377            ChangeType::Behavior => 0.50,
378            ChangeType::Move => 0.40,
379        };
380
381        // Decay with distance
382        let decay = 1.0 / (1.0 + distance as f64 * 0.5);
383        (base * decay).min(1.0)
384    }
385
386    fn compute_blast_radius(
387        &self,
388        direct: &[ImpactedNode],
389        transitive: &[ImpactedNode],
390    ) -> BlastRadius {
391        let all_nodes: Vec<u64> = direct
392            .iter()
393            .chain(transitive.iter())
394            .map(|n| n.node_id)
395            .collect();
396
397        let mut files = HashSet::new();
398        let mut modules = HashSet::new();
399        let mut functions = 0usize;
400        let mut loc = 0usize;
401        let mut tests = 0usize;
402
403        for &node_id in &all_nodes {
404            if let Some(unit) = self.graph.get_unit(node_id) {
405                files.insert(unit.file_path.display().to_string());
406
407                // Extract module from qualified name
408                if let Some(last_dot) = unit.qualified_name.rfind('.') {
409                    modules.insert(unit.qualified_name[..last_dot].to_string());
410                } else if let Some(last_sep) = unit.qualified_name.rfind("::") {
411                    modules.insert(unit.qualified_name[..last_sep].to_string());
412                }
413
414                if unit.unit_type == CodeUnitType::Function {
415                    functions += 1;
416                }
417                if unit.unit_type == CodeUnitType::Test {
418                    tests += 1;
419                }
420
421                let lines = if unit.span.end_line > unit.span.start_line {
422                    (unit.span.end_line - unit.span.start_line) as usize
423                } else {
424                    1
425                };
426                loc += lines;
427            }
428        }
429
430        BlastRadius {
431            files_affected: files.len(),
432            functions_affected: functions,
433            modules_affected: modules.len(),
434            loc_affected: loc,
435            tests_affected: tests,
436        }
437    }
438
439    fn assess_risk(
440        &self,
441        blast_radius: &BlastRadius,
442        direct: &[ImpactedNode],
443        transitive: &[ImpactedNode],
444    ) -> RiskLevel {
445        let total = direct.len() + transitive.len();
446        let will_break = direct
447            .iter()
448            .chain(transitive.iter())
449            .filter(|n| n.impact_type == ImpactType::WillBreak)
450            .count();
451
452        if total == 0 {
453            return RiskLevel::Low;
454        }
455
456        if will_break > 10 || blast_radius.files_affected > 20 {
457            RiskLevel::Critical
458        } else if will_break > 3 || blast_radius.files_affected > 10 || total > 30 {
459            RiskLevel::High
460        } else if will_break > 0 || total > 10 {
461            RiskLevel::Medium
462        } else {
463            RiskLevel::Low
464        }
465    }
466
467    fn generate_mitigations(
468        &self,
469        change: &ProposedChange,
470        risk_level: &RiskLevel,
471        blast_radius: &BlastRadius,
472    ) -> Vec<Mitigation> {
473        let mut mitigations = Vec::new();
474
475        match change.change_type {
476            ChangeType::Signature => {
477                mitigations.push(Mitigation {
478                    description: "Add compatibility wrapper that delegates to new signature"
479                        .to_string(),
480                    effort: "Low".to_string(),
481                    risk_reduction: 0.7,
482                });
483                mitigations.push(Mitigation {
484                    description: "Deprecate old signature with migration period".to_string(),
485                    effort: "Medium".to_string(),
486                    risk_reduction: 0.9,
487                });
488            }
489            ChangeType::Deletion => {
490                mitigations.push(Mitigation {
491                    description: "Replace with deprecation warning first".to_string(),
492                    effort: "Low".to_string(),
493                    risk_reduction: 0.5,
494                });
495            }
496            ChangeType::Rename => {
497                mitigations.push(Mitigation {
498                    description: "Add type alias or re-export from old name".to_string(),
499                    effort: "Low".to_string(),
500                    risk_reduction: 0.8,
501                });
502            }
503            _ => {}
504        }
505
506        if *risk_level == RiskLevel::High || *risk_level == RiskLevel::Critical {
507            mitigations.push(Mitigation {
508                description: "Deploy incrementally with feature flags".to_string(),
509                effort: "Medium".to_string(),
510                risk_reduction: 0.6,
511            });
512        }
513
514        if blast_radius.tests_affected == 0 {
515            mitigations.push(Mitigation {
516                description: "Add tests before making the change".to_string(),
517                effort: "Medium".to_string(),
518                risk_reduction: 0.4,
519            });
520        }
521
522        mitigations
523    }
524}
525
526// ── Tests ────────────────────────────────────────────────────────────────────
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use crate::types::{CodeUnit, CodeUnitType, Edge, Language, Span};
532    use std::path::PathBuf;
533
534    fn test_graph() -> CodeGraph {
535        let mut graph = CodeGraph::with_default_dimension();
536
537        // A -> B -> C chain
538        let a = graph.add_unit(CodeUnit::new(
539            CodeUnitType::Function,
540            Language::Rust,
541            "func_a".to_string(),
542            "mod::func_a".to_string(),
543            PathBuf::from("src/a.rs"),
544            Span::new(1, 0, 10, 0),
545        ));
546        let b = graph.add_unit(CodeUnit::new(
547            CodeUnitType::Function,
548            Language::Rust,
549            "func_b".to_string(),
550            "mod::func_b".to_string(),
551            PathBuf::from("src/b.rs"),
552            Span::new(1, 0, 20, 0),
553        ));
554        let c = graph.add_unit(CodeUnit::new(
555            CodeUnitType::Function,
556            Language::Rust,
557            "func_c".to_string(),
558            "mod::func_c".to_string(),
559            PathBuf::from("src/c.rs"),
560            Span::new(1, 0, 15, 0),
561        ));
562
563        // B calls A, C calls B
564        let _ = graph.add_edge(Edge::new(b, a, EdgeType::Calls));
565        let _ = graph.add_edge(Edge::new(c, b, EdgeType::Calls));
566
567        graph
568    }
569
570    #[test]
571    fn analyze_deletion_impact() {
572        let graph = test_graph();
573        let analyzer = ImpactAnalyzer::new(&graph);
574        let change = ProposedChange {
575            target: 0, // func_a
576            change_type: ChangeType::Deletion,
577            description: "Delete func_a".to_string(),
578        };
579        let result = analyzer.analyze(change, 5);
580        assert!(!result.direct_impact.is_empty());
581        assert_eq!(result.direct_impact[0].impact_type, ImpactType::WillBreak);
582    }
583
584    #[test]
585    fn blast_radius_computed() {
586        let graph = test_graph();
587        let analyzer = ImpactAnalyzer::new(&graph);
588        let change = ProposedChange {
589            target: 0,
590            change_type: ChangeType::Signature,
591            description: "Change signature".to_string(),
592        };
593        let result = analyzer.analyze(change, 5);
594        assert!(result.blast_radius.files_affected > 0);
595    }
596
597    #[test]
598    fn mitigations_generated() {
599        let graph = test_graph();
600        let analyzer = ImpactAnalyzer::new(&graph);
601        let change = ProposedChange {
602            target: 0,
603            change_type: ChangeType::Signature,
604            description: "Change params".to_string(),
605        };
606        let result = analyzer.analyze(change, 5);
607        assert!(!result.mitigations.is_empty());
608    }
609
610    #[test]
611    fn visualize_produces_json() {
612        let graph = test_graph();
613        let analyzer = ImpactAnalyzer::new(&graph);
614        let change = ProposedChange {
615            target: 0,
616            change_type: ChangeType::Behavior,
617            description: "Change behavior".to_string(),
618        };
619        let result = analyzer.analyze(change, 3);
620        let viz = analyzer.visualize(&result);
621        assert!(viz.get("nodes").is_some());
622        assert!(viz.get("edges").is_some());
623    }
624}