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(
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 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 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 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 if !id.ends_with("/?/") && view.entity(id).is_some() {
205 resolved_sym_ids.insert(id.clone());
206 }
207 }
208 {
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 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 let mut importer_depth: BTreeMap<String, u32> = BTreeMap::new();
257 let mut importer_prov: BTreeMap<String, scc_core::Provenance> = BTreeMap::new();
258 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 {
303 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 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 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 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 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 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 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 let mut seen_flows: HashSet<&str> = HashSet::new();
444 let all_flows = view.flows();
445 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 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 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 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 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 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 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 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 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
588fn 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
626pub 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 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 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 fn import_row_fallback_names_package_member_importer() {
688 use scc_core::Entity;
689 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 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 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 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 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}