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 production_crossings(graph: &RealityGraph, store: &Store) -> Result<Vec<String>> {
173 crossing_lines(graph, store, true)
174}
175
176fn crossing_lines(graph: &RealityGraph, store: &Store, prod_only: bool) -> Result<Vec<String>> {
181 let units = unit_dirs(graph);
182 let comps = store.components()?;
183 let mut lines: Vec<String> = Vec::new();
184 for rel in store.all_relationships()? {
185 if rel.predicate != scc_core::predicates::CROSSES_BOUNDARY {
186 continue;
187 }
188 if prod_only && !crossing_subject_production(graph, &rel.subject) {
189 continue;
190 }
191 let Some(subj) = graph.entity(&rel.subject) else {
192 continue;
193 };
194 let obj_name = graph
195 .entity(&rel.object)
196 .map(|o| o.name.clone())
197 .unwrap_or_else(|| {
198 rel.object.rsplit('/').next().unwrap_or(&rel.object).to_string()
199 });
200 match subj.kind.as_str() {
201 kinds::COMPONENT => {
202 let ua = unit_for_component(subj, &units);
203 let ub = unit_for_component(
204 graph.entity(&rel.object).unwrap_or(subj),
205 &units,
206 );
207 lines.push(format!("{ua}/{} -> {ub}/{obj_name}", subj.name));
208 }
209 kinds::SYMBOL => {
210 let (unit, owner) = component_of_symbol(graph, &rel.subject, &comps)
211 .map(|c| (unit_for_component(c, &units), c.name.clone()))
212 .unwrap_or_else(|| ("local".to_string(), subj.name.clone()));
213 lines.push(format!("{unit}/{owner} -> external/{obj_name}"));
214 }
215 _ => {}
216 }
217 }
218 lines.sort();
219 lines.dedup();
220 Ok(lines)
221}
222
223fn crossing_subject_production(graph: &RealityGraph, subject: &str) -> bool {
227 let Some(subj) = graph.entity(subject) else {
228 return true;
229 };
230 match subj.kind.as_str() {
231 kinds::COMPONENT => {
232 let paths: Vec<String> = subj
233 .attributes
234 .get("implementation")
235 .and_then(|v| v.get("paths"))
236 .and_then(|v| v.as_array())
237 .map(|a| {
238 a.iter()
239 .filter_map(|x| x.as_str().map(String::from))
240 .collect()
241 })
242 .unwrap_or_default();
243 crate::components::component_role(&paths) == "production"
244 }
245 _ => subj
246 .attributes
247 .get("file")
248 .and_then(|v| v.as_str())
249 .and_then(crate::components::path_role)
250 .map(|r| r == "production")
251 .unwrap_or(true),
252 }
253}
254
255fn component_of_symbol<'a>(
258 graph: &RealityGraph,
259 sym_id: &str,
260 comps: &'a [Entity],
261) -> Option<&'a Entity> {
262 let file = graph.entity(sym_id)?.attributes.get("file")?.as_str()?;
263 let mut best: Option<(&'a Entity, usize)> = None;
264 for c in comps {
265 if let Some(paths) = c
266 .attributes
267 .get("implementation")
268 .and_then(|v| v.get("paths"))
269 .and_then(|v| v.as_array())
270 {
271 for p in paths {
272 if let Some(p) = p.as_str() {
273 if file == p || file.starts_with(&format!("{p}/")) {
274 let len = p.len();
275 if best.map(|(_, l)| len > l).unwrap_or(true) {
276 best = Some((c, len));
277 }
278 }
279 }
280 }
281 }
282 }
283 best.map(|(c, _)| c)
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use scc_core::{entity_id, symbol_id};
290
291 fn store_with() -> (Store, tempfile::TempDir) {
292 let tmp = tempfile::TempDir::new().unwrap();
293 let root = tmp.path().join("repo");
294 std::fs::create_dir_all(&root).unwrap();
295 let store = Store::open(&tmp.path().join("scc.db"), &root).unwrap();
296 (store, tmp)
297 }
298
299 fn unit(store: &Store, name: &str, dir: &str) {
300 let mut e = Entity::new(
301 entity_id(&store.repo_id, kinds::DEPLOYMENT_UNIT, name),
302 kinds::DEPLOYMENT_UNIT,
303 name,
304 );
305 e.attr("build_context", serde_json::json!(dir));
306 store.insert_entity(&e, &[]).unwrap();
307 }
308
309 fn component(store: &Store, name: &str, paths: &[&str]) -> Entity {
310 let mut e = Entity::new(
311 entity_id(&store.repo_id, kinds::COMPONENT, name),
312 kinds::COMPONENT,
313 name,
314 );
315 e.attr(
316 "implementation",
317 serde_json::json!({ "paths": paths, "symbols": [] }),
318 );
319 e
320 }
321
322 fn dep(store: &Store, from: &Entity, to: &Entity, prov: Provenance) {
323 let r = Relationship::new(
324 format!("rel:test:{}:{}", from.name, to.name),
325 from.id.clone(),
326 scc_core::predicates::DEPENDS_ON,
327 to.id.clone(),
328 prov,
329 )
330 .with_evidence(vec!["evidence:test1".to_string()]);
331 store.insert_relationship(&r, "").unwrap();
332 }
333
334 fn setup_basic(store: &Store) -> Vec<Entity> {
340 unit(store, "web", "services/web");
341 unit(store, "api", "services/api");
342 let web = component(store, "web", &["services/web"]);
343 let web_worker = component(store, "web-worker", &["services/web/worker"]);
344 let api = component(store, "api", &["services/api"]);
345 let util = component(store, "util", &["shared"]);
346 let comps = vec![web.clone(), web_worker.clone(), api.clone(), util.clone()];
347 store.replace_components(&comps).unwrap();
348 dep(store, &web, &api, Provenance::Resolved);
349 dep(store, &web, &web_worker, Provenance::Resolved);
350 dep(store, &web_worker, &util, Provenance::Resolved);
351 dep(store, &api, &util, Provenance::Resolved);
352 dep(store, &web, &util, Provenance::Extracted);
353 comps
354 }
355
356 #[test]
357 fn display_does_not_mutate_the_store() {
359 let (store, _t) = store_with();
362 let _comps = setup_basic(&store);
363 let graph = RealityGraph::load(&store).unwrap();
364
365 let before = store.all_relationships().unwrap().len();
366 let lines = boundary_crossings(&graph, &store).unwrap();
367 let after = store.all_relationships().unwrap().len();
368 assert_eq!(before, after, "display must not mutate the store");
369 assert!(lines.is_empty(), "{lines:?}");
372
373 let compiled = compile_boundaries(&graph, &store).unwrap();
377 for (rel, src) in compiled {
378 store.insert_relationship(&rel, &src).unwrap();
379 }
380 let after_compile = store.all_relationships().unwrap().len();
381 assert!(after_compile > before, "compile inserts crossings");
382 let before2 = store.all_relationships().unwrap().len();
383 let lines2 = boundary_crossings(&graph, &store).unwrap();
384 let after2 = store.all_relationships().unwrap().len();
385 assert_eq!(before2, after2, "display must not mutate the store (2)");
386 assert!(!lines2.is_empty(), "compiled crossings render: {lines2:?}");
387 }
388
389 #[test]
390 fn crossings_only_across_units() {
391 let (store, _t) = store_with();
392 let comps = setup_basic(&store);
393 let graph = RealityGraph::load(&store).unwrap();
394 let out = compile_boundaries(&graph, &store).unwrap();
395 assert_eq!(out.len(), 3, "web->api, web-worker->util, api->util");
396
397 for (rel, src) in &out {
398 assert_eq!(rel.predicate, scc_core::predicates::CROSSES_BOUNDARY);
399 assert_eq!(rel.provenance, Provenance::Extracted);
400 assert_eq!(rel.confidence, 0.9);
401 assert_eq!(rel.evidence, vec!["evidence:test1".to_string()]);
402 assert!(src.is_empty(), "derived facts carry no source path");
403 }
404
405 let find = |name: &str| comps.iter().find(|c| c.name == name).unwrap();
406 let web = find("web");
407 let web_worker = find("web-worker");
408 let api = find("api");
409 let util = find("util");
410
411 assert!(!out.iter().any(|(r, _)| r.subject == web.id && r.object == web_worker.id));
413 assert!(!out.iter().any(|(r, _)| r.subject == web.id && r.object == util.id));
414 assert!(out.iter().any(|(r, _)| r.subject == web.id && r.object == api.id));
416 assert!(out.iter().any(|(r, _)| r.subject == web_worker.id && r.object == util.id));
417 assert!(out.iter().any(|(r, _)| r.subject == api.id && r.object == util.id));
418
419 let out2 = compile_boundaries(&graph, &store).unwrap();
421 assert_eq!(out2.len(), 3);
422 }
423
424 #[test]
425 fn external_api_call_crosses_boundary() {
426 let (store, _t) = store_with();
427 unit(&store, "api", "services/api");
428 let api_comp = component(&store, "api", &["services/api"]);
429 store.replace_components(&[api_comp]).unwrap();
430
431 let sym = symbol_id(&store.repo_id, "services/api/app.py", "main");
432 let ext = entity_id(&store.repo_id, kinds::EXTERNAL_API, "stripe.api");
433 let mut se = Entity::new(sym.clone(), kinds::SYMBOL, "main");
434 se.attr("file", serde_json::json!("services/api/app.py"));
435 store.insert_entity(&se, &[]).unwrap();
436 store
437 .insert_entity(&Entity::new(ext.clone(), kinds::EXTERNAL_API, "stripe.api"), &[])
438 .unwrap();
439 store
440 .insert_relationship(
441 &Relationship::new(
442 "rel:test:call".to_string(),
443 sym.clone(),
444 scc_core::predicates::CALLS,
445 ext.clone(),
446 Provenance::Resolved,
447 )
448 .with_evidence(vec!["evidence:call1".to_string()]),
449 "services/api/app.py",
450 )
451 .unwrap();
452
453 let graph = RealityGraph::load(&store).unwrap();
454 let out = compile_boundaries(&graph, &store).unwrap();
455 assert_eq!(out.len(), 1);
456 let (rel, src) = &out[0];
457 assert_eq!(rel.subject, sym);
458 assert_eq!(rel.object, ext);
459 assert_eq!(rel.predicate, scc_core::predicates::CROSSES_BOUNDARY);
460 assert_eq!(rel.provenance, Provenance::Extracted);
461 assert_eq!(rel.confidence, 0.9);
462 assert_eq!(rel.evidence, vec!["evidence:call1".to_string()]);
463 assert!(src.is_empty());
464 }
465
466 #[test]
467 fn no_crossings_within_unit() {
468 let (store, _t) = store_with();
469 unit(&store, "svc", "src/svc");
470 let a = component(&store, "a", &["src/svc/a"]);
471 let b = component(&store, "b", &["src/svc/b"]);
472 store.replace_components(&[a.clone(), b.clone()]).unwrap();
473 dep(&store, &a, &b, Provenance::Resolved);
474 let graph = RealityGraph::load(&store).unwrap();
475 let out = compile_boundaries(&graph, &store).unwrap();
476 assert!(out.is_empty(), "same deployment unit: no crossing");
477 let lines = boundary_crossings(&graph, &store).unwrap();
478 assert!(lines.is_empty());
479 }
480
481 #[test]
482 fn boundary_crossings_strings() {
483 let (store, _t) = store_with();
484 setup_basic(&store);
485
486 let sym = symbol_id(&store.repo_id, "services/api/app.py", "main");
487 let ext = entity_id(&store.repo_id, kinds::EXTERNAL_API, "stripe.api");
488 let mut se = Entity::new(sym.clone(), kinds::SYMBOL, "main");
489 se.attr("file", serde_json::json!("services/api/app.py"));
490 store.insert_entity(&se, &[]).unwrap();
491 store
492 .insert_entity(&Entity::new(ext.clone(), kinds::EXTERNAL_API, "stripe.api"), &[])
493 .unwrap();
494 store
495 .insert_relationship(
496 &Relationship::new(
497 "rel:test:call".to_string(),
498 sym,
499 scc_core::predicates::CALLS,
500 ext,
501 Provenance::Resolved,
502 ),
503 "services/api/app.py",
504 )
505 .unwrap();
506
507 let graph = RealityGraph::load(&store).unwrap();
508 let crossings = compile_boundaries(&graph, &store).unwrap();
510 for (rel, src) in crossings {
511 store.insert_relationship(&rel, &src).unwrap();
512 }
513 let lines = boundary_crossings(&graph, &store).unwrap();
514 assert_eq!(
515 lines,
516 vec![
517 "api/api -> external/stripe.api".to_string(),
518 "api/api -> local/util".to_string(),
519 "web/web -> api/api".to_string(),
520 "web/web-worker -> local/util".to_string(),
521 ]
522 );
523 }
524}