1use crate::RealityGraph;
21use scc_core::{kinds, predicates, Entity, Provenance};
22use std::collections::{BTreeMap, BTreeSet, HashMap};
23
24pub const S_PERSISTENT: &str = "persistent";
25pub const S_RUNTIME: &str = "runtime";
26pub const S_REACTIVE: &str = "reactive";
27pub const S_CONFIGURATION: &str = "configuration";
28pub const S_CACHES: &str = "caches";
29pub const S_DERIVED: &str = "derived";
30
31pub const STATE_SECTIONS: [&str; 6] = [
33 S_PERSISTENT,
34 S_RUNTIME,
35 S_REACTIVE,
36 S_CONFIGURATION,
37 S_CACHES,
38 S_DERIVED,
39];
40
41pub fn section_label(section: &str) -> &'static str {
44 match section {
45 S_PERSISTENT => "DATA OWNERSHIP",
46 S_RUNTIME => "RUNTIME STATE",
47 S_REACTIVE => "REACTIVE STATE",
48 S_CONFIGURATION => "CONFIGURATION",
49 S_CACHES => "CACHES",
50 S_DERIVED => "DERIVED / REGISTRIES",
51 _ => "STATE",
52 }
53}
54
55pub fn concept_occurrences<'g>(graph: &'g RealityGraph, concept_id: &str) -> Vec<&'g Entity> {
61 let mut occs: Vec<&Entity> = graph
62 .in_pred(concept_id, predicates::OCCURS)
63 .into_iter()
64 .filter_map(|r| graph.entities.get(&r.subject))
65 .collect();
66 occs.sort_by(|a, b| a.id.cmp(&b.id));
67 occs
68}
69
70pub fn occurrence_count(graph: &RealityGraph, concept_id: &str) -> usize {
74 concept_occurrences(graph, concept_id).len()
75}
76
77pub fn occurrence_producer(graph: &RealityGraph, concept_id: &str) -> Option<String> {
83 let mut freq: BTreeMap<String, usize> = BTreeMap::new();
84 for occ in concept_occurrences(graph, concept_id) {
85 if let Some(o) = occ.attributes.get("owner").and_then(|v| v.as_str()) {
86 *freq.entry(o.to_string()).or_default() += 1;
87 }
88 }
89 freq.into_iter()
90 .max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
91 .map(|(name, _)| name)
92}
93
94pub fn is_cache_store(name: &str, technology: Option<&str>) -> bool {
97 if let Some(t) = technology {
98 let t = t.to_ascii_lowercase();
99 if matches!(t.as_str(), "redis" | "memcached" | "valkey") {
100 return true;
101 }
102 }
103 let n = name.to_ascii_lowercase();
104 n.contains("cache") || n.contains("redis") || n.contains("memcache")
105}
106
107pub fn compile_state_authority(
120 graph: &RealityGraph,
121 symbol_comp: &HashMap<String, String>,
122) -> BTreeMap<String, Vec<String>> {
123 let mut sections: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
124 for k in STATE_SECTIONS {
127 sections.entry(k.to_string()).or_default();
128 }
129 let mut push = |section: &str, line: String| {
130 sections
131 .entry(section.to_string())
132 .or_default()
133 .insert(line);
134 };
135
136 let prov_str = |p: &Provenance| p.as_str().to_string();
137
138 let comp_of = |sym_id: &str| symbol_comp.get(sym_id).cloned();
140
141 let mut symbol_ids: Vec<&String> = symbol_comp.keys().collect();
143 symbol_ids.sort();
144 for sym_id in symbol_ids {
145 let Some(comp) = comp_of(sym_id) else {
146 continue;
147 };
148 let sym_name = graph
149 .entities
150 .get(sym_id)
151 .map(|e| e.name.clone())
152 .unwrap_or_else(|| {
153 sym_id
154 .rsplit('/')
155 .next()
156 .unwrap_or(sym_id.as_str())
157 .to_string()
158 });
159 let mut rels: Vec<&scc_core::Relationship> = graph.out_edges(sym_id);
160 rels.sort_by(|a, b| {
161 a.predicate
162 .cmp(&b.predicate)
163 .then_with(|| a.object.cmp(&b.object))
164 .then_with(|| a.id.cmp(&b.id))
165 });
166 for r in rels {
167 let target = match graph.entities.get(&r.object) {
168 Some(e) => e,
169 None => continue,
170 };
171 match r.predicate.as_str() {
172 predicates::WRITES => {
173 if is_cache_store(&target.name, tech(graph, &r.object)) {
174 push(
175 S_CACHES,
176 format!(
177 "{} writes {} ({})",
178 comp,
179 target.name,
180 prov_str(&r.provenance)
181 ),
182 );
183 push(
184 S_CACHES,
185 format!(
186 "{}::{} writes {} ({})",
187 comp,
188 sym_name,
189 target.name,
190 prov_str(&r.provenance)
191 ),
192 );
193 } else if target.kind == kinds::DATA_STORE || target.kind == kinds::DATA_ENTITY
194 {
195 let tgt = store_ref(graph, &r.object);
196 push(
197 S_PERSISTENT,
198 format!("{} owns {} ({})", comp, tgt, prov_str(&r.provenance)),
199 );
200 push(
201 S_PERSISTENT,
202 format!(
203 "{}::{} writes {} ({})",
204 comp,
205 sym_name,
206 tgt,
207 prov_str(&r.provenance)
208 ),
209 );
210 }
211 }
212 predicates::READS | predicates::QUERIES => {
213 let verb = if r.predicate == predicates::QUERIES {
214 "queries"
215 } else {
216 "reads"
217 };
218 let cache = target.kind == kinds::DATA_STORE
219 && is_cache_store(&target.name, tech(graph, &r.object));
220 if cache {
221 if r.predicate == predicates::READS {
222 push(
223 S_CACHES,
224 format!(
225 "{} reads {} ({})",
226 comp,
227 target.name,
228 prov_str(&r.provenance)
229 ),
230 );
231 }
232 push(
233 S_CACHES,
234 format!(
235 "{}::{} {verb} {} ({})",
236 comp,
237 sym_name,
238 target.name,
239 prov_str(&r.provenance)
240 ),
241 );
242 } else if target.kind == kinds::DATA_STORE || target.kind == kinds::DATA_ENTITY
243 {
244 push(
246 S_PERSISTENT,
247 format!(
248 "{}::{} {verb} {} ({})",
249 comp,
250 sym_name,
251 store_ref(graph, &r.object),
252 prov_str(&r.provenance)
253 ),
254 );
255 }
256 }
257 predicates::PUBLISHES => {
258 push(
259 S_DERIVED,
260 format!(
261 "{} publishes {} ({})",
262 comp,
263 target.name,
264 prov_str(&r.provenance)
265 ),
266 );
267 push(
268 S_DERIVED,
269 format!(
270 "{}::{} publishes {} ({})",
271 comp,
272 sym_name,
273 target.name,
274 prov_str(&r.provenance)
275 ),
276 );
277 }
278 predicates::SUBSCRIBES => {
279 push(
280 S_DERIVED,
281 format!(
282 "{} subscribes {} ({})",
283 comp,
284 target.name,
285 prov_str(&r.provenance)
286 ),
287 );
288 push(
289 S_DERIVED,
290 format!(
291 "{}::{} subscribes {} ({})",
292 comp,
293 sym_name,
294 target.name,
295 prov_str(&r.provenance)
296 ),
297 );
298 }
299 predicates::CONSUMES => {
300 push(
301 S_DERIVED,
302 format!(
303 "{} consumes {} ({})",
304 comp,
305 target.name,
306 prov_str(&r.provenance)
307 ),
308 );
309 push(
310 S_DERIVED,
311 format!(
312 "{}::{} consumes {} ({})",
313 comp,
314 sym_name,
315 target.name,
316 prov_str(&r.provenance)
317 ),
318 );
319 }
320 predicates::REGISTERS => {
321 let is_mw_registry = target.kind == kinds::MIDDLEWARE
322 || target.kind == kinds::REGISTRY
323 || (target.kind == kinds::CONTRACT
324 && target
325 .attributes
326 .get("kind")
327 .and_then(|v| v.as_str())
328 .map(|k| matches!(k, "middleware" | "registry"))
329 .unwrap_or(false));
330 if is_mw_registry {
331 push(
332 S_DERIVED,
333 format!(
334 "{} registers {} ({})",
335 comp,
336 target.name,
337 prov_str(&r.provenance)
338 ),
339 );
340 }
341 }
342 _ => {}
343 }
344 }
345 }
346
347 let mut runtime_entities: Vec<&scc_core::Entity> = graph
349 .entities_of_kind(kinds::FIELD)
350 .into_iter()
351 .filter(|f| f.attributes.get("mutable").and_then(|v| v.as_bool()) == Some(true))
352 .collect();
353 runtime_entities.extend(graph.entities_of_kind(kinds::STATE));
354 runtime_entities.extend(graph.entities_of_kind(kinds::REGISTRY));
355 runtime_entities.sort_by(|a, b| a.id.cmp(&b.id));
356 for e in runtime_entities {
357 let mut owner: Option<String> = None;
360 let mut prov: Option<Provenance> = None;
361 let mut rels = graph.in_pred(&e.id, predicates::CONTAINS);
362 rels.sort_by(|a, b| a.id.cmp(&b.id));
363 for r in rels {
364 if let Some(c) = comp_of(&r.subject) {
365 owner = Some(c);
366 prov = Some(r.provenance);
367 break;
368 }
369 }
370 let Some(comp) = owner else { continue };
371 let tag = match e.kind.as_str() {
372 kinds::FIELD => "mutable",
373 kinds::STATE => "state",
374 _ => "registry",
375 };
376 push(
377 S_RUNTIME,
378 format!(
379 "{} owns {} ({tag}) ({})",
380 comp,
381 e.name,
382 prov.map(|p| prov_str(&p))
383 .unwrap_or_else(|| "EXTRACTED".to_string())
384 ),
385 );
386 }
387
388 for cfg in graph.entities_of_kind(kinds::CONFIGURATION) {
390 let mut rels = graph.out_pred(&cfg.id, predicates::CONFIGURED_BY);
391 rels.sort_by(|a, b| a.id.cmp(&b.id));
392 for r in rels {
393 if let Some(comp) = comp_of(&r.object) {
394 push(
395 S_CONFIGURATION,
396 format!(
397 "{} configured_by {} ({})",
398 comp,
399 cfg.name,
400 prov_str(&r.provenance)
401 ),
402 );
403 }
404 }
405 }
406
407 for e in graph.entities_of_kind(kinds::REACTIVE) {
413 let mut occs = concept_occurrences(graph, &e.id);
414 occs.sort_by(|a, b| a.id.cmp(&b.id));
415 for occ in occs {
416 let access = occ
417 .attributes
418 .get("access")
419 .and_then(|v| v.as_str())
420 .unwrap_or("state");
421 let expr = occ
422 .attributes
423 .get("expr")
424 .and_then(|v| v.as_str())
425 .map(|s| s.to_string());
426 let mut rels = graph.in_pred(&occ.id, predicates::OWNS);
428 rels.sort_by(|a, b| a.id.cmp(&b.id));
429 for r in rels {
430 if let Some(comp) = comp_of(&r.subject) {
431 let line = match &expr {
432 Some(exp) if !exp.is_empty() => format!(
433 "{} owns reactive: {} [{}] = {} ({})",
434 comp,
435 e.name,
436 access,
437 exp,
438 prov_str(&r.provenance)
439 ),
440 _ => format!(
441 "{} owns reactive: {} [{}] ({})",
442 comp,
443 e.name,
444 access,
445 prov_str(&r.provenance)
446 ),
447 };
448 push(S_REACTIVE, line);
449 }
450 }
451 }
452 }
453
454 sections
455 .into_iter()
456 .map(|(k, v)| (k, v.into_iter().collect()))
457 .collect()
458}
459
460pub fn state_authority_groups(graph: &RealityGraph) -> Vec<BTreeSet<String>> {
473 let mut store_syms: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
476 let mut sym_ids: Vec<&String> = graph
477 .entities_of_kind(kinds::SYMBOL)
478 .into_iter()
479 .map(|e| &e.id)
480 .collect();
481 sym_ids.sort();
482 for sym in sym_ids {
483 for r in graph.out_pred(sym, predicates::WRITES) {
484 let target = if r.object.contains("/data/") {
485 graph
486 .entities
487 .get(&r.object)
488 .and_then(|e| e.attributes.get("store"))
489 .and_then(|v| v.as_str())
490 .map(|s| scc_core::entity_id(&graph.repo_id, kinds::DATA_STORE, s))
491 .unwrap_or_else(|| r.object.clone())
492 } else {
493 r.object.clone()
494 };
495 store_syms.entry(target).or_default().insert(sym.clone());
496 }
497 }
498 let mut cfg_syms: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
500 for cfg in graph.entities_of_kind(kinds::CONFIGURATION) {
501 let mut rels = graph.out_pred(&cfg.id, predicates::CONFIGURED_BY);
502 rels.sort_by(|a, b| a.id.cmp(&b.id));
503 for r in rels {
504 cfg_syms
505 .entry(cfg.id.clone())
506 .or_default()
507 .insert(r.object.clone());
508 }
509 }
510 let mut reactive_syms: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
516 for rs in graph.entities_of_kind(kinds::REACTIVE) {
517 for r in graph.in_pred(&rs.id, predicates::OCCURS) {
518 let mut rels = graph.in_pred(&r.subject, predicates::OWNS);
519 rels.sort_by(|a, b| a.id.cmp(&b.id));
520 for or in rels {
521 reactive_syms
522 .entry(rs.id.clone())
523 .or_default()
524 .insert(or.subject.clone());
525 }
526 }
527 }
528 let mut out: Vec<BTreeSet<String>> = Vec::new();
529 for group in store_syms
530 .values()
531 .chain(cfg_syms.values())
532 .chain(reactive_syms.values())
533 {
534 if group.len() >= 2 {
535 out.push(group.clone());
536 }
537 }
538 out.sort();
539 out
540}
541
542#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
548pub struct StateClaim {
550 pub component: String,
551 pub verb: String,
552 pub target: String,
553 pub provenance: String,
554}
555
556pub fn compile_state_claims(
563 graph: &RealityGraph,
564 symbol_comp: &HashMap<String, String>,
565) -> Vec<StateClaim> {
566 let mut claims: BTreeSet<StateClaim> = BTreeSet::new();
567 let comp_of = |sym_id: &str| symbol_comp.get(sym_id).cloned();
568 let prov_str = |p: &Provenance| p.as_str().to_string();
569
570 let mut symbol_ids: Vec<&String> = symbol_comp.keys().collect();
572 symbol_ids.sort();
573 for sym_id in symbol_ids {
574 let Some(comp) = comp_of(sym_id) else {
575 continue;
576 };
577 let mut rels: Vec<&scc_core::Relationship> = graph.out_edges(sym_id);
578 rels.sort_by(|a, b| {
579 a.predicate
580 .cmp(&b.predicate)
581 .then_with(|| a.object.cmp(&b.object))
582 .then_with(|| a.id.cmp(&b.id))
583 });
584 for r in rels {
585 let Some(target) = graph.entities.get(&r.object) else {
586 continue;
587 };
588 let tgt = match r.predicate.as_str() {
589 predicates::WRITES => {
590 if target.kind == kinds::DATA_STORE || target.kind == kinds::DATA_ENTITY {
591 Some(("owns", store_ref(graph, &r.object)))
592 } else {
593 Some(("owns", target.name.clone()))
594 }
595 }
596 predicates::READS | predicates::QUERIES => Some(("reads", target.name.clone())),
597 predicates::PUBLISHES | predicates::SUBSCRIBES | predicates::CONSUMES => {
598 Some(("owns", target.name.clone()))
599 }
600 predicates::REGISTERS => {
601 let is_mw_registry = target.kind == kinds::MIDDLEWARE
602 || target.kind == kinds::REGISTRY
603 || (target.kind == kinds::CONTRACT
604 && target
605 .attributes
606 .get("kind")
607 .and_then(|v| v.as_str())
608 .map(|k| matches!(k, "middleware" | "registry"))
609 .unwrap_or(false));
610 if is_mw_registry {
611 Some(("owns", target.name.clone()))
612 } else {
613 None
614 }
615 }
616 _ => None,
617 };
618 if let Some((verb, t)) = tgt {
619 claims.insert(StateClaim {
620 component: comp.clone(),
621 verb: verb.to_string(),
622 target: t,
623 provenance: prov_str(&r.provenance),
624 });
625 }
626 }
627 }
628
629 let mut runtime_entities: Vec<&scc_core::Entity> = graph
631 .entities_of_kind(kinds::FIELD)
632 .into_iter()
633 .filter(|f| f.attributes.get("mutable").and_then(|v| v.as_bool()) == Some(true))
634 .collect();
635 runtime_entities.extend(graph.entities_of_kind(kinds::STATE));
636 runtime_entities.extend(graph.entities_of_kind(kinds::REGISTRY));
637 runtime_entities.sort_by(|a, b| a.id.cmp(&b.id));
638 for e in runtime_entities {
639 let mut rels = graph.in_pred(&e.id, predicates::CONTAINS);
640 rels.sort_by(|a, b| a.id.cmp(&b.id));
641 for r in rels {
642 if let Some(comp) = comp_of(&r.subject) {
643 claims.insert(StateClaim {
644 component: comp,
645 verb: "owns".into(),
646 target: e.name.clone(),
647 provenance: prov_str(&r.provenance),
648 });
649 break;
650 }
651 }
652 }
653
654 for cfg in graph.entities_of_kind(kinds::CONFIGURATION) {
656 let mut rels = graph.out_pred(&cfg.id, predicates::CONFIGURED_BY);
657 rels.sort_by(|a, b| a.id.cmp(&b.id));
658 for r in rels {
659 if let Some(comp) = comp_of(&r.object) {
660 claims.insert(StateClaim {
661 component: comp,
662 verb: "owns".into(),
663 target: cfg.name.clone(),
664 provenance: prov_str(&r.provenance),
665 });
666 }
667 }
668 }
669
670 for e in graph.entities_of_kind(kinds::REACTIVE) {
674 let mut occs = concept_occurrences(graph, &e.id);
675 occs.sort_by(|a, b| a.id.cmp(&b.id));
676 for occ in occs {
677 let access = occ
678 .attributes
679 .get("access")
680 .and_then(|v| v.as_str())
681 .unwrap_or("state");
682 let mut rels = graph.in_pred(&occ.id, predicates::OWNS);
683 rels.sort_by(|a, b| a.id.cmp(&b.id));
684 for r in rels {
685 if let Some(comp) = comp_of(&r.subject) {
686 claims.insert(StateClaim {
687 component: comp,
688 verb: "owns".into(),
689 target: format!("reactive: {} [{}]", e.name, access),
690 provenance: prov_str(&r.provenance),
691 });
692 }
693 }
694 }
695 }
696
697 let mut out: Vec<StateClaim> = claims.into_iter().collect();
698 out.sort();
699 out
700}
701
702fn store_ref(graph: &RealityGraph, id: &str) -> String {
706 match graph.entities.get(id) {
707 Some(e) if e.kind == kinds::DATA_ENTITY => e
708 .attributes
709 .get("store")
710 .and_then(|v| v.as_str())
711 .map(|s| format!("{s}.{}", e.name))
712 .unwrap_or_else(|| e.name.clone()),
713 Some(e) => e.name.clone(),
714 None => id.to_string(),
715 }
716}
717
718fn tech<'a>(graph: &'a RealityGraph, id: &str) -> Option<&'a str> {
719 graph
720 .entities
721 .get(id)
722 .and_then(|e| e.attributes.get("technology"))
723 .and_then(|v| v.as_str())
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729 use scc_core::{entity_id, symbol_id, Entity, Relationship};
730 use scc_store::Store;
731
732 fn open() -> (tempfile::TempDir, Store) {
733 let dir = tempfile::TempDir::new().unwrap();
734 let root = dir.path().join("repo");
735 std::fs::create_dir_all(&root).unwrap();
736 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
737 (dir, store)
738 }
739
740 fn sym(store: &Store, path: &str, name: &str) -> String {
742 let id = symbol_id(&store.repo_id, path, name);
743 store
744 .insert_entity(
745 &Entity::new(id.clone(), kinds::SYMBOL, name),
746 &[path.into()],
747 )
748 .unwrap();
749 id
750 }
751
752 fn component(store: &Store, name: &str, files: &[&str]) {
753 let id = entity_id(&store.repo_id, kinds::COMPONENT, name);
757 let mut existing: Vec<scc_core::Entity> = store.components().unwrap();
758 if let Some(c) = existing.iter_mut().find(|c| c.name == name) {
759 c.id = id.clone();
761 } else {
762 existing.push(scc_core::Entity::new(id.clone(), kinds::COMPONENT, name));
763 }
764 store.replace_components(&existing).unwrap();
765 for f in files {
766 let fid = entity_id(&store.repo_id, kinds::FILE, f);
767 store
768 .insert_relationship(
769 &Relationship::new(
770 format!("rel:c:{name}:{f}"),
771 id.clone(),
772 predicates::CONTAINS,
773 fid,
774 Provenance::Extracted,
775 ),
776 f,
777 )
778 .unwrap();
779 }
780 }
781
782 fn attach(store: &Store, comp: &str, sym_id: &str, path: &str) {
783 let fid = entity_id(&store.repo_id, kinds::FILE, path);
785 store
786 .insert_relationship(
787 &Relationship::new(
788 format!("rel:fc:{comp}:{sym_id}"),
789 fid,
790 predicates::CONTAINS,
791 sym_id.to_string(),
792 Provenance::Extracted,
793 ),
794 path,
795 )
796 .unwrap();
797 }
798
799 #[test]
800 fn attributes_state_ownership_per_component() {
802 let (_dir, store) = open();
803 let repo = store.repo_id.clone();
804
805 component(&store, "api", &["api/app.py"]);
806 component(&store, "web", &["web/app.py"]);
807
808 let api_writer = sym(&store, "api/app.py", "create_user");
811 attach(&store, "api", &api_writer, "api/app.py");
812 let store_ent = entity_id(&repo, kinds::DATA_STORE, "db");
813 store
814 .insert_entity(
815 &Entity::new(store_ent.clone(), kinds::DATA_STORE, "db"),
816 &["api/app.py".into()],
817 )
818 .unwrap();
819 let user = entity_id(&repo, kinds::DATA_ENTITY, "db.users");
820 store
821 .insert_entity(
822 Entity::new(user.clone(), kinds::DATA_ENTITY, "users")
823 .attr("store", serde_json::json!("db")),
824 &["api/app.py".into()],
825 )
826 .unwrap();
827 store
828 .insert_relationship(
829 &Relationship::new(
830 "rel:w:users",
831 api_writer.clone(),
832 predicates::WRITES,
833 user,
834 Provenance::Extracted,
835 )
836 .with_confidence(1.0),
837 "api/app.py",
838 )
839 .unwrap();
840 let cache = entity_id(&repo, kinds::DATA_STORE, "redis");
841 store
842 .insert_entity(
843 Entity::new(cache.clone(), kinds::DATA_STORE, "redis")
844 .attr("technology", serde_json::json!("redis")),
845 &["api/app.py".into()],
846 )
847 .unwrap();
848 store
849 .insert_relationship(
850 &Relationship::new(
851 "rel:r:cache",
852 api_writer.clone(),
853 predicates::READS,
854 cache,
855 Provenance::Extracted,
856 ),
857 "api/app.py",
858 )
859 .unwrap();
860 let cart = sym(&store, "api/app.py", "Cart");
862 attach(&store, "api", &cart, "api/app.py");
863 let field = entity_id(&repo, kinds::FIELD, "Cart.items");
864 store
865 .insert_entity(
866 Entity::new(field.clone(), kinds::FIELD, "Cart.items")
867 .attr("mutable", serde_json::json!(true))
868 .attr("owner", serde_json::json!("Cart")),
869 &["api/app.py".into()],
870 )
871 .unwrap();
872 store
873 .insert_relationship(
874 &Relationship::new(
875 "rel:f:items",
876 cart,
877 predicates::CONTAINS,
878 field,
879 Provenance::Extracted,
880 ),
881 "api/app.py",
882 )
883 .unwrap();
884 let cfg = entity_id(&repo, kinds::CONFIGURATION, "DEBUG");
886 store
887 .insert_entity(
888 &Entity::new(cfg.clone(), kinds::CONFIGURATION, "DEBUG"),
889 &["api/app.py".into()],
890 )
891 .unwrap();
892 store
893 .insert_relationship(
894 &Relationship::new(
895 "rel:cfg",
896 cfg,
897 predicates::CONFIGURED_BY,
898 api_writer.clone(),
899 Provenance::Extracted,
900 ),
901 "api/app.py",
902 )
903 .unwrap();
904 let topic = entity_id(&repo, kinds::TOPIC, "user.created");
906 store
907 .insert_entity(
908 Entity::new(topic.clone(), kinds::TOPIC, "user.created")
909 .attr("store", serde_json::json!("kafka")),
910 &["api/app.py".into()],
911 )
912 .unwrap();
913 store
914 .insert_relationship(
915 &Relationship::new(
916 "rel:p:topic",
917 api_writer.clone(),
918 predicates::PUBLISHES,
919 topic,
920 Provenance::Extracted,
921 ),
922 "api/app.py",
923 )
924 .unwrap();
925 let mw = entity_id(&repo, kinds::MIDDLEWARE, "RequestLogger");
927 store
928 .insert_entity(
929 &Entity::new(mw.clone(), kinds::MIDDLEWARE, "RequestLogger"),
930 &["api/app.py".into()],
931 )
932 .unwrap();
933 store
934 .insert_relationship(
935 &Relationship::new(
936 "rel:reg:mw",
937 api_writer,
938 predicates::REGISTERS,
939 mw,
940 Provenance::Extracted,
941 ),
942 "api/app.py",
943 )
944 .unwrap();
945
946 let web_reader = sym(&store, "web/app.py", "list_items");
948 attach(&store, "web", &web_reader, "web/app.py");
949 store
950 .insert_relationship(
951 &Relationship::new(
952 "rel:r:web",
953 web_reader,
954 predicates::READS,
955 store_ent,
956 Provenance::Extracted,
957 ),
958 "web/app.py",
959 )
960 .unwrap();
961
962 let graph = RealityGraph::load(&store).unwrap();
963 let mut symbol_comp: HashMap<String, String> = HashMap::new();
964 for c in &graph.components {
965 for r in graph.out_pred(&c.id, predicates::CONTAINS) {
966 for sr in graph.out_pred(&r.object, predicates::CONTAINS) {
967 symbol_comp.insert(sr.object.clone(), c.name.clone());
968 }
969 }
970 }
971
972 let state = compile_state_authority(&graph, &symbol_comp);
973 for k in STATE_SECTIONS {
975 assert!(state.contains_key(k), "missing section {k}: {state:?}");
976 }
977 let persistent = &state[S_PERSISTENT];
978 assert!(
979 persistent
980 .iter()
981 .any(|l| l == "api owns db.users (EXTRACTED)"),
982 "component owns missing: {persistent:?}"
983 );
984 assert!(
985 persistent
986 .iter()
987 .any(|l| l == "api::create_user writes db.users (EXTRACTED)"),
988 "function writes missing: {persistent:?}"
989 );
990 assert!(
991 persistent
992 .iter()
993 .any(|l| l == "web::list_items reads db (EXTRACTED)"),
994 "function reads missing: {persistent:?}"
995 );
996 assert!(
997 !persistent.iter().any(|l| l.contains("web owns")),
998 "readers must not own: {persistent:?}"
999 );
1000 let runtime = &state[S_RUNTIME];
1001 assert_eq!(runtime.len(), 1, "{runtime:?}");
1002 assert_eq!(runtime[0], "api owns Cart.items (mutable) (EXTRACTED)");
1003 let config = &state[S_CONFIGURATION];
1004 assert_eq!(config[0], "api configured_by DEBUG (EXTRACTED)");
1005 let caches = &state[S_CACHES];
1006 assert_eq!(caches[0], "api reads redis (EXTRACTED)");
1007 let derived = &state[S_DERIVED];
1008 assert!(
1009 derived
1010 .iter()
1011 .any(|l| l.starts_with("api publishes user.created")),
1012 "{derived:?}"
1013 );
1014 assert!(
1015 derived
1016 .iter()
1017 .any(|l| l == "api registers RequestLogger (EXTRACTED)"),
1018 "{derived:?}"
1019 );
1020
1021 for k in STATE_SECTIONS {
1023 for line in &state[k] {
1024 assert!(
1025 !line.contains("web owns") && !line.starts_with("web owns"),
1026 "web must not own state: {line}"
1027 );
1028 }
1029 }
1030
1031 let graph2 = RealityGraph::load(&store).unwrap();
1033 let state2 = compile_state_authority(&graph2, &symbol_comp);
1034 assert_eq!(state, state2);
1035 }
1036
1037 #[test]
1038 fn function_access_is_not_ownership() {
1040 let (_dir, store) = open();
1041 let repo = store.repo_id.clone();
1042 component(&store, "api", &["api/app.py"]);
1043 component(&store, "web", &["web/app.py"]);
1044 let writer = sym(&store, "api/app.py", "save");
1045 attach(&store, "api", &writer, "api/app.py");
1046 let reader = sym(&store, "web/app.py", "load");
1047 attach(&store, "web", &reader, "web/app.py");
1048 let db = entity_id(&repo, kinds::DATA_STORE, "orders");
1049 store
1050 .insert_entity(
1051 &Entity::new(db.clone(), kinds::DATA_STORE, "orders"),
1052 &["api/app.py".into()],
1053 )
1054 .unwrap();
1055 store
1056 .insert_relationship(
1057 &Relationship::new(
1058 "rel:w",
1059 writer,
1060 predicates::WRITES,
1061 db.clone(),
1062 Provenance::Extracted,
1063 ),
1064 "api/app.py",
1065 )
1066 .unwrap();
1067 store
1068 .insert_relationship(
1069 &Relationship::new(
1070 "rel:r",
1071 reader,
1072 predicates::READS,
1073 db,
1074 Provenance::Extracted,
1075 ),
1076 "web/app.py",
1077 )
1078 .unwrap();
1079 let graph = RealityGraph::load(&store).unwrap();
1080 let mut symbol_comp: HashMap<String, String> = HashMap::new();
1081 for c in &graph.components {
1082 for r in graph.out_pred(&c.id, predicates::CONTAINS) {
1083 for sr in graph.out_pred(&r.object, predicates::CONTAINS) {
1084 symbol_comp.insert(sr.object.clone(), c.name.clone());
1085 }
1086 }
1087 }
1088 let state = compile_state_authority(&graph, &symbol_comp);
1089 let persistent = &state[S_PERSISTENT];
1090 assert!(persistent
1091 .iter()
1092 .any(|l| l == "api owns orders (EXTRACTED)"));
1093 assert!(persistent
1094 .iter()
1095 .any(|l| l == "api::save writes orders (EXTRACTED)"));
1096 assert!(persistent
1097 .iter()
1098 .any(|l| l == "web::load reads orders (EXTRACTED)"));
1099 assert!(!persistent.iter().any(|l| l.contains("web owns")));
1100 let claims = compile_state_claims(&graph, &symbol_comp);
1101 assert!(claims
1102 .iter()
1103 .any(|c| c.component == "api" && c.verb == "owns" && c.target == "orders"));
1104 assert!(claims
1105 .iter()
1106 .any(|c| c.component == "web" && c.verb == "reads" && c.target == "orders"));
1107 assert!(!claims
1108 .iter()
1109 .any(|c| c.component == "web" && c.verb == "owns"));
1110 }
1111
1112 #[test]
1113 fn cache_store_heuristics() {
1114 assert!(is_cache_store("redis", Some("redis")));
1115 assert!(is_cache_store("cache", None));
1116 assert!(is_cache_store("user-cache", None));
1117 assert!(is_cache_store("kv", Some("valkey")));
1118 assert!(!is_cache_store("db", Some("postgres")));
1119 assert!(!is_cache_store("orders", None));
1120 }
1121
1122 #[test]
1129
1130 fn reactive_state_owners_group_and_attribute() {
1132 let (_dir, store) = open();
1133 let repo = store.repo_id.clone();
1134 component(&store, "api", &["api/app.py"]);
1135 let a = sym(&store, "api/app.py", "store_a");
1136 attach(&store, "api", &a, "api/app.py");
1137 let b = sym(&store, "api/app.py", "store_b");
1138 attach(&store, "api", &b, "api/app.py");
1139
1140 let rs = entity_id(&repo, kinds::REACTIVE, "count");
1144 store
1145 .insert_entity(
1146 &Entity::new(rs.clone(), kinds::REACTIVE, "count"),
1147 &["api/app.py".into()],
1148 )
1149 .unwrap();
1150 for (i, (occ, owner_id, owner_name)) in [
1151 (
1152 scc_core::occurrence_id(&repo, "count", "api/app.py", "store_a", 1),
1153 a.clone(),
1154 "store_a",
1155 ),
1156 (
1157 scc_core::occurrence_id(&repo, "count", "api/app.py", "store_b", 2),
1158 b.clone(),
1159 "store_b",
1160 ),
1161 ]
1162 .iter()
1163 .enumerate()
1164 {
1165 store
1166 .insert_entity(
1167 Entity::new(
1168 occ.clone(),
1169 kinds::OCCURRENCE,
1170 format!("count@api/app.py@{}@{}", owner_name, i + 1),
1171 )
1172 .attr("concept", serde_json::json!(rs))
1173 .attr("path", serde_json::json!("api/app.py"))
1174 .attr("owner", serde_json::json!(owner_name))
1175 .attr("line", serde_json::json!(i + 1))
1176 .attr("access", serde_json::json!("state")),
1177 &["api/app.py".into()],
1178 )
1179 .unwrap();
1180 store
1181 .insert_relationship(
1182 &Relationship::new(
1183 format!("rel:owns:{i}"),
1184 owner_id.clone(),
1185 predicates::OWNS,
1186 occ.clone(),
1187 Provenance::Extracted,
1188 ),
1189 "api/app.py",
1190 )
1191 .unwrap();
1192 store
1193 .insert_relationship(
1194 &Relationship::new(
1195 format!("rel:occ:{i}"),
1196 occ.clone(),
1197 predicates::OCCURS,
1198 rs.clone(),
1199 Provenance::Extracted,
1200 ),
1201 "api/app.py",
1202 )
1203 .unwrap();
1204 }
1205
1206 let groups = state_authority_groups(&RealityGraph::load(&store).unwrap());
1208 assert!(
1209 groups
1210 .iter()
1211 .any(|g| g.contains(&a) && g.contains(&b) && g.len() == 2),
1212 "reactive owners must group: {groups:?}"
1213 );
1214
1215 let graph = RealityGraph::load(&store).unwrap();
1218 let mut symbol_comp: HashMap<String, String> = HashMap::new();
1219 for c in &graph.components {
1220 for r in graph.out_pred(&c.id, predicates::CONTAINS) {
1221 for sr in graph.out_pred(&r.object, predicates::CONTAINS) {
1222 symbol_comp.insert(sr.object.clone(), c.name.clone());
1223 }
1224 }
1225 }
1226 let state = compile_state_authority(&graph, &symbol_comp);
1227 assert_eq!(
1228 state[S_REACTIVE],
1229 vec!["api owns reactive: count [state] (EXTRACTED)".to_string()],
1230 "{:?}",
1231 state[S_REACTIVE]
1232 );
1233
1234 assert_eq!(occurrence_count(&graph, &rs), 2);
1236 assert_eq!(occurrence_producer(&graph, &rs).as_deref(), Some("store_a"));
1237
1238 let claims = compile_state_claims(&graph, &symbol_comp);
1240 assert!(
1241 claims.iter().any(|c| c.target == "reactive: count [state]"),
1242 "claims missing reactive state: {claims:?}"
1243 );
1244 }
1245
1246 #[test]
1247 fn non_mutable_fields_are_not_runtime_state() {
1249 let (_dir, store) = open();
1250 let repo = store.repo_id.clone();
1251 component(&store, "api", &["api/app.py"]);
1252 let cart = sym(&store, "api/app.py", "Cart");
1253 attach(&store, "api", &cart, "api/app.py");
1254 let field = entity_id(&repo, kinds::FIELD, "Cart.name");
1255 store
1256 .insert_entity(
1257 Entity::new(field.clone(), kinds::FIELD, "Cart.name")
1258 .attr("mutable", serde_json::json!(false))
1259 .attr("owner", serde_json::json!("Cart")),
1260 &["api/app.py".into()],
1261 )
1262 .unwrap();
1263 store
1264 .insert_relationship(
1265 &Relationship::new(
1266 "rel:f:name",
1267 cart,
1268 predicates::CONTAINS,
1269 field,
1270 Provenance::Extracted,
1271 ),
1272 "api/app.py",
1273 )
1274 .unwrap();
1275 let graph = RealityGraph::load(&store).unwrap();
1276 let mut symbol_comp = HashMap::new();
1277 for c in &graph.components {
1278 for r in graph.out_pred(&c.id, predicates::CONTAINS) {
1279 for sr in graph.out_pred(&r.object, predicates::CONTAINS) {
1280 symbol_comp.insert(sr.object.clone(), c.name.clone());
1281 }
1282 }
1283 }
1284 let state = compile_state_authority(&graph, &symbol_comp);
1285 let runtime = state.get(S_RUNTIME).map(|v| v.as_slice()).unwrap_or(&[]);
1286 assert!(
1287 runtime.is_empty(),
1288 "immutable field is not runtime state: {runtime:?}"
1289 );
1290 }
1291
1292 #[test]
1296 fn module_global_and_static_state_attribute_to_component() {
1297 let (_dir, store) = open();
1298 let repo = store.repo_id.clone();
1299 component(&store, "api", &["api/app.py"]);
1300
1301 let module = sym(&store, "api/app.py", "app");
1303 attach(&store, "api", &module, "api/app.py");
1304 let field = entity_id(&repo, kinds::FIELD, "app.DEFAULT_TIMEOUT");
1305 store
1306 .insert_entity(
1307 Entity::new(field.clone(), kinds::FIELD, "app.DEFAULT_TIMEOUT")
1308 .attr("mutable", serde_json::json!(true))
1309 .attr("owner", serde_json::json!("app")),
1310 &["api/app.py".into()],
1311 )
1312 .unwrap();
1313 store
1314 .insert_relationship(
1315 &Relationship::new(
1316 "rel:f:dt",
1317 module,
1318 predicates::CONTAINS,
1319 field,
1320 Provenance::Extracted,
1321 ),
1322 "api/app.py",
1323 )
1324 .unwrap();
1325
1326 let cfg = sym(&store, "api/app.py", "Config");
1328 attach(&store, "api", &cfg, "api/app.py");
1329 let stat = entity_id(&repo, kinds::FIELD, "Config.retries");
1330 store
1331 .insert_entity(
1332 Entity::new(stat.clone(), kinds::FIELD, "Config.retries")
1333 .attr("mutable", serde_json::json!(true))
1334 .attr("owner", serde_json::json!("Config")),
1335 &["api/app.py".into()],
1336 )
1337 .unwrap();
1338 store
1339 .insert_relationship(
1340 &Relationship::new(
1341 "rel:f:r",
1342 cfg,
1343 predicates::CONTAINS,
1344 stat,
1345 Provenance::Extracted,
1346 ),
1347 "api/app.py",
1348 )
1349 .unwrap();
1350
1351 let graph = RealityGraph::load(&store).unwrap();
1352 let mut symbol_comp: HashMap<String, String> = HashMap::new();
1353 for c in &graph.components {
1354 for r in graph.out_pred(&c.id, predicates::CONTAINS) {
1355 for sr in graph.out_pred(&r.object, predicates::CONTAINS) {
1356 symbol_comp.insert(sr.object.clone(), c.name.clone());
1357 }
1358 }
1359 }
1360
1361 let state = compile_state_authority(&graph, &symbol_comp);
1362 let runtime = &state[S_RUNTIME];
1363 assert!(
1364 runtime
1365 .iter()
1366 .any(|l| l == "api owns app.DEFAULT_TIMEOUT (mutable) (EXTRACTED)"),
1367 "module global missing from runtime state: {runtime:?}"
1368 );
1369 assert!(
1370 runtime
1371 .iter()
1372 .any(|l| l == "api owns Config.retries (mutable) (EXTRACTED)"),
1373 "class static missing from runtime state: {runtime:?}"
1374 );
1375
1376 let claims = compile_state_claims(&graph, &symbol_comp);
1378 assert!(
1379 claims
1380 .iter()
1381 .any(|c| c.component == "api" && c.target == "app.DEFAULT_TIMEOUT"),
1382 "claims missing module global: {claims:?}"
1383 );
1384 assert!(
1385 claims
1386 .iter()
1387 .any(|c| c.component == "api" && c.target == "Config.retries"),
1388 "claims missing class static: {claims:?}"
1389 );
1390 }
1391}