1use crate::{RealityGraph, Result};
14use scc_core::kinds;
15use scc_core::{entity_id, Provenance, Relationship};
16use scc_store::Store;
17use serde_json::json;
18use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
19
20pub const RELPREFIX: &str = "rel:comp:";
21
22pub const BOUNDARY_DECLARED: &str = "declared";
27pub const BOUNDARY_PACKAGE: &str = "package";
28pub const BOUNDARY_DEPLOYMENT: &str = "deployment";
29pub const BOUNDARY_CLI: &str = "cli";
30pub const BOUNDARY_CODE_REGION: &str = "code-region";
31pub const BOUNDARY_ROOT: &str = "root";
32
33pub const LAYER_CODE_REGION: &str = "code_region";
35pub const LAYER_COMPONENT: &str = "component";
36pub const LAYER_SUBSYSTEM: &str = "subsystem";
37pub const LAYER_SERVICE: &str = "service";
38
39pub const MERGE_THRESHOLD: i32 = 6;
41pub const SERVICE_THRESHOLD: i32 = 12;
43
44pub(crate) fn boundary_rank(kind: &str) -> u8 {
48 match kind {
49 BOUNDARY_DECLARED => 3,
50 BOUNDARY_DEPLOYMENT => 2,
51 BOUNDARY_CLI => 2,
52 BOUNDARY_PACKAGE => 1,
53 _ => 0,
54 }
55}
56
57pub fn rel(parts: &[&str]) -> String {
58 let mut h = blake3::Hasher::new();
59 for p in parts {
60 h.update(p.as_bytes());
61 h.update(b"|");
62 }
63 format!("{RELPREFIX}{}", &h.finalize().to_hex()[..12])
64}
65
66pub fn parse_prov(s: &str) -> Provenance {
67 match s {
68 "RESOLVED" => Provenance::Resolved,
69 "EXTRACTED" => Provenance::Extracted,
70 "DECLARED" => Provenance::Declared,
71 "OBSERVED" => Provenance::Observed,
72 "INFERRED" => Provenance::Inferred,
73 _ => Provenance::Inferred,
74 }
75}
76
77pub fn prov_rank(p: Provenance) -> u8 {
78 match p {
79 Provenance::Resolved => 4,
80 Provenance::Observed => 4,
81 Provenance::Extracted => 3,
82 Provenance::Declared => 2,
83 Provenance::Inferred => 1,
84 Provenance::Stale => 0,
85 }
86}
87
88pub fn path_role(path: &str) -> Option<&'static str> {
100 for (depth, seg) in path.split('/').enumerate() {
109 match seg.to_ascii_lowercase().as_str() {
110 "tests" | "test" | "testing" | "__tests__" | "spec" | "specs" | "e2e" => {
111 return Some("test")
112 }
113 "fixtures" | "fixture" | "testdata" | "test-data" => return Some("fixture"),
114 "benchmarks" | "benchmark" | "benches" | "bench" => return Some("benchmark"),
115 "examples" | "example" | "samples" | "demo" => {
116 if depth <= 1 {
117 return Some("example");
118 }
119 }
120 "docs" | "doc" => return Some("docs"),
121 "config" | "conf" | "configuration" | "workflows" => return Some("config"),
122 "generated" | "__generated__" => return Some("generated"),
123 "vendor" | "third_party" | "third-party" | "node_modules" => return Some("vendor"),
124 "target" | "dist" | "build" | "out" | "coverage" => return Some("build"),
125 "tools" | "tooling" | "devtools" => return Some("tooling"),
126 "sdk" => return Some("sdk"),
127 _ => {}
128 }
129 }
130 None
131}
132
133pub fn is_production_path(path: &str) -> bool {
136 path_role(path).is_none()
137}
138
139pub fn component_role(paths: &[String]) -> &'static str {
141 let mut roles = std::collections::BTreeSet::new();
142 for p in paths {
143 if let Some(r) = path_role(p) {
144 roles.insert(r);
145 }
146 }
147 if roles.is_empty() {
148 return "production";
149 }
150 let all_covered = paths.iter().all(|p| path_role(p).is_some());
151 if roles.len() == 1 && all_covered {
152 return roles.into_iter().next().unwrap_or("production");
153 }
154 "mixed"
155}
156
157#[derive(Debug, Clone)]
158pub struct ComponentCandidate {
160 pub name: String,
161 pub dirs: Vec<String>,
162 pub boundary_kind: String,
165 pub intent: Option<String>,
171}
172
173impl ComponentCandidate {
175pub fn new(name: impl Into<String>, dirs: Vec<String>, boundary_kind: impl Into<String>) -> Self {
177 ComponentCandidate {
178 name: name.into(),
179 dirs,
180 boundary_kind: boundary_kind.into(),
181 intent: None,
182 }
183 }
184}
185
186pub fn component_for_path(path: &str, candidates: &[ComponentCandidate]) -> String {
189 let mut best: Option<(String, usize)> = None;
190 for c in candidates {
191 for d in &c.dirs {
192 let d = d.trim_end_matches('/');
193 if d.is_empty() {
194 continue;
195 }
196 if path == d || path.starts_with(&format!("{d}/")) {
197 let len = d.len();
198 if best.as_ref().map(|(_, bl)| len > *bl).unwrap_or(true) {
199 best = Some((c.name.clone(), len));
200 }
201 }
202 }
203 }
204 match best {
205 Some((name, _)) => name,
206 None => {
207 if path.contains('/') {
209 let seg = path.split('/').next().unwrap_or("root");
210 if seg == ".scc" {
211 "root".to_string()
212 } else {
213 seg.to_string()
214 }
215 } else {
216 "root".to_string()
217 }
218 }
219 }
220}
221
222fn norm_manifest_dir(p: &str) -> String {
225 let t = p.trim();
226 let t = t.strip_prefix("./").unwrap_or(t);
227 t.trim_end_matches('/').to_string()
228}
229
230fn toml_string_array(table: &str, key: &str) -> Vec<String> {
235 let mut out: Vec<String> = Vec::new();
236 let mut rest = table;
237 while let Some(pos) = rest.find(key) {
238 let before_ok = pos == 0
239 || !rest[..pos]
240 .chars()
241 .last()
242 .map(|c| c.is_alphanumeric() || c == '_')
243 .unwrap_or(false);
244 let after_trim = rest[pos + key.len()..].trim_start();
245 if before_ok && after_trim.starts_with('=') {
246 let mut scan = &after_trim[1..];
247 loop {
248 let open = scan.find('"');
249 let close = scan.find(']');
250 match (open, close) {
251 (Some(q), Some(c)) if q < c => match scan[q + 1..].find('"') {
252 Some(e) => {
253 out.push(scan[q + 1..q + 1 + e].to_string());
254 scan = &scan[q + 1 + e + 1..];
255 }
256 None => break,
257 },
258 _ => break, }
260 }
261 break;
262 }
263 rest = &rest[pos + key.len()..];
264 }
265 out
266}
267
268fn toml_table(text: &str, header: &str) -> Option<String> {
272 let mut start: Option<usize> = None;
273 let mut end = text.lines().count();
274 for (i, line) in text.lines().enumerate() {
275 let l = line.trim();
276 if start.is_none() {
277 if l == header {
278 start = Some(i);
279 }
280 continue;
281 }
282 if l.starts_with('[') && l.ends_with(']') {
283 end = i;
284 break;
285 }
286 }
287 let start = start?;
288 Some(
289 text.lines()
290 .skip(start)
291 .take(end.saturating_sub(start))
292 .collect::<Vec<_>>()
293 .join("\n"),
294 )
295}
296
297fn toml_package_name(text: &str) -> Option<String> {
300 let table = toml_table(text, "[package]")?;
301 for line in table.lines() {
302 let l = line.trim();
303 if let Some(eq) = l.find('=') {
304 if l[..eq].trim() == "name" {
305 let v = l[eq + 1..].trim();
306 if let Some(s) = v.strip_prefix('"') {
307 if let Some(e) = s.find('"') {
308 return Some(s[..e].to_string());
309 }
310 }
311 }
312 }
313 }
314 None
315}
316
317fn gowork_use_dirs(text: &str) -> Vec<String> {
320 let mut out: Vec<String> = Vec::new();
321 let lines: Vec<&str> = text.lines().map(str::trim).collect();
322 let mut i = 0;
323 while i < lines.len() {
324 let toks: Vec<&str> = lines[i].split_whitespace().collect();
325 if toks.first() == Some(&"use") {
326 if toks.len() >= 2 && toks[1].starts_with('(') {
327 for t in &toks[1..] {
329 let t = t.trim_matches(|c| c == '(' || c == ')');
330 if !t.is_empty() {
331 out.push(norm_manifest_dir(t));
332 }
333 }
334 i += 1;
335 while i < lines.len() && lines[i] != ")" {
336 let l = lines[i];
337 if !l.is_empty() && !l.starts_with("//") {
338 out.push(norm_manifest_dir(l));
339 }
340 i += 1;
341 }
342 } else if toks.len() >= 2 {
343 out.push(norm_manifest_dir(toks[1]));
344 } else {
345 i += 1;
347 while i < lines.len() && lines[i] != ")" {
348 let l = lines[i];
349 if !l.is_empty() && !l.starts_with("//") {
350 out.push(norm_manifest_dir(l));
351 }
352 i += 1;
353 }
354 }
355 }
356 i += 1;
357 }
358 out
359}
360
361fn crate_name(root: &std::path::Path, dir: &str) -> String {
365 if let Ok(text) = std::fs::read_to_string(root.join(dir).join("Cargo.toml")) {
366 if let Some(n) = toml_package_name(&text) {
367 if !n.is_empty() {
368 return n;
369 }
370 }
371 }
372 dir.rsplit('/').next().unwrap_or(dir).to_string()
373}
374
375fn cargo_workspace_members(root: &std::path::Path) -> Vec<(String, String)> {
379 let text = match std::fs::read_to_string(root.join("Cargo.toml")) {
380 Ok(t) => t,
381 Err(_) => return Vec::new(),
382 };
383 let Some(ws) = toml_table(&text, "[workspace]") else {
384 return Vec::new();
385 };
386 let members = toml_string_array(&ws, "members");
387 if members.is_empty() {
388 return Vec::new();
389 }
390 let excludes: BTreeSet<String> =
391 toml_string_array(&ws, "exclude").into_iter().map(|e| norm_manifest_dir(&e)).collect();
392 let mut out: Vec<(String, String)> = Vec::new();
393 for m in members {
394 let m = norm_manifest_dir(&m);
395 if m.is_empty() || m == "." || excludes.contains(&m) {
396 continue;
397 }
398 if let Some(glob_dir) = m.strip_suffix("/*") {
399 let base = root.join(glob_dir);
400 let mut dirs: Vec<String> = std::fs::read_dir(&base)
401 .map(|rd| {
402 rd.filter_map(|e| e.ok())
403 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
404 .filter_map(|e| e.file_name().into_string().ok())
405 .filter(|n| !n.starts_with('.'))
406 .filter(|n| {
407 root.join(format!("{glob_dir}/{n}")).join("Cargo.toml").is_file()
408 })
409 .collect()
410 })
411 .unwrap_or_default();
412 dirs.sort();
413 for d in dirs {
414 let dir = format!("{glob_dir}/{d}");
415 if excludes.contains(&dir) {
416 continue;
417 }
418 out.push((crate_name(root, &dir), dir));
419 }
420 } else if root.join(&m).is_dir() {
421 out.push((crate_name(root, &m), m));
422 }
423 }
425 out
426}
427
428fn merge_manifest_candidate(out: &mut Vec<ComponentCandidate>, name: String, dir: String) {
430 if let Some(c) = out.iter_mut().find(|c| c.name == name) {
431 if !c.dirs.contains(&dir) {
432 c.dirs.push(dir);
433 }
434 if boundary_rank(BOUNDARY_PACKAGE) > boundary_rank(&c.boundary_kind) {
435 c.boundary_kind = BOUNDARY_PACKAGE.to_string();
436 }
437 } else {
438 out.push(ComponentCandidate {
439 name,
440 dirs: vec![dir],
441 boundary_kind: BOUNDARY_PACKAGE.to_string(), intent: None,
442 });
443 }
444}
445
446fn manifest_package_candidates(root: &std::path::Path) -> Vec<ComponentCandidate> {
452 let mut out: Vec<ComponentCandidate> = Vec::new();
453 for (name, dir) in cargo_workspace_members(root) {
454 merge_manifest_candidate(&mut out, name, dir);
455 }
456 for dir in gowork_use_dirs(&std::fs::read_to_string(root.join("go.work")).unwrap_or_default()) {
457 if dir.is_empty() || dir == "." || !root.join(&dir).is_dir() {
458 continue; }
460 let name = dir.rsplit('/').next().unwrap_or(&dir).to_string();
461 merge_manifest_candidate(&mut out, name, dir);
462 }
463 out
464}
465
466fn cli_package_dirs(graph: &RealityGraph) -> BTreeSet<String> {
474 let mut dirs: BTreeSet<String> = BTreeSet::new();
475 let note_file = |fname: &str, dirs: &mut BTreeSet<String>| {
476 let dir = match fname.rsplit_once('/') {
477 Some((parent, _)) if !parent.is_empty() => parent.to_string(),
478 _ => "root".to_string(),
479 };
480 dirs.insert(dir);
481 };
482 for f in graph.entities_of_kind(kinds::FILE) {
485 let has_cli = f
486 .attributes
487 .get("entrypoints")
488 .and_then(|v| v.as_array())
489 .map(|eps| eps.iter().any(|e| e.as_str() == Some("cli-subcommand")))
490 .unwrap_or(false);
491 if has_cli {
492 note_file(&f.name, &mut dirs);
493 }
494 }
495 for e in graph.entities_of_kind(kinds::SYMBOL) {
497 let is_cli = e
498 .attributes
499 .get("entrypoints")
500 .and_then(|v| v.as_array())
501 .map(|eps| eps.iter().any(|e| e.as_str() == Some("cli-subcommand")))
502 .unwrap_or(false)
503 || e
504 .attributes
505 .get("cli_flags")
506 .and_then(|v| v.as_array())
507 .map(|fl| !fl.is_empty())
508 .unwrap_or(false);
509 if is_cli {
510 if let Some(file) = e.attributes.get("file").and_then(|v| v.as_str()) {
511 note_file(file, &mut dirs);
512 }
513 }
514 }
515 dirs
516}
517
518pub(crate) fn build_candidates(
524 graph: &RealityGraph,
525 store: &Store,
526 intent: &[(String, serde_json::Value)],
527) -> Vec<ComponentCandidate> {
528 let mut candidates: Vec<ComponentCandidate> = Vec::new();
530 let mut declared_names: HashSet<String> = HashSet::new();
531 for (source, claim) in intent {
532 if source == "component" {
533 let name = claim["name"].as_str().unwrap_or("").to_string();
534 if name.is_empty() {
535 continue;
536 }
537 declared_names.insert(name.clone());
538 let mut dirs: Vec<String> = Vec::new();
539 if let Some(paths) = claim["paths"].as_array() {
540 for p in paths {
541 if let Some(s) = p.as_str() {
542 dirs.push(s.to_string());
543 }
544 }
545 }
546 dirs.push(name.clone()); candidates.push(ComponentCandidate {
548 name: name.clone(),
549 dirs,
550 boundary_kind: BOUNDARY_DECLARED.to_string(),
551 intent: Some(name),
552 });
553 }
554 }
555 for pkg in graph.entities_of_kind(kinds::PACKAGE) {
557 if let Some(path) = pkg.attributes.get("path").and_then(|v| v.as_str()) {
558 let name = pkg.name.clone();
559 if let Some(c) = candidates.iter_mut().find(|c| c.name == name) {
560 if !c.dirs.contains(&path.to_string()) {
561 c.dirs.push(path.to_string());
562 }
563 if boundary_rank(BOUNDARY_PACKAGE) > boundary_rank(&c.boundary_kind) {
564 c.boundary_kind = BOUNDARY_PACKAGE.to_string();
565 }
566 } else {
567 candidates.push(ComponentCandidate {
568 name,
569 dirs: vec![path.to_string()],
570 boundary_kind: BOUNDARY_PACKAGE.to_string(), intent: None,
571 });
572 }
573 }
574 }
575 for cand in manifest_package_candidates(&store.root) {
580 for dir in cand.dirs {
581 merge_manifest_candidate(&mut candidates, cand.name.clone(), dir);
582 }
583 }
584 for du in graph.entities_of_kind(kinds::DEPLOYMENT_UNIT) {
586 if let Some(ctx) = du.attributes.get("build_context").and_then(|v| v.as_str()) {
587 if ctx == "." || ctx == "./" {
588 continue;
589 }
590 let ctx = ctx.trim_start_matches("./");
591 let name = du.name.clone();
592 if let Some(c) = candidates.iter_mut().find(|c| c.name == name) {
593 if !c.dirs.contains(&ctx.to_string()) {
594 c.dirs.push(ctx.to_string());
595 }
596 if boundary_rank(BOUNDARY_DEPLOYMENT) > boundary_rank(&c.boundary_kind) {
597 c.boundary_kind = BOUNDARY_DEPLOYMENT.to_string();
598 }
599 } else {
600 candidates.push(ComponentCandidate {
601 name,
602 dirs: vec![ctx.to_string()],
603 boundary_kind: BOUNDARY_DEPLOYMENT.to_string(), intent: None,
604 });
605 }
606 }
607 }
608 for dir in cli_package_dirs(graph) {
612 if let Some(c) = candidates.iter_mut().find(|c| c.name == dir) {
613 if !c.dirs.contains(&dir) {
614 c.dirs.push(dir);
615 }
616 if boundary_rank(BOUNDARY_CLI) > boundary_rank(&c.boundary_kind) {
617 c.boundary_kind = BOUNDARY_CLI.to_string();
618 }
619 } else {
620 candidates.push(ComponentCandidate {
621 name: dir.clone(),
622 dirs: vec![dir.clone()],
623 boundary_kind: BOUNDARY_CLI.to_string(), intent: None,
624 });
625 }
626 }
627 let mut top_dirs: HashSet<String> = HashSet::new();
630 for f in graph.entities_of_kind(kinds::FILE) {
631 if f.name.contains('/') {
632 if let Some(seg) = f.name.split('/').next() {
633 if !seg.is_empty() {
634 top_dirs.insert(seg.to_string());
635 }
636 }
637 }
638 }
639 top_dirs.insert("root".to_string());
640 for d in &top_dirs {
641 if !candidates.iter().any(|c| c.name == *d) {
642 candidates.push(ComponentCandidate {
643 name: d.clone(),
644 dirs: vec![d.clone()],
645 boundary_kind: if d == "root" {
646 BOUNDARY_ROOT.to_string()
647 } else {
648 BOUNDARY_CODE_REGION.to_string()
649 }, intent: None,
650 });
651 }
652 }
653 candidates
654}
655
656pub fn compile_components(
658 graph: &RealityGraph,
659 store: &Store,
660 intent: &[(String, serde_json::Value)],
661 pairs: &[crate::cochange::CochangePair],
662) -> Result<Vec<scc_core::Entity>> {
663 let repo_id = &store.repo_id;
664 let candidates = build_candidates(graph, store, intent);
665
666 let du_ctxs: Vec<(String, String)> = graph
678 .entities_of_kind(kinds::DEPLOYMENT_UNIT)
679 .into_iter()
680 .filter_map(|du| {
681 let ctx = du.attributes.get("build_context").and_then(|v| v.as_str())?;
682 if ctx == "." || ctx == "./" {
683 return None;
684 }
685 Some((du.name.clone(), ctx.trim_start_matches("./").to_string()))
686 })
687 .collect();
688 let mut du_ctxs_sorted = du_ctxs;
689 du_ctxs_sorted.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0)));
690 let clustering = crate::clustering::cluster_components(
691 graph,
692 store,
693 intent,
694 &candidates,
695 pairs,
696 &du_ctxs_sorted,
697 )?;
698
699 let files_in_component: BTreeMap<String, Vec<String>> = clustering.files_in_component;
701 let symbol_component: HashMap<String, String> = clustering.symbol_component;
703 let parent_per_comp: BTreeMap<String, String> = clustering.parent_per_comp;
704
705 let mut responsibilities: BTreeMap<String, Vec<(String, Provenance, f64)>> = BTreeMap::new();
707 type OwnershipClaim = (String, Provenance, f64, Vec<String>);
711 let mut owns: BTreeMap<String, Vec<OwnershipClaim>> = BTreeMap::new();
712 let mut depends: BTreeMap<String, Vec<(String, Provenance, f64, u32)>> = BTreeMap::new();
713 let mut symbols_per_comp: BTreeMap<String, Vec<String>> = BTreeMap::new();
714 let mut evidence_per_comp: BTreeMap<String, Vec<String>> = BTreeMap::new();
715 let mut retries_per_comp: BTreeMap<String, Vec<String>> = BTreeMap::new();
716
717 for (sym_id, comp) in &symbol_component {
719 for r in graph.out_pred(sym_id, scc_core::predicates::HANDLES) {
720 if let Some(route) = graph.entities.get(&r.object) {
721 let method = route.attributes.get("method").and_then(|v| v.as_str()).unwrap_or("");
722 let path = route.attributes.get("path").and_then(|v| v.as_str()).unwrap_or("");
723 responsibilities.entry(comp.clone()).or_default().push((
724 format!("Handles {method} {path}"),
725 Provenance::Resolved,
726 1.0,
727 ));
728 }
729 }
730 }
731 for (sym_id, comp) in &symbol_component {
733 for r in graph.out_pred(sym_id, scc_core::predicates::WRITES) {
734 owns.entry(comp.clone()).or_default().push((
735 r.object.clone(),
736 r.provenance,
737 r.confidence,
738 r.evidence.clone(),
739 ));
740 }
741 }
742 for (sym_id, comp) in &symbol_component {
744 for r in graph.out_pred(sym_id, scc_core::predicates::CALLS) {
745 if let Some(target_comp) = symbol_component.get(&r.object) {
746 if target_comp != comp {
747 let entry = depends.entry(comp.clone()).or_default();
748 if let Some((_, p, c, n)) = entry
749 .iter_mut()
750 .find(|(t, _, _, _)| t == target_comp)
751 {
752 *n += 1;
753 if prov_rank(r.provenance) > prov_rank(*p) {
754 *p = r.provenance;
755 }
756 *c = c.max(r.confidence);
757 } else {
758 entry.push((target_comp.clone(), r.provenance, r.confidence, 1));
759 }
760 }
761 }
762 }
763 }
764 for v in owns.values_mut() {
768 v.sort_by(|a, b| a.0.cmp(&b.0));
769 }
770 for v in depends.values_mut() {
771 v.sort_by(|a, b| a.0.cmp(&b.0));
772 }
773 for (sym_id, comp) in &symbol_component {
776 if let Some(e) = graph.entities.get(sym_id) {
777 symbols_per_comp
778 .entry(comp.clone())
779 .or_default()
780 .push(e.name.clone());
781 evidence_per_comp
782 .entry(comp.clone())
783 .or_default()
784 .extend(e.evidence.clone());
785 if let Some(rp) = e.attributes.get("retry_policy").and_then(|v| v.as_str()) {
786 retries_per_comp
787 .entry(comp.clone())
788 .or_default()
789 .push(format!("{} ({rp})", e.name));
790 }
791 }
792 }
793 for v in symbols_per_comp.values_mut() {
794 v.sort();
795 v.dedup();
796 }
797 for v in evidence_per_comp.values_mut() {
798 v.sort();
799 v.dedup();
800 }
801 for v in retries_per_comp.values_mut() {
802 v.sort();
803 v.dedup();
804 }
805
806 let mut symbol_list: Vec<(String, String)> = symbol_component
812 .iter()
813 .map(|(s, c)| (s.clone(), c.clone()))
814 .collect();
815 symbol_list.sort();
816 let mut shared_writes: BTreeMap<(String, String), HashSet<String>> = BTreeMap::new();
818 let mut entrypoints: BTreeMap<String, usize> = BTreeMap::new();
820 let mut events: BTreeMap<String, usize> = BTreeMap::new();
822 let mut calls: BTreeMap<String, (usize, usize)> = BTreeMap::new();
824 for (sym_id, comp) in &symbol_list {
825 for r in graph.out_pred(sym_id, scc_core::predicates::WRITES) {
826 let target = if r.object.contains("/data/") {
830 graph
831 .entities
832 .get(&r.object)
833 .and_then(|e| e.attributes.get("store"))
834 .and_then(|v| v.as_str())
835 .map(|s| entity_id(repo_id, kinds::DATA_STORE, s))
836 .unwrap_or_else(|| r.object.clone())
837 } else {
838 r.object.clone()
839 };
840 shared_writes
841 .entry((comp.clone(), target))
842 .or_default()
843 .insert(sym_id.clone());
844 }
845 for _r in graph.out_pred(sym_id, scc_core::predicates::HANDLES) {
846 *entrypoints.entry(comp.clone()).or_insert(0) += 1;
847 }
848 for _r in graph
849 .out_pred(sym_id, scc_core::predicates::PUBLISHES)
850 .into_iter()
851 .chain(graph.out_pred(sym_id, scc_core::predicates::CONSUMES))
852 {
853 *events.entry(comp.clone()).or_insert(0) += 1;
854 }
855 for r in graph.out_pred(sym_id, scc_core::predicates::CALLS) {
856 let e = calls.entry(comp.clone()).or_insert((0, 0));
857 e.1 += 1;
858 if symbol_component.get(&r.object) == Some(comp) {
859 e.0 += 1;
860 }
861 }
862 }
863 let mut route_entities: BTreeMap<String, usize> = BTreeMap::new();
865 for (comp, files) in &files_in_component {
866 for fid in files {
867 for r in graph.out_pred(fid, scc_core::predicates::CONTAINS) {
868 if graph
869 .entities
870 .get(&r.object)
871 .map(|e| e.kind == kinds::ROUTE)
872 .unwrap_or(false)
873 {
874 *route_entities.entry(comp.clone()).or_insert(0) += 1;
875 }
876 }
877 }
878 }
879 let mut intent_resp: BTreeMap<String, Vec<String>> = BTreeMap::new();
889 let mut intent_owns: BTreeMap<String, Vec<String>> = BTreeMap::new();
890 for (source, claim) in intent {
891 if source == "component" {
892 let name = claim["name"].as_str().unwrap_or("").to_string();
893 if name.is_empty() {
894 continue;
895 }
896 let paths: Vec<String> = claim["paths"]
897 .as_array()
898 .map(|a| {
899 a.iter()
900 .filter_map(|p| p.as_str().map(String::from))
901 .collect()
902 })
903 .unwrap_or_default();
904 let mut targets: Vec<String> = Vec::new();
905 for c in &clustering.clusters {
906 let member_region = c.member_regions.iter().any(|&m| {
911 clustering
912 .regions
913 .get(m)
914 .map(|r| r.name == name)
915 .unwrap_or(false)
916 });
917 let covered = !paths.is_empty()
918 && paths.iter().all(|p| {
919 c.dirs.iter().any(|d| {
920 let d = d.trim_end_matches('/');
921 p == d || p.starts_with(&format!("{d}/")) || d.starts_with(&format!("{p}/"))
922 })
923 });
924 if member_region || covered || c.name == name {
925 targets.push(c.name.clone());
926 }
927 }
928 if let Some(resp) = claim["responsibility"].as_array() {
929 for t in &targets {
930 for r in resp {
931 if let Some(s) = r.as_str() {
932 intent_resp.entry(t.clone()).or_default().push(s.to_string());
933 }
934 }
935 }
936 }
937 if let Some(o) = claim["owns"].as_array() {
938 for t in &targets {
939 for ow in o {
940 if let Some(s) = ow.as_str() {
941 intent_owns.entry(t.clone()).or_default().push(s.to_string());
942 }
943 }
944 }
945 }
946 }
947 }
948
949 let comp_names: Vec<String> = clustering
951 .clusters
952 .iter()
953 .map(|c| c.name.clone())
954 .collect();
955
956 let mut out: Vec<scc_core::Entity> = Vec::new();
957 for name in comp_names {
958 let id = entity_id(repo_id, kinds::COMPONENT, &name);
959 let mut e = scc_core::Entity::new(id.clone(), kinds::COMPONENT, name.clone());
960
961 let mut resp: Vec<serde_json::Value> = Vec::new();
962 let mut seen: HashSet<String> = HashSet::new();
963 let push_resp = |text: String, prov: Provenance, conf: f64,
964 resp: &mut Vec<serde_json::Value>, seen: &mut HashSet<String>| {
965 if !seen.insert(text.clone()) {
966 return;
967 }
968 resp.push(json!({
969 "text": text,
970 "provenance": prov.as_str(),
971 "confidence": conf,
972 }));
973 };
974 if let Some(irs) = intent_resp.get(&name) {
975 for s in irs {
976 push_resp(s.clone(), Provenance::Declared, 1.0, &mut resp, &mut seen);
977 }
978 }
979 if let Some(rs) = responsibilities.get(&name) {
980 let mut sorted = rs.clone();
981 sorted.sort_by(|a, b| {
982 prov_rank(b.1)
983 .cmp(&prov_rank(a.1))
984 .then_with(|| a.0.cmp(&b.0))
985 });
986 for (text, prov, conf) in sorted {
987 push_resp(text, prov, conf, &mut resp, &mut seen);
988 }
989 }
990 e.attr("responsibility", json!(resp));
995
996 let cluster = clustering
997 .clusters
998 .iter()
999 .find(|c| c.name == name)
1000 .expect("every compiled component is a clustering result");
1001 let dirs = cluster.dirs.clone();
1002 e.attr(
1003 "implementation",
1004 json!({
1005 "paths": dirs,
1006 "symbols": symbols_per_comp.get(&name).cloned().unwrap_or_default(),
1007 }),
1008 );
1009
1010 e.attr("role", json!(component_role(&dirs)));
1013
1014 let mut score: f64 = match cluster.boundary_kind.as_str() {
1016 BOUNDARY_DEPLOYMENT => 5.0,
1017 BOUNDARY_PACKAGE => 4.0,
1018 BOUNDARY_CLI => 4.0,
1021 BOUNDARY_CODE_REGION | BOUNDARY_ROOT => 1.0,
1022 _ => 0.0,
1025 };
1026 if shared_writes
1027 .iter()
1028 .any(|((c, _), syms)| c == &name && syms.len() >= 2)
1029 {
1030 score += 4.0; }
1032 if entrypoints.get(&name).copied().unwrap_or(0) > 0 {
1033 score += 4.0; }
1035 if route_entities.get(&name).copied().unwrap_or(0) > 0 {
1036 score += 3.0; }
1038 if events.get(&name).copied().unwrap_or(0) > 0 {
1039 score += 3.0; }
1041 if let Some((internal, total)) = calls.get(&name) {
1042 if *total > 0 {
1043 score += 3.0 * (*internal as f64 / *total as f64); }
1045 }
1046 let dir_refs: Vec<&str> = dirs.iter().map(|d| d.as_str()).collect();
1047 let co_pairs = pairs
1048 .iter()
1049 .filter(|p| {
1050 crate::cochange::file_in_paths(&p.a, &dir_refs)
1051 && crate::cochange::file_in_paths(&p.b, &dir_refs)
1052 })
1053 .count();
1054 score += 2.0 * co_pairs as f64; score = (score * 1000.0).round() / 1000.0;
1056 e.attr("boundary_kind", json!(cluster.boundary_kind.clone()));
1057 e.attr("layer", json!(cluster.layer.clone()));
1058 e.attr("clustering_score", json!(score));
1059 if let Some(parent) = parent_per_comp.get(&name) {
1060 e.attr("parent", json!(parent));
1061 }
1062
1063 let mut owned_claims: Vec<(String, Provenance, f64, Vec<String>)> = owns
1066 .get(&name)
1067 .cloned()
1068 .unwrap_or_default();
1069 if let Some(ios) = intent_owns.get(&name) {
1070 for target in ios {
1071 let target_l = target.to_ascii_lowercase();
1072 let matched = graph
1073 .entities_of_kind(kinds::DATA_STORE)
1074 .into_iter()
1075 .chain(graph.entities_of_kind(kinds::DATA_ENTITY))
1076 .find(|e| e.name.to_ascii_lowercase() == target_l)
1077 .map(|e| e.id.clone());
1078 if let Some(mid) = matched {
1079 owned_claims.push((mid, Provenance::Declared, 1.0, Vec::new()));
1080 }
1081 }
1082 }
1083 owned_claims.sort_by(|a, b| {
1084 a.0.cmp(&b.0)
1085 .then_with(|| prov_rank(b.1).cmp(&prov_rank(a.1)))
1086 });
1087 let owned_json: Vec<serde_json::Value> = owned_claims
1088 .iter()
1089 .map(|(t, p, c, ev)| {
1090 json!({
1091 "target": t,
1092 "provenance": p.as_str(),
1093 "confidence": c,
1094 "evidence": ev,
1095 })
1096 })
1097 .collect();
1098 e.attr("owns", json!(owned_json));
1099
1100 let deps: Vec<serde_json::Value> = depends
1101 .get(&name)
1102 .map(|v| {
1103 let mut sorted = v.clone();
1104 sorted.sort_by(|a, b| b.3.cmp(&a.3).then_with(|| a.0.cmp(&b.0)));
1105 sorted
1106 .into_iter()
1107 .map(|(t, p, c, n)| {
1108 json!({"target": t, "provenance": p.as_str(), "confidence": c, "call_count": n})
1109 })
1110 .collect()
1111 })
1112 .unwrap_or_default();
1113 e.attr("depends_on", json!(deps));
1114 e.attr(
1115 "retries",
1116 json!(retries_per_comp.get(&name).cloned().unwrap_or_default()),
1117 );
1118
1119 e.evidence = evidence_per_comp.get(&name).cloned().unwrap_or_default();
1120 out.push(e);
1121 }
1122
1123 clear_component_relationships(store)?;
1126 let mut rels: Vec<(Relationship, String)> = Vec::new();
1127
1128 let sym_evidence_in_file = |fid: &str| -> Vec<String> {
1130 let mut ev: Vec<String> = Vec::new();
1131 for r in graph.out_pred(fid, scc_core::predicates::CONTAINS) {
1132 if let Some(e) = graph.entities.get(&r.object) {
1133 ev.extend(e.evidence.clone());
1134 }
1135 }
1136 ev
1137 };
1138 let write_evidence_to = |store_id: &str| -> Vec<String> {
1139 let store_target = if store_id.contains("/data/") {
1141 graph
1142 .entities
1143 .get(store_id)
1144 .and_then(|e| e.attributes.get("store"))
1145 .and_then(|v| v.as_str())
1146 .map(|s| entity_id(repo_id, kinds::DATA_STORE, s))
1147 .unwrap_or_else(|| store_id.to_string())
1148 } else {
1149 store_id.to_string()
1150 };
1151 let mut ev: Vec<String> = Vec::new();
1152 for r in graph.in_pred(&store_target, scc_core::predicates::WRITES) {
1153 ev.extend(r.evidence.clone());
1154 }
1155 ev.sort();
1156 ev.dedup();
1157 ev
1158 };
1159 let call_evidence_between = |from_comp: &str, to_comp: &str| -> Vec<String> {
1160 let mut ev: Vec<String> = Vec::new();
1161 for (sym_id, comp) in &symbol_component {
1162 if comp != from_comp {
1163 continue;
1164 }
1165 for r in graph.out_pred(sym_id, scc_core::predicates::CALLS) {
1166 if let Some(tc) = symbol_component.get(&r.object) {
1167 if tc == to_comp {
1168 ev.extend(r.evidence.clone());
1169 }
1170 }
1171 }
1172 }
1173 ev.sort();
1175 ev.dedup();
1176 ev
1177 };
1178
1179 for e in &out {
1180 if let Some(files) = files_in_component.get(&e.name) {
1181 for fid in files {
1182 rels.push((
1183 Relationship::new(
1184 rel(&["component_contains", &e.id, fid]),
1185 e.id.clone(),
1186 scc_core::predicates::CONTAINS,
1187 fid.clone(),
1188 Provenance::Extracted,
1189 )
1190 .with_evidence(sym_evidence_in_file(fid)),
1191 String::new(),
1192 ));
1193 }
1194 }
1195 if let Some(owned) = e.attributes.get("owns").and_then(|v| v.as_array()) {
1196 for o in owned {
1197 let target = o.get("target").and_then(|v| v.as_str());
1198 let prov = parse_prov(
1199 o.get("provenance")
1200 .and_then(|v| v.as_str())
1201 .unwrap_or("INFERRED"),
1202 );
1203 let conf = o
1204 .get("confidence")
1205 .and_then(|v| v.as_f64())
1206 .unwrap_or_else(|| prov.default_confidence());
1207 let claim_evidence: Vec<String> = o
1208 .get("evidence")
1209 .and_then(|v| v.as_array())
1210 .map(|a| {
1211 a.iter()
1212 .filter_map(|x| x.as_str().map(|s| s.to_string()))
1213 .collect()
1214 })
1215 .unwrap_or_default();
1216 if let Some(os) = target {
1217 let prov_tag = prov.as_str().to_ascii_lowercase();
1222 let mut evidence = claim_evidence;
1223 if evidence.is_empty() {
1224 evidence = write_evidence_to(os);
1225 }
1226 rels.push((
1227 Relationship::new(
1228 rel(&["component_owns", &e.id, os, &prov_tag]),
1229 e.id.clone(),
1230 scc_core::predicates::OWNS,
1231 os.to_string(),
1232 prov,
1233 )
1234 .with_confidence(conf)
1235 .with_evidence(evidence),
1236 String::new(),
1237 ));
1238 }
1239 }
1240 }
1241 if let Some(deps) = e.attributes.get("depends_on").and_then(|v| v.as_array()) {
1242 for d in deps {
1243 if let Some(t) = d.get("target").and_then(|v| v.as_str()) {
1244 let target_id = entity_id(repo_id, kinds::COMPONENT, t);
1245 let prov = parse_prov(
1246 d.get("provenance")
1247 .and_then(|v| v.as_str())
1248 .unwrap_or("INFERRED"),
1249 );
1250 rels.push((
1251 Relationship::new(
1252 rel(&["component_depends", &e.id, &target_id]),
1253 e.id.clone(),
1254 scc_core::predicates::DEPENDS_ON,
1255 target_id,
1256 prov,
1257 )
1258 .with_evidence(call_evidence_between(&e.name, t)),
1259 String::new(),
1260 ));
1261 }
1262 }
1263 }
1264 }
1265 for (r, src) in rels {
1266 store.insert_relationship(&r, &src)?;
1267 }
1268
1269 let state_authority = crate::state::compile_state_authority(graph, &symbol_component);
1273 for c in out.iter_mut() {
1274 let mut mine: Vec<String> = Vec::new();
1275 for section in [
1276 crate::state::S_RUNTIME,
1277 crate::state::S_REACTIVE,
1278 crate::state::S_CONFIGURATION,
1279 crate::state::S_CACHES,
1280 crate::state::S_DERIVED,
1281 ] {
1282 if let Some(lines) = state_authority.get(section) {
1283 let prefix = format!("{} ", c.name);
1284 for l in lines {
1285 if l.starts_with(&prefix) {
1286 mine.push(l.clone());
1287 }
1288 }
1289 }
1290 }
1291 c.attr("state_authority", json!(mine));
1292 }
1293 crate::clustering::compile_services(
1294 store,
1295 &out,
1296 &clustering.component_weights,
1297 &clustering.cross_unit,
1298 &parent_per_comp,
1299 )?;
1300
1301 Ok(out)
1302}
1303
1304fn clear_component_relationships(store: &Store) -> Result<()> {
1305 let rows = store.all_relationships()?;
1306 let ids: Vec<String> = rows
1307 .into_iter()
1308 .filter(|r| r.id.starts_with(RELPREFIX))
1309 .map(|r| r.id)
1310 .collect();
1311 for id in ids {
1312 store.delete_relationship(&id)?;
1313 }
1314 Ok(())
1315}
1316
1317#[cfg(test)]
1318mod tests {
1319 use super::*;
1320
1321 #[test]
1322 fn component_role_scopes_non_production_trees() {
1324 let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<Vec<_>>();
1325 assert_eq!(component_role(&v(&["src/api", "src/db"])), "production");
1326 assert_eq!(component_role(&v(&["fixtures/http-service-python"])), "fixture");
1327 assert_eq!(component_role(&v(&["tests", "spec/e2e"])), "test");
1328 assert_eq!(component_role(&v(&["benchmarks/external"])), "benchmark");
1329 assert_eq!(component_role(&v(&["src/api", "tests/api"])), "mixed");
1330 assert_eq!(component_role(&v(&["fixtures/a", "benchmarks/b"])), "mixed");
1331 assert_eq!(component_role(&[]), "production");
1332 }
1333
1334 #[test]
1335 fn path_role_ignores_namespace_example_segments() {
1337 assert_eq!(
1340 path_role("java_service/src/main/java/com/example/greet/GreetingImpl.java"),
1341 None
1342 );
1343 assert_eq!(path_role("examples/foo/main.py"), Some("example"));
1345 assert_eq!(path_role("mycrate/examples/foo.rs"), Some("example"));
1346 assert_eq!(path_role("fixtures/foo/app.py"), Some("fixture"));
1347 assert_eq!(path_role("src/test/foo.py"), Some("test"));
1348 }
1349
1350 #[test]
1351 fn intent_ownership_stays_declared() {
1352 let dir = tempfile::TempDir::new().unwrap();
1355 let root = dir.path().join("repo");
1356 std::fs::create_dir_all(&root).unwrap();
1357 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1358
1359 let repo = store.repo_id.clone();
1361 let store_ent = scc_core::entity_id(&repo, kinds::DATA_STORE, "db");
1362 store
1363 .insert_entity(
1364 &scc_core::Entity::new(store_ent.clone(), kinds::DATA_STORE, "db"),
1365 &["main.py".into()],
1366 )
1367 .unwrap();
1368 let file = scc_core::entity_id(&repo, kinds::FILE, "main.py");
1369 store
1370 .insert_entity(
1371 &scc_core::Entity::new(file.clone(), kinds::FILE, "main.py"),
1372 &["main.py".into()],
1373 )
1374 .unwrap();
1375 let sym = scc_core::symbol_id(&repo, "main.py", "save");
1376 store
1377 .insert_entity(
1378 &scc_core::Entity::new(sym.clone(), kinds::SYMBOL, "save"),
1379 &["main.py".into()],
1380 )
1381 .unwrap();
1382 store
1383 .insert_relationship(
1384 &Relationship::new(
1385 "rel:contains",
1386 file,
1387 scc_core::predicates::CONTAINS,
1388 sym.clone(),
1389 Provenance::Extracted,
1390 ),
1391 "main.py",
1392 )
1393 .unwrap();
1394 store
1395 .insert_relationship(
1396 &Relationship::new(
1397 "rel:w",
1398 sym.clone(),
1399 scc_core::predicates::WRITES,
1400 store_ent.clone(),
1401 Provenance::Extracted,
1402 )
1403 .with_confidence(1.0),
1404 "main.py",
1405 )
1406 .unwrap();
1407
1408 let intent = vec![(
1410 "component".to_string(),
1411 serde_json::json!({"name": "root", "owns": ["db"]}),
1412 )];
1413 let graph = RealityGraph::load(&store).unwrap();
1414 let comps = compile_components(&graph, &store, &intent, &[]).unwrap();
1415 let root_comp = comps.iter().find(|c| c.name == "root").unwrap();
1416
1417 let claims = root_comp.attributes.get("owns").unwrap().as_array().unwrap();
1419 assert_eq!(claims.len(), 2, "{claims:?}");
1420 let declared = claims
1421 .iter()
1422 .find(|c| c.get("provenance").and_then(|v| v.as_str()) == Some("DECLARED"))
1423 .expect("intent claim present");
1424 assert_eq!(declared["target"].as_str().unwrap(), store_ent);
1425 let extracted = claims
1426 .iter()
1427 .find(|c| c.get("provenance").and_then(|v| v.as_str()) == Some("EXTRACTED"))
1428 .expect("write-edge claim present");
1429 assert_eq!(extracted["target"].as_str().unwrap(), store_ent);
1430
1431 let rels = store.all_relationships().unwrap();
1433 let owns: Vec<_> = rels
1434 .iter()
1435 .filter(|r| r.predicate == scc_core::predicates::OWNS)
1436 .collect();
1437 assert_eq!(owns.len(), 2, "{rels:?}");
1438 assert!(
1439 owns.iter().any(|r| r.provenance == Provenance::Declared),
1440 "declared ownership relationship must exist: {owns:?}"
1441 );
1442 assert!(
1443 !owns.iter().any(|r| r.provenance == Provenance::Resolved),
1444 "no provenance promotion allowed: {owns:?}"
1445 );
1446 }
1447
1448 #[test]
1449fn path_assignment() {
1451 let cands = vec![
1452 ComponentCandidate::new("web", vec!["src/web".into()], BOUNDARY_PACKAGE),
1453 ComponentCandidate::new("api", vec!["src/api".into()], BOUNDARY_DECLARED),
1454 ];
1455 assert_eq!(component_for_path("src/api/routes.py", &cands), "api");
1456 assert_eq!(component_for_path("src/web/app.ts", &cands), "web");
1457 assert_eq!(component_for_path("src/shared/util.py", &cands), "src");
1458 assert_eq!(component_for_path("README.md", &cands), "root");
1459 }
1460
1461 fn insert_file_with_symbols(
1464 store: &Store,
1465 path: &str,
1466 symbols: &[&str],
1467 ) -> (String, Vec<String>) {
1468 let repo = store.repo_id.clone();
1469 let file_id = scc_core::entity_id(&repo, kinds::FILE, path);
1470 store
1471 .insert_entity(
1472 &scc_core::Entity::new(file_id.clone(), kinds::FILE, path),
1473 &[path.into()],
1474 )
1475 .unwrap();
1476 let mut sym_ids = Vec::new();
1477 for s in symbols {
1478 let sid = scc_core::symbol_id(&repo, path, s);
1479 store
1480 .insert_entity(
1481 &scc_core::Entity::new(sid.clone(), kinds::SYMBOL, *s),
1482 &[path.into()],
1483 )
1484 .unwrap();
1485 store
1486 .insert_relationship(
1487 &Relationship::new(
1488 format!("rel:contains:{}:{s}", path.replace('/', "_")),
1489 file_id.clone(),
1490 scc_core::predicates::CONTAINS,
1491 sid.clone(),
1492 Provenance::Extracted,
1493 ),
1494 path,
1495 )
1496 .unwrap();
1497 sym_ids.push(sid);
1498 }
1499 (file_id, sym_ids)
1500 }
1501
1502 #[test]
1503 fn boundary_kind_classification() {
1504 let dir = tempfile::TempDir::new().unwrap();
1509 let root = dir.path().join("repo");
1510 std::fs::create_dir_all(&root).unwrap();
1511 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1512 let repo = store.repo_id.clone();
1513
1514 for f in [
1515 "web/app.py",
1516 "packages/a/util.py",
1517 "services/api/main.py",
1518 "misc/util.py",
1519 "README.md",
1520 ] {
1521 store
1522 .insert_entity(
1523 &scc_core::Entity::new(
1524 scc_core::entity_id(&repo, kinds::FILE, f),
1525 kinds::FILE,
1526 f,
1527 ),
1528 &[f.into()],
1529 )
1530 .unwrap();
1531 }
1532 let mut pkg = scc_core::Entity::new(
1534 scc_core::entity_id(&repo, kinds::PACKAGE, "pkg_a"),
1535 kinds::PACKAGE,
1536 "pkg_a",
1537 );
1538 pkg.attr("path", serde_json::json!("packages/a"));
1539 store
1540 .insert_entity(&pkg, &["packages/a/util.py".into()])
1541 .unwrap();
1542 let mut du = scc_core::Entity::new(
1544 scc_core::entity_id(&repo, kinds::DEPLOYMENT_UNIT, "api"),
1545 kinds::DEPLOYMENT_UNIT,
1546 "api",
1547 );
1548 du.attr("build_context", serde_json::json!("services/api"));
1549 store
1550 .insert_entity(&du, &["services/api/main.py".into()])
1551 .unwrap();
1552
1553 let intent = vec![(
1554 "component".to_string(),
1555 serde_json::json!({"name": "web", "paths": ["web"]}),
1556 )];
1557 let graph = RealityGraph::load(&store).unwrap();
1558 let comps = compile_components(&graph, &store, &intent, &[]).unwrap();
1559 let by_name: std::collections::BTreeMap<&str, &scc_core::Entity> =
1560 comps.iter().map(|c| (c.name.as_str(), c)).collect();
1561 let kind_of = |n: &str| by_name[n].attributes["boundary_kind"].as_str().unwrap();
1562 assert_eq!(kind_of("web"), BOUNDARY_DECLARED);
1563 assert_eq!(kind_of("pkg_a"), BOUNDARY_PACKAGE);
1564 assert_eq!(kind_of("api"), BOUNDARY_DEPLOYMENT);
1565 assert_eq!(kind_of("misc"), BOUNDARY_CODE_REGION);
1566 assert_eq!(kind_of("root"), BOUNDARY_ROOT);
1567 assert!(
1572 !by_name.contains_key("services"),
1573 "empty dir shells are pruned by the clusterer: {comps:?}"
1574 );
1575 assert_eq!(by_name["api"].kind, kinds::COMPONENT);
1577 assert!(by_name.contains_key("api"), "candidate name unchanged");
1578 assert_eq!(by_name["api"].attributes["parent"], serde_json::json!("api"));
1580 assert!(
1581 !by_name["web"].attributes.contains_key("parent"),
1582 "no parent outside a deployment unit"
1583 );
1584 for c in &comps {
1586 assert!(c.attributes.contains_key("boundary_kind"), "{}", c.name);
1587 assert!(c.attributes.contains_key("clustering_score"), "{}", c.name);
1588 }
1589 }
1590
1591 #[test]
1592 fn cli_package_dirs_become_cli_boundary_components() {
1593 let dir = tempfile::TempDir::new().unwrap();
1599 let root = dir.path().join("repo");
1600 std::fs::create_dir_all(&root).unwrap();
1601 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1602 let _repo = store.repo_id.clone();
1603
1604 let (helm_file, helm_syms) =
1607 insert_file_with_symbols(&store, "cmd/helm/helm.go", &["rootCmd", "serveCmd"]);
1608 let mut flag_sym = store.get_entity(&helm_syms[0]).unwrap().unwrap();
1609 flag_sym
1610 .attributes
1611 .insert("file".into(), serde_json::json!("cmd/helm/helm.go"));
1612 flag_sym
1613 .attributes
1614 .insert("cli_flags".into(), serde_json::json!(["--port", "--env"]));
1615 store.insert_entity(&flag_sym, &["cmd/helm/helm.go".into()]).unwrap();
1616 let mut ep_sym = store.get_entity(&helm_syms[1]).unwrap().unwrap();
1617 ep_sym
1618 .attributes
1619 .insert("file".into(), serde_json::json!("cmd/helm/helm.go"));
1620 ep_sym.attributes
1621 .insert("entrypoints".into(), serde_json::json!(["cli-subcommand"]));
1622 store.insert_entity(&ep_sym, &["cmd/helm/helm.go".into()]).unwrap();
1623
1624 let (pkg_file, _) = insert_file_with_symbols(&store, "pkg/util.go", &["Util"]);
1626 let _ = (helm_file, pkg_file);
1627
1628 let graph = RealityGraph::load(&store).unwrap();
1629 let comps = compile_components(&graph, &store, &[], &[]).unwrap();
1630 let by_name: std::collections::BTreeMap<&str, &scc_core::Entity> =
1631 comps.iter().map(|c| (c.name.as_str(), c)).collect();
1632
1633 let cli = by_name["cmd/helm"];
1634 assert_eq!(cli.attributes["boundary_kind"], serde_json::json!("cli"));
1635 assert_eq!(
1636 cli.attributes["layer"],
1637 serde_json::json!("component"),
1638 "cli components are authoritative components, not code regions"
1639 );
1640 let impl_paths = cli.attributes["implementation"]["paths"]
1642 .as_array()
1643 .map(|a| {
1644 a.iter()
1645 .filter_map(|v| v.as_str().map(|s| s.to_string()))
1646 .collect::<Vec<_>>()
1647 })
1648 .unwrap_or_default();
1649 assert_eq!(impl_paths, vec!["cmd/helm"]);
1650 assert_eq!(
1652 by_name["pkg"].attributes["boundary_kind"],
1653 serde_json::json!("code-region")
1654 );
1655 assert_eq!(by_name["root"].attributes["boundary_kind"], serde_json::json!("root"));
1656 }
1657
1658 #[test]
1659 fn cli_evidence_in_root_dir_promotes_root_boundary() {
1660 let dir = tempfile::TempDir::new().unwrap();
1664 let root = dir.path().join("repo");
1665 std::fs::create_dir_all(&root).unwrap();
1666 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1667 let _repo = store.repo_id.clone();
1668 let (_, syms) = insert_file_with_symbols(&store, "cli.py", &["main"]);
1669 let mut sym = store.get_entity(&syms[0]).unwrap().unwrap();
1670 sym.attributes.insert("file".into(), serde_json::json!("cli.py"));
1671 sym.attributes
1672 .insert("cli_flags".into(), serde_json::json!(["--verbose"]));
1673 store.insert_entity(&sym, &["cli.py".into()]).unwrap();
1674
1675 let graph = RealityGraph::load(&store).unwrap();
1676 let comps = compile_components(&graph, &store, &[], &[]).unwrap();
1677 let by_name: std::collections::BTreeMap<&str, &scc_core::Entity> =
1678 comps.iter().map(|c| (c.name.as_str(), c)).collect();
1679 assert_eq!(
1680 by_name["root"].attributes["boundary_kind"],
1681 serde_json::json!("cli")
1682 );
1683 }
1684
1685 #[test]
1686 fn hierarchy_clusterer_builds_layer_stack() {
1687 let dir = tempfile::TempDir::new().unwrap();
1693 let root = dir.path().join("repo");
1694 std::fs::create_dir_all(&root).unwrap();
1695 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1696 let repo = store.repo_id.clone();
1697
1698 let mut intent: Vec<(String, serde_json::Value)> = Vec::new();
1699 for name in ["a", "b", "c", "d", "e"] {
1700 intent.push((
1701 "component".to_string(),
1702 serde_json::json!({"name": name, "paths": [name]}),
1703 ));
1704 }
1705
1706 let (_fa, sa) = insert_file_with_symbols(&store, "a/x.py", &["a_main"]);
1708 let (fb, sb) = insert_file_with_symbols(&store, "b/x.py", &["b_worker"]);
1709 let (fc, sc) = insert_file_with_symbols(&store, "c/x.py", &["c_main"]);
1710 let (_fd, sd) = insert_file_with_symbols(&store, "d/x.py", &["d_worker"]);
1711 let (_fe, _se) = insert_file_with_symbols(&store, "e/x.py", &["e_standalone"]);
1712 let _ = (sa[0].clone(), sb[0].clone(), sc[0].clone(), sd[0].clone());
1713
1714 for (i, (syms, store_name)) in [
1716 (sa.clone(), "db"),
1717 (sb.clone(), "db"),
1718 (sc.clone(), "db2"),
1719 (sd.clone(), "db2"),
1720 ]
1721 .into_iter()
1722 .enumerate()
1723 {
1724 let store_ent = scc_core::entity_id(&repo, kinds::DATA_STORE, store_name);
1725 store
1726 .insert_entity(
1727 &scc_core::Entity::new(store_ent.clone(), kinds::DATA_STORE, store_name),
1728 &["x.py".into()],
1729 )
1730 .unwrap();
1731 store
1732 .insert_relationship(
1733 &Relationship::new(
1734 format!("rel:w:{store_name}:{i}"),
1735 syms[0].clone(),
1736 scc_core::predicates::WRITES,
1737 store_ent,
1738 Provenance::Extracted,
1739 ),
1740 "x.py",
1741 )
1742 .unwrap();
1743 }
1744 store
1747 .insert_relationship(
1748 &Relationship::new(
1749 "rel:call_ab",
1750 sa[0].clone(),
1751 scc_core::predicates::CALLS,
1752 sb[0].clone(),
1753 Provenance::Extracted,
1754 ),
1755 "a/x.py",
1756 )
1757 .unwrap();
1758 store
1759 .insert_relationship(
1760 &Relationship::new(
1761 "rel:call_cd",
1762 sc[0].clone(),
1763 scc_core::predicates::CALLS,
1764 sd[0].clone(),
1765 Provenance::Extracted,
1766 ),
1767 "c/x.py",
1768 )
1769 .unwrap();
1770 for (i, sym) in [sa[0].clone(), sd[0].clone()].iter().enumerate() {
1772 let route = scc_core::entity_id(&repo, kinds::ROUTE, &format!("get-/r{i}"));
1773 store
1774 .insert_entity(
1775 scc_core::Entity::new(route.clone(), kinds::ROUTE, format!("get-/r{i}"))
1776 .attr("method", serde_json::json!("GET"))
1777 .attr("path", serde_json::json!(format!("/r{i}"))),
1778 &["x.py".into()],
1779 )
1780 .unwrap();
1781 store
1782 .insert_relationship(
1783 &Relationship::new(
1784 format!("rel:h{i}"),
1785 sym.clone(),
1786 scc_core::predicates::HANDLES,
1787 route,
1788 Provenance::Extracted,
1789 ),
1790 "x.py",
1791 )
1792 .unwrap();
1793 }
1794 for (i, sym) in [sb[0].clone(), sd[0].clone()].iter().enumerate() {
1796 let mut e = store.get_entity(sym).unwrap().unwrap();
1797 e.attributes
1798 .insert("entrypoints".into(), serde_json::json!(["cli-subcommand"]));
1799 store.insert_entity(&e, &["x.py".into()]).unwrap();
1800 let _ = i;
1801 }
1802 for (i, sym) in [sc[0].clone(), sd[0].clone()].iter().enumerate() {
1804 let topic = scc_core::entity_id(&repo, kinds::TOPIC, &format!("topic{i}"));
1805 store
1806 .insert_entity(
1807 &scc_core::Entity::new(topic.clone(), kinds::TOPIC, format!("topic{i}")),
1808 &["x.py".into()],
1809 )
1810 .unwrap();
1811 store
1812 .insert_relationship(
1813 &Relationship::new(
1814 format!("rel:p{i}"),
1815 sym.clone(),
1816 scc_core::predicates::PUBLISHES,
1817 topic,
1818 Provenance::Extracted,
1819 ),
1820 "x.py",
1821 )
1822 .unwrap();
1823 }
1824 let cfg = scc_core::entity_id(&repo, kinds::CONFIGURATION, "MODE");
1826 store
1827 .insert_entity(
1828 &scc_core::Entity::new(cfg.clone(), kinds::CONFIGURATION, "MODE"),
1829 &["x.py".into()],
1830 )
1831 .unwrap();
1832 for sym in [sa[0].clone(), sc[0].clone()] {
1833 store
1834 .insert_relationship(
1835 &Relationship::new(
1836 format!("rel:cfg:{}", sym),
1837 cfg.clone(),
1838 scc_core::predicates::CONFIGURED_BY,
1839 sym,
1840 Provenance::Extracted,
1841 ),
1842 "x.py",
1843 )
1844 .unwrap();
1845 }
1846 store
1848 .insert_relationship(
1849 &Relationship::new(
1850 "rel:imp_bc",
1851 fb.clone(),
1852 scc_core::predicates::IMPORTS,
1853 fc.clone(),
1854 Provenance::Extracted,
1855 ),
1856 "b/x.py",
1857 )
1858 .unwrap();
1859
1860 let graph = RealityGraph::load(&store).unwrap();
1861 let comps = compile_components(&graph, &store, &intent, &[]).unwrap();
1862
1863 let names: std::collections::BTreeSet<&str> =
1878 comps.iter().map(|c| c.name.as_str()).collect();
1879 for n in ["a+b", "c+d", "e", "root"] {
1880 assert!(names.contains(n), "clustering result keeps {n}: {names:?}");
1881 }
1882 assert!(!names.contains("a"), "merged pair has no a shell: {names:?}");
1883 assert!(!names.contains("b"), "merged pair has no b shell: {names:?}");
1884 assert!(!names.contains("c"), "merged pair has no c shell: {names:?}");
1885 assert!(!names.contains("d"), "merged pair has no d shell: {names:?}");
1886 let by_name: std::collections::BTreeMap<&str, &scc_core::Entity> =
1887 comps.iter().map(|c| (c.name.as_str(), c)).collect();
1888
1889 let services = store.entities_by_kind(kinds::SERVICE).unwrap();
1891 assert_eq!(services.len(), 0, "no service at sum 6: {services:?}");
1892 let subsystems = store.entities_by_kind(kinds::SUBSYSTEM).unwrap();
1893 assert_eq!(subsystems.len(), 0, "no subsystem containers anymore: {subsystems:?}");
1894
1895 assert_eq!(by_name["a+b"].attributes["layer"], serde_json::json!("component"));
1898 assert_eq!(by_name["c+d"].attributes["layer"], serde_json::json!("component"));
1899 assert_eq!(by_name["e"].attributes["layer"], serde_json::json!("component"));
1900 assert_eq!(by_name["root"].attributes["layer"], serde_json::json!("code_region"));
1901 let ab_paths = by_name["a+b"].attributes["implementation"]["paths"]
1904 .as_array()
1905 .unwrap();
1906 assert_eq!(
1907 ab_paths,
1908 &vec![serde_json::json!("a"), serde_json::json!("b")],
1909 "merged component paths: {ab_paths:?}"
1910 );
1911
1912 let graph2 = RealityGraph::load(&store).unwrap();
1914 let comps2 = compile_components(&graph2, &store, &intent, &[]).unwrap();
1915 assert_eq!(comps2.len(), comps.len());
1916 let names2: std::collections::BTreeSet<&str> =
1917 comps2.iter().map(|c| c.name.as_str()).collect();
1918 assert_eq!(names2, names, "cluster names stable across recompiles");
1919 for (a, b) in comps.iter().zip(comps2.iter()) {
1920 assert_eq!(a.attributes.get("layer"), b.attributes.get("layer"), "{}", a.name);
1921 assert_eq!(a.attributes.get("parent"), b.attributes.get("parent"), "{}", a.name);
1922 }
1923 let services2 = store.entities_by_kind(kinds::SERVICE).unwrap();
1924 assert_eq!(services2.len(), 0, "no container accumulation");
1925 }
1926
1927 #[test]
1928 fn clustering_score_deterministic_and_ranked() {
1929 let dir = tempfile::TempDir::new().unwrap();
1933 let root = dir.path().join("repo");
1934 std::fs::create_dir_all(&root).unwrap();
1935 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1936 let repo = store.repo_id.clone();
1937
1938 let (_f1, api_syms) =
1939 insert_file_with_symbols(&store, "api/routes.py", &["handle_a", "handle_b"]);
1940 let (_f2, api_helpers) = insert_file_with_symbols(&store, "api/helpers.py", &["helper"]);
1941 let (_f3, _web_syms) = insert_file_with_symbols(&store, "web/app.py", &["web_index"]);
1942
1943 let store_ent = scc_core::entity_id(&repo, kinds::DATA_STORE, "db");
1944 store
1945 .insert_entity(
1946 &scc_core::Entity::new(store_ent.clone(), kinds::DATA_STORE, "db"),
1947 &["api/routes.py".into()],
1948 )
1949 .unwrap();
1950 let routes_file = scc_core::entity_id(&repo, kinds::FILE, "api/routes.py");
1951 for (i, sym) in ["handle_a", "handle_b"].iter().enumerate() {
1952 let route = scc_core::entity_id(&repo, kinds::ROUTE, &format!("GET /api/{i}"));
1953 store
1954 .insert_entity(
1955 scc_core::Entity::new(route.clone(), kinds::ROUTE, format!("GET /api/{i}"))
1956 .attr("method", serde_json::json!("GET"))
1957 .attr("path", serde_json::json!(format!("/api/{i}"))),
1958 &["api/routes.py".into()],
1959 )
1960 .unwrap();
1961 store
1963 .insert_relationship(
1964 &Relationship::new(
1965 format!("rel:route_contains_{i}"),
1966 routes_file.clone(),
1967 scc_core::predicates::CONTAINS,
1968 route.clone(),
1969 Provenance::Extracted,
1970 ),
1971 "api/routes.py",
1972 )
1973 .unwrap();
1974 let sym_id = scc_core::symbol_id(&repo, "api/routes.py", sym);
1975 store
1977 .insert_relationship(
1978 &Relationship::new(
1979 format!("rel:handles_{i}"),
1980 sym_id.clone(),
1981 scc_core::predicates::HANDLES,
1982 route.clone(),
1983 Provenance::Extracted,
1984 ),
1985 "api/routes.py",
1986 )
1987 .unwrap();
1988 store
1990 .insert_relationship(
1991 &Relationship::new(
1992 format!("rel:writes_{i}"),
1993 sym_id,
1994 scc_core::predicates::WRITES,
1995 store_ent.clone(),
1996 Provenance::Extracted,
1997 ),
1998 "api/routes.py",
1999 )
2000 .unwrap();
2001 }
2002 store
2004 .insert_relationship(
2005 &Relationship::new(
2006 "rel:call_internal",
2007 api_syms[0].clone(),
2008 scc_core::predicates::CALLS,
2009 api_helpers[0].clone(),
2010 Provenance::Extracted,
2011 ),
2012 "api/routes.py",
2013 )
2014 .unwrap();
2015
2016 let intent = vec![(
2017 "component".to_string(),
2018 serde_json::json!({"name": "api", "paths": ["api"]}),
2019 )];
2020
2021 let graph = RealityGraph::load(&store).unwrap();
2022 let comps = compile_components(&graph, &store, &intent, &[]).unwrap();
2023 let graph2 = RealityGraph::load(&store).unwrap();
2024 let comps2 = compile_components(&graph2, &store, &intent, &[]).unwrap();
2025 let score = |c: &scc_core::Entity| c.attributes["clustering_score"].as_f64().unwrap();
2026 for (a, b) in comps.iter().zip(comps2.iter()) {
2027 assert_eq!(
2028 a.attributes["clustering_score"],
2029 b.attributes["clustering_score"],
2030 "scores must be deterministic for {}",
2031 a.name
2032 );
2033 }
2034 let api = comps.iter().find(|c| c.name == "api").unwrap();
2035 let web = comps.iter().find(|c| c.name == "web").unwrap();
2036 assert_eq!(score(api), 14.0, "{:?}", api.attributes);
2037 assert_eq!(score(web), 1.0, "bare directory: +1 only");
2038 assert!(score(api) > score(web), "evidence-rich candidate outranks a bare dir");
2039 assert_eq!(api.attributes["boundary_kind"], serde_json::json!("declared"));
2040 assert_eq!(web.attributes["boundary_kind"], serde_json::json!("code-region"));
2041 }
2042
2043 #[test]
2044 fn manifest_parsing_is_deterministic() {
2045 let ws = "[workspace]\nmembers = [\n \"crates/a\",\n \"crates/b\",\n]\nexclude = [\"crates/a\"]\n";
2047 assert_eq!(
2048 toml_string_array(ws, "members"),
2049 vec!["crates/a", "crates/b"]
2050 );
2051 assert_eq!(toml_string_array(ws, "exclude"), vec!["crates/a"]);
2052 let trick = "[workspace]\nexclude_members = [\"x\"]\nmembers = [\"crates/a\"]\n";
2054 assert_eq!(toml_string_array(trick, "members"), vec!["crates/a"]);
2055 let pkg = "[package]\nname = \"grep-cli\"\nversion = \"0.1.0\"\n";
2057 assert_eq!(toml_package_name(pkg).as_deref(), Some("grep-cli"));
2058 let deps = "[dependencies]\nname = \"x\"\n";
2060 assert_eq!(toml_package_name(deps), None);
2061 let gow = "go 1.22.0\n\nuse (\n\t./cmd/app\n\t./internal/lib\n\t// a comment\n)\n\nuse ./third\n";
2063 assert_eq!(
2064 gowork_use_dirs(gow),
2065 vec!["cmd/app", "internal/lib", "third"]
2066 );
2067 assert_eq!(gowork_use_dirs("user = \"x\"\nuse ./only\n"), vec!["only"]);
2069 assert!(gowork_use_dirs("go 1.22\n\nmodule = \"nouse\"\n").is_empty());
2070 }
2071
2072 #[test]
2073 fn cargo_workspace_members_compile_to_package_components() {
2075 let dir = tempfile::TempDir::new().unwrap();
2084 let root = dir.path().join("repo");
2085 std::fs::create_dir_all(&root).unwrap();
2086 std::fs::create_dir_all(root.join("crates/alpha/src")).unwrap();
2087 std::fs::create_dir_all(root.join("crates/beta/src")).unwrap();
2088 std::fs::write(
2089 root.join("Cargo.toml"),
2090 "[workspace]\nmembers = [\"crates/*\"]\n\n[package]\nname = \"top\"\n",
2091 )
2092 .unwrap();
2093 std::fs::write(
2094 root.join("crates/alpha/Cargo.toml"),
2095 "[package]\nname = \"alpha\"\n",
2096 )
2097 .unwrap();
2098 std::fs::write(
2099 root.join("crates/beta/Cargo.toml"),
2100 "[package]\nname = \"beta\"\n",
2101 )
2102 .unwrap();
2103
2104 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2105 let (fa, sa) =
2106 insert_file_with_symbols(&store, "crates/alpha/src/lib.rs", &["alpha_run"]);
2107 let (fb, sb) =
2108 insert_file_with_symbols(&store, "crates/beta/src/lib.rs", &["beta_run"]);
2109 let (_fr, _sr) = insert_file_with_symbols(&store, "README.md", &["readme"]);
2110 let _ = (&fa, &fb, &sa, &sb);
2111
2112 let graph = RealityGraph::load(&store).unwrap();
2113 let comps = compile_components(&graph, &store, &[], &[]).unwrap();
2114 let by_name: std::collections::BTreeMap<&str, &scc_core::Entity> =
2115 comps.iter().map(|c| (c.name.as_str(), c)).collect();
2116
2117 assert!(
2118 by_name.contains_key("crates/alpha/src"),
2119 "per-crate component: {comps:?}"
2120 );
2121 assert!(
2122 by_name.contains_key("crates/beta/src"),
2123 "per-crate component: {comps:?}"
2124 );
2125 assert_eq!(
2126 by_name["crates/alpha/src"].attributes["boundary_kind"].as_str(),
2127 Some(BOUNDARY_PACKAGE)
2128 );
2129 assert_eq!(
2130 by_name["crates/beta/src"].attributes["boundary_kind"].as_str(),
2131 Some(BOUNDARY_PACKAGE)
2132 );
2133 assert_eq!(
2134 by_name["crates/alpha/src"].attributes["implementation"]["paths"],
2135 json!(["crates/alpha/src"])
2136 );
2137 assert_eq!(
2138 by_name["crates/beta/src"].attributes["implementation"]["paths"],
2139 json!(["crates/beta/src"])
2140 );
2141 let alpha_syms = by_name["crates/alpha/src"].attributes["implementation"]["symbols"]
2143 .as_array()
2144 .unwrap();
2145 assert!(alpha_syms.iter().any(|s| s == "alpha_run"));
2146 let beta_syms = by_name["crates/beta/src"].attributes["implementation"]["symbols"]
2147 .as_array()
2148 .unwrap();
2149 assert!(beta_syms.iter().any(|s| s == "beta_run"));
2150 if let Some(crates) = by_name.get("crates") {
2153 let syms = crates.attributes["implementation"]["symbols"]
2154 .as_array()
2155 .unwrap();
2156 assert!(syms.is_empty(), "'crates' must not swallow members: {syms:?}");
2157 }
2158 let cands = vec![
2160 ComponentCandidate::new("crates/alpha/src", vec!["crates/alpha/src".into()], BOUNDARY_PACKAGE),
2161 ComponentCandidate::new("crates/beta/src", vec!["crates/beta/src".into()], BOUNDARY_PACKAGE),
2162 ComponentCandidate::new("crates", vec!["crates".into()], BOUNDARY_CODE_REGION),
2163 ];
2164 assert_eq!(component_for_path("crates/alpha/src/lib.rs", &cands), "crates/alpha/src");
2165 assert_eq!(component_for_path("crates/beta/src/lib.rs", &cands), "crates/beta/src");
2166 }
2167
2168 #[test]
2169 fn gowork_modules_compile_to_package_components() {
2170 let dir = tempfile::TempDir::new().unwrap();
2174 let root = dir.path().join("repo");
2175 std::fs::create_dir_all(&root).unwrap();
2176 for d in ["cmd/app", "internal/lib", "third"] {
2177 std::fs::create_dir_all(root.join(d)).unwrap();
2178 }
2179 std::fs::write(
2180 root.join("go.work"),
2181 "go 1.22.0\n\nuse (\n\t./cmd/app\n\t./internal/lib\n)\n\nuse ./third\n",
2182 )
2183 .unwrap();
2184
2185 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2186 let (_fa, sa) = insert_file_with_symbols(&store, "cmd/app/main.go", &["app_main"]);
2187 let (_fb, sb) = insert_file_with_symbols(&store, "internal/lib/lib.go", &["lib_fn"]);
2188 let (_fc, sc) = insert_file_with_symbols(&store, "third/x.go", &["third_fn"]);
2189 let _ = (&sa, &sb, &sc);
2190
2191 let graph = RealityGraph::load(&store).unwrap();
2192 let comps = compile_components(&graph, &store, &[], &[]).unwrap();
2193 let by_name: std::collections::BTreeMap<&str, &scc_core::Entity> =
2194 comps.iter().map(|c| (c.name.as_str(), c)).collect();
2195
2196 for n in ["app", "lib", "third"] {
2197 assert!(by_name.contains_key(n), "module component missing: {comps:?}");
2198 assert_eq!(
2199 by_name[n].attributes["boundary_kind"].as_str(),
2200 Some(BOUNDARY_PACKAGE),
2201 "{n}"
2202 );
2203 }
2204 assert_eq!(
2205 by_name["app"].attributes["implementation"]["paths"],
2206 json!(["cmd/app"])
2207 );
2208 assert_eq!(
2209 by_name["lib"].attributes["implementation"]["paths"],
2210 json!(["internal/lib"])
2211 );
2212 let app_syms = by_name["app"].attributes["implementation"]["symbols"]
2213 .as_array()
2214 .unwrap();
2215 assert!(app_syms.iter().any(|s| s == "app_main"));
2216 let lib_syms = by_name["lib"].attributes["implementation"]["symbols"]
2217 .as_array()
2218 .unwrap();
2219 assert!(lib_syms.iter().any(|s| s == "lib_fn"));
2220 if let Some(cmd) = by_name.get("cmd") {
2222 let syms = cmd.attributes["implementation"]["symbols"]
2223 .as_array()
2224 .unwrap();
2225 assert!(syms.is_empty(), "'cmd' must not swallow modules: {syms:?}");
2226 }
2227 }
2228}