Skip to main content

scc_graph/
impact.rs

1//! Impact analysis (docs/API_AND_INTEGRATIONS.md §2 `impact_context`).
2//!
3//! Given files/symbols/diff, determine: affected components, flows,
4//! upstream/downstream consumers, contracts (routes), data, invariants,
5//! tests, and a risk assessment.
6
7use crate::components::component_for_path;
8use crate::{trust::TrustedGraphView, Result};
9use scc_core::kinds;
10use scc_core::Severity;
11use scc_store::Store;
12use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
13
14#[derive(Debug, Clone, Default, serde::Serialize)]
15// trace:exempt reason=internal-detail
16pub struct Impact {
17    pub files: Vec<String>,
18    pub components: Vec<String>, // component ids
19    pub flows: Vec<String>,      // flow ids
20    pub upstream: Vec<String>,   // component ids that depend on affected
21    pub downstream: Vec<String>, // component ids affected depends on
22    pub contracts: Vec<String>,  // route ids
23    pub data: Vec<String>,       // store/data entity ids
24    pub invariants: Vec<String>, // invariant ids
25    pub tests: Vec<String>,      // test entity ids
26    pub risk: String,            // low | medium | high
27    #[serde(default)]
28    pub notes: Vec<String>,
29    /// Per-file importer closure: (importing file, depth, provenance).
30    /// The primary impact signal — components/flows interpret it, and the
31    /// pack renders it first so a glued component never hides file truth.
32    #[serde(default)]
33    pub importers: Vec<Importer>,
34    /// Historical co-change partners not in the current file set.
35    /// Never merged into `components` / `flows` / `contracts` / `data`.
36    #[serde(default)]
37    pub forgotten_partners: Vec<ForgottenPartner>,
38}
39
40/// A file that historically changes with an affected file but is not in
41/// the current impact file set. Reason is always `cochange` here.
42/// A file importing (transitively) an impact target, with BFS depth and
43/// the provenance of the edge that discovered it.
44#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
45// trace:exempt reason=internal-detail
46pub struct Importer {
47    pub file: String,
48    pub depth: u32,
49    pub provenance: scc_core::Provenance,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
53// trace:exempt reason=internal-detail
54pub struct ForgottenPartner {
55    pub file: String,
56    pub partner: String,
57    pub commits: u32,
58    pub reason: String,
59}
60
61/// Co-change partners of `changed` that are not themselves in `changed`.
62/// Deterministic: sorted by commits desc, then file, then partner.
63// trace:v1 id=impl.scc.impact.forgotten-partners work=WORK-ripwire-lessons-phase6 satisfies=REQ-forgotten-impact-partners
64pub fn forgotten_cochange_partners(
65    pairs: &[crate::cochange::CochangePair],
66    changed: &[String],
67) -> Vec<ForgottenPartner> {
68    let changed_set: std::collections::BTreeSet<&str> =
69        changed.iter().map(|s| s.as_str()).collect();
70    let mut out: Vec<ForgottenPartner> = Vec::new();
71    for pair in pairs {
72        let (file, partner) = if changed_set.contains(pair.a.as_str())
73            && !changed_set.contains(pair.b.as_str())
74        {
75            (pair.a.clone(), pair.b.clone())
76        } else if changed_set.contains(pair.b.as_str()) && !changed_set.contains(pair.a.as_str()) {
77            (pair.b.clone(), pair.a.clone())
78        } else {
79            continue;
80        };
81        out.push(ForgottenPartner {
82            file,
83            partner,
84            commits: pair.commits,
85            reason: "cochange".into(),
86        });
87    }
88    out.sort_by(|a, b| {
89        b.commits
90            .cmp(&a.commits)
91            .then_with(|| a.file.cmp(&b.file))
92            .then_with(|| a.partner.cmp(&b.partner))
93    });
94    out
95}
96
97/// Second wave — same-package / re-export callers: a file whose symbol
98/// calls a symbol defined in an affected file is itself affected, even with
99/// no import edge (Go same-package calls, unresolved receivers, facade
100/// re-exports). Depth-graded like the import wave: seed depth + 1,
101/// transitively.
102#[allow(clippy::too_many_arguments)]
103// trace:v1 id=impl.scc.impact.caller-wave work=WORK-SI-MMMJA4G6 satisfies=REQ-SCC-IR
104fn caller_wave(
105    view: &TrustedGraphView,
106    graph: &crate::RealityGraph,
107    importer_depth: &mut BTreeMap<String, u32>,
108    importer_prov: &mut BTreeMap<String, scc_core::Provenance>,
109    max_depth: u32,
110) {
111    let mut queue: VecDeque<(String, u32)> = importer_depth.iter().map(|(f, d)| (f.clone(), *d)).collect();
112        // callee symbol id -> defining file (sweep once, not per edge).
113        let mut sym_file: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
114        for e in graph.entities_of_kind(kinds::SYMBOL) {
115            if let Some(f) = e.attributes.get("file").and_then(|v| v.as_str()) {
116                sym_file.insert(e.id.as_str(), f);
117            }
118        }
119        while let Some((path, depth)) = queue.pop_front() {
120            if depth >= max_depth {
121                continue;
122            }
123            let target_file_id = scc_core::entity_id(&graph.repo_id, kinds::FILE, &path);
124            // symbols defined in this file: CONTAINS edges file -> symbol.
125            let mut owned: Vec<&str> = Vec::new();
126            for r in view.out_pred(&target_file_id, scc_core::predicates::CONTAINS) {
127                owned.push(r.object.as_str());
128            }
129            // reverse call edges into those symbols.
130            let mut callers: Vec<(&str, scc_core::Provenance)> = Vec::new();
131            for sym in &owned {
132                for r in view.in_pred(sym, scc_core::predicates::CALLS) {
133                    callers.push((r.subject.as_str(), r.provenance));
134                }
135            }
136            callers.sort_by(|a, b| a.0.cmp(b.0));
137            callers.dedup_by(|a, b| a.0 == b.0);
138            for (caller_id, prov) in callers {
139                let Some(caller_file) = sym_file.get(caller_id).copied() else { continue };
140                if caller_file == path {
141                    continue;
142                }
143                match importer_depth.get(caller_file) {
144                    Some(&d) if d <= depth + 1 => {}
145                    _ => {
146                        importer_depth.insert(caller_file.to_string(), depth + 1);
147                        importer_prov.entry(caller_file.to_string()).or_insert(prov);
148                        queue.push_back((caller_file.to_string(), depth + 1));
149                    }
150                }
151            }
152        }
153    }
154
155// trace:v1 id=impl.scc.impact work=WORK-SCC-013 satisfies=REQ-forgotten-impact-partners,REQ-SCC-IR
156// trace:v1 id=impl.scc.impact.importers work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
157// trace:v1 id=impl.scc.impact.flowexact work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
158// trace:v1 id=impl.impact-import-fallback work=WORK-SI-Z1KJWXDQ satisfies=REQ-SI-503JSBGP
159pub fn compute_impact(
160    view: &TrustedGraphView,
161    store: &Store,
162    files: &[String],
163    symbols: &[String],
164) -> Result<Impact> {
165    let graph = &view.graph;
166    let mut imp = Impact::default();
167
168    let file_ids: HashSet<String> = files
169        .iter()
170        .map(|f| scc_core::entity_id(&graph.repo_id, kinds::FILE, f))
171        .collect();
172    // Ghost inputs must never produce a confident report: partition the
173    // requested files into indexed vs unknown. Unknown targets are reported
174    // in notes (partial) or refuse the whole query (all unknown).
175    let (resolved_files, unresolved_files): (Vec<&String>, Vec<&String>) =
176        files.iter().partition(|f| {
177            view.entity(&scc_core::entity_id(&graph.repo_id, kinds::FILE, f)).is_some()
178        });
179    let sym_ids: HashSet<String> = symbols
180        .iter()
181        .map(|s| scc_core::symbol_id(&graph.repo_id, "?", s))
182        .collect();
183    // symbols may be given as plain names — resolve against the index
184    // (one full symbol sweep, not one per requested symbol).
185    let mut resolved_sym_ids: HashSet<String> = HashSet::new();
186    let all_symbols = graph.entities_of_kind(kinds::SYMBOL);
187    for s in symbols {
188        let matches: Vec<String> = all_symbols
189            .iter()
190            .filter(|e| e.name == *s)
191            .map(|e| e.id.clone())
192            .collect();
193        if matches.is_empty() {
194            // exact entity id?
195            if view.entity(s).is_some() {
196                resolved_sym_ids.insert(s.clone());
197            }
198        } else {
199            resolved_sym_ids.extend(matches);
200        }
201    }
202    for id in &sym_ids {
203        // symbol_id with "?" file is a miss; resolved ones are real
204        if !id.ends_with("/?/") && view.entity(id).is_some() {
205            resolved_sym_ids.insert(id.clone());
206        }
207    }
208    // File-scoped symbols: index symbols by file ONCE, then take only the
209    // requested files' symbols. The old code swept all symbols per query;
210    // scoping keeps this linear in the request, not the repo.
211    {
212        let mut by_file: HashSet<String> = HashSet::new();
213        for f in &resolved_files {
214            by_file.insert((*f).clone());
215        }
216        for e in &all_symbols {
217            if let Some(f) = e.attributes.get("file").and_then(|v| v.as_str()) {
218                if by_file.contains(f) {
219                    resolved_sym_ids.insert(e.id.clone());
220                }
221            }
222        }
223    }
224
225    // Cochange history is a legitimate signal on paths without file entities
226    // (an unindexed repo still has git history): only refuse when there is
227    // genuinely nothing to analyze — no resolved graph targets AND no
228    // cochange pair touching a requested file. Otherwise proceed with a note.
229    let pairs = crate::cochange::cached_cochange_pairs(store).unwrap_or_default();
230    let has_cochange = files.iter().any(|f| {
231        pairs.iter().any(|p| p.a == **f || p.b == **f)
232    });
233    if !files.is_empty() && resolved_files.is_empty() && resolved_sym_ids.is_empty() && !has_cochange {
234        let mut unknown: Vec<String> = unresolved_files.iter().map(|s| s.to_string()).collect();
235        unknown.extend(symbols.iter().filter(|s| {
236            !resolved_sym_ids.iter().any(|r| r == *s || r.ends_with(&format!("/{s}")))
237        }).cloned());
238        unknown.sort();
239        unknown.dedup();
240        return Err(crate::GraphError::Impact(format!(
241            "unknown target(s) {} — not in the index; refusing to fabricate",
242            unknown.join(", ")
243        )));
244    }
245    for f in &unresolved_files {
246        imp.notes.push(format!("unknown target '{f}': not in index, excluded from analysis"));
247    }
248
249    // Per-file importer closure (the PRIMARY impact signal): BFS over
250    // `imports` edges reversed (importer -> imported), seeded from the
251    // resolved files. Components/flows below are interpretations of this
252    // closure, not the closure itself — so a glued mega-component can make
253    // them soupy without corrupting the file answer. Bounded: visited-set
254    // dedup makes each file expand once; depth caps the fan-out. Provenance
255    // travels with the edge; the shallowest depth wins on re-visit.
256    let mut importer_depth: BTreeMap<String, u32> = BTreeMap::new();
257    let mut importer_prov: BTreeMap<String, scc_core::Provenance> = BTreeMap::new();
258    // trace:exempt reason=const-data
259    const IMPORTER_MAX_DEPTH: u32 = 8;
260    {
261        let mut queue: VecDeque<(String, u32)> = VecDeque::new();
262        for f in &resolved_files {
263            importer_depth.insert((*f).clone(), 0);
264            queue.push_back(((*f).clone(), 0));
265        }
266        while let Some((path, depth)) = queue.pop_front() {
267            if depth >= IMPORTER_MAX_DEPTH {
268                continue;
269            }
270            let target_id = scc_core::entity_id(&graph.repo_id, kinds::FILE, &path);
271            for r in view.in_pred(&target_id, scc_core::predicates::IMPORTS) {
272                let importer = match view.entity(&r.subject) {
273                    Some(e) if e.kind == kinds::FILE => e.name.clone(),
274                    _ => continue,
275                };
276                if files.iter().any(|f| f == &importer) {
277                    continue;
278                }
279                match importer_depth.get(&importer) {
280                    Some(&d) if d <= depth + 1 => {}
281                    _ => {
282                        importer_depth.insert(importer.clone(), depth + 1);
283                        importer_prov.insert(importer.clone(), r.provenance);
284                        queue.push_back((importer, depth + 1));
285                    }
286                }
287            }
288        }
289    }
290
291    caller_wave(view, graph, &mut importer_depth, &mut importer_prov, IMPORTER_MAX_DEPTH);
292
293    // Issue #15 supplement: consult the indexed `imports` rows directly
294    // (the same table the tests-to-run signal uses) for any wanted file
295    // the edge waves missed. Edge BFS misses when the changed file is
296    // imported through a form the resolver marks External/Unresolved
297    // (`from src import util` -> external_api `src`), but the imports
298    // row still names the importing file. Runs as a supplement, not an
299    // all-or-nothing fallback: files the waves already found keep their
300    // wave depth (contains_key guard); only genuinely missed files are
301    // added, and only indexed files — never fabricated, never external.
302    {
303        // trace:inherit impl.scc.impact reason=import-row-supplement-inside-compute-impact
304        let wanted: std::collections::BTreeSet<String> = resolved_files
305            .iter()
306            .map(|f| (*f).clone())
307            .chain(resolved_sym_ids.iter().filter_map(|id| {
308                view.entity(id)
309                    .and_then(|e| e.attributes.get("file"))
310                    .and_then(|v| v.as_str())
311                    .map(|f| f.to_string())
312            }))
313            .collect();
314        if !wanted.is_empty() {
315            if let Ok(rows) = store.all_imports() {
316                for (file, module, names, _line, _typ) in &rows {
317                    if files.iter().any(|f| f == file) {
318                        continue;
319                    }
320                    if importer_depth.contains_key(file) {
321                        continue;
322                    }
323                    if view.entity(&scc_core::entity_id(&graph.repo_id, kinds::FILE, file)).is_none() {
324                        continue;
325                    }
326                    let target = module.replace('.', "/");
327                    let hit = wanted.iter().any(|w| {
328                        let stem = w.rsplit_once('.').map(|(s, _)| s).unwrap_or(w);
329                        // member-exact: `from src import util` names stem
330                        // `src/util` exactly — precise, no fuzzy over-match.
331                        let member_exact = names.iter().any(|(local, imported)| {
332                            format!("{target}/{imported}") == stem
333                                || format!("{target}/{local}") == stem
334                        });
335                        member_exact
336                            || *w == target
337                            || w.starts_with(&format!("{target}/"))
338                            || target == *stem
339                            || target.starts_with(&format!("{stem}/"))
340                            || w.ends_with(&format!("/{target}"))
341                    });
342                    if hit {
343                        importer_depth.insert(file.clone(), 1);
344                        importer_prov.insert(file.clone(), scc_core::Provenance::Extracted);
345                    }
346                }
347            }
348        }
349    }
350
351    for (file, depth) in &importer_depth {
352        if *depth == 0 {
353            continue;
354        }
355        imp.importers.push(Importer {
356            file: file.clone(),
357            depth: *depth,
358            provenance: importer_prov.get(file).copied().unwrap_or(scc_core::Provenance::Extracted),
359        });
360    }
361    imp.importers.sort_by(|a, b| {
362        a.depth.cmp(&b.depth).then_with(|| a.file.cmp(&b.file))
363    });
364
365    // affected components: components containing affected files or symbols
366    let mut affected_comps: BTreeSet<String> = BTreeSet::new();
367    let comps = store.components()?;
368    for c in &comps {
369        let paths: Vec<String> = c
370            .attributes
371            .get("implementation")
372            .and_then(|i| i.get("paths"))
373            .and_then(|p| p.as_array())
374            .map(|a| {
375                a.iter()
376                    .filter_map(|x| x.as_str().map(|s| s.to_string()))
377                    .collect()
378            })
379            .unwrap_or_default();
380        let symbols_list: Vec<String> = c
381            .attributes
382            .get("implementation")
383            .and_then(|i| i.get("symbols"))
384            .and_then(|p| p.as_array())
385            .map(|a| {
386                a.iter()
387                    .filter_map(|x| x.as_str().map(|s| s.to_string()))
388                    .collect()
389            })
390            .unwrap_or_default();
391        for f in &resolved_files {
392            let seg = component_for_path(f, &component_candidates(&comps));
393            if seg == c.name {
394                affected_comps.insert(c.id.clone());
395            }
396        }
397        if !resolved_sym_ids.is_empty() {
398            // resolved name-set built once per component scan (not once per
399            // symbol): the inner entities_of_kind sweep was O(comps × syms).
400            let sym_names: HashSet<String> = all_symbols
401                .iter()
402                .filter(|e| resolved_sym_ids.contains(&e.id))
403                .map(|e| e.name.clone())
404                .collect();
405            for s in &symbols_list {
406                if sym_names.contains(s) {
407                    affected_comps.insert(c.id.clone());
408                    break;
409                }
410            }
411        }
412        let _ = paths;
413    }
414    // also via contains relationships
415    for c in &comps {
416        for r in view.out_pred(&c.id, scc_core::predicates::CONTAINS) {
417            if file_ids.contains(&r.object) {
418                affected_comps.insert(c.id.clone());
419            }
420        }
421    }
422
423    imp.components = affected_comps.iter().cloned().collect();
424
425    // flows containing affected components or their symbols
426    let mut affected_syms: HashSet<String> = HashSet::new();
427    for cid in &affected_comps {
428        for r in view.out_pred(cid, scc_core::predicates::CONTAINS) {
429            // file ids, expand to symbols
430            for sr in view.out_pred(&r.object, scc_core::predicates::CONTAINS) {
431                affected_syms.insert(sr.object.clone());
432            }
433        }
434    }
435    affected_syms.extend(resolved_sym_ids.iter().cloned());
436
437    // Flow matching by exact step identity — never substring. Steps carry
438    // (actor, operation) over component/symbol ids; the old code ran a
439    // steps×(components+symbols+files) `contains` matrix, which is both
440    // quadratic AND wrong (component "api" matches every actor containing
441    // those letters; django never finished). Exact id membership is linear
442    // in total steps and terminates by construction — no time budget needed.
443    let mut seen_flows: HashSet<&str> = HashSet::new();
444    let all_flows = view.flows();
445    // affected files' ids + resolved symbol ids: the exact step vocabulary.
446    // (owned Strings — file_ids/affected_comps outlive this block.)
447    let mut step_vocab: HashSet<&str> = HashSet::new();
448    for id in &file_ids {
449        step_vocab.insert(id.as_str());
450    }
451    for sid in &resolved_sym_ids {
452        step_vocab.insert(sid.as_str());
453    }
454    for cid in &affected_comps {
455        step_vocab.insert(cid.as_str());
456    }
457    // requested file names too (steps sometimes name the path, not the id).
458    for f in files {
459        step_vocab.insert(f.as_str());
460    }
461    for flow in &all_flows {
462        let steps_mention = flow.steps.iter().any(|s| {
463            step_vocab.contains(s.actor.as_str()) || step_vocab.contains(s.operation.as_str())
464        });
465        if steps_mention && seen_flows.insert(flow.id.as_str()) {
466            imp.flows.push(flow.id.clone());
467        }
468    }
469    // entrypoint attribute on flows
470    for flow in &all_flows {
471        if let Some(ep) = flow.attributes.get("entrypoint").and_then(|v| v.as_str()) {
472            if affected_syms.contains(ep) && seen_flows.insert(flow.id.as_str()) {
473                imp.flows.push(flow.id.clone());
474            }
475        }
476    }
477
478    // upstream (depend on affected) / downstream (affected depends on)
479    for cid in &affected_comps {
480        for r in view.out_pred(cid, scc_core::predicates::DEPENDS_ON) {
481            imp.downstream.push(r.object.clone());
482        }
483        for r in view.in_pred(cid, scc_core::predicates::DEPENDS_ON) {
484            imp.upstream.push(r.subject.clone());
485        }
486    }
487    imp.upstream.sort();
488    imp.upstream.dedup();
489    imp.downstream.sort();
490    imp.downstream.dedup();
491
492    // contracts: routes handled by affected symbols
493    for sid in &affected_syms {
494        for r in view.out_pred(sid, scc_core::predicates::HANDLES) {
495            imp.contracts.push(r.object.clone());
496        }
497    }
498
499    // data: stores owned by affected components + accessed by affected symbols
500    for cid in &affected_comps {
501        for r in view.out_pred(cid, scc_core::predicates::OWNS) {
502            imp.data.push(r.object.clone());
503        }
504    }
505    for sid in &affected_syms {
506        for pred in ["reads", "writes", "queries"] {
507            for r in view.out_pred(sid, pred) {
508                imp.data.push(r.object.clone());
509            }
510        }
511    }
512    imp.data.sort();
513    imp.data.dedup();
514
515    // invariants whose scope intersects affected entities
516    for inv in &view.invariants() {
517        let scoped = inv
518            .scope
519            .iter()
520            .any(|s| affected_comps.contains(s) || imp.data.contains(s));
521        if scoped {
522            imp.invariants.push(inv.id.clone());
523        }
524    }
525
526    // tests covering affected symbols
527    for sid in &affected_syms {
528        for r in view.out_pred(sid, scc_core::predicates::TESTED_BY) {
529            imp.tests.push(r.object.clone());
530        }
531    }
532    imp.tests.sort();
533    imp.tests.dedup();
534
535    imp.files = files.to_vec();
536
537    // risk: high if critical invariants affected or contracts changed;
538    // medium if flows affected; else low
539    let critical_invariants = imp
540        .invariants
541        .iter()
542        .filter(|iid| {
543            graph
544                .invariants
545                .iter()
546                .find(|i| i.id == **iid)
547                .map(|i| i.severity == Severity::Critical)
548                .unwrap_or(false)
549        })
550        .count();
551    if critical_invariants > 0 || !imp.contracts.is_empty() {
552        imp.risk = "high".into();
553        if critical_invariants > 0 {
554            imp.notes.push(format!(
555                "{critical_invariants} critical invariant(s) in scope of the change"
556            ));
557        }
558        if !imp.contracts.is_empty() {
559            imp.notes.push(format!(
560                "{} API contract(s) (routes) affected — consumers may break",
561                imp.contracts.len()
562            ));
563        }
564    } else if !imp.flows.is_empty() || !imp.tests.is_empty() {
565        imp.risk = "medium".into();
566    } else {
567        imp.risk = "low".into();
568    }
569    if !imp.tests.is_empty() {
570        imp.notes.push(format!(
571            "{} test(s) exercise the affected code",
572            imp.tests.len()
573        ));
574    }
575
576    let pairs = crate::cochange::cached_cochange_pairs(store).unwrap_or_default();
577    imp.forgotten_partners = forgotten_cochange_partners(&pairs, &imp.files);
578    if !imp.forgotten_partners.is_empty() {
579        imp.notes.push(format!(
580            "{} forgotten co-change partner(s) — historical, not EXTRACTED impact",
581            imp.forgotten_partners.len()
582        ));
583    }
584
585    Ok(imp)
586}
587
588// trace:exempt reason=internal-detail
589fn component_candidates(comps: &[scc_core::Entity]) -> Vec<crate::components::ComponentCandidate> {
590    comps
591        .iter()
592        .map(|c| {
593            let mut dirs: Vec<String> = c
594                .attributes
595                .get("implementation")
596                .and_then(|i| i.get("paths"))
597                .and_then(|p| p.as_array())
598                .map(|a| {
599                    a.iter()
600                        .filter_map(|x| x.as_str().map(|s| s.to_string()))
601                        .collect()
602                })
603                .unwrap_or_default();
604            if dirs.is_empty() {
605                dirs.push(c.name.clone());
606            }
607            crate::components::ComponentCandidate {
608                name: c.name.clone(),
609                dirs,
610                boundary_kind: c
611                    .attributes
612                    .get("boundary_kind")
613                    .and_then(|v| v.as_str())
614                    .map(|s| s.to_string())
615                    .unwrap_or_else(|| crate::components::BOUNDARY_CODE_REGION.to_string()),
616                intent: c
617                    .attributes
618                    .get("intent")
619                    .and_then(|v| v.as_str())
620                    .map(|s| s.to_string()),
621            }
622        })
623        .collect()
624}
625
626/// Files/symbols in the current diff (git diff --name-only).
627// trace:exempt reason=internal-detail
628pub fn diff_files(store: &Store, base: Option<&str>) -> Result<Vec<String>> {
629    let root = &store.root;
630    let mut cmd = std::process::Command::new("git");
631    cmd.args(["diff", "--name-only", "--diff-filter=ACMRT"]);
632    if let Some(b) = base {
633        cmd.arg(format!("{b}...HEAD"));
634    } else {
635        cmd.arg("HEAD");
636    }
637    cmd.arg("--");
638    let out = cmd
639        .current_dir(root)
640        .output()
641        .map_err(|e| scc_store::StoreError::NotInitialized(format!("git diff failed: {e}")))?;
642    let mut files = Vec::new();
643    for line in String::from_utf8_lossy(&out.stdout).lines() {
644        let line = line.trim();
645        if !line.is_empty() {
646            files.push(line.to_string());
647        }
648    }
649    Ok(files)
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655
656    // trace:exempt reason=unit-test
657    fn fixture_two_file_call() -> (tempfile::TempDir, Store) {
658        use scc_core::{Entity, Relationship};
659        let dir = tempfile::TempDir::new().unwrap();
660        let root = dir.path().join("repo");
661        std::fs::create_dir_all(&root).unwrap();
662        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
663        let repo = store.repository().id.clone();
664        // a.go defines Svc.Do; b.go calls it with no import edge
665        // (same-package shape).
666        for (path, sym) in [("a.go", "Svc.Do"), ("b.go", "main")] {
667            let fid = scc_core::entity_id(&repo, kinds::FILE, path);
668            store.insert_entity(&Entity::new(fid.clone(), kinds::FILE, path.to_string()), &[path.to_string()]).unwrap();
669            let sid = scc_core::entity_id(&repo, kinds::SYMBOL, &format!("{path}/{sym}"));
670            let mut se = Entity::new(sid.clone(), kinds::SYMBOL, sym.to_string());
671            se.attr("file", serde_json::json!(path));
672            store.insert_entity(&se, &[path.to_string()]).unwrap();
673            store.insert_relationship(&Relationship::new(
674                format!("contains-{path}"), fid, scc_core::predicates::CONTAINS, sid, scc_core::Provenance::Extracted,
675            ), path).unwrap();
676        }
677        let a_sym = scc_core::entity_id(&repo, kinds::SYMBOL, "a.go/Svc.Do");
678        let b_sym = scc_core::entity_id(&repo, kinds::SYMBOL, "b.go/main");
679        store.insert_relationship(&Relationship::new(
680            "calls-b-a".to_string(), b_sym, scc_core::predicates::CALLS, a_sym, scc_core::Provenance::Extracted,
681        ), "b.go").unwrap();
682        (dir, store)
683    }
684
685    #[test]
686    // trace:v1 id=test.impact-import-fallback-recall work=WORK-SI-Z1KJWXDQ satisfies=REQ-SI-503JSBGP exercises=impl.impact-import-fallback
687    fn import_row_fallback_names_package_member_importer() {
688        use scc_core::Entity;
689        // `from src import util` resolves External (no file edge), but the
690        // imports row still names the importing file — the fallback must
691        // surface it at file level instead of answering 0%.
692        let dir = tempfile::TempDir::new().unwrap();
693        let root = dir.path().join("repo");
694        std::fs::create_dir_all(root.join("src")).unwrap();
695        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
696        let repo = store.repository().id.clone();
697        for path in ["src/util.py", "src/viapkg.py"] {
698            let fid = scc_core::entity_id(&repo, kinds::FILE, path);
699            store.insert_entity(&Entity::new(fid, kinds::FILE, path.to_string()), &[path.to_string()]).unwrap();
700        }
701        store.insert_imports("src/viapkg.py", &[("src".into(), vec![("util".into(), "util".into())], 1, "member".into())]).unwrap();
702        let g = crate::RealityGraph::load(&store).unwrap();
703        let v = TrustedGraphView::new(&g, &store, &[], crate::TrustPolicy::default());
704        let imp = compute_impact(&v, &store, &["src/util.py".to_string()], &[]).unwrap();
705        assert!(imp.importers.iter().any(|i| i.file == "src/viapkg.py"), "{imp:?}");
706    }
707
708    #[test]
709    // trace:v1 id=test.scc.impact.caller-wave verifies=REQ-SCC-IR exercises=impl.scc.impact.caller-wave
710    fn caller_wave_pulls_same_package_callers() {
711        let (_dir, store) = fixture_two_file_call();
712        let g = crate::RealityGraph::load(&store).unwrap();
713        let v = TrustedGraphView::new(&g, &store, &[], crate::TrustPolicy::default());
714        let imp = compute_impact(&v, &store, &["a.go".to_string()], &[]).unwrap();
715        assert!(imp.importers.iter().any(|i| i.file == "b.go"), "{imp:?}");
716    }
717
718    #[test]
719    // trace:exempt reason=internal-detail
720    fn empty_impact() {
721        let dir = tempfile::TempDir::new().unwrap();
722        let root = dir.path().join("repo");
723        std::fs::create_dir_all(&root).unwrap();
724        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
725        let g = crate::RealityGraph::load(&store).unwrap();
726        let v = TrustedGraphView::new(&g, &store, &[], crate::TrustPolicy::default());
727        let imp = compute_impact(&v, &store, &[], &[]).unwrap();
728        assert!(imp.components.is_empty());
729        assert_eq!(imp.risk, "low");
730        assert!(imp.forgotten_partners.is_empty());
731    }
732
733    #[test]
734    // trace:v1 id=test.scc.impact.ghost-target-refused verifies=REQ-SI-503JSBGP exercises=impl.scc.impact
735    fn ghost_target_refused() {
736        let dir = tempfile::TempDir::new().unwrap();
737        let root = dir.path().join("repo");
738        std::fs::create_dir_all(&root).unwrap();
739        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
740        let g = crate::RealityGraph::load(&store).unwrap();
741        let v = TrustedGraphView::new(&g, &store, &[], crate::TrustPolicy::default());
742        let err = compute_impact(&v, &store, &["src/handle.rs".into()], &[])
743            .expect_err("nonexistent path must not produce a report");
744        assert!(err.to_string().contains("refusing to fabricate"), "{err}");
745        assert!(err.to_string().contains("src/handle.rs"), "{err}");
746    }
747
748    #[test]
749    // trace:v1 id=test.scc.impact.forgotten-partners verifies=REQ-forgotten-impact-partners exercises=impl.scc.impact.forgotten-partners
750    fn forgotten_partners_are_not_semantic_impact() {
751        let pairs = vec![
752            crate::cochange::CochangePair {
753                a: "src/a.py".into(),
754                b: "src/b.py".into(),
755                commits: 3,
756            },
757            crate::cochange::CochangePair {
758                a: "src/a.py".into(),
759                b: "src/c.py".into(),
760                commits: 2,
761            },
762        ];
763        let found = forgotten_cochange_partners(&pairs, &["src/a.py".into()]);
764        assert_eq!(found.len(), 2);
765        assert_eq!(found[0].partner, "src/b.py");
766        assert_eq!(found[0].commits, 3);
767        assert_eq!(found[0].reason, "cochange");
768        assert_eq!(found[1].partner, "src/c.py");
769        let none = forgotten_cochange_partners(
770            &pairs,
771            &["src/a.py".into(), "src/b.py".into(), "src/c.py".into()],
772        );
773        assert!(
774            none.is_empty(),
775            "partners already in the change set are not forgotten"
776        );
777    }
778}