1use crate::{RealityGraph, Result};
23use scc_core::kinds;
24use scc_core::{Entity, Provenance, Relationship};
25use scc_store::Store;
26
27pub const RELPREFIX: &str = "rel:boundary:";
28
29fn rel_id(parts: &[&str]) -> String {
30 let mut h = blake3::Hasher::new();
31 for p in parts {
32 h.update(p.as_bytes());
33 h.update(b"|");
34 }
35 format!("{RELPREFIX}{}", &h.finalize().to_hex()[..12])
36}
37
38fn unit_dirs(graph: &RealityGraph) -> Vec<(String, String)> {
42 let mut out: Vec<(String, String)> = Vec::new();
43 for e in graph.entities_of_kind(kinds::DEPLOYMENT_UNIT) {
44 let dir = if let Some(ctx) = e.attributes.get("build_context").and_then(|v| v.as_str()) {
45 let ctx = ctx.trim().trim_start_matches("./");
46 if ctx.is_empty() || ctx == "." {
47 continue;
48 }
49 ctx.to_string()
50 } else if e.attributes.contains_key("image") {
51 e.name.clone()
52 } else {
53 continue;
54 };
55 out.push((e.name.clone(), dir));
56 }
57 out
58}
59
60fn unit_for_component(comp: &Entity, units: &[(String, String)]) -> String {
63 let mut best: Option<(String, usize)> = None;
64 if let Some(paths) = comp
65 .attributes
66 .get("implementation")
67 .and_then(|v| v.get("paths"))
68 .and_then(|v| v.as_array())
69 {
70 for p in paths {
71 if let Some(p) = p.as_str() {
72 for (name, dir) in units {
73 if (p == dir || p.starts_with(&format!("{dir}/")))
74 && best.as_ref().map(|(_, l)| dir.len() > *l).unwrap_or(true)
75 {
76 best = Some((name.clone(), dir.len()));
77 }
78 }
79 }
80 }
81 }
82 best.map(|(n, _)| n).unwrap_or_else(|| "local".to_string())
83}
84
85pub fn compile_boundaries(graph: &RealityGraph, store: &Store) -> Result<Vec<(Relationship, String)>> {
91 let stale: Vec<String> = store
93 .all_relationships()?
94 .into_iter()
95 .filter(|r| r.id.starts_with(RELPREFIX))
96 .map(|r| r.id)
97 .collect();
98 for id in stale {
99 store.delete_relationship(&id)?;
100 }
101
102 let units = unit_dirs(graph);
103 let mut crossings: Vec<Relationship> = Vec::new();
104 for r in store.all_relationships()? {
105 if r.predicate == scc_core::predicates::DEPENDS_ON
106 && r.provenance == Provenance::Resolved
107 {
108 let (subj, obj) = match (graph.entity(&r.subject), graph.entity(&r.object)) {
110 (Some(s), Some(o)) => (s, o),
111 _ => continue,
112 };
113 if subj.kind != kinds::COMPONENT || obj.kind != kinds::COMPONENT {
114 continue;
115 }
116 if unit_for_component(subj, &units) == unit_for_component(obj, &units) {
117 continue;
118 }
119 crossings.push(
120 Relationship::new(
121 rel_id(&["crosses_boundary", &r.subject, &r.object]),
122 r.subject.clone(),
123 scc_core::predicates::CROSSES_BOUNDARY,
124 r.object.clone(),
125 Provenance::Extracted,
126 )
127 .with_confidence(0.9)
128 .with_evidence(r.evidence.clone()),
129 );
130 } else if r.predicate == scc_core::predicates::CALLS
131 && r.object.contains("/external_api/")
132 {
133 crossings.push(
135 Relationship::new(
136 rel_id(&["external_crossing", &r.subject, &r.object]),
137 r.subject.clone(),
138 scc_core::predicates::CROSSES_BOUNDARY,
139 r.object.clone(),
140 Provenance::Extracted,
141 )
142 .with_confidence(0.9)
143 .with_evidence(r.evidence.clone()),
144 );
145 }
146 }
147
148 crossings.sort_by(|a, b| a.subject.cmp(&b.subject).then_with(|| a.object.cmp(&b.object)));
149 Ok(crossings.into_iter().map(|r| (r, String::new())).collect())
150}
151
152pub fn boundary_crossings(graph: &RealityGraph, store: &Store) -> Result<Vec<String>> {
163 crossing_lines(graph, store, false)
164}
165
166pub fn boundary_crossings_from_rels(
172 graph: &RealityGraph,
173 rels: &[&scc_core::Relationship],
174 prod_only: bool,
175) -> Vec<String> {
176 let units = unit_dirs(graph);
180 let mut lines: Vec<String> = Vec::new();
181 for rel in rels {
182 if rel.predicate != scc_core::predicates::CROSSES_BOUNDARY {
183 continue;
184 }
185 if prod_only && !crossing_subject_production(graph, &rel.subject) {
186 continue;
187 }
188 let Some(subj) = graph.entity(&rel.subject) else {
189 continue;
190 };
191 let obj_name = graph
192 .entity(&rel.object)
193 .map(|o| o.name.clone())
194 .unwrap_or_else(|| {
195 rel.object.rsplit('/').next().unwrap_or(&rel.object).to_string()
196 });
197 match subj.kind.as_str() {
198 kinds::COMPONENT => {
199 let ua = unit_for_component(subj, &units);
200 let ub = unit_for_component(
201 graph.entity(&rel.object).unwrap_or(subj),
202 &units,
203 );
204 lines.push(format!("{ua}/{} -> {ub}/{obj_name}", subj.name));
205 }
206 kinds::SYMBOL => {
207 let (unit, owner) = component_of_symbol(graph, &rel.subject, &graph.components)
208 .map(|c| (unit_for_component(c, &units), c.name.clone()))
209 .unwrap_or_else(|| ("local".to_string(), subj.name.clone()));
210 lines.push(format!("{unit}/{owner} -> external/{obj_name}"));
211 }
212 _ => {}
213 }
214 }
215 lines.sort();
216 lines.dedup();
217 lines
218}
219
220pub fn production_crossings(graph: &RealityGraph, store: &Store) -> Result<Vec<String>> {
227 crossing_lines(graph, store, true)
228}
229
230fn crossing_lines(graph: &RealityGraph, store: &Store, prod_only: bool) -> Result<Vec<String>> {
235 let units = unit_dirs(graph);
236 let comps = store.components()?;
237 let mut lines: Vec<String> = Vec::new();
238 for rel in store.all_relationships()? {
239 if rel.predicate != scc_core::predicates::CROSSES_BOUNDARY {
240 continue;
241 }
242 if prod_only && !crossing_subject_production(graph, &rel.subject) {
243 continue;
244 }
245 let Some(subj) = graph.entity(&rel.subject) else {
246 continue;
247 };
248 let obj_name = graph
249 .entity(&rel.object)
250 .map(|o| o.name.clone())
251 .unwrap_or_else(|| {
252 rel.object.rsplit('/').next().unwrap_or(&rel.object).to_string()
253 });
254 match subj.kind.as_str() {
255 kinds::COMPONENT => {
256 let ua = unit_for_component(subj, &units);
257 let ub = unit_for_component(
258 graph.entity(&rel.object).unwrap_or(subj),
259 &units,
260 );
261 lines.push(format!("{ua}/{} -> {ub}/{obj_name}", subj.name));
262 }
263 kinds::SYMBOL => {
264 let (unit, owner) = component_of_symbol(graph, &rel.subject, &comps)
265 .map(|c| (unit_for_component(c, &units), c.name.clone()))
266 .unwrap_or_else(|| ("local".to_string(), subj.name.clone()));
267 lines.push(format!("{unit}/{owner} -> external/{obj_name}"));
268 }
269 _ => {}
270 }
271 }
272 lines.sort();
273 lines.dedup();
274 Ok(lines)
275}
276
277fn crossing_subject_production(graph: &RealityGraph, subject: &str) -> bool {
281 let Some(subj) = graph.entity(subject) else {
282 return true;
283 };
284 match subj.kind.as_str() {
285 kinds::COMPONENT => {
286 let paths: Vec<String> = subj
287 .attributes
288 .get("implementation")
289 .and_then(|v| v.get("paths"))
290 .and_then(|v| v.as_array())
291 .map(|a| {
292 a.iter()
293 .filter_map(|x| x.as_str().map(String::from))
294 .collect()
295 })
296 .unwrap_or_default();
297 crate::components::component_role(&paths) == "production"
298 }
299 _ => subj
300 .attributes
301 .get("file")
302 .and_then(|v| v.as_str())
303 .and_then(crate::components::path_role)
304 .map(|r| r == "production")
305 .unwrap_or(true),
306 }
307}
308
309fn component_of_symbol<'a>(
312 graph: &RealityGraph,
313 sym_id: &str,
314 comps: &'a [Entity],
315) -> Option<&'a Entity> {
316 let file = graph.entity(sym_id)?.attributes.get("file")?.as_str()?;
317 let mut best: Option<(&'a Entity, usize)> = None;
318 for c in comps {
319 if let Some(paths) = c
320 .attributes
321 .get("implementation")
322 .and_then(|v| v.get("paths"))
323 .and_then(|v| v.as_array())
324 {
325 for p in paths {
326 if let Some(p) = p.as_str() {
327 if file == p || file.starts_with(&format!("{p}/")) {
328 let len = p.len();
329 if best.map(|(_, l)| len > l).unwrap_or(true) {
330 best = Some((c, len));
331 }
332 }
333 }
334 }
335 }
336 }
337 best.map(|(c, _)| c)
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use scc_core::{entity_id, symbol_id};
344
345 fn store_with() -> (Store, tempfile::TempDir) {
346 let tmp = tempfile::TempDir::new().unwrap();
347 let root = tmp.path().join("repo");
348 std::fs::create_dir_all(&root).unwrap();
349 let store = Store::open(&tmp.path().join("scc.db"), &root).unwrap();
350 (store, tmp)
351 }
352
353 fn unit(store: &Store, name: &str, dir: &str) {
354 let mut e = Entity::new(
355 entity_id(&store.repo_id, kinds::DEPLOYMENT_UNIT, name),
356 kinds::DEPLOYMENT_UNIT,
357 name,
358 );
359 e.attr("build_context", serde_json::json!(dir));
360 store.insert_entity(&e, &[]).unwrap();
361 }
362
363 fn component(store: &Store, name: &str, paths: &[&str]) -> Entity {
364 let mut e = Entity::new(
365 entity_id(&store.repo_id, kinds::COMPONENT, name),
366 kinds::COMPONENT,
367 name,
368 );
369 e.attr(
370 "implementation",
371 serde_json::json!({ "paths": paths, "symbols": [] }),
372 );
373 e
374 }
375
376 fn dep(store: &Store, from: &Entity, to: &Entity, prov: Provenance) {
377 let r = Relationship::new(
378 format!("rel:test:{}:{}", from.name, to.name),
379 from.id.clone(),
380 scc_core::predicates::DEPENDS_ON,
381 to.id.clone(),
382 prov,
383 )
384 .with_evidence(vec!["evidence:test1".to_string()]);
385 store.insert_relationship(&r, "").unwrap();
386 }
387
388 fn setup_basic(store: &Store) -> Vec<Entity> {
394 unit(store, "web", "services/web");
395 unit(store, "api", "services/api");
396 let web = component(store, "web", &["services/web"]);
397 let web_worker = component(store, "web-worker", &["services/web/worker"]);
398 let api = component(store, "api", &["services/api"]);
399 let util = component(store, "util", &["shared"]);
400 let comps = vec![web.clone(), web_worker.clone(), api.clone(), util.clone()];
401 store.replace_components(&comps).unwrap();
402 dep(store, &web, &api, Provenance::Resolved);
403 dep(store, &web, &web_worker, Provenance::Resolved);
404 dep(store, &web_worker, &util, Provenance::Resolved);
405 dep(store, &api, &util, Provenance::Resolved);
406 dep(store, &web, &util, Provenance::Extracted);
407 comps
408 }
409
410 #[test]
411 fn display_does_not_mutate_the_store() {
413 let (store, _t) = store_with();
416 let _comps = setup_basic(&store);
417 let graph = RealityGraph::load(&store).unwrap();
418
419 let before = store.all_relationships().unwrap().len();
420 let lines = boundary_crossings(&graph, &store).unwrap();
421 let after = store.all_relationships().unwrap().len();
422 assert_eq!(before, after, "display must not mutate the store");
423 assert!(lines.is_empty(), "{lines:?}");
426
427 let compiled = compile_boundaries(&graph, &store).unwrap();
431 for (rel, src) in compiled {
432 store.insert_relationship(&rel, &src).unwrap();
433 }
434 let after_compile = store.all_relationships().unwrap().len();
435 assert!(after_compile > before, "compile inserts crossings");
436 let before2 = store.all_relationships().unwrap().len();
437 let lines2 = boundary_crossings(&graph, &store).unwrap();
438 let after2 = store.all_relationships().unwrap().len();
439 assert_eq!(before2, after2, "display must not mutate the store (2)");
440 assert!(!lines2.is_empty(), "compiled crossings render: {lines2:?}");
441 }
442
443 #[test]
444 fn crossings_only_across_units() {
445 let (store, _t) = store_with();
446 let comps = setup_basic(&store);
447 let graph = RealityGraph::load(&store).unwrap();
448 let out = compile_boundaries(&graph, &store).unwrap();
449 assert_eq!(out.len(), 3, "web->api, web-worker->util, api->util");
450
451 for (rel, src) in &out {
452 assert_eq!(rel.predicate, scc_core::predicates::CROSSES_BOUNDARY);
453 assert_eq!(rel.provenance, Provenance::Extracted);
454 assert_eq!(rel.confidence, 0.9);
455 assert_eq!(rel.evidence, vec!["evidence:test1".to_string()]);
456 assert!(src.is_empty(), "derived facts carry no source path");
457 }
458
459 let find = |name: &str| comps.iter().find(|c| c.name == name).unwrap();
460 let web = find("web");
461 let web_worker = find("web-worker");
462 let api = find("api");
463 let util = find("util");
464
465 assert!(!out.iter().any(|(r, _)| r.subject == web.id && r.object == web_worker.id));
467 assert!(!out.iter().any(|(r, _)| r.subject == web.id && r.object == util.id));
468 assert!(out.iter().any(|(r, _)| r.subject == web.id && r.object == api.id));
470 assert!(out.iter().any(|(r, _)| r.subject == web_worker.id && r.object == util.id));
471 assert!(out.iter().any(|(r, _)| r.subject == api.id && r.object == util.id));
472
473 let out2 = compile_boundaries(&graph, &store).unwrap();
475 assert_eq!(out2.len(), 3);
476 }
477
478 #[test]
479 fn external_api_call_crosses_boundary() {
480 let (store, _t) = store_with();
481 unit(&store, "api", "services/api");
482 let api_comp = component(&store, "api", &["services/api"]);
483 store.replace_components(&[api_comp]).unwrap();
484
485 let sym = symbol_id(&store.repo_id, "services/api/app.py", "main");
486 let ext = entity_id(&store.repo_id, kinds::EXTERNAL_API, "stripe.api");
487 let mut se = Entity::new(sym.clone(), kinds::SYMBOL, "main");
488 se.attr("file", serde_json::json!("services/api/app.py"));
489 store.insert_entity(&se, &[]).unwrap();
490 store
491 .insert_entity(&Entity::new(ext.clone(), kinds::EXTERNAL_API, "stripe.api"), &[])
492 .unwrap();
493 store
494 .insert_relationship(
495 &Relationship::new(
496 "rel:test:call".to_string(),
497 sym.clone(),
498 scc_core::predicates::CALLS,
499 ext.clone(),
500 Provenance::Resolved,
501 )
502 .with_evidence(vec!["evidence:call1".to_string()]),
503 "services/api/app.py",
504 )
505 .unwrap();
506
507 let graph = RealityGraph::load(&store).unwrap();
508 let out = compile_boundaries(&graph, &store).unwrap();
509 assert_eq!(out.len(), 1);
510 let (rel, src) = &out[0];
511 assert_eq!(rel.subject, sym);
512 assert_eq!(rel.object, ext);
513 assert_eq!(rel.predicate, scc_core::predicates::CROSSES_BOUNDARY);
514 assert_eq!(rel.provenance, Provenance::Extracted);
515 assert_eq!(rel.confidence, 0.9);
516 assert_eq!(rel.evidence, vec!["evidence:call1".to_string()]);
517 assert!(src.is_empty());
518 }
519
520 #[test]
521 fn no_crossings_within_unit() {
522 let (store, _t) = store_with();
523 unit(&store, "svc", "src/svc");
524 let a = component(&store, "a", &["src/svc/a"]);
525 let b = component(&store, "b", &["src/svc/b"]);
526 store.replace_components(&[a.clone(), b.clone()]).unwrap();
527 dep(&store, &a, &b, Provenance::Resolved);
528 let graph = RealityGraph::load(&store).unwrap();
529 let out = compile_boundaries(&graph, &store).unwrap();
530 assert!(out.is_empty(), "same deployment unit: no crossing");
531 let lines = boundary_crossings(&graph, &store).unwrap();
532 assert!(lines.is_empty());
533 }
534
535 #[test]
536 fn boundary_crossings_strings() {
537 let (store, _t) = store_with();
538 setup_basic(&store);
539
540 let sym = symbol_id(&store.repo_id, "services/api/app.py", "main");
541 let ext = entity_id(&store.repo_id, kinds::EXTERNAL_API, "stripe.api");
542 let mut se = Entity::new(sym.clone(), kinds::SYMBOL, "main");
543 se.attr("file", serde_json::json!("services/api/app.py"));
544 store.insert_entity(&se, &[]).unwrap();
545 store
546 .insert_entity(&Entity::new(ext.clone(), kinds::EXTERNAL_API, "stripe.api"), &[])
547 .unwrap();
548 store
549 .insert_relationship(
550 &Relationship::new(
551 "rel:test:call".to_string(),
552 sym,
553 scc_core::predicates::CALLS,
554 ext,
555 Provenance::Resolved,
556 ),
557 "services/api/app.py",
558 )
559 .unwrap();
560
561 let graph = RealityGraph::load(&store).unwrap();
562 let crossings = compile_boundaries(&graph, &store).unwrap();
564 for (rel, src) in crossings {
565 store.insert_relationship(&rel, &src).unwrap();
566 }
567 let lines = boundary_crossings(&graph, &store).unwrap();
568 assert_eq!(
569 lines,
570 vec![
571 "api/api -> external/stripe.api".to_string(),
572 "api/api -> local/util".to_string(),
573 "web/web -> api/api".to_string(),
574 "web/web-worker -> local/util".to_string(),
575 ]
576 );
577 }
578}