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