1use crate::{RealityGraph, Result};
51use scc_core::{kinds, predicates, Archetype};
52use scc_store::Store;
53use serde_json::json;
54use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
55
56use crate::components::{
57 boundary_rank, component_for_path, BOUNDARY_CODE_REGION, BOUNDARY_PACKAGE, BOUNDARY_ROOT,
58 ComponentCandidate, LAYER_CODE_REGION, LAYER_COMPONENT, LAYER_SERVICE, MERGE_THRESHOLD,
59 SERVICE_THRESHOLD,
60};
61
62pub const W_CALL: i32 = 2;
65pub const W_STATE: i32 = 4;
67pub const W_PUBLIC_SURFACE: i32 = 4;
70pub const W_PUBLIC_SURFACE_LIBRARY: i32 = 8;
72pub const W_FLOW: i32 = 3;
74pub const W_EVENT: i32 = 3;
77pub const W_SURFACE_FAMILY: i32 = 2;
82pub const W_TYPE_HIERARCHY: i32 = 2;
84pub const W_DEPLOYMENT: i32 = 5;
86pub const W_PACKAGE: i32 = 5;
90pub const COCHANGE_CAP: i32 = 5;
92
93pub const COHESION_FRACTION: f64 = 0.4;
98
99pub const MAX_UNDECLARED_FILES: usize = 64;
109
110#[derive(Debug, Clone)]
112pub struct ClusterComponent {
113 pub name: String,
114 pub dirs: Vec<String>,
116 pub boundary_kind: String,
118 pub layer: String,
122 pub files: Vec<String>,
124 pub member_regions: Vec<usize>,
126}
127
128pub struct ClusteringResult {
131 pub clusters: Vec<ClusterComponent>,
133 pub regions: Vec<ComponentCandidate>,
136 pub symbol_component: HashMap<String, String>,
139 pub files_in_component: BTreeMap<String, Vec<String>>,
141 pub parent_per_comp: BTreeMap<String, String>,
143 pub component_weights: Vec<Vec<i32>>,
146 pub cross_unit: Vec<Vec<bool>>,
150}
151
152pub fn build_regions(
177 graph: &RealityGraph,
178 candidates: &[ComponentCandidate],
179) -> Vec<ComponentCandidate> {
180 let mut regions: Vec<ComponentCandidate> = Vec::new();
181 let mut by_name: HashSet<String> = HashSet::new();
182 let push = |regions: &mut Vec<ComponentCandidate>,
183 by_name: &mut HashSet<String>,
184 c: ComponentCandidate| {
185 if by_name.insert(c.name.clone()) {
186 regions.push(c);
187 }
188 };
189
190 let mut test_files: HashSet<String> = HashSet::new();
195 for t in graph.entities_of_kind(kinds::TEST) {
196 if let Some(f) = t.attributes.get("file").and_then(|v| v.as_str()) {
197 test_files.insert(f.to_string());
198 }
199 }
200
201 let mut auth: Vec<ComponentCandidate> = candidates
208 .iter()
209 .filter(|c| c.boundary_kind != BOUNDARY_CODE_REGION)
210 .cloned()
211 .collect();
212 auth.sort_by(|a, b| a.name.cmp(&b.name));
213 for c in &auth {
214 if c.boundary_kind == BOUNDARY_ROOT || c.name == "root" {
215 push(&mut regions, &mut by_name, c.clone());
217 continue;
218 }
219 let is_package = c.boundary_kind == BOUNDARY_PACKAGE;
220 let mut dirs = c.dirs.clone();
221 dirs.sort();
222 dirs.dedup();
223 let mut dir_region_dirs: Vec<String> = Vec::new();
226 for dir in &dirs {
227 let dir = dir.trim_end_matches('/');
228 if dir.is_empty() {
229 continue;
230 }
231 let prefix = format!("{dir}/");
232 let mut subs: BTreeSet<String> = BTreeSet::new();
233 let mut direct: Vec<String> = Vec::new();
234 for f in graph.entities_of_kind(kinds::FILE) {
235 if f.name == *dir || f.name.starts_with(&prefix) {
236 let rest = &f.name[dir.len() + 1..];
237 if let Some(slash) = rest.find('/') {
238 subs.insert(format!("{dir}/{}", &rest[..slash]));
239 } else {
240 direct.push(f.name.clone());
241 }
242 }
243 }
244 direct.sort();
245 for sub in subs {
246 push(&mut regions, &mut by_name, ComponentCandidate {
247 name: sub.clone(),
248 dirs: vec![sub.clone()],
249 boundary_kind: c.boundary_kind.clone(),
250 intent: c.intent.clone(),
251 });
252 }
253 if is_package && direct.len() >= 2 {
254 let code: Vec<&String> = direct
258 .iter()
259 .filter(|f| !test_files.contains(*f))
260 .collect();
261 if code.len() >= 2 {
262 for (k, f) in code.iter().enumerate() {
263 let mut dirs = vec![(*f).clone()];
264 if k == 0 {
265 for t in direct.iter().filter(|t| test_files.contains(*t)) {
266 dirs.push(t.clone());
267 }
268 }
269 push(&mut regions, &mut by_name, ComponentCandidate {
270 name: (**f).clone(),
271 dirs,
272 boundary_kind: c.boundary_kind.clone(),
273 intent: c.intent.clone(),
274 });
275 }
276 } else {
277 dir_region_dirs.push(dir.to_string());
278 }
279 } else if !direct.is_empty() {
280 dir_region_dirs.push(dir.to_string());
284 }
285 }
286 if !dir_region_dirs.is_empty() {
287 push(&mut regions, &mut by_name, ComponentCandidate {
288 name: c.name.clone(),
289 dirs: dir_region_dirs,
290 boundary_kind: c.boundary_kind.clone(),
291 intent: c.intent.clone(),
292 });
293 }
294 }
295
296 let mut code: Vec<ComponentCandidate> = candidates
299 .iter()
300 .filter(|c| c.boundary_kind == BOUNDARY_CODE_REGION)
301 .cloned()
302 .collect();
303 code.sort_by(|a, b| a.name.cmp(&b.name));
304 for c in &code {
305 if c.name == "root" || by_name.contains(&c.name) {
306 continue;
307 }
308 let dir = &c.name;
309 let prefix = format!("{dir}/");
310 let mut direct = false;
311 let mut subs: BTreeSet<String> = BTreeSet::new();
312 for f in graph.entities_of_kind(kinds::FILE) {
313 if f.name == *dir || f.name.starts_with(&prefix) {
314 let rest = &f.name[dir.len() + 1..];
315 if let Some(slash) = rest.find('/') {
316 subs.insert(format!("{dir}/{}", &rest[..slash]));
317 } else {
318 direct = true;
319 }
320 }
321 }
322 if direct && !by_name.contains(dir) {
323 by_name.insert(dir.clone());
324 regions.push(ComponentCandidate {
325 name: dir.clone(),
326 dirs: vec![dir.clone()],
327 boundary_kind: BOUNDARY_CODE_REGION.to_string(), intent: None,
328 });
329 }
330 for sub in subs {
331 if by_name.contains(&sub) {
332 continue;
333 }
334 by_name.insert(sub.clone());
335 regions.push(ComponentCandidate {
336 name: sub.clone(),
337 dirs: vec![sub.clone()],
338 boundary_kind: BOUNDARY_CODE_REGION.to_string(), intent: None,
339 });
340 }
341 }
342
343 if !by_name.contains("root") {
345 regions.push(ComponentCandidate {
346 name: "root".to_string(),
347 dirs: vec!["root".to_string()],
348 boundary_kind: BOUNDARY_ROOT.to_string(), intent: None,
349 });
350 }
351 regions
352}
353
354pub fn cluster_components(
359 graph: &RealityGraph,
360 store: &Store,
361 intent: &[(String, serde_json::Value)],
362 candidates: &[ComponentCandidate],
363 pairs: &[crate::cochange::CochangePair],
364 du_ctxs: &[(String, String)],
365) -> Result<ClusteringResult> {
366 let mut regions = build_regions(graph, candidates);
367
368 let mut files_in_region: BTreeMap<String, Vec<String>> = BTreeMap::new();
370 for f in graph.entities_of_kind(kinds::FILE) {
371 let region = component_for_path(&f.name, ®ions);
372 files_in_region
373 .entry(region)
374 .or_default()
375 .push(f.id.clone());
376 }
377 for v in files_in_region.values_mut() {
378 v.sort();
379 }
380 regions.retain(|r| {
383 r.boundary_kind != BOUNDARY_CODE_REGION
384 || files_in_region
385 .get(&r.name)
386 .map(|v| !v.is_empty())
387 .unwrap_or(false)
388 });
389
390 let n = regions.len();
391 let idx: HashMap<&str, usize> = regions
392 .iter()
393 .enumerate()
394 .map(|(i, r)| (r.name.as_str(), i))
395 .collect();
396 let mut w: Vec<Vec<i32>> = vec![vec![0i32; n]; n];
397
398 let mut symbol_region: HashMap<String, usize> = HashMap::new();
400 for (region, files) in &files_in_region {
401 for fid in files {
402 for r in graph.out_pred(fid, scc_core::predicates::CONTAINS) {
403 if let Some(&ri) = idx.get(region.as_str()) {
404 symbol_region.insert(r.object.clone(), ri);
405 }
406 }
407 }
408 }
409 let mut path_region: BTreeMap<String, usize> = BTreeMap::new();
411 for f in graph.entities_of_kind(kinds::FILE) {
412 if let Some(&ri) = idx.get(component_for_path(&f.name, ®ions).as_str()) {
413 path_region.insert(f.name.clone(), ri);
414 }
415 }
416 let mut syms: Vec<(&String, usize)> = symbol_region
417 .iter()
418 .map(|(s, r)| (s, *r))
419 .collect();
420 syms.sort();
421
422 let mut parent_per_region: Vec<Option<String>> = vec![None; n];
424 for (i, r) in regions.iter().enumerate() {
425 for (du_name, ctx) in du_ctxs {
426 let inside = r.dirs.iter().any(|d| {
427 let d = d.trim_end_matches('/');
428 d == ctx.as_str() || d.starts_with(&format!("{ctx}/"))
429 });
430 if inside {
431 parent_per_region[i] = Some(du_name.clone());
432 break;
433 }
434 }
435 }
436
437 let mut test_files: HashSet<String> = HashSet::new();
442 for t in graph.entities_of_kind(kinds::TEST) {
443 if let Some(f) = t.attributes.get("file").and_then(|v| v.as_str()) {
444 test_files.insert(f.to_string());
445 }
446 }
447 let is_test_symbol = |sym: &str| -> bool {
448 graph
449 .entities
450 .get(sym)
451 .and_then(|e| e.attributes.get("file"))
452 .and_then(|v| v.as_str())
453 .map(|f| test_files.contains(f))
454 .unwrap_or(false)
455 };
456
457 let archetype = crate::archetype::detect_archetype(graph, store);
458
459 for (sym, ra) in &syms {
461 if is_test_symbol(sym) {
462 continue;
463 }
464 for r in graph.out_pred(sym, scc_core::predicates::CALLS) {
465 if let Some(&rb) = symbol_region.get(&r.object) {
466 if ra != &rb {
467 w[*ra][rb] += W_CALL;
468 w[rb][*ra] += W_CALL;
469 }
470 }
471 }
472 }
473
474 for group in crate::state::state_authority_groups(graph) {
476 let set: BTreeSet<String> = group
477 .iter()
478 .filter_map(|s| symbol_region.get(s).map(|&r| regions[r].name.clone()))
479 .collect();
480 if set.len() >= 2 {
481 add_pair_weight(&mut w, &idx, &set, W_STATE);
482 }
483 }
484
485 let mut public_targets: HashSet<String> = HashSet::new();
489 for e in graph.entities_of_kind(kinds::EXPORT) {
490 public_targets.insert(e.id.clone());
491 }
492 for r in graph.all_rels() {
493 if r.predicate == predicates::EXPORTS {
494 public_targets.insert(r.object.clone());
498 public_targets.insert(r.subject.clone());
499 }
500 }
501 let mut hier_groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
502 let mut reg_groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
503 for &(sym, ra) in &syms {
504 for pred in [predicates::IMPLEMENTS, predicates::INHERITS] {
505 for r in graph.out_pred(sym, pred) {
506 let group = hier_groups.entry(r.object.clone()).or_default();
507 group.insert(regions[ra].name.clone());
508 if let Some(&rt) = symbol_region.get(&r.object) {
511 group.insert(regions[rt].name.clone());
512 }
513 }
514 }
515 for r in graph.out_pred(sym, predicates::REGISTERS) {
516 let group = reg_groups.entry(r.object.clone()).or_default();
517 group.insert(regions[ra].name.clone());
518 if let Some(&rt) = symbol_region.get(&r.object) {
521 group.insert(regions[rt].name.clone());
522 }
523 }
524 }
525 let ps_w = if archetype == Archetype::LibrarySdk {
526 W_PUBLIC_SURFACE_LIBRARY
527 } else {
528 W_PUBLIC_SURFACE
529 };
530 for (target, set) in &hier_groups {
531 if set.len() < 2 {
532 continue;
533 }
534 if public_targets.contains(target) {
535 add_pair_weight(&mut w, &idx, set, ps_w);
537 } else {
538 add_pair_weight(&mut w, &idx, set, W_TYPE_HIERARCHY);
540 }
541 }
542 for set in reg_groups.values() {
543 if set.len() >= 2 {
544 add_pair_weight(&mut w, &idx, set, ps_w);
547 }
548 }
549
550 let mut flow_pairs: BTreeSet<(usize, usize)> = BTreeSet::new();
556 for (entry, group) in crate::flows::flow_participant_groups(graph, store, intent) {
557 if is_test_symbol(&entry) {
558 continue;
559 }
560 let set: BTreeSet<String> = group
561 .iter()
562 .filter_map(|s| symbol_region.get(s).map(|&r| regions[r].name.clone()))
563 .collect();
564 if set.len() < 2 {
565 continue;
566 }
567 let cs: Vec<&String> = set.iter().collect();
568 for (k, a) in cs.iter().enumerate() {
569 let Some(&ia) = idx.get(a.as_str()) else { continue };
570 for b in cs.iter().skip(k + 1) {
571 let Some(&ib) = idx.get(b.as_str()) else { continue };
572 flow_pairs.insert(if ia < ib { (ia, ib) } else { (ib, ia) });
573 }
574 }
575 }
576 for (ia, ib) in flow_pairs {
577 w[ia][ib] += W_FLOW;
578 w[ib][ia] += W_FLOW;
579 }
580
581 let mut topic_groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
583 for &(sym, ra) in &syms {
584 for pred in [
585 predicates::PUBLISHES,
586 predicates::CONSUMES,
587 predicates::SUBSCRIBES,
588 ] {
589 for r in graph.out_pred(sym, pred) {
590 topic_groups
591 .entry(r.object.clone())
592 .or_default()
593 .insert(regions[ra].name.clone());
594 }
595 }
596 }
597 for set in topic_groups.values() {
598 if set.len() >= 2 {
599 add_pair_weight(&mut w, &idx, set, W_EVENT);
600 }
601 }
602
603 let mut surface_families: BTreeMap<&'static str, BTreeSet<String>> = BTreeMap::new();
611 for s in crate::flows::invocation_surfaces(graph) {
612 if is_test_symbol(&s.symbol) {
613 continue;
614 }
615 let family: &'static str = match s.kind {
616 scc_core::InvocationSurfaceKind::Queue => "queue",
617 scc_core::InvocationSurfaceKind::Schedule => "schedule",
618 scc_core::InvocationSurfaceKind::Plugin => "plugin",
619 scc_core::InvocationSurfaceKind::FrameworkCallback
620 | scc_core::InvocationSurfaceKind::Lifecycle => "lifecycle",
621 _ => continue,
622 };
623 let Some(&ra) = symbol_region.get(&s.symbol) else { continue };
624 surface_families
625 .entry(family)
626 .or_default()
627 .insert(regions[ra].name.clone());
628 }
629 for set in surface_families.values() {
630 if set.len() >= 2 {
631 add_pair_weight(&mut w, &idx, set, W_SURFACE_FAMILY);
632 }
633 }
634
635 let mut cc: BTreeMap<(usize, usize), i32> = BTreeMap::new();
637 for p in pairs {
638 if let (Some(&ra), Some(&rb)) = (path_region.get(&p.a), path_region.get(&p.b)) {
639 if ra != rb {
640 let (x, y) = if ra < rb { (ra, rb) } else { (rb, ra) };
641 *cc.entry((x, y)).or_insert(0) += 1;
642 }
643 }
644 }
645 for ((x, y), count) in cc {
646 let weight = count.min(COCHANGE_CAP);
647 w[x][y] += weight;
648 w[y][x] += weight;
649 }
650
651 let mut du_groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
653 for (i, du) in parent_per_region.iter().enumerate() {
654 if let Some(du) = du {
655 du_groups
656 .entry(du.clone())
657 .or_default()
658 .insert(regions[i].name.clone());
659 }
660 }
661 for set in du_groups.values() {
662 if set.len() >= 2 {
663 add_pair_weight(&mut w, &idx, set, W_DEPLOYMENT);
664 }
665 }
666
667 let mut pkg_groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
674 for (i, r) in regions.iter().enumerate() {
675 for cand in candidates {
676 if cand.boundary_kind != BOUNDARY_PACKAGE {
677 continue;
678 }
679 let inside = cand.dirs.iter().any(|cd| {
680 let cd = cd.trim_end_matches('/');
681 r.dirs.iter().any(|d| {
682 let d = d.trim_end_matches('/');
683 d == cd || d.starts_with(&format!("{cd}/"))
684 })
685 });
686 if inside {
687 pkg_groups
688 .entry(cand.name.clone())
689 .or_default()
690 .insert(regions[i].name.clone());
691 }
692 }
693 }
694 for set in pkg_groups.values() {
695 if set.len() >= 2 {
696 add_pair_weight(&mut w, &idx, set, W_PACKAGE);
697 }
698 }
699
700 if let Some(prior) = crate::archetype::cluster_prior(archetype) {
702 let mut set: BTreeSet<String> = BTreeSet::new();
703 for &(sym, ra) in &syms {
704 let Some(e) = graph.entities.get(sym) else { continue };
705 let name = regions[ra].name.clone();
706 let has = match prior {
707 crate::archetype::ClusterPrior::CliCommands => {
708 let cli_ep = e
709 .attributes
710 .get("entrypoints")
711 .and_then(|v| v.as_array())
712 .map(|eps| {
713 eps.iter().any(|k| {
714 matches!(k.as_str(), Some("cli-subcommand") | Some("cli"))
715 })
716 })
717 .unwrap_or(false);
718 let cli_flags = e
719 .attributes
720 .get("cli_flags")
721 .and_then(|v| v.as_array())
722 .map(|fl| !fl.is_empty())
723 .unwrap_or(false);
724 cli_ep || cli_flags
725 }
726 crate::archetype::ClusterPrior::FrameworkRegistrations => {
727 !graph.out_pred(sym, predicates::REGISTERS).is_empty()
728 }
729 crate::archetype::ClusterPrior::ServiceEntrypoints => {
730 !graph.out_pred(sym, predicates::HANDLES).is_empty()
731 || e.attributes
732 .get("entrypoints")
733 .and_then(|v| v.as_array())
734 .map(|eps| !eps.is_empty())
735 .unwrap_or(false)
736 }
737 crate::archetype::ClusterPrior::CompilerPhases => {
738 crate::archetype::is_phase_symbol(&e.name)
739 }
740 };
741 if has {
742 set.insert(name);
743 }
744 }
745 if set.len() >= 2 {
746 add_pair_weight(&mut w, &idx, &set, crate::archetype::PRIOR_WEIGHT);
747 }
748 }
749
750 let mut cross_unit: Vec<Vec<bool>> = vec![vec![false; n]; n];
753 for i in 0..n {
754 for j in (i + 1)..n {
755 if let (Some(a), Some(b)) = (&parent_per_region[i], &parent_per_region[j]) {
756 if a != b {
757 cross_unit[i][j] = true;
758 cross_unit[j][i] = true;
759 }
760 }
761 }
762 }
763
764 let mut dsu = Dsu::new(n);
766 greedy_merge(&mut dsu, &w, n, MERGE_THRESHOLD, &cross_unit);
767 let mut clusters: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
768 for i in 0..n {
769 clusters.entry(dsu.find(i)).or_default().push(i);
770 }
771 let mut comps: Vec<ClusterComponent> = clusters
772 .values()
773 .map(|members| build_cluster(®ions, members))
774 .collect();
775 comps.sort_by(|a, b| a.name.cmp(&b.name));
776
777 let mut files_in_component: BTreeMap<String, Vec<String>> = BTreeMap::new();
779 for c in &comps {
780 let mut files: Vec<String> = Vec::new();
781 for &m in &c.member_regions {
782 if let Some(fs) = files_in_region.get(®ions[m].name) {
783 files.extend(fs.iter().cloned());
784 }
785 }
786 files.sort();
787 files.dedup();
788 files_in_component.insert(c.name.clone(), files);
789 }
790 let mut symbol_component: HashMap<String, String> = HashMap::new();
791 for (sym, &ri) in &symbol_region {
792 let root = dsu.find(ri);
793 if let Some(members) = clusters.get(&root) {
794 let comp_name = &comps
795 .iter()
796 .find(|c| c.member_regions == *members)
797 .map(|c| c.name.clone())
798 .unwrap_or_default();
799 symbol_component.insert(sym.clone(), comp_name.clone());
800 }
801 }
802
803 {
813 let mut split_comps: Vec<ClusterComponent> = Vec::new();
814 let mut split_files: BTreeMap<String, Vec<String>> = BTreeMap::new();
815 for c in &comps {
816 let files = files_in_component.get(&c.name).cloned().unwrap_or_default();
817 let protected = c.member_regions.iter().any(|&m| {
818 regions[m].intent.is_some()
819 || regions[m].boundary_kind == crate::components::BOUNDARY_DECLARED
820 || regions[m].boundary_kind == crate::components::BOUNDARY_DEPLOYMENT
821 || regions[m].boundary_kind == crate::components::BOUNDARY_PACKAGE
822 });
823 if protected || files.len() <= MAX_UNDECLARED_FILES {
824 split_comps.push(c.clone());
825 split_files.insert(c.name.clone(), files);
826 continue;
827 }
828 let mut queue: VecDeque<(Vec<usize>, usize, Vec<String>)> =
837 VecDeque::new();
838 queue.push_back((c.member_regions.clone(), 0, Vec::new()));
839 let mut emitted_any = false;
840 while let Some((members, depth, trail)) = queue.pop_front() {
841 let mut sfiles: Vec<String> = Vec::new();
842 for &m in &members {
843 if let Some(fs) = files_in_region.get(®ions[m].name) {
844 sfiles.extend(fs.iter().cloned());
845 }
846 }
847 sfiles.sort();
848 sfiles.dedup();
849 if sfiles.len() <= MAX_UNDECLARED_FILES {
850 let mut sub = build_cluster(®ions, &members);
851 sub.name = dedup_cluster_name(&split_files, &sub.name, &trail);
852 split_files.insert(sub.name.clone(), sfiles);
853 split_comps.push(sub);
854 emitted_any = true;
855 continue;
856 }
857 let mut by_seg: BTreeMap<String, Vec<usize>> = BTreeMap::new();
858 for &m in &members {
859 let seg = regions[m]
860 .name
861 .split('/')
862 .nth(depth)
863 .unwrap_or("root")
864 .to_string();
865 by_seg.entry(seg).or_default().push(m);
866 }
867 if by_seg.len() < 2 {
868 let mut sub = build_cluster(®ions, &members);
870 sub.name = dedup_cluster_name(&split_files, &sub.name, &trail);
871 split_files.insert(sub.name.clone(), sfiles);
872 split_comps.push(sub);
873 emitted_any = true;
874 continue;
875 }
876 for (seg, sub_members) in &by_seg {
877 let mut t = trail.clone();
878 t.push(seg.clone());
879 queue.push_back((sub_members.clone(), depth + 1, t));
880 }
881 }
882 if !emitted_any {
883 split_comps.push(c.clone());
884 split_files.insert(c.name.clone(), files);
885 }
886 continue;
887 }
888 split_comps.sort_by(|a, b| a.name.cmp(&b.name));
889 comps = split_comps;
890 files_in_component = split_files;
891 let region_owner: std::collections::HashMap<usize, String> = comps
894 .iter()
895 .flat_map(|c| c.member_regions.iter().map(|&m| (m, c.name.clone())))
896 .collect();
897 for (sym, comp) in symbol_component.iter_mut() {
898 if comps.iter().any(|c| &c.name == comp) {
899 continue;
900 }
901 if let Some(&ri) = symbol_region.get(sym) {
902 if let Some(owner) = region_owner.get(&ri) {
903 *comp = owner.clone();
904 }
905 }
906 }
907 }
908
909 let mut parent_per_comp: BTreeMap<String, String> = BTreeMap::new();
911 for c in &comps {
912 for (du_name, ctx) in du_ctxs {
913 let inside = c.dirs.iter().any(|d| {
914 let d = d.trim_end_matches('/');
915 d == ctx.as_str() || d.starts_with(&format!("{ctx}/"))
916 });
917 if inside {
918 parent_per_comp.insert(c.name.clone(), du_name.clone());
919 break;
920 }
921 }
922 }
923
924 let m = comps.len();
926 let mut component_weights: Vec<Vec<i32>> = vec![vec![0i32; m]; m];
927 let mut cunit: Vec<Vec<bool>> = vec![vec![false; m]; m];
928 for (i, ci) in comps.iter().enumerate() {
929 for (j, cj) in comps.iter().enumerate() {
930 if i == j {
931 continue;
932 }
933 let mut sum = 0;
934 for &ra in &ci.member_regions {
935 for &rb in &cj.member_regions {
936 sum += w[ra][rb];
937 }
938 }
939 component_weights[i][j] = sum;
940 }
941 }
942 for (i, ci) in comps.iter().enumerate() {
943 for (j, cj) in comps.iter().enumerate() {
944 if i == j {
945 continue;
946 }
947 let cross = ci.member_regions.iter().any(|&ra| {
948 cj.member_regions.iter().any(|&rb| cross_unit[ra][rb])
949 });
950 cunit[i][j] = cross;
951 }
952 }
953
954 Ok(ClusteringResult {
955 clusters: comps,
956 regions,
957 symbol_component,
958 files_in_component,
959 parent_per_comp,
960 component_weights,
961 cross_unit: cunit,
962 })
963}
964
965fn build_cluster(regions: &[ComponentCandidate], members: &[usize]) -> ClusterComponent {
970 let name = cluster_name(regions, members);
971 let mut dirs: BTreeSet<String> = BTreeSet::new();
972 let mut boundary: Option<(u8, String)> = None;
973 let mut sorted: Vec<usize> = members.to_vec();
976 sorted.sort_by(|a, b| regions[*a].name.cmp(®ions[*b].name));
977 for &m in &sorted {
978 let r = ®ions[m];
979 for d in &r.dirs {
980 dirs.insert(d.clone());
981 }
982 let rank = boundary_rank(&r.boundary_kind);
983 match &boundary {
984 Some((br, _)) if *br >= rank => {}
985 _ => boundary = Some((rank, r.boundary_kind.clone())),
986 }
987 }
988 let boundary_kind = boundary.map(|(_, k)| k).unwrap_or_else(|| BOUNDARY_CODE_REGION.to_string());
989 let layer = if members.len() == 1
990 && (boundary_kind == BOUNDARY_CODE_REGION || boundary_kind == BOUNDARY_ROOT)
991 {
992 LAYER_CODE_REGION.to_string()
993 } else {
994 LAYER_COMPONENT.to_string()
995 };
996 let mut member_regions = members.to_vec();
997 member_regions.sort();
998 ClusterComponent {
999 name,
1000 dirs: dirs.into_iter().collect(),
1001 boundary_kind,
1002 layer,
1003 files: Vec::new(),
1004 member_regions,
1005 }
1006}
1007
1008fn dedup_cluster_name(
1013 taken: &BTreeMap<String, Vec<String>>,
1014 base: &str,
1015 trail: &[String],
1016) -> String {
1017 if !taken.contains_key(base) {
1018 return base.to_string();
1019 }
1020 if !trail.is_empty() {
1021 let t = format!("{}/{}", base, trail.join("/"));
1022 if !taken.contains_key(&t) {
1023 return t;
1024 }
1025 }
1026 let mut n = 2;
1027 while taken.contains_key(&format!("{base}~{n}")) {
1028 n += 1;
1029 }
1030 format!("{base}~{n}")
1031}
1032
1033fn cluster_name(regions: &[ComponentCandidate], members: &[usize]) -> String {
1039 let intents: BTreeSet<&str> = members
1043 .iter()
1044 .filter_map(|&i| regions[i].intent.as_deref())
1045 .collect();
1046 if intents.len() == 1 {
1047 return intents.into_iter().next().unwrap().to_string();
1048 }
1049 if members.len() == 1 {
1050 return regions[members[0]].name.clone();
1051 }
1052 let segs: Vec<Vec<&str>> = members
1053 .iter()
1054 .map(|&i| regions[i].name.split('/').collect())
1055 .collect();
1056 let mut common: Vec<&str> = Vec::new();
1057 'outer: for k in 0..segs[0].len() {
1058 let seg = segs[0][k];
1059 for other in segs.iter().skip(1) {
1060 if other.get(k) != Some(&seg) {
1061 break 'outer;
1062 }
1063 }
1064 common.push(seg);
1065 }
1066 if !common.is_empty() {
1067 let lcp = common.join("/");
1068 let taken_by_other = regions.iter().enumerate().any(|(i, r)| {
1070 !members.contains(&i) && r.name == lcp
1071 });
1072 if !taken_by_other {
1073 return lcp;
1074 }
1075 }
1076 let mut names: Vec<String> = members.iter().map(|&i| regions[i].name.clone()).collect();
1077 names.sort();
1078 names.join("+")
1079}
1080
1081fn add_pair_weight(w: &mut [Vec<i32>], idx: &HashMap<&str, usize>, set: &BTreeSet<String>, weight: i32) {
1083 let cs: Vec<&String> = set.iter().collect();
1084 for (k, a) in cs.iter().enumerate() {
1085 let Some(&ia) = idx.get(a.as_str()) else { continue };
1086 for b in cs.iter().skip(k + 1) {
1087 let Some(&ib) = idx.get(b.as_str()) else { continue };
1088 w[ia][ib] += weight;
1089 w[ib][ia] += weight;
1090 }
1091 }
1092}
1093
1094pub(crate) struct Dsu {
1097 parent: Vec<usize>,
1098}
1099
1100impl Dsu {
1101 pub(crate) fn new(n: usize) -> Dsu {
1102 Dsu {
1103 parent: (0..n).collect(),
1104 }
1105 }
1106 pub(crate) fn find(&mut self, mut x: usize) -> usize {
1107 let mut root = x;
1108 while self.parent[root] != root {
1109 root = self.parent[root];
1110 }
1111 while self.parent[x] != root {
1112 let next = self.parent[x];
1113 self.parent[x] = root;
1114 x = next;
1115 }
1116 root
1117 }
1118 pub(crate) fn union(&mut self, a: usize, b: usize) {
1119 let (ra, rb) = (self.find(a), self.find(b));
1120 if ra != rb {
1121 self.parent[rb] = ra;
1122 }
1123 }
1124}
1125
1126fn cluster_weight(w: &[Vec<i32>], dsu: &mut Dsu, a: usize, b: usize, cross_unit: &[Vec<bool>]) -> i32 {
1130 let (ra, rb) = (dsu.find(a), dsu.find(b));
1131 let mut m = 0;
1132 for (i, row) in w.iter().enumerate() {
1133 if dsu.find(i) != ra {
1134 continue;
1135 }
1136 for (j, cell) in row.iter().enumerate() {
1137 if dsu.find(j) != rb {
1138 continue;
1139 }
1140 let cell = if cross_unit[i][j] && *cell <= SERVICE_THRESHOLD {
1141 0
1142 } else {
1143 *cell
1144 };
1145 m = m.max(cell);
1146 }
1147 }
1148 m
1149}
1150
1151fn cluster_avg(w: &[Vec<i32>], dsu: &mut Dsu, a: usize, b: usize, cross_unit: &[Vec<bool>]) -> f64 {
1158 let (ra, rb) = (dsu.find(a), dsu.find(b));
1159 let mut sum = 0i64;
1160 let mut count = 0i64;
1161 for (i, row) in w.iter().enumerate() {
1162 if dsu.find(i) != ra {
1163 continue;
1164 }
1165 for (j, cell) in row.iter().enumerate() {
1166 if dsu.find(j) != rb {
1167 continue;
1168 }
1169 let cell = if cross_unit[i][j] && *cell <= SERVICE_THRESHOLD {
1170 0
1171 } else {
1172 *cell
1173 };
1174 sum += cell as i64;
1175 count += 1;
1176 }
1177 }
1178 if count == 0 {
1179 return 0.0;
1180 }
1181 sum as f64 / count as f64
1182}
1183
1184fn greedy_merge(
1196 dsu: &mut Dsu,
1197 w: &[Vec<i32>],
1198 n: usize,
1199 threshold: i32,
1200 cross_unit: &[Vec<bool>],
1201) {
1202 loop {
1203 let mut best: Option<(i32, usize, usize)> = None;
1204 for i in 0..n {
1205 for j in (i + 1)..n {
1206 if dsu.find(i) == dsu.find(j) {
1207 continue;
1208 }
1209 let wi = cluster_weight(w, dsu, i, j, cross_unit);
1210 if wi < threshold {
1211 continue;
1212 }
1213 let accepted = wi >= SERVICE_THRESHOLD
1214 || cluster_avg(w, dsu, i, j, cross_unit) >= COHESION_FRACTION * wi as f64;
1215 if !accepted {
1216 continue;
1217 }
1218 match best {
1219 Some((bw, bi, bj)) => {
1220 if wi > bw || (wi == bw && (i < bi || (i == bi && j < bj))) {
1221 best = Some((wi, i, j));
1222 }
1223 }
1224 None => best = Some((wi, i, j)),
1225 }
1226 }
1227 }
1228 match best {
1229 Some((_, i, j)) => dsu.union(i, j),
1230 _ => break,
1231 }
1232 }
1233}
1234
1235fn cluster_weight_sum(
1241 w: &[Vec<i32>],
1242 dsu: &mut Dsu,
1243 a: usize,
1244 b: usize,
1245 cross_unit: &[Vec<bool>],
1246) -> i32 {
1247 let (ra, rb) = (dsu.find(a), dsu.find(b));
1248 let mut sum = 0;
1249 for (i, row) in w.iter().enumerate() {
1250 if dsu.find(i) != ra {
1251 continue;
1252 }
1253 for (j, cell) in row.iter().enumerate() {
1254 if dsu.find(j) != rb {
1255 continue;
1256 }
1257 if cross_unit[i][j] && *cell <= SERVICE_THRESHOLD {
1258 continue;
1259 }
1260 sum += *cell;
1261 }
1262 }
1263 sum
1264}
1265
1266fn greedy_merge_sum(
1268 dsu: &mut Dsu,
1269 w: &[Vec<i32>],
1270 n: usize,
1271 threshold: i32,
1272 cross_unit: &[Vec<bool>],
1273) {
1274 loop {
1275 let mut best: Option<(i32, usize, usize)> = None;
1276 for i in 0..n {
1277 for j in (i + 1)..n {
1278 if dsu.find(i) == dsu.find(j) {
1279 continue;
1280 }
1281 let wi = cluster_weight_sum(w, dsu, i, j, cross_unit);
1282 match best {
1283 Some((bw, bi, bj)) => {
1284 if wi > bw || (wi == bw && (i < bi || (i == bi && j < bj))) {
1285 best = Some((wi, i, j));
1286 }
1287 }
1288 None => best = Some((wi, i, j)),
1289 }
1290 }
1291 }
1292 match best {
1293 Some((wi, i, j)) if wi >= threshold => dsu.union(i, j),
1294 _ => break,
1295 }
1296 }
1297}
1298
1299const HIER_RELPREFIX: &str = "rel:hier:";
1302
1303fn hier_rel(parts: &[&str]) -> String {
1304 let mut h = blake3::Hasher::new();
1305 for p in parts {
1306 h.update(p.as_bytes());
1307 h.update(b"|");
1308 }
1309 format!("{HIER_RELPREFIX}{}", &h.finalize().to_hex()[..12])
1310}
1311
1312pub fn compile_services(
1318 store: &Store,
1319 comps: &[scc_core::Entity],
1320 component_weights: &[Vec<i32>],
1321 cross_unit: &[Vec<bool>],
1322 parent_per_comp: &BTreeMap<String, String>,
1323) -> Result<()> {
1324 clear_hierarchy(store)?;
1325 let n = comps.len();
1326 if n < 2 {
1327 return Ok(());
1328 }
1329 let mut dsu = Dsu::new(n);
1330 greedy_merge_sum(&mut dsu, component_weights, n, SERVICE_THRESHOLD, cross_unit);
1331
1332 let mut unions: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
1333 for i in 0..n {
1334 unions.entry(dsu.find(i)).or_default().push(i);
1335 }
1336 let repo = &store.repo_id;
1337 for members in unions.values() {
1338 if members.len() < 2 {
1339 continue;
1340 }
1341 let dus: BTreeSet<&String> = members
1343 .iter()
1344 .filter_map(|i| parent_per_comp.get(comps[*i].name.as_str()))
1345 .collect();
1346 let name = if dus.len() == 1 {
1347 dus.into_iter().next().unwrap().clone()
1348 } else {
1349 let mut names: Vec<String> =
1350 members.iter().map(|i| comps[*i].name.clone()).collect();
1351 names.sort();
1352 names.join("+")
1353 };
1354 let id = scc_core::entity_id(repo, kinds::SERVICE, &name);
1355 let mut e = scc_core::Entity::new(id.clone(), kinds::SERVICE, name.clone());
1356 e.attr("layer", json!(LAYER_SERVICE));
1357 let member_names: Vec<String> =
1358 members.iter().map(|i| comps[*i].name.clone()).collect();
1359 e.attr("members", json!(member_names));
1360 store.insert_entity(&e, &[])?;
1361 for m in members {
1362 let target = scc_core::entity_id(repo, kinds::COMPONENT, &comps[*m].name);
1363 let r = scc_core::Relationship::new(
1364 hier_rel(&["hier_contains", &id, &target]),
1365 id.clone(),
1366 scc_core::predicates::CONTAINS,
1367 target,
1368 scc_core::Provenance::Extracted,
1369 );
1370 store.insert_relationship(&r, "")?;
1371 }
1372 }
1373 Ok(())
1374}
1375
1376pub(crate) fn clear_hierarchy(store: &Store) -> Result<()> {
1379 for kind in [kinds::SUBSYSTEM, kinds::SERVICE] {
1380 let ids: Vec<String> = store
1381 .entities_by_kind(kind)?
1382 .into_iter()
1383 .map(|e| e.id)
1384 .collect();
1385 if !ids.is_empty() {
1386 store.delete_entities(&ids)?;
1387 }
1388 }
1389 let rows = store.all_relationships()?;
1390 let ids: Vec<String> = rows
1391 .into_iter()
1392 .filter(|r| r.id.starts_with(HIER_RELPREFIX))
1393 .map(|r| r.id)
1394 .collect();
1395 for id in ids {
1396 store.delete_relationship(&id)?;
1397 }
1398 Ok(())
1399}
1400
1401#[cfg(test)]
1402mod tests {
1403 use super::*;
1404 use scc_core::{entity_id, kinds, predicates, symbol_id, Entity, Provenance, Relationship};
1405 use scc_store::Store;
1406
1407 fn insert_file_with_symbols(
1410 store: &Store,
1411 path: &str,
1412 symbols: &[&str],
1413 ) -> Vec<String> {
1414 let repo = store.repo_id.clone();
1415 let file_id = entity_id(&repo, kinds::FILE, path);
1416 store
1417 .insert_entity(&Entity::new(file_id.clone(), kinds::FILE, path), &[path.into()])
1418 .unwrap();
1419 let mut sym_ids = Vec::new();
1420 for s in symbols {
1421 let sid = symbol_id(&repo, path, s);
1422 store
1423 .insert_entity(&Entity::new(sid.clone(), kinds::SYMBOL, *s), &[path.into()])
1424 .unwrap();
1425 store
1426 .insert_relationship(
1427 &Relationship::new(
1428 format!("rel:contains:{}:{s}", path.replace('/', "_")),
1429 file_id.clone(),
1430 predicates::CONTAINS,
1431 sid.clone(),
1432 Provenance::Extracted,
1433 ),
1434 path,
1435 )
1436 .unwrap();
1437 sym_ids.push(sid);
1438 }
1439 sym_ids
1440 }
1441
1442 fn store_for() -> (Store, tempfile::TempDir) {
1443 let tmp = tempfile::TempDir::new().unwrap();
1444 let root = tmp.path().join("repo");
1445 std::fs::create_dir_all(&root).unwrap();
1446 let store = Store::open(&tmp.path().join("scc.db"), &root).unwrap();
1447 (store, tmp)
1448 }
1449
1450 fn compile(store: &Store) -> Vec<Entity> {
1451 let graph = RealityGraph::load(store).unwrap();
1452 crate::components::compile_components(&graph, store, &[], &[]).unwrap()
1453 }
1454
1455 #[test]
1456 fn flat_library_package_splits_into_unrelated_modules() {
1458 let (store, _t) = store_for();
1465 let repo = store.repo_id.clone();
1466 let mut pkg = Entity::new(entity_id(&repo, kinds::PACKAGE, "src"), kinds::PACKAGE, "src");
1467 pkg.attr("path", serde_json::json!("src"));
1468 store
1469 .insert_entity(&pkg, &["src/core.py".into()])
1470 .unwrap();
1471 let _ = insert_file_with_symbols(&store, "src/core.py", &["core_fn"]);
1474 let _ = insert_file_with_symbols(&store, "src/parser.py", &["parse_arg"]);
1475 let _ = insert_file_with_symbols(&store, "src/termui.py", &["render_line"]);
1476
1477 let comps = compile(&store);
1478 let names: Vec<&str> = comps.iter().map(|c| c.name.as_str()).collect();
1479 for n in ["src/core.py", "src/parser.py", "src/termui.py"] {
1480 assert!(names.contains(&n), "module {n} split out: {names:?}");
1481 }
1482 assert!(
1483 !names.contains(&"src"),
1484 "no fused package blob: {names:?}"
1485 );
1486 for n in ["src/core.py", "src/parser.py", "src/termui.py"] {
1488 let c = comps.iter().find(|c| c.name == n).unwrap();
1489 assert_eq!(
1490 c.attributes["boundary_kind"],
1491 serde_json::json!(crate::components::BOUNDARY_PACKAGE),
1492 "{n}"
1493 );
1494 assert_eq!(c.attributes["layer"], serde_json::json!(LAYER_COMPONENT), "{n}");
1495 }
1496 }
1497
1498 #[test]
1499 fn single_link_chaining_is_blocked() {
1501 let (store, _t) = store_for();
1507 let repo = store.repo_id.clone();
1508 let sa = insert_file_with_symbols(&store, "a/x.py", &["a_run"]);
1509 let sb = insert_file_with_symbols(&store, "b/x.py", &["b_run"]);
1510 let sc = insert_file_with_symbols(&store, "c/x.py", &["c_run"]);
1511 let sd = insert_file_with_symbols(&store, "d/x.py", &["d_run"]);
1512 for (i, (syms, db)) in [
1514 ([sa[0].clone(), sb[0].clone()].to_vec(), "db1"),
1515 ([sb[0].clone(), sc[0].clone()].to_vec(), "db2"),
1516 ([sc[0].clone(), sd[0].clone()].to_vec(), "db3"),
1517 ]
1518 .into_iter()
1519 .enumerate()
1520 {
1521 let store_ent = entity_id(&repo, kinds::DATA_STORE, db);
1522 store
1523 .insert_entity(
1524 &Entity::new(store_ent.clone(), kinds::DATA_STORE, db),
1525 &["a/x.py".into()],
1526 )
1527 .unwrap();
1528 for (k, sym) in syms.iter().enumerate() {
1529 store
1530 .insert_relationship(
1531 &Relationship::new(
1532 format!("rel:w:{i}:{k}"),
1533 sym.clone(),
1534 predicates::WRITES,
1535 store_ent.clone(),
1536 Provenance::Extracted,
1537 ),
1538 "a/x.py",
1539 )
1540 .unwrap();
1541 }
1542 }
1543 for (i, (from, to)) in [
1545 (sa[0].clone(), sb[0].clone()),
1546 (sb[0].clone(), sc[0].clone()),
1547 (sc[0].clone(), sd[0].clone()),
1548 ]
1549 .iter()
1550 .enumerate()
1551 {
1552 store
1553 .insert_relationship(
1554 &Relationship::new(
1555 format!("rel:call:{i}"),
1556 from.clone(),
1557 predicates::CALLS,
1558 to.clone(),
1559 Provenance::Extracted,
1560 ),
1561 "a/x.py",
1562 )
1563 .unwrap();
1564 }
1565
1566 let comps = compile(&store);
1567 let names: Vec<&str> = comps.iter().map(|c| c.name.as_str()).collect();
1568 assert!(
1569 names.contains(&"a+b+c"),
1570 "the first three links cohere (avg 3 >= 2.4): {names:?}"
1571 );
1572 assert!(
1573 names.contains(&"d"),
1574 "the tail link must NOT chain on a single edge: {names:?}"
1575 );
1576 assert!(
1577 !names.contains(&"a+b+c+d"),
1578 "the chain must not collapse into one component: {names:?}"
1579 );
1580 }
1581
1582 #[test]
1583 fn code_region_dir_splits_into_low_cohesion_modules() {
1584 let (store, _t) = store_for();
1589 let repo = store.repo_id.clone();
1590 let _ = insert_file_with_symbols(&store, "src/checkout/cart.py", &["add_item"]);
1591 let _ = insert_file_with_symbols(&store, "src/pricing/tax.py", &["compute_tax"]);
1592 let _ = repo;
1594
1595 let comps = compile(&store);
1596 let names: Vec<&str> = comps.iter().map(|c| c.name.as_str()).collect();
1597 assert!(
1598 names.contains(&"src/checkout"),
1599 "checkout module split out: {names:?}"
1600 );
1601 assert!(names.contains(&"src/pricing"), "pricing module split out: {names:?}");
1602 assert!(!names.contains(&"src"), "no fused src blob: {names:?}");
1603 assert!(names.contains(&"root"), "root shell stays: {names:?}");
1604 for n in ["src/checkout", "src/pricing"] {
1606 let c = comps.iter().find(|c| c.name == n).unwrap();
1607 assert_eq!(
1608 c.attributes["boundary_kind"],
1609 serde_json::json!(crate::components::BOUNDARY_CODE_REGION),
1610 "{n}"
1611 );
1612 assert_eq!(c.attributes["layer"], serde_json::json!(LAYER_CODE_REGION), "{n}");
1613 }
1614 }
1615
1616 #[test]
1617 fn cross_dir_regions_merge_on_call_and_state_weight() {
1618 let (store, _t) = store_for();
1622 let repo = store.repo_id.clone();
1623 let sa = insert_file_with_symbols(&store, "auth/session.py", &["create_session"]);
1624 let sb = insert_file_with_symbols(&store, "users/api.py", &["get_user"]);
1625
1626 let db = entity_id(&repo, kinds::DATA_STORE, "db");
1627 store
1628 .insert_entity(&Entity::new(db.clone(), kinds::DATA_STORE, "db"), &["auth/session.py".into()])
1629 .unwrap();
1630 for (i, sym) in [sa[0].clone(), sb[0].clone()].iter().enumerate() {
1631 store
1632 .insert_relationship(
1633 &Relationship::new(
1634 format!("rel:w:{i}"),
1635 sym.clone(),
1636 predicates::WRITES,
1637 db.clone(),
1638 Provenance::Extracted,
1639 ),
1640 "auth/session.py",
1641 )
1642 .unwrap();
1643 }
1644 store
1646 .insert_relationship(
1647 &Relationship::new(
1648 "rel:call",
1649 sa[0].clone(),
1650 predicates::CALLS,
1651 sb[0].clone(),
1652 Provenance::Extracted,
1653 ),
1654 "auth/session.py",
1655 )
1656 .unwrap();
1657
1658 let comps = compile(&store);
1659 let names: Vec<&str> = comps.iter().map(|c| c.name.as_str()).collect();
1660 assert!(
1661 names.contains(&"auth+users"),
1662 "cross-dir merge into one component: {names:?}"
1663 );
1664 assert!(!names.contains(&"auth"), "no auth shell: {names:?}");
1665 assert!(!names.contains(&"users"), "no users shell: {names:?}");
1666 let merged = comps.iter().find(|c| c.name == "auth+users").unwrap();
1667 let paths = merged.attributes["implementation"]["paths"]
1668 .as_array()
1669 .unwrap();
1670 assert_eq!(
1671 paths,
1672 &vec![serde_json::json!("auth"), serde_json::json!("users")],
1673 "merged component keeps both dirs as priors"
1674 );
1675 assert_eq!(merged.attributes["layer"], serde_json::json!(LAYER_COMPONENT));
1676 }
1677
1678 #[test]
1679 fn library_architecture_comes_from_exports() {
1680 let (store, _t) = store_for();
1685 let repo = store.repo_id.clone();
1686 let si = insert_file_with_symbols(&store, "lib/contracts/base.py", &["iface"]);
1687 let sa = insert_file_with_symbols(&store, "lib/impl_a/a.py", &["ImplA"]);
1688 let sb = insert_file_with_symbols(&store, "lib/impl_b/b.py", &["ImplB"]);
1689
1690 for (sym, name) in [
1692 (si[0].clone(), "iface"),
1693 (sa[0].clone(), "ImplA"),
1694 (sb[0].clone(), "ImplB"),
1695 ] {
1696 let exp = entity_id(&repo, kinds::EXPORT, name);
1697 store
1698 .insert_entity(&Entity::new(exp.clone(), kinds::EXPORT, name), &["lib/contracts/base.py".into()])
1699 .unwrap();
1700 store
1701 .insert_relationship(
1702 &Relationship::new(
1703 format!("rel:exp:{name}"),
1704 sym.clone(),
1705 predicates::EXPORTS,
1706 exp,
1707 Provenance::Extracted,
1708 ),
1709 "lib/contracts/base.py",
1710 )
1711 .unwrap();
1712 }
1713 for (i, sym) in [sa[0].clone(), sb[0].clone()].iter().enumerate() {
1715 store
1716 .insert_relationship(
1717 &Relationship::new(
1718 format!("rel:impl:{i}"),
1719 sym.clone(),
1720 predicates::IMPLEMENTS,
1721 si[0].clone(),
1722 Provenance::Extracted,
1723 ),
1724 "lib/impl_a/a.py",
1725 )
1726 .unwrap();
1727 }
1728
1729 let comps = compile(&store);
1730 let names: Vec<&str> = comps.iter().map(|c| c.name.as_str()).collect();
1731 assert!(
1734 names.contains(&"lib"),
1735 "export-driven merge into one component: {names:?}"
1736 );
1737 assert!(!names.contains(&"lib/contracts"), "no contracts shell: {names:?}");
1738 assert!(!names.contains(&"lib/impl_a"), "no impl_a shell: {names:?}");
1739 assert!(!names.contains(&"lib/impl_b"), "no impl_b shell: {names:?}");
1740 assert!(names.contains(&"root"), "root shell stays: {names:?}");
1741 let lib = comps.iter().find(|c| c.name == "lib").unwrap();
1742 assert_eq!(lib.attributes["layer"], serde_json::json!(LAYER_COMPONENT));
1743 }
1744
1745 #[test]
1751 fn queue_consumer_regions_cohere_on_surface_family() {
1753 let (store, _t) = store_for();
1754 let repo = store.repo_id.clone();
1755 let sa = insert_file_with_symbols(&store, "jobs-a/consumer.py", &["consume_a"]);
1756 let sb = insert_file_with_symbols(&store, "jobs-b/consumer.py", &["consume_b"]);
1757 for (i, (sym, topic)) in [
1758 (sa[0].clone(), "orders".to_string()),
1759 (sb[0].clone(), "shipments".to_string()),
1760 ]
1761 .iter()
1762 .enumerate()
1763 {
1764 let topic_id = entity_id(&repo, kinds::TOPIC, topic);
1765 store
1766 .insert_entity(
1767 &Entity::new(topic_id.clone(), kinds::TOPIC, topic),
1768 &["jobs-a/consumer.py".into()],
1769 )
1770 .unwrap();
1771 store
1772 .insert_relationship(
1773 &Relationship::new(
1774 format!("rel:sub:{i}"),
1775 sym.clone(),
1776 predicates::SUBSCRIBES,
1777 topic_id,
1778 Provenance::Extracted,
1779 ),
1780 "jobs-a/consumer.py",
1781 )
1782 .unwrap();
1783 }
1784 store
1787 .insert_relationship(
1788 &Relationship::new(
1789 "rel:call",
1790 sa[0].clone(),
1791 predicates::CALLS,
1792 sb[0].clone(),
1793 Provenance::Extracted,
1794 ),
1795 "jobs-a/consumer.py",
1796 )
1797 .unwrap();
1798
1799 let comps = compile(&store);
1800 let names: Vec<&str> = comps.iter().map(|c| c.name.as_str()).collect();
1801 assert!(
1802 names.contains(&"jobs-a+jobs-b"),
1803 "queue-consumer regions cohere into one component: {names:?}"
1804 );
1805 assert!(!names.contains(&"jobs-a"), "no jobs-a shell: {names:?}");
1806 assert!(!names.contains(&"jobs-b"), "no jobs-b shell: {names:?}");
1807 }
1808
1809 #[test]
1810 fn deployment_unit_boundary_is_a_constraint() {
1811 let (store, _t) = store_for();
1815 let repo = store.repo_id.clone();
1816 let sa = insert_file_with_symbols(&store, "svc-a/worker.py", &["a_main"]);
1817 let sb = insert_file_with_symbols(&store, "svc-b/worker.py", &["b_main"]);
1818
1819 for (name, ctx, file) in [
1820 ("du-a", "svc-a", "svc-a/worker.py"),
1821 ("du-b", "svc-b", "svc-b/worker.py"),
1822 ] {
1823 let mut du = Entity::new(entity_id(&repo, kinds::DEPLOYMENT_UNIT, name), kinds::DEPLOYMENT_UNIT, name);
1824 du.attr("build_context", serde_json::json!(ctx));
1825 store.insert_entity(&du, &[file.into()]).unwrap();
1826 }
1827 let db = entity_id(&repo, kinds::DATA_STORE, "db");
1828 store
1829 .insert_entity(&Entity::new(db.clone(), kinds::DATA_STORE, "db"), &["svc-a/worker.py".into()])
1830 .unwrap();
1831 for (i, sym) in [sa[0].clone(), sb[0].clone()].iter().enumerate() {
1832 store
1833 .insert_relationship(
1834 &Relationship::new(
1835 format!("rel:w:{i}"),
1836 sym.clone(),
1837 predicates::WRITES,
1838 db.clone(),
1839 Provenance::Extracted,
1840 ),
1841 "svc-a/worker.py",
1842 )
1843 .unwrap();
1844 }
1845 store
1847 .insert_relationship(
1848 &Relationship::new(
1849 "rel:call",
1850 sa[0].clone(),
1851 predicates::CALLS,
1852 sb[0].clone(),
1853 Provenance::Extracted,
1854 ),
1855 "svc-a/worker.py",
1856 )
1857 .unwrap();
1858
1859 let comps = compile(&store);
1860 let names: Vec<&str> = comps.iter().map(|c| c.name.as_str()).collect();
1861 assert!(
1862 names.contains(&"du-a") && names.contains(&"du-b"),
1863 "cross-unit weight 6 must NOT merge: {names:?}"
1864 );
1865 assert!(
1866 !names.iter().any(|n| n.contains('+')),
1867 "no cross-unit merge at 6: {names:?}"
1868 );
1869 for n in ["du-a", "du-b"] {
1872 let c = comps.iter().find(|c| c.name == n).unwrap();
1873 assert_eq!(c.attributes["parent"].as_str().unwrap(), n, "{n}");
1874 }
1875
1876 for i in 0..4 {
1880 store
1881 .insert_relationship(
1882 &Relationship::new(
1883 format!("rel:call2:{i}"),
1884 sa[0].clone(),
1885 predicates::CALLS,
1886 sb[0].clone(),
1887 Provenance::Extracted,
1888 ),
1889 "svc-a/worker.py",
1890 )
1891 .unwrap();
1892 }
1893 let comps2 = compile(&store);
1894 let names2: Vec<&str> = comps2.iter().map(|c| c.name.as_str()).collect();
1895 assert!(
1896 names2.iter().any(|n| n.contains('+')),
1897 "cross-unit weight >12 merges: {names2:?}"
1898 );
1899 }
1900
1901 #[test]
1902 fn archetype_prior_makes_cli_command_regions_cohere() {
1903 let (store, _t) = store_for();
1907 let sa = insert_file_with_symbols(&store, "cmd/serve/serve.go", &["serve_root"]);
1908 let sb = insert_file_with_symbols(&store, "cmd/version/version.go", &["version_root"]);
1909
1910 for (sym, file) in [(sa[0].clone(), "cmd/serve/serve.go"), (sb[0].clone(), "cmd/version/version.go")] {
1913 let mut e = store.get_entity(&sym).unwrap().unwrap();
1914 e.attributes.insert("entrypoints".into(), serde_json::json!(["cli-subcommand"]));
1915 e.attributes.insert("file".into(), serde_json::json!(file));
1916 store.insert_entity(&e, &[file.into()]).unwrap();
1917 }
1918 store
1921 .insert_relationship(
1922 &Relationship::new(
1923 "rel:call",
1924 sa[0].clone(),
1925 predicates::CALLS,
1926 sb[0].clone(),
1927 Provenance::Extracted,
1928 ),
1929 "cmd/serve/serve.go",
1930 )
1931 .unwrap();
1932
1933 let comps = compile(&store);
1934 let names: Vec<&str> = comps.iter().map(|c| c.name.as_str()).collect();
1935 assert!(
1938 names.contains(&"cmd"),
1939 "cli command regions cohere into one component: {names:?}"
1940 );
1941 assert!(!names.contains(&"cmd/serve"), "no serve shell: {names:?}");
1942 assert!(!names.contains(&"cmd/version"), "no version shell: {names:?}");
1943 let cmd = comps.iter().find(|c| c.name == "cmd").unwrap();
1944 assert_eq!(
1945 cmd.attributes["boundary_kind"],
1946 serde_json::json!(crate::components::BOUNDARY_CLI),
1947 "merged cli regions keep the cli boundary"
1948 );
1949 }
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960 #[test]
1961 fn clustering_is_deterministic() {
1962 let (store, _t) = store_for();
1965 let repo = store.repo_id.clone();
1966 let sa = insert_file_with_symbols(&store, "auth/session.py", &["create_session"]);
1967 let sb = insert_file_with_symbols(&store, "users/api.py", &["get_user"]);
1968 let _ = insert_file_with_symbols(&store, "billing/invoice.py", &["make_invoice"]);
1969 let db = entity_id(&repo, kinds::DATA_STORE, "db");
1970 store
1971 .insert_entity(&Entity::new(db.clone(), kinds::DATA_STORE, "db"), &["auth/session.py".into()])
1972 .unwrap();
1973 for (i, sym) in [sa[0].clone(), sb[0].clone()].iter().enumerate() {
1974 store
1975 .insert_relationship(
1976 &Relationship::new(
1977 format!("rel:w:{i}"),
1978 sym.clone(),
1979 predicates::WRITES,
1980 db.clone(),
1981 Provenance::Extracted,
1982 ),
1983 "auth/session.py",
1984 )
1985 .unwrap();
1986 }
1987 store
1988 .insert_relationship(
1989 &Relationship::new(
1990 "rel:call",
1991 sa[0].clone(),
1992 predicates::CALLS,
1993 sb[0].clone(),
1994 Provenance::Extracted,
1995 ),
1996 "auth/session.py",
1997 )
1998 .unwrap();
1999
2000 let c1 = compile(&store);
2001 let c2 = compile(&store);
2002 assert_eq!(c1.len(), c2.len());
2003 for (a, b) in c1.iter().zip(c2.iter()) {
2004 assert_eq!(a.name, b.name);
2005 assert_eq!(a.attributes.get("layer"), b.attributes.get("layer"), "{}", a.name);
2006 assert_eq!(a.attributes.get("parent"), b.attributes.get("parent"), "{}", a.name);
2007 assert_eq!(
2008 a.attributes.get("boundary_kind"),
2009 b.attributes.get("boundary_kind"),
2010 "{}",
2011 a.name
2012 );
2013 assert_eq!(
2014 a.attributes.get("clustering_score"),
2015 b.attributes.get("clustering_score"),
2016 "{}",
2017 a.name
2018 );
2019 }
2020 assert!(c1.iter().any(|c| c.name == "auth+users"));
2022 assert!(c2.iter().any(|c| c.name == "auth+users"));
2023 }
2024}