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
158pub fn compute_impact(
159    view: &TrustedGraphView,
160    store: &Store,
161    files: &[String],
162    symbols: &[String],
163) -> Result<Impact> {
164    let graph = &view.graph;
165    let mut imp = Impact::default();
166
167    let file_ids: HashSet<String> = files
168        .iter()
169        .map(|f| scc_core::entity_id(&graph.repo_id, kinds::FILE, f))
170        .collect();
171    // Ghost inputs must never produce a confident report: partition the
172    // requested files into indexed vs unknown. Unknown targets are reported
173    // in notes (partial) or refuse the whole query (all unknown).
174    let (resolved_files, unresolved_files): (Vec<&String>, Vec<&String>) =
175        files.iter().partition(|f| {
176            view.entity(&scc_core::entity_id(&graph.repo_id, kinds::FILE, f)).is_some()
177        });
178    let sym_ids: HashSet<String> = symbols
179        .iter()
180        .map(|s| scc_core::symbol_id(&graph.repo_id, "?", s))
181        .collect();
182    // symbols may be given as plain names — resolve against the index
183    // (one full symbol sweep, not one per requested symbol).
184    let mut resolved_sym_ids: HashSet<String> = HashSet::new();
185    let all_symbols = graph.entities_of_kind(kinds::SYMBOL);
186    for s in symbols {
187        let matches: Vec<String> = all_symbols
188            .iter()
189            .filter(|e| e.name == *s)
190            .map(|e| e.id.clone())
191            .collect();
192        if matches.is_empty() {
193            // exact entity id?
194            if view.entity(s).is_some() {
195                resolved_sym_ids.insert(s.clone());
196            }
197        } else {
198            resolved_sym_ids.extend(matches);
199        }
200    }
201    for id in &sym_ids {
202        // symbol_id with "?" file is a miss; resolved ones are real
203        if !id.ends_with("/?/") && view.entity(id).is_some() {
204            resolved_sym_ids.insert(id.clone());
205        }
206    }
207    // File-scoped symbols: index symbols by file ONCE, then take only the
208    // requested files' symbols. The old code swept all symbols per query;
209    // scoping keeps this linear in the request, not the repo.
210    {
211        let mut by_file: HashSet<String> = HashSet::new();
212        for f in &resolved_files {
213            by_file.insert((*f).clone());
214        }
215        for e in &all_symbols {
216            if let Some(f) = e.attributes.get("file").and_then(|v| v.as_str()) {
217                if by_file.contains(f) {
218                    resolved_sym_ids.insert(e.id.clone());
219                }
220            }
221        }
222    }
223
224    // Cochange history is a legitimate signal on paths without file entities
225    // (an unindexed repo still has git history): only refuse when there is
226    // genuinely nothing to analyze — no resolved graph targets AND no
227    // cochange pair touching a requested file. Otherwise proceed with a note.
228    let pairs = crate::cochange::cached_cochange_pairs(store).unwrap_or_default();
229    let has_cochange = files.iter().any(|f| {
230        pairs.iter().any(|p| p.a == **f || p.b == **f)
231    });
232    if !files.is_empty() && resolved_files.is_empty() && resolved_sym_ids.is_empty() && !has_cochange {
233        let mut unknown: Vec<String> = unresolved_files.iter().map(|s| s.to_string()).collect();
234        unknown.extend(symbols.iter().filter(|s| {
235            !resolved_sym_ids.iter().any(|r| r == *s || r.ends_with(&format!("/{s}")))
236        }).cloned());
237        unknown.sort();
238        unknown.dedup();
239        return Err(crate::GraphError::Impact(format!(
240            "unknown target(s) {} — not in the index; refusing to fabricate",
241            unknown.join(", ")
242        )));
243    }
244    for f in &unresolved_files {
245        imp.notes.push(format!("unknown target '{f}': not in index, excluded from analysis"));
246    }
247
248    // Per-file importer closure (the PRIMARY impact signal): BFS over
249    // `imports` edges reversed (importer -> imported), seeded from the
250    // resolved files. Components/flows below are interpretations of this
251    // closure, not the closure itself — so a glued mega-component can make
252    // them soupy without corrupting the file answer. Bounded: visited-set
253    // dedup makes each file expand once; depth caps the fan-out. Provenance
254    // travels with the edge; the shallowest depth wins on re-visit.
255    let mut importer_depth: BTreeMap<String, u32> = BTreeMap::new();
256    let mut importer_prov: BTreeMap<String, scc_core::Provenance> = BTreeMap::new();
257    // trace:exempt reason=const-data
258    const IMPORTER_MAX_DEPTH: u32 = 8;
259    {
260        let mut queue: VecDeque<(String, u32)> = VecDeque::new();
261        for f in &resolved_files {
262            importer_depth.insert((*f).clone(), 0);
263            queue.push_back(((*f).clone(), 0));
264        }
265        while let Some((path, depth)) = queue.pop_front() {
266            if depth >= IMPORTER_MAX_DEPTH {
267                continue;
268            }
269            let target_id = scc_core::entity_id(&graph.repo_id, kinds::FILE, &path);
270            for r in view.in_pred(&target_id, scc_core::predicates::IMPORTS) {
271                let importer = match view.entity(&r.subject) {
272                    Some(e) if e.kind == kinds::FILE => e.name.clone(),
273                    _ => continue,
274                };
275                if files.iter().any(|f| f == &importer) {
276                    continue;
277                }
278                match importer_depth.get(&importer) {
279                    Some(&d) if d <= depth + 1 => {}
280                    _ => {
281                        importer_depth.insert(importer.clone(), depth + 1);
282                        importer_prov.insert(importer.clone(), r.provenance);
283                        queue.push_back((importer, depth + 1));
284                    }
285                }
286            }
287        }
288    }
289
290    caller_wave(view, graph, &mut importer_depth, &mut importer_prov, IMPORTER_MAX_DEPTH);
291
292    for (file, depth) in &importer_depth {
293        if *depth == 0 {
294            continue;
295        }
296        imp.importers.push(Importer {
297            file: file.clone(),
298            depth: *depth,
299            provenance: importer_prov.get(file).copied().unwrap_or(scc_core::Provenance::Extracted),
300        });
301    }
302    imp.importers.sort_by(|a, b| {
303        a.depth.cmp(&b.depth).then_with(|| a.file.cmp(&b.file))
304    });
305
306    // affected components: components containing affected files or symbols
307    let mut affected_comps: BTreeSet<String> = BTreeSet::new();
308    let comps = store.components()?;
309    for c in &comps {
310        let paths: Vec<String> = c
311            .attributes
312            .get("implementation")
313            .and_then(|i| i.get("paths"))
314            .and_then(|p| p.as_array())
315            .map(|a| {
316                a.iter()
317                    .filter_map(|x| x.as_str().map(|s| s.to_string()))
318                    .collect()
319            })
320            .unwrap_or_default();
321        let symbols_list: Vec<String> = c
322            .attributes
323            .get("implementation")
324            .and_then(|i| i.get("symbols"))
325            .and_then(|p| p.as_array())
326            .map(|a| {
327                a.iter()
328                    .filter_map(|x| x.as_str().map(|s| s.to_string()))
329                    .collect()
330            })
331            .unwrap_or_default();
332        for f in &resolved_files {
333            let seg = component_for_path(f, &component_candidates(&comps));
334            if seg == c.name {
335                affected_comps.insert(c.id.clone());
336            }
337        }
338        if !resolved_sym_ids.is_empty() {
339            // resolved name-set built once per component scan (not once per
340            // symbol): the inner entities_of_kind sweep was O(comps × syms).
341            let sym_names: HashSet<String> = all_symbols
342                .iter()
343                .filter(|e| resolved_sym_ids.contains(&e.id))
344                .map(|e| e.name.clone())
345                .collect();
346            for s in &symbols_list {
347                if sym_names.contains(s) {
348                    affected_comps.insert(c.id.clone());
349                    break;
350                }
351            }
352        }
353        let _ = paths;
354    }
355    // also via contains relationships
356    for c in &comps {
357        for r in view.out_pred(&c.id, scc_core::predicates::CONTAINS) {
358            if file_ids.contains(&r.object) {
359                affected_comps.insert(c.id.clone());
360            }
361        }
362    }
363
364    imp.components = affected_comps.iter().cloned().collect();
365
366    // flows containing affected components or their symbols
367    let mut affected_syms: HashSet<String> = HashSet::new();
368    for cid in &affected_comps {
369        for r in view.out_pred(cid, scc_core::predicates::CONTAINS) {
370            // file ids, expand to symbols
371            for sr in view.out_pred(&r.object, scc_core::predicates::CONTAINS) {
372                affected_syms.insert(sr.object.clone());
373            }
374        }
375    }
376    affected_syms.extend(resolved_sym_ids.iter().cloned());
377
378    // Flow matching by exact step identity — never substring. Steps carry
379    // (actor, operation) over component/symbol ids; the old code ran a
380    // steps×(components+symbols+files) `contains` matrix, which is both
381    // quadratic AND wrong (component "api" matches every actor containing
382    // those letters; django never finished). Exact id membership is linear
383    // in total steps and terminates by construction — no time budget needed.
384    let mut seen_flows: HashSet<&str> = HashSet::new();
385    let all_flows = view.flows();
386    // affected files' ids + resolved symbol ids: the exact step vocabulary.
387    // (owned Strings — file_ids/affected_comps outlive this block.)
388    let mut step_vocab: HashSet<&str> = HashSet::new();
389    for id in &file_ids {
390        step_vocab.insert(id.as_str());
391    }
392    for sid in &resolved_sym_ids {
393        step_vocab.insert(sid.as_str());
394    }
395    for cid in &affected_comps {
396        step_vocab.insert(cid.as_str());
397    }
398    // requested file names too (steps sometimes name the path, not the id).
399    for f in files {
400        step_vocab.insert(f.as_str());
401    }
402    for flow in &all_flows {
403        let steps_mention = flow.steps.iter().any(|s| {
404            step_vocab.contains(s.actor.as_str()) || step_vocab.contains(s.operation.as_str())
405        });
406        if steps_mention && seen_flows.insert(flow.id.as_str()) {
407            imp.flows.push(flow.id.clone());
408        }
409    }
410    // entrypoint attribute on flows
411    for flow in &all_flows {
412        if let Some(ep) = flow.attributes.get("entrypoint").and_then(|v| v.as_str()) {
413            if affected_syms.contains(ep) && seen_flows.insert(flow.id.as_str()) {
414                imp.flows.push(flow.id.clone());
415            }
416        }
417    }
418
419    // upstream (depend on affected) / downstream (affected depends on)
420    for cid in &affected_comps {
421        for r in view.out_pred(cid, scc_core::predicates::DEPENDS_ON) {
422            imp.downstream.push(r.object.clone());
423        }
424        for r in view.in_pred(cid, scc_core::predicates::DEPENDS_ON) {
425            imp.upstream.push(r.subject.clone());
426        }
427    }
428    imp.upstream.sort();
429    imp.upstream.dedup();
430    imp.downstream.sort();
431    imp.downstream.dedup();
432
433    // contracts: routes handled by affected symbols
434    for sid in &affected_syms {
435        for r in view.out_pred(sid, scc_core::predicates::HANDLES) {
436            imp.contracts.push(r.object.clone());
437        }
438    }
439
440    // data: stores owned by affected components + accessed by affected symbols
441    for cid in &affected_comps {
442        for r in view.out_pred(cid, scc_core::predicates::OWNS) {
443            imp.data.push(r.object.clone());
444        }
445    }
446    for sid in &affected_syms {
447        for pred in ["reads", "writes", "queries"] {
448            for r in view.out_pred(sid, pred) {
449                imp.data.push(r.object.clone());
450            }
451        }
452    }
453    imp.data.sort();
454    imp.data.dedup();
455
456    // invariants whose scope intersects affected entities
457    for inv in &view.invariants() {
458        let scoped = inv
459            .scope
460            .iter()
461            .any(|s| affected_comps.contains(s) || imp.data.contains(s));
462        if scoped {
463            imp.invariants.push(inv.id.clone());
464        }
465    }
466
467    // tests covering affected symbols
468    for sid in &affected_syms {
469        for r in view.out_pred(sid, scc_core::predicates::TESTED_BY) {
470            imp.tests.push(r.object.clone());
471        }
472    }
473    imp.tests.sort();
474    imp.tests.dedup();
475
476    imp.files = files.to_vec();
477
478    // risk: high if critical invariants affected or contracts changed;
479    // medium if flows affected; else low
480    let critical_invariants = imp
481        .invariants
482        .iter()
483        .filter(|iid| {
484            graph
485                .invariants
486                .iter()
487                .find(|i| i.id == **iid)
488                .map(|i| i.severity == Severity::Critical)
489                .unwrap_or(false)
490        })
491        .count();
492    if critical_invariants > 0 || !imp.contracts.is_empty() {
493        imp.risk = "high".into();
494        if critical_invariants > 0 {
495            imp.notes.push(format!(
496                "{critical_invariants} critical invariant(s) in scope of the change"
497            ));
498        }
499        if !imp.contracts.is_empty() {
500            imp.notes.push(format!(
501                "{} API contract(s) (routes) affected — consumers may break",
502                imp.contracts.len()
503            ));
504        }
505    } else if !imp.flows.is_empty() || !imp.tests.is_empty() {
506        imp.risk = "medium".into();
507    } else {
508        imp.risk = "low".into();
509    }
510    if !imp.tests.is_empty() {
511        imp.notes.push(format!(
512            "{} test(s) exercise the affected code",
513            imp.tests.len()
514        ));
515    }
516
517    let pairs = crate::cochange::cached_cochange_pairs(store).unwrap_or_default();
518    imp.forgotten_partners = forgotten_cochange_partners(&pairs, &imp.files);
519    if !imp.forgotten_partners.is_empty() {
520        imp.notes.push(format!(
521            "{} forgotten co-change partner(s) — historical, not EXTRACTED impact",
522            imp.forgotten_partners.len()
523        ));
524    }
525
526    Ok(imp)
527}
528
529// trace:exempt reason=internal-detail
530fn component_candidates(comps: &[scc_core::Entity]) -> Vec<crate::components::ComponentCandidate> {
531    comps
532        .iter()
533        .map(|c| {
534            let mut dirs: Vec<String> = c
535                .attributes
536                .get("implementation")
537                .and_then(|i| i.get("paths"))
538                .and_then(|p| p.as_array())
539                .map(|a| {
540                    a.iter()
541                        .filter_map(|x| x.as_str().map(|s| s.to_string()))
542                        .collect()
543                })
544                .unwrap_or_default();
545            if dirs.is_empty() {
546                dirs.push(c.name.clone());
547            }
548            crate::components::ComponentCandidate {
549                name: c.name.clone(),
550                dirs,
551                boundary_kind: c
552                    .attributes
553                    .get("boundary_kind")
554                    .and_then(|v| v.as_str())
555                    .map(|s| s.to_string())
556                    .unwrap_or_else(|| crate::components::BOUNDARY_CODE_REGION.to_string()),
557                intent: c
558                    .attributes
559                    .get("intent")
560                    .and_then(|v| v.as_str())
561                    .map(|s| s.to_string()),
562            }
563        })
564        .collect()
565}
566
567/// Files/symbols in the current diff (git diff --name-only).
568// trace:exempt reason=internal-detail
569pub fn diff_files(store: &Store, base: Option<&str>) -> Result<Vec<String>> {
570    let root = &store.root;
571    let mut cmd = std::process::Command::new("git");
572    cmd.args(["diff", "--name-only", "--diff-filter=ACMRT"]);
573    if let Some(b) = base {
574        cmd.arg(format!("{b}...HEAD"));
575    } else {
576        cmd.arg("HEAD");
577    }
578    cmd.arg("--");
579    let out = cmd
580        .current_dir(root)
581        .output()
582        .map_err(|e| scc_store::StoreError::NotInitialized(format!("git diff failed: {e}")))?;
583    let mut files = Vec::new();
584    for line in String::from_utf8_lossy(&out.stdout).lines() {
585        let line = line.trim();
586        if !line.is_empty() {
587            files.push(line.to_string());
588        }
589    }
590    Ok(files)
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    // trace:exempt reason=unit-test
598    fn fixture_two_file_call() -> (tempfile::TempDir, Store) {
599        use scc_core::{Entity, Relationship};
600        let dir = tempfile::TempDir::new().unwrap();
601        let root = dir.path().join("repo");
602        std::fs::create_dir_all(&root).unwrap();
603        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
604        let repo = store.repository().id.clone();
605        // a.go defines Svc.Do; b.go calls it with no import edge
606        // (same-package shape).
607        for (path, sym) in [("a.go", "Svc.Do"), ("b.go", "main")] {
608            let fid = scc_core::entity_id(&repo, kinds::FILE, path);
609            store.insert_entity(&Entity::new(fid.clone(), kinds::FILE, path.to_string()), &[path.to_string()]).unwrap();
610            let sid = scc_core::entity_id(&repo, kinds::SYMBOL, &format!("{path}/{sym}"));
611            let mut se = Entity::new(sid.clone(), kinds::SYMBOL, sym.to_string());
612            se.attr("file", serde_json::json!(path));
613            store.insert_entity(&se, &[path.to_string()]).unwrap();
614            store.insert_relationship(&Relationship::new(
615                format!("contains-{path}"), fid, scc_core::predicates::CONTAINS, sid, scc_core::Provenance::Extracted,
616            ), path).unwrap();
617        }
618        let a_sym = scc_core::entity_id(&repo, kinds::SYMBOL, "a.go/Svc.Do");
619        let b_sym = scc_core::entity_id(&repo, kinds::SYMBOL, "b.go/main");
620        store.insert_relationship(&Relationship::new(
621            "calls-b-a".to_string(), b_sym, scc_core::predicates::CALLS, a_sym, scc_core::Provenance::Extracted,
622        ), "b.go").unwrap();
623        (dir, store)
624    }
625
626    #[test]
627    // trace:v1 id=test.scc.impact.caller-wave verifies=REQ-SCC-IR exercises=impl.scc.impact.caller-wave
628    fn caller_wave_pulls_same_package_callers() {
629        let (_dir, store) = fixture_two_file_call();
630        let g = crate::RealityGraph::load(&store).unwrap();
631        let v = TrustedGraphView::new(&g, &store, &[], crate::TrustPolicy::default());
632        let imp = compute_impact(&v, &store, &["a.go".to_string()], &[]).unwrap();
633        assert!(imp.importers.iter().any(|i| i.file == "b.go"), "{imp:?}");
634    }
635
636    #[test]
637    // trace:exempt reason=internal-detail
638    fn empty_impact() {
639        let dir = tempfile::TempDir::new().unwrap();
640        let root = dir.path().join("repo");
641        std::fs::create_dir_all(&root).unwrap();
642        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
643        let g = crate::RealityGraph::load(&store).unwrap();
644        let v = TrustedGraphView::new(&g, &store, &[], crate::TrustPolicy::default());
645        let imp = compute_impact(&v, &store, &[], &[]).unwrap();
646        assert!(imp.components.is_empty());
647        assert_eq!(imp.risk, "low");
648        assert!(imp.forgotten_partners.is_empty());
649    }
650
651    #[test]
652    // trace:v1 id=test.scc.impact.ghost-target-refused verifies=REQ-SI-503JSBGP exercises=impl.scc.impact
653    fn ghost_target_refused() {
654        let dir = tempfile::TempDir::new().unwrap();
655        let root = dir.path().join("repo");
656        std::fs::create_dir_all(&root).unwrap();
657        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
658        let g = crate::RealityGraph::load(&store).unwrap();
659        let v = TrustedGraphView::new(&g, &store, &[], crate::TrustPolicy::default());
660        let err = compute_impact(&v, &store, &["src/handle.rs".into()], &[])
661            .expect_err("nonexistent path must not produce a report");
662        assert!(err.to_string().contains("refusing to fabricate"), "{err}");
663        assert!(err.to_string().contains("src/handle.rs"), "{err}");
664    }
665
666    #[test]
667    // trace:v1 id=test.scc.impact.forgotten-partners verifies=REQ-forgotten-impact-partners exercises=impl.scc.impact.forgotten-partners
668    fn forgotten_partners_are_not_semantic_impact() {
669        let pairs = vec![
670            crate::cochange::CochangePair {
671                a: "src/a.py".into(),
672                b: "src/b.py".into(),
673                commits: 3,
674            },
675            crate::cochange::CochangePair {
676                a: "src/a.py".into(),
677                b: "src/c.py".into(),
678                commits: 2,
679            },
680        ];
681        let found = forgotten_cochange_partners(&pairs, &["src/a.py".into()]);
682        assert_eq!(found.len(), 2);
683        assert_eq!(found[0].partner, "src/b.py");
684        assert_eq!(found[0].commits, 3);
685        assert_eq!(found[0].reason, "cochange");
686        assert_eq!(found[1].partner, "src/c.py");
687        let none = forgotten_cochange_partners(
688            &pairs,
689            &["src/a.py".into(), "src/b.py".into(), "src/c.py".into()],
690        );
691        assert!(
692            none.is_empty(),
693            "partners already in the change set are not forgotten"
694        );
695    }
696}