Skip to main content

nusy_codegraph/
impact.rs

1//! Impact analysis — compute the blast radius of code changes.
2//!
3//! Given a `CodeDiffResult` (which nodes changed) and the CodeGraph state,
4//! computes which other nodes are transitively affected via call edges,
5//! which tests cover the affected area, and produces a human-readable report.
6
7use crate::git_tools::{CodeDiffChangeType, CodeDiffResult};
8use crate::schema::{CodeEdgePredicate, CodeNode, CodeNodeKind, extract_file_path, node_col};
9use crate::search::{callers, tests_for, transitive_callers};
10use arrow::array::{Array, RecordBatch, StringArray};
11use std::collections::HashSet;
12
13/// The blast radius of a set of code changes.
14#[derive(Debug, Clone)]
15pub struct ImpactReport {
16    /// Nodes directly changed (from the diff).
17    pub directly_changed: Vec<ChangedNode>,
18    /// Nodes that call a changed node (1 hop).
19    pub direct_callers: Vec<CodeNode>,
20    /// Nodes reachable via transitive call chains (up to max_depth).
21    pub transitive_callers: Vec<CodeNode>,
22    /// Test nodes that cover any affected node.
23    pub affected_tests: Vec<CodeNode>,
24    /// Summary statistics.
25    pub stats: ImpactStats,
26}
27
28/// A directly changed node with its change type.
29#[derive(Debug, Clone)]
30pub struct ChangedNode {
31    pub node: CodeNode,
32    pub change_type: CodeDiffChangeType,
33}
34
35/// Summary statistics for an impact report.
36#[derive(Debug, Clone, Default)]
37pub struct ImpactStats {
38    pub directly_changed: usize,
39    pub direct_callers: usize,
40    pub transitive_callers: usize,
41    pub affected_tests: usize,
42    pub total_blast_radius: usize,
43    pub files_affected: usize,
44}
45
46impl std::fmt::Display for ImpactStats {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        write!(
49            f,
50            "{} changed, {} direct callers, {} transitive callers, {} tests affected ({} total across {} files)",
51            self.directly_changed,
52            self.direct_callers,
53            self.transitive_callers,
54            self.affected_tests,
55            self.total_blast_radius,
56            self.files_affected,
57        )
58    }
59}
60
61/// Compute the impact (blast radius) of a code diff.
62///
63/// `diff` is the object-level diff from `codegraph_diff`.
64/// `nodes_batch` is the current CodeNodes state.
65/// `edges_batch` is the current CodeEdges state.
66/// `max_depth` controls how deep transitive caller search goes (default 3).
67pub fn impact_analysis(
68    diff: &CodeDiffResult,
69    nodes_batch: &RecordBatch,
70    edges_batch: &RecordBatch,
71    max_depth: usize,
72) -> ImpactReport {
73    // Build a set of changed node IDs (added + modified + removed)
74    let changed_ids: HashSet<String> = diff.entries.iter().map(|e| e.node_id.clone()).collect();
75
76    // Resolve changed nodes to CodeNode structs (skip removed — they're not in head batch)
77    let node_map = build_node_map(nodes_batch);
78    let directly_changed: Vec<ChangedNode> = diff
79        .entries
80        .iter()
81        .filter_map(|entry| {
82            let node = node_map.get(entry.node_id.as_str()).cloned().or_else(|| {
83                // For removed nodes, synthesize a minimal CodeNode from diff entry
84                if entry.change_type == CodeDiffChangeType::Removed {
85                    Some(CodeNode {
86                        id: entry.node_id.clone(),
87                        kind: CodeNodeKind::parse(&entry.kind).unwrap_or(CodeNodeKind::Function),
88                        parent_id: None,
89                        name: entry.name.clone(),
90                        signature: None,
91                        docstring: None,
92                        body_hash: None,
93                        body: None,
94                        loc: None,
95                        cyclomatic_complexity: None,
96                        coverage_pct: None,
97                        last_modified: None,
98                        ..Default::default()
99                    })
100                } else {
101                    None
102                }
103            })?;
104            Some(ChangedNode {
105                node,
106                change_type: entry.change_type,
107            })
108        })
109        .collect();
110
111    // Find direct callers of changed nodes (1 hop)
112    let mut direct_caller_ids: HashSet<String> = HashSet::new();
113    for entry in &diff.entries {
114        if entry.change_type == CodeDiffChangeType::Removed {
115            continue; // Can't find callers of removed nodes in head batch
116        }
117        for caller in callers(&entry.node_id, nodes_batch, edges_batch) {
118            if !changed_ids.contains(&caller.id) {
119                direct_caller_ids.insert(caller.id.clone());
120            }
121        }
122    }
123
124    // Find transitive callers (up to max_depth)
125    let mut transitive_caller_ids: HashSet<String> = HashSet::new();
126    for entry in &diff.entries {
127        if entry.change_type == CodeDiffChangeType::Removed {
128            continue;
129        }
130        for tc in transitive_callers(
131            &entry.node_id,
132            CodeEdgePredicate::Calls,
133            max_depth,
134            nodes_batch,
135            edges_batch,
136        ) {
137            if !changed_ids.contains(&tc.id) && !direct_caller_ids.contains(&tc.id) {
138                transitive_caller_ids.insert(tc.id.clone());
139            }
140        }
141    }
142
143    // Find tests that cover any affected node (changed + direct + transitive callers)
144    let mut test_ids: HashSet<String> = HashSet::new();
145    let all_affected: HashSet<&str> = changed_ids
146        .iter()
147        .chain(direct_caller_ids.iter())
148        .chain(transitive_caller_ids.iter())
149        .map(|s| s.as_str())
150        .collect();
151
152    for node_id in &all_affected {
153        for test in tests_for(node_id, nodes_batch, edges_batch) {
154            test_ids.insert(test.id.clone());
155        }
156    }
157
158    // Resolve IDs to CodeNode structs
159    let direct_callers: Vec<CodeNode> = direct_caller_ids
160        .iter()
161        .filter_map(|id| node_map.get(id.as_str()).cloned())
162        .collect();
163    let transitive_callers_nodes: Vec<CodeNode> = transitive_caller_ids
164        .iter()
165        .filter_map(|id| node_map.get(id.as_str()).cloned())
166        .collect();
167    let affected_tests: Vec<CodeNode> = test_ids
168        .iter()
169        .filter_map(|id| node_map.get(id.as_str()).cloned())
170        .collect();
171
172    // Compute stats
173    let mut all_files: HashSet<String> = HashSet::new();
174    for cn in &directly_changed {
175        if let Some(fp) = extract_file_path(&cn.node.id) {
176            all_files.insert(fp);
177        }
178    }
179    for n in direct_callers.iter().chain(transitive_callers_nodes.iter()) {
180        if let Some(fp) = extract_file_path(&n.id) {
181            all_files.insert(fp);
182        }
183    }
184
185    let stats = ImpactStats {
186        directly_changed: directly_changed.len(),
187        direct_callers: direct_callers.len(),
188        transitive_callers: transitive_callers_nodes.len(),
189        affected_tests: affected_tests.len(),
190        total_blast_radius: directly_changed.len()
191            + direct_callers.len()
192            + transitive_callers_nodes.len(),
193        files_affected: all_files.len(),
194    };
195
196    ImpactReport {
197        directly_changed,
198        direct_callers,
199        transitive_callers: transitive_callers_nodes,
200        affected_tests,
201        stats,
202    }
203}
204
205/// Format an impact report as a human-readable summary.
206pub fn format_impact_report(report: &ImpactReport) -> String {
207    let mut out = String::new();
208    out.push_str(&format!("## Impact Analysis\n\n{}\n\n", report.stats));
209
210    if !report.directly_changed.is_empty() {
211        out.push_str("### Directly Changed\n\n");
212        for cn in &report.directly_changed {
213            out.push_str(&format!(
214                "  {} {} `{}`\n",
215                match cn.change_type {
216                    CodeDiffChangeType::Added => "+",
217                    CodeDiffChangeType::Removed => "-",
218                    CodeDiffChangeType::Modified => "~",
219                },
220                cn.node.kind,
221                cn.node.name,
222            ));
223        }
224        out.push('\n');
225    }
226
227    if !report.direct_callers.is_empty() {
228        out.push_str("### Direct Callers (may need updating)\n\n");
229        for n in &report.direct_callers {
230            out.push_str(&format!("  {} `{}`\n", n.kind, n.name));
231        }
232        out.push('\n');
233    }
234
235    if !report.transitive_callers.is_empty() {
236        out.push_str("### Transitive Callers (ripple effect)\n\n");
237        for n in &report.transitive_callers {
238            out.push_str(&format!("  {} `{}`\n", n.kind, n.name));
239        }
240        out.push('\n');
241    }
242
243    if !report.affected_tests.is_empty() {
244        out.push_str("### Affected Tests (should be run)\n\n");
245        for t in &report.affected_tests {
246            out.push_str(&format!("  {} `{}`\n", t.kind, t.name));
247        }
248        out.push('\n');
249    }
250
251    out
252}
253
254/// Build a node_id → CodeNode map from the batch.
255fn build_node_map(batch: &RecordBatch) -> std::collections::HashMap<String, CodeNode> {
256    use crate::schema::CodeNodeKind;
257    use arrow::array::{Float64Array, Int32Array};
258
259    let mut map = std::collections::HashMap::new();
260    if batch.num_rows() == 0 {
261        return map;
262    }
263
264    let ids = batch
265        .column(node_col::ID)
266        .as_any()
267        .downcast_ref::<StringArray>()
268        .expect("id");
269    let names = batch
270        .column(node_col::NAME)
271        .as_any()
272        .downcast_ref::<StringArray>()
273        .expect("name");
274    let parent_ids = batch
275        .column(node_col::PARENT_ID)
276        .as_any()
277        .downcast_ref::<StringArray>()
278        .expect("parent_id");
279    let signatures = batch
280        .column(node_col::SIGNATURE)
281        .as_any()
282        .downcast_ref::<StringArray>()
283        .expect("signature");
284    let docstrings = batch
285        .column(node_col::DOCSTRING)
286        .as_any()
287        .downcast_ref::<StringArray>()
288        .expect("docstring");
289    let body_hashes = batch
290        .column(node_col::BODY_HASH)
291        .as_any()
292        .downcast_ref::<StringArray>()
293        .expect("body_hash");
294    let locs = batch
295        .column(node_col::LOC)
296        .as_any()
297        .downcast_ref::<Int32Array>()
298        .expect("loc");
299    let complexities = batch
300        .column(node_col::CYCLOMATIC_COMPLEXITY)
301        .as_any()
302        .downcast_ref::<Int32Array>()
303        .expect("complexity");
304    let coverages = batch
305        .column(node_col::COVERAGE_PCT)
306        .as_any()
307        .downcast_ref::<Float64Array>()
308        .expect("coverage");
309    let kind_dict = batch
310        .column(node_col::KIND)
311        .as_any()
312        .downcast_ref::<arrow::array::Int8DictionaryArray>()
313        .expect("kind dict");
314    let kind_values = kind_dict
315        .values()
316        .as_any()
317        .downcast_ref::<StringArray>()
318        .expect("kind values");
319
320    for i in 0..batch.num_rows() {
321        let kind_key = kind_dict.keys().value(i) as usize;
322        let kind_str = kind_values.value(kind_key);
323        let node = CodeNode {
324            id: ids.value(i).to_string(),
325            kind: CodeNodeKind::parse(kind_str).unwrap_or(CodeNodeKind::Function),
326            parent_id: if parent_ids.is_null(i) {
327                None
328            } else {
329                Some(parent_ids.value(i).to_string())
330            },
331            name: names.value(i).to_string(),
332            signature: if signatures.is_null(i) {
333                None
334            } else {
335                Some(signatures.value(i).to_string())
336            },
337            docstring: if docstrings.is_null(i) {
338                None
339            } else {
340                Some(docstrings.value(i).to_string())
341            },
342            body_hash: if body_hashes.is_null(i) {
343                None
344            } else {
345                Some(body_hashes.value(i).to_string())
346            },
347            body: None, // Body not extracted in impact analysis
348            loc: if locs.is_null(i) {
349                None
350            } else {
351                Some(locs.value(i))
352            },
353            cyclomatic_complexity: if complexities.is_null(i) {
354                None
355            } else {
356                Some(complexities.value(i))
357            },
358            coverage_pct: if coverages.is_null(i) {
359                None
360            } else {
361                Some(coverages.value(i))
362            },
363            last_modified: None,
364            ..Default::default()
365        };
366        map.insert(node.id.clone(), node);
367    }
368
369    map
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::git_tools::codegraph_diff;
376    use crate::schema::{
377        CodeEdge, CodeEdgePredicate, CodeNode, CodeNodeKind, build_code_edges_batch,
378        build_code_nodes_batch,
379    };
380
381    fn sample_nodes() -> Vec<CodeNode> {
382        vec![
383            CodeNode {
384                id: "func:a.py::entry".into(),
385                kind: CodeNodeKind::Function,
386                parent_id: None,
387                name: "entry".into(),
388                signature: Some("def entry()".into()),
389                docstring: None,
390                body_hash: Some("entry_v1".into()),
391                body: None,
392                loc: Some(20),
393                cyclomatic_complexity: Some(3),
394                coverage_pct: None,
395                last_modified: None,
396                ..Default::default()
397            },
398            CodeNode {
399                id: "func:a.py::process".into(),
400                kind: CodeNodeKind::Function,
401                parent_id: None,
402                name: "process".into(),
403                signature: Some("def process(data)".into()),
404                docstring: None,
405                body_hash: Some("proc_v1".into()),
406                body: None,
407                loc: Some(40),
408                cyclomatic_complexity: Some(7),
409                coverage_pct: None,
410                last_modified: None,
411                ..Default::default()
412            },
413            CodeNode {
414                id: "func:b.py::helper".into(),
415                kind: CodeNodeKind::Function,
416                parent_id: None,
417                name: "helper".into(),
418                signature: Some("def helper(x)".into()),
419                docstring: None,
420                body_hash: Some("help_v1".into()),
421                body: None,
422                loc: Some(10),
423                cyclomatic_complexity: Some(1),
424                coverage_pct: None,
425                last_modified: None,
426                ..Default::default()
427            },
428            CodeNode {
429                id: "func:b.py::unrelated".into(),
430                kind: CodeNodeKind::Function,
431                parent_id: None,
432                name: "unrelated".into(),
433                signature: None,
434                docstring: None,
435                body_hash: Some("unrel_v1".into()),
436                body: None,
437                loc: Some(5),
438                cyclomatic_complexity: Some(1),
439                coverage_pct: None,
440                last_modified: None,
441                ..Default::default()
442            },
443            CodeNode {
444                id: "test:tests/test_a.py::test_entry".into(),
445                kind: CodeNodeKind::Test,
446                parent_id: None,
447                name: "test_entry".into(),
448                signature: None,
449                docstring: None,
450                body_hash: Some("te_v1".into()),
451                body: None,
452                loc: Some(8),
453                cyclomatic_complexity: Some(1),
454                coverage_pct: None,
455                last_modified: None,
456                ..Default::default()
457            },
458        ]
459    }
460
461    fn sample_edges() -> Vec<CodeEdge> {
462        vec![
463            // entry → process → helper (call chain)
464            CodeEdge {
465                source_id: "func:a.py::entry".into(),
466                target_id: "func:a.py::process".into(),
467                predicate: CodeEdgePredicate::Calls,
468                weight: Some(1.0),
469                commit_id: None,
470            },
471            CodeEdge {
472                source_id: "func:a.py::process".into(),
473                target_id: "func:b.py::helper".into(),
474                predicate: CodeEdgePredicate::Calls,
475                weight: Some(2.0),
476                commit_id: None,
477            },
478            // test covers entry
479            CodeEdge {
480                source_id: "test:tests/test_a.py::test_entry".into(),
481                target_id: "func:a.py::entry".into(),
482                predicate: CodeEdgePredicate::Tests,
483                weight: Some(1.0),
484                commit_id: None,
485            },
486        ]
487    }
488
489    #[test]
490    fn test_impact_modified_leaf() {
491        // Modify helper (leaf node) — callers: process, entry (transitive)
492        let base = build_code_nodes_batch(&sample_nodes()).unwrap();
493        let mut head_nodes = sample_nodes();
494        head_nodes[2].body_hash = Some("help_v2".into()); // modify helper
495        let head = build_code_nodes_batch(&head_nodes).unwrap();
496        let edges = build_code_edges_batch(&sample_edges()).unwrap();
497
498        let diff = codegraph_diff(&base, &head).unwrap();
499        let report = impact_analysis(&diff, &head, &edges, 3);
500
501        assert_eq!(report.stats.directly_changed, 1);
502        assert_eq!(report.directly_changed[0].node.name, "helper");
503
504        // process calls helper directly
505        assert_eq!(report.stats.direct_callers, 1);
506        assert_eq!(report.direct_callers[0].name, "process");
507
508        // entry calls process which calls helper — transitive
509        // (entry is a transitive caller, not direct)
510        assert!(report.stats.transitive_callers >= 1);
511
512        assert_eq!(report.stats.affected_tests, 1);
513        assert_eq!(report.affected_tests[0].name, "test_entry");
514    }
515
516    #[test]
517    fn test_impact_no_changes() {
518        let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
519        let edges = build_code_edges_batch(&sample_edges()).unwrap();
520        let diff = codegraph_diff(&nodes, &nodes).unwrap();
521
522        let report = impact_analysis(&diff, &nodes, &edges, 3);
523        assert_eq!(report.stats.directly_changed, 0);
524        assert_eq!(report.stats.direct_callers, 0);
525        assert_eq!(report.stats.affected_tests, 0);
526    }
527
528    #[test]
529    fn test_impact_unrelated_change() {
530        // Modify unrelated — no callers, no tests
531        let base = build_code_nodes_batch(&sample_nodes()).unwrap();
532        let mut head_nodes = sample_nodes();
533        head_nodes[3].body_hash = Some("unrel_v2".into());
534        let head = build_code_nodes_batch(&head_nodes).unwrap();
535        let edges = build_code_edges_batch(&sample_edges()).unwrap();
536
537        let diff = codegraph_diff(&base, &head).unwrap();
538        let report = impact_analysis(&diff, &head, &edges, 3);
539
540        assert_eq!(report.stats.directly_changed, 1);
541        assert_eq!(report.stats.direct_callers, 0);
542        assert_eq!(report.stats.transitive_callers, 0);
543        assert_eq!(report.stats.affected_tests, 0);
544    }
545
546    #[test]
547    fn test_impact_depth_limits_transitive() {
548        let base = build_code_nodes_batch(&sample_nodes()).unwrap();
549        let mut head_nodes = sample_nodes();
550        head_nodes[2].body_hash = Some("help_v2".into());
551        let head = build_code_nodes_batch(&head_nodes).unwrap();
552        let edges = build_code_edges_batch(&sample_edges()).unwrap();
553
554        // Depth 1: only direct callers (process), entry is at depth 2
555        let diff = codegraph_diff(&base, &head).unwrap();
556        let report = impact_analysis(&diff, &head, &edges, 1);
557        assert_eq!(report.stats.direct_callers, 1); // process
558        assert_eq!(report.stats.transitive_callers, 0); // entry excluded at depth 1
559    }
560
561    #[test]
562    fn test_impact_removed_node() {
563        let base = build_code_nodes_batch(&sample_nodes()).unwrap();
564        // Remove helper
565        let head_nodes: Vec<CodeNode> = sample_nodes()
566            .into_iter()
567            .filter(|n| n.name != "helper")
568            .collect();
569        let head = build_code_nodes_batch(&head_nodes).unwrap();
570        let edges = build_code_edges_batch(&sample_edges()).unwrap();
571
572        let diff = codegraph_diff(&base, &head).unwrap();
573        let report = impact_analysis(&diff, &head, &edges, 3);
574
575        assert_eq!(report.stats.directly_changed, 1);
576        assert_eq!(report.directly_changed[0].node.name, "helper");
577        assert_eq!(
578            report.directly_changed[0].change_type,
579            CodeDiffChangeType::Removed
580        );
581    }
582
583    #[test]
584    fn test_impact_files_affected() {
585        let base = build_code_nodes_batch(&sample_nodes()).unwrap();
586        let mut head_nodes = sample_nodes();
587        head_nodes[2].body_hash = Some("help_v2".into()); // b.py::helper
588        let head = build_code_nodes_batch(&head_nodes).unwrap();
589        let edges = build_code_edges_batch(&sample_edges()).unwrap();
590
591        let diff = codegraph_diff(&base, &head).unwrap();
592        let report = impact_analysis(&diff, &head, &edges, 3);
593
594        // helper is in b.py, callers (process, entry) are in a.py
595        assert_eq!(report.stats.files_affected, 2);
596    }
597
598    #[test]
599    fn test_format_impact_report() {
600        let base = build_code_nodes_batch(&sample_nodes()).unwrap();
601        let mut head_nodes = sample_nodes();
602        head_nodes[2].body_hash = Some("help_v2".into());
603        let head = build_code_nodes_batch(&head_nodes).unwrap();
604        let edges = build_code_edges_batch(&sample_edges()).unwrap();
605
606        let diff = codegraph_diff(&base, &head).unwrap();
607        let report = impact_analysis(&diff, &head, &edges, 3);
608        let formatted = format_impact_report(&report);
609
610        assert!(formatted.contains("## Impact Analysis"));
611        assert!(formatted.contains("helper"));
612        assert!(formatted.contains("Direct Callers"));
613        assert!(formatted.contains("test_entry"));
614    }
615}