1use 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)]
15pub struct Impact {
17 pub files: Vec<String>,
18 pub components: Vec<String>, pub flows: Vec<String>, pub upstream: Vec<String>, pub downstream: Vec<String>, pub contracts: Vec<String>, pub data: Vec<String>, pub invariants: Vec<String>, pub tests: Vec<String>, pub risk: String, #[serde(default)]
28 pub notes: Vec<String>,
29 #[serde(default)]
33 pub importers: Vec<Importer>,
34 #[serde(default)]
37 pub forgotten_partners: Vec<ForgottenPartner>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
45pub 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)]
53pub struct ForgottenPartner {
55 pub file: String,
56 pub partner: String,
57 pub commits: u32,
58 pub reason: String,
59}
60
61pub 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#[allow(clippy::too_many_arguments)]
103fn 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 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 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 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
155pub 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 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 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 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 if !id.ends_with("/?/") && view.entity(id).is_some() {
204 resolved_sym_ids.insert(id.clone());
205 }
206 }
207 {
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 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 let mut importer_depth: BTreeMap<String, u32> = BTreeMap::new();
256 let mut importer_prov: BTreeMap<String, scc_core::Provenance> = BTreeMap::new();
257 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 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 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 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 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 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 let mut seen_flows: HashSet<&str> = HashSet::new();
385 let all_flows = view.flows();
386 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 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 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 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 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 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 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 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 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
529fn 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
567pub 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 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 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 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 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 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 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}